diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh index 7dff4a0894..544674e169 100755 --- a/.devcontainer/server/container-common.sh +++ b/.devcontainer/server/container-common.sh @@ -73,10 +73,8 @@ install_dependencies() { log "Installing dependencies" ( cd "${IMMICH_WORKSPACE}" || exit 1 - run_cmd make ci-server - run_cmd make ci-sdk - run_cmd make build-sdk - run_cmd make ci-web + export CI=1 FROZEN=1 OFFLINE=1 + run_cmd make setup-web-dev setup-server-dev ) log "" } diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 4e340ae5bd..d7efc92cb1 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -22,7 +22,7 @@ services: immich-machine-learning: env_file: !reset [] - + database: env_file: !reset [] environment: !override @@ -31,7 +31,7 @@ services: POSTGRES_DB: ${DB_DATABASE_NAME-immich} POSTGRES_INITDB_ARGS: '--data-checksums' POSTGRES_HOST_AUTH_METHOD: md5 - volumes: + volumes: - ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data redis: diff --git a/.github/.prettierignore b/.github/.prettierignore new file mode 100644 index 0000000000..cc41cea9b2 --- /dev/null +++ b/.github/.prettierignore @@ -0,0 +1,4 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/Makefile b/Makefile index 207665d31c..815d1a153b 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ test-medium-dev: docker exec -it immich_server /bin/sh -c "npm run test:medium" build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ; -install-all: $(foreach M,$(MODULES),install-$M) ; +install-all: $(foreach M,$(MODULES),install-$M) ; ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ; check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ; lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ; @@ -106,4 +106,5 @@ clean: command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true -setup-dev: install-server install-sdk build-sdk install-web \ No newline at end of file +setup-server-dev: install-server +setup-web-dev: install-sdk build-sdk install-web diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 1d5004d385..cf9eafbf23 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -60,6 +60,7 @@ import { io, type Socket } from 'socket.io-client'; import { loginDto, signupDto } from 'src/fixtures'; import { makeRandomImage } from 'src/generators'; import request from 'supertest'; +export type { Emitter } from '@socket.io/component-emitter'; type CommandResponse = { stdout: string; stderr: string; exitCode: number | null }; type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden'; @@ -84,10 +85,10 @@ export const immichAdmin = (args: string[]) => export const specialCharStrings = ["'", '"', ',', '{', '}', '*']; export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; -const executeCommand = (command: string, args: string[]) => { +const executeCommand = (command: string, args: string[], options?: { cwd?: string }) => { let _resolve: (value: CommandResponse) => void; const promise = new Promise((resolve) => (_resolve = resolve)); - const child = spawn(command, args, { stdio: 'pipe' }); + const child = spawn(command, args, { stdio: 'pipe', cwd: options?.cwd }); let stdout = ''; let stderr = ''; diff --git a/i18n/en.json b/i18n/en.json index d81a6270ad..91a55cc85f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -719,6 +719,7 @@ "default_locale": "Default Locale", "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", + "delete_action_prompt": "{count} deleted permanently", "delete_album": "Delete album", "delete_api_key_prompt": "Are you sure you want to delete this API key?", "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", @@ -1842,6 +1843,7 @@ "total": "Total", "total_usage": "Total usage", "trash": "Trash", + "trash_action_prompt": "{count} moved to trash", "trash_all": "Trash All", "trash_count": "Trash {count, number}", "trash_delete_asset": "Trash/Delete Asset", @@ -1859,9 +1861,11 @@ "unable_to_change_pin_code": "Unable to change PIN code", "unable_to_setup_pin_code": "Unable to setup PIN code", "unarchive": "Unarchive", + "unarchive_action_prompt": "{count} removed from Archive", "unarchived_count": "{count, plural, other {Unarchived #}}", "undo": "Undo", "unfavorite": "Unfavorite", + "unfavorite_action_prompt": "{count} removed from Favorites", "unhide_person": "Unhide person", "unknown": "Unknown", "unknown_country": "Unknown Country", diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 4e40ede5af..7da2fd3920 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -517,16 +517,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.13" +version = "0.115.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/64/ec0788201b5554e2a87c49af26b77a4d132f807a0fa9675257ac92c6aa0e/fastapi-0.115.13.tar.gz", hash = "sha256:55d1d25c2e1e0a0a50aceb1c8705cd932def273c102bff0b1c1da88b3c6eb307", size = 295680, upload-time = "2025-06-17T11:49:45.575Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315, upload-time = "2025-06-17T11:49:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, ] [[package]] @@ -900,7 +900,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.33.0" +version = "0.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -912,9 +912,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/8a/1362d565fefabaa4185cf3ae842a98dbc5b35146f5694f7080f043a6952f/huggingface_hub-0.33.0.tar.gz", hash = "sha256:aa31f70d29439d00ff7a33837c03f1f9dd83971ce4e29ad664d63ffb17d3bb97", size = 426179, upload-time = "2025-06-11T17:08:07.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/8a95c5632080ae312c0498744b2b852195e10b05a20b1be11c5141092f4c/huggingface_hub-0.33.2.tar.gz", hash = "sha256:84221defaec8fa09c090390cd68c78b88e3c4c2b7befba68d3dc5aacbc3c2c5f", size = 426637, upload-time = "2025-07-02T06:26:05.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/fb/53587a89fbc00799e4179796f51b3ad713c5de6bb680b2becb6d37c94649/huggingface_hub-0.33.0-py3-none-any.whl", hash = "sha256:e8668875b40c68f9929150d99727d39e5ebb8a05a98e4191b908dc7ded9074b3", size = 514799, upload-time = "2025-06-11T17:08:05.757Z" }, + { url = "https://files.pythonhosted.org/packages/44/f4/5f3f22e762ad1965f01122b42dae5bf0e009286e2dba601ce1d0dba72424/huggingface_hub-0.33.2-py3-none-any.whl", hash = "sha256:3749498bfa91e8cde2ddc2c1db92c79981f40e66434c20133b39e5928ac9bcc5", size = 515373, upload-time = "2025-07-02T06:26:03.072Z" }, ] [[package]] @@ -1044,7 +1044,7 @@ requires-dist = [ { name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.15.0,<2" }, { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.15.0,<2" }, { name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.15.0,<2" }, - { name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.17.0,<2", index = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" }, + { name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.17.0,<2", index = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple" }, { name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.17.1,<1.19.0" }, { name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" }, { name = "orjson", specifier = ">=3.9.5" }, @@ -1568,7 +1568,7 @@ wheels = [ [[package]] name = "onnxruntime-gpu" version = "1.19.2" -source = { registry = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" } +source = { registry = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple" } dependencies = [ { name = "coloredlogs" }, { name = "flatbuffers" }, @@ -1936,16 +1936,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.9.1" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] [[package]] @@ -2304,27 +2304,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.0" +version = "0.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, - { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, - { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, - { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, - { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, - { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, - { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, - { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, - { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, - { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" }, + { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" }, + { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" }, + { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" }, + { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" }, + { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" }, + { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" }, ] [[package]] @@ -2504,27 +2504,27 @@ wheels = [ [[package]] name = "tokenizers" -version = "0.21.1" +version = "0.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/76/5ac0c97f1117b91b7eb7323dcd61af80d72f790b4df71249a7850c195f30/tokenizers-0.21.1.tar.gz", hash = "sha256:a1bb04dc5b448985f86ecd4b05407f5a8d97cb2c0532199b2a302a604a0165ab", size = 343256, upload-time = "2025-03-13T10:51:18.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/2d/b0fce2b8201635f60e8c95990080f58461cc9ca3d5026de2e900f38a7f21/tokenizers-0.21.2.tar.gz", hash = "sha256:fdc7cffde3e2113ba0e6cc7318c40e3438a4d74bbc62bf04bcc63bdfb082ac77", size = 351545, upload-time = "2025-06-24T10:24:52.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/1f/328aee25f9115bf04262e8b4e5a2050b7b7cf44b59c74e982db7270c7f30/tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41", size = 2780767, upload-time = "2025-03-13T10:51:09.459Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1a/4526797f3719b0287853f12c5ad563a9be09d446c44ac784cdd7c50f76ab/tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3", size = 2650555, upload-time = "2025-03-13T10:51:07.692Z" }, - { url = "https://files.pythonhosted.org/packages/4d/7a/a209b29f971a9fdc1da86f917fe4524564924db50d13f0724feed37b2a4d/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28da6b72d4fb14ee200a1bd386ff74ade8992d7f725f2bde2c495a9a98cf4d9f", size = 2937541, upload-time = "2025-03-13T10:50:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1e/b788b50ffc6191e0b1fc2b0d49df8cff16fe415302e5ceb89f619d12c5bc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34d8cfde551c9916cb92014e040806122295a6800914bab5865deb85623931cf", size = 2819058, upload-time = "2025-03-13T10:50:59.525Z" }, - { url = "https://files.pythonhosted.org/packages/36/aa/3626dfa09a0ecc5b57a8c58eeaeb7dd7ca9a37ad9dd681edab5acd55764c/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaa852d23e125b73d283c98f007e06d4595732104b65402f46e8ef24b588d9f8", size = 3133278, upload-time = "2025-03-13T10:51:04.678Z" }, - { url = "https://files.pythonhosted.org/packages/a4/4d/8fbc203838b3d26269f944a89459d94c858f5b3f9a9b6ee9728cdcf69161/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a21a15d5c8e603331b8a59548bbe113564136dc0f5ad8306dd5033459a226da0", size = 3144253, upload-time = "2025-03-13T10:51:01.261Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1b/2bd062adeb7c7511b847b32e356024980c0ffcf35f28947792c2d8ad2288/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdbd4c067c60a0ac7eca14b6bd18a5bebace54eb757c706b47ea93204f7a37c", size = 3398225, upload-time = "2025-03-13T10:51:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/8a/63/38be071b0c8e06840bc6046991636bcb30c27f6bb1e670f4f4bc87cf49cc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd9a0061e403546f7377df940e866c3e678d7d4e9643d0461ea442b4f89e61a", size = 3038874, upload-time = "2025-03-13T10:51:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/ec/83/afa94193c09246417c23a3c75a8a0a96bf44ab5630a3015538d0c316dd4b/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db9484aeb2e200c43b915a1a0150ea885e35f357a5a8fabf7373af333dcc8dbf", size = 9014448, upload-time = "2025-03-13T10:51:10.927Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b3/0e1a37d4f84c0f014d43701c11eb8072704f6efe8d8fc2dcdb79c47d76de/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed248ab5279e601a30a4d67bdb897ecbe955a50f1e7bb62bd99f07dd11c2f5b6", size = 8937877, upload-time = "2025-03-13T10:51:12.688Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/ff08f50e6d615eb180a4a328c65907feb6ded0b8f990ec923969759dc379/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9ac78b12e541d4ce67b4dfd970e44c060a2147b9b2a21f509566d556a509c67d", size = 9186645, upload-time = "2025-03-13T10:51:14.723Z" }, - { url = "https://files.pythonhosted.org/packages/5f/aa/8ae85f69a9f6012c6f8011c6f4aa1c96154c816e9eea2e1b758601157833/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5a69c1a4496b81a5ee5d2c1f3f7fbdf95e90a0196101b0ee89ed9956b8a168f", size = 9384380, upload-time = "2025-03-13T10:51:16.526Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5b/a5d98c89f747455e8b7a9504910c865d5e51da55e825a7ae641fb5ff0a58/tokenizers-0.21.1-cp39-abi3-win32.whl", hash = "sha256:1039a3a5734944e09de1d48761ade94e00d0fa760c0e0551151d4dd851ba63e3", size = 2239506, upload-time = "2025-03-13T10:51:20.643Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b6/072a8e053ae600dcc2ac0da81a23548e3b523301a442a6ca900e92ac35be/tokenizers-0.21.1-cp39-abi3-win_amd64.whl", hash = "sha256:0f0dcbcc9f6e13e675a66d7a5f2f225a736745ce484c1a4e07476a89ccdad382", size = 2435481, upload-time = "2025-03-13T10:51:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cc/2936e2d45ceb130a21d929743f1e9897514691bec123203e10837972296f/tokenizers-0.21.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:342b5dfb75009f2255ab8dec0041287260fed5ce00c323eb6bab639066fef8ec", size = 2875206, upload-time = "2025-06-24T10:24:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e6/33f41f2cc7861faeba8988e7a77601407bf1d9d28fc79c5903f8f77df587/tokenizers-0.21.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:126df3205d6f3a93fea80c7a8a266a78c1bd8dd2fe043386bafdd7736a23e45f", size = 2732655, upload-time = "2025-06-24T10:24:41.56Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1791eb329c07122a75b01035b1a3aa22ad139f3ce0ece1b059b506d9d9de/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a32cd81be21168bd0d6a0f0962d60177c447a1aa1b1e48fa6ec9fc728ee0b12", size = 3019202, upload-time = "2025-06-24T10:24:31.791Z" }, + { url = "https://files.pythonhosted.org/packages/05/15/fd2d8104faa9f86ac68748e6f7ece0b5eb7983c7efc3a2c197cb98c99030/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8bd8999538c405133c2ab999b83b17c08b7fc1b48c1ada2469964605a709ef91", size = 2934539, upload-time = "2025-06-24T10:24:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2e/53e8fd053e1f3ffbe579ca5f9546f35ac67cf0039ed357ad7ec57f5f5af0/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e9944e61239b083a41cf8fc42802f855e1dca0f499196df37a8ce219abac6eb", size = 3248665, upload-time = "2025-06-24T10:24:39.024Z" }, + { url = "https://files.pythonhosted.org/packages/00/15/79713359f4037aa8f4d1f06ffca35312ac83629da062670e8830917e2153/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:514cd43045c5d546f01142ff9c79a96ea69e4b5cda09e3027708cb2e6d5762ab", size = 3451305, upload-time = "2025-06-24T10:24:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/959f3a8756fc9396aeb704292777b84f02a5c6f25c3fc3ba7530db5feb2c/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1b9405822527ec1e0f7d8d2fdb287a5730c3a6518189c968254a8441b21faae", size = 3214757, upload-time = "2025-06-24T10:24:37.784Z" }, + { url = "https://files.pythonhosted.org/packages/c5/74/f41a432a0733f61f3d21b288de6dfa78f7acff309c6f0f323b2833e9189f/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed9a4d51c395103ad24f8e7eb976811c57fbec2af9f133df471afcd922e5020", size = 3121887, upload-time = "2025-06-24T10:24:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6a/bc220a11a17e5d07b0dfb3b5c628621d4dcc084bccd27cfaead659963016/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c41862df3d873665ec78b6be36fcc30a26e3d4902e9dd8608ed61d49a48bc19", size = 9091965, upload-time = "2025-06-24T10:24:44.431Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/ac386d79c4ef20dc6f39c4706640c24823dca7ebb6f703bfe6b5f0292d88/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed21dc7e624e4220e21758b2e62893be7101453525e3d23264081c9ef9a6d00d", size = 9053372, upload-time = "2025-06-24T10:24:46.455Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/5440bf203b2a5358f074408f7f9c42884849cd9972879e10ee6b7a8c3b3d/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:0e73770507e65a0e0e2a1affd6b03c36e3bc4377bd10c9ccf51a82c77c0fe365", size = 9298632, upload-time = "2025-06-24T10:24:48.446Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d2/faa1acac3f96a7427866e94ed4289949b2524f0c1878512516567d80563c/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:106746e8aa9014a12109e58d540ad5465b4c183768ea96c03cbc24c44d329958", size = 9470074, upload-time = "2025-06-24T10:24:50.378Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a5/896e1ef0707212745ae9f37e84c7d50269411aef2e9ccd0de63623feecdf/tokenizers-0.21.2-cp39-abi3-win32.whl", hash = "sha256:cabda5a6d15d620b6dfe711e1af52205266d05b379ea85a8a301b3593c60e962", size = 2330115, upload-time = "2025-06-24T10:24:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/cc2755ee10be859c4338c962a35b9a663788c0c0b50c0bdd8078fb6870cf/tokenizers-0.21.2-cp39-abi3-win_amd64.whl", hash = "sha256:58747bb898acdb1007f37a7bbe614346e98dc28708ffb66a3fd50ce169ac6c98", size = 2509918, upload-time = "2025-06-24T10:24:53.71Z" }, ] [[package]] @@ -2628,16 +2628,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.34.3" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] [package.optional-dependencies] diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 9ec0d763f7..5183c274b3 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -5,6 +5,7 @@ import android.content.Context import android.database.Cursor import android.provider.MediaStore import android.util.Log +import androidx.core.database.getStringOrNull import java.io.File import java.io.FileInputStream import java.security.MessageDigest @@ -152,7 +153,8 @@ open class NativeSyncApiImplBase(context: Context) { continue } - val name = cursor.getString(bucketNameColumn) + // MediaStore might return null for bucket name (commonly for the Root Directory), so default to "Internal Storage" + val name = cursor.getStringOrNull(bucketNameColumn) ?: "Internal Storage" val updatedAt = cursor.getLong(dateModified) albums.add(PlatformAlbum(id, name, updatedAt, false, 0)) albumsCount[id] = 1 diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 096128e526..32ba52e366 100644 --- a/mobile/drift_schemas/main/drift_schema_v1.json +++ b/mobile/drift_schemas/main/drift_schema_v1.json @@ -1 +1,1331 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":4,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":6,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":7,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":8,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":9,"references":[2,8],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":10,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":11,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":12,"references":[1,11],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":13,"references":[11,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}}]} \ No newline at end of file +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.2.0" + }, + "options": { "store_date_time_values_as_text": true }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "user_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_admin", + "getter_name": "isAdmin", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_admin\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_admin\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "email", + "getter_name": "email", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "profile_image_path", + "getter_name": "profileImagePath", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quota_size_in_bytes", + "getter_name": "quotaSizeInBytes", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quota_usage_in_bytes", + "getter_name": "quotaUsageInBytes", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["id"] + } + }, + { + "id": 1, + "references": [0], + "type": "table", + "data": { + "name": "remote_asset_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(AssetType.values)", + "dart_type_name": "AssetType" + } + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "width", + "getter_name": "width", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "height", + "getter_name": "height", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "duration_in_seconds", + "getter_name": "durationInSeconds", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checksum", + "getter_name": "checksum", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_favorite", + "getter_name": "isFavorite", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_favorite\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owner_id", + "getter_name": "ownerId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "local_date_time", + "getter_name": "localDateTime", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "thumb_hash", + "getter_name": "thumbHash", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "deleted_at", + "getter_name": "deletedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "visibility", + "getter_name": "visibility", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(AssetVisibility.values)", + "dart_type_name": "AssetVisibility" + } + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["id"] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "local_asset_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(AssetType.values)", + "dart_type_name": "AssetType" + } + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "width", + "getter_name": "width", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "height", + "getter_name": "height", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "duration_in_seconds", + "getter_name": "durationInSeconds", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checksum", + "getter_name": "checksum", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_favorite", + "getter_name": "isFavorite", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_favorite\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["id"] + } + }, + { + "id": 3, + "references": [2], + "type": "index", + "data": { + "on": 2, + "name": "idx_local_asset_checksum", + "sql": null, + "unique": false, + "columns": ["checksum"] + } + }, + { + "id": 4, + "references": [1], + "type": "index", + "data": { + "on": 1, + "name": "UQ_remote_asset_owner_checksum", + "sql": null, + "unique": true, + "columns": ["checksum", "owner_id"] + } + }, + { + "id": 5, + "references": [1], + "type": "index", + "data": { + "on": 1, + "name": "idx_remote_asset_checksum", + "sql": null, + "unique": false, + "columns": ["checksum"] + } + }, + { + "id": 6, + "references": [0], + "type": "table", + "data": { + "name": "user_metadata_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "user_id", + "getter_name": "userId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "preferences", + "getter_name": "preferences", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "userPreferenceConverter", + "dart_type_name": "UserPreferences" + } + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["user_id"] + } + }, + { + "id": 7, + "references": [0], + "type": "table", + "data": { + "name": "partner_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "shared_by_id", + "getter_name": "sharedById", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "shared_with_id", + "getter_name": "sharedWithId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "in_timeline", + "getter_name": "inTimeline", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"in_timeline\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"in_timeline\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["shared_by_id", "shared_with_id"] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "local_album_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "backup_selection", + "getter_name": "backupSelection", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(BackupSelection.values)", + "dart_type_name": "BackupSelection" + } + }, + { + "name": "is_ios_shared_album", + "getter_name": "isIosSharedAlbum", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_ios_shared_album\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_ios_shared_album\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "marker", + "getter_name": "marker_", + "moor_type": "bool", + "nullable": true, + "customConstraints": null, + "defaultConstraints": "CHECK (\"marker\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"marker\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["id"] + } + }, + { + "id": 9, + "references": [2, 8], + "type": "table", + "data": { + "name": "local_album_asset_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "asset_id", + "getter_name": "assetId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES local_asset_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES local_asset_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "album_id", + "getter_name": "albumId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES local_album_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES local_album_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["asset_id", "album_id"] + } + }, + { + "id": 10, + "references": [1], + "type": "table", + "data": { + "name": "remote_exif_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "asset_id", + "getter_name": "assetId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "city", + "getter_name": "city", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "state", + "getter_name": "state", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "country", + "getter_name": "country", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "date_time_original", + "getter_name": "dateTimeOriginal", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "height", + "getter_name": "height", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "width", + "getter_name": "width", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "exposure_time", + "getter_name": "exposureTime", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "f_number", + "getter_name": "fNumber", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "file_size", + "getter_name": "fileSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "focal_length", + "getter_name": "focalLength", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "latitude", + "getter_name": "latitude", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "longitude", + "getter_name": "longitude", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "iso", + "getter_name": "iso", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "make", + "getter_name": "make", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "model", + "getter_name": "model", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "orientation", + "getter_name": "orientation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "time_zone", + "getter_name": "timeZone", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rating", + "getter_name": "rating", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "projection_type", + "getter_name": "projectionType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["asset_id"] + } + }, + { + "id": 11, + "references": [0, 1], + "type": "table", + "data": { + "name": "remote_album_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'\\'')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owner_id", + "getter_name": "ownerId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "thumbnail_asset_id", + "getter_name": "thumbnailAssetId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE SET NULL", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE SET NULL" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "is_activity_enabled", + "getter_name": "isActivityEnabled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_activity_enabled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_activity_enabled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "order", + "getter_name": "order", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(AlbumAssetOrder.values)", + "dart_type_name": "AlbumAssetOrder" + } + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["id"] + } + }, + { + "id": 12, + "references": [1, 11], + "type": "table", + "data": { + "name": "remote_album_asset_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "asset_id", + "getter_name": "assetId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "album_id", + "getter_name": "albumId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES remote_album_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES remote_album_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["asset_id", "album_id"] + } + }, + { + "id": 13, + "references": [11, 0], + "type": "table", + "data": { + "name": "remote_album_user_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "album_id", + "getter_name": "albumId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES remote_album_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES remote_album_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "user_id", + "getter_name": "userId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "role", + "getter_name": "role", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(AlbumUserRole.values)", + "dart_type_name": "AlbumUserRole" + } + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["album_id", "user_id"] + } + }, + { + "id": 14, + "references": [0], + "type": "table", + "data": { + "name": "memory_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('CURRENT_TIMESTAMP')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "deleted_at", + "getter_name": "deletedAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owner_id", + "getter_name": "ownerId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES user_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES user_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(MemoryType.values)", + "dart_type_name": "MemoryType" + } + }, + { + "name": "data", + "getter_name": "data", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_saved", + "getter_name": "isSaved", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_saved\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_saved\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "memory_at", + "getter_name": "memoryAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seen_at", + "getter_name": "seenAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "show_at", + "getter_name": "showAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "hide_at", + "getter_name": "hideAt", + "moor_type": "dateTime", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["id"] + } + }, + { + "id": 15, + "references": [1, 14], + "type": "table", + "data": { + "name": "memory_asset_entity", + "was_declared_in_moor": false, + "columns": [ + { + "name": "asset_id", + "getter_name": "assetId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES remote_asset_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + }, + { + "name": "memory_id", + "getter_name": "memoryId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES memory_entity (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES memory_entity (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": ["unknown"] + } + ], + "is_virtual": false, + "without_rowid": true, + "constraints": [], + "strict": true, + "explicit_pk": ["asset_id", "memory_id"] + } + } + ] +} diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 9833f9a682..1226d1730f 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -1,5 +1,5 @@ -part 'remote_asset.model.dart'; part 'local_asset.model.dart'; +part 'remote_asset.model.dart'; enum AssetType { // do not change this order! @@ -40,7 +40,24 @@ sealed class BaseAsset { bool get isImage => type == AssetType.image; bool get isVideo => type == AssetType.video; + + double? get aspectRatio { + if (width != null && height != null && height! > 0) { + return width! / height!; + } + return null; + } + + bool get hasRemote => + storage == AssetState.remote || storage == AssetState.merged; + bool get hasLocal => + storage == AssetState.local || storage == AssetState.merged; + bool get isLocalOnly => storage == AssetState.local; + bool get isRemoteOnly => storage == AssetState.remote; + + // Overridden in subclasses AssetState get storage; + String get heroTag; @override String toString() { diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 95eb1bce9f..30a4955fa8 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -22,6 +22,9 @@ class LocalAsset extends BaseAsset { AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged; + @override + String get heroTag => '${id}_${remoteId ?? checksum}'; + @override String toString() { return '''LocalAsset { diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 608a03f2b2..d04f3340ac 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -36,6 +36,9 @@ class RemoteAsset extends BaseAsset { AssetState get storage => localId == null ? AssetState.remote : AssetState.merged; + @override + String get heroTag => '${localId ?? checksum}_$id'; + @override String toString() { return '''Asset { @@ -75,4 +78,38 @@ class RemoteAsset extends BaseAsset { localId.hashCode ^ thumbHash.hashCode ^ visibility.hashCode; + + RemoteAsset copyWith({ + String? id, + String? localId, + String? name, + String? ownerId, + String? checksum, + AssetType? type, + DateTime? createdAt, + DateTime? updatedAt, + int? width, + int? height, + int? durationInSeconds, + bool? isFavorite, + String? thumbHash, + AssetVisibility? visibility, + }) { + return RemoteAsset( + id: id ?? this.id, + localId: localId ?? this.localId, + name: name ?? this.name, + ownerId: ownerId ?? this.ownerId, + checksum: checksum ?? this.checksum, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + isFavorite: isFavorite ?? this.isFavorite, + thumbHash: thumbHash ?? this.thumbHash, + visibility: visibility ?? this.visibility, + ); + } } diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index e95653ca4e..b73aa4cae1 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -3,6 +3,8 @@ class ExifInfo { final int? fileSize; final String? description; final bool isFlipped; + final double? width; + final double? height; final String? orientation; final String? timeZone; final DateTime? dateTimeOriginal; @@ -45,6 +47,8 @@ class ExifInfo { this.fileSize, this.description, this.orientation, + this.width, + this.height, this.timeZone, this.dateTimeOriginal, this.isFlipped = false, @@ -68,6 +72,9 @@ class ExifInfo { return other.fileSize == fileSize && other.description == description && + other.isFlipped == isFlipped && + other.width == width && + other.height == height && other.orientation == orientation && other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && @@ -91,6 +98,9 @@ class ExifInfo { return fileSize.hashCode ^ description.hashCode ^ orientation.hashCode ^ + isFlipped.hashCode ^ + width.hashCode ^ + height.hashCode ^ timeZone.hashCode ^ dateTimeOriginal.hashCode ^ latitude.hashCode ^ @@ -114,6 +124,9 @@ class ExifInfo { fileSize: ${fileSize ?? 'NA'}, description: ${description ?? 'NA'}, orientation: ${orientation ?? 'NA'}, +width: ${width ?? 'NA'}, +height: ${height ?? 'NA'}, +isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, latitude: ${latitude ?? 'NA'}, diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart new file mode 100644 index 0000000000..ba2a43428f --- /dev/null +++ b/mobile/lib/domain/models/memory.model.dart @@ -0,0 +1,166 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:collection/collection.dart'; + +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +enum MemoryTypeEnum { + // do not change this order! + onThisDay, +} + +class MemoryData { + final int year; + + const MemoryData({ + required this.year, + }); + + MemoryData copyWith({ + int? year, + }) { + return MemoryData( + year: year ?? this.year, + ); + } + + Map toMap() { + return { + 'year': year, + }; + } + + factory MemoryData.fromMap(Map map) { + return MemoryData( + year: map['year'] as int, + ); + } + + String toJson() => json.encode(toMap()); + + factory MemoryData.fromJson(String source) => + MemoryData.fromMap(json.decode(source) as Map); + + @override + String toString() => 'MemoryData(year: $year)'; + + @override + bool operator ==(covariant MemoryData other) { + if (identical(this, other)) return true; + + return other.year == year; + } + + @override + int get hashCode => year.hashCode; +} + +// Model for a memory stored in the server +class DriftMemory { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + + // enum + final MemoryTypeEnum type; + final MemoryData data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + final List assets; + + const DriftMemory({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt, + required this.assets, + }); + + DriftMemory copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? deletedAt, + String? ownerId, + MemoryTypeEnum? type, + MemoryData? data, + bool? isSaved, + DateTime? memoryAt, + DateTime? seenAt, + DateTime? showAt, + DateTime? hideAt, + List? assets, + }) { + return DriftMemory( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + assets: assets ?? this.assets, + ); + } + + @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)'; + } + + @override + bool operator ==(covariant DriftMemory other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.deletedAt == deletedAt && + other.ownerId == ownerId && + other.type == type && + other.data == data && + other.isSaved == isSaved && + other.memoryAt == memoryAt && + other.seenAt == seenAt && + other.showAt == showAt && + other.hideAt == hideAt && + listEquals(other.assets, assets); + } + + @override + int get hashCode { + return id.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + deletedAt.hashCode ^ + ownerId.hashCode ^ + type.hashCode ^ + data.hashCode ^ + isSaved.hashCode ^ + memoryAt.hashCode ^ + seenAt.hashCode ^ + showAt.hashCode ^ + hideAt.hashCode ^ + assets.hashCode; + } +} diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index d975cbb4fe..a256ee3589 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -3,7 +3,11 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { tilesPerRow(StoreKey.tilesPerRow, 4), groupAssetsBy(StoreKey.groupAssetsBy, 0), - showStorageIndicator(StoreKey.storageIndicator, true); + showStorageIndicator(StoreKey.storageIndicator, true), + loadOriginal(StoreKey.loadOriginal, false), + preferRemoteImage(StoreKey.preferRemoteImage, false), + advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), + ; const Setting(this.storeKey, this.defaultValue); diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart new file mode 100644 index 0000000000..c4a0766601 --- /dev/null +++ b/mobile/lib/domain/services/asset.service.dart @@ -0,0 +1,30 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; + +class AssetService { + final RemoteAssetRepository _remoteAssetRepository; + final DriftLocalAssetRepository _localAssetRepository; + + const AssetService({ + required RemoteAssetRepository remoteAssetRepository, + required DriftLocalAssetRepository localAssetRepository, + }) : _remoteAssetRepository = remoteAssetRepository, + _localAssetRepository = localAssetRepository; + + Stream watchAsset(BaseAsset asset) { + final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; + return asset is LocalAsset + ? _localAssetRepository.watchAsset(id) + : _remoteAssetRepository.watchAsset(id); + } + + Future getExif(BaseAsset asset) async { + if (asset is LocalAsset || asset is! RemoteAsset) { + return null; + } + + return _remoteAssetRepository.getExif(asset.id); + } +} diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart new file mode 100644 index 0000000000..c94b8a9f0a --- /dev/null +++ b/mobile/lib/domain/services/memory.service.dart @@ -0,0 +1,15 @@ +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:logging/logging.dart'; + +class DriftMemoryService { + final log = Logger("DriftMemoryService"); + + final DriftMemoryRepository _repository; + + DriftMemoryService(this._repository); + + Future> getMemoryLane(String ownerId) { + return _repository.getAll(ownerId); + } +} diff --git a/mobile/lib/domain/services/setting.service.dart b/mobile/lib/domain/services/setting.service.dart index 2d1937be5a..8f91e9c66b 100644 --- a/mobile/lib/domain/services/setting.service.dart +++ b/mobile/lib/domain/services/setting.service.dart @@ -1,6 +1,11 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; +// Singleton instance of SettingsService, to use in places +// where reactivity is not required +// ignore: non_constant_identifier_names +final AppSetting = SettingsService(storeService: StoreService.I); + class SettingsService { final StoreService _storeService; diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 2160018df5..c4e40726b5 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -146,6 +146,14 @@ class SyncStreamService { // to acknowledge that the client has processed all the backfill events case SyncEntityType.syncAckV1: return; + case SyncEntityType.memoryV1: + return _syncStreamRepository.updateMemoriesV1(data.cast()); + case SyncEntityType.memoryDeleteV1: + return _syncStreamRepository.deleteMemoriesV1(data.cast()); + case SyncEntityType.memoryToAssetV1: + return _syncStreamRepository.updateMemoryAssetsV1(data.cast()); + case SyncEntityType.memoryToAssetDeleteV1: + return _syncStreamRepository.deleteMemoryAssetsV1(data.cast()); default: _logger.warning("Unknown sync data type: $type"); } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 56a12cac07..618ccd250f 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; @@ -57,14 +58,19 @@ class TimelineFactory { class TimelineService { final TimelineAssetSource _assetSource; final TimelineBucketSource _bucketSource; + int _totalAssets = 0; + int get totalAssets => _totalAssets; TimelineService({ required TimelineAssetSource assetSource, required TimelineBucketSource bucketSource, }) : _assetSource = assetSource, _bucketSource = bucketSource { - _bucketSubscription = - _bucketSource().listen((_) => unawaited(reloadBucket())); + _bucketSubscription = _bucketSource().listen((buckets) { + _totalAssets = + buckets.fold(0, (acc, bucket) => acc + bucket.assetCount); + unawaited(_reloadBucket()); + }); } final AsyncMutex _mutex = AsyncMutex(); @@ -74,8 +80,9 @@ class TimelineService { Stream> Function() get watchBuckets => _bucketSource; - Future reloadBucket() => _mutex.run(() async { + Future _reloadBucket() => _mutex.run(() async { _buffer = await _assetSource(_bufferOffset, _buffer.length); + EventStream.shared.emit(const TimelineReloadEvent()); }); Future> loadAssets(int index, int count) => @@ -117,6 +124,7 @@ class TimelineService { index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length; List getAssets(int index, int count) { + assert(index + count <= totalAssets); if (!hasRange(index, count)) { throw RangeError('TimelineService::getAssets Index out of range'); } @@ -124,6 +132,17 @@ class TimelineService { return _buffer.slice(start, start + count); } + // Pre-cache assets around the given index for asset viewer + Future preCacheAssets(int index) => + _mutex.run(() => _loadAssets(index, 5)); + + BaseAsset getAsset(int index) { + if (!hasRange(index, 1)) { + throw RangeError('TimelineService::getAsset Index out of range'); + } + return _buffer.elementAt(index - _bufferOffset); + } + Future dispose() async { await _bucketSubscription?.cancel(); _bucketSubscription = null; diff --git a/mobile/lib/domain/utils/event_stream.dart b/mobile/lib/domain/utils/event_stream.dart new file mode 100644 index 0000000000..65ee17e12b --- /dev/null +++ b/mobile/lib/domain/utils/event_stream.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +sealed class Event { + const Event(); +} + +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} + +class ViewerOpenBottomSheetEvent extends Event { + const ViewerOpenBottomSheetEvent(); +} + +class EventStream { + EventStream._(); + + static final EventStream shared = EventStream._(); + + final StreamController _controller = + StreamController.broadcast(); + + void emit(Event event) { + _controller.add(event); + } + + Stream where() { + if (T == Event) { + return _controller.stream as Stream; + } + return _controller.stream.where((event) => event is T).cast(); + } + + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return where().listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + /// Closes the stream controller + void dispose() { + _controller.close(); + } +} diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index c78643d89b..2ec8f0023f 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart' hide Query; import 'package:immich_mobile/domain/models/exif.model.dart' as domain; +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/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; @@ -132,6 +133,8 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin { TextColumn get model => text().nullable()(); + TextColumn get lens => text().nullable()(); + TextColumn get orientation => text().nullable()(); TextColumn get timeZone => text().nullable()(); @@ -143,3 +146,27 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin { @override Set get primaryKey => {assetId}; } + +extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { + domain.ExifInfo toDto() => domain.ExifInfo( + fileSize: fileSize, + dateTimeOriginal: dateTimeOriginal, + timeZone: timeZone, + make: make, + model: model, + iso: iso, + city: city, + state: state, + country: country, + description: description, + orientation: orientation, + latitude: latitude, + longitude: longitude, + f: fNumber?.toDouble(), + mm: focalLength?.toDouble(), + lens: lens, + width: width?.toDouble(), + height: height?.toDouble(), + isFlipped: ExifDtoConverter.isOrientationFlipped(orientation), + ); +} diff --git a/mobile/lib/infrastructure/entities/exif.entity.drift.dart b/mobile/lib/infrastructure/entities/exif.entity.drift.dart index a8fd3a4477..10712948ea 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.drift.dart @@ -27,6 +27,7 @@ typedef $$RemoteExifEntityTableCreateCompanionBuilder i0.Value iso, i0.Value make, i0.Value model, + i0.Value lens, i0.Value orientation, i0.Value timeZone, i0.Value rating, @@ -51,6 +52,7 @@ typedef $$RemoteExifEntityTableUpdateCompanionBuilder i0.Value iso, i0.Value make, i0.Value model, + i0.Value lens, i0.Value orientation, i0.Value timeZone, i0.Value rating, @@ -150,6 +152,9 @@ class $$RemoteExifEntityTableFilterComposer i0.ColumnFilters get model => $composableBuilder( column: $table.model, builder: (column) => i0.ColumnFilters(column)); + i0.ColumnFilters get lens => $composableBuilder( + column: $table.lens, builder: (column) => i0.ColumnFilters(column)); + i0.ColumnFilters get orientation => $composableBuilder( column: $table.orientation, builder: (column) => i0.ColumnFilters(column)); @@ -249,6 +254,9 @@ class $$RemoteExifEntityTableOrderingComposer i0.ColumnOrderings get model => $composableBuilder( column: $table.model, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get lens => $composableBuilder( + column: $table.lens, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get orientation => $composableBuilder( column: $table.orientation, builder: (column) => i0.ColumnOrderings(column)); @@ -345,6 +353,9 @@ class $$RemoteExifEntityTableAnnotationComposer i0.GeneratedColumn get model => $composableBuilder(column: $table.model, builder: (column) => column); + i0.GeneratedColumn get lens => + $composableBuilder(column: $table.lens, builder: (column) => column); + i0.GeneratedColumn get orientation => $composableBuilder( column: $table.orientation, builder: (column) => column); @@ -424,6 +435,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< i0.Value iso = const i0.Value.absent(), i0.Value make = const i0.Value.absent(), i0.Value model = const i0.Value.absent(), + i0.Value lens = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value timeZone = const i0.Value.absent(), i0.Value rating = const i0.Value.absent(), @@ -447,6 +459,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< iso: iso, make: make, model: model, + lens: lens, orientation: orientation, timeZone: timeZone, rating: rating, @@ -470,6 +483,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< i0.Value iso = const i0.Value.absent(), i0.Value make = const i0.Value.absent(), i0.Value model = const i0.Value.absent(), + i0.Value lens = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value timeZone = const i0.Value.absent(), i0.Value rating = const i0.Value.absent(), @@ -493,6 +507,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< iso: iso, make: make, model: model, + lens: lens, orientation: orientation, timeZone: timeZone, rating: rating, @@ -666,6 +681,12 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity late final i0.GeneratedColumn model = i0.GeneratedColumn( 'model', aliasedName, true, type: i0.DriftSqlType.string, requiredDuringInsert: false); + static const i0.VerificationMeta _lensMeta = + const i0.VerificationMeta('lens'); + @override + late final i0.GeneratedColumn lens = i0.GeneratedColumn( + 'lens', aliasedName, true, + type: i0.DriftSqlType.string, requiredDuringInsert: false); static const i0.VerificationMeta _orientationMeta = const i0.VerificationMeta('orientation'); @override @@ -709,6 +730,7 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity iso, make, model, + lens, orientation, timeZone, rating, @@ -803,6 +825,10 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity context.handle( _modelMeta, model.isAcceptableOrUnknown(data['model']!, _modelMeta)); } + if (data.containsKey('lens')) { + context.handle( + _lensMeta, lens.isAcceptableOrUnknown(data['lens']!, _lensMeta)); + } if (data.containsKey('orientation')) { context.handle( _orientationMeta, @@ -868,6 +894,8 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity .read(i0.DriftSqlType.string, data['${effectivePrefix}make']), model: attachedDatabase.typeMapping .read(i0.DriftSqlType.string, data['${effectivePrefix}model']), + lens: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}lens']), orientation: attachedDatabase.typeMapping .read(i0.DriftSqlType.string, data['${effectivePrefix}orientation']), timeZone: attachedDatabase.typeMapping @@ -909,6 +937,7 @@ class RemoteExifEntityData extends i0.DataClass final int? iso; final String? make; final String? model; + final String? lens; final String? orientation; final String? timeZone; final int? rating; @@ -931,6 +960,7 @@ class RemoteExifEntityData extends i0.DataClass this.iso, this.make, this.model, + this.lens, this.orientation, this.timeZone, this.rating, @@ -987,6 +1017,9 @@ class RemoteExifEntityData extends i0.DataClass if (!nullToAbsent || model != null) { map['model'] = i0.Variable(model); } + if (!nullToAbsent || lens != null) { + map['lens'] = i0.Variable(lens); + } if (!nullToAbsent || orientation != null) { map['orientation'] = i0.Variable(orientation); } @@ -1024,6 +1057,7 @@ class RemoteExifEntityData extends i0.DataClass iso: serializer.fromJson(json['iso']), make: serializer.fromJson(json['make']), model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), orientation: serializer.fromJson(json['orientation']), timeZone: serializer.fromJson(json['timeZone']), rating: serializer.fromJson(json['rating']), @@ -1051,6 +1085,7 @@ class RemoteExifEntityData extends i0.DataClass 'iso': serializer.toJson(iso), 'make': serializer.toJson(make), 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), 'orientation': serializer.toJson(orientation), 'timeZone': serializer.toJson(timeZone), 'rating': serializer.toJson(rating), @@ -1076,6 +1111,7 @@ class RemoteExifEntityData extends i0.DataClass i0.Value iso = const i0.Value.absent(), i0.Value make = const i0.Value.absent(), i0.Value model = const i0.Value.absent(), + i0.Value lens = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value timeZone = const i0.Value.absent(), i0.Value rating = const i0.Value.absent(), @@ -1101,6 +1137,7 @@ class RemoteExifEntityData extends i0.DataClass iso: iso.present ? iso.value : this.iso, make: make.present ? make.value : this.make, model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, orientation: orientation.present ? orientation.value : this.orientation, timeZone: timeZone.present ? timeZone.value : this.timeZone, rating: rating.present ? rating.value : this.rating, @@ -1132,6 +1169,7 @@ class RemoteExifEntityData extends i0.DataClass iso: data.iso.present ? data.iso.value : this.iso, make: data.make.present ? data.make.value : this.make, model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, orientation: data.orientation.present ? data.orientation.value : this.orientation, timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, @@ -1162,6 +1200,7 @@ class RemoteExifEntityData extends i0.DataClass ..write('iso: $iso, ') ..write('make: $make, ') ..write('model: $model, ') + ..write('lens: $lens, ') ..write('orientation: $orientation, ') ..write('timeZone: $timeZone, ') ..write('rating: $rating, ') @@ -1189,6 +1228,7 @@ class RemoteExifEntityData extends i0.DataClass iso, make, model, + lens, orientation, timeZone, rating, @@ -1215,6 +1255,7 @@ class RemoteExifEntityData extends i0.DataClass other.iso == this.iso && other.make == this.make && other.model == this.model && + other.lens == this.lens && other.orientation == this.orientation && other.timeZone == this.timeZone && other.rating == this.rating && @@ -1240,6 +1281,7 @@ class RemoteExifEntityCompanion final i0.Value iso; final i0.Value make; final i0.Value model; + final i0.Value lens; final i0.Value orientation; final i0.Value timeZone; final i0.Value rating; @@ -1262,6 +1304,7 @@ class RemoteExifEntityCompanion this.iso = const i0.Value.absent(), this.make = const i0.Value.absent(), this.model = const i0.Value.absent(), + this.lens = const i0.Value.absent(), this.orientation = const i0.Value.absent(), this.timeZone = const i0.Value.absent(), this.rating = const i0.Value.absent(), @@ -1285,6 +1328,7 @@ class RemoteExifEntityCompanion this.iso = const i0.Value.absent(), this.make = const i0.Value.absent(), this.model = const i0.Value.absent(), + this.lens = const i0.Value.absent(), this.orientation = const i0.Value.absent(), this.timeZone = const i0.Value.absent(), this.rating = const i0.Value.absent(), @@ -1308,6 +1352,7 @@ class RemoteExifEntityCompanion i0.Expression? iso, i0.Expression? make, i0.Expression? model, + i0.Expression? lens, i0.Expression? orientation, i0.Expression? timeZone, i0.Expression? rating, @@ -1331,6 +1376,7 @@ class RemoteExifEntityCompanion if (iso != null) 'iso': iso, if (make != null) 'make': make, if (model != null) 'model': model, + if (lens != null) 'lens': lens, if (orientation != null) 'orientation': orientation, if (timeZone != null) 'time_zone': timeZone, if (rating != null) 'rating': rating, @@ -1356,6 +1402,7 @@ class RemoteExifEntityCompanion i0.Value? iso, i0.Value? make, i0.Value? model, + i0.Value? lens, i0.Value? orientation, i0.Value? timeZone, i0.Value? rating, @@ -1378,6 +1425,7 @@ class RemoteExifEntityCompanion iso: iso ?? this.iso, make: make ?? this.make, model: model ?? this.model, + lens: lens ?? this.lens, orientation: orientation ?? this.orientation, timeZone: timeZone ?? this.timeZone, rating: rating ?? this.rating, @@ -1439,6 +1487,9 @@ class RemoteExifEntityCompanion if (model.present) { map['model'] = i0.Variable(model.value); } + if (lens.present) { + map['lens'] = i0.Variable(lens.value); + } if (orientation.present) { map['orientation'] = i0.Variable(orientation.value); } @@ -1474,6 +1525,7 @@ class RemoteExifEntityCompanion ..write('iso: $iso, ') ..write('make: $make, ') ..write('model: $model, ') + ..write('lens: $lens, ') ..write('orientation: $orientation, ') ..write('timeZone: $timeZone, ') ..write('rating: $rating, ') diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 39c3822b04..62f91ae458 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -28,5 +28,8 @@ extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { updatedAt: updatedAt, durationInSeconds: durationInSeconds, isFavorite: isFavorite, + height: height, + width: width, + remoteId: null, ); } diff --git a/mobile/lib/infrastructure/entities/memory.entity.dart b/mobile/lib/infrastructure/entities/memory.entity.dart new file mode 100644 index 0000000000..0e19802103 --- /dev/null +++ b/mobile/lib/infrastructure/entities/memory.entity.dart @@ -0,0 +1,36 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class MemoryEntity extends Table with DriftDefaultsMixin { + const MemoryEntity(); + + TextColumn get id => text()(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get deletedAt => dateTime().nullable()(); + + TextColumn get ownerId => + text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + IntColumn get type => intEnum()(); + + TextColumn get data => text()(); + + BoolColumn get isSaved => boolean().withDefault(const Constant(false))(); + + DateTimeColumn get memoryAt => dateTime()(); + + DateTimeColumn get seenAt => dateTime().nullable()(); + + DateTimeColumn get showAt => dateTime().nullable()(); + + DateTimeColumn get hideAt => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/memory.entity.drift.dart b/mobile/lib/infrastructure/entities/memory.entity.drift.dart new file mode 100644 index 0000000000..cb88651ba4 --- /dev/null +++ b/mobile/lib/infrastructure/entities/memory.entity.drift.dart @@ -0,0 +1,970 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/memory.model.dart' as i2; +import 'package:immich_mobile/infrastructure/entities/memory.entity.dart' as i3; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; + +typedef $$MemoryEntityTableCreateCompanionBuilder = i1.MemoryEntityCompanion + Function({ + required String id, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value deletedAt, + required String ownerId, + required i2.MemoryTypeEnum type, + required String data, + i0.Value isSaved, + required DateTime memoryAt, + i0.Value seenAt, + i0.Value showAt, + i0.Value hideAt, +}); +typedef $$MemoryEntityTableUpdateCompanionBuilder = i1.MemoryEntityCompanion + Function({ + i0.Value id, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value deletedAt, + i0.Value ownerId, + i0.Value type, + i0.Value data, + i0.Value isSaved, + i0.Value memoryAt, + i0.Value seenAt, + i0.Value showAt, + i0.Value hideAt, +}); + +final class $$MemoryEntityTableReferences extends i0.BaseReferences< + i0.GeneratedDatabase, i1.$MemoryEntityTable, i1.MemoryEntityData> { + $$MemoryEntityTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('user_entity') + .createAlias(i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer(db) + .resultSet('memory_entity') + .ownerId, + i6.ReadDatabaseContainer(db) + .resultSet('user_entity') + .id)); + + i5.$$UserEntityTableProcessedTableManager get ownerId { + final $_column = $_itemColumn('owner_id')!; + + final manager = i5 + .$$UserEntityTableTableManager( + $_db, + i6.ReadDatabaseContainer($_db) + .resultSet('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 $$MemoryEntityTableFilterComposer + extends i0.Composer { + $$MemoryEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnWithTypeConverterFilters + get type => $composableBuilder( + column: $table.type, + builder: (column) => i0.ColumnWithTypeConverterFilters(column)); + + i0.ColumnFilters get data => $composableBuilder( + column: $table.data, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get isSaved => $composableBuilder( + column: $table.isSaved, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get memoryAt => $composableBuilder( + column: $table.memoryAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get seenAt => $composableBuilder( + column: $table.seenAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get showAt => $composableBuilder( + column: $table.showAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get hideAt => $composableBuilder( + column: $table.hideAt, builder: (column) => i0.ColumnFilters(column)); + + i5.$$UserEntityTableFilterComposer get ownerId { + final i5.$$UserEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.ownerId, + referencedTable: i6.ReadDatabaseContainer($db) + .resultSet('user_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$UserEntityTableFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer($db) + .resultSet('user_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MemoryEntityTableOrderingComposer + extends i0.Composer { + $$MemoryEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get type => $composableBuilder( + column: $table.type, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get data => $composableBuilder( + column: $table.data, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get isSaved => $composableBuilder( + column: $table.isSaved, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get memoryAt => $composableBuilder( + column: $table.memoryAt, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get seenAt => $composableBuilder( + column: $table.seenAt, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get showAt => $composableBuilder( + column: $table.showAt, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get hideAt => $composableBuilder( + column: $table.hideAt, builder: (column) => i0.ColumnOrderings(column)); + + i5.$$UserEntityTableOrderingComposer get ownerId { + final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.ownerId, + referencedTable: i6.ReadDatabaseContainer($db) + .resultSet('user_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$UserEntityTableOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer($db) + .resultSet('user_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MemoryEntityTableAnnotationComposer + extends i0.Composer { + $$MemoryEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + i0.GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + i0.GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + i0.GeneratedColumn get data => + $composableBuilder(column: $table.data, builder: (column) => column); + + i0.GeneratedColumn get isSaved => + $composableBuilder(column: $table.isSaved, builder: (column) => column); + + i0.GeneratedColumn get memoryAt => + $composableBuilder(column: $table.memoryAt, builder: (column) => column); + + i0.GeneratedColumn get seenAt => + $composableBuilder(column: $table.seenAt, builder: (column) => column); + + i0.GeneratedColumn get showAt => + $composableBuilder(column: $table.showAt, builder: (column) => column); + + i0.GeneratedColumn get hideAt => + $composableBuilder(column: $table.hideAt, builder: (column) => column); + + i5.$$UserEntityTableAnnotationComposer get ownerId { + final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.ownerId, + referencedTable: i6.ReadDatabaseContainer($db) + .resultSet('user_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$UserEntityTableAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer($db) + .resultSet('user_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MemoryEntityTableTableManager extends i0.RootTableManager< + i0.GeneratedDatabase, + i1.$MemoryEntityTable, + i1.MemoryEntityData, + i1.$$MemoryEntityTableFilterComposer, + i1.$$MemoryEntityTableOrderingComposer, + i1.$$MemoryEntityTableAnnotationComposer, + $$MemoryEntityTableCreateCompanionBuilder, + $$MemoryEntityTableUpdateCompanionBuilder, + (i1.MemoryEntityData, i1.$$MemoryEntityTableReferences), + i1.MemoryEntityData, + i0.PrefetchHooks Function({bool ownerId})> { + $$MemoryEntityTableTableManager( + i0.GeneratedDatabase db, i1.$MemoryEntityTable table) + : super(i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$MemoryEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$$MemoryEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$$MemoryEntityTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + i0.Value id = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value deletedAt = const i0.Value.absent(), + i0.Value ownerId = const i0.Value.absent(), + i0.Value type = const i0.Value.absent(), + i0.Value data = const i0.Value.absent(), + i0.Value isSaved = const i0.Value.absent(), + i0.Value memoryAt = const i0.Value.absent(), + i0.Value seenAt = const i0.Value.absent(), + i0.Value showAt = const i0.Value.absent(), + i0.Value hideAt = const i0.Value.absent(), + }) => + i1.MemoryEntityCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + ownerId: ownerId, + type: type, + data: data, + isSaved: isSaved, + memoryAt: memoryAt, + seenAt: seenAt, + showAt: showAt, + hideAt: hideAt, + ), + createCompanionCallback: ({ + required String id, + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value deletedAt = const i0.Value.absent(), + required String ownerId, + required i2.MemoryTypeEnum type, + required String data, + i0.Value isSaved = const i0.Value.absent(), + required DateTime memoryAt, + i0.Value seenAt = const i0.Value.absent(), + i0.Value showAt = const i0.Value.absent(), + i0.Value hideAt = const i0.Value.absent(), + }) => + i1.MemoryEntityCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + ownerId: ownerId, + type: type, + data: data, + isSaved: isSaved, + memoryAt: memoryAt, + seenAt: seenAt, + showAt: showAt, + hideAt: hideAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + i1.$$MemoryEntityTableReferences(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.$$MemoryEntityTableReferences._ownerIdTable(db), + referencedColumn: + i1.$$MemoryEntityTableReferences._ownerIdTable(db).id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$MemoryEntityTableProcessedTableManager = i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$MemoryEntityTable, + i1.MemoryEntityData, + i1.$$MemoryEntityTableFilterComposer, + i1.$$MemoryEntityTableOrderingComposer, + i1.$$MemoryEntityTableAnnotationComposer, + $$MemoryEntityTableCreateCompanionBuilder, + $$MemoryEntityTableUpdateCompanionBuilder, + (i1.MemoryEntityData, i1.$$MemoryEntityTableReferences), + i1.MemoryEntityData, + i0.PrefetchHooks Function({bool ownerId})>; + +class $MemoryEntityTable extends i3.MemoryEntity + with i0.TableInfo<$MemoryEntityTable, i1.MemoryEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $MemoryEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _createdAtMeta = + const i0.VerificationMeta('createdAt'); + @override + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn('created_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime); + static const i0.VerificationMeta _updatedAtMeta = + const i0.VerificationMeta('updatedAt'); + @override + late final i0.GeneratedColumn updatedAt = + i0.GeneratedColumn('updated_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime); + static const i0.VerificationMeta _deletedAtMeta = + const i0.VerificationMeta('deletedAt'); + @override + late final i0.GeneratedColumn deletedAt = + i0.GeneratedColumn('deleted_at', aliasedName, true, + type: i0.DriftSqlType.dateTime, requiredDuringInsert: false); + static const i0.VerificationMeta _ownerIdMeta = + const i0.VerificationMeta('ownerId'); + @override + late final i0.GeneratedColumn ownerId = i0.GeneratedColumn( + 'owner_id', aliasedName, false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + @override + late final i0.GeneratedColumnWithTypeConverter type = + i0.GeneratedColumn('type', aliasedName, false, + type: i0.DriftSqlType.int, requiredDuringInsert: true) + .withConverter( + i1.$MemoryEntityTable.$convertertype); + static const i0.VerificationMeta _dataMeta = + const i0.VerificationMeta('data'); + @override + late final i0.GeneratedColumn data = i0.GeneratedColumn( + 'data', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _isSavedMeta = + const i0.VerificationMeta('isSaved'); + @override + late final i0.GeneratedColumn isSaved = i0.GeneratedColumn( + 'is_saved', aliasedName, false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + i0.GeneratedColumn.constraintIsAlways('CHECK ("is_saved" IN (0, 1))'), + defaultValue: const i4.Constant(false)); + static const i0.VerificationMeta _memoryAtMeta = + const i0.VerificationMeta('memoryAt'); + @override + late final i0.GeneratedColumn memoryAt = + i0.GeneratedColumn('memory_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, requiredDuringInsert: true); + static const i0.VerificationMeta _seenAtMeta = + const i0.VerificationMeta('seenAt'); + @override + late final i0.GeneratedColumn seenAt = i0.GeneratedColumn( + 'seen_at', aliasedName, true, + type: i0.DriftSqlType.dateTime, requiredDuringInsert: false); + static const i0.VerificationMeta _showAtMeta = + const i0.VerificationMeta('showAt'); + @override + late final i0.GeneratedColumn showAt = i0.GeneratedColumn( + 'show_at', aliasedName, true, + type: i0.DriftSqlType.dateTime, requiredDuringInsert: false); + static const i0.VerificationMeta _hideAtMeta = + const i0.VerificationMeta('hideAt'); + @override + late final i0.GeneratedColumn hideAt = i0.GeneratedColumn( + 'hide_at', aliasedName, true, + type: i0.DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable 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('deleted_at')) { + context.handle(_deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); + } + if (data.containsKey('owner_id')) { + context.handle(_ownerIdMeta, + ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta)); + } else if (isInserting) { + context.missing(_ownerIdMeta); + } + if (data.containsKey('data')) { + context.handle( + _dataMeta, this.data.isAcceptableOrUnknown(data['data']!, _dataMeta)); + } else if (isInserting) { + context.missing(_dataMeta); + } + if (data.containsKey('is_saved')) { + context.handle(_isSavedMeta, + isSaved.isAcceptableOrUnknown(data['is_saved']!, _isSavedMeta)); + } + if (data.containsKey('memory_at')) { + context.handle(_memoryAtMeta, + memoryAt.isAcceptableOrUnknown(data['memory_at']!, _memoryAtMeta)); + } else if (isInserting) { + context.missing(_memoryAtMeta); + } + if (data.containsKey('seen_at')) { + context.handle(_seenAtMeta, + seenAt.isAcceptableOrUnknown(data['seen_at']!, _seenAtMeta)); + } + if (data.containsKey('show_at')) { + context.handle(_showAtMeta, + showAt.isAcceptableOrUnknown(data['show_at']!, _showAtMeta)); + } + if (data.containsKey('hide_at')) { + context.handle(_hideAtMeta, + hideAt.isAcceptableOrUnknown(data['hide_at']!, _hideAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.MemoryEntityData( + 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'])!, + deletedAt: attachedDatabase.typeMapping + .read(i0.DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + ownerId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + type: i1.$MemoryEntityTable.$convertertype.fromSql(attachedDatabase + .typeMapping + .read(i0.DriftSqlType.int, data['${effectivePrefix}type'])!), + data: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}data'])!, + isSaved: attachedDatabase.typeMapping + .read(i0.DriftSqlType.bool, data['${effectivePrefix}is_saved'])!, + memoryAt: attachedDatabase.typeMapping + .read(i0.DriftSqlType.dateTime, data['${effectivePrefix}memory_at'])!, + seenAt: attachedDatabase.typeMapping + .read(i0.DriftSqlType.dateTime, data['${effectivePrefix}seen_at']), + showAt: attachedDatabase.typeMapping + .read(i0.DriftSqlType.dateTime, data['${effectivePrefix}show_at']), + hideAt: attachedDatabase.typeMapping + .read(i0.DriftSqlType.dateTime, data['${effectivePrefix}hide_at']), + ); + } + + @override + $MemoryEntityTable createAlias(String alias) { + return $MemoryEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 $convertertype = + const i0.EnumIndexConverter(i2.MemoryTypeEnum.values); + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends i0.DataClass + implements i0.Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final i2.MemoryTypeEnum type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['created_at'] = i0.Variable(createdAt); + map['updated_at'] = i0.Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = i0.Variable(deletedAt); + } + map['owner_id'] = i0.Variable(ownerId); + { + map['type'] = + i0.Variable(i1.$MemoryEntityTable.$convertertype.toSql(type)); + } + map['data'] = i0.Variable(data); + map['is_saved'] = i0.Variable(isSaved); + map['memory_at'] = i0.Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = i0.Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = i0.Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = i0.Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson(Map json, + {i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: i1.$MemoryEntityTable.$convertertype + .fromJson(serializer.fromJson(json['type'])), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer + .toJson(i1.$MemoryEntityTable.$convertertype.toJson(type)), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + i1.MemoryEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + i0.Value deletedAt = const i0.Value.absent(), + String? ownerId, + i2.MemoryTypeEnum? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + i0.Value seenAt = const i0.Value.absent(), + i0.Value showAt = const i0.Value.absent(), + i0.Value hideAt = const i0.Value.absent()}) => + i1.MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(i1.MemoryEntityCompanion data) { + return MemoryEntityData( + 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, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, deletedAt, ownerId, + type, data, isSaved, memoryAt, seenAt, showAt, hideAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value createdAt; + final i0.Value updatedAt; + final i0.Value deletedAt; + final i0.Value ownerId; + final i0.Value type; + final i0.Value data; + final i0.Value isSaved; + final i0.Value memoryAt; + final i0.Value seenAt; + final i0.Value showAt; + final i0.Value hideAt; + const MemoryEntityCompanion({ + this.id = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.deletedAt = const i0.Value.absent(), + this.ownerId = const i0.Value.absent(), + this.type = const i0.Value.absent(), + this.data = const i0.Value.absent(), + this.isSaved = const i0.Value.absent(), + this.memoryAt = const i0.Value.absent(), + this.seenAt = const i0.Value.absent(), + this.showAt = const i0.Value.absent(), + this.hideAt = const i0.Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.deletedAt = const i0.Value.absent(), + required String ownerId, + required i2.MemoryTypeEnum type, + required String data, + this.isSaved = const i0.Value.absent(), + required DateTime memoryAt, + this.seenAt = const i0.Value.absent(), + this.showAt = const i0.Value.absent(), + this.hideAt = const i0.Value.absent(), + }) : id = i0.Value(id), + ownerId = i0.Value(ownerId), + type = i0.Value(type), + data = i0.Value(data), + memoryAt = i0.Value(memoryAt); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? createdAt, + i0.Expression? updatedAt, + i0.Expression? deletedAt, + i0.Expression? ownerId, + i0.Expression? type, + i0.Expression? data, + i0.Expression? isSaved, + i0.Expression? memoryAt, + i0.Expression? seenAt, + i0.Expression? showAt, + i0.Expression? hideAt, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + i1.MemoryEntityCompanion copyWith( + {i0.Value? id, + i0.Value? createdAt, + i0.Value? updatedAt, + i0.Value? deletedAt, + i0.Value? ownerId, + i0.Value? type, + i0.Value? data, + i0.Value? isSaved, + i0.Value? memoryAt, + i0.Value? seenAt, + i0.Value? showAt, + i0.Value? hideAt}) { + return i1.MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = i0.Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = i0.Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = i0.Variable(ownerId.value); + } + if (type.present) { + map['type'] = i0.Variable( + i1.$MemoryEntityTable.$convertertype.toSql(type.value)); + } + if (data.present) { + map['data'] = i0.Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = i0.Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = i0.Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = i0.Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = i0.Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = i0.Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/entities/memory_asset.entity.dart b/mobile/lib/infrastructure/entities/memory_asset.entity.dart new file mode 100644 index 0000000000..c304b03724 --- /dev/null +++ b/mobile/lib/infrastructure/entities/memory_asset.entity.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class MemoryAssetEntity extends Table with DriftDefaultsMixin { + const MemoryAssetEntity(); + + TextColumn get assetId => + text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get memoryId => + text().references(MemoryEntity, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {assetId, memoryId}; +} diff --git a/mobile/lib/infrastructure/entities/memory_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/memory_asset.entity.drift.dart new file mode 100644 index 0000000000..9253e8bc05 --- /dev/null +++ b/mobile/lib/infrastructure/entities/memory_asset.entity.drift.dart @@ -0,0 +1,550 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart' + as i1; +import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart' + as i2; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i3; +import 'package:drift/internal/modular.dart' as i4; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' + as i5; + +typedef $$MemoryAssetEntityTableCreateCompanionBuilder + = i1.MemoryAssetEntityCompanion Function({ + required String assetId, + required String memoryId, +}); +typedef $$MemoryAssetEntityTableUpdateCompanionBuilder + = i1.MemoryAssetEntityCompanion Function({ + i0.Value assetId, + i0.Value memoryId, +}); + +final class $$MemoryAssetEntityTableReferences extends i0.BaseReferences< + i0.GeneratedDatabase, + i1.$MemoryAssetEntityTable, + i1.MemoryAssetEntityData> { + $$MemoryAssetEntityTableReferences( + super.$_db, super.$_table, super.$_typedResult); + + static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i4.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') + .createAlias(i0.$_aliasNameGenerator( + i4.ReadDatabaseContainer(db) + .resultSet('memory_asset_entity') + .assetId, + i4.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') + .id)); + + i3.$$RemoteAssetEntityTableProcessedTableManager get assetId { + final $_column = $_itemColumn('asset_id')!; + + final manager = i3 + .$$RemoteAssetEntityTableTableManager( + $_db, + i4.ReadDatabaseContainer($_db) + .resultSet('remote_asset_entity')) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } + + static i5.$MemoryEntityTable _memoryIdTable(i0.GeneratedDatabase db) => + i4.ReadDatabaseContainer(db) + .resultSet('memory_entity') + .createAlias(i0.$_aliasNameGenerator( + i4.ReadDatabaseContainer(db) + .resultSet('memory_asset_entity') + .memoryId, + i4.ReadDatabaseContainer(db) + .resultSet('memory_entity') + .id)); + + i5.$$MemoryEntityTableProcessedTableManager get memoryId { + final $_column = $_itemColumn('memory_id')!; + + final manager = i5 + .$$MemoryEntityTableTableManager( + $_db, + i4.ReadDatabaseContainer($_db) + .resultSet('memory_entity')) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_memoryIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$MemoryAssetEntityTableFilterComposer + extends i0.Composer { + $$MemoryAssetEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i3.$$RemoteAssetEntityTableFilterComposer get assetId { + final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i3.$$RemoteAssetEntityTableFilterComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + i5.$$MemoryEntityTableFilterComposer get memoryId { + final i5.$$MemoryEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.memoryId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('memory_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$MemoryEntityTableFilterComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet('memory_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MemoryAssetEntityTableOrderingComposer + extends i0.Composer { + $$MemoryAssetEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i3.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i3.$$RemoteAssetEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i3.$$RemoteAssetEntityTableOrderingComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet( + 'remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + i5.$$MemoryEntityTableOrderingComposer get memoryId { + final i5.$$MemoryEntityTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.memoryId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('memory_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$MemoryEntityTableOrderingComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet('memory_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MemoryAssetEntityTableAnnotationComposer + extends i0.Composer { + $$MemoryAssetEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i3.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i3.$$RemoteAssetEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i3.$$RemoteAssetEntityTableAnnotationComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet( + 'remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + i5.$$MemoryEntityTableAnnotationComposer get memoryId { + final i5.$$MemoryEntityTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.memoryId, + referencedTable: i4.ReadDatabaseContainer($db) + .resultSet('memory_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i5.$$MemoryEntityTableAnnotationComposer( + $db: $db, + $table: i4.ReadDatabaseContainer($db) + .resultSet('memory_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MemoryAssetEntityTableTableManager extends i0.RootTableManager< + i0.GeneratedDatabase, + i1.$MemoryAssetEntityTable, + i1.MemoryAssetEntityData, + i1.$$MemoryAssetEntityTableFilterComposer, + i1.$$MemoryAssetEntityTableOrderingComposer, + i1.$$MemoryAssetEntityTableAnnotationComposer, + $$MemoryAssetEntityTableCreateCompanionBuilder, + $$MemoryAssetEntityTableUpdateCompanionBuilder, + (i1.MemoryAssetEntityData, i1.$$MemoryAssetEntityTableReferences), + i1.MemoryAssetEntityData, + i0.PrefetchHooks Function({bool assetId, bool memoryId})> { + $$MemoryAssetEntityTableTableManager( + i0.GeneratedDatabase db, i1.$MemoryAssetEntityTable table) + : super(i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$MemoryAssetEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => i1 + .$$MemoryAssetEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$$MemoryAssetEntityTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + i0.Value assetId = const i0.Value.absent(), + i0.Value memoryId = const i0.Value.absent(), + }) => + i1.MemoryAssetEntityCompanion( + assetId: assetId, + memoryId: memoryId, + ), + createCompanionCallback: ({ + required String assetId, + required String memoryId, + }) => + i1.MemoryAssetEntityCompanion.insert( + assetId: assetId, + memoryId: memoryId, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + i1.$$MemoryAssetEntityTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({assetId = false, memoryId = 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 (assetId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.assetId, + referencedTable: + i1.$$MemoryAssetEntityTableReferences._assetIdTable(db), + referencedColumn: i1.$$MemoryAssetEntityTableReferences + ._assetIdTable(db) + .id, + ) as T; + } + if (memoryId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.memoryId, + referencedTable: i1.$$MemoryAssetEntityTableReferences + ._memoryIdTable(db), + referencedColumn: i1.$$MemoryAssetEntityTableReferences + ._memoryIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$MemoryAssetEntityTableProcessedTableManager + = i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$MemoryAssetEntityTable, + i1.MemoryAssetEntityData, + i1.$$MemoryAssetEntityTableFilterComposer, + i1.$$MemoryAssetEntityTableOrderingComposer, + i1.$$MemoryAssetEntityTableAnnotationComposer, + $$MemoryAssetEntityTableCreateCompanionBuilder, + $$MemoryAssetEntityTableUpdateCompanionBuilder, + (i1.MemoryAssetEntityData, i1.$$MemoryAssetEntityTableReferences), + i1.MemoryAssetEntityData, + i0.PrefetchHooks Function({bool assetId, bool memoryId})>; + +class $MemoryAssetEntityTable extends i2.MemoryAssetEntity + with i0.TableInfo<$MemoryAssetEntityTable, i1.MemoryAssetEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $MemoryAssetEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _assetIdMeta = + const i0.VerificationMeta('assetId'); + @override + late final i0.GeneratedColumn assetId = i0.GeneratedColumn( + 'asset_id', aliasedName, false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + static const i0.VerificationMeta _memoryIdMeta = + const i0.VerificationMeta('memoryId'); + @override + late final i0.GeneratedColumn memoryId = i0.GeneratedColumn( + 'memory_id', aliasedName, false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, + {bool isInserting = false}) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('asset_id')) { + context.handle(_assetIdMeta, + assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta)); + } else if (isInserting) { + context.missing(_assetIdMeta); + } + if (data.containsKey('memory_id')) { + context.handle(_memoryIdMeta, + memoryId.isAcceptableOrUnknown(data['memory_id']!, _memoryIdMeta)); + } else if (isInserting) { + context.missing(_memoryIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {assetId, memoryId}; + @override + i1.MemoryAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + memoryId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}memory_id'])!, + ); + } + + @override + $MemoryAssetEntityTable createAlias(String alias) { + return $MemoryAssetEntityTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends i0.DataClass + implements i0.Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = i0.Variable(assetId); + map['memory_id'] = i0.Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson(Map json, + {i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + i1.MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + i1.MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(i1.MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends i0.UpdateCompanion { + final i0.Value assetId; + final i0.Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const i0.Value.absent(), + this.memoryId = const i0.Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = i0.Value(assetId), + memoryId = i0.Value(memoryId); + static i0.Insertable custom({ + i0.Expression? assetId, + i0.Expression? memoryId, + }) { + return i0.RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + i1.MemoryAssetEntityCompanion copyWith( + {i0.Value? assetId, i0.Value? memoryId}) { + return i1.MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = i0.Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = i0.Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 51f731f0ff..51e5e4d73c 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -1,7 +1,7 @@ import 'remote_asset.entity.dart'; import 'local_asset.entity.dart'; -mergedAsset: SELECT * FROM +mergedAsset: SELECT * FROM ( SELECT rae.id as remote_id, @@ -22,7 +22,7 @@ mergedAsset: SELECT * FROM LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum WHERE - rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? UNION ALL SELECT NULL as remote_id, @@ -48,12 +48,12 @@ mergedAsset: SELECT * FROM ORDER BY created_at DESC LIMIT $limit; -mergedBucket(:group_by AS INTEGER): -SELECT +mergedBucket(:group_by AS INTEGER): +SELECT COUNT(*) as asset_count, CASE - WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day - WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month + WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at, 'localtime') -- day + WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at, 'localtime') -- month END AS bucket_date FROM ( @@ -65,7 +65,7 @@ FROM LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum WHERE - rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? UNION ALL SELECT lae.name, diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index be9d8b521e..f836dabe6a 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -18,7 +18,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 FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE 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 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 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 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($), ...generatedlimit.introducedVariables @@ -51,7 +51,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) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at) 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.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 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', variables: [ i0.Variable(groupBy), for (var $ in var2) i0.Variable($) diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index dbe491b035..e71598a999 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart'; 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/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; @@ -46,6 +48,8 @@ class IsarDatabaseRepository implements IDatabaseRepository { RemoteAlbumEntity, RemoteAlbumAssetEntity, RemoteAlbumUserEntity, + MemoryEntity, + MemoryAssetEntity, ], include: { 'package:immich_mobile/infrastructure/entities/merged_asset.drift', diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 69fd84b79a..925591def8 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -23,9 +23,13 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity. as i10; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart' as i11; -import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' as i12; -import 'package:drift/internal/modular.dart' as i13; +import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart' + as i13; +import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' + as i14; +import 'package:drift/internal/modular.dart' as i15; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); @@ -51,8 +55,11 @@ abstract class $Drift extends i0.GeneratedDatabase { i10.$RemoteAlbumAssetEntityTable(this); late final i11.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i11.$RemoteAlbumUserEntityTable(this); - i12.MergedAssetDrift get mergedAssetDrift => i13.ReadDatabaseContainer(this) - .accessor(i12.MergedAssetDrift.new); + late final i12.$MemoryEntityTable memoryEntity = i12.$MemoryEntityTable(this); + late final i13.$MemoryAssetEntityTable memoryAssetEntity = + i13.$MemoryAssetEntityTable(this); + i14.MergedAssetDrift get mergedAssetDrift => i15.ReadDatabaseContainer(this) + .accessor(i14.MergedAssetDrift.new); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -71,7 +78,9 @@ abstract class $Drift extends i0.GeneratedDatabase { remoteExifEntity, remoteAlbumEntity, remoteAlbumAssetEntity, - remoteAlbumUserEntity + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => @@ -175,6 +184,27 @@ abstract class $Drift extends i0.GeneratedDatabase { kind: i0.UpdateKind.delete), ], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('user_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('memory_entity', kind: i0.UpdateKind.delete), + ], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('remote_asset_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('memory_asset_entity', kind: i0.UpdateKind.delete), + ], + ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('memory_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('memory_asset_entity', kind: i0.UpdateKind.delete), + ], + ), ], ); @override @@ -208,4 +238,8 @@ class $DriftManager { _db, _db.remoteAlbumAssetEntity); i11.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i11 .$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity); + i12.$$MemoryEntityTableTableManager get memoryEntity => + i12.$$MemoryEntityTableTableManager(_db, _db.memoryEntity); + i13.$$MemoryAssetEntityTableTableManager get memoryAssetEntity => + i13.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity); } diff --git a/mobile/lib/infrastructure/repositories/exif.repository.dart b/mobile/lib/infrastructure/repositories/exif.repository.dart index d25572fdad..0012e329ca 100644 --- a/mobile/lib/infrastructure/repositories/exif.repository.dart +++ b/mobile/lib/infrastructure/repositories/exif.repository.dart @@ -1,8 +1,6 @@ -import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' as entity; -import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:isar/isar.dart'; @@ -43,36 +41,3 @@ class IsarExifRepository extends IsarDatabaseRepository { }); } } - -class DriftRemoteExifRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftRemoteExifRepository(this._db) : super(_db); - - Future get(String assetId) { - final query = _db.remoteExifEntity.select() - ..where((exif) => exif.assetId.equals(assetId)); - - return query.map((asset) => asset.toDto()).getSingleOrNull(); - } -} - -extension on RemoteExifEntityData { - ExifInfo toDto() { - return ExifInfo( - fileSize: fileSize, - description: description, - orientation: orientation, - timeZone: timeZone, - dateTimeOriginal: dateTimeOriginal, - latitude: latitude, - longitude: longitude, - city: city, - state: state, - country: country, - make: make, - model: model, - f: fNumber, - iso: iso, - ); - } -} diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 2efa04cc1b..28ca600f61 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,5 +1,6 @@ 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'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -7,6 +8,26 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { final Drift _db; const DriftLocalAssetRepository(this._db) : super(_db); + Stream watchAsset(String id) { + final query = _db.localAssetEntity + .select() + .addColumns([_db.localAssetEntity.id]).join([ + leftOuterJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), + ]) + ..where(_db.localAssetEntity.id.equals(id)); + + return query.map((row) { + final asset = row.readTable(_db.localAssetEntity).toDto(); + return asset.copyWith( + remoteId: row.read(_db.remoteAssetEntity.id), + ); + }).watchSingleOrNull(); + } + Future updateHashes(Iterable hashes) { if (hashes.isEmpty) { return Future.value(); diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart new file mode 100644 index 0000000000..ff5f75c2ac --- /dev/null +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -0,0 +1,81 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftMemoryRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftMemoryRepository(this._db) : super(_db); + + Future> getAll(String ownerId) async { + final now = DateTime.now(); + final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); + + final query = _db.select(_db.memoryEntity).join([ + leftOuterJoin( + _db.memoryAssetEntity, + _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id), + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline), + ), + ]) + ..where(_db.memoryEntity.ownerId.equals(ownerId)) + ..where(_db.memoryEntity.deletedAt.isNull()) + ..where( + _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc), + ) + ..where( + _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc), + ) + ..orderBy([ + OrderingTerm.desc(_db.memoryEntity.memoryAt), + OrderingTerm.asc(_db.remoteAssetEntity.createdAt), + ]); + + final rows = await query.get(); + + final Map memoriesMap = {}; + + for (final row in rows) { + final memory = row.readTable(_db.memoryEntity); + final asset = row.readTable(_db.remoteAssetEntity); + + final existingMemory = memoriesMap[memory.id]; + if (existingMemory != null) { + existingMemory.assets.add(asset.toDto()); + } else { + final assets = [asset.toDto()]; + memoriesMap[memory.id] = memory.toDto().copyWith(assets: assets); + } + } + + return memoriesMap.values.toList(); + } +} + +extension on MemoryEntityData { + DriftMemory toDto() { + return DriftMemory( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + ownerId: ownerId, + type: type, + data: MemoryData.fromJson(data), + isSaved: isSaved, + memoryAt: memoryAt, + seenAt: seenAt, + showAt: showAt, + hideAt: hideAt, + assets: [], + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index a9e0811104..fc341bc91e 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,13 +1,44 @@ 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/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/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class DriftRemoteAssetRepository extends DriftDatabaseRepository { +class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; - const DriftRemoteAssetRepository(this._db) : super(_db); + const RemoteAssetRepository(this._db) : super(_db); + + Stream watchAsset(String id) { + final query = _db.remoteAssetEntity + .select() + .addColumns([_db.localAssetEntity.id]).join([ + leftOuterJoin( + _db.localAssetEntity, + _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), + useColumns: false, + ), + ]) + ..where(_db.remoteAssetEntity.id.equals(id)); + + return query.map((row) { + final asset = row.readTable(_db.remoteAssetEntity).toDto(); + return asset.copyWith( + localId: row.read(_db.localAssetEntity.id), + ); + }).watchSingleOrNull(); + } + + Future getExif(String id) { + return _db.managers.remoteExifEntity + .filter((row) => row.assetId.id.equals(id)) + .map((row) => row.toDto()) + .getSingleOrNull(); + } Future updateFavorite(List ids, bool isFavorite) { return _db.batch((batch) async { @@ -33,6 +64,22 @@ class DriftRemoteAssetRepository extends DriftDatabaseRepository { }); } + Future trash(List ids) { + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion(deletedAt: Value(DateTime.now())), + where: (e) => e.id.equals(id), + ); + } + }); + } + + Future delete(List ids) { + return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids)); + } + Future updateLocation(List ids, LatLng location) { return _db.batch((batch) async { for (final id in ids) { diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 2b6b616d87..1f41e48522 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -5,20 +5,21 @@ import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; class StorageRepository { - final _log = Logger('StorageRepository'); + const StorageRepository(); Future getFileForAsset(LocalAsset asset) async { + final log = Logger('StorageRepository'); File? file; try { final entity = await AssetEntity.fromId(asset.id); file = await entity?.originFile; if (file == null) { - _log.warning( + log.warning( "Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", ); } } catch (error, stackTrace) { - _log.warning( + log.warning( "Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", error, stackTrace, diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index ccc79fa818..99199a7fc6 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -52,6 +52,8 @@ class SyncApiRepository { SyncRequestType.albumAssetsV1, SyncRequestType.albumAssetExifsV1, SyncRequestType.albumToAssetsV1, + SyncRequestType.memoriesV1, + SyncRequestType.memoryToAssetsV1, ], ).toJson(), ); @@ -157,6 +159,10 @@ const _kResponseMap = { SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson, SyncEntityType.albumToAssetDeleteV1: SyncAlbumToAssetDeleteV1.fromJson, SyncEntityType.syncAckV1: _SyncAckV1.fromJson, + SyncEntityType.memoryV1: SyncMemoryV1.fromJson, + SyncEntityType.memoryDeleteV1: SyncMemoryDeleteV1.fromJson, + SyncEntityType.memoryToAssetV1: SyncMemoryAssetV1.fromJson, + SyncEntityType.memoryToAssetDeleteV1: SyncMemoryAssetDeleteV1.fromJson, }; class _SyncAckV1 { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index dfe65b698e..b88083aa02 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -1,12 +1,17 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.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/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'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:logging/logging.dart'; @@ -64,8 +69,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: SyncPartnerDeleteV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: SyncPartnerDeleteV1', error, stackTrace); rethrow; } } @@ -87,8 +92,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: SyncPartnerV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: SyncPartnerV1', error, stackTrace); rethrow; } } @@ -98,10 +103,11 @@ class SyncStreamRepository extends DriftDatabaseRepository { String debugLabel = 'user', }) async { try { - await _db.remoteAssetEntity - .deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId))); - } catch (e, s) { - _logger.severe('Error: deleteAssetsV1 - $debugLabel', e, s); + await _db.remoteAssetEntity.deleteWhere( + (row) => row.id.isIn(data.map((error) => error.assetId)), + ); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stackTrace); rethrow; } } @@ -136,8 +142,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAssetsV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: updateAssetsV1 - $debugLabel', error, stackTrace); rethrow; } } @@ -161,8 +167,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { fNumber: Value(exif.fNumber), fileSize: Value(exif.fileSizeInByte), focalLength: Value(exif.focalLength), - latitude: Value(exif.latitude), - longitude: Value(exif.longitude), + latitude: Value(exif.latitude?.toDouble()), + longitude: Value(exif.longitude?.toDouble()), iso: Value(exif.iso), make: Value(exif.make), model: Value(exif.model), @@ -170,6 +176,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { timeZone: Value(exif.timeZone), rating: Value(exif.rating), projectionType: Value(exif.projectionType), + lens: Value(exif.lensModel), ); batch.insert( @@ -179,18 +186,23 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAssetsExifV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe( + 'Error: updateAssetsExifV1 - $debugLabel', + error, + stackTrace, + ); rethrow; } } Future deleteAlbumsV1(Iterable data) async { try { - await _db.remoteAlbumEntity - .deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId))); - } catch (e, s) { - _logger.severe('Error: deleteAlbumsV1', e, s); + await _db.remoteAlbumEntity.deleteWhere( + (row) => row.id.isIn(data.map((e) => e.albumId)), + ); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAlbumsV1', error, stackTrace); rethrow; } } @@ -217,8 +229,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAlbumsV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: updateAlbumsV1', error, stackTrace); rethrow; } } @@ -236,8 +248,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: deleteAlbumUsersV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAlbumUsersV1', error, stackTrace); rethrow; } } @@ -263,8 +275,12 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAlbumUsersV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe( + 'Error: updateAlbumUsersV1 - $debugLabel', + error, + stackTrace, + ); rethrow; } } @@ -284,8 +300,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: deleteAlbumToAssetsV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAlbumToAssetsV1', error, stackTrace); rethrow; } } @@ -309,8 +325,96 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAlbumToAssetsV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe( + 'Error: updateAlbumToAssetsV1 - $debugLabel', + error, + stackTrace, + ); + rethrow; + } + } + + Future updateMemoriesV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final memory in data) { + final companion = MemoryEntityCompanion( + createdAt: Value(memory.createdAt), + deletedAt: Value(memory.deletedAt), + ownerId: Value(memory.ownerId), + type: Value(memory.type.toMemoryType()), + data: Value(jsonEncode(memory.data)), + isSaved: Value(memory.isSaved), + memoryAt: Value(memory.memoryAt), + seenAt: Value.absentIfNull(memory.seenAt), + showAt: Value.absentIfNull(memory.showAt), + hideAt: Value.absentIfNull(memory.hideAt), + ); + + batch.insert( + _db.memoryEntity, + companion.copyWith(id: Value(memory.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stackTrace) { + _logger.severe('Error: updateMemoriesV1', error, stackTrace); + rethrow; + } + } + + Future deleteMemoriesV1(Iterable data) async { + try { + await _db.memoryEntity.deleteWhere( + (row) => row.id.isIn(data.map((e) => e.memoryId)), + ); + } catch (error, stackTrace) { + _logger.severe('Error: deleteMemoriesV1', error, stackTrace); + rethrow; + } + } + + Future updateMemoryAssetsV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final asset in data) { + final companion = MemoryAssetEntityCompanion( + memoryId: Value(asset.memoryId), + assetId: Value(asset.assetId), + ); + + batch.insert( + _db.memoryAssetEntity, + companion, + onConflict: DoNothing(), + ); + } + }); + } catch (error, stackTrace) { + _logger.severe('Error: updateMemoryAssetsV1', error, stackTrace); + rethrow; + } + } + + Future deleteMemoryAssetsV1( + Iterable data, + ) async { + try { + await _db.batch((batch) { + for (final asset in data) { + batch.delete( + _db.memoryAssetEntity, + MemoryAssetEntityCompanion( + memoryId: Value(asset.memoryId), + assetId: Value(asset.assetId), + ), + ); + } + }); + } catch (error, stackTrace) { + _logger.severe('Error: deleteMemoryAssetsV1', error, stackTrace); rethrow; } } @@ -334,6 +438,13 @@ extension on AssetOrder { }; } +extension on MemoryType { + MemoryTypeEnum toMemoryType() => switch (this) { + MemoryType.onThisDay => MemoryTypeEnum.onThisDay, + _ => throw Exception('Unknown MemoryType value: $this'), + }; +} + extension on api.AlbumUserRole { AlbumUserRole toAlbumUserRole() => switch (this) { api.AlbumUserRole.editor => AlbumUserRole.editor, @@ -356,7 +467,7 @@ extension on String { Duration? toDuration() { try { final parts = split(':') - .map((e) => double.parse(e).toInt()) + .map((error) => double.parse(error).toInt()) .toList(growable: false); return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]); diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 77b734ce0b..6fdbecced1 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -238,7 +238,7 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildImage(Asset asset) { return PhotoViewGalleryPageOptions( - onDragStart: (_, details, __) { + onDragStart: (_, details, __, ___) { localPosition.value = details.localPosition; }, onDragUpdate: (_, details, __) { @@ -267,7 +267,7 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { return PhotoViewGalleryPageOptions.customChild( - onDragStart: (_, details, __) => + onDragStart: (_, details, __, ___) => localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: _getHeroAttributes(asset), @@ -370,7 +370,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), itemCount: totalAssets.value, scrollDirection: Axis.horizontal, - onPageChanged: (value) { + onPageChanged: (value, _) { final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 31ccb12392..452c153342 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -42,22 +42,6 @@ class TabShellPage extends ConsumerWidget { ); } - void onNavigationSelected(TabsRouter router, int index) { - // On Photos page menu tapped - if (router.activeIndex == 0 && index == 0) { - scrollToTopNotifierProvider.scrollToTop(); - } - - // On Search page tapped - if (router.activeIndex == 1 && index == 1) { - ref.read(searchInputFocusProvider).requestFocus(); - } - - ref.read(hapticFeedbackProvider.notifier).selectionClick(); - router.setActiveIndex(index); - ref.read(tabProvider.notifier).state = TabEnum.values[index]; - } - final navigationDestinations = [ NavigationDestination( label: 'photos'.tr(), @@ -110,15 +94,6 @@ class TabShellPage extends ConsumerWidget { ), ]; - Widget bottomNavigationBar(TabsRouter tabsRouter) { - return NavigationBar( - selectedIndex: tabsRouter.activeIndex, - onDestinationSelected: (index) => - onNavigationSelected(tabsRouter, index), - destinations: navigationDestinations, - ); - } - Widget navigationRail(TabsRouter tabsRouter) { return NavigationRail( destinations: navigationDestinations @@ -131,15 +106,13 @@ class TabShellPage extends ConsumerWidget { ) .toList(), onDestinationSelected: (index) => - onNavigationSelected(tabsRouter, index), + _onNavigationSelected(tabsRouter, index, ref), selectedIndex: tabsRouter.activeIndex, labelType: NavigationRailLabelType.all, groupAlignment: 0.0, ); } - final multiselectEnabled = - ref.watch(multiSelectProvider.select((s) => s.isEnabled)); return AutoTabsRouter( routes: [ const MainTimelineRoute(), @@ -173,12 +146,57 @@ class TabShellPage extends ConsumerWidget { ], ) : heroedChild, - bottomNavigationBar: multiselectEnabled || isScreenLandscape - ? null - : bottomNavigationBar(tabsRouter), + bottomNavigationBar: _BottomNavigationBar( + tabsRouter: tabsRouter, + destinations: navigationDestinations, + ), ), ); }, ); } } + +void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { + // On Photos page menu tapped + if (router.activeIndex == 0 && index == 0) { + scrollToTopNotifierProvider.scrollToTop(); + } + + // On Search page tapped + if (router.activeIndex == 1 && index == 1) { + ref.read(searchInputFocusProvider).requestFocus(); + } + + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + router.setActiveIndex(index); + ref.read(tabProvider.notifier).state = TabEnum.values[index]; +} + +class _BottomNavigationBar extends ConsumerWidget { + const _BottomNavigationBar({ + required this.tabsRouter, + required this.destinations, + }); + + final List destinations; + final TabsRouter tabsRouter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isScreenLandscape = context.orientation == Orientation.landscape; + final isMultiselectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + + if (isScreenLandscape || isMultiselectEnabled) { + return const SizedBox.shrink(); + } + + return NavigationBar( + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: (index) => + _onNavigationSelected(tabsRouter, index, ref), + destinations: destinations, + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 090db4f6ba..9ec8002463 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -1,7 +1,9 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @RoutePage() class MainTimelinePage extends ConsumerWidget { @@ -9,6 +11,22 @@ class MainTimelinePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return const Timeline(); + final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); + + return memoryLaneProvider.when( + data: (memories) { + return memories.isEmpty + ? const Timeline() + : Timeline( + topSliverWidget: SliverToBoxAdapter( + key: Key('memory-lane-${memories.first.assets.first.id}'), + child: DriftMemoryLane(memories: memories), + ), + topSliverWidgetHeight: 200, + ); + }, + loading: () => const Timeline(), + error: (error, stackTrace) => const Timeline(), + ); } } diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index 10d09f8de5..f0a648fd5a 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -154,6 +154,14 @@ final _remoteStats = [ name: 'Remote Albums', load: (db) => db.managers.remoteAlbumEntity.count(), ), + _Stat( + name: 'Memories', + load: (db) => db.managers.memoryEntity.count(), + ), + _Stat( + name: 'Memories Assets', + load: (db) => db.managers.memoryAssetEntity.count(), + ), ]; @RoutePage() diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart new file mode 100644 index 0000000000..7da2d1a4c7 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -0,0 +1,394 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.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/memory.model.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart'; +import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; +import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; + +/// Expects [currentAssetNotifier] to be set before navigating to this page +@RoutePage() +class DriftMemoryPage extends HookConsumerWidget { + final List memories; + final int memoryIndex; + + const DriftMemoryPage({ + required this.memories, + required this.memoryIndex, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentMemory = useState(memories[memoryIndex]); + final currentAssetPage = useState(0); + final currentMemoryIndex = useState(memoryIndex); + final assetProgress = useState( + "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}", + ); + const bgColor = Colors.black; + final currentAsset = useState(null); + + /// The list of all of the asset page controllers + final memoryAssetPageControllers = + List.generate(memories.length, (i) => usePageController()); + + /// The main vertically scrolling page controller with each list of memories + final memoryPageController = usePageController(initialPage: memoryIndex); + + useEffect(() { + // Memories is an immersive activity + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + return () { + // Clean up to normal edge to edge when we are done + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + }; + }); + + toNextMemory() { + memoryPageController.nextPage( + duration: const Duration(milliseconds: 500), + curve: Curves.easeIn, + ); + } + + void toPreviousMemory() { + if (currentMemoryIndex.value > 0) { + // Move to the previous memory page + memoryPageController.previousPage( + duration: const Duration(milliseconds: 500), + curve: Curves.easeIn, + ); + + // Wait for the next frame to ensure the page is built + SchedulerBinding.instance.addPostFrameCallback((_) { + final previousIndex = currentMemoryIndex.value - 1; + final previousMemoryController = + memoryAssetPageControllers[previousIndex]; + + // Ensure the controller is attached + if (previousMemoryController.hasClients) { + previousMemoryController + .jumpToPage(memories[previousIndex].assets.length - 1); + } else { + // Wait for the next frame until it is attached + SchedulerBinding.instance.addPostFrameCallback((_) { + if (previousMemoryController.hasClients) { + previousMemoryController + .jumpToPage(memories[previousIndex].assets.length - 1); + } + }); + } + }); + } + } + + toNextAsset(int currentAssetIndex) { + if (currentAssetIndex + 1 < currentMemory.value.assets.length) { + // Go to the next asset + PageController controller = + memoryAssetPageControllers[currentMemoryIndex.value]; + + controller.nextPage( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + } else { + // Go to the next memory since we are at the end of our assets + toNextMemory(); + } + } + + toPreviousAsset(int currentAssetIndex) { + if (currentAssetIndex > 0) { + // Go to the previous asset + PageController controller = + memoryAssetPageControllers[currentMemoryIndex.value]; + + controller.previousPage( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + } else { + // Go to the previous memory since we are at the end of our assets + toPreviousMemory(); + } + } + + updateProgressText() { + assetProgress.value = + "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}"; + } + + /// Downloads and caches the image for the asset at this [currentMemory]'s index + precacheAsset(int index) async { + // Guard index out of range + if (index < 0) { + return; + } + + // Context might be removed due to popping out of Memory Lane during Scroll handling + if (!context.mounted) { + return; + } + + late RemoteAsset asset; + if (index < currentMemory.value.assets.length) { + // Uses the next asset in this current memory + asset = currentMemory.value.assets[index]; + } else { + // Precache the first asset in the next memory if available + final currentMemoryIndex = memories.indexOf(currentMemory.value); + + // Guard no memory found + if (currentMemoryIndex == -1) { + return; + } + + final nextMemoryIndex = currentMemoryIndex + 1; + // Guard no next memory + if (nextMemoryIndex >= memories.length) { + return; + } + + // Get the first asset from the next memory + asset = memories[nextMemoryIndex].assets.first; + } + + // Precache the asset + final size = MediaQuery.sizeOf(context); + await precacheImage( + getFullImageProvider( + asset, + size: Size(size.width, size.height), + ), + context, + size: size, + ); + } + + // Precache the next page right away if we are on the first page + if (currentAssetPage.value == 0) { + Future.delayed(const Duration(milliseconds: 200)) + .then((_) => precacheAsset(1)); + } + + Future onAssetChanged(int otherIndex) async { + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + currentAssetPage.value = otherIndex; + updateProgressText(); + + // Wait for page change animation to finish + await Future.delayed(const Duration(milliseconds: 400)); + // And then precache the next asset + await precacheAsset(otherIndex + 1); + + final asset = currentMemory.value.assets[otherIndex]; + currentAsset.value = asset; + ref.read(currentAssetNotifier.notifier).setAsset(asset); + // if (asset.isVideo || asset.isMotionPhoto) { + if (asset.isVideo) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + } + + /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called + * when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final + * page during the end of scroll is different than the current page + */ + return NotificationListener( + onNotification: (ScrollNotification notification) { + // Calculate OverScroll manually using the number of pixels away from maxScrollExtent + // maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1 + // or sum of vertical pixels of all memories for depth = 0 + if (notification is ScrollUpdateNotification) { + final isEpiloguePage = + (memoryPageController.page?.floor() ?? 0) >= memories.length; + + final offset = notification.metrics.pixels; + if (isEpiloguePage && + (offset > notification.metrics.maxScrollExtent + 150)) { + context.maybePop(); + return true; + } + } + + return false; + }, + child: Scaffold( + backgroundColor: bgColor, + body: SafeArea( + child: PageView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + scrollDirection: Axis.vertical, + controller: memoryPageController, + onPageChanged: (pageNumber) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + if (pageNumber < memories.length) { + currentMemoryIndex.value = pageNumber; + currentMemory.value = memories[pageNumber]; + } + + currentAssetPage.value = 0; + + updateProgressText(); + }, + itemCount: memories.length + 1, + itemBuilder: (context, mIndex) { + // Build last page + if (mIndex == memories.length) { + return MemoryEpilogue( + onStartOver: () => memoryPageController.animateToPage( + 0, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + ), + ); + } + + final yearsAgo = DateTime.now().year - memories[mIndex].data.year; + final title = 'years_ago'.t( + context: context, + args: { + 'years': yearsAgo.toString(), + }, + ); + // Build horizontal page + final assetController = memoryAssetPageControllers[mIndex]; + return Column( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 8.0, + bottom: 2.0, + ), + child: AnimatedBuilder( + animation: assetController, + builder: (context, child) { + double value = 0.0; + if (assetController.hasClients) { + // We can only access [page] if this has clients + value = assetController.page ?? 0; + } + return MemoryProgressIndicator( + ticks: memories[mIndex].assets.length, + value: (value + 1) / memories[mIndex].assets.length, + ); + }, + ), + ), + Expanded( + child: Stack( + children: [ + PageView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + controller: assetController, + onPageChanged: onAssetChanged, + scrollDirection: Axis.horizontal, + itemCount: memories[mIndex].assets.length, + itemBuilder: (context, index) { + final asset = memories[mIndex].assets[index]; + return Stack( + children: [ + Container( + color: Colors.black, + child: DriftMemoryCard( + asset: asset, + title: title, + showTitle: index == 0, + ), + ), + Positioned.fill( + child: Row( + children: [ + // Left side of the screen + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + toPreviousAsset(index); + }, + ), + ), + + // Right side of the screen + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + toNextAsset(index); + }, + ), + ), + ], + ), + ), + ], + ); + }, + ), + Positioned( + top: 8, + left: 8, + child: MaterialButton( + minWidth: 0, + onPressed: () { + // auto_route doesn't invoke pop scope, so + // turn off full screen mode here + // https://github.com/Milad-Akarie/auto_route_library/issues/1799 + context.maybePop(); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + }, + shape: const CircleBorder(), + color: Colors.white.withValues(alpha: 0.2), + elevation: 0, + child: const Icon( + Icons.close_rounded, + color: Colors.white, + ), + ), + ), + if (currentAsset.value != null && + currentAsset.value!.isVideo) + Positioned( + bottom: 24, + right: 32, + child: Icon( + Icons.videocam_outlined, + color: Colors.grey[200], + ), + ), + ], + ), + ), + DriftMemoryBottomInfo( + memory: memories[mIndex], + title: title, + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index d1d0695a99..86537816e3 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -5,7 +5,6 @@ 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/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -20,7 +19,6 @@ class ArchiveActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).archive(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'archive_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 6c60b47535..94e3610a57 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -9,17 +9,33 @@ class BaseActionButton extends StatelessWidget { this.onPressed, this.onLongPressed, this.maxWidth = 90.0, + this.minWidth, + this.menuItem = false, }); final String label; final IconData iconData; final double maxWidth; + final double? minWidth; + final bool menuItem; final void Function()? onPressed; final void Function()? onLongPressed; @override Widget build(BuildContext context) { - final minWidth = context.isMobile ? context.width / 4.5 : 75.0; + final miniWidth = + 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 textColor = context.themeData.textTheme.labelLarge?.color; + + if (menuItem) { + return IconButton( + onPressed: onPressed, + icon: Icon(iconData, size: iconSize, color: iconColor), + ); + } return ConstrainedBox( constraints: BoxConstraints( @@ -30,19 +46,22 @@ class BaseActionButton extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20)), ), + textColor: textColor, onPressed: onPressed, onLongPress: onLongPressed, - minWidth: minWidth, + minWidth: miniWidth, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(iconData, size: 24), + Icon(iconData, size: iconSize, color: iconColor), const SizedBox(height: 8), Text( label, - style: - const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w400, + ), maxLines: 3, textAlign: TextAlign.center, ), diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index ff77e99041..d81f998a7b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -1,10 +1,42 @@ 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 DeletePermanentActionButton extends ConsumerWidget { - const DeletePermanentActionButton({super.key}); + final ActionSource source; + + const DeletePermanentActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).delete(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'delete_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) { @@ -12,6 +44,7 @@ class DeletePermanentActionButton extends ConsumerWidget { maxWidth: 110.0, iconData: Icons.delete_forever, label: "delete_dialog_title".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart index 50d13e6b4e..39d059c2d1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart @@ -5,14 +5,18 @@ 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/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class FavoriteActionButton extends ConsumerWidget { final ActionSource source; + final bool menuItem; - const FavoriteActionButton({super.key, required this.source}); + const FavoriteActionButton({ + super.key, + required this.source, + this.menuItem = false, + }); void _onTap(BuildContext context, WidgetRef ref) async { if (!context.mounted) { @@ -20,7 +24,11 @@ class FavoriteActionButton extends ConsumerWidget { } final result = await ref.read(actionProvider.notifier).favorite(source); - await ref.read(timelineServiceProvider).reloadBucket(); + + if (source == ActionSource.viewer) { + return; + } + ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'favorite_action_prompt'.t( @@ -45,6 +53,7 @@ class FavoriteActionButton extends ConsumerWidget { return BaseActionButton( iconData: Icons.favorite_border_rounded, label: "favorite".t(context: context), + menuItem: menuItem, onPressed: () => _onTap(context, ref), ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 503dd34403..7546f07961 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -5,7 +5,6 @@ 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/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +20,6 @@ class MoveToLockFolderActionButton extends ConsumerWidget { final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'move_to_lock_folder_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart index 32857f300e..20fb62013f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart @@ -5,7 +5,6 @@ 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/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +20,6 @@ class RemoveFromLockFolderActionButton extends ConsumerWidget { final result = await ref.read(actionProvider.notifier).removeFromLockFolder(source); - await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'remove_from_lock_folder_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_buton.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_buton.widget.dart deleted file mode 100644 index ccaaf314fc..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_buton.widget.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; - -class TrashActionButton extends ConsumerWidget { - const TrashActionButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - maxWidth: 85.0, - iconData: Icons.delete_outline_rounded, - label: "control_bottom_app_bar_trash_from_immich".t(context: context), - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart new file mode 100644 index 0000000000..449b688550 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -0,0 +1,50 @@ +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 TrashActionButton extends ConsumerWidget { + final ActionSource source; + + const TrashActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).trash(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'trash_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( + maxWidth: 85.0, + iconData: Icons.delete_outline_rounded, + label: "control_bottom_app_bar_trash_from_immich".t(context: context), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index 7fa0f8513a..a58d9f1ee1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -1,16 +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 UnarchiveActionButton extends ConsumerWidget { - const UnarchiveActionButton({super.key}); + final ActionSource source; + + const UnarchiveActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'unarchive_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.unarchive_outlined, label: "unarchive".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart index 420821ef3f..b465643796 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart @@ -1,16 +1,60 @@ 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 UnFavoriteActionButton extends ConsumerWidget { - const UnFavoriteActionButton({super.key}); + final ActionSource source; + final bool menuItem; + + const UnFavoriteActionButton({ + super.key, + required this.source, + this.menuItem = false, + }); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).unFavorite(source); + + if (source == ActionSource.viewer) { + return; + } + + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'unfavorite_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.favorite_rounded, label: "unfavorite".t(context: context), + onPressed: () => _onTap(context, ref), + menuItem: menuItem, ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart new file mode 100644 index 0000000000..c91b71319c --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -0,0 +1,529 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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_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'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/widgets/photo_view/photo_view.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; +import 'package:platform/platform.dart'; + +@RoutePage() +class AssetViewerPage extends StatelessWidget { + final int initialIndex; + final TimelineService timelineService; + + const AssetViewerPage({ + super.key, + required this.initialIndex, + required this.timelineService, + }); + + @override + Widget build(BuildContext context) { + // This is necessary to ensure that the timeline service is available + // since the Timeline and AssetViewer are on different routes / Widget subtrees. + return ProviderScope( + overrides: [timelineServiceProvider.overrideWithValue(timelineService)], + child: AssetViewer(initialIndex: initialIndex), + ); + } +} + +class AssetViewer extends ConsumerStatefulWidget { + final int initialIndex; + final Platform? platform; + + const AssetViewer({ + super.key, + required this.initialIndex, + this.platform, + }); + + @override + ConsumerState createState() => _AssetViewerState(); +} + +const double _kBottomSheetMinimumExtent = 0.4; +const double _kBottomSheetSnapExtent = 0.7; + +class _AssetViewerState extends ConsumerState { + late PageController pageController; + late DraggableScrollableController bottomSheetController; + PersistentBottomSheetController? sheetCloseController; + // PhotoViewGallery takes care of disposing it's controllers + PhotoViewControllerBase? viewController; + StreamSubscription? reloadSubscription; + + late Platform platform; + late PhotoViewControllerValue initialPhotoViewState; + bool? hasDraggedDown; + bool isSnapping = false; + bool blockGestures = false; + bool dragInProgress = false; + bool shouldPopOnDrag = false; + double? initialScale; + double previousExtent = _kBottomSheetMinimumExtent; + Offset dragDownPosition = Offset.zero; + int totalAssets = 0; + BuildContext? scaffoldContext; + + // Delayed operations that should be cancelled on disposal + final List _delayedOperations = []; + + @override + void initState() { + super.initState(); + pageController = PageController(initialPage: widget.initialIndex); + platform = widget.platform ?? const LocalPlatform(); + totalAssets = ref.read(timelineServiceProvider).totalAssets; + bottomSheetController = DraggableScrollableController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _onAssetChanged(widget.initialIndex); + }); + reloadSubscription = EventStream.shared.listen(_onEvent); + } + + @override + void dispose() { + pageController.dispose(); + bottomSheetController.dispose(); + _cancelTimers(); + reloadSubscription?.cancel(); + super.dispose(); + } + + bool get showingBottomSheet => + ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); + + Color get backgroundColor { + final opacity = + ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); + return Colors.black.withAlpha(opacity); + } + + void _cancelTimers() { + for (final timer in _delayedOperations) { + timer.cancel(); + } + _delayedOperations.clear(); + } + + // This is used to calculate the scale of the asset when the bottom sheet is showing. + // It is a small increment to ensure that the asset is slightly zoomed in when the + // bottom sheet is showing, which emulates the zoom effect. + double get _getScaleForBottomSheet => + (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + + 0.01; + + double _getVerticalOffsetForBottomSheet(double extent) => + (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); + + Future _precacheImage(int index) async { + if (!mounted || index < 0 || index >= totalAssets) { + return; + } + + final asset = ref.read(timelineServiceProvider).getAsset(index); + final screenSize = Size(context.width, context.height); + + // Precache both thumbnail and full image for smooth transitions + unawaited( + Future.wait([ + precacheImage( + getThumbnailImageProvider(asset: asset, size: screenSize), + context, + onError: (_, __) {}, + ), + precacheImage( + getFullImageProvider(asset, size: screenSize), + context, + onError: (_, __) {}, + ), + ]), + ); + } + + void _onAssetChanged(int index) { + final asset = ref.read(timelineServiceProvider).getAsset(index); + ref.read(currentAssetNotifier.notifier).setAsset(asset); + unawaited(ref.read(timelineServiceProvider).preCacheAssets(index)); + _cancelTimers(); + // This will trigger the pre-caching of adjacent assets ensuring + // that they are ready when the user navigates to them. + final timer = Timer(Durations.medium4, () { + // Check if widget is still mounted before proceeding + if (!mounted) return; + + for (final offset in [-1, 1]) { + unawaited(_precacheImage(index + offset)); + } + }); + _delayedOperations.add(timer); + } + + void _onPageBuild(PhotoViewControllerBase controller) { + viewController ??= controller; + if (showingBottomSheet) { + final verticalOffset = (context.height * bottomSheetController.size) - + (context.height * _kBottomSheetMinimumExtent); + controller.position = Offset(0, -verticalOffset); + } + } + + void _onPageChanged(int index, PhotoViewControllerBase? controller) { + _onAssetChanged(index); + viewController = controller; + + // If the bottom sheet is showing, we need to adjust scale the asset to + // emulate the zoom effect + if (showingBottomSheet) { + initialScale = controller?.scale; + controller?.scale = _getScaleForBottomSheet; + } + } + + void _onDragStart( + _, + DragStartDetails details, + PhotoViewControllerBase controller, + PhotoViewScaleStateController scaleStateController, + ) { + viewController = controller; + dragDownPosition = details.localPosition; + initialPhotoViewState = controller.value; + final isZoomed = + scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || + scaleStateController.scaleState == PhotoViewScaleState.covering; + if (!showingBottomSheet && isZoomed) { + blockGestures = true; + } + } + + void _onDragEnd(BuildContext ctx, _, __) { + dragInProgress = false; + + if (shouldPopOnDrag) { + // Dismiss immediately without state updates to avoid rebuilds + ctx.maybePop(); + return; + } + + // Do not reset the state if the bottom sheet is showing + if (showingBottomSheet) { + _snapBottomSheet(); + return; + } + + // If the gestures are blocked, do not reset the state + if (blockGestures) { + blockGestures = false; + return; + } + + shouldPopOnDrag = false; + hasDraggedDown = null; + viewController?.animateMultiple( + position: initialPhotoViewState.position, + scale: initialPhotoViewState.scale, + rotation: initialPhotoViewState.rotation, + ); + ref.read(assetViewerProvider.notifier).setOpacity(255); + } + + void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { + if (blockGestures) { + return; + } + + dragInProgress = true; + final delta = details.localPosition - dragDownPosition; + hasDraggedDown ??= delta.dy > 0; + if (!hasDraggedDown! || showingBottomSheet) { + _handleDragUp(ctx, delta); + return; + } + + _handleDragDown(ctx, delta); + } + + void _handleDragUp(BuildContext ctx, Offset delta) { + const double openThreshold = 50; + + final position = initialPhotoViewState.position + Offset(0, delta.dy); + final distanceToOrigin = position.distance; + + viewController?.updateMultiple(position: position); + // Moves the bottom sheet when the asset is being dragged up + if (showingBottomSheet && bottomSheetController.isAttached) { + final centre = (ctx.height * _kBottomSheetMinimumExtent); + bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); + } + + if (distanceToOrigin > openThreshold && !showingBottomSheet) { + _openBottomSheet(ctx); + } + } + + void _handleDragDown(BuildContext ctx, Offset delta) { + const double dragRatio = 0.2; + const double popThreshold = 75; + + final distance = delta.distance; + shouldPopOnDrag = delta.dy > 0 && distance > popThreshold; + + final maxScaleDistance = ctx.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + double? updatedScale; + if (initialPhotoViewState.scale != null) { + updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); + } + + final backgroundOpacity = + (255 * (1.0 - (scaleReduction / dragRatio))).round(); + + viewController?.updateMultiple( + position: initialPhotoViewState.position + delta, + scale: updatedScale, + ); + ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); + } + + void _onTapDown(_, __, ___) { + if (!showingBottomSheet) { + ref.read(assetViewerProvider.notifier).toggleControls(); + } + } + + bool _onNotification(Notification delta) { + if (delta is DraggableScrollableNotification) { + _handleDraggableNotification(delta); + } + + // Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after + // the isSnapping guard is to prevent the notification from recursively handling the + // notification, eventually resulting in a heap overflow + if (!isSnapping && delta is ScrollEndNotification) { + _snapBottomSheet(); + } + return false; + } + + void _handleDraggableNotification(DraggableScrollableNotification delta) { + final currentExtent = delta.extent; + final isDraggingDown = currentExtent < previousExtent; + previousExtent = currentExtent; + // Closes the bottom sheet if the user is dragging down + if (isDraggingDown && delta.extent < 0.5) { + if (dragInProgress) { + blockGestures = true; + } + sheetCloseController?.close(); + } + + // If the asset is being dragged down, we do not want to update the asset position again + if (dragInProgress) { + return; + } + + final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent); + // Moves the asset when the bottom sheet is being dragged + if (verticalOffset > 0) { + viewController?.position = Offset(0, -verticalOffset); + } + } + + void _onEvent(Event event) { + if (event is TimelineReloadEvent) { + _onTimelineReload(event); + return; + } + + if (event is ViewerOpenBottomSheetEvent) { + final extent = _kBottomSheetMinimumExtent + 0.3; + _openBottomSheet(scaffoldContext!, extent: extent); + final offset = _getVerticalOffsetForBottomSheet(extent); + viewController?.position = Offset(0, -offset); + return; + } + } + + void _onTimelineReload(_) { + setState(() { + totalAssets = ref.read(timelineServiceProvider).totalAssets; + if (totalAssets == 0) { + context.maybePop(); + return; + } + + final index = pageController.page?.round() ?? 0; + final newAsset = ref.read(timelineServiceProvider).getAsset(index); + final currentAsset = ref.read(currentAssetNotifier); + // Do not reload / close the bottom sheet if the asset has not changed + if (newAsset.heroTag == currentAsset?.heroTag) { + return; + } + + _onAssetChanged(pageController.page!.round()); + sheetCloseController?.close(); + }); + } + + void _openBottomSheet( + BuildContext ctx, { + double extent = _kBottomSheetMinimumExtent, + }) { + ref.read(assetViewerProvider.notifier).setBottomSheet(true); + initialScale = viewController?.scale; + viewController?.updateMultiple(scale: _getScaleForBottomSheet); + previousExtent = _kBottomSheetMinimumExtent; + sheetCloseController = showBottomSheet( + context: ctx, + sheetAnimationStyle: AnimationStyle( + duration: Durations.short4, + reverseDuration: Durations.short2, + ), + constraints: const BoxConstraints(maxWidth: double.infinity), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)), + ), + backgroundColor: ctx.colorScheme.surfaceContainerLowest, + builder: (_) { + return NotificationListener( + onNotification: _onNotification, + child: AssetDetailBottomSheet( + controller: bottomSheetController, + initialChildSize: extent, + ), + ); + }, + ); + sheetCloseController?.closed.then((_) => _handleSheetClose()); + } + + void _handleSheetClose() { + viewController?.animateMultiple(position: Offset.zero); + viewController?.updateMultiple(scale: initialScale); + ref.read(assetViewerProvider.notifier).setBottomSheet(false); + sheetCloseController = null; + shouldPopOnDrag = false; + hasDraggedDown = null; + } + + void _snapBottomSheet() { + if (bottomSheetController.size > _kBottomSheetSnapExtent || + bottomSheetController.size < 0.4) { + return; + } + isSnapping = true; + bottomSheetController.animateTo( + _kBottomSheetSnapExtent, + duration: Durations.short3, + curve: Curves.easeOut, + ); + } + + Widget _placeholderBuilder( + BuildContext ctx, + ImageChunkEvent? progress, + int index, + ) { + final asset = ref.read(timelineServiceProvider).getAsset(index); + return Container( + width: double.infinity, + height: double.infinity, + color: backgroundColor, + child: Thumbnail( + asset: asset, + fit: BoxFit.contain, + size: Size( + ctx.width, + ctx.height, + ), + ), + ); + } + + PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { + scaffoldContext ??= ctx; + final asset = ref.read(timelineServiceProvider).getAsset(index); + final size = Size(ctx.width, ctx.height); + + return PhotoViewGalleryPageOptions( + key: ValueKey(asset.heroTag), + imageProvider: getFullImageProvider(asset, size: size), + heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag), + filterQuality: FilterQuality.high, + tightMode: true, + initialScale: PhotoViewComputedScale.contained * 0.999, + minScale: PhotoViewComputedScale.contained * 0.999, + disableScaleGestures: showingBottomSheet, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onTapDown: _onTapDown, + errorBuilder: (_, __, ___) => Container( + width: ctx.width, + height: ctx.height, + color: backgroundColor, + child: Thumbnail( + asset: asset, + fit: BoxFit.contain, + size: size, + ), + ), + ); + } + + void _onPop(bool didPop, T? result) { + ref.read(currentAssetNotifier.notifier).dispose(); + } + + @override + Widget build(BuildContext context) { + // Rebuild the widget when the asset viewer state changes + // 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)); + + // 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 + return PopScope( + onPopInvokedWithResult: _onPop, + child: Scaffold( + backgroundColor: backgroundColor, + appBar: const ViewerTopAppBar(), + extendBody: true, + extendBodyBehindAppBar: true, + body: PhotoViewGallery.builder( + gaplessPlayback: true, + loadingBuilder: _placeholderBuilder, + pageController: pageController, + scrollPhysics: platform.isIOS + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics() // Use heavy physics for Android + , + itemCount: totalAssets, + onPageChanged: _onPageChanged, + onPageBuild: _onPageBuild, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), + bottomNavigationBar: const ViewerBottomBar(), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart new file mode 100644 index 0000000000..231d40c9ca --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -0,0 +1,76 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +class AssetViewerState { + final int backgroundOpacity; + final bool showingBottomSheet; + final bool showingControls; + + const AssetViewerState({ + this.backgroundOpacity = 255, + this.showingBottomSheet = false, + this.showingControls = true, + }); + + AssetViewerState copyWith({ + int? backgroundOpacity, + bool? showingBottomSheet, + bool? showingControls, + }) { + return AssetViewerState( + backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, + showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, + showingControls: showingControls ?? this.showingControls, + ); + } + + @override + String toString() { + return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is AssetViewerState && + other.backgroundOpacity == backgroundOpacity && + other.showingBottomSheet == showingBottomSheet && + other.showingControls == showingControls; + } + + @override + int get hashCode => + backgroundOpacity.hashCode ^ + showingBottomSheet.hashCode ^ + showingControls.hashCode; +} + +class AssetViewerStateNotifier extends AutoDisposeNotifier { + @override + AssetViewerState build() { + return const AssetViewerState(); + } + + void setOpacity(int opacity) { + state = state.copyWith( + backgroundOpacity: opacity, + showingControls: opacity == 255 ? true : state.showingControls, + ); + } + + void setBottomSheet(bool showing) { + state = state.copyWith( + showingBottomSheet: showing, + showingControls: showing ? true : state.showingControls, + ); + } + + void toggleControls() { + state = state.copyWith(showingControls: !state.showingControls); + } +} + +final assetViewerProvider = + AutoDisposeNotifierProvider( + AssetViewerStateNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart new file mode 100644 index 0000000000..6269bef6be --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +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'; +import 'package:immich_mobile/providers/user.provider.dart'; + +class ViewerBottomBar extends ConsumerWidget { + const ViewerBottomBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + + final user = ref.watch(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isSheetOpen = ref.watch( + assetViewerProvider.select((s) => s.showingBottomSheet), + ); + int opacity = ref.watch( + assetViewerProvider.select((state) => state.backgroundOpacity), + ); + final showControls = + ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0; + } + + final actions = [ + const ShareActionButton(), + const _EditActionButton(), + if (asset.hasRemote && isOwner) + const ArchiveActionButton(source: ActionSource.viewer), + ]; + + return IgnorePointer( + ignoring: opacity < 255, + child: AnimatedOpacity( + opacity: opacity / 255, + duration: Durations.short2, + child: AnimatedSwitcher( + duration: Durations.short4, + child: isSheetOpen + ? const SizedBox.shrink() + : SafeArea( + child: Theme( + data: context.themeData.copyWith( + iconTheme: + const IconThemeData(size: 22, color: Colors.white), + textTheme: context.themeData.textTheme.copyWith( + labelLarge: + context.themeData.textTheme.labelLarge?.copyWith( + color: Colors.white, + ), + ), + ), + child: Container( + height: 80, + color: Colors.black.withAlpha(125), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: actions, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _EditActionButton extends ConsumerWidget { + const _EditActionButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.tune_outlined, + label: 'edit'.t(context: context), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart new file mode 100644 index 0000000000..d0bdc28d10 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -0,0 +1,253 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +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/domain/models/exif.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/delete_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +const _kSeparator = ' • '; + +class AssetDetailBottomSheet extends ConsumerWidget { + final DraggableScrollableController? controller; + final double initialChildSize; + + const AssetDetailBottomSheet({ + this.controller, + this.initialChildSize = 0.35, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + + final isTrashEnable = ref.watch( + serverInfoProvider.select((state) => state.serverFeatures.trash), + ); + + final actions = [ + const ShareActionButton(), + if (asset.hasRemote) ...[ + const ShareLinkActionButton(source: ActionSource.viewer), + const ArchiveActionButton(source: ActionSource.viewer), + if (!asset.hasLocal) const DownloadActionButton(), + isTrashEnable + ? const TrashActionButton(source: ActionSource.viewer) + : const DeletePermanentActionButton(source: ActionSource.viewer), + const MoveToLockFolderActionButton( + source: ActionSource.viewer, + ), + ], + if (asset.storage == AssetState.local) ...[ + const DeleteLocalActionButton(), + const UploadActionButton(), + ], + ]; + + return BaseBottomSheet( + actions: actions, + slivers: const [_AssetDetailBottomSheet()], + controller: controller, + initialChildSize: initialChildSize, + minChildSize: 0.1, + maxChildSize: 0.88, + expand: false, + shouldCloseOnMinExtent: false, + resizeOnScroll: false, + ); + } +} + +class _AssetDetailBottomSheet extends ConsumerWidget { + const _AssetDetailBottomSheet(); + + String _getDateTime(BuildContext ctx, BaseAsset asset) { + 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'; + } + + 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 fileSize = + exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => + '$fileSize$_kSeparator$resolution', + }; + } + + String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + final fNumber = + exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final exposureTime = + exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final focalLength = + exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + + return [fNumber, exposureTime, focalLength, iso] + .where((spec) => spec != null && spec.isNotEmpty) + .join(_kSeparator); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + + return SliverList.list( + children: [ + // Asset Date and Time + _SheetTile( + title: _getDateTime(context, asset), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + const SheetLocationDetails(), + // Details header + _SheetTile( + title: 'exif_bottom_sheet_details'.t(context: context), + titleStyle: context.textTheme.labelLarge, + ), + // File info + _SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.w600), + leading: Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 30, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getFileInfo(asset, exifInfo), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color?.withAlpha(200), + ), + ), + // Camera info + if (cameraTitle != null) + _SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.w600), + leading: Icon( + Icons.camera_outlined, + size: 30, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color?.withAlpha(200), + ), + ), + ], + ); + } +} + +class _SheetTile extends StatelessWidget { + final String title; + final Widget? leading; + final String? subtitle; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + + const _SheetTile({ + required this.title, + this.titleStyle, + this.leading, + this.subtitle, + this.subtitleStyle, + }); + + @override + Widget build(BuildContext context) { + final Widget titleWidget; + if (leading == null) { + titleWidget = LimitedBox( + maxWidth: double.infinity, + child: Text(title, style: titleStyle), + ); + } else { + titleWidget = Container( + width: double.infinity, + padding: const EdgeInsets.only(left: 15), + child: Text(title, style: titleStyle), + ); + } + + final Widget? subtitleWidget; + if (leading == null && subtitle != null) { + subtitleWidget = Text(subtitle!, style: subtitleStyle); + } else if (leading != null && subtitle != null) { + subtitleWidget = Padding( + padding: const EdgeInsets.only(left: 15), + child: Text(subtitle!, style: subtitleStyle), + ); + } else { + subtitleWidget = null; + } + + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + title: titleWidget, + titleAlignment: ListTileTitleAlignment.center, + leading: leading, + contentPadding: leading == null ? null : const EdgeInsets.only(left: 25), + subtitle: subtitleWidget, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart new file mode 100644 index 0000000000..2d22d063bd --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart @@ -0,0 +1,127 @@ +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/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class SheetLocationDetails extends ConsumerStatefulWidget { + const SheetLocationDetails({super.key}); + + @override + ConsumerState createState() => _SheetLocationDetailsState(); +} + +class _SheetLocationDetailsState extends ConsumerState { + BaseAsset? asset; + ExifInfo? exifInfo; + MapLibreMapController? _mapController; + + String? _getLocationName(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + final cityName = exifInfo.city; + final stateName = exifInfo.state; + + if (cityName != null && stateName != null) { + return "$cityName, $stateName"; + } + return null; + } + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + } + + void _onExifChanged( + AsyncValue? previous, + AsyncValue current, + ) { + asset = ref.read(currentAssetNotifier); + setState(() { + exifInfo = current.valueOrNull; + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + if (exifInfo != null && hasCoordinates) { + _mapController?.moveCamera( + CameraUpdate.newLatLng( + LatLng(exifInfo!.latitude!, exifInfo!.longitude!), + ), + ); + } + }); + } + + @override + void initState() { + super.initState(); + ref.listenManual( + currentAssetExifProvider, + _onExifChanged, + fireImmediately: true, + ); + } + + @override + Widget build(BuildContext context) { + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + + // Guard no lat/lng + if (!hasCoordinates || + (asset is LocalAsset && !(asset as LocalAsset).hasRemote)) { + return const SizedBox.shrink(); + } + + final remoteId = asset is LocalAsset + ? (asset as LocalAsset).remoteId + : (asset as RemoteAsset).id; + final locationName = _getLocationName(exifInfo); + final coordinates = + "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}"; + + return Padding( + padding: EdgeInsets.symmetric( + vertical: 16.0, + horizontal: context.isMobile ? 16.0 : 56.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + "exif_bottom_sheet_location".t(context: context), + style: context.textTheme.labelLarge, + ), + ), + ExifMap( + exifInfo: exifInfo!, + markerId: remoteId, + onMapCreated: _onMapCreated, + ), + const SizedBox(height: 15), + if (locationName != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + locationName, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + coordinates, + style: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color?.withAlpha(150), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart new file mode 100644 index 0000000000..b7e8477073 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -0,0 +1,114 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +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/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_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'; +import 'package:immich_mobile/providers/user.provider.dart'; + +class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { + const ViewerTopAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + + final user = ref.watch(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + + final isShowingSheet = ref + .watch(assetViewerProvider.select((state) => state.showingBottomSheet)); + int opacity = ref.watch( + assetViewerProvider.select((state) => state.backgroundOpacity), + ); + final showControls = + ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0; + } + + final actions = [ + if (asset.hasRemote && isOwner && !asset.isFavorite) + const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), + if (asset.hasRemote && isOwner && asset.isFavorite) + const UnFavoriteActionButton( + source: ActionSource.viewer, + menuItem: true, + ), + const _KebabMenu(), + ]; + + return IgnorePointer( + ignoring: opacity < 255, + child: AnimatedOpacity( + opacity: opacity / 255, + duration: Durations.short2, + child: AppBar( + backgroundColor: + isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), + leading: const _AppBarBackButton(), + iconTheme: const IconThemeData(size: 22, color: Colors.white), + actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), + shape: const Border(), + actions: isShowingSheet ? null : actions, + ), + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(60.0); +} + +class _KebabMenu extends ConsumerWidget { + const _KebabMenu(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + onPressed: () { + EventStream.shared.emit(const ViewerOpenBottomSheetEvent()); + }, + icon: const Icon(Icons.more_vert_rounded), + ); + } +} + +class _AppBarBackButton extends ConsumerWidget { + const _AppBarBackButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isShowingSheet = ref + .watch(assetViewerProvider.select((state) => state.showingBottomSheet)); + final backgroundColor = + isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black; + final foregroundColor = + isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white; + + return Padding( + padding: const EdgeInsets.only(left: 12.0), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor, + shape: const CircleBorder(), + iconSize: 22, + iconColor: foregroundColor, + padding: EdgeInsets.zero, + elevation: isShowingSheet ? 4 : 0, + ), + onPressed: context.maybePop, + child: const Icon(Icons.arrow_back_rounded), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart index d041bca514..2db8ae2b4c 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart @@ -1,6 +1,7 @@ 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/theme_extensions.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; class BaseBottomSheet extends ConsumerStatefulWidget { @@ -74,10 +75,7 @@ class _BaseDraggableScrollableSheetState clipBehavior: Clip.antiAlias, elevation: 6.0, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), ), margin: const EdgeInsets.symmetric(horizontal: 0), child: CustomScrollView( @@ -86,17 +84,22 @@ class _BaseDraggableScrollableSheetState SliverToBoxAdapter( child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 10), const _DragHandle(), - const SizedBox(height: 16), - SizedBox( - height: 120, - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - children: widget.actions, + const SizedBox(height: 14), + if (widget.actions.isNotEmpty) + SizedBox( + height: 115, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: widget.actions, + ), ), - ), + if (widget.actions.isNotEmpty) ...[ + const Divider(indent: 16, endIndent: 16), + const SizedBox(height: 16), + ], ], ), ), @@ -118,7 +121,7 @@ class _DragHandle extends StatelessWidget { height: 6, width: 32, decoration: BoxDecoration( - color: context.themeData.dividerColor, + color: context.themeData.dividerColor.lighten(amount: 0.6), borderRadius: const BorderRadius.all(Radius.circular(20)), ), ); diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart index d122d188ff..f5ba759130 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_buton.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -39,8 +39,10 @@ class HomeBottomAppBar extends ConsumerWidget { const FavoriteActionButton(source: ActionSource.timeline), const DownloadActionButton(), isTrashEnable - ? const TrashActionButton() - : const DeletePermanentActionButton(), + ? const TrashActionButton(source: ActionSource.timeline) + : const DeletePermanentActionButton( + source: ActionSource.timeline, + ), const EditDateTimeActionButton(), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton( diff --git a/mobile/lib/presentation/widgets/images/full_image.widget.dart b/mobile/lib/presentation/widgets/images/full_image.widget.dart new file mode 100644 index 0000000000..77ea996b89 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/full_image.widget.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:octo_image/octo_image.dart'; + +class FullImage extends StatelessWidget { + const FullImage( + this.asset, { + required this.size, + this.fit = BoxFit.cover, + this.placeholder = const ThumbnailPlaceholder(), + super.key, + }); + + final BaseAsset asset; + final Size size; + final Widget? placeholder; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final provider = getFullImageProvider(asset, size: size); + return OctoImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 100), + placeholderBuilder: placeholder != null ? (_) => placeholder! : null, + image: provider, + width: size.width, + height: size.height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + provider.evict(); + return const Icon(Icons.image_not_supported_outlined, size: 32); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart new file mode 100644 index 0000000000..e79665baf7 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; + +ImageProvider getFullImageProvider( + BaseAsset asset, { + Size size = const Size(1080, 1920), +}) { + // Create new provider and cache it + final ImageProvider provider; + if (_shouldUseLocalAsset(asset)) { + provider = LocalFullImageProvider(asset: asset as LocalAsset, size: size); + } else { + final String assetId; + if (asset is LocalAsset && asset.hasRemote) { + assetId = asset.remoteId!; + } else if (asset is RemoteAsset) { + assetId = asset.id; + } else { + throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); + } + provider = RemoteFullImageProvider(assetId: assetId); + } + + return provider; +} + +ImageProvider getThumbnailImageProvider({ + BaseAsset? asset, + String? remoteId, + Size size = const Size.square(256), +}) { + assert( + asset != null || remoteId != null, + 'Either asset or remoteId must be provided', + ); + + if (remoteId != null) { + return RemoteThumbProvider(assetId: remoteId); + } + + if (_shouldUseLocalAsset(asset!)) { + return LocalThumbProvider(asset: asset as LocalAsset, size: size); + } + + final String assetId; + if (asset is LocalAsset && asset.hasRemote) { + assetId = asset.remoteId!; + } else if (asset is RemoteAsset) { + assetId = asset.id; + } else { + throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); + } + + return RemoteThumbProvider(assetId: assetId); +} + +bool _shouldUseLocalAsset(BaseAsset asset) => + asset is LocalAsset && + (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart new file mode 100644 index 0000000000..214dede1af --- /dev/null +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; +import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; +import 'package:logging/logging.dart'; + +class LocalThumbProvider extends ImageProvider { + final AssetMediaRepository _assetMediaRepository = + const AssetMediaRepository(); + final CacheManager? cacheManager; + + final LocalAsset asset; + final Size size; + + const LocalThumbProvider({ + required this.asset, + this.size = const Size.square(kTimelineFixedTileExtent), + this.cacheManager, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + LocalThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ThumbnailImageCacheManager(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, cache, decode), + scale: 1.0, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset', key.asset), + ], + ); + } + + Future _codec( + LocalThumbProvider key, + CacheManager cache, + ImageDecoderCallback decode, + ) async { + final cacheKey = + '${key.asset.id}-${key.asset.updatedAt}-${key.size.width}x${key.size.height}'; + + final fileFromCache = await cache.getFileFromCache(cacheKey); + if (fileFromCache != null) { + try { + final buffer = + await ImmutableBuffer.fromFilePath(fileFromCache.file.path); + return decode(buffer); + } catch (_) {} + } + + final thumbnailBytes = + await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size); + if (thumbnailBytes == null) { + PaintingBinding.instance.imageCache.evict(key); + throw StateError( + "Loading thumb for local photo ${key.asset.name} failed", + ); + } + + final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); + unawaited(cache.putFile(cacheKey, thumbnailBytes)); + return decode(buffer); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is LocalThumbProvider) { + return asset.id == other.asset.id && + asset.updatedAt == other.asset.updatedAt; + } + return false; + } + + @override + int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode; +} + +class LocalFullImageProvider extends ImageProvider { + final AssetMediaRepository _assetMediaRepository = + const AssetMediaRepository(); + final StorageRepository _storageRepository = const StorageRepository(); + + final LocalAsset asset; + final Size size; + + const LocalFullImageProvider({ + required this.asset, + required this.size, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) { + return MultiImageStreamCompleter( + codec: _codec(key, decode), + scale: 1.0, + informationCollector: () sync* { + yield ErrorDescription(asset.name); + }, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) async* { + try { + switch (key.asset.type) { + case AssetType.image: + yield* _decodeProgressive(key, decode); + break; + case AssetType.video: + final codec = await _getThumbnailCodec(key, decode); + if (codec == null) { + throw StateError("Failed to load preview for ${key.asset.name}"); + } + yield codec; + break; + case AssetType.other: + case AssetType.audio: + throw StateError('Unsupported asset type ${key.asset.type}'); + } + } catch (error, stack) { + Logger('ImmichLocalImageProvider') + .severe('Error loading local image ${key.asset.name}', error, stack); + throw const ImageLoadingException( + 'Could not load image from local storage', + ); + } + } + + Future _getThumbnailCodec( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) async { + final thumbBytes = + await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size); + if (thumbBytes == null) { + return null; + } + final buffer = await ImmutableBuffer.fromUint8List(thumbBytes); + return decode(buffer); + } + + Stream _decodeProgressive( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) async* { + final file = await _storageRepository.getFileForAsset(key.asset); + if (file == null) { + throw StateError("Opening file for asset ${key.asset.name} failed"); + } + + final fileSize = await file.length(); + final devicePixelRatio = + PlatformDispatcher.instance.views.first.devicePixelRatio; + final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB + final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$')); + final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS); + + if (isProgressive) { + try { + final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2; + final size = Size( + (key.size.width * progressiveMultiplier).clamp(256, 1024), + (key.size.height * progressiveMultiplier).clamp(256, 1024), + ); + final mediumThumb = + await _assetMediaRepository.getThumbnail(key.asset.id, size: size); + if (mediumThumb != null) { + final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); + yield await decode(mediumBuffer); + } + } catch (_) {} + } + + // Load original only when the file is smaller or if the user wants to load original images + // Or load a slightly larger image for progressive loading + if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) { + final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6; + final size = Size( + (key.size.width * progressiveMultiplier).clamp(512, 2048), + (key.size.height * progressiveMultiplier).clamp(512, 2048), + ); + final highThumb = + await _assetMediaRepository.getThumbnail(key.asset.id, size: size); + if (highThumb != null) { + final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); + yield await decode(highBuffer); + } + return; + } + + final buffer = await ImmutableBuffer.fromFilePath(file.path); + yield await decode(buffer); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is LocalFullImageProvider) { + return asset.id == other.asset.id && + asset.updatedAt == other.asset.updatedAt && + size == other.size; + } + return false; + } + + @override + int get hashCode => + asset.id.hashCode ^ asset.updatedAt.hashCode ^ size.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/local_thumb_provider.dart b/mobile/lib/presentation/widgets/images/local_thumb_provider.dart deleted file mode 100644 index 11b2f2b08e..0000000000 --- a/mobile/lib/presentation/widgets/images/local_thumb_provider.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; -import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; - -class LocalThumbProvider extends ImageProvider { - final AssetMediaRepository _assetMediaRepository = - const AssetMediaRepository(); - final CacheManager? cacheManager; - - final LocalAsset asset; - final double height; - final double width; - - LocalThumbProvider({ - required this.asset, - this.height = kTimelineFixedTileExtent, - this.width = kTimelineFixedTileExtent, - this.cacheManager, - }); - - @override - Future obtainKey( - ImageConfiguration configuration, - ) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter loadImage( - LocalThumbProvider key, - ImageDecoderCallback decode, - ) { - final cache = cacheManager ?? ThumbnailImageCacheManager(); - return MultiFrameImageStreamCompleter( - codec: _codec(key, cache, decode), - scale: 1.0, - informationCollector: () => [ - DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Asset', key.asset), - ], - ); - } - - Future _codec( - LocalThumbProvider key, - CacheManager cache, - ImageDecoderCallback decode, - ) async { - final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height'; - - final fileFromCache = await cache.getFileFromCache(cacheKey); - if (fileFromCache != null) { - try { - final buffer = - await ImmutableBuffer.fromFilePath(fileFromCache.file.path); - return await decode(buffer); - } catch (_) {} - } - - final thumbnailBytes = await _assetMediaRepository.getThumbnail( - key.asset.id, - size: Size(key.width, key.height), - ); - if (thumbnailBytes == null) { - PaintingBinding.instance.imageCache.evict(key); - throw StateError( - "Loading thumb for local photo ${key.asset.name} failed", - ); - } - - final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); - unawaited(cache.putFile(cacheKey, thumbnailBytes)); - return decode(buffer); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is LocalThumbProvider) { - return asset.id == other.asset.id && - asset.updatedAt == other.asset.updatedAt; - } - return false; - } - - @override - int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode; -} diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart new file mode 100644 index 0000000000..14d13a08d8 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/providers/image/cache/image_loader.dart'; +import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class RemoteThumbProvider extends ImageProvider { + final String assetId; + final CacheManager? cacheManager; + + const RemoteThumbProvider({ + required this.assetId, + this.cacheManager, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + RemoteThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? RemoteImageCacheManager(); + final chunkController = StreamController(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, cache, decode, chunkController), + scale: 1.0, + chunkEvents: chunkController.stream, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + ], + ); + } + + Future _codec( + RemoteThumbProvider key, + CacheManager cache, + ImageDecoderCallback decode, + StreamController chunkController, + ) async { + final preview = getThumbnailUrlForRemoteId( + key.assetId, + ); + + return ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + chunkEvents: chunkController, + ).whenComplete(chunkController.close); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is RemoteThumbProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} + +class RemoteFullImageProvider extends ImageProvider { + final String assetId; + final CacheManager? cacheManager; + + const RemoteFullImageProvider({ + required this.assetId, + this.cacheManager, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + RemoteFullImageProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? RemoteImageCacheManager(); + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, cache, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + Stream _codec( + RemoteFullImageProvider key, + CacheManager cache, + ImageDecoderCallback decode, + StreamController chunkController, + ) async* { + yield await ImageLoader.loadImageFromCache( + getPreviewUrlForRemoteId(key.assetId), + cache: cache, + decode: decode, + chunkEvents: chunkController, + ); + + if (AppSetting.get(Setting.loadOriginal)) { + yield await ImageLoader.loadImageFromCache( + getOriginalUrlForRemoteId(key.assetId), + cache: cache, + decode: decode, + chunkEvents: chunkController, + ); + } + await chunkController.close(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is RemoteFullImageProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart b/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart deleted file mode 100644 index f9388ea5d6..0000000000 --- a/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; -import 'package:immich_mobile/providers/image/cache/image_loader.dart'; -import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; - -class RemoteThumbProvider extends ImageProvider { - final String assetId; - final double height; - final double width; - final CacheManager? cacheManager; - - const RemoteThumbProvider({ - required this.assetId, - this.height = kTimelineFixedTileExtent, - this.width = kTimelineFixedTileExtent, - this.cacheManager, - }); - - @override - Future obtainKey( - ImageConfiguration configuration, - ) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter loadImage( - RemoteThumbProvider key, - ImageDecoderCallback decode, - ) { - final cache = cacheManager ?? ThumbnailImageCacheManager(); - final chunkController = StreamController(); - return MultiFrameImageStreamCompleter( - codec: _codec(key, cache, decode, chunkController), - scale: 1.0, - chunkEvents: chunkController.stream, - informationCollector: () => [ - DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Asset Id', key.assetId), - ], - ); - } - - Future _codec( - RemoteThumbProvider key, - CacheManager cache, - ImageDecoderCallback decode, - StreamController chunkController, - ) async { - final preview = getThumbnailUrlForRemoteId( - key.assetId, - ); - - return ImageLoader.loadImageFromCache( - preview, - cache: cache, - decode: decode, - chunkEvents: chunkController, - ).whenComplete(chunkController.close); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is RemoteThumbProvider) { - return assetId == other.assetId; - } - - return false; - } - - @override - int get hashCode => assetId.hashCode; -} diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index bdf0baa3ca..f54c32dac1 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart'; @@ -25,49 +24,12 @@ class Thumbnail extends StatelessWidget { final Size size; final BoxFit fit; - static ImageProvider imageProvider({ - BaseAsset? asset, - String? remoteId, - Size size = const Size.square(256), - }) { - assert( - asset != null || remoteId != null, - 'Either asset or remoteId must be provided', - ); - - if (remoteId != null) { - return RemoteThumbProvider( - assetId: remoteId, - height: size.height, - width: size.width, - ); - } - - if (asset is LocalAsset) { - return LocalThumbProvider( - asset: asset, - height: size.height, - width: size.width, - ); - } - - if (asset is RemoteAsset) { - return RemoteThumbProvider( - assetId: asset.id, - height: size.height, - width: size.width, - ); - } - - throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); - } - @override Widget build(BuildContext context) { final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; final provider = - imageProvider(asset: asset, remoteId: remoteId, size: size); + getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size); return OctoImage.fromSet( image: provider, diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index f243fb1130..571d1c5412 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -59,10 +59,13 @@ class ThumbnailTile extends ConsumerWidget { child: Stack( children: [ Positioned.fill( - child: Thumbnail( - asset: asset, - fit: fit, - size: size, + child: Hero( + tag: asset.heroTag, + child: Thumbnail( + asset: asset, + fit: fit, + size: size, + ), ), ), if (asset.isVideo) diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart new file mode 100644 index 0000000000..79e6288a72 --- /dev/null +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -0,0 +1,64 @@ +// ignore_for_file: require_trailing_commas + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; + +import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart'; + +class DriftMemoryBottomInfo extends StatelessWidget { + final DriftMemory memory; + final String title; + const DriftMemoryBottomInfo({ + super.key, + required this.memory, + required this.title, + }); + + @override + Widget build(BuildContext context) { + final df = DateFormat.yMMMMd(); + final fileCreatedDate = memory.assets.first.createdAt; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: Colors.grey[400], + fontSize: 13.0, + fontWeight: FontWeight.w500, + ), + ), + Text( + df.format(fileCreatedDate), + style: const TextStyle( + color: Colors.white, + fontSize: 15.0, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + MaterialButton( + minWidth: 0, + onPressed: () { + context.maybePop(); + scrollToDateNotifierProvider.scrollToDate(fileCreatedDate); + }, + shape: const CircleBorder(), + color: Colors.white.withValues(alpha: 0.2), + elevation: 0, + child: const Icon( + Icons.open_in_new, + color: Colors.white, + ), + ), + ]), + ); + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart new file mode 100644 index 0000000000..8268196089 --- /dev/null +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -0,0 +1,159 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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/presentation/widgets/images/full_image.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; + +class DriftMemoryCard extends StatelessWidget { + final RemoteAsset asset; + final String title; + final bool showTitle; + final Function()? onVideoEnded; + + const DriftMemoryCard({ + required this.asset, + required this.title, + required this.showTitle, + this.onVideoEnded, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.black, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25.0)), + side: BorderSide( + color: Colors.black, + width: 1.0, + ), + ), + clipBehavior: Clip.hardEdge, + child: Stack( + children: [ + SizedBox.expand( + child: _BlurredBackdrop(asset: asset), + ), + LayoutBuilder( + builder: (context, constraints) { + // Determine the fit using the aspect ratio + BoxFit fit = BoxFit.contain; + if (asset.width != null && asset.height != null) { + final aspectRatio = asset.width! / asset.height!; + final phoneAspectRatio = + constraints.maxWidth / constraints.maxHeight; + // Look for a 25% difference in either direction + if (phoneAspectRatio * .75 < aspectRatio && + phoneAspectRatio * 1.25 > aspectRatio) { + // Cover to look nice if we have nearly the same aspect ratio + fit = BoxFit.cover; + } + } + + if (asset.isImage) { + return Hero( + tag: 'memory-${asset.id}', + child: FullImage( + asset, + fit: fit, + size: const Size(double.infinity, double.infinity), + ), + ); + } else { + return Hero( + tag: 'memory-${asset.id}', + // child: SizedBox( + // width: context.width, + // height: context.height, + // child: NativeVideoViewerPage( + // key: ValueKey(asset.id), + // asset: asset, + // showControls: false, + // playbackDelayFactor: 2, + // image: ImmichImage( + // asset, + // width: context.width, + // height: context.height, + // fit: BoxFit.contain, + // ), + // ), + // ), + child: FullImage( + asset, + fit: fit, + size: const Size(double.infinity, double.infinity), + ), + ); + } + }, + ), + if (showTitle) + Positioned( + left: 18.0, + bottom: 18.0, + child: Text( + title, + style: context.textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _BlurredBackdrop extends HookWidget { + final RemoteAsset asset; + + const _BlurredBackdrop({required this.asset}); + + @override + Widget build(BuildContext context) { + final blurhash = useDriftBlurHashRef(asset).value; + if (blurhash != null) { + // Use a nice cheap blur hash image decoration + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: MemoryImage( + blurhash, + ), + fit: BoxFit.cover, + ), + ), + child: Container( + color: Colors.black.withValues(alpha: 0.2), + ), + ); + } else { + // Fall back to using a more expensive image filtered + // Since the ImmichImage is already precached, we can + // safely use that as the image provider + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: getFullImageProvider( + asset, + size: Size(context.width, context.height), + ), + fit: BoxFit.cover, + ), + ), + child: Container( + color: Colors.black.withValues(alpha: 0.2), + ), + ), + ); + } + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart new file mode 100644 index 0000000000..aa21f36dd1 --- /dev/null +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -0,0 +1,115 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class DriftMemoryLane extends ConsumerWidget { + final List memories; + + const DriftMemoryLane({super.key, required this.memories}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + ), + child: CarouselView( + itemExtent: 145.0, + shrinkExtent: 1.0, + elevation: 2, + backgroundColor: Colors.black, + overlayColor: WidgetStateProperty.all( + Colors.white.withValues(alpha: 0.1), + ), + onTap: (index) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + if (memories[index].assets.isNotEmpty) { + final asset = memories[index].assets[0]; + ref.read(currentAssetNotifier.notifier).setAsset(asset); + + if (asset.isVideo) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + } + + context.pushRoute( + DriftMemoryRoute( + memories: memories, + memoryIndex: index, + ), + ); + }, + children: + memories.map((memory) => DriftMemoryCard(memory: memory)).toList(), + ), + ); + } +} + +class DriftMemoryCard extends ConsumerWidget { + const DriftMemoryCard({ + super.key, + required this.memory, + }); + + final DriftMemory memory; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final yearsAgo = DateTime.now().year - memory.data.year; + final title = 'years_ago'.t( + context: context, + args: { + 'years': yearsAgo.toString(), + }, + ); + return Center( + child: Stack( + children: [ + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.darken, + ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', + child: SizedBox( + width: 205, + height: 200, + child: Thumbnail( + remoteId: memory.assets[0].id, + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 4de9eaad38..3fbba803db 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -12,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart' import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; class FixedSegment extends Segment { final double tileHeight; @@ -35,50 +37,24 @@ class FixedSegment extends Segment { @override double indexToLayoutOffset(int index) { - index -= gridIndex; - if (index < 0) { - return startOffset; - } - return gridOffset + (mainAxisExtend * index); + final relativeIndex = index - gridIndex; + return relativeIndex < 0 + ? startOffset + : gridOffset + (mainAxisExtend * relativeIndex); } @override int getMinChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= gridOffset; - if (!scrollOffset.isFinite || scrollOffset < 0) { - return firstIndex; - } - final rowsAbove = (scrollOffset / mainAxisExtend).floor(); - return gridIndex + rowsAbove; + final adjustedOffset = scrollOffset - gridOffset; + if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex; + return gridIndex + (adjustedOffset / mainAxisExtend).floor(); } @override int getMaxChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= gridOffset; - if (!scrollOffset.isFinite || scrollOffset < 0) { - return firstIndex; - } - final firstRowBelow = (scrollOffset / mainAxisExtend).ceil(); - return gridIndex + firstRowBelow - 1; - } - - void _handleOnTap(WidgetRef ref, BaseAsset asset) { - final multiSelectState = ref.read(multiSelectProvider); - if (!multiSelectState.isEnabled) { - return; - } - - ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); - } - - void _handleOnLongPress(WidgetRef ref, BaseAsset asset) { - final multiSelectState = ref.read(multiSelectProvider); - if (multiSelectState.isEnabled) { - return; - } - - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + final adjustedOffset = scrollOffset - gridOffset; + if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex; + return gridIndex + (adjustedOffset / mainAxisExtend).ceil() - 1; } @override @@ -97,132 +73,128 @@ class FixedSegment extends Segment { ); } - return _buildRow(firstAssetIndex + assetIndex, numberOfAssets); - } - - Widget _buildRow(int assetIndex, int count) => RepaintBoundary( - child: Consumer( - builder: (ctx, ref, _) { - final isScrubbing = - ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); - final timelineService = ref.read(timelineServiceProvider); - - // Create stable callback references to prevent unnecessary rebuilds - onTap(BaseAsset asset) => _handleOnTap(ref, asset); - onLongPress(BaseAsset asset) => _handleOnLongPress(ref, asset); - - // Timeline is being scrubbed, show placeholders - if (isScrubbing) { - return SegmentBuilder.buildPlaceholder( - ctx, - count, - size: Size.square(tileHeight), - spacing: spacing, - ); - } - - // Bucket is already loaded, show the assets - if (timelineService.hasRange(assetIndex, count)) { - final assets = timelineService.getAssets(assetIndex, count); - return _buildAssetRow( - ctx, - assets, - baseAssetIndex: assetIndex, - onTap: onTap, - onLongPress: onLongPress, - ); - } - - // Bucket is not loaded, show placeholders and load the bucket - return FutureBuilder( - future: timelineService.loadAssets(assetIndex, count), - builder: (ctxx, snap) { - if (snap.connectionState != ConnectionState.done) { - return SegmentBuilder.buildPlaceholder( - ctx, - count, - size: Size.square(tileHeight), - spacing: spacing, - ); - } - - return _buildAssetRow( - ctxx, - snap.requireData, - baseAssetIndex: assetIndex, - onTap: onTap, - onLongPress: onLongPress, - ); - }, - ); - }, - ), - ); - - Widget _buildAssetRow( - BuildContext context, - List assets, { - required void Function(BaseAsset) onTap, - required void Function(BaseAsset) onLongPress, - required int baseAssetIndex, - }) => - FixedTimelineRow( - dimension: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: List.generate( - assets.length, - (i) => _AssetTileWidget( - key: ValueKey(_generateUniqueKey(assets[i], baseAssetIndex + i)), - asset: assets[i], - onTap: onTap, - onLongPress: onLongPress, - ), - ), - ); - - /// Generates a unique key for an asset that handles different asset types - /// and prevents duplicate keys even when assets have the same name/timestamp - String _generateUniqueKey(BaseAsset asset, int assetIndex) { - // Try to get the most unique identifier based on asset type - if (asset is RemoteAsset) { - // For remote/merged assets, use the remote ID which is globally unique - return 'asset_${asset.id}'; - } else if (asset is LocalAsset) { - // For local assets, use the local ID which should be unique per device - return 'local_${asset.id}'; - } else { - // Fallback for any other BaseAsset implementation - // Use checksum if available for additional uniqueness - final checksum = asset.checksum; - if (checksum != null && checksum.isNotEmpty) { - return 'checksum_${checksum.hashCode}'; - } else { - // Last resort: use global asset index + object hash for uniqueness - return 'fallback_${assetIndex}_${asset.hashCode}_${asset.createdAt.microsecondsSinceEpoch}'; - } - } + return _FixedSegmentRow( + assetIndex: firstAssetIndex + assetIndex, + assetCount: numberOfAssets, + tileHeight: tileHeight, + spacing: spacing, + ); } } -class _AssetTileWidget extends StatelessWidget { +class _FixedSegmentRow extends ConsumerWidget { + final int assetIndex; + final int assetCount; + final double tileHeight; + final double spacing; + + const _FixedSegmentRow({ + required this.assetIndex, + required this.assetCount, + required this.tileHeight, + required this.spacing, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isScrubbing = + ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); + final timelineService = ref.read(timelineServiceProvider); + + if (isScrubbing) { + return _buildPlaceholder(context); + } + + if (timelineService.hasRange(assetIndex, assetCount)) { + return _buildAssetRow( + context, + timelineService.getAssets(assetIndex, assetCount), + ); + } + + return FutureBuilder>( + future: timelineService.loadAssets(assetIndex, assetCount), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return _buildPlaceholder(context); + } + return _buildAssetRow(context, snapshot.requireData); + }, + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return SegmentBuilder.buildPlaceholder( + context, + assetCount, + size: Size.square(tileHeight), + spacing: spacing, + ); + } + + Widget _buildAssetRow(BuildContext context, List assets) { + return FixedTimelineRow( + dimension: tileHeight, + spacing: spacing, + textDirection: Directionality.of(context), + children: [ + for (int i = 0; i < assets.length; i++) + _AssetTileWidget( + key: ValueKey(assets[i].heroTag), + asset: assets[i], + assetIndex: assetIndex + i, + ), + ], + ); + } +} + +class _AssetTileWidget extends ConsumerWidget { final BaseAsset asset; - final void Function(BaseAsset) onTap; - final void Function(BaseAsset) onLongPress; + final int assetIndex; const _AssetTileWidget({ super.key, required this.asset, - required this.onTap, - required this.onLongPress, + required this.assetIndex, }); + void _handleOnTap( + BuildContext ctx, + WidgetRef ref, + int assetIndex, + BaseAsset asset, + ) { + final multiSelectState = ref.read(multiSelectProvider); + if (!multiSelectState.isEnabled) { + ctx.pushRoute( + AssetViewerRoute( + initialIndex: assetIndex, + timelineService: ref.read(timelineServiceProvider), + ), + ); + return; + } + + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } + + void _handleOnLongPress(WidgetRef ref, BaseAsset asset) { + final multiSelectState = ref.read(multiSelectProvider); + if (multiSelectState.isEnabled) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return RepaintBoundary( child: GestureDetector( - onTap: () => onTap(asset), - onLongPress: () => onLongPress(asset), + onTap: () => _handleOnTap(context, ref, assetIndex, asset), + onLongPress: () => _handleOnLongPress(ref, asset), child: ThumbnailTile(asset), ), ); diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index e660f77767..f88c123e1a 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -26,6 +26,8 @@ class Scrubber extends ConsumerStatefulWidget { final double bottomPadding; + final double? monthSegmentSnappingOffset; + Scrubber({ super.key, Key? scrollThumbKey, @@ -33,6 +35,7 @@ class Scrubber extends ConsumerStatefulWidget { required this.timelineHeight, this.topPadding = 0, this.bottomPadding = 0, + this.monthSegmentSnappingOffset, required this.child, }) : assert(child.scrollDirection == Axis.vertical); @@ -296,7 +299,10 @@ class ScrubberState extends ConsumerState final viewportHeight = _scrollController.position.viewportDimension; final targetScrollOffset = layoutSegment.startOffset; - final centeredOffset = targetScrollOffset - (viewportHeight / 4) + 100; + final centeredOffset = targetScrollOffset - + (viewportHeight / 4) + + 100 + + (widget.monthSegmentSnappingOffset ?? 0.0); _scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent)); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index fd0806cff0..04015aafe9 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart'; @@ -18,7 +20,10 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; class Timeline extends StatelessWidget { - const Timeline({super.key}); + const Timeline({super.key, this.topSliverWidget, this.topSliverWidgetHeight}); + + final Widget? topSliverWidget; + final double? topSliverWidgetHeight; @override Widget build(BuildContext context) { @@ -36,103 +41,135 @@ class Timeline extends StatelessWidget { ), ), ], - child: const _SliverTimeline(), + child: _SliverTimeline( + topSliverWidget: topSliverWidget, + topSliverWidgetHeight: topSliverWidgetHeight, + ), ), ), ); } } -class _SliverTimeline extends StatefulWidget { - const _SliverTimeline(); +class _SliverTimeline extends ConsumerStatefulWidget { + const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight}); + + final Widget? topSliverWidget; + final double? topSliverWidgetHeight; @override - State createState() => _SliverTimelineState(); + ConsumerState createState() => _SliverTimelineState(); } -class _SliverTimelineState extends State<_SliverTimeline> { +class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final _scrollController = ScrollController(); + StreamSubscription? _reloadSubscription; + + @override + void initState() { + super.initState(); + _reloadSubscription = + EventStream.shared.listen((_) => setState(() {})); + } @override void dispose() { _scrollController.dispose(); + _reloadSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext _) { - return Consumer( - builder: (context, ref, child) { - final asyncSegments = ref.watch(timelineSegmentProvider); - final maxHeight = - ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); - final isMultiSelectEnabled = - ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return asyncSegments.widgetWhen( - onData: (segments) { - final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; - final statusBarHeight = context.padding.top; - final totalAppBarHeight = statusBarHeight + kToolbarHeight; - const scrubberBottomPadding = 100.0; + final asyncSegments = ref.watch(timelineSegmentProvider); + final maxHeight = + ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + return asyncSegments.widgetWhen( + onData: (segments) { + final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + final statusBarHeight = context.padding.top; + final totalAppBarHeight = statusBarHeight + kToolbarHeight; + const scrubberBottomPadding = 100.0; - return PrimaryScrollController( - controller: _scrollController, - child: Stack( - children: [ - Scrubber( - layoutSegments: segments, - timelineHeight: maxHeight, - topPadding: totalAppBarHeight + 10, - bottomPadding: - context.padding.bottom + scrubberBottomPadding, - child: CustomScrollView( - primary: true, - cacheExtent: maxHeight * 2, - slivers: [ - SliverAnimatedOpacity( - duration: Durations.medium1, - opacity: isMultiSelectEnabled ? 0 : 1, - sliver: const ImmichSliverAppBar( - floating: true, - pinned: false, - snap: false, - ), - ), - _SliverSegmentedList( - segments: segments, - delegate: SliverChildBuilderDelegate( - (ctx, index) { - if (index >= childCount) return null; - final segment = segments.findByIndex(index); - return segment?.builder(ctx, index) ?? - const SizedBox.shrink(); - }, - childCount: childCount, - addAutomaticKeepAlives: false, - // We add repaint boundary around tiles, so skip the auto boundaries - addRepaintBoundaries: false, - ), - ), - const SliverPadding( - padding: EdgeInsets.only( - bottom: scrubberBottomPadding, - ), - ), - ], + return PrimaryScrollController( + controller: _scrollController, + child: Stack( + children: [ + Scrubber( + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: totalAppBarHeight + 10, + bottomPadding: context.padding.bottom + scrubberBottomPadding, + monthSegmentSnappingOffset: widget.topSliverWidgetHeight, + child: CustomScrollView( + primary: true, + cacheExtent: maxHeight * 2, + slivers: [ + const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, ), - ), - if (isMultiSelectEnabled) ...[ - const Positioned( - top: 60, - left: 25, - child: _MultiSelectStatusButton(), + if (widget.topSliverWidget != null) widget.topSliverWidget!, + _SliverSegmentedList( + segments: segments, + delegate: SliverChildBuilderDelegate( + (ctx, index) { + if (index >= childCount) return null; + final segment = segments.findByIndex(index); + return segment?.builder(ctx, index) ?? + const SizedBox.shrink(); + }, + childCount: childCount, + addAutomaticKeepAlives: false, + // We add repaint boundary around tiles, so skip the auto boundaries + addRepaintBoundaries: false, + ), + ), + const SliverPadding( + padding: EdgeInsets.only( + bottom: scrubberBottomPadding, + ), ), - const HomeBottomAppBar(), ], - ], + ), ), - ); - }, + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); + + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: const Positioned( + top: 60, + left: 25, + child: _MultiSelectStatusButton(), + ), + ), + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); + + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: const HomeBottomAppBar(), + ), + ], + ), ); }, ); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 940adf10ed..0c197ca683 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; @@ -39,37 +40,40 @@ class ActionNotifier extends Notifier { _service = ref.watch(actionServiceProvider); } - List _getIdsForSource(ActionSource source) { - final currentUser = ref.read(currentUserProvider); - if (T is RemoteAsset && currentUser == null) { - return []; - } + List _getRemoteIdsForSource(ActionSource source) { + return _getIdsForSource(source).toIds().toList(); + } + List _getOwnedRemoteForSource(ActionSource source) { + final ownerId = ref.read(currentUserProvider)?.id; + return _getIdsForSource(source) + .ownedAssets(ownerId) + .toIds() + .toList(); + } + + Iterable _getIdsForSource(ActionSource source) { final Set assets = switch (source) { ActionSource.timeline => ref.read(multiSelectProvider.select((s) => s.selectedAssets)), - ActionSource.viewer => {}, + ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { + BaseAsset asset => {asset}, + null => {}, + }, }; return switch (T) { - const (RemoteAsset) => assets - .where( - (asset) => asset is RemoteAsset && asset.ownerId == currentUser!.id, - ) - .cast() - .map((asset) => asset.id) - .toList(), - const (LocalAsset) => - assets.whereType().map((asset) => asset.id).toList(), - _ => [], - }; + const (RemoteAsset) => assets.whereType(), + const (LocalAsset) => assets.whereType(), + _ => [], + } as Iterable; } Future shareLink( ActionSource source, BuildContext context, ) async { - final ids = _getIdsForSource(source); + final ids = _getRemoteIdsForSource(source); try { await _service.shareLink(ids, context); return ActionResult(count: ids.length, success: true); @@ -84,7 +88,7 @@ class ActionNotifier extends Notifier { } Future favorite(ActionSource source) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { await _service.favorite(ids); return ActionResult(count: ids.length, success: true); @@ -99,7 +103,7 @@ class ActionNotifier extends Notifier { } Future unFavorite(ActionSource source) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { await _service.unFavorite(ids); return ActionResult(count: ids.length, success: true); @@ -114,7 +118,7 @@ class ActionNotifier extends Notifier { } Future archive(ActionSource source) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { await _service.archive(ids); return ActionResult(count: ids.length, success: true); @@ -129,7 +133,7 @@ class ActionNotifier extends Notifier { } Future unArchive(ActionSource source) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { await _service.unArchive(ids); return ActionResult(count: ids.length, success: true); @@ -144,7 +148,7 @@ class ActionNotifier extends Notifier { } Future moveToLockFolder(ActionSource source) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { await _service.moveToLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -159,7 +163,7 @@ class ActionNotifier extends Notifier { } Future removeFromLockFolder(ActionSource source) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { await _service.removeFromLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -173,11 +177,41 @@ class ActionNotifier extends Notifier { } } + Future trash(ActionSource source) async { + final ids = _getOwnedRemoteForSource(source); + try { + await _service.trash(ids); + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to trash assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } + + Future delete(ActionSource source) async { + final ids = _getOwnedRemoteForSource(source); + try { + await _service.delete(ids); + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to delete assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } + Future editLocation( ActionSource source, BuildContext context, ) async { - final ids = _getIdsForSource(source); + final ids = _getOwnedRemoteForSource(source); try { final isEdited = await _service.editLocation(ids, context); if (!isEdited) { @@ -195,3 +229,12 @@ class ActionNotifier extends Notifier { } } } + +extension on Iterable { + Iterable toIds() => map((e) => e.id); + + Iterable ownedAssets(String? ownerId) { + if (ownerId == null) return []; + return whereType().where((a) => a.ownerId == ownerId); + } +} diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 28a6bda278..0015986243 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -7,6 +8,13 @@ final localAssetRepository = Provider( (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), ); -final remoteAssetRepository = Provider( - (ref) => DriftRemoteAssetRepository(ref.watch(driftProvider)), +final remoteAssetRepositoryProvider = Provider( + (ref) => RemoteAssetRepository(ref.watch(driftProvider)), +); + +final assetServiceProvider = Provider( + (ref) => AssetService( + remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), + localAssetRepository: ref.watch(localAssetRepository), + ), ); diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart new file mode 100644 index 0000000000..996d5d816f --- /dev/null +++ b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +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'; + +final currentAssetNotifier = + AutoDisposeNotifierProvider( + CurrentAssetNotifier.new, +); + +class CurrentAssetNotifier extends AutoDisposeNotifier { + KeepAliveLink? _keepAliveLink; + StreamSubscription? _assetSubscription; + + @override + BaseAsset? build() => null; + + void setAsset(BaseAsset asset) { + _keepAliveLink?.close(); + _assetSubscription?.cancel(); + state = asset; + _assetSubscription = ref + .watch(assetServiceProvider) + .watchAsset(asset) + .listen((updatedAsset) { + if (updatedAsset != null) { + state = updatedAsset; + } + }); + _keepAliveLink = ref.keepAlive(); + } + + void dispose() { + _keepAliveLink?.close(); + _assetSubscription?.cancel(); + } +} + +final currentAssetExifProvider = FutureProvider.autoDispose( + (ref) { + final currentAsset = ref.watch(currentAssetNotifier); + if (currentAsset == null) { + return null; + } + return ref.watch(assetServiceProvider).getExif(currentAsset); + }, +); diff --git a/mobile/lib/providers/infrastructure/exif.provider.dart b/mobile/lib/providers/infrastructure/exif.provider.dart index af4bb933ec..59ad632927 100644 --- a/mobile/lib/providers/infrastructure/exif.provider.dart +++ b/mobile/lib/providers/infrastructure/exif.provider.dart @@ -8,7 +8,3 @@ part 'exif.provider.g.dart'; @Riverpod(keepAlive: true) IsarExifRepository exifRepository(Ref ref) => IsarExifRepository(ref.watch(isarProvider)); - -final remoteExifRepository = Provider( - (ref) => DriftRemoteExifRepository(ref.watch(driftProvider)), -); diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart new file mode 100644 index 0000000000..0e58943f55 --- /dev/null +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -0,0 +1,27 @@ +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/domain/services/memory.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'db.provider.dart'; + +final driftMemoryRepositoryProvider = Provider( + (ref) => DriftMemoryRepository(ref.watch(driftProvider)), +); + +final driftMemoryServiceProvider = Provider( + (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), +); + +final driftMemoryFutureProvider = + FutureProvider.autoDispose>((ref) async { + final user = ref.watch(currentUserProvider); + if (user == null) { + return []; + } + + final service = ref.watch(driftMemoryServiceProvider); + + return service.getMemoryLane(user.id); +}); diff --git a/mobile/lib/providers/infrastructure/storage.provider.dart b/mobile/lib/providers/infrastructure/storage.provider.dart index f9ac10b461..5bbbe51497 100644 --- a/mobile/lib/providers/infrastructure/storage.provider.dart +++ b/mobile/lib/providers/infrastructure/storage.provider.dart @@ -2,5 +2,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; final storageRepositoryProvider = Provider( - (ref) => StorageRepository(), + (ref) => const StorageRepository(), ); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 9631428409..0dff309172 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -48,6 +48,10 @@ class AssetApiRepository extends ApiRepository { return result; } + Future delete(List ids, bool force) async { + return _api.deleteAssets(AssetBulkDeleteDto(ids: ids, force: force)); + } + Future updateVisibility( List ids, AssetVisibilityEnum visibility, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 31e1d715a1..56e1aa0f96 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -2,7 +2,9 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; @@ -70,6 +72,8 @@ import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -81,6 +85,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/local_auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; @@ -371,6 +376,23 @@ class AppRouter extends RootStackRouter { page: RemoteTimelineRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: AssetViewerRoute.page, + guards: [_authGuard, _duplicateGuard], + type: RouteType.custom( + customRouteBuilder: (context, child, page) => PageRouteBuilder( + fullscreenDialog: page.fullscreenDialog, + settings: page, + pageBuilder: (_, __, ___) => child, + opaque: false, + ), + ), + ), + AutoRoute( + page: DriftMemoryRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 2ea10491b3..f8f37970f9 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -403,6 +403,58 @@ class ArchiveRoute extends PageRouteInfo { ); } +/// generated route for +/// [AssetViewerPage] +class AssetViewerRoute extends PageRouteInfo { + AssetViewerRoute({ + Key? key, + required int initialIndex, + required TimelineService timelineService, + List? children, + }) : super( + AssetViewerRoute.name, + args: AssetViewerRouteArgs( + key: key, + initialIndex: initialIndex, + timelineService: timelineService, + ), + initialChildren: children, + ); + + static const String name = 'AssetViewerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AssetViewerPage( + key: args.key, + initialIndex: args.initialIndex, + timelineService: args.timelineService, + ); + }, + ); +} + +class AssetViewerRouteArgs { + const AssetViewerRouteArgs({ + this.key, + required this.initialIndex, + required this.timelineService, + }); + + final Key? key; + + final int initialIndex; + + final TimelineService timelineService; + + @override + String toString() { + return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService}'; + } +} + /// generated route for /// [BackupAlbumSelectionPage] class BackupAlbumSelectionRoute extends PageRouteInfo { @@ -566,6 +618,58 @@ class DriftAlbumsRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftMemoryPage] +class DriftMemoryRoute extends PageRouteInfo { + DriftMemoryRoute({ + required List memories, + required int memoryIndex, + Key? key, + List? children, + }) : super( + DriftMemoryRoute.name, + args: DriftMemoryRouteArgs( + memories: memories, + memoryIndex: memoryIndex, + key: key, + ), + initialChildren: children, + ); + + static const String name = 'DriftMemoryRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftMemoryPage( + memories: args.memories, + memoryIndex: args.memoryIndex, + key: args.key, + ); + }, + ); +} + +class DriftMemoryRouteArgs { + const DriftMemoryRouteArgs({ + required this.memories, + required this.memoryIndex, + this.key, + }); + + final List memories; + + final int memoryIndex; + + final Key? key; + + @override + String toString() { + return 'DriftMemoryRouteArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}'; + } +} + /// generated route for /// [EditImagePage] class EditImageRoute extends PageRouteInfo { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index b5ddbac270..2f4c8cc926 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -2,10 +2,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -15,20 +13,17 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; final actionServiceProvider = Provider( (ref) => ActionService( ref.watch(assetApiRepositoryProvider), - ref.watch(remoteAssetRepository), - ref.watch(remoteExifRepository), + ref.watch(remoteAssetRepositoryProvider), ), ); class ActionService { final AssetApiRepository _assetApiRepository; - final DriftRemoteAssetRepository _remoteAssetRepository; - final DriftRemoteExifRepository _remoteExifRepository; + final RemoteAssetRepository _remoteAssetRepository; const ActionService( this._assetApiRepository, this._remoteAssetRepository, - this._remoteExifRepository, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -93,13 +88,23 @@ class ActionService { ); } + Future trash(List remoteIds) async { + await _assetApiRepository.delete(remoteIds, false); + await _remoteAssetRepository.trash(remoteIds); + } + + Future delete(List remoteIds) async { + await _assetApiRepository.delete(remoteIds, true); + await _remoteAssetRepository.delete(remoteIds); + } + Future editLocation( List remoteIds, BuildContext context, ) async { LatLng? initialLatLng; if (remoteIds.length == 1) { - final exif = await _remoteExifRepository.get(remoteIds[0]); + final exif = await _remoteAssetRepository.getExif(remoteIds[0]); if (exif?.latitude != null && exif?.longitude != null) { initialLatLng = LatLng(exif!.latitude!, exif.longitude!); diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index dcb8dacb0d..8c70472765 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -1,4 +1,6 @@ import 'package:flutter/painting.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; @@ -37,10 +39,12 @@ final class CustomImageCache implements ImageCache { /// Gets the cache for the given key /// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] /// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider] - ImageCache _cacheForKey(Object key) => - (key is ImmichLocalImageProvider || key is ImmichRemoteImageProvider) - ? _large - : _small; + ImageCache _cacheForKey(Object key) => (key is ImmichLocalImageProvider || + key is ImmichRemoteImageProvider || + key is LocalFullImageProvider || + key is RemoteFullImageProvider) + ? _large + : _small; @override bool containsKey(Object key) { diff --git a/mobile/lib/utils/hooks/blurhash_hook.dart b/mobile/lib/utils/hooks/blurhash_hook.dart index 9231e2d972..62208c4cf5 100644 --- a/mobile/lib/utils/hooks/blurhash_hook.dart +++ b/mobile/lib/utils/hooks/blurhash_hook.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:thumbhash/thumbhash.dart' as thumbhash; @@ -15,3 +16,15 @@ ObjectRef useBlurHashRef(Asset? asset) { return useRef(thumbhash.rgbaToBmp(rbga)); } + +ObjectRef useDriftBlurHashRef(RemoteAsset? asset) { + if (asset?.thumbHash == null) { + return useRef(null); + } + + final rbga = thumbhash.thumbHashToRGBA( + base64Decode(asset!.thumbHash!), + ); + + return useRef(thumbhash.rgbaToBmp(rbga)); +} diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 50218eaffd..bde50f3a90 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -73,6 +73,9 @@ String getThumbnailUrlForRemoteId( return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}'; } +String getPreviewUrlForRemoteId(final String id) => + '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}'; + String getPlaybackUrlForRemoteId(final String id) { return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; } diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index f3f72dfd87..7b6325cf2c 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -9,11 +9,13 @@ import 'package:url_launcher/url_launcher.dart'; class ExifMap extends StatelessWidget { final ExifInfo exifInfo; final String? markerId; + final MapCreatedCallback? onMapCreated; const ExifMap({ super.key, required this.exifInfo, this.markerId = 'marker', + this.onMapCreated, }); @override @@ -82,6 +84,7 @@ class ExifMap extends StatelessWidget { debugPrint('Opening Map Uri: $uri'); launchUrl(uri); }, + onCreated: onMapCreated, ); }, ); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 51a3a136b9..ff0e88e5d7 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; @@ -39,64 +40,70 @@ class ImmichSliverAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final isMultiSelectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return SliverAppBar( - floating: floating, - pinned: pinned, - snap: snap, - expandedHeight: expandedHeight, - backgroundColor: context.colorScheme.surfaceContainer, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(5), - ), - ), - automaticallyImplyLeading: false, - centerTitle: false, - title: title ?? const _ImmichLogoWithText(), - actions: [ - if (actions != null) - ...actions!.map( - (action) => Padding( - padding: const EdgeInsets.only(right: 16), - child: action, - ), - ), - IconButton( - icon: const Icon(Icons.swipe_left_alt_rounded), - onPressed: () => context.pop(), - ), - IconButton( - onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), - icon: const Icon( - Icons.sync, + return SliverAnimatedOpacity( + duration: Durations.medium1, + opacity: isMultiSelectEnabled ? 0 : 1, + sliver: SliverAppBar( + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + backgroundColor: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(5), ), ), - if (isCasting) - Padding( - padding: const EdgeInsets.only(right: 12), - child: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => const CastDialog(), - ); - }, - icon: Icon( - isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + automaticallyImplyLeading: false, + centerTitle: false, + title: title ?? const _ImmichLogoWithText(), + actions: [ + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, ), ), + IconButton( + icon: const Icon(Icons.swipe_left_alt_rounded), + onPressed: () => context.pop(), ), - if (showUploadButton) + IconButton( + onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), + icon: const Icon( + Icons.sync, + ), + ), + if (isCasting) + Padding( + padding: const EdgeInsets.only(right: 12), + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + icon: Icon( + isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + ), + ), + ), + if (showUploadButton) + const Padding( + padding: EdgeInsets.only(right: 20), + child: _BackupIndicator(), + ), const Padding( padding: EdgeInsets.only(right: 20), - child: _BackupIndicator(), + child: _ProfileIndicator(), ), - const Padding( - padding: EdgeInsets.only(right: 20), - child: _ProfileIndicator(), - ), - ], + ], + ), ); } } diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index b225a2edcb..06935cd4b5 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; @@ -24,6 +25,7 @@ class MapThumbnail extends HookConsumerWidget { final double width; final ThemeMode? themeMode; final bool showAttribution; + final MapCreatedCallback? onCreated; const MapThumbnail({ super.key, @@ -36,16 +38,19 @@ class MapThumbnail extends HookConsumerWidget { this.showMarkerPin = false, this.themeMode, this.showAttribution = true, + this.onCreated, }); @override Widget build(BuildContext context, WidgetRef ref) { final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final controller = useRef(null); + final styleLoaded = useState(false); final position = useValueNotifier?>(null); Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; + styleLoaded.value = false; if (assetMarkerRemoteId != null) { // The iOS impl returns wrong toScreenLocation without the delay Future.delayed( @@ -54,17 +59,26 @@ class MapThumbnail extends HookConsumerWidget { position.value = await mapController.toScreenLocation(centre), ); } + onCreated?.call(mapController); } Future onStyleLoaded() async { if (showMarkerPin && controller.value != null) { await controller.value?.addMarkerAtLatLng(centre); } + styleLoaded.value = true; } return MapThemeOverride( themeMode: themeMode, - mapBuilder: (style) => SizedBox( + mapBuilder: (style) => AnimatedContainer( + duration: Durations.medium2, + curve: Curves.easeOut, + foregroundDecoration: BoxDecoration( + color: context.colorScheme.inverseSurface + .withAlpha(styleLoaded.value ? 0 : 200), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), height: height, width: width, child: ClipRRect( diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index f72d1e298f..30e08748b8 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart'; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart'; import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart'; @@ -16,6 +15,11 @@ export 'src/photo_view_computed_scale.dart'; export 'src/photo_view_scale_state.dart'; export 'src/utils/photo_view_hero_attributes.dart'; +typedef PhotoViewControllerCallback = PhotoViewControllerBase Function(); +typedef PhotoViewControllerCallbackBuilder = void Function( + PhotoViewControllerCallback photoViewMethod, +); + /// A [StatefulWidget] that contains all the photo view rendering elements. /// /// Sample code to use within an image: @@ -239,8 +243,11 @@ class PhotoView extends StatefulWidget { this.wantKeepAlive = false, this.gaplessPlayback = false, this.heroAttributes, + this.onPageBuild, + this.controllerCallbackBuilder, this.scaleStateChangedCallback, this.enableRotation = false, + this.semanticLabel, this.controller, this.scaleStateController, this.maxScale, @@ -260,6 +267,7 @@ class PhotoView extends StatefulWidget { this.tightMode, this.filterQuality, this.disableGestures, + this.disableScaleGestures, this.errorBuilder, this.enablePanAlways, }) : child = null, @@ -278,6 +286,8 @@ class PhotoView extends StatefulWidget { this.backgroundDecoration, this.wantKeepAlive = false, this.heroAttributes, + this.onPageBuild, + this.controllerCallbackBuilder, this.scaleStateChangedCallback, this.enableRotation = false, this.controller, @@ -298,9 +308,11 @@ class PhotoView extends StatefulWidget { this.gestureDetectorBehavior, this.tightMode, this.filterQuality, + this.disableScaleGestures, this.disableGestures, this.enablePanAlways, - }) : errorBuilder = null, + }) : semanticLabel = null, + errorBuilder = null, imageProvider = null, gaplessPlayback = false, loadingBuilder = null, @@ -325,6 +337,11 @@ class PhotoView extends StatefulWidget { /// `true` -> keeps the state final bool wantKeepAlive; + /// A Semantic description of the image. + /// + /// Used to provide a description of the image to TalkBack on Android, and VoiceOver on iOS. + final String? semanticLabel; + /// This is used to continue showing the old image (`true`), or briefly show /// nothing (`false`), when the `imageProvider` changes. By default it's set /// to `false`. @@ -338,6 +355,12 @@ class PhotoView extends StatefulWidget { /// by default it is `MediaQuery.of(context).size`. final Size? customSize; + // Called when a new PhotoView widget is built + final ValueChanged? onPageBuild; + + // Called from the parent during page change to get the new controller + final PhotoViewControllerCallbackBuilder? controllerCallbackBuilder; + /// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in. final ValueChanged? scaleStateChangedCallback; @@ -419,6 +442,9 @@ class PhotoView extends StatefulWidget { // Useful when custom gesture detector is used in child widget. final bool? disableGestures; + /// Mirror to [PhotoView.disableGestures] + final bool? disableScaleGestures; + /// Enable pan the widget even if it's smaller than the hole parent widget. /// Useful when you want to drag a widget without restrictions. final bool? enablePanAlways; @@ -452,6 +478,7 @@ class _PhotoViewState extends State if (widget.controller == null) { _controlledController = true; _controller = PhotoViewController(); + widget.onPageBuild?.call(_controller); } else { _controlledController = false; _controller = widget.controller!; @@ -466,6 +493,8 @@ class _PhotoViewState extends State } _scaleStateController.outputScaleStateStream.listen(scaleStateListener); + // Pass a ref to the method back to the gallery so it can fetch the controller on page changes + widget.controllerCallbackBuilder?.call(_controllerGetter); } @override @@ -474,6 +503,7 @@ class _PhotoViewState extends State if (!_controlledController) { _controlledController = true; _controller = PhotoViewController(); + widget.onPageBuild?.call(_controller); } } else { _controlledController = false; @@ -509,6 +539,8 @@ class _PhotoViewState extends State } } + PhotoViewControllerBase _controllerGetter() => _controller; + @override Widget build(BuildContext context) { super.build(context); @@ -547,6 +579,7 @@ class _PhotoViewState extends State tightMode: widget.tightMode, filterQuality: widget.filterQuality, disableGestures: widget.disableGestures, + disableScaleGestures: widget.disableScaleGestures, enablePanAlways: widget.enablePanAlways, child: widget.child, ) @@ -554,6 +587,7 @@ class _PhotoViewState extends State imageProvider: widget.imageProvider!, loadingBuilder: widget.loadingBuilder, backgroundDecoration: backgroundDecoration, + semanticLabel: widget.semanticLabel, gaplessPlayback: widget.gaplessPlayback, heroAttributes: widget.heroAttributes, scaleStateChangedCallback: widget.scaleStateChangedCallback, @@ -577,6 +611,7 @@ class _PhotoViewState extends State tightMode: widget.tightMode, filterQuality: widget.filterQuality, disableGestures: widget.disableGestures, + disableScaleGestures: widget.disableScaleGestures, errorBuilder: widget.errorBuilder, enablePanAlways: widget.enablePanAlways, index: widget.index, @@ -625,7 +660,8 @@ typedef PhotoViewImageTapDownCallback = Function( typedef PhotoViewImageDragStartCallback = Function( BuildContext context, DragStartDetails details, - PhotoViewControllerValue controllerValue, + PhotoViewControllerBase controllerValue, + PhotoViewScaleStateController scaleStateController, ); /// A type definition for a callback when the user drags diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index 26c292d678..1cd4d4b217 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -4,13 +4,14 @@ import 'package:immich_mobile/widgets/photo_view/photo_view.dart' show LoadingBuilder, PhotoView, + PhotoViewControllerCallback, + PhotoViewImageDragEndCallback, + PhotoViewImageDragStartCallback, + PhotoViewImageDragUpdateCallback, + PhotoViewImageLongPressStartCallback, + PhotoViewImageScaleEndCallback, PhotoViewImageTapDownCallback, PhotoViewImageTapUpCallback, - PhotoViewImageDragStartCallback, - PhotoViewImageDragEndCallback, - PhotoViewImageDragUpdateCallback, - PhotoViewImageScaleEndCallback, - PhotoViewImageLongPressStartCallback, ScaleStateCycle; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart'; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart'; @@ -19,7 +20,10 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; /// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery] -typedef PhotoViewGalleryPageChangedCallback = void Function(int index); +typedef PhotoViewGalleryPageChangedCallback = void Function( + int index, + PhotoViewControllerBase? controller, +); /// A type definition for a [Function] that defines a page in [PhotoViewGallery.build] typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function( @@ -114,12 +118,14 @@ class PhotoViewGallery extends StatefulWidget { this.reverse = false, this.pageController, this.onPageChanged, + this.onPageBuild, this.scaleStateChangedCallback, this.enableRotation = false, this.scrollPhysics, this.scrollDirection = Axis.horizontal, this.customSize, this.allowImplicitScrolling = false, + this.enablePanAlways = false, }) : itemCount = null, builder = null; @@ -137,12 +143,14 @@ class PhotoViewGallery extends StatefulWidget { this.reverse = false, this.pageController, this.onPageChanged, + this.onPageBuild, this.scaleStateChangedCallback, this.enableRotation = false, this.scrollPhysics, this.scrollDirection = Axis.horizontal, this.customSize, this.allowImplicitScrolling = false, + this.enablePanAlways = false, }) : pageOptions = null, assert(itemCount != null), assert(builder != null); @@ -168,6 +176,9 @@ class PhotoViewGallery extends StatefulWidget { /// Mirror to [PhotoView.wantKeepAlive] final bool wantKeepAlive; + /// Mirror to [PhotoView.enablePanAlways] + final bool enablePanAlways; + /// Mirror to [PhotoView.gaplessPlayback] final bool gaplessPlayback; @@ -180,6 +191,9 @@ class PhotoViewGallery extends StatefulWidget { /// An callback to be called on a page change final PhotoViewGalleryPageChangedCallback? onPageChanged; + /// Mirror to [PhotoView.onPageBuild] + final ValueChanged? onPageBuild; + /// Mirror to [PhotoView.scaleStateChangedCallback] final ValueChanged? scaleStateChangedCallback; @@ -206,6 +220,7 @@ class PhotoViewGallery extends StatefulWidget { class _PhotoViewGalleryState extends State { late final PageController _controller = widget.pageController ?? PageController(); + PhotoViewControllerCallback? _getController; void scaleStateChangedCallback(PhotoViewScaleState scaleState) { if (widget.scaleStateChangedCallback != null) { @@ -224,6 +239,14 @@ class _PhotoViewGalleryState extends State { return widget.pageOptions!.length; } + void _getControllerCallbackBuilder(PhotoViewControllerCallback method) { + _getController = method; + } + + void _onPageChange(int page) { + widget.onPageChanged?.call(page, _getController?.call()); + } + @override Widget build(BuildContext context) { // Enable corner hit test @@ -232,7 +255,7 @@ class _PhotoViewGalleryState extends State { child: PageView.builder( reverse: widget.reverse, controller: _controller, - onPageChanged: widget.onPageChanged, + onPageChanged: _onPageChange, itemCount: itemCount, itemBuilder: _buildItem, scrollDirection: widget.scrollDirection, @@ -248,13 +271,15 @@ class _PhotoViewGalleryState extends State { final PhotoView photoView = isCustomChild ? PhotoView.customChild( - key: ObjectKey(index), + key: pageOption.key ?? ObjectKey(index), childSize: pageOption.childSize, backgroundDecoration: widget.backgroundDecoration, wantKeepAlive: widget.wantKeepAlive, controller: pageOption.controller, scaleStateController: pageOption.scaleStateController, customSize: widget.customSize, + onPageBuild: widget.onPageBuild, + controllerCallbackBuilder: _getControllerCallbackBuilder, scaleStateChangedCallback: scaleStateChangedCallback, enableRotation: widget.enableRotation, initialScale: pageOption.initialScale, @@ -273,17 +298,22 @@ class _PhotoViewGalleryState extends State { filterQuality: pageOption.filterQuality, basePosition: pageOption.basePosition, disableGestures: pageOption.disableGestures, + disableScaleGestures: pageOption.disableScaleGestures, heroAttributes: pageOption.heroAttributes, + enablePanAlways: widget.enablePanAlways, child: pageOption.child, ) : PhotoView( - key: ObjectKey(index), + key: pageOption.key ?? ObjectKey(index), index: index, imageProvider: pageOption.imageProvider, loadingBuilder: widget.loadingBuilder, backgroundDecoration: widget.backgroundDecoration, + semanticLabel: pageOption.semanticLabel, wantKeepAlive: widget.wantKeepAlive, controller: pageOption.controller, + onPageBuild: widget.onPageBuild, + controllerCallbackBuilder: _getControllerCallbackBuilder, scaleStateController: pageOption.scaleStateController, customSize: widget.customSize, gaplessPlayback: widget.gaplessPlayback, @@ -305,6 +335,8 @@ class _PhotoViewGalleryState extends State { filterQuality: pageOption.filterQuality, basePosition: pageOption.basePosition, disableGestures: pageOption.disableGestures, + disableScaleGestures: pageOption.disableScaleGestures, + enablePanAlways: widget.enablePanAlways, errorBuilder: pageOption.errorBuilder, heroAttributes: pageOption.heroAttributes, ); @@ -331,9 +363,10 @@ class _PhotoViewGalleryState extends State { /// class PhotoViewGalleryPageOptions { PhotoViewGalleryPageOptions({ - Key? key, + this.key, required this.imageProvider, this.heroAttributes, + this.semanticLabel, this.minScale, this.maxScale, this.initialScale, @@ -351,6 +384,7 @@ class PhotoViewGalleryPageOptions { this.gestureDetectorBehavior, this.tightMode, this.filterQuality, + this.disableScaleGestures, this.disableGestures, this.errorBuilder, }) : child = null, @@ -358,8 +392,10 @@ class PhotoViewGalleryPageOptions { assert(imageProvider != null); const PhotoViewGalleryPageOptions.customChild({ + this.key, required this.child, this.childSize, + this.semanticLabel, this.heroAttributes, this.minScale, this.maxScale, @@ -378,16 +414,22 @@ class PhotoViewGalleryPageOptions { this.gestureDetectorBehavior, this.tightMode, this.filterQuality, + this.disableScaleGestures, this.disableGestures, }) : errorBuilder = null, imageProvider = null; + final Key? key; + /// Mirror to [PhotoView.imageProvider] final ImageProvider? imageProvider; /// Mirror to [PhotoView.heroAttributes] final PhotoViewHeroAttributes? heroAttributes; + /// Mirror to [PhotoView.semanticLabel] + final String? semanticLabel; + /// Mirror to [PhotoView.minScale] final dynamic minScale; @@ -445,6 +487,9 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.disableGestures] final bool? disableGestures; + /// Mirror to [PhotoView.disableGestures] + final bool? disableScaleGestures; + /// Quality levels for image filters. final FilterQuality? filterQuality; diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart index e26708bb41..37d1c78de1 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart @@ -37,6 +37,13 @@ abstract class PhotoViewControllerBase { /// Closes streams and removes eventual listeners. void dispose(); + void positionAnimationBuilder(void Function(Offset)? value); + void scaleAnimationBuilder(void Function(double)? value); + void rotationAnimationBuilder(void Function(double)? value); + + /// Animates multiple fields of the state + void animateMultiple({Offset? position, double? scale, double? rotation}); + /// Add a listener that will ignore updates made internally /// /// Since it is made for internal use, it is not performatic to use more than one @@ -147,12 +154,31 @@ class PhotoViewController late StreamController _outputCtrl; + late void Function(Offset)? _animatePosition; + late void Function(double)? _animateScale; + late void Function(double)? _animateRotation; + @override Stream get outputStateStream => _outputCtrl.stream; @override late PhotoViewControllerValue prevValue; + @override + void positionAnimationBuilder(void Function(Offset)? value) { + _animatePosition = value; + } + + @override + void scaleAnimationBuilder(void Function(double)? value) { + _animateScale = value; + } + + @override + void rotationAnimationBuilder(void Function(double)? value) { + _animateRotation = value; + } + @override void reset() { value = initial; @@ -172,6 +198,21 @@ class PhotoViewController _valueNotifier.removeIgnorableListener(callback); } + @override + void animateMultiple({Offset? position, double? scale, double? rotation}) { + if (position != null && _animatePosition != null) { + _animatePosition!(position); + } + + if (scale != null && _animateScale != null) { + _animateScale!(scale); + } + + if (rotation != null && _animateRotation != null) { + _animateRotation!(rotation); + } + } + @override void dispose() { _outputCtrl.close(); diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart index 968ac652e7..e2e668199a 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart @@ -111,6 +111,16 @@ mixin PhotoViewControllerDelegate on State { ); } + PhotoViewScaleState getScaleStateFromNewScale(double newScale) { + PhotoViewScaleState newScaleState = PhotoViewScaleState.initial; + if (scale != scaleBoundaries.initialScale) { + newScaleState = (newScale > scaleBoundaries.initialScale) + ? PhotoViewScaleState.zoomedIn + : PhotoViewScaleState.zoomedOut; + } + return newScaleState; + } + void updateScaleStateFromNewScale(double newScale) { PhotoViewScaleState newScaleState = PhotoViewScaleState.initial; if (scale != scaleBoundaries.initialScale) { diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart index 16021ceab1..dea8be1a0f 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart @@ -26,6 +26,8 @@ class PhotoViewScaleStateController { StreamController.broadcast() ..sink.add(PhotoViewScaleState.initial); + bool _hasZoomedOutManually = false; + /// The output for state/value updates Stream get outputScaleStateStream => _outputScaleStateCtrl.stream; @@ -42,10 +44,20 @@ class PhotoViewScaleStateController { return; } + if (newValue == PhotoViewScaleState.zoomedOut) { + _hasZoomedOutManually = true; + } + + if (newValue == PhotoViewScaleState.initial) { + _hasZoomedOutManually = false; + } + prevScaleState = _scaleStateNotifier.value; _scaleStateNotifier.value = newValue; } + bool get hasZoomedOutManually => _hasZoomedOutManually; + /// Checks if its actual value is different than previousValue bool get hasChanged => prevScaleState != scaleState; @@ -71,6 +83,15 @@ class PhotoViewScaleStateController { if (_scaleStateNotifier.value == newValue) { return; } + + if (newValue == PhotoViewScaleState.zoomedOut) { + _hasZoomedOutManually = true; + } + + if (newValue == PhotoViewScaleState.initial) { + _hasZoomedOutManually = false; + } + prevScaleState = _scaleStateNotifier.value; _scaleStateNotifier.updateIgnoring(newValue); } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index bb892737f6..6b6e5067c5 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -29,6 +29,7 @@ class PhotoViewCore extends StatefulWidget { super.key, required this.imageProvider, required this.backgroundDecoration, + required this.semanticLabel, required this.gaplessPlayback, required this.heroAttributes, required this.enableRotation, @@ -48,6 +49,7 @@ class PhotoViewCore extends StatefulWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + required this.disableScaleGestures, required this.enablePanAlways, }) : customChild = null; @@ -73,12 +75,15 @@ class PhotoViewCore extends StatefulWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + required this.disableScaleGestures, required this.enablePanAlways, - }) : imageProvider = null, + }) : semanticLabel = null, + imageProvider = null, gaplessPlayback = false; final Decoration? backgroundDecoration; final ImageProvider? imageProvider; + final String? semanticLabel; final bool? gaplessPlayback; final PhotoViewHeroAttributes? heroAttributes; final bool enableRotation; @@ -103,6 +108,7 @@ class PhotoViewCore extends StatefulWidget { final HitTestBehavior? gestureDetectorBehavior; final bool tightMode; final bool disableGestures; + final bool disableScaleGestures; final bool enablePanAlways; final FilterQuality filterQuality; @@ -120,6 +126,7 @@ class PhotoViewCoreState extends State TickerProviderStateMixin, PhotoViewControllerDelegate, HitCornersDetector { + Offset? _normalizedPosition; double? _scaleBefore; double? _rotationBefore; @@ -152,32 +159,33 @@ class PhotoViewCoreState extends State void onScaleStart(ScaleStartDetails details) { _rotationBefore = controller.rotation; _scaleBefore = scale; + _normalizedPosition = details.focalPoint - controller.position; _scaleAnimationController.stop(); _positionAnimationController.stop(); _rotationAnimationController.stop(); } + bool _shouldAllowPanRotate() => switch (scaleStateController.scaleState) { + PhotoViewScaleState.zoomedIn => + scaleStateController.hasZoomedOutManually, + _ => true, + }; + void onScaleUpdate(ScaleUpdateDetails details) { - final centeredFocalPoint = Offset( - details.focalPoint.dx - scaleBoundaries.outerSize.width / 2, - details.focalPoint.dy - scaleBoundaries.outerSize.height / 2, - ); final double newScale = _scaleBefore! * details.scale; - final double scaleDelta = newScale / scale; - final Offset newPosition = - (controller.position + details.focalPointDelta) * scaleDelta - - centeredFocalPoint * (scaleDelta - 1); + Offset delta = details.focalPoint - _normalizedPosition!; updateScaleStateFromNewScale(newScale); + final panEnabled = widget.enablePanAlways && _shouldAllowPanRotate(); + final rotationEnabled = widget.enableRotation && _shouldAllowPanRotate(); + updateMultiple( scale: newScale, - position: widget.enablePanAlways - ? newPosition - : clampPosition(position: newPosition), - rotation: - widget.enableRotation ? _rotationBefore! + details.rotation : null, - rotationFocusPoint: widget.enableRotation ? details.focalPoint : null, + position: + panEnabled ? delta : clampPosition(position: delta * details.scale), + rotation: rotationEnabled ? _rotationBefore! + details.rotation : null, + rotationFocusPoint: rotationEnabled ? details.focalPoint : null, ); } @@ -189,6 +197,16 @@ class PhotoViewCoreState extends State widget.onScaleEnd?.call(context, details, controller.value); + final scaleState = getScaleStateFromNewScale(scale); + if (scaleState == PhotoViewScaleState.zoomedOut) { + scaleStateController.scaleState = PhotoViewScaleState.originalSize; + } else if (scaleState == PhotoViewScaleState.zoomedIn) { + animateRotation(controller.rotation, 0); + if (_shouldAllowPanRotate()) { + animatePosition(controller.position, Offset.zero); + } + } + //animate back to maxScale if gesture exceeded the maxScale specified if (s > maxScale) { final double scaleComebackRatio = maxScale / s; @@ -232,6 +250,9 @@ class PhotoViewCoreState extends State } void animateScale(double from, double to) { + if (!mounted) { + return; + } _scaleAnimation = Tween( begin: from, end: to, @@ -242,6 +263,9 @@ class PhotoViewCoreState extends State } void animatePosition(Offset from, Offset to) { + if (!mounted) { + return; + } _positionAnimation = Tween(begin: from, end: to) .animate(_positionAnimationController); _positionAnimationController @@ -250,6 +274,9 @@ class PhotoViewCoreState extends State } void animateRotation(double from, double to) { + if (!mounted) { + return; + } _rotationAnimation = Tween(begin: from, end: to) .animate(_rotationAnimationController); _rotationAnimationController @@ -271,11 +298,28 @@ class PhotoViewCoreState extends State } } + void _animateControllerPosition(Offset position) { + animatePosition(controller.position, position); + } + + void _animateControllerScale(double scale) { + if (controller.scale != null) { + animateScale(controller.scale!, scale); + } + } + + void _animateControllerRotation(double rotation) { + animateRotation(controller.rotation, rotation); + } + @override void initState() { super.initState(); initDelegate(); addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate); + controller.positionAnimationBuilder(_animateControllerPosition); + controller.scaleAnimationBuilder(_animateControllerScale); + controller.rotationAnimationBuilder(_animateControllerRotation); cachedScaleBoundaries = widget.scaleBoundaries; @@ -341,7 +385,7 @@ class PhotoViewCoreState extends State basePosition, useImageScale, ), - child: _buildHero(), + child: _buildHero(_buildChild()), ); final child = Container( @@ -363,18 +407,29 @@ class PhotoViewCoreState extends State } return PhotoViewGestureDetector( - onDoubleTap: nextScaleState, - onScaleStart: onScaleStart, - onScaleUpdate: onScaleUpdate, - onScaleEnd: onScaleEnd, + disableScaleGestures: widget.disableScaleGestures, + onDoubleTap: widget.disableScaleGestures ? null : onDoubleTap, + onScaleStart: widget.disableScaleGestures ? null : onScaleStart, + onScaleUpdate: widget.disableScaleGestures ? null : onScaleUpdate, + onScaleEnd: widget.disableScaleGestures ? null : onScaleEnd, onDragStart: widget.onDragStart != null - ? (details) => widget.onDragStart!(context, details, value) + ? (details) => widget.onDragStart!( + context, + details, + widget.controller, + widget.scaleStateController, + ) : null, onDragEnd: widget.onDragEnd != null - ? (details) => widget.onDragEnd!(context, details, value) + ? (details) => + widget.onDragEnd!(context, details, widget.controller.value) : null, onDragUpdate: widget.onDragUpdate != null - ? (details) => widget.onDragUpdate!(context, details, value) + ? (details) => widget.onDragUpdate!( + context, + details, + widget.controller.value, + ) : null, hitDetector: this, onTapUp: widget.onTapUp != null @@ -395,7 +450,7 @@ class PhotoViewCoreState extends State ); } - Widget _buildHero() { + Widget _buildHero(Widget child) { return heroAttributes != null ? Hero( tag: heroAttributes!.tag, @@ -403,16 +458,20 @@ class PhotoViewCoreState extends State flightShuttleBuilder: heroAttributes!.flightShuttleBuilder, placeholderBuilder: heroAttributes!.placeholderBuilder, transitionOnUserGestures: heroAttributes!.transitionOnUserGestures, - child: _buildChild(), + child: child, ) - : _buildChild(); + : child; } Widget _buildChild() { return widget.hasCustomChild ? widget.customChild! : Image( + key: widget.heroAttributes?.tag != null + ? ObjectKey(widget.heroAttributes!.tag) + : null, image: widget.imageProvider!, + semanticLabel: widget.semanticLabel, gaplessPlayback: widget.gaplessPlayback ?? false, filterQuality: widget.filterQuality, width: scaleBoundaries.childSize.width * scale, @@ -442,6 +501,7 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { final double offsetX = halfWidth * (basePosition.x + 1); final double offsetY = halfHeight * (basePosition.y + 1); + return Offset(offsetX, offsetY); } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 2eef5e6742..93fd1526da 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -21,6 +21,7 @@ class PhotoViewGestureDetector extends StatelessWidget { this.onTapUp, this.onTapDown, this.behavior, + this.disableScaleGestures = false, }); final GestureDoubleTapCallback? onDoubleTap; @@ -43,6 +44,8 @@ class PhotoViewGestureDetector extends StatelessWidget { final HitTestBehavior? behavior; + final bool disableScaleGestures; + @override Widget build(BuildContext context) { final scope = PhotoViewGestureDetectorScope.of(context); @@ -96,9 +99,11 @@ class PhotoViewGestureDetector extends StatelessWidget { ), (PhotoViewGestureRecognizer instance) { instance + ..dragStartBehavior = DragStartBehavior.start ..onStart = onScaleStart ..onUpdate = onScaleUpdate - ..onEnd = onScaleEnd; + ..onEnd = onScaleEnd + ..disableScaleGestures = disableScaleGestures; }, ); @@ -124,10 +129,12 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { this.validateAxis, this.touchSlopFactor = 1, PointerDeviceKind? kind, + this.disableScaleGestures = false, }) : super(supportedDevices: null); final HitCornersDetector? hitDetector; final Axis? validateAxis; final double touchSlopFactor; + bool disableScaleGestures; Map _pointerLocations = {}; @@ -155,7 +162,7 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { @override void handleEvent(PointerEvent event) { - if (validateAxis != null) { + if (validateAxis != null && !disableScaleGestures) { bool didChangeConfiguration = false; if (event is PointerMoveEvent) { if (!event.synthesized) { diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index 57496f3777..d4afe85d2b 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -11,6 +11,7 @@ class ImageWrapper extends StatefulWidget { required this.imageProvider, required this.loadingBuilder, required this.backgroundDecoration, + required this.semanticLabel, required this.gaplessPlayback, required this.heroAttributes, required this.scaleStateChangedCallback, @@ -34,6 +35,7 @@ class ImageWrapper extends StatefulWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + this.disableScaleGestures, required this.errorBuilder, required this.enablePanAlways, required this.index, @@ -43,6 +45,7 @@ class ImageWrapper extends StatefulWidget { final LoadingBuilder? loadingBuilder; final ImageErrorWidgetBuilder? errorBuilder; final BoxDecoration backgroundDecoration; + final String? semanticLabel; final bool gaplessPlayback; final PhotoViewHeroAttributes? heroAttributes; final ValueChanged? scaleStateChangedCallback; @@ -66,6 +69,7 @@ class ImageWrapper extends StatefulWidget { final bool? tightMode; final FilterQuality? filterQuality; final bool? disableGestures; + final bool? disableScaleGestures; final bool? enablePanAlways; final int index; @@ -193,6 +197,7 @@ class _ImageWrapperState extends State { return PhotoViewCore( imageProvider: widget.imageProvider, backgroundDecoration: widget.backgroundDecoration, + semanticLabel: widget.semanticLabel, gaplessPlayback: widget.gaplessPlayback, enableRotation: widget.enableRotation, heroAttributes: widget.heroAttributes, @@ -212,6 +217,7 @@ class _ImageWrapperState extends State { tightMode: widget.tightMode ?? false, filterQuality: widget.filterQuality ?? FilterQuality.none, disableGestures: widget.disableGestures ?? false, + disableScaleGestures: widget.disableScaleGestures ?? false, enablePanAlways: widget.enablePanAlways ?? false, ); } @@ -266,6 +272,7 @@ class CustomChildWrapper extends StatelessWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + this.disableScaleGestures, required this.enablePanAlways, }); @@ -296,6 +303,7 @@ class CustomChildWrapper extends StatelessWidget { final HitTestBehavior? gestureDetectorBehavior; final bool? tightMode; final FilterQuality? filterQuality; + final bool? disableScaleGestures; final bool? disableGestures; final bool? enablePanAlways; @@ -330,6 +338,7 @@ class CustomChildWrapper extends StatelessWidget { tightMode: tightMode ?? false, filterQuality: filterQuality ?? FilterQuality.none, disableGestures: disableGestures ?? false, + disableScaleGestures: disableScaleGestures ?? false, enablePanAlways: enablePanAlways ?? false, ); } diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index a0b61bcaff..28288422fd 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -81,6 +81,14 @@ void main() { debugLabel: any(named: 'debugLabel'), ), ).thenAnswer(successHandler); + when(() => mockSyncStreamRepo.updateMemoriesV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deleteMemoriesV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deleteMemoryAssetsV1(any())) + .thenAnswer(successHandler); sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, @@ -227,5 +235,94 @@ void main() { verify(() => mockSyncApiRepo.ack(["2"])).called(1); }, ); + + test("processes memory sync events successfully", () async { + final events = [ + SyncStreamStub.memoryV1, + SyncStreamStub.memoryDeleteV1, + SyncStreamStub.memoryToAssetV1, + SyncStreamStub.memoryToAssetDeleteV1, + ]; + + await simulateEvents(events); + + verifyInOrder([ + () => mockSyncStreamRepo.updateMemoriesV1(any()), + () => mockSyncApiRepo.ack(["5"]), + () => mockSyncStreamRepo.deleteMemoriesV1(any()), + () => mockSyncApiRepo.ack(["6"]), + () => mockSyncStreamRepo.updateMemoryAssetsV1(any()), + () => mockSyncApiRepo.ack(["7"]), + () => mockSyncStreamRepo.deleteMemoryAssetsV1(any()), + () => mockSyncApiRepo.ack(["8"]), + ]); + verifyNever(() => mockAbortCallbackWrapper()); + }); + + test("processes mixed memory and user events in correct order", () async { + final events = [ + SyncStreamStub.memoryDeleteV1, + SyncStreamStub.userV1Admin, + SyncStreamStub.memoryToAssetV1, + SyncStreamStub.memoryV1, + ]; + + await simulateEvents(events); + + verifyInOrder([ + () => mockSyncStreamRepo.deleteMemoriesV1(any()), + () => mockSyncApiRepo.ack(["6"]), + () => mockSyncStreamRepo.updateUsersV1(any()), + () => mockSyncApiRepo.ack(["1"]), + () => mockSyncStreamRepo.updateMemoryAssetsV1(any()), + () => mockSyncApiRepo.ack(["7"]), + () => mockSyncStreamRepo.updateMemoriesV1(any()), + () => mockSyncApiRepo.ack(["5"]), + ]); + verifyNever(() => mockAbortCallbackWrapper()); + }); + + test("handles memory sync failure gracefully", () async { + when(() => mockSyncStreamRepo.updateMemoriesV1(any())) + .thenThrow(Exception("Memory sync failed")); + + final events = [ + SyncStreamStub.memoryV1, + SyncStreamStub.userV1Admin, + ]; + + expect( + () async => await simulateEvents(events), + throwsA(isA()), + ); + }); + + test("processes memory asset events with correct data types", () async { + final events = [SyncStreamStub.memoryToAssetV1]; + + await simulateEvents(events); + + verify(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(["7"])).called(1); + }); + + test("processes memory delete events with correct data types", () async { + final events = [SyncStreamStub.memoryDeleteV1]; + + await simulateEvents(events); + + verify(() => mockSyncStreamRepo.deleteMemoriesV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(["6"])).called(1); + }); + + test("processes memory create/update events with correct data types", + () async { + final events = [SyncStreamStub.memoryV1]; + + await simulateEvents(events); + + verify(() => mockSyncStreamRepo.updateMemoriesV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(["5"])).called(1); + }); }); } diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index ba97f1434a..de2d58bc9d 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -42,4 +42,47 @@ abstract final class SyncStreamStub { data: SyncPartnerDeleteV1(sharedById: "3", sharedWithId: "4"), ack: "4", ); + + static final memoryV1 = SyncEvent( + type: SyncEntityType.memoryV1, + data: SyncMemoryV1( + createdAt: DateTime(2023, 1, 1), + data: {"year": 2023, "title": "Test Memory"}, + deletedAt: null, + hideAt: null, + id: "memory-1", + isSaved: false, + memoryAt: DateTime(2023, 1, 1), + ownerId: "user-1", + seenAt: null, + showAt: DateTime(2023, 1, 1), + type: MemoryType.onThisDay, + updatedAt: DateTime(2023, 1, 1), + ), + ack: "5", + ); + + static final memoryDeleteV1 = SyncEvent( + type: SyncEntityType.memoryDeleteV1, + data: SyncMemoryDeleteV1(memoryId: "memory-2"), + ack: "6", + ); + + static final memoryToAssetV1 = SyncEvent( + type: SyncEntityType.memoryToAssetV1, + data: SyncMemoryAssetV1( + assetId: "asset-1", + memoryId: "memory-1", + ), + ack: "7", + ); + + static final memoryToAssetDeleteV1 = SyncEvent( + type: SyncEntityType.memoryToAssetDeleteV1, + data: SyncMemoryAssetDeleteV1( + assetId: "asset-2", + memoryId: "memory-1", + ), + ack: "8", + ); } diff --git a/server/src/emails/components/button.component.tsx b/server/src/emails/components/button.component.tsx index 9c229fc16d..b490e36650 100644 --- a/server/src/emails/components/button.component.tsx +++ b/server/src/emails/components/button.component.tsx @@ -2,9 +2,7 @@ import React from 'react'; import { Button, ButtonProps } from '@react-email/components'; -interface ImmichButtonProps extends ButtonProps {} - -export const ImmichButton = ({ children, ...props }: ImmichButtonProps) => ( +export const ImmichButton = ({ children, ...props }: ButtonProps) => (