Compare commits

..

25 Commits

Author SHA1 Message Date
midzelis
a8e20d7b49 Use explicit users and chown instad of umask 000 2025-07-19 04:13:48 +00:00
midzelis
97c256e89b Update docker permissions (dev) 2025-07-19 03:52:41 +00:00
Min Idzelis
f929dc0816 fix: devcontainer layout (#20021) 2025-07-18 19:07:49 -04:00
Min Idzelis
9e94f52b05 chore: dockerfile layout changes (#19673)
Dockerfile layout changes

Fix up web path

feat: update server env vars for layout
2025-07-18 17:56:26 -04:00
Alex
5d244c6fec chore: finish drift locked page (#20013)
* feat: overlay mechanism

* handle merged asset local id extraction

* locked view asset viewer actions

* pr feedback
2025-07-18 18:16:22 +00:00
megumin
dcfe8d5ade fix: send filename when viewing the original file (#20005)
* feat: add fileName to downloadOriginal response

* test: add fileName to ImmichFileResponse for downloadOriginal

* lint: use single quotes for fileName string in test
2025-07-18 11:55:24 -05:00
shenlong
635f5de186 chore: change dcm constraint to include 1.30.0 (#20017)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-07-18 11:54:35 -05:00
Brandon Wees
9719965caf fix: invalid android manifest (#20015)
fix extra > in android manifest
2025-07-18 11:54:15 -05:00
Min Idzelis
f33e1ad94c feat: relocate scripts, PATH update (#20002)
Relocate scripts, and PATH updates
2025-07-18 11:19:06 -04:00
Min Idzelis
576f681b5c feat: remove dep on cwd for workers (#20012) 2025-07-18 10:57:49 -04:00
Jason Rasmussen
493d85b021 feat!: absolute file paths (#19995)
feat: absolute file paths
2025-07-18 10:57:29 -04:00
Brandon Wees
f32d4f15b6 feat(mobile): android widgets (#19310)
* wip

* wip widgets

* more wip changes

* latest changes

* working random widget

* cleanup

* add configurable widget

* add memory widget and cleanup of codebase

* album name handling

* add deeplinks

* finish minor refactoring and add some polish :)

* fix single shot type on random widget

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* switch to ExposedDropdownMenuBox for random configure activity

* handle empty album and no connection edge cases

* android project cleanup

* fix proguard and gson issues

* fix deletion handling

* fix proguard stripping for widget model classes/enums

* change random configuration activity close to a checkmark on right side

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-07-18 09:37:07 -05:00
Daimolean
7bae49ebd5 feat(mobile): people sync (#19777)
* feat(mobile): drift people sync

* merge main

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-18 14:21:39 +00:00
Alex
2e63b9d951 chore: add sync indicator and better album state management (#20004)
* album list rerendering

* sync indicator

* sync indicator

* fix: lint
2025-07-18 13:39:28 +00:00
Alex
137f0d48c0 chore: styling for asset_viewer bottom sheet (#20006)
bottom sheet styling
2025-07-18 08:36:29 -05:00
renovate[bot]
53acf08263 fix(deps): update dependency multer to v2.0.2 [security] (#20003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 12:08:57 +02:00
shenlong
f32cd74232 feat: show stacks in asset viewer (#19935)
* feat: show stacks in asset viewer

* fix: global key issue and flash on stack asset change

* feat(mobile): stack and unstack action (#19941)

* feat(mobile): stack and unstack action

* add custom model

* use stackId from ActionSource

* Update mobile/lib/providers/infrastructure/action.provider.dart

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix: lint

* fix: bad merge

* fix: test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Daimolean <92239625+wuzihao051119@users.noreply.github.com>
Co-authored-by: wuzihao051119 <wuzihao051119@outlook.com>
2025-07-18 04:31:04 +00:00
Alex
546f841b2c chore: migration continue button (#20000)
* feat: add toggle to switch between Isar and Sqlite

* reset sqlite on beta

* start sync on app open in new timeline

* fix lint

* migrate hashes when new timeline is selected

* migrate hashes immediately after beta is enabled

* show loading indicator in change timeline page

* some stylings

* fix some styling issue

* release resources on isolate close

* replace route and styling

* handle migration back to old timeline

* check if a provider is mounted before calling dispose on it

* styling

* styling and button

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-07-17 15:58:55 -05:00
Daimolean
8491fe459d feat(mobile): drift search page (#19811)
* feat(mobile): drift search page

* migrate to drift page

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-17 15:25:25 -05:00
shenlong
2046dcc5b4 fix: mobile storage status check (#19986)
* fix: _shouldUseLocalAsset check

* show storage indicators in local album view

* update local thumb provider to work with remote asset

* update checks

* do not show upload button when selection is only merged assets

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-17 17:13:21 +00:00
Brandon Wees
03ff425664 chore(mobile): update casting to new asset viewer (#19994)
* update casting to new asset viewer

* handle websocket

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-17 17:08:32 +00:00
Mert
055b930066 refactor(mobile): share action button in new timeline (#19967)
* share asset button

* include source

* move to repository

* formatting
2025-07-17 11:41:30 -05:00
shenlong
531515daf9 feat: add toggle to switch between Isar and Sqlite (#19953) 2025-07-17 11:12:29 -05:00
Min Idzelis
b256c51b6b docs: update devcontainer docs (#19237) 2025-07-17 14:49:55 +00:00
Lucas Correia
238dc7c085 chore: adjust setting subtitle to less clumsy expression (#19970) 2025-07-17 15:40:49 +02:00
201 changed files with 18280 additions and 1244 deletions

View File

@@ -11,8 +11,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION}/photos:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION}/photos/upload:/workspaces/immich/server/upload/upload
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
database:

View File

@@ -13,8 +13,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/usr/src/app/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/usr/src/app/upload/upload
- /etc/localtime:/etc/localtime:ro
immich-web:

View File

@@ -3,9 +3,6 @@
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "Installing npm dependencies (node_modules)..."
install_dependencies

View File

@@ -1,41 +1,41 @@
.vscode/
.github/
.git/
.env*
*.log
*.tmp
*.temp
**/Dockerfile
**/node_modules/
**/.pnpm-store/
**/dist/
**/coverage/
**/build/
design/
docker/
Dockerfile
!docker/scripts
docs/
!docs/package.json
!docs/package-lock.json
e2e/
!e2e/package.json
!e2e/package-lock.json
fastlane/
machine-learning/
misc/
mobile/
cli/coverage/
cli/dist/
cli/node_modules/
cli/Dockerfile
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
!open-api/typescript-sdk/package.json
!open-api/typescript-sdk/package-lock.json
server/coverage/
server/node_modules/
server/upload/
server/src/queries
server/dist/
server/www/
server/Dockerfile
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
web/.env
web/Dockerfile

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
vite.config.js.timestamp-*
.pnpm-store

View File

@@ -16,22 +16,25 @@ name: immich-dev
services:
immich-server:
container_name: immich_server
command: ['/usr/src/app/bin/immich-dev']
command: ['immich-dev']
image: immich-server-dev:latest
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
build:
args:
- SERVER_USER=${SERVER_USER:-0}
- SERVER_GROUP=${SERVER_GROUP:-0}
context: ../
dockerfile: server/Dockerfile
target: dev
restart: unless-stopped
volumes:
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ../server:/usr/src/app/server
- ../open-api:/usr/src/app/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
- /usr/src/app/server/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -69,19 +72,23 @@ services:
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0
build:
context: ../web
command: ['/usr/src/app/bin/immich-web']
args:
- WEB_USER=${WEB_USER:-1000}
- WEB_GROUP=${WEB_GROUP:-1000}
context: ../
dockerfile: web/Dockerfile
command: ['immich-web']
env_file:
- .env
ports:
- 3000:3000
- 24678:24678
volumes:
- ../web:/usr/src/app
- ../i18n:/usr/src/i18n
- ../open-api/:/usr/src/open-api/
- ../web:/usr/src/app/web
- ../i18n:/usr/src/app/i18n
- ../open-api/:/usr/src/app/open-api/
# - ../../ui:/usr/ui
- /usr/src/app/node_modules
- /usr/src/app/web/node_modules
ulimits:
nofile:
soft: 1048576

View File

@@ -2,16 +2,17 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@@ -88,3 +89,24 @@ Print Immich Version
immich-admin version
v1.129.0
```
Change media location
```
immich-admin change-media-location
? Enter the previous value of IMMICH_MEDIA_LOCATION: /usr/src/app/upload
? Enter the new value of IMMICH_MEDIA_LOCATION: /data
Previous value: /usr/src/app/upload
Current value: /data
Changing database paths from "/usr/src/app/upload/*" to "/data/*"
? Do you want to proceed? [Y/n] y
Database file paths updated successfully! 🎉
You may now set IMMICH_MEDIA_LOCATION=/data and restart!
(please remember to update applicable volume mounts e.g. ${UPLOAD_LOCATION}:/data)
```

View File

@@ -7,7 +7,7 @@ sidebar_position: 3
Dev Containers provide a consistent, reproducible development environment using Docker containers. With a single click, you can get started with an Immich development environment on Mac, Linux, Windows, or in the cloud using GitHub Codespaces.
[![Open in VSCode Containers](https://img.shields.io/static/v1?label=VSCode%20DevContainer&message=Immich&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/immich-app/immich/)
Get started fast!
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/immich-app/immich/)
@@ -71,7 +71,7 @@ cd immich
The immich dev containers read environment variables from your shell environment, not from `.env` files. This allows them to work in cloud environments without pre-configuration.
:::important Required Configuration
:::important Configuration
When running locally, and if you want to create (or use an existing) DB and/or photo storage folder, you must set the `UPLOAD_LOCATION` variable in your shell environment before launching the Dev Container. This determines where uploaded files are stored and also where the DB stores it data.
```bash
@@ -88,6 +88,10 @@ source ~/.bashrc
### Step 3: Launch the Dev Container
:::tip
Immich development makes extensive use of specialized [base images](https://github.com/immich-app/base-images) for its docker-compose based development. For this reason, you won't be able to use VSCode's **_Clone Repository in a Container Volume_** command.
:::
#### Using VS Code UI:
1. Open the cloned repository in VS Code

View File

@@ -29,20 +29,20 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `./upload`<sup>\*3</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :---------------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/usr/src/app/upload`<sup>\*3</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -3,7 +3,6 @@ name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['./start.sh']
image: immich-server:latest
build:
context: ../

View File

@@ -373,10 +373,12 @@
"admin_password": "Admin Password",
"administration": "Administration",
"advanced": "Advanced",
"advanced_settings_beta_timeline_subtitle": "Try the new app experience",
"advanced_settings_beta_timeline_title": "Beta Timeline",
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_log_level_title": "Log level: {level}",
"advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.",
"advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from local assets. Activate this setting to load remote images instead.",
"advanced_settings_prefer_remote_title": "Prefer remote images",
"advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request",
"advanced_settings_proxy_headers_title": "Proxy Headers",
@@ -1691,6 +1693,7 @@
"settings_saved": "Settings saved",
"setup_pin_code": "Setup a PIN code",
"share": "Share",
"share_action_prompt": "Shared {count} assets",
"share_add_photos": "Add photos",
"share_assets_selected": "{count} selected",
"share_dialog_preparing": "Preparing...",
@@ -1792,6 +1795,7 @@
"sort_title": "Title",
"source": "Source",
"stack": "Stack",
"stack_action_prompt": "{count} stacked",
"stack_duplicates": "Stack duplicates",
"stack_select_one_photo": "Select one main photo for the stack",
"stack_selected_photos": "Stack selected photos",
@@ -1902,6 +1906,7 @@
"unselect_all_duplicates": "Unselect all duplicates",
"unselect_all_in": "Unselect all in {group}",
"unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"untagged": "Untagged",
"up_next": "Up next",

View File

@@ -106,6 +106,7 @@ custom_lint:
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
- lib/domain/services/search.service.dart
# refactor
- lib/models/map/map_marker.model.dart

View File

@@ -3,6 +3,8 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
}
def localProperties = new Properties()
@@ -45,6 +47,10 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
buildFeatures {
compose true
}
defaultConfig {
applicationId "app.alextran.immich"
minSdkVersion 26
@@ -105,6 +111,8 @@ dependencies {
def guava_version = '33.3.1-android'
def glide_version = '4.16.0'
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
@@ -116,6 +124,17 @@ dependencies {
ksp "com.github.bumptech.glide:ksp:$glide_version"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
//Glance Widget
implementation "androidx.glance:glance-appwidget:$compose_version"
implementation "com.google.code.gson:gson:$gson_version"
// Glance Configure
implementation "androidx.activity:activity-compose:1.8.2"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
}
// This is uncommented in F-Droid build script

View File

@@ -25,8 +25,15 @@
@com.google.gson.annotations.SerializedName <fields>;
}
# TypeToken preventions
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
##---------------End: proguard configuration for Gson ----------
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }

View File

@@ -141,6 +141,41 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- Widgets -->
<receiver
android:name=".widget.RandomReceiver"
android:exported="true"
android:label="@string/random_widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/random_widget" />
</receiver>
<receiver
android:name=".widget.MemoryReceiver"
android:exported="true"
android:label="@string/memory_widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/memory_widget" />
</receiver>
<activity android:name=".widget.configure.RandomConfigure"
android:exported="true"
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
</application>
@@ -154,4 +189,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -0,0 +1,33 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}

View File

@@ -0,0 +1,241 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import app.alextran.immich.widget.model.*
import java.time.LocalDate
class ImageDownloadWorker(
private val context: Context,
workerParameters: WorkerParameters
) : CoroutineWorker(context, workerParameters) {
companion object {
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
private fun buildConstraints(): Constraints {
return Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
}
private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data {
return Data.Builder()
.putString(kWorkerWidgetType, widgetType.toString())
.putInt(kWorkerWidgetID, appWidgetId)
.build()
}
fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) {
val manager = WorkManager.getInstance(context)
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
20, TimeUnit.MINUTES
)
.setConstraints(buildConstraints())
.setInputData(buildInputData(appWidgetId, widgetType))
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniquePeriodicWork(
"$uniqueWorkName-$appWidgetId",
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) {
val manager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequestBuilder<ImageDownloadWorker>()
.setConstraints(buildConstraints())
.setInputData(buildInputData(appWidgetId, widgetType))
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniqueWork(
"$uniqueWorkName-$appWidgetId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
suspend fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
override suspend fun doWork(): Result {
return try {
val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
}
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
Log.e(uniqueWorkName, "Error while loading image", e)
if (runAttemptCount < 10) {
Result.retry()
} else {
Result.failure()
}
}
}
private suspend fun updateWidget(
glanceId: GlanceId,
imageUUID: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context,glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters(AssetType.IMAGE)
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
val albumName = widgetConfig[kSelectedAlbumName]
var subtitle: String? = if (showSubtitle == true) albumName else ""
if (albumId != null) {
filters.albumIds = listOf(albumId)
}
var randomSearch = api.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = api.fetchSearchResults(SearchFilters(AssetType.IMAGE))
subtitle = ""
}
val random = randomSearch.first()
val image = api.fetchImage(random)
return WidgetEntry(
image,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now()
val memories = api.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
if (memories.isNotEmpty()) {
// pick a random asset from a random memory
val memory = memories.random()
asset = memory.assets.random()
val yearDiff = today.year - memory.data.year
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(AssetType.IMAGE, size=1)
asset = api.fetchSearchResults(filters).first()
}
val image = api.fetchImage(asset)
return WidgetEntry(
image,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}

View File

@@ -0,0 +1,103 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
return ServerConfig(
serverURL,
sessionKey
)
}
}
private val gson = Gson()
private val serverConfig = cfg
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
}
}

View File

@@ -0,0 +1,56 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoryReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
CoroutineScope(Dispatchers.Default).launch {
val provider = ComponentName(context, MemoryReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
}
}
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -0,0 +1,124 @@
package app.alextran.immich.widget
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
}
// WIDGET CONTENT
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.clickable {
val intent = Intent(Intent.ACTION_VIEW, deeplinkURL ?: "immich://".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
) {
if (bitmap != null) {
Image(
provider = ImageProvider(bitmap),
contentDescription = "Widget Image",
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
if (!subtitle.isNullOrBlank()) {
Column(
verticalAlignment = Alignment.Bottom,
horizontalAlignment = Alignment.Start,
modifier = GlanceModifier
.fillMaxSize()
.padding(12.dp)
) {
Text(
text = subtitle,
style = TextStyle(
color = ColorProvider(Color.White),
fontSize = 16.sp
),
modifier = GlanceModifier
.background(ColorProvider(Color(0x99000000))) // 60% black
.padding(8.dp)
.cornerRadius(8.dp)
)
}
}
} else {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
)
if (widgetState == WidgetState.LOG_IN.toString()) {
Box(
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Log in to your Immich server", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.fillMaxWidth().padding(16.dp)
) {
CircularProgressIndicator(
modifier = GlanceModifier.size(12.dp)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text("Loading widget...", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RandomReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
CoroutineScope(Dispatchers.Default).launch {
val provider = ComponentName(context, RandomReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
}
}
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -0,0 +1,64 @@
package app.alextran.immich.widget.configure
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
data class DropdownItem (
val label: String,
val id: String,
)
// Creating a composable to display a drop down menu
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Dropdown(items: List<DropdownItem>,
selectedItem: DropdownItem?,
onItemSelected: (DropdownItem) -> Unit,
enabled: Boolean = true
) {
var expanded by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf(selectedItem?.label ?: items[0].label) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded && enabled },
) {
TextField(
value = selectedOption,
onValueChange = {},
readOnly = true,
enabled = enabled,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
items.forEach { option ->
DropdownMenuItem(
text = { Text(option.label, color = MaterialTheme.colorScheme.onSurface) },
onClick = {
selectedOption = option.label
onItemSelected(option)
expanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package app.alextran.immich.widget.configure
import android.os.Build
import androidx.compose.foundation.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun LightDarkTheme(
content: @Composable () -> Unit
) {
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme ->
dynamicDarkColorScheme(context)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme ->
dynamicLightColorScheme(context)
isDarkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -0,0 +1,210 @@
package app.alextran.immich.widget.configure
import android.appwidget.AppWidgetManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import app.alextran.immich.widget.ImageDownloadWorker
import app.alextran.immich.widget.ImmichAPI
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
class RandomConfigure : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get widget ID from intent
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val glanceId = GlanceAppWidgetManager(applicationContext)
.getGlanceIdBy(appWidgetId)
setContent {
LightDarkTheme {
RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = {
finish()
Log.w("WIDGET_ACTIVITY", "SAVING")
})
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) {
var selectedAlbum by remember { mutableStateOf<DropdownItem?>(null) }
var showAlbumName by remember { mutableStateOf(false) }
var availableAlbums by remember { mutableStateOf<List<DropdownItem>>(listOf()) }
var state by remember { mutableStateOf(WidgetConfigState.LOADING) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// get albums from server
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = api.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}
state = WidgetConfigState.SUCCESS
} catch (e: FileNotFoundException) {
Log.e("WidgetWorker", "Error fetching albums: ${e.message}")
state = WidgetConfigState.NO_CONNECTION
albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId))
}
availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems
// load selected configuration
val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId }
selectedAlbum = albumEntity ?: availableAlbums.first()
// load showAlbumName
showAlbumName = currentState[kShowAlbumName] == true
}
suspend fun saveConfiguration() {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kSelectedAlbum] = selectedAlbum?.id ?: ""
prefs[kSelectedAlbumName] = selectedAlbum?.label ?: ""
prefs[kShowAlbumName] = showAlbumName
}
ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM)
}
Scaffold(
topBar = {
TopAppBar (
title = { Text("Widget Configuration") },
actions = {
IconButton(onClick = {
scope.launch {
saveConfiguration()
onDone()
}
}) {
Icon(Icons.Default.Check, contentDescription = "Close", tint = MaterialTheme.colorScheme.primary)
}
}
)
}
) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding), // Respect the top bar
color = MaterialTheme.colorScheme.background
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
when (state) {
WidgetConfigState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp))
WidgetConfigState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.")
else -> {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium)
// no connection warning
if (state == WidgetConfigState.NO_CONNECTION) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Warning",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "No connection to the server is available. Please try again later.",
style = MaterialTheme.typography.bodyMedium
)
}
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Album")
Dropdown(
items = availableAlbums,
selectedItem = selectedAlbum,
onItemSelected = { selectedAlbum = it },
enabled = (state != WidgetConfigState.NO_CONNECTION)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Show Album Name")
Switch(
checked = showAlbumName,
onCheckedChange = { showAlbumName = it },
enabled = (state != WidgetConfigState.NO_CONNECTION)
)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,79 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
enum class AssetType {
IMAGE, VIDEO, AUDIO, OTHER
}
data class Asset(
val id: String,
val type: AssetType,
)
data class SearchFilters(
var type: AssetType = AssetType.IMAGE,
val size: Int = 1,
var albumIds: List<String> = listOf()
)
data class MemoryResult(
val id: String,
var assets: List<Asset>,
val type: String,
val data: MemoryData
) {
data class MemoryData(val year: Int)
}
data class Album(
val id: String,
val albumName: String
)
// MARK: Widget Specific
enum class WidgetType {
RANDOM, MEMORIES;
}
enum class WidgetState {
LOADING, SUCCESS, LOG_IN;
}
enum class WidgetConfigState {
LOADING, SUCCESS, LOG_IN, NO_CONNECTION
}
data class WidgetEntry (
val image: Bitmap,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
// MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
val kSelectedAlbum = stringPreferencesKey("albumID")
val kSelectedAlbumName = stringPreferencesKey("albumName")
val kShowAlbumName = booleanPreferencesKey("showAlbumName")
val kDeeplinkURL = stringPreferencesKey("deeplink")
const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="memory_widget_title">Memories</string>
<string name="random_widget_title">Random</string>
<string name="memory_widget_description">See memories from Immich.</string>
<string name="random_widget_description">View a random image from your library or a specific album.</string>
</resources>

View File

@@ -0,0 +1,9 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
android:description="@string/memory_widget_description"
android:previewImage="@drawable/memory_preview"
/>

View File

@@ -0,0 +1,13 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
android:configure="app.alextran.immich.widget.configure.RandomConfigure"
android:widgetFeatures="reconfigurable|configuration_optional"
tools:targetApi="28"
android:description="@string/random_widget_description"
android:previewImage="@drawable/random_preview"
/>

View File

@@ -1 +1 @@
version: '>=1.29.0 <1.30.0'
version: '>=1.29.0 <=1.30.0'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,7 +23,7 @@ class ImmichLinter extends PluginBase {
return rules;
}
static makeCode(String name, LintOptions options) => LintCode(
static LintCode makeCode(String name, LintOptions options) => LintCode(
name: name,
problemMessage: options.json["message"] as String,
errorSeverity: ErrorSeverity.WARNING,

View File

@@ -1 +0,0 @@
{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":["<all>"]},"commands":{"<all>":{"tool":"phony","inputs":["<WorkspaceHeaderMapVFSFilesWritten>"],"outputs":["<all>"]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":["<WorkspaceHeaderMapVFSFilesWritten>"]}}}

View File

@@ -28,7 +28,8 @@ const String appShareGroupId = "group.app.immich.share";
// add widget identifiers here for new widgets
// these are used to force a widget refresh
const List<String> kWidgetNames = [
'com.immich.widget.random',
'com.immich.widget.memory',
// (iOSName, androidFQDN)
const List<(String, String)> kWidgetNames = [
('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'),
('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'),
];

View File

@@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset {
}''';
}
// Not checking for remoteId here
@override
bool operator ==(Object other) {
if (other is! LocalAsset) return false;

View File

@@ -14,6 +14,8 @@ class RemoteAsset extends BaseAsset {
final String? thumbHash;
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final int stackCount;
const RemoteAsset({
required this.id,
@@ -31,6 +33,8 @@ class RemoteAsset extends BaseAsset {
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
this.stackCount = 0,
});
@override
@@ -56,9 +60,14 @@ class RemoteAsset extends BaseAsset {
isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"},
visibility: $visibility,
stackId: ${stackId ?? "<NA>"},
stackCount: $stackCount,
checksum: $checksum,
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
}''';
}
// Not checking for localId here
@override
bool operator ==(Object other) {
if (other is! RemoteAsset) return false;
@@ -67,7 +76,9 @@ class RemoteAsset extends BaseAsset {
id == other.id &&
ownerId == other.ownerId &&
thumbHash == other.thumbHash &&
visibility == other.visibility;
visibility == other.visibility &&
stackId == other.stackId &&
stackCount == other.stackCount;
}
@override
@@ -77,7 +88,9 @@ class RemoteAsset extends BaseAsset {
ownerId.hashCode ^
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode;
visibility.hashCode ^
stackId.hashCode ^
stackCount.hashCode;
RemoteAsset copyWith({
String? id,
@@ -95,6 +108,8 @@ class RemoteAsset extends BaseAsset {
String? thumbHash,
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
int? stackCount,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -112,6 +127,8 @@ class RemoteAsset extends BaseAsset {
thumbHash: thumbHash ?? this.thumbHash,
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
stackCount: stackCount ?? this.stackCount,
);
}
}

View File

@@ -124,7 +124,21 @@ class DriftMemory {
@override
String toString() {
return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt, assets: $assets)';
return '''Memory {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: ${deletedAt ?? "<NA>"},
ownerId: $ownerId,
type: $type,
data: $data,
isSaved: $isSaved,
memoryAt: $memoryAt,
seenAt: ${seenAt ?? "<NA>"},
showAt: ${showAt ?? "<NA>"},
hideAt: ${hideAt ?? "<NA>"},
assets: $assets
}''';
}
@override

View File

@@ -1,7 +1,8 @@
import 'dart:convert';
class Person {
const Person({
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
required this.id,
this.birthDate,
required this.isHidden,
@@ -22,7 +23,7 @@ class Person {
return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)';
}
Person copyWith({
PersonDto copyWith({
String? id,
DateTime? birthDate,
bool? isHidden,
@@ -30,7 +31,7 @@ class Person {
String? thumbnailPath,
DateTime? updatedAt,
}) {
return Person(
return PersonDto(
id: id ?? this.id,
birthDate: birthDate ?? this.birthDate,
isHidden: isHidden ?? this.isHidden,
@@ -51,8 +52,8 @@ class Person {
};
}
factory Person.fromMap(Map<String, dynamic> map) {
return Person(
factory PersonDto.fromMap(Map<String, dynamic> map) {
return PersonDto(
id: map['id'] as String,
birthDate: map['birthDate'] != null
? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int)
@@ -68,11 +69,11 @@ class Person {
String toJson() => json.encode(toMap());
factory Person.fromJson(String source) =>
Person.fromMap(json.decode(source) as Map<String, dynamic>);
factory PersonDto.fromJson(String source) =>
PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
@override
bool operator ==(covariant Person other) {
bool operator ==(covariant PersonDto other) {
if (identical(this, other)) return true;
return other.id == id &&
@@ -93,3 +94,109 @@ class Person {
updatedAt.hashCode;
}
}
// Model for a person stored in the server
class Person {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? faceAssetId;
final String thumbnailPath;
final bool isFavorite;
final bool isHidden;
final String? color;
final DateTime? birthDate;
const Person({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.faceAssetId,
required this.thumbnailPath,
required this.isFavorite,
required this.isHidden,
required this.color,
this.birthDate,
});
Person copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
String? faceAssetId,
String? thumbnailPath,
bool? isFavorite,
bool? isHidden,
String? color,
DateTime? birthDate,
}) {
return Person(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId ?? this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color ?? this.color,
birthDate: birthDate ?? this.birthDate,
);
}
@override
String toString() {
return '''Person {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
name: $name,
faceAssetId: ${faceAssetId ?? "<NA>"},
thumbnailPath: $thumbnailPath,
isFavorite: $isFavorite,
isHidden: $isHidden,
color: ${color ?? "<NA>"},
birthDate: ${birthDate ?? "<NA>"}
}''';
}
@override
bool operator ==(covariant Person other) {
if (identical(this, other)) return true;
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.ownerId == ownerId &&
other.name == name &&
other.faceAssetId == faceAssetId &&
other.thumbnailPath == thumbnailPath &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.color == color &&
other.birthDate == birthDate;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
ownerId.hashCode ^
name.hashCode ^
faceAssetId.hashCode ^
thumbnailPath.hashCode ^
isFavorite.hashCode ^
isHidden.hashCode ^
color.hashCode ^
birthDate.hashCode;
}
}

View File

@@ -0,0 +1,38 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final int? nextPage;
const SearchResult({
required this.assets,
this.nextPage,
});
int get totalAssets => assets.length;
SearchResult copyWith({
List<BaseAsset>? assets,
int? nextPage,
}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
);
}
@override
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}

View File

@@ -1,5 +1,3 @@
import 'dart:convert';
// Model for a stack stored in the server
class Stack {
final String id;
@@ -32,34 +30,15 @@ class Stack {
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'createdAt': createdAt.millisecondsSinceEpoch,
'updatedAt': updatedAt.millisecondsSinceEpoch,
'ownerId': ownerId,
'primaryAssetId': primaryAssetId,
};
}
factory Stack.fromMap(Map<String, dynamic> map) {
return Stack(
id: map['id'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int),
ownerId: map['ownerId'] as String,
primaryAssetId: map['primaryAssetId'] as String,
);
}
String toJson() => json.encode(toMap());
factory Stack.fromJson(String source) =>
Stack.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'Stack(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, ownerId: $ownerId, primaryAssetId: $primaryAssetId)';
return '''Stack {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
primaryAssetId: $primaryAssetId
}''';
}
@override
@@ -82,3 +61,27 @@ class Stack {
primaryAssetId.hashCode;
}
}
class StackResponse {
final String id;
final String primaryAssetId;
final List<String> assetIds;
const StackResponse({
required this.id,
required this.primaryAssetId,
required this.assetIds,
});
@override
bool operator ==(covariant StackResponse other) {
if (identical(this, other)) return true;
return other.id == id &&
other.primaryAssetId == primaryAssetId &&
other.assetIds == assetIds;
}
@override
int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode;
}

View File

@@ -68,7 +68,9 @@ enum StoreKey<T> {
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),
betaTimeline<bool>._(1002);
const StoreKey._(this.id);
final int id;

View File

@@ -1,3 +1,5 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy {
day,
month,
@@ -38,3 +40,7 @@ class TimeBucket extends Bucket {
@override
int get hashCode => super.hashCode ^ date.hashCode;
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}

View File

@@ -24,6 +24,17 @@ class AssetService {
: _remoteAssetRepository.watchAsset(id);
}
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
return [];
}
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
// Include the primary asset in the stack as the first item
return [asset, ...assets];
});
}
Future<ExifInfo?> getExif(BaseAsset asset) async {
if (!asset.hasRemote) {
return null;

View File

@@ -0,0 +1,92 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
class SearchService {
final _log = Logger("SearchService");
final SearchApiRepository _searchApiRepository;
SearchService(this._searchApiRepository);
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) async {
try {
return await _searchApiRepository.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
} catch (e) {
_log.warning("Failed to get search suggestions", e);
}
return [];
}
Future<SearchResult?> search(SearchFilter filter, int page) async {
try {
final response = await _searchApiRepository.search(filter, page);
if (response == null || response.assets.items.isEmpty) {
return null;
}
return SearchResult(
assets: response.assets.items.map((e) => e.toDto()).toList(),
nextPage: response.assets.nextPage?.toInt(),
);
} catch (error, stackTrace) {
_log.severe("Failed to search for assets", error, stackTrace);
}
return null;
}
}
extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
);
}
}
extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

View File

@@ -93,6 +93,8 @@ class StoreService {
await _storeRepository.deleteAll();
_cache.clear();
}
bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? false;
}
class StoreKeyNotFoundException implements Exception {

View File

@@ -240,6 +240,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteUserMetadatasV1(
data.cast(),
);
case SyncEntityType.personV1:
return _syncStreamRepository.updatePeopleV1(data.cast());
case SyncEntityType.personDeleteV1:
return _syncStreamRepository.deletePeopleV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}

View File

@@ -65,6 +65,9 @@ class TimelineFactory {
TimelineService place(String place) =>
TimelineService(_timelineRepository.place(place, groupBy));
TimelineService fromAssets(List<BaseAsset> assets) =>
TimelineService(_timelineRepository.fromAssets(assets));
}
class TimelineService {

View File

@@ -4,13 +4,24 @@ import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart';
typedef SyncCallback = void Function();
typedef SyncErrorCallback = void Function(String error);
class BackgroundSyncManager {
final SyncCallback? onRemoteSyncStart;
final SyncCallback? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
Cancelable<void>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _hashTask;
BackgroundSyncManager();
BackgroundSyncManager({
this.onRemoteSyncStart,
this.onRemoteSyncComplete,
this.onRemoteSyncError,
});
Future<void> cancel() {
final futures = <Future>[];
@@ -72,10 +83,16 @@ class BackgroundSyncManager {
return _syncTask!.future;
}
onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
);
return _syncTask!.whenComplete(() {
onRemoteSyncComplete?.call();
_syncTask = null;
}).catchError((error) {
onRemoteSyncError?.call(error.toString());
_syncTask = null;
});
}

View File

@@ -1,17 +1,9 @@
import 'dart:async';
sealed class Event {
class Event {
const Event();
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class EventStream {
EventStream._();

View File

@@ -1,5 +1,6 @@
import 'remote_asset.entity.dart';
import 'local_asset.entity.dart';
import 'stack.entity.dart';
mergedAsset: SELECT * FROM
(
@@ -18,13 +19,33 @@ mergedAsset: SELECT * FROM
rae.checksum,
rae.owner_id,
rae.live_photo_video_id,
0 as orientation
0 as orientation,
rae.stack_id,
COALESCE(stack_count.total_count, 0) AS stack_count
FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
LEFT JOIN
(SELECT
stack_id,
COUNT(*) AS total_count
FROM remote_asset_entity
WHERE deleted_at IS NULL
AND visibility = 0
AND stack_id IS NOT NULL
GROUP BY stack_id
) AS stack_count ON rae.stack_id = stack_count.stack_id
WHERE
rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ?
rae.deleted_at IS NULL
AND rae.visibility = 0
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
UNION ALL
SELECT
NULL as remote_id,
@@ -41,7 +62,9 @@ mergedAsset: SELECT * FROM
lae.checksum,
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation
lae.orientation,
NULL as stack_id,
0 AS stack_count
FROM
local_asset_entity lae
LEFT JOIN
@@ -68,8 +91,16 @@ FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE
rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ?
rae.deleted_at IS NULL
AND rae.visibility = 0
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
UNION ALL
SELECT
lae.name,

View File

@@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i5;
class MergedAssetDrift extends i1.ModularAccessor {
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
@@ -18,7 +20,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id LEFT JOIN (SELECT stack_id, COUNT(*) AS total_count FROM remote_asset_entity WHERE deleted_at IS NULL AND visibility = 0 AND stack_id IS NOT NULL GROUP BY stack_id) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, 0 AS stack_count FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in var1) i0.Variable<String>($),
...generatedlimit.introducedVariables
@@ -26,6 +28,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
readsFrom: {
remoteAssetEntity,
localAssetEntity,
stackEntity,
...generatedlimit.watchedTables,
}).map((i0.QueryRow row) => MergedAssetResult(
remoteId: row.readNullable<String>('remote_id'),
@@ -44,6 +47,8 @@ class MergedAssetDrift extends i1.ModularAccessor {
ownerId: row.readNullable<String>('owner_id'),
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'),
stackId: row.readNullable<String>('stack_id'),
stackCount: row.read<int>('stack_count'),
));
}
@@ -53,7 +58,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
$arrayStartIndex += var2.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in var2) i0.Variable<String>($)
@@ -61,6 +66,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
readsFrom: {
remoteAssetEntity,
localAssetEntity,
stackEntity,
}).map((i0.QueryRow row) => MergedBucketResult(
assetCount: row.read<int>('asset_count'),
bucketDate: row.read<String>('bucket_date'),
@@ -73,6 +79,9 @@ class MergedAssetDrift extends i1.ModularAccessor {
i4.$LocalAssetEntityTable get localAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i4.$LocalAssetEntityTable>('local_asset_entity');
i5.$StackEntityTable get stackEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i5.$StackEntityTable>('stack_entity');
}
class MergedAssetResult {
@@ -91,6 +100,8 @@ class MergedAssetResult {
final String? ownerId;
final String? livePhotoVideoId;
final int orientation;
final String? stackId;
final int stackCount;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -107,6 +118,8 @@ class MergedAssetResult {
this.ownerId,
this.livePhotoVideoId,
required this.orientation,
this.stackId,
required this.stackCount,
});
}

View File

@@ -0,0 +1,34 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class PersonEntity extends Table with DriftDefaultsMixin {
const PersonEntity();
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get name => text()();
// TODO: foreign key refering to asset faces
TextColumn get faceAssetId => text().nullable()();
TextColumn get thumbnailPath => text()();
BoolColumn get isFavorite => boolean()();
BoolColumn get isHidden => boolean()();
TextColumn get color => text().nullable()();
DateTimeColumn get birthDate => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -0,0 +1,933 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/person.entity.dart' as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i4;
import 'package:drift/internal/modular.dart' as i5;
typedef $$PersonEntityTableCreateCompanionBuilder = i1.PersonEntityCompanion
Function({
required String id,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
required String ownerId,
required String name,
i0.Value<String?> faceAssetId,
required String thumbnailPath,
required bool isFavorite,
required bool isHidden,
i0.Value<String?> color,
i0.Value<DateTime?> birthDate,
});
typedef $$PersonEntityTableUpdateCompanionBuilder = i1.PersonEntityCompanion
Function({
i0.Value<String> id,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
i0.Value<String> ownerId,
i0.Value<String> name,
i0.Value<String?> faceAssetId,
i0.Value<String> thumbnailPath,
i0.Value<bool> isFavorite,
i0.Value<bool> isHidden,
i0.Value<String?> color,
i0.Value<DateTime?> birthDate,
});
final class $$PersonEntityTableReferences extends i0.BaseReferences<
i0.GeneratedDatabase, i1.$PersonEntityTable, i1.PersonEntityData> {
$$PersonEntityTableReferences(super.$_db, super.$_table, super.$_typedResult);
static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$PersonEntityTable>('person_entity')
.ownerId,
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.id));
i4.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;
final manager = i4
.$$UserEntityTableTableManager(
$_db,
i5.ReadDatabaseContainer($_db)
.resultSet<i4.$UserEntityTable>('user_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_ownerIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$PersonEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$PersonEntityTable> {
$$PersonEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get name => $composableBuilder(
column: $table.name, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get faceAssetId => $composableBuilder(
column: $table.faceAssetId,
builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get thumbnailPath => $composableBuilder(
column: $table.thumbnailPath,
builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<bool> get isFavorite => $composableBuilder(
column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<bool> get isHidden => $composableBuilder(
column: $table.isHidden, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get color => $composableBuilder(
column: $table.color, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get birthDate => $composableBuilder(
column: $table.birthDate, builder: (column) => i0.ColumnFilters(column));
i4.$$UserEntityTableFilterComposer get ownerId {
final i4.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$UserEntityTableFilterComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$PersonEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$PersonEntityTable> {
$$PersonEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get name => $composableBuilder(
column: $table.name, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get faceAssetId => $composableBuilder(
column: $table.faceAssetId,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get thumbnailPath => $composableBuilder(
column: $table.thumbnailPath,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<bool> get isFavorite => $composableBuilder(
column: $table.isFavorite,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<bool> get isHidden => $composableBuilder(
column: $table.isHidden, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get color => $composableBuilder(
column: $table.color, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get birthDate => $composableBuilder(
column: $table.birthDate,
builder: (column) => i0.ColumnOrderings(column));
i4.$$UserEntityTableOrderingComposer get ownerId {
final i4.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$UserEntityTableOrderingComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$PersonEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$PersonEntityTable> {
$$PersonEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
i0.GeneratedColumn<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
i0.GeneratedColumn<String> get faceAssetId => $composableBuilder(
column: $table.faceAssetId, builder: (column) => column);
i0.GeneratedColumn<String> get thumbnailPath => $composableBuilder(
column: $table.thumbnailPath, builder: (column) => column);
i0.GeneratedColumn<bool> get isFavorite => $composableBuilder(
column: $table.isFavorite, builder: (column) => column);
i0.GeneratedColumn<bool> get isHidden =>
$composableBuilder(column: $table.isHidden, builder: (column) => column);
i0.GeneratedColumn<String> get color =>
$composableBuilder(column: $table.color, builder: (column) => column);
i0.GeneratedColumn<DateTime> get birthDate =>
$composableBuilder(column: $table.birthDate, builder: (column) => column);
i4.$$UserEntityTableAnnotationComposer get ownerId {
final i4.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$UserEntityTableAnnotationComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$PersonEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$PersonEntityTable,
i1.PersonEntityData,
i1.$$PersonEntityTableFilterComposer,
i1.$$PersonEntityTableOrderingComposer,
i1.$$PersonEntityTableAnnotationComposer,
$$PersonEntityTableCreateCompanionBuilder,
$$PersonEntityTableUpdateCompanionBuilder,
(i1.PersonEntityData, i1.$$PersonEntityTableReferences),
i1.PersonEntityData,
i0.PrefetchHooks Function({bool ownerId})> {
$$PersonEntityTableTableManager(
i0.GeneratedDatabase db, i1.$PersonEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$PersonEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$PersonEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$PersonEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
i0.Value<String> id = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
i0.Value<String> ownerId = const i0.Value.absent(),
i0.Value<String> name = const i0.Value.absent(),
i0.Value<String?> faceAssetId = const i0.Value.absent(),
i0.Value<String> thumbnailPath = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<bool> isHidden = const i0.Value.absent(),
i0.Value<String?> color = const i0.Value.absent(),
i0.Value<DateTime?> birthDate = const i0.Value.absent(),
}) =>
i1.PersonEntityCompanion(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
thumbnailPath: thumbnailPath,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
),
createCompanionCallback: ({
required String id,
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required String ownerId,
required String name,
i0.Value<String?> faceAssetId = const i0.Value.absent(),
required String thumbnailPath,
required bool isFavorite,
required bool isHidden,
i0.Value<String?> color = const i0.Value.absent(),
i0.Value<DateTime?> birthDate = const i0.Value.absent(),
}) =>
i1.PersonEntityCompanion.insert(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
thumbnailPath: thumbnailPath,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
),
withReferenceMapper: (p0) => p0
.map((e) => (
e.readTable(table),
i1.$$PersonEntityTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({ownerId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins: <
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic>>(state) {
if (ownerId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.ownerId,
referencedTable:
i1.$$PersonEntityTableReferences._ownerIdTable(db),
referencedColumn:
i1.$$PersonEntityTableReferences._ownerIdTable(db).id,
) as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
));
}
typedef $$PersonEntityTableProcessedTableManager = i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$PersonEntityTable,
i1.PersonEntityData,
i1.$$PersonEntityTableFilterComposer,
i1.$$PersonEntityTableOrderingComposer,
i1.$$PersonEntityTableAnnotationComposer,
$$PersonEntityTableCreateCompanionBuilder,
$$PersonEntityTableUpdateCompanionBuilder,
(i1.PersonEntityData, i1.$$PersonEntityTableReferences),
i1.PersonEntityData,
i0.PrefetchHooks Function({bool ownerId})>;
class $PersonEntityTable extends i2.PersonEntity
with i0.TableInfo<$PersonEntityTable, i1.PersonEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$PersonEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
'id', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _createdAtMeta =
const i0.VerificationMeta('createdAt');
@override
late final i0.GeneratedColumn<DateTime> createdAt =
i0.GeneratedColumn<DateTime>('created_at', aliasedName, false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime);
static const i0.VerificationMeta _updatedAtMeta =
const i0.VerificationMeta('updatedAt');
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>('updated_at', aliasedName, false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime);
static const i0.VerificationMeta _ownerIdMeta =
const i0.VerificationMeta('ownerId');
@override
late final i0.GeneratedColumn<String> ownerId = i0.GeneratedColumn<String>(
'owner_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
static const i0.VerificationMeta _nameMeta =
const i0.VerificationMeta('name');
@override
late final i0.GeneratedColumn<String> name = i0.GeneratedColumn<String>(
'name', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _faceAssetIdMeta =
const i0.VerificationMeta('faceAssetId');
@override
late final i0.GeneratedColumn<String> faceAssetId =
i0.GeneratedColumn<String>('face_asset_id', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _thumbnailPathMeta =
const i0.VerificationMeta('thumbnailPath');
@override
late final i0.GeneratedColumn<String> thumbnailPath =
i0.GeneratedColumn<String>('thumbnail_path', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _isFavoriteMeta =
const i0.VerificationMeta('isFavorite');
@override
late final i0.GeneratedColumn<bool> isFavorite = i0.GeneratedColumn<bool>(
'is_favorite', aliasedName, false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_favorite" IN (0, 1))'));
static const i0.VerificationMeta _isHiddenMeta =
const i0.VerificationMeta('isHidden');
@override
late final i0.GeneratedColumn<bool> isHidden = i0.GeneratedColumn<bool>(
'is_hidden', aliasedName, false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_hidden" IN (0, 1))'));
static const i0.VerificationMeta _colorMeta =
const i0.VerificationMeta('color');
@override
late final i0.GeneratedColumn<String> color = i0.GeneratedColumn<String>(
'color', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _birthDateMeta =
const i0.VerificationMeta('birthDate');
@override
late final i0.GeneratedColumn<DateTime> birthDate =
i0.GeneratedColumn<DateTime>('birth_date', aliasedName, true,
type: i0.DriftSqlType.dateTime, requiredDuringInsert: false);
@override
List<i0.GeneratedColumn> get $columns => [
id,
createdAt,
updatedAt,
ownerId,
name,
faceAssetId,
thumbnailPath,
isFavorite,
isHidden,
color,
birthDate
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'person_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.PersonEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('updated_at')) {
context.handle(_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
}
if (data.containsKey('owner_id')) {
context.handle(_ownerIdMeta,
ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta));
} else if (isInserting) {
context.missing(_ownerIdMeta);
}
if (data.containsKey('name')) {
context.handle(
_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
} else if (isInserting) {
context.missing(_nameMeta);
}
if (data.containsKey('face_asset_id')) {
context.handle(
_faceAssetIdMeta,
faceAssetId.isAcceptableOrUnknown(
data['face_asset_id']!, _faceAssetIdMeta));
}
if (data.containsKey('thumbnail_path')) {
context.handle(
_thumbnailPathMeta,
thumbnailPath.isAcceptableOrUnknown(
data['thumbnail_path']!, _thumbnailPathMeta));
} else if (isInserting) {
context.missing(_thumbnailPathMeta);
}
if (data.containsKey('is_favorite')) {
context.handle(
_isFavoriteMeta,
isFavorite.isAcceptableOrUnknown(
data['is_favorite']!, _isFavoriteMeta));
} else if (isInserting) {
context.missing(_isFavoriteMeta);
}
if (data.containsKey('is_hidden')) {
context.handle(_isHiddenMeta,
isHidden.isAcceptableOrUnknown(data['is_hidden']!, _isHiddenMeta));
} else if (isInserting) {
context.missing(_isHiddenMeta);
}
if (data.containsKey('color')) {
context.handle(
_colorMeta, color.isAcceptableOrUnknown(data['color']!, _colorMeta));
}
if (data.containsKey('birth_date')) {
context.handle(_birthDateMeta,
birthDate.isAcceptableOrUnknown(data['birth_date']!, _birthDateMeta));
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.PersonEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.PersonEntityData(
id: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!,
createdAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
ownerId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}owner_id'])!,
name: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!,
faceAssetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, data['${effectivePrefix}face_asset_id']),
thumbnailPath: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, data['${effectivePrefix}thumbnail_path'])!,
isFavorite: attachedDatabase.typeMapping
.read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!,
isHidden: attachedDatabase.typeMapping
.read(i0.DriftSqlType.bool, data['${effectivePrefix}is_hidden'])!,
color: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}color']),
birthDate: attachedDatabase.typeMapping
.read(i0.DriftSqlType.dateTime, data['${effectivePrefix}birth_date']),
);
}
@override
$PersonEntityTable createAlias(String alias) {
return $PersonEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class PersonEntityData extends i0.DataClass
implements i0.Insertable<i1.PersonEntityData> {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? faceAssetId;
final String thumbnailPath;
final bool isFavorite;
final bool isHidden;
final String? color;
final DateTime? birthDate;
const PersonEntityData(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.faceAssetId,
required this.thumbnailPath,
required this.isFavorite,
required this.isHidden,
this.color,
this.birthDate});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['created_at'] = i0.Variable<DateTime>(createdAt);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
map['owner_id'] = i0.Variable<String>(ownerId);
map['name'] = i0.Variable<String>(name);
if (!nullToAbsent || faceAssetId != null) {
map['face_asset_id'] = i0.Variable<String>(faceAssetId);
}
map['thumbnail_path'] = i0.Variable<String>(thumbnailPath);
map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['is_hidden'] = i0.Variable<bool>(isHidden);
if (!nullToAbsent || color != null) {
map['color'] = i0.Variable<String>(color);
}
if (!nullToAbsent || birthDate != null) {
map['birth_date'] = i0.Variable<DateTime>(birthDate);
}
return map;
}
factory PersonEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return PersonEntityData(
id: serializer.fromJson<String>(json['id']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
ownerId: serializer.fromJson<String>(json['ownerId']),
name: serializer.fromJson<String>(json['name']),
faceAssetId: serializer.fromJson<String?>(json['faceAssetId']),
thumbnailPath: serializer.fromJson<String>(json['thumbnailPath']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
isHidden: serializer.fromJson<bool>(json['isHidden']),
color: serializer.fromJson<String?>(json['color']),
birthDate: serializer.fromJson<DateTime?>(json['birthDate']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'ownerId': serializer.toJson<String>(ownerId),
'name': serializer.toJson<String>(name),
'faceAssetId': serializer.toJson<String?>(faceAssetId),
'thumbnailPath': serializer.toJson<String>(thumbnailPath),
'isFavorite': serializer.toJson<bool>(isFavorite),
'isHidden': serializer.toJson<bool>(isHidden),
'color': serializer.toJson<String?>(color),
'birthDate': serializer.toJson<DateTime?>(birthDate),
};
}
i1.PersonEntityData copyWith(
{String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
i0.Value<String?> faceAssetId = const i0.Value.absent(),
String? thumbnailPath,
bool? isFavorite,
bool? isHidden,
i0.Value<String?> color = const i0.Value.absent(),
i0.Value<DateTime?> birthDate = const i0.Value.absent()}) =>
i1.PersonEntityData(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color.present ? color.value : this.color,
birthDate: birthDate.present ? birthDate.value : this.birthDate,
);
PersonEntityData copyWithCompanion(i1.PersonEntityCompanion data) {
return PersonEntityData(
id: data.id.present ? data.id.value : this.id,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId,
name: data.name.present ? data.name.value : this.name,
faceAssetId:
data.faceAssetId.present ? data.faceAssetId.value : this.faceAssetId,
thumbnailPath: data.thumbnailPath.present
? data.thumbnailPath.value
: this.thumbnailPath,
isFavorite:
data.isFavorite.present ? data.isFavorite.value : this.isFavorite,
isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden,
color: data.color.present ? data.color.value : this.color,
birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate,
);
}
@override
String toString() {
return (StringBuffer('PersonEntityData(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('name: $name, ')
..write('faceAssetId: $faceAssetId, ')
..write('thumbnailPath: $thumbnailPath, ')
..write('isFavorite: $isFavorite, ')
..write('isHidden: $isHidden, ')
..write('color: $color, ')
..write('birthDate: $birthDate')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, createdAt, updatedAt, ownerId, name,
faceAssetId, thumbnailPath, isFavorite, isHidden, color, birthDate);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.PersonEntityData &&
other.id == this.id &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.ownerId == this.ownerId &&
other.name == this.name &&
other.faceAssetId == this.faceAssetId &&
other.thumbnailPath == this.thumbnailPath &&
other.isFavorite == this.isFavorite &&
other.isHidden == this.isHidden &&
other.color == this.color &&
other.birthDate == this.birthDate);
}
class PersonEntityCompanion extends i0.UpdateCompanion<i1.PersonEntityData> {
final i0.Value<String> id;
final i0.Value<DateTime> createdAt;
final i0.Value<DateTime> updatedAt;
final i0.Value<String> ownerId;
final i0.Value<String> name;
final i0.Value<String?> faceAssetId;
final i0.Value<String> thumbnailPath;
final i0.Value<bool> isFavorite;
final i0.Value<bool> isHidden;
final i0.Value<String?> color;
final i0.Value<DateTime?> birthDate;
const PersonEntityCompanion({
this.id = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
this.ownerId = const i0.Value.absent(),
this.name = const i0.Value.absent(),
this.faceAssetId = const i0.Value.absent(),
this.thumbnailPath = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.isHidden = const i0.Value.absent(),
this.color = const i0.Value.absent(),
this.birthDate = const i0.Value.absent(),
});
PersonEntityCompanion.insert({
required String id,
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
required String ownerId,
required String name,
this.faceAssetId = const i0.Value.absent(),
required String thumbnailPath,
required bool isFavorite,
required bool isHidden,
this.color = const i0.Value.absent(),
this.birthDate = const i0.Value.absent(),
}) : id = i0.Value(id),
ownerId = i0.Value(ownerId),
name = i0.Value(name),
thumbnailPath = i0.Value(thumbnailPath),
isFavorite = i0.Value(isFavorite),
isHidden = i0.Value(isHidden);
static i0.Insertable<i1.PersonEntityData> custom({
i0.Expression<String>? id,
i0.Expression<DateTime>? createdAt,
i0.Expression<DateTime>? updatedAt,
i0.Expression<String>? ownerId,
i0.Expression<String>? name,
i0.Expression<String>? faceAssetId,
i0.Expression<String>? thumbnailPath,
i0.Expression<bool>? isFavorite,
i0.Expression<bool>? isHidden,
i0.Expression<String>? color,
i0.Expression<DateTime>? birthDate,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (ownerId != null) 'owner_id': ownerId,
if (name != null) 'name': name,
if (faceAssetId != null) 'face_asset_id': faceAssetId,
if (thumbnailPath != null) 'thumbnail_path': thumbnailPath,
if (isFavorite != null) 'is_favorite': isFavorite,
if (isHidden != null) 'is_hidden': isHidden,
if (color != null) 'color': color,
if (birthDate != null) 'birth_date': birthDate,
});
}
i1.PersonEntityCompanion copyWith(
{i0.Value<String>? id,
i0.Value<DateTime>? createdAt,
i0.Value<DateTime>? updatedAt,
i0.Value<String>? ownerId,
i0.Value<String>? name,
i0.Value<String?>? faceAssetId,
i0.Value<String>? thumbnailPath,
i0.Value<bool>? isFavorite,
i0.Value<bool>? isHidden,
i0.Value<String?>? color,
i0.Value<DateTime?>? birthDate}) {
return i1.PersonEntityCompanion(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId ?? this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color ?? this.color,
birthDate: birthDate ?? this.birthDate,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (createdAt.present) {
map['created_at'] = i0.Variable<DateTime>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
if (ownerId.present) {
map['owner_id'] = i0.Variable<String>(ownerId.value);
}
if (name.present) {
map['name'] = i0.Variable<String>(name.value);
}
if (faceAssetId.present) {
map['face_asset_id'] = i0.Variable<String>(faceAssetId.value);
}
if (thumbnailPath.present) {
map['thumbnail_path'] = i0.Variable<String>(thumbnailPath.value);
}
if (isFavorite.present) {
map['is_favorite'] = i0.Variable<bool>(isFavorite.value);
}
if (isHidden.present) {
map['is_hidden'] = i0.Variable<bool>(isHidden.value);
}
if (color.present) {
map['color'] = i0.Variable<String>(color.value);
}
if (birthDate.present) {
map['birth_date'] = i0.Variable<DateTime>(birthDate.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('PersonEntityCompanion(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('name: $name, ')
..write('faceAssetId: $faceAssetId, ')
..write('thumbnailPath: $thumbnailPath, ')
..write('isFavorite: $isFavorite, ')
..write('isHidden: $isHidden, ')
..write('color: $color, ')
..write('birthDate: $birthDate')
..write(')'))
.toString();
}
}

View File

@@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table
IntColumn get visibility => intEnum<AssetVisibility>()();
TextColumn get stackId => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
visibility: visibility,
livePhotoVideoId: livePhotoVideoId,
localId: null,
stackId: stackId,
);
}

View File

@@ -29,6 +29,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder
i0.Value<DateTime?> deletedAt,
i0.Value<String?> livePhotoVideoId,
required i2.AssetVisibility visibility,
i0.Value<String?> stackId,
});
typedef $$RemoteAssetEntityTableUpdateCompanionBuilder
= i1.RemoteAssetEntityCompanion Function({
@@ -48,6 +49,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder
i0.Value<DateTime?> deletedAt,
i0.Value<String?> livePhotoVideoId,
i0.Value<i2.AssetVisibility> visibility,
i0.Value<String?> stackId,
});
final class $$RemoteAssetEntityTableReferences extends i0.BaseReferences<
@@ -145,6 +147,9 @@ class $$RemoteAssetEntityTableFilterComposer
column: $table.visibility,
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
i0.ColumnFilters<String> get stackId => $composableBuilder(
column: $table.stackId, builder: (column) => i0.ColumnFilters(column));
i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -231,6 +236,9 @@ class $$RemoteAssetEntityTableOrderingComposer
column: $table.visibility,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get stackId => $composableBuilder(
column: $table.stackId, builder: (column) => i0.ColumnOrderings(column));
i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
@@ -309,6 +317,9 @@ class $$RemoteAssetEntityTableAnnotationComposer
$composableBuilder(
column: $table.visibility, builder: (column) => column);
i0.GeneratedColumn<String> get stackId =>
$composableBuilder(column: $table.stackId, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
@@ -373,6 +384,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
i0.Value<i2.AssetVisibility> visibility = const i0.Value.absent(),
i0.Value<String?> stackId = const i0.Value.absent(),
}) =>
i1.RemoteAssetEntityCompanion(
name: name,
@@ -391,6 +403,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
deletedAt: deletedAt,
livePhotoVideoId: livePhotoVideoId,
visibility: visibility,
stackId: stackId,
),
createCompanionCallback: ({
required String name,
@@ -409,6 +422,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
required i2.AssetVisibility visibility,
i0.Value<String?> stackId = const i0.Value.absent(),
}) =>
i1.RemoteAssetEntityCompanion.insert(
name: name,
@@ -427,6 +441,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
deletedAt: deletedAt,
livePhotoVideoId: livePhotoVideoId,
visibility: visibility,
stackId: stackId,
),
withReferenceMapper: (p0) => p0
.map((e) => (
@@ -602,6 +617,12 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
type: i0.DriftSqlType.int, requiredDuringInsert: true)
.withConverter<i2.AssetVisibility>(
i1.$RemoteAssetEntityTable.$convertervisibility);
static const i0.VerificationMeta _stackIdMeta =
const i0.VerificationMeta('stackId');
@override
late final i0.GeneratedColumn<String> stackId = i0.GeneratedColumn<String>(
'stack_id', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -619,7 +640,8 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
thumbHash,
deletedAt,
livePhotoVideoId,
visibility
visibility,
stackId
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -703,6 +725,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
livePhotoVideoId.isAcceptableOrUnknown(
data['live_photo_video_id']!, _livePhotoVideoIdMeta));
}
if (data.containsKey('stack_id')) {
context.handle(_stackIdMeta,
stackId.isAcceptableOrUnknown(data['stack_id']!, _stackIdMeta));
}
return context;
}
@@ -748,6 +774,8 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
visibility: i1.$RemoteAssetEntityTable.$convertervisibility.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int, data['${effectivePrefix}visibility'])!),
stackId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}stack_id']),
);
}
@@ -785,6 +813,7 @@ class RemoteAssetEntityData extends i0.DataClass
final DateTime? deletedAt;
final String? livePhotoVideoId;
final i2.AssetVisibility visibility;
final String? stackId;
const RemoteAssetEntityData(
{required this.name,
required this.type,
@@ -801,7 +830,8 @@ class RemoteAssetEntityData extends i0.DataClass
this.thumbHash,
this.deletedAt,
this.livePhotoVideoId,
required this.visibility});
required this.visibility,
this.stackId});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
@@ -841,6 +871,9 @@ class RemoteAssetEntityData extends i0.DataClass
map['visibility'] = i0.Variable<int>(
i1.$RemoteAssetEntityTable.$convertervisibility.toSql(visibility));
}
if (!nullToAbsent || stackId != null) {
map['stack_id'] = i0.Variable<String>(stackId);
}
return map;
}
@@ -866,6 +899,7 @@ class RemoteAssetEntityData extends i0.DataClass
livePhotoVideoId: serializer.fromJson<String?>(json['livePhotoVideoId']),
visibility: i1.$RemoteAssetEntityTable.$convertervisibility
.fromJson(serializer.fromJson<int>(json['visibility'])),
stackId: serializer.fromJson<String?>(json['stackId']),
);
}
@override
@@ -890,6 +924,7 @@ class RemoteAssetEntityData extends i0.DataClass
'livePhotoVideoId': serializer.toJson<String?>(livePhotoVideoId),
'visibility': serializer.toJson<int>(
i1.$RemoteAssetEntityTable.$convertervisibility.toJson(visibility)),
'stackId': serializer.toJson<String?>(stackId),
};
}
@@ -909,7 +944,8 @@ class RemoteAssetEntityData extends i0.DataClass
i0.Value<String?> thumbHash = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
i2.AssetVisibility? visibility}) =>
i2.AssetVisibility? visibility,
i0.Value<String?> stackId = const i0.Value.absent()}) =>
i1.RemoteAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -932,6 +968,7 @@ class RemoteAssetEntityData extends i0.DataClass
? livePhotoVideoId.value
: this.livePhotoVideoId,
visibility: visibility ?? this.visibility,
stackId: stackId.present ? stackId.value : this.stackId,
);
RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) {
return RemoteAssetEntityData(
@@ -959,6 +996,7 @@ class RemoteAssetEntityData extends i0.DataClass
: this.livePhotoVideoId,
visibility:
data.visibility.present ? data.visibility.value : this.visibility,
stackId: data.stackId.present ? data.stackId.value : this.stackId,
);
}
@@ -980,7 +1018,8 @@ class RemoteAssetEntityData extends i0.DataClass
..write('thumbHash: $thumbHash, ')
..write('deletedAt: $deletedAt, ')
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility')
..write('visibility: $visibility, ')
..write('stackId: $stackId')
..write(')'))
.toString();
}
@@ -1002,7 +1041,8 @@ class RemoteAssetEntityData extends i0.DataClass
thumbHash,
deletedAt,
livePhotoVideoId,
visibility);
visibility,
stackId);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -1022,7 +1062,8 @@ class RemoteAssetEntityData extends i0.DataClass
other.thumbHash == this.thumbHash &&
other.deletedAt == this.deletedAt &&
other.livePhotoVideoId == this.livePhotoVideoId &&
other.visibility == this.visibility);
other.visibility == this.visibility &&
other.stackId == this.stackId);
}
class RemoteAssetEntityCompanion
@@ -1043,6 +1084,7 @@ class RemoteAssetEntityCompanion
final i0.Value<DateTime?> deletedAt;
final i0.Value<String?> livePhotoVideoId;
final i0.Value<i2.AssetVisibility> visibility;
final i0.Value<String?> stackId;
const RemoteAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1060,6 +1102,7 @@ class RemoteAssetEntityCompanion
this.deletedAt = const i0.Value.absent(),
this.livePhotoVideoId = const i0.Value.absent(),
this.visibility = const i0.Value.absent(),
this.stackId = const i0.Value.absent(),
});
RemoteAssetEntityCompanion.insert({
required String name,
@@ -1078,6 +1121,7 @@ class RemoteAssetEntityCompanion
this.deletedAt = const i0.Value.absent(),
this.livePhotoVideoId = const i0.Value.absent(),
required i2.AssetVisibility visibility,
this.stackId = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
@@ -1101,6 +1145,7 @@ class RemoteAssetEntityCompanion
i0.Expression<DateTime>? deletedAt,
i0.Expression<String>? livePhotoVideoId,
i0.Expression<int>? visibility,
i0.Expression<String>? stackId,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1119,6 +1164,7 @@ class RemoteAssetEntityCompanion
if (deletedAt != null) 'deleted_at': deletedAt,
if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId,
if (visibility != null) 'visibility': visibility,
if (stackId != null) 'stack_id': stackId,
});
}
@@ -1138,7 +1184,8 @@ class RemoteAssetEntityCompanion
i0.Value<String?>? thumbHash,
i0.Value<DateTime?>? deletedAt,
i0.Value<String?>? livePhotoVideoId,
i0.Value<i2.AssetVisibility>? visibility}) {
i0.Value<i2.AssetVisibility>? visibility,
i0.Value<String?>? stackId}) {
return i1.RemoteAssetEntityCompanion(
name: name ?? this.name,
type: type ?? this.type,
@@ -1156,6 +1203,7 @@ class RemoteAssetEntityCompanion
deletedAt: deletedAt ?? this.deletedAt,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
visibility: visibility ?? this.visibility,
stackId: stackId ?? this.stackId,
);
}
@@ -1213,6 +1261,9 @@ class RemoteAssetEntityCompanion
.$RemoteAssetEntityTable.$convertervisibility
.toSql(visibility.value));
}
if (stackId.present) {
map['stack_id'] = i0.Variable<String>(stackId.value);
}
return map;
}
@@ -1234,7 +1285,8 @@ class RemoteAssetEntityCompanion
..write('thumbHash: $thumbHash, ')
..write('deletedAt: $deletedAt, ')
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility')
..write('visibility: $visibility, ')
..write('stackId: $stackId')
..write(')'))
.toString();
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
@@ -10,6 +11,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
@@ -17,6 +19,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:isar/isar.dart';
import 'db.repository.drift.dart';
@@ -52,6 +55,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
MemoryEntity,
MemoryAssetEntity,
StackEntity,
PersonEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',
@@ -68,10 +72,36 @@ class Drift extends $Drift implements IDatabaseRepository {
);
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// Run migration steps without foreign keys and re-enable them later
await customStatement('PRAGMA foreign_keys = OFF');
await m.runMigrationSteps(
from: from,
to: to,
steps: migrationSteps(
from1To2: (m, _) async {
for (final entity in allSchemaEntities) {
await m.drop(entity);
await m.create(entity);
}
},
),
);
if (kDebugMode) {
// Fail if the migration broke foreign keys
final wrongFKs =
await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
}
await customStatement('PRAGMA foreign_keys = ON;');
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL');

View File

@@ -7,31 +7,33 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
as i2;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i12;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i12;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i14;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i15;
import 'package:drift/internal/modular.dart' as i16;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i16;
import 'package:drift/internal/modular.dart' as i17;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -41,28 +43,29 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.$RemoteAssetEntityTable(this);
late final i3.$LocalAssetEntityTable localAssetEntity =
i3.$LocalAssetEntityTable(this);
late final i4.$UserMetadataEntityTable userMetadataEntity =
i4.$UserMetadataEntityTable(this);
late final i5.$PartnerEntityTable partnerEntity =
i5.$PartnerEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity =
i6.$LocalAlbumEntityTable(this);
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i7.$LocalAlbumAssetEntityTable(this);
late final i8.$RemoteExifEntityTable remoteExifEntity =
i8.$RemoteExifEntityTable(this);
late final i9.$RemoteAlbumEntityTable remoteAlbumEntity =
i9.$RemoteAlbumEntityTable(this);
late final i10.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity =
i10.$RemoteAlbumAssetEntityTable(this);
late final i11.$RemoteAlbumUserEntityTable remoteAlbumUserEntity =
i11.$RemoteAlbumUserEntityTable(this);
late final i12.$MemoryEntityTable memoryEntity = i12.$MemoryEntityTable(this);
late final i13.$MemoryAssetEntityTable memoryAssetEntity =
i13.$MemoryAssetEntityTable(this);
late final i14.$StackEntityTable stackEntity = i14.$StackEntityTable(this);
i15.MergedAssetDrift get mergedAssetDrift => i16.ReadDatabaseContainer(this)
.accessor<i15.MergedAssetDrift>(i15.MergedAssetDrift.new);
late final i4.$StackEntityTable stackEntity = i4.$StackEntityTable(this);
late final i5.$UserMetadataEntityTable userMetadataEntity =
i5.$UserMetadataEntityTable(this);
late final i6.$PartnerEntityTable partnerEntity =
i6.$PartnerEntityTable(this);
late final i7.$LocalAlbumEntityTable localAlbumEntity =
i7.$LocalAlbumEntityTable(this);
late final i8.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i8.$LocalAlbumAssetEntityTable(this);
late final i9.$RemoteExifEntityTable remoteExifEntity =
i9.$RemoteExifEntityTable(this);
late final i10.$RemoteAlbumEntityTable remoteAlbumEntity =
i10.$RemoteAlbumEntityTable(this);
late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity =
i11.$RemoteAlbumAssetEntityTable(this);
late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity =
i12.$RemoteAlbumUserEntityTable(this);
late final i13.$MemoryEntityTable memoryEntity = i13.$MemoryEntityTable(this);
late final i14.$MemoryAssetEntityTable memoryAssetEntity =
i14.$MemoryAssetEntityTable(this);
late final i15.$PersonEntityTable personEntity = i15.$PersonEntityTable(this);
i16.MergedAssetDrift get mergedAssetDrift => i17.ReadDatabaseContainer(this)
.accessor<i16.MergedAssetDrift>(i16.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -71,6 +74,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
userEntity,
remoteAssetEntity,
localAssetEntity,
stackEntity,
i3.idxLocalAssetChecksum,
i2.uQRemoteAssetOwnerChecksum,
i2.idxRemoteAssetChecksum,
@@ -84,7 +88,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
stackEntity
personEntity
];
@override
i0.StreamQueryUpdateRules get streamUpdateRules =>
@@ -97,6 +101,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
@@ -213,7 +224,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete),
i0.TableUpdate('person_entity', kind: i0.UpdateKind.delete),
],
),
],
@@ -232,27 +243,29 @@ class $DriftManager {
i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
i3.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i4.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i5.$$PartnerEntityTableTableManager get partnerEntity =>
i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
i4.$$StackEntityTableTableManager get stackEntity =>
i4.$$StackEntityTableTableManager(_db, _db.stackEntity);
i5.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i5.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i6.$$PartnerEntityTableTableManager get partnerEntity =>
i6.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i7.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i7.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i8.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i8
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i8.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i9.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i9.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i10.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i10.$$RemoteAlbumAssetEntityTableTableManager(
i9.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i10.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i11.$$RemoteAlbumAssetEntityTableTableManager(
_db, _db.remoteAlbumAssetEntity);
i11.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i11
i12.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i12
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
i12.$$MemoryEntityTableTableManager get memoryEntity =>
i12.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i13.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i13.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i14.$$StackEntityTableTableManager get stackEntity =>
i14.$$StackEntityTableTableManager(_db, _db.stackEntity);
i13.$$MemoryEntityTableTableManager get memoryEntity =>
i13.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i14.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i14.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i15.$$PersonEntityTableTableManager get personEntity =>
i15.$$PersonEntityTableTableManager(_db, _db.personEntity);
}

View File

@@ -0,0 +1,944 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'dart:typed_data' as i2;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class Schema2 extends i0.VersionedSchema {
Schema2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
localAssetEntity,
stackEntity,
idxLocalAssetChecksum,
uQRemoteAssetOwnerChecksum,
idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
localAlbumEntity,
localAlbumAssetEntity,
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
];
late final Shape0 userEntity = Shape0(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
_column_6,
_column_7,
],
attachedDatabase: database,
),
alias: null);
late final Shape1 remoteAssetEntity = Shape1(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_24,
],
attachedDatabase: database,
),
alias: null);
final i1.Index idxLocalAssetChecksum = i1.Index('idx_local_asset_checksum',
'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)');
final i1.Index uQRemoteAssetOwnerChecksum = i1.Index(
'UQ_remote_asset_owner_checksum',
'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)');
final i1.Index idxRemoteAssetChecksum = i1.Index('idx_remote_asset_checksum',
'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)');
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(user_id, "key")',
],
columns: [
_column_25,
_column_26,
_column_27,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(shared_by_id, shared_with_id)',
],
columns: [
_column_28,
_column_29,
_column_30,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 localAlbumEntity = Shape6(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_33,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(asset_id, album_id)',
],
columns: [
_column_34,
_column_35,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(asset_id)',
],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(asset_id, album_id)',
],
columns: [
_column_36,
_column_60,
],
attachedDatabase: database,
),
alias: null);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(album_id, user_id)',
],
columns: [
_column_60,
_column_25,
_column_61,
],
attachedDatabase: database,
),
alias: null);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(asset_id, memory_id)',
],
columns: [
_column_36,
_column_68,
],
attachedDatabase: database,
),
alias: null);
late final Shape13 personEntity = Shape13(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_70,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isAdmin =>
columnsByName['is_admin']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get email =>
columnsByName['email']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get profileImagePath =>
columnsByName['profile_image_path']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get quotaSizeInBytes =>
columnsByName['quota_size_in_bytes']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get quotaUsageInBytes =>
columnsByName['quota_usage_in_bytes']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_0(String aliasedName) =>
i1.GeneratedColumn<String>('id', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_2(String aliasedName) =>
i1.GeneratedColumn<bool>('is_admin', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_admin" IN (0, 1))'),
defaultValue: const CustomExpression('0'));
i1.GeneratedColumn<String> _column_3(String aliasedName) =>
i1.GeneratedColumn<String>('email', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_4(String aliasedName) =>
i1.GeneratedColumn<String>('profile_image_path', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_5(String aliasedName) =>
i1.GeneratedColumn<DateTime>('updated_at', aliasedName, false,
type: i1.DriftSqlType.dateTime,
defaultValue: const CustomExpression('CURRENT_TIMESTAMP'));
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
i1.GeneratedColumn<int>('quota_size_in_bytes', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_7(String aliasedName) =>
i1.GeneratedColumn<int>('quota_usage_in_bytes', aliasedName, false,
type: i1.DriftSqlType.int, defaultValue: const CustomExpression('0'));
class Shape1 extends i0.VersionedTable {
Shape1({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get localDateTime =>
columnsByName['local_date_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get thumbHash =>
columnsByName['thumb_hash']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get livePhotoVideoId =>
columnsByName['live_photo_video_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stackId =>
columnsByName['stack_id']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_8(String aliasedName) =>
i1.GeneratedColumn<int>('type', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<DateTime> _column_9(String aliasedName) =>
i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
type: i1.DriftSqlType.dateTime,
defaultValue: const CustomExpression('CURRENT_TIMESTAMP'));
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
i1.GeneratedColumn<int>('width', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_11(String aliasedName) =>
i1.GeneratedColumn<int>('height', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_12(String aliasedName) =>
i1.GeneratedColumn<int>('duration_in_seconds', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
i1.GeneratedColumn<String>('checksum', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_14(String aliasedName) =>
i1.GeneratedColumn<bool>('is_favorite', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_favorite" IN (0, 1))'),
defaultValue: const CustomExpression('0'));
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
i1.GeneratedColumn<String>('owner_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
i1.GeneratedColumn<DateTime> _column_16(String aliasedName) =>
i1.GeneratedColumn<DateTime>('local_date_time', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<String> _column_17(String aliasedName) =>
i1.GeneratedColumn<String>('thumb_hash', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_18(String aliasedName) =>
i1.GeneratedColumn<DateTime>('deleted_at', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<String> _column_19(String aliasedName) =>
i1.GeneratedColumn<String>('live_photo_video_id', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<int> _column_20(String aliasedName) =>
i1.GeneratedColumn<int>('visibility', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_21(String aliasedName) =>
i1.GeneratedColumn<String>('stack_id', aliasedName, true,
type: i1.DriftSqlType.string);
class Shape2 extends i0.VersionedTable {
Shape2({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_22(String aliasedName) =>
i1.GeneratedColumn<String>('checksum', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<int> _column_23(String aliasedName) =>
i1.GeneratedColumn<int>('orientation', aliasedName, false,
type: i1.DriftSqlType.int, defaultValue: const CustomExpression('0'));
class Shape3 extends i0.VersionedTable {
Shape3({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get primaryAssetId =>
columnsByName['primary_asset_id']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_24(String aliasedName) =>
i1.GeneratedColumn<String>('primary_asset_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id)'));
class Shape4 extends i0.VersionedTable {
Shape4({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get userId =>
columnsByName['user_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get key =>
columnsByName['key']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get value =>
columnsByName['value']! as i1.GeneratedColumn<i2.Uint8List>;
}
i1.GeneratedColumn<String> _column_25(String aliasedName) =>
i1.GeneratedColumn<String>('user_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
i1.GeneratedColumn<int> _column_26(String aliasedName) =>
i1.GeneratedColumn<int>('key', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<i2.Uint8List> _column_27(String aliasedName) =>
i1.GeneratedColumn<i2.Uint8List>('value', aliasedName, false,
type: i1.DriftSqlType.blob);
class Shape5 extends i0.VersionedTable {
Shape5({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get sharedById =>
columnsByName['shared_by_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get sharedWithId =>
columnsByName['shared_with_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get inTimeline =>
columnsByName['in_timeline']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_28(String aliasedName) =>
i1.GeneratedColumn<String>('shared_by_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
i1.GeneratedColumn<String> _column_29(String aliasedName) =>
i1.GeneratedColumn<String>('shared_with_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
i1.GeneratedColumn<bool> _column_30(String aliasedName) =>
i1.GeneratedColumn<bool>('in_timeline', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("in_timeline" IN (0, 1))'),
defaultValue: const CustomExpression('0'));
class Shape6 extends i0.VersionedTable {
Shape6({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get backupSelection =>
columnsByName['backup_selection']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get isIosSharedAlbum =>
columnsByName['is_ios_shared_album']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get marker_ =>
columnsByName['marker']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<int> _column_31(String aliasedName) =>
i1.GeneratedColumn<int>('backup_selection', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<bool> _column_32(String aliasedName) =>
i1.GeneratedColumn<bool>('is_ios_shared_album', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_ios_shared_album" IN (0, 1))'),
defaultValue: const CustomExpression('0'));
i1.GeneratedColumn<bool> _column_33(String aliasedName) =>
i1.GeneratedColumn<bool>('marker', aliasedName, true,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("marker" IN (0, 1))'));
class Shape7 extends i0.VersionedTable {
Shape7({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_34(String aliasedName) =>
i1.GeneratedColumn<String>('asset_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES local_asset_entity (id) ON DELETE CASCADE'));
i1.GeneratedColumn<String> _column_35(String aliasedName) =>
i1.GeneratedColumn<String>('album_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES local_album_entity (id) ON DELETE CASCADE'));
class Shape8 extends i0.VersionedTable {
Shape8({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get city =>
columnsByName['city']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get state =>
columnsByName['state']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get country =>
columnsByName['country']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get dateTimeOriginal =>
columnsByName['date_time_original']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get exposureTime =>
columnsByName['exposure_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get fNumber =>
columnsByName['f_number']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get fileSize =>
columnsByName['file_size']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<double> get focalLength =>
columnsByName['focal_length']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get iso =>
columnsByName['iso']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get make =>
columnsByName['make']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get model =>
columnsByName['model']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get lens =>
columnsByName['lens']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get timeZone =>
columnsByName['time_zone']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get rating =>
columnsByName['rating']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get projectionType =>
columnsByName['projection_type']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_36(String aliasedName) =>
i1.GeneratedColumn<String>('asset_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE'));
i1.GeneratedColumn<String> _column_37(String aliasedName) =>
i1.GeneratedColumn<String>('city', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_38(String aliasedName) =>
i1.GeneratedColumn<String>('state', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_39(String aliasedName) =>
i1.GeneratedColumn<String>('country', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_40(String aliasedName) =>
i1.GeneratedColumn<DateTime>('date_time_original', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<String> _column_41(String aliasedName) =>
i1.GeneratedColumn<String>('description', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_42(String aliasedName) =>
i1.GeneratedColumn<String>('exposure_time', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<double> _column_43(String aliasedName) =>
i1.GeneratedColumn<double>('f_number', aliasedName, true,
type: i1.DriftSqlType.double);
i1.GeneratedColumn<int> _column_44(String aliasedName) =>
i1.GeneratedColumn<int>('file_size', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<double> _column_45(String aliasedName) =>
i1.GeneratedColumn<double>('focal_length', aliasedName, true,
type: i1.DriftSqlType.double);
i1.GeneratedColumn<double> _column_46(String aliasedName) =>
i1.GeneratedColumn<double>('latitude', aliasedName, true,
type: i1.DriftSqlType.double);
i1.GeneratedColumn<double> _column_47(String aliasedName) =>
i1.GeneratedColumn<double>('longitude', aliasedName, true,
type: i1.DriftSqlType.double);
i1.GeneratedColumn<int> _column_48(String aliasedName) =>
i1.GeneratedColumn<int>('iso', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_49(String aliasedName) =>
i1.GeneratedColumn<String>('make', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_50(String aliasedName) =>
i1.GeneratedColumn<String>('model', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_51(String aliasedName) =>
i1.GeneratedColumn<String>('lens', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_52(String aliasedName) =>
i1.GeneratedColumn<String>('orientation', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_53(String aliasedName) =>
i1.GeneratedColumn<String>('time_zone', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<int> _column_54(String aliasedName) =>
i1.GeneratedColumn<int>('rating', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_55(String aliasedName) =>
i1.GeneratedColumn<String>('projection_type', aliasedName, true,
type: i1.DriftSqlType.string);
class Shape9 extends i0.VersionedTable {
Shape9({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get thumbnailAssetId =>
columnsByName['thumbnail_asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isActivityEnabled =>
columnsByName['is_activity_enabled']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get order =>
columnsByName['order']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_56(String aliasedName) =>
i1.GeneratedColumn<String>('description', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'\''));
i1.GeneratedColumn<String> _column_57(String aliasedName) =>
i1.GeneratedColumn<String>('thumbnail_asset_id', aliasedName, true,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE SET NULL'));
i1.GeneratedColumn<bool> _column_58(String aliasedName) =>
i1.GeneratedColumn<bool>('is_activity_enabled', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_activity_enabled" IN (0, 1))'),
defaultValue: const CustomExpression('1'));
i1.GeneratedColumn<int> _column_59(String aliasedName) =>
i1.GeneratedColumn<int>('order', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_60(String aliasedName) =>
i1.GeneratedColumn<String>('album_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE CASCADE'));
class Shape10 extends i0.VersionedTable {
Shape10({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get userId =>
columnsByName['user_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get role =>
columnsByName['role']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_61(String aliasedName) =>
i1.GeneratedColumn<int>('role', aliasedName, false,
type: i1.DriftSqlType.int);
class Shape11 extends i0.VersionedTable {
Shape11({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get data =>
columnsByName['data']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isSaved =>
columnsByName['is_saved']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get memoryAt =>
columnsByName['memory_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get seenAt =>
columnsByName['seen_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get showAt =>
columnsByName['show_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get hideAt =>
columnsByName['hide_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_62(String aliasedName) =>
i1.GeneratedColumn<String>('data', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_63(String aliasedName) =>
i1.GeneratedColumn<bool>('is_saved', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_saved" IN (0, 1))'),
defaultValue: const CustomExpression('0'));
i1.GeneratedColumn<DateTime> _column_64(String aliasedName) =>
i1.GeneratedColumn<DateTime>('memory_at', aliasedName, false,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<DateTime> _column_65(String aliasedName) =>
i1.GeneratedColumn<DateTime>('seen_at', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<DateTime> _column_66(String aliasedName) =>
i1.GeneratedColumn<DateTime>('show_at', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<DateTime> _column_67(String aliasedName) =>
i1.GeneratedColumn<DateTime>('hide_at', aliasedName, true,
type: i1.DriftSqlType.dateTime);
class Shape12 extends i0.VersionedTable {
Shape12({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get memoryId =>
columnsByName['memory_id']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_68(String aliasedName) =>
i1.GeneratedColumn<String>('memory_id', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES memory_entity (id) ON DELETE CASCADE'));
class Shape13 extends i0.VersionedTable {
Shape13({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get faceAssetId =>
columnsByName['face_asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get thumbnailPath =>
columnsByName['thumbnail_path']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get isHidden =>
columnsByName['is_hidden']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get color =>
columnsByName['color']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get birthDate =>
columnsByName['birth_date']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_69(String aliasedName) =>
i1.GeneratedColumn<String>('face_asset_id', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_70(String aliasedName) =>
i1.GeneratedColumn<String>('thumbnail_path', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_71(String aliasedName) =>
i1.GeneratedColumn<bool>('is_favorite', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_favorite" IN (0, 1))'));
i1.GeneratedColumn<bool> _column_72(String aliasedName) =>
i1.GeneratedColumn<bool>('is_hidden', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_hidden" IN (0, 1))'));
i1.GeneratedColumn<String> _column_73(String aliasedName) =>
i1.GeneratedColumn<String>('color', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_74(String aliasedName) =>
i1.GeneratedColumn<DateTime>('birth_date', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = Schema2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
));

View File

@@ -326,16 +326,12 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return transaction(() async {
if (assetsToUnLink.isNotEmpty) {
await _db.batch((batch) {
for (final assetToUnLink in assetsToUnLink) {
batch.deleteWhere(
_db.localAlbumAssetEntity,
(row) =>
row.assetId.equals(assetToUnLink) &
row.albumId.equals(albumId),
);
}
});
await _db.batch(
(batch) => batch.deleteWhere(
_db.localAlbumAssetEntity,
(f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
),
);
}
await _deleteAssets(assetsToDelete);
@@ -363,9 +359,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
}
return _db.batch((batch) {
for (final id in ids) {
batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
}
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
});
}

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -45,9 +46,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
Future<void> delete(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final id in ids) {
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
for (final slice in ids.slices(32000)) {
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
}
});
}

View File

@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPersonRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPersonRepository(this._db) : super(_db);
Future<List<Person>> getAll(String userId) {
final query = _db.personEntity.select()
..where((e) => e.ownerId.equals(userId));
return query.map((person) {
return person.toDto();
}).get();
}
}
extension on PersonEntityData {
Person toDto() {
return Person(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
thumbnailPath: thumbnailPath,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
);
}
}

View File

@@ -121,15 +121,10 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
);
}
Future<void> removeAssets(String albumId, List<String> assetIds) {
return _db.batch((batch) {
for (final assetId in assetIds) {
batch.deleteWhere(
_db.remoteAlbumAssetEntity,
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
);
}
});
Future<int> removeAssets(String albumId, List<String> assetIds) {
return _db.remoteAlbumAssetEntity.deleteWhere(
(tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds),
);
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
@@ -165,7 +160,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final userIds = albumUserRows.map((row) => row.userId);
// TODO: remove this isIn() after removing UserDto
return (_db.select(_db.userEntity)..where((row) => row.id.isIn(userIds)))
.map(
(user) => UserDto(

View File

@@ -1,11 +1,13 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
hide ExifInfo;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -30,25 +32,66 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Stream<RemoteAsset?> watchAsset(String id) {
final query = _db.remoteAssetEntity
.select()
.addColumns([_db.localAssetEntity.id]).join([
final stackCountRef = _db.stackEntity.id.count();
final query = _db.remoteAssetEntity.select().addColumns([
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
stackCountRef,
]).join([
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.stackEntity,
_db.stackEntity.primaryAssetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity.createAlias('stacked_assets'),
_db.stackEntity.id.equalsExp(
_db.remoteAssetEntity.createAlias('stacked_assets').stackId,
),
useColumns: false,
),
])
..where(_db.remoteAssetEntity.id.equals(id));
..where(_db.remoteAssetEntity.id.equals(id))
..groupBy([
_db.remoteAssetEntity.id,
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
]);
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
final primaryAssetId = row.read(_db.stackEntity.primaryAssetId);
final stackCount =
primaryAssetId == id ? (row.read(stackCountRef) ?? 0) : 0;
return asset.copyWith(
localId: row.read(_db.localAssetEntity.id),
stackCount: stackCount,
);
}).watchSingleOrNull();
}
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) {
return Future.value([]);
}
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not(),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();
}
Future<ExifInfo?> getExif(String id) {
return _db.managers.remoteExifEntity
.filter((row) => row.assetId.id.equals(id))
@@ -129,14 +172,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future<void> delete(List<String> ids) {
return _db.batch((batch) {
for (final id in ids) {
batch.deleteWhere(
_db.remoteAssetEntity,
(row) => row.id.equals(id),
);
}
});
return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
}
Future<void> updateLocation(List<String> ids, LatLng location) {
@@ -153,4 +189,53 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
});
}
Future<void> stack(String userId, StackResponse stack) {
return _db.transaction(() async {
final stackIds = await _db.managers.stackEntity
.filter((row) => row.primaryAssetId.id.isIn(stack.assetIds))
.map((row) => row.id)
.get();
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
await _db.batch((batch) {
final companion = StackEntityCompanion(
ownerId: Value(userId),
primaryAssetId: Value(stack.primaryAssetId),
);
batch.insert(
_db.stackEntity,
companion.copyWith(id: Value(stack.id)),
onConflict: DoUpdate((_) => companion),
);
for (final assetId in stack.assetIds) {
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(
stackId: Value(stack.id),
),
where: (e) => e.id.equals(assetId),
);
}
});
});
}
Future<void> unStack(List<String> stackIds) {
return _db.transaction(() async {
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
// TODO: delete this after adding foreign key on stackId
await _db.batch((batch) {
batch.update(
_db.remoteAssetEntity,
const RemoteAssetEntityCompanion(stackId: Value(null)),
where: (e) => e.stackId.isIn(stackIds),
);
});
});
}
}

View File

@@ -0,0 +1,87 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'
hide AssetVisibility;
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:openapi/api.dart';
class SearchApiRepository extends ApiRepository {
final SearchApi _api;
const SearchApiRepository(this._api);
Future<SearchResponseDto?> search(SearchFilter filter, int page) {
AssetTypeEnum? type;
if (filter.mediaType.index == AssetType.image.index) {
type = AssetTypeEnum.IMAGE;
} else if (filter.mediaType.index == AssetType.video.index) {
type = AssetTypeEnum.VIDEO;
}
if (filter.context != null && filter.context!.isNotEmpty) {
return _api.searchSmart(
SmartSearchDto(
query: filter.context!,
language: filter.language,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}
return _api.searchAssets(
MetadataSearchDto(
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
? filter.filename
: null,
country: filter.location.country,
description:
filter.description != null && filter.description!.isNotEmpty
? filter.description
: null,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) =>
_api.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
}

View File

@@ -57,6 +57,7 @@ class SyncApiRepository {
SyncRequestType.stacksV1,
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
],
).toJson(),
);
@@ -173,6 +174,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson,
SyncEntityType.userMetadataV1: SyncUserMetadataV1.fromJson,
SyncEntityType.userMetadataDeleteV1: SyncUserMetadataDeleteV1.fromJson,
SyncEntityType.personV1: SyncPersonV1.fromJson,
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
};
class _SyncAckV1 {

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
@@ -29,14 +30,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final user in data) {
batch.deleteWhere(
_db.userEntity,
(row) => row.id.equals(user.userId),
);
}
});
await _db.userEntity
.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
} catch (error, stack) {
_logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow;
@@ -112,14 +107,9 @@ class SyncStreamRepository extends DriftDatabaseRepository {
String debugLabel = 'user',
}) async {
try {
await _db.batch((batch) {
for (final asset in data) {
batch.deleteWhere(
_db.remoteAssetEntity,
(row) => row.id.equals(asset.assetId),
);
}
});
await _db.remoteAssetEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.assetId)),
);
} catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow;
@@ -148,6 +138,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()),
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
);
batch.insert(
@@ -213,14 +204,9 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final album in data) {
batch.deleteWhere(
_db.remoteAlbumEntity,
(row) => row.id.equals(album.albumId),
);
}
});
await _db.remoteAlbumEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.albumId)),
);
} catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow;
@@ -387,14 +373,9 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final memory in data) {
batch.deleteWhere(
_db.memoryEntity,
(row) => row.id.equals(memory.memoryId),
);
}
});
await _db.memoryEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.memoryId)),
);
} catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow;
@@ -476,14 +457,9 @@ class SyncStreamRepository extends DriftDatabaseRepository {
String debugLabel = 'user',
}) async {
try {
await _db.batch((batch) {
for (final stack in data) {
batch.deleteWhere(
_db.stackEntity,
(row) => row.id.equals(stack.stackId),
);
}
});
await _db.stackEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.stackId)),
);
} catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
rethrow;
@@ -536,6 +512,48 @@ class SyncStreamRepository extends DriftDatabaseRepository {
rethrow;
}
}
Future<void> updatePeopleV1(Iterable<SyncPersonV1> data) async {
try {
await _db.batch((batch) {
for (final person in data) {
final companion = PersonEntityCompanion(
createdAt: Value(person.createdAt),
updatedAt: Value(person.updatedAt),
ownerId: Value(person.ownerId),
name: Value(person.name),
faceAssetId: Value(person.faceAssetId),
thumbnailPath: Value(person.thumbnailPath),
isFavorite: Value(person.isFavorite),
isHidden: Value(person.isHidden),
color: Value(person.color),
birthDate: Value(person.birthDate),
);
batch.insert(
_db.personEntity,
companion.copyWith(id: Value(person.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updatePeopleV1', error, stack);
rethrow;
}
}
Future<void> deletePeopleV1(
Iterable<SyncPersonDeleteV1> data,
) async {
try {
await _db.personEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.personId)),
);
} catch (error, stack) {
_logger.severe('Error: deletePeopleV1', error, stack);
}
}
}
extension on AssetTypeEnum {

View File

@@ -89,6 +89,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
stackCount: row.stackCount,
)
: LocalAsset(
id: row.localId!,
@@ -165,15 +167,25 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
],
)
..addColumns([_db.remoteAssetEntity.id])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
return asset.copyWith(
remoteId: row.read(_db.remoteAssetEntity.id),
);
}).get();
}
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
@@ -292,6 +304,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.get();
}
TimelineQuery fromAssets(List<BaseAsset> assets) => (
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
assetSource: (offset, count) =>
Future.value(assets.skip(offset).take(count).toList()),
);
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
_remoteQueryBuilder(
filter: (row) =>

View File

@@ -237,7 +237,7 @@ class SearchFilter {
String? filename;
String? description;
String? language;
Set<Person> people;
Set<PersonDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@@ -282,7 +282,7 @@ class SearchFilter {
String? filename,
String? description,
String? language,
Set<Person>? people,
Set<PersonDto>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,

View File

@@ -0,0 +1,132 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:permission_handler/permission_handler.dart';
@RoutePage()
class ChangeExperiencePage extends ConsumerStatefulWidget {
final bool switchingToBeta;
const ChangeExperiencePage({super.key, required this.switchingToBeta});
@override
ConsumerState createState() => _ChangeExperiencePageState();
}
class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
bool hasMigrated = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _handleMigration());
}
Future<void> _handleMigration() async {
if (widget.switchingToBeta) {
final assetNotifier = ref.read(assetProvider.notifier);
if (assetNotifier.mounted) {
assetNotifier.dispose();
}
final albumNotifier = ref.read(albumProvider.notifier);
if (albumNotifier.mounted) {
albumNotifier.dispose();
}
final permission = await ref
.read(galleryPermissionNotifier.notifier)
.requestGalleryPermission();
if (permission.isGranted) {
await ref.read(backgroundSyncProvider).syncLocal(full: true);
await migrateDeviceAssetToSqlite(
ref.read(isarProvider),
ref.read(driftProvider),
);
}
} else {
await ref.read(backgroundSyncProvider).cancel();
}
if (mounted) {
setState(() {
HapticFeedback.heavyImpact();
hasMigrated = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated
? const Icon(
Icons.check_circle_rounded,
color: Colors.green,
size: 48.0,
)
: const SizedBox(
width: 50.0,
height: 50.0,
child: CircularProgressIndicator(),
),
),
const SizedBox(height: 16.0),
Center(
child: Column(
children: [
SizedBox(
width: 300.0,
child: AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated
? Text(
"Migration success!",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
)
: Text(
"Data migration in progress...\nPlease wait and don't close this page",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
if (hasMigrated)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton(
onPressed: () {
context.replaceRoute(
widget.switchingToBeta
? const TabShellRoute()
: const TabControllerRoute(),
);
},
child: const Text("Continue"),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -125,7 +125,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final asset = loadAsset(currentIndex.value);
if (asset.isRemote) {
ref.read(castProvider.notifier).loadMedia(asset, false);
ref.read(castProvider.notifier).loadMediaOld(asset, false);
} else {
if (isCasting) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -394,7 +394,7 @@ class GalleryViewerPage extends HookConsumerWidget {
// send image to casting if the server has it
if (newAsset.isRemote) {
ref.read(castProvider.notifier).loadMedia(newAsset, false);
ref.read(castProvider.notifier).loadMediaOld(newAsset, false);
} else {
context.scaffoldMessenger.clearSnackBars();

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart';
@@ -94,55 +95,59 @@ class _MobileLayout extends StatelessWidget {
const _MobileLayout();
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 10.0),
children: SettingSection.values
.map(
(setting) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
final List<Widget> settings = SettingSection.values
.map(
(setting) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
color: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
color: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
leading: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.isDarkTheme
? Colors.black26
: Colors.white.withAlpha(100),
),
leading: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.isDarkTheme
? Colors.black26
: Colors.white.withAlpha(100),
),
padding: const EdgeInsets.all(16.0),
child: Icon(setting.icon, color: context.primaryColor),
),
title: Text(
setting.title,
style: context.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
subtitle: Text(
setting.subtitle,
style: context.textTheme.labelLarge,
).tr(),
onTap: () =>
context.pushRoute(SettingsSubRoute(section: setting)),
padding: const EdgeInsets.all(16.0),
child: Icon(setting.icon, color: context.primaryColor),
),
title: Text(
setting.title,
style: context.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
subtitle: Text(
setting.subtitle,
style: context.textTheme.labelLarge,
).tr(),
onTap: () =>
context.pushRoute(SettingsSubRoute(section: setting)),
),
),
)
.toList(),
),
)
.toList();
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(top: 10.0, bottom: 56),
children: [
const BetaTimelineListTile(),
...settings,
],
);
}
}

View File

@@ -73,7 +73,15 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
if (context.router.current.name == SplashScreenRoute.name) {
context.replaceRoute(const TabControllerRoute());
context.replaceRoute(
Store.isBetaTimelineEnabled
? const TabShellRoute()
: const TabControllerRoute(),
);
}
if (Store.isBetaTimelineEnabled) {
return;
}
final hasPermission =

View File

@@ -9,39 +9,31 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart';
@RoutePage()
class TabShellPage extends ConsumerWidget {
class TabShellPage extends ConsumerStatefulWidget {
const TabShellPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScreenLandscape = context.orientation == Orientation.landscape;
ConsumerState<TabShellPage> createState() => _TabShellPageState();
}
Widget buildIcon({required Widget icon, required bool isProcessing}) {
if (!isProcessing) return icon;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -18,
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
context.primaryColor,
),
),
),
),
],
);
}
class _TabShellPageState extends ConsumerState<TabShellPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(websocketProvider.notifier).connect();
runNewSync(ref, full: true);
});
}
@override
Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape;
final navigationDestinations = [
NavigationDestination(
@@ -49,12 +41,9 @@ class TabShellPage extends ConsumerWidget {
icon: const Icon(
Icons.photo_library_outlined,
),
selectedIcon: buildIcon(
isProcessing: false,
icon: Icon(
Icons.photo_library,
color: context.primaryColor,
),
selectedIcon: Icon(
Icons.photo_library,
color: context.primaryColor,
),
),
NavigationDestination(
@@ -72,12 +61,9 @@ class TabShellPage extends ConsumerWidget {
icon: const Icon(
Icons.photo_album_outlined,
),
selectedIcon: buildIcon(
isProcessing: false,
icon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
selectedIcon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
),
NavigationDestination(
@@ -85,12 +71,9 @@ class TabShellPage extends ConsumerWidget {
icon: const Icon(
Icons.space_dashboard_outlined,
),
selectedIcon: buildIcon(
isProcessing: false,
icon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
selectedIcon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
),
];
@@ -117,7 +100,7 @@ class TabShellPage extends ConsumerWidget {
return AutoTabsRouter(
routes: [
const MainTimelineRoute(),
SearchRoute(),
DriftSearchRoute(),
const DriftAlbumsRoute(),
const DriftLibraryRoute(),
],
@@ -167,7 +150,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
// Album page
if (index == 2) {
ref.read(remoteAlbumProvider.notifier).getAll();
ref.read(remoteAlbumProvider.notifier).refresh();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();

View File

@@ -1,13 +1,14 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useState;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/local_auth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@RoutePage()
class PinAuthPage extends HookConsumerWidget {
@@ -19,6 +20,7 @@ class PinAuthPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final localAuthState = ref.watch(localAuthProvider);
final showPinRegistrationForm = useState(createPinCode);
final isBetaTimeline = Store.isBetaTimelineEnabled;
Future<void> registerBiometric(String pinCode) async {
final isRegistered =
@@ -39,7 +41,11 @@ class PinAuthPage extends HookConsumerWidget {
),
);
context.replaceRoute(const LockedRoute());
if (isBetaTimeline) {
context.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
}
}
@@ -93,8 +99,14 @@ class PinAuthPage extends HookConsumerWidget {
Center(
child: PinVerificationForm(
autoFocus: true,
onSuccess: (_) =>
context.replaceRoute(const LockedRoute()),
onSuccess: (_) {
if (isBetaTimeline) {
context
.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
},
),
),
const SizedBox(height: 24),

View File

@@ -147,7 +147,7 @@ class SearchPage extends HookConsumerWidget {
);
showPeoplePicker() {
handleOnSelect(Set<Person> value) {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(
people: value,
);

View File

@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
@@ -75,7 +76,9 @@ class ShareIntentPage extends HookConsumerWidget {
leading: IconButton(
onPressed: () {
context.navigateTo(
const TabControllerRoute(),
Store.isBetaTimelineEnabled
? const TabShellRoute()
: const TabControllerRoute(),
);
},
icon: const Icon(Icons.arrow_back),

View File

@@ -22,16 +22,6 @@ final _features = [
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Video',
icon: Icons.video_collection_outlined,
onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
),
_Feature(
name: 'Recently Taken',
icon: Icons.schedule_outlined,
onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
@@ -122,6 +112,7 @@ final _features = [
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
},
),
_Feature(

View File

@@ -13,7 +13,7 @@ class MainTimelinePage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
return memoryLaneProvider.when(
return memoryLaneProvider.maybeWhen(
data: (memories) {
return memories.isEmpty
? const Timeline(showStorageIndicator: true)
@@ -26,8 +26,7 @@ class MainTimelinePage extends ConsumerWidget {
showStorageIndicator: true,
);
},
loading: () => const Timeline(showStorageIndicator: true),
error: (error, stackTrace) => const Timeline(showStorageIndicator: true),
orElse: () => const Timeline(showStorageIndicator: true),
);
}
}

View File

@@ -166,6 +166,10 @@ final _remoteStats = [
name: 'Stacks',
load: (db) => db.managers.stackEntity.count(),
),
_Stat(
name: 'People',
load: (db) => db.managers.personEntity.count(),
),
];
@RoutePage()

View File

@@ -40,7 +40,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
// Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(remoteAlbumProvider.notifier).getAll();
ref.read(remoteAlbumProvider.notifier).refresh();
});
searchController.addListener(() {
@@ -88,17 +88,20 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override
Widget build(BuildContext context) {
final albumState = ref.watch(remoteAlbumProvider);
final albums = albumState.filteredAlbums;
final isLoading = albumState.isLoading;
final error = albumState.error;
final albums =
ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
final userId = ref.watch(currentUserProvider)?.id;
return RefreshIndicator(
onRefresh: onRefresh,
edgeOffset: 100,
child: CustomScrollView(
slivers: [
ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
actions: [
IconButton(
icon: const Icon(
@@ -133,14 +136,10 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
? _AlbumGrid(
albums: albums,
userId: userId,
isLoading: isLoading,
error: error,
)
: _AlbumList(
albums: albums,
userId: userId,
isLoading: isLoading,
error: error,
),
],
),
@@ -481,46 +480,15 @@ class _QuickSortAndViewMode extends StatelessWidget {
class _AlbumList extends ConsumerWidget {
const _AlbumList({
required this.isLoading,
required this.error,
required this.albums,
required this.userId,
});
final bool isLoading;
final String? error;
final List<RemoteAlbum> albums;
final String? userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (isLoading) {
return const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
),
);
}
if (error != null) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'Error loading albums: $error',
style: TextStyle(
color: context.colorScheme.error,
),
),
),
),
);
}
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
@@ -623,44 +591,13 @@ class _AlbumGrid extends StatelessWidget {
const _AlbumGrid({
required this.albums,
required this.userId,
required this.isLoading,
required this.error,
});
final List<RemoteAlbum> albums;
final String? userId;
final bool isLoading;
final String? error;
@override
Widget build(BuildContext context) {
if (isLoading) {
return const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
),
);
}
if (error != null) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'Error loading albums: $error',
style: TextStyle(
color: context.colorScheme.error,
),
),
),
),
);
}
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(

View File

@@ -4,14 +4,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftLockedFolderPage extends StatelessWidget {
class DriftLockedFolderPage extends ConsumerStatefulWidget {
const DriftLockedFolderPage({super.key});
@override
ConsumerState<DriftLockedFolderPage> createState() =>
_DriftLockedFolderPageState();
}
class _DriftLockedFolderPageState extends ConsumerState<DriftLockedFolderPage>
with WidgetsBindingObserver {
bool _showOverlay = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (mounted) {
setState(() {
_showOverlay = state != AppLifecycleState.resumed;
});
}
}
@override
Widget build(BuildContext context) {
return ProviderScope(
@@ -30,12 +61,18 @@ class DriftLockedFolderPage extends StatelessWidget {
},
),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(
title: 'locked_folder'.t(context: context),
),
bottomSheet: const LockedFolderBottomSheet(),
),
child: _showOverlay
? const SizedBox()
: PopScope(
onPopInvokedWithResult: (didPop, _) =>
didPop ? ref.read(authProvider.notifier).lockPinCode() : null,
child: Timeline(
appBar: MesmerizingSliverAppBar(
title: 'locked_folder'.t(context: context),
),
bottomSheet: const LockedFolderBottomSheet(),
),
),
);
}
}

View File

@@ -1,9 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftRecentlyTakenPage extends StatelessWidget {
@@ -29,7 +31,9 @@ class DriftRecentlyTakenPage extends StatelessWidget {
},
),
],
child: const Timeline(),
child: Timeline(
appBar: MesmerizingSliverAppBar(title: 'recently_taken'.t()),
),
);
}
}

View File

@@ -1,9 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftVideoPage extends StatelessWidget {
@@ -27,7 +29,9 @@ class DriftVideoPage extends StatelessWidget {
},
),
],
child: const Timeline(),
child: Timeline(
appBar: MesmerizingSliverAppBar(title: 'videos'.t()),
),
);
}
}

View File

@@ -30,6 +30,7 @@ class LocalTimelinePage extends StatelessWidget {
child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name),
bottomSheet: const LocalAlbumBottomSheet(),
showStorageIndicator: true,
),
);
}

View File

@@ -0,0 +1,925 @@
import 'dart:async';
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
@RoutePage()
class DriftSearchPage extends HookConsumerWidget {
const DriftSearchPage({super.key, this.preFilter});
final SearchFilter? preFilter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textSearchType = useState<TextSearchType>(TextSearchType.context);
final searchHintText =
useState<String>('sunrise_on_the_beach'.t(context: context));
final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
people: preFilter?.people ?? {},
location: preFilter?.location ?? SearchLocationFilter(),
camera: preFilter?.camera ?? SearchCameraFilter(),
date: preFilter?.date ?? SearchDateFilter(),
display: preFilter?.display ??
SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: preFilter?.mediaType ?? AssetType.other,
language:
"${context.locale.languageCode}-${context.locale.countryCode}",
),
);
final previousFilter = useState<SearchFilter?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(
message,
style: context.textTheme.labelLarge,
),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
search() async {
if (filter.value.isEmpty) {
return;
}
if (preFilter == null && filter.value == previousFilter.value) {
return;
}
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref
.watch(paginatedSearchProvider.notifier)
.search(filter.value);
if (!hasResult) {
context.showSnackBar(
searchInfoSnackBar('search_no_result'.t(context: context)),
);
}
previousFilter.value = filter.value;
isSearching.value = false;
}
loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref
.watch(paginatedSearchProvider.notifier)
.search(filter.value);
if (!hasResult) {
context.showSnackBar(
searchInfoSnackBar('search_no_more_result'.t(context: context)),
);
}
isSearching.value = false;
}
searchPreFilter() {
if (preFilter != null) {
Future.delayed(
Duration.zero,
() {
search();
if (preFilter!.location.city != null) {
locationCurrentFilterWidget.value = Text(
preFilter!.location.city!,
style: context.textTheme.labelLarge,
);
}
},
);
}
}
useEffect(
() {
Future.microtask(
() => ref.invalidate(paginatedSearchProvider),
);
searchPreFilter();
return null;
},
[],
);
showPeoplePicker() {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(
people: value,
);
peopleCurrentFilterWidget.value = Text(
value
.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context))
.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
people: {},
);
peopleCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.t(context: context),
expanded: true,
onSearch: search,
onClear: handleClear,
child: PeoplePicker(
onSelect: handleOnSelect,
filter: filter.value.people,
),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(
country: value['country'],
city: value['city'],
state: value['state'],
),
);
final locationText = <String>[];
if (value['country'] != null) {
locationText.add(value['country']!);
}
if (value['state'] != null) {
locationText.add(value['state']!);
}
if (value['city'] != null) {
locationText.add(value['city']!);
}
locationCurrentFilterWidget.value = Text(
locationText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(),
);
locationCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.t(context: context),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(
bottom: context.viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LocationPicker(
onSelected: handleOnSelect,
filter: filter.value.location,
),
),
),
),
),
);
}
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(
make: value['make'],
model: value['model'],
),
);
cameraCurrentFilterWidget.value = Text(
'${value['make'] ?? ''} ${value['model'] ?? ''}',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(),
);
cameraCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.t(context: context),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(
onSelect: handleOnSelect,
filter: filter.value.camera,
),
),
),
);
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
helpText: 'search_filter_date_title'.t(context: context),
cancelText: 'cancel'.t(context: context),
confirmText: 'select'.t(context: context),
saveText: 'save'.t(context: context),
errorFormatText: 'invalid_date_format'.t(context: context),
errorInvalidText: 'invalid_date'.t(context: context),
fieldStartHintText: 'start_date'.t(context: context),
fieldEndHintText: 'end_date'.t(context: context),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
);
if (date == null) {
filter.value = filter.value.copyWith(
date: SearchDateFilter(),
);
dateRangeCurrentFilterWidget.value = null;
search();
return;
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(
const Duration(
hours: 23,
minutes: 59,
seconds: 59,
),
),
),
);
// If date range is less than 24 hours, set the end date to the end of the day
if (date.end.difference(date.start).inHours < 24) {
dateRangeCurrentFilterWidget.value = Text(
DateFormat.yMMMd().format(date.start.toLocal()),
style: context.textTheme.labelLarge,
);
} else {
dateRangeCurrentFilterWidget.value = Text(
'search_filter_date_interval'.t(
context: context,
args: {
"start": DateFormat.yMMMd().format(date.start.toLocal()),
"end": DateFormat.yMMMd().format(date.end.toLocal()),
},
),
style: context.textTheme.labelLarge,
);
}
search();
}
// MEDIA PICKER
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
filter.value = filter.value.copyWith(
mediaType: assetType,
);
mediaTypeCurrentFilterWidget.value = Text(
assetType == AssetType.image
? 'image'.t(context: context)
: assetType == AssetType.video
? 'video'.t(context: context)
: 'all'.t(context: context),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
mediaType: AssetType.other,
);
mediaTypeCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.t(context: context),
onSearch: search,
onClear: handleClear,
child: MediaTypePicker(
onSelect: handleOnSelected,
filter: filter.value.mediaType,
),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
final filterText = <String>[];
value.forEach((key, value) {
switch (key) {
case DisplayOption.notInAlbum:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isNotInAlbum: value,
),
);
if (value) {
filterText.add(
'search_filter_display_option_not_in_album'
.t(context: context),
);
}
break;
case DisplayOption.archive:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isArchive: value,
),
);
if (value) {
filterText.add('archive'.t(context: context));
}
break;
case DisplayOption.favorite:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isFavorite: value,
),
);
if (value) {
filterText.add('favorite'.t(context: context));
}
break;
}
});
if (filterText.isEmpty) {
displayOptionCurrentFilterWidget.value = null;
return;
}
displayOptionCurrentFilterWidget.value = Text(
filterText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
);
displayOptionCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'display_options'.t(context: context),
onSearch: search,
onClear: handleClear,
child: DisplayOptionPicker(
onSelect: handleOnSelect,
filter: filter.value.display,
),
),
);
}
handleTextSubmitted(String value) {
switch (textSearchType.value) {
case TextSearchType.context:
filter.value = filter.value.copyWith(
filename: '',
context: value,
description: '',
);
break;
case TextSearchType.filename:
filter.value = filter.value.copyWith(
filename: value,
context: '',
description: '',
);
break;
case TextSearchType.description:
filter.value = filter.value.copyWith(
filename: '',
context: '',
description: value,
);
break;
}
search();
}
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined,
};
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
automaticallyImplyLeading: true,
actions: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: MenuAnchor(
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(24),
),
),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.all(4),
),
),
builder: (
BuildContext context,
MenuController controller,
Widget? child,
) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert_rounded),
tooltip: 'Show text search menu',
);
},
menuChildren: [
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.image_search_rounded),
title: Text(
'search_by_context'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context,
),
onPressed: () {
textSearchType.value = TextSearchType.context;
searchHintText.value =
'sunrise_on_the_beach'.t(context: context);
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.abc_rounded),
title: Text(
'search_filter_filename'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename,
),
onPressed: () {
textSearchType.value = TextSearchType.filename;
searchHintText.value =
'file_name_or_extension'.t(context: context);
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(
'search_by_description'.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color:
textSearchType.value == TextSearchType.description
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected:
textSearchType.value == TextSearchType.description,
),
onPressed: () {
textSearchType.value = TextSearchType.description;
searchHintText.value =
'search_by_description_example'.t(context: context);
},
),
],
),
),
],
title: Container(
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.onSurface.withAlpha(0),
width: 0,
),
borderRadius: const BorderRadius.all(
Radius.circular(24),
),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withValues(alpha: 0.075),
context.colorScheme.primary.withValues(alpha: 0.09),
context.colorScheme.primary.withValues(alpha: 0.075),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SearchField(
hintText: searchHintText.value,
key: const Key('search_text_field'),
controller: textSearchController,
contentPadding: preFilter != null
? const EdgeInsets.only(left: 24)
: const EdgeInsets.all(8),
prefixIcon: preFilter != null
? null
: Icon(
getSearchPrefixIcon(),
color: context.colorScheme.primary,
),
onSubmitted: handleTextSubmitted,
focusNode: ref.watch(searchInputFocusProvider),
),
),
),
body: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.only(top: 12.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 50,
child: ListView(
key: const Key('search_filter_chip_list'),
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SearchFilterChip(
icon: Icons.people_alt_outlined,
onTap: showPeoplePicker,
label: 'people'.t(context: context),
currentFilter: peopleCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.location_on_outlined,
onTap: showLocationPicker,
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_outlined,
onTap: showCameraPicker,
label: 'camera'.t(context: context),
currentFilter: cameraCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.date_range_outlined,
onTap: showDatePicker,
label: 'search_filter_date'.t(context: context),
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label:
'search_filter_display_options'.t(context: context),
currentFilter: displayOptionCurrentFilterWidget.value,
),
],
),
),
),
),
if (isSearching.value)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
],
),
);
}
}
class _SearchResultGrid extends ConsumerWidget {
final VoidCallback onScrollEnd;
const _SearchResultGrid({required this.onScrollEnd});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchResult = ref.watch(paginatedSearchProvider);
if (searchResult.totalAssets == 0) {
return const _SearchEmptyContent();
}
return NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification = notification.context
?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() !=
null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent &&
isVerticalScroll &&
!isBottomSheetNotification) {
onScrollEnd();
}
return true;
},
child: SliverFillRemaining(
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith(
(ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.fromAssets(searchResult.assets);
ref.onDispose(timelineService.dispose);
return timelineService;
},
),
],
child: Timeline(
key: ValueKey(searchResult.totalAssets),
appBar: null,
groupBy: GroupAssetsBy.none,
),
),
),
);
}
}
class _SearchEmptyContent extends StatelessWidget {
const _SearchEmptyContent();
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: ListView(
shrinkWrap: true,
children: [
const SizedBox(height: 40),
Center(
child: Image.asset(
context.isDarkTheme
? 'assets/polaroid-dark.png'
: 'assets/polaroid-light.png',
height: 125,
),
),
const SizedBox(height: 16),
Center(
child: Text(
'search_page_search_photos_videos'.t(context: context),
style: context.textTheme.labelLarge,
),
),
const SizedBox(height: 32),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _QuickLinkList(),
),
],
),
);
}
}
class _QuickLinkList extends StatelessWidget {
const _QuickLinkList();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(20),
),
border: Border.all(
color: context.colorScheme.outline.withAlpha(10),
width: 1,
),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_QuickLink(
title: 'recently_taken'.t(context: context),
icon: Icons.schedule_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()),
),
_QuickLink(
title: 'videos'.t(context: context),
icon: Icons.play_circle_outline_rounded,
onTap: () => context.pushRoute(const DriftVideoRoute()),
),
_QuickLink(
title: 'favorites'.t(context: context),
icon: Icons.favorite_border_rounded,
isBottom: true,
onTap: () => context.pushRoute(const DriftFavoriteRoute()),
),
],
),
);
}
}
class _QuickLink extends StatelessWidget {
final String title;
final IconData icon;
final VoidCallback onTap;
final bool isTop;
final bool isBottom;
const _QuickLink({
required this.title,
required this.icon,
required this.onTap,
this.isTop = false,
this.isBottom = false,
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.only(
topLeft: Radius.circular(isTop ? 20 : 0),
topRight: Radius.circular(isTop ? 20 : 0),
bottomLeft: Radius.circular(isBottom ? 20 : 0),
bottomRight: Radius.circular(isBottom ? 20 : 0),
);
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
leading: Icon(
icon,
size: 26,
),
title: Text(
title,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
final paginatedSearchProvider =
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
final SearchService _searchService;
PaginatedSearchNotifier(this._searchService)
: super(const SearchResult(assets: [], nextPage: 1));
Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
final result = await _searchService.search(filter, state.nextPage!);
if (result == null) {
return false;
}
state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
);
return true;
}
clear() {
state = const SearchResult(assets: [], nextPage: 1);
}
}

View File

@@ -6,6 +6,7 @@ class BaseActionButton extends StatelessWidget {
super.key,
required this.label,
required this.iconData,
this.iconColor,
this.onPressed,
this.onLongPressed,
this.maxWidth = 90.0,
@@ -15,6 +16,7 @@ class BaseActionButton extends StatelessWidget {
final String label;
final IconData iconData;
final Color? iconColor;
final double maxWidth;
final double? minWidth;
final bool menuItem;
@@ -27,7 +29,8 @@ class BaseActionButton extends StatelessWidget {
minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = iconTheme.color ?? context.themeData.iconTheme.color;
final iconColor =
this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (menuItem) {

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.menuItem = true});
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
return BaseActionButton(
iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
iconColor:
isCasting ? context.primaryColor : null, // null = default color
label: "cast".t(context: context),
onPressed: () {
showDialog(
context: context,
builder: (context) => const CastDialog(),
);
},
menuItem: menuItem,
);
}
}

View File

@@ -1,12 +1,49 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class ShareActionButton extends ConsumerWidget {
const ShareActionButton({super.key});
final ActionSource source;
const ShareActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).shareAssets(source);
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
} else if (result.count > 0) {
ImmichToast.show(
context: context,
msg: 'share_action_prompt'
.t(context: context, args: {'count': result.count.toString()}),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.success,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -14,6 +51,7 @@ class ShareActionButton extends ConsumerWidget {
iconData:
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: 'share'.t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -1,16 +1,56 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class StackActionButton extends ConsumerWidget {
const StackActionButton({super.key});
final ActionSource source;
const StackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access stack action');
}
final result =
await ref.read(actionProvider.notifier).stack(user.id, source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'stack_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "stack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget {
final ActionSource source;
const UnStackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unStack(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unstack_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "unstack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class StackChildrenNotifier
extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
@override
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
if (asset == null ||
asset is! RemoteAsset ||
asset.stackId == null ||
// The stackCount check is to ensure we only fetch stacks for timelines that have stacks
asset.stackCount == 0) {
return const [];
}
return ref.watch(assetServiceProvider).getStack(asset);
}
}
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(
StackChildrenNotifier.new,
);

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class AssetStackRow extends ConsumerWidget {
const AssetStackRow({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
int opacity = ref.watch(
assetViewerProvider.select((state) => state.backgroundOpacity),
);
final showControls =
ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0;
}
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: ref.watch(stackChildrenNotifier(asset)).when(
data: (state) => SizedBox.square(
dimension: 80,
child: _StackList(stack: state),
),
error: (_, __) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
);
}
}
class _StackList extends ConsumerWidget {
final List<RemoteAsset> stack;
const _StackList({required this.stack});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(
left: 5,
right: 5,
bottom: 30,
),
itemCount: stack.length,
itemBuilder: (ctx, index) {
final asset = stack[index];
return Padding(
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
ref.read(assetViewerProvider.notifier).setStackIndex(index);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
},
child: Container(
height: 60,
width: 60,
decoration: index ==
ref.watch(assetViewerProvider.select((s) => s.stackIndex))
? const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(
BorderSide(color: Colors.white, width: 2),
),
)
: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Stack(
fit: StackFit.expand,
children: [
Image(
fit: BoxFit.cover,
image: getThumbnailImageProvider(
remoteId: asset.id,
size: const Size.square(60),
),
),
if (asset.isVideo)
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
size: 16,
shadows: [
Shadow(
blurRadius: 5.0,
color: Color.fromRGBO(0, 0, 0, 0.6),
offset: Offset(0.0, 0.0),
),
],
),
],
),
),
),
),
);
},
);
}
}

View File

@@ -1,13 +1,17 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
@@ -18,8 +22,10 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart';
@@ -83,6 +89,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
int stackIndex = 0;
BuildContext? scaffoldContext;
Map<String, GlobalKey> videoPlayerKeys = {};
@@ -165,6 +172,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
// which could be stack children as well
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
@@ -184,6 +195,40 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
});
_delayedOperations.add(timer);
_handleCasting(asset);
}
void _handleCasting(BaseAsset asset) {
if (!ref.read(castProvider).isCasting) return;
// hide any casting snackbars if they exist
context.scaffoldMessenger.hideCurrentSnackBar();
// send image to casting if the server has it
if (asset.hasRemote) {
final remoteAsset = asset as RemoteAsset;
ref.read(castProvider.notifier).loadMedia(remoteAsset, false);
} else {
// casting cannot show local assets
context.scaffoldMessenger.clearSnackBars();
if (ref.read(castProvider).isCasting) {
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
"local_asset_cast_failed".tr(),
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
),
);
}
}
}
void _onPageBuild(PhotoViewControllerBase controller) {
@@ -452,7 +497,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageChunkEvent? progress,
int index,
) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren
.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
return Container(
width: double.infinity,
height: double.infinity,
@@ -480,9 +530,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
final asset = ref.read(timelineServiceProvider).getAsset(index);
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren
.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (asset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, asset);
}
@@ -568,8 +623,24 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider);
// Listen for casting changes and send initial asset to the cast provider
ref.listen(castProvider.select((value) => value.isCasting),
(_, isCasting) async {
if (!isCasting) return;
final asset = ref.read(currentAssetNotifier);
if (asset == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleCasting(asset);
});
});
final isInLockedView = ref.watch(inLockedViewProvider);
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
// Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable
@@ -596,7 +667,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
bottomNavigationBar: const ViewerBottomBar(),
bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink()
: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AssetStackRow(),
if (!isInLockedView) const ViewerBottomBar(),
],
),
),
);
}

View File

@@ -1,26 +1,40 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class AssetViewerState {
final int backgroundOpacity;
final bool showingBottomSheet;
final bool showingControls;
final BaseAsset? currentAsset;
final int stackIndex;
const AssetViewerState({
this.backgroundOpacity = 255,
this.showingBottomSheet = false,
this.showingControls = true,
this.currentAsset,
this.stackIndex = 0,
});
AssetViewerState copyWith({
int? backgroundOpacity,
bool? showingBottomSheet,
bool? showingControls,
BaseAsset? currentAsset,
int? stackIndex,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
showingControls: showingControls ?? this.showingControls,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
);
}
@@ -36,14 +50,18 @@ class AssetViewerState {
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingBottomSheet == showingBottomSheet &&
other.showingControls == showingControls;
other.showingControls == showingControls &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingBottomSheet.hashCode ^
showingControls.hashCode;
showingControls.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
@@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
return const AssetViewerState();
}
void setAsset(BaseAsset? asset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(int opacity) {
state = state.copyWith(
backgroundOpacity: opacity,
@@ -76,6 +98,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls);
}
void setStackIndex(int index) {
state = state.copyWith(stackIndex: index);
}
}
final assetViewerProvider =

View File

@@ -3,9 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -38,8 +36,7 @@ class ViewerBottomBar extends ConsumerWidget {
}
final actions = <Widget>[
const ShareActionButton(),
const _EditActionButton(),
const ShareActionButton(source: ActionSource.viewer),
if (asset.hasRemote && isOwner)
const ArchiveActionButton(source: ActionSource.viewer),
];
@@ -86,15 +83,3 @@ class ViewerBottomBar extends ConsumerWidget {
);
}
}
class _EditActionButton extends ConsumerWidget {
const _EditActionButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.tune_outlined,
label: 'edit'.t(context: context),
);
}
}

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
@@ -44,8 +45,10 @@ class AssetDetailBottomSheet extends ConsumerWidget {
serverInfoProvider.select((state) => state.serverFeatures.trash),
);
final isInLockedView = ref.watch(inLockedViewProvider);
final actions = <Widget>[
const ShareActionButton(),
const ShareActionButton(source: ActionSource.viewer),
if (asset.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.viewer),
const ArchiveActionButton(source: ActionSource.viewer),
@@ -63,8 +66,10 @@ class AssetDetailBottomSheet extends ConsumerWidget {
],
];
final lockedViewActions = <Widget>[];
return BaseBottomSheet(
actions: actions,
actions: isInLockedView ? lockedViewActions : actions,
slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,
@@ -73,6 +78,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
expand: false,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
);
}
}
@@ -84,14 +90,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final dateTime = asset.createdAt.toLocal();
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
return '$date$_kSeparator$time';
final timezone = dateTime.timeZoneOffset.isNegative
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
return '$date$_kSeparator$time $timezone';
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height ?? exifInfo?.height;
final width = asset.width ?? exifInfo?.width;
final resolution =
(width != null && height != null) ? "$width x $height" : null;
final resolution = (width != null && height != null)
? "${width.toInt()} x ${height.toInt()}"
: null;
final fileSize =
exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
@@ -150,46 +160,46 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
// Asset Date and Time
_SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16,
titleStyle: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SheetLocationDetails(),
// Details header
_SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context),
titleStyle: context.textTheme.labelLarge,
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
// File info
_SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.w600),
titleStyle: context.textTheme.labelLarge,
leading: Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 30,
size: 24,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
),
// Camera info
if (cameraTitle != null)
_SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.w600),
titleStyle: context.textTheme.labelLarge,
leading: Icon(
Icons.camera_outlined,
size: 30,
size: 24,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
),
],

View File

@@ -72,7 +72,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
// Guard no lat/lng
if (!hasCoordinates ||
(asset is LocalAsset && !(asset as LocalAsset).hasRemote)) {
(asset != null && asset is LocalAsset && asset!.hasRemote)) {
return const SizedBox.shrink();
}
@@ -95,7 +95,10 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
padding: const EdgeInsets.only(bottom: 16),
child: Text(
"exif_bottom_sheet_location".t(context: context),
style: context.textTheme.labelLarge,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
),
ExifMap(
@@ -109,15 +112,13 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
locationName,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
style: context.textTheme.labelLarge,
),
),
Text(
coordinates,
style: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(150),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(150),
),
),
],

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