From 7e8488694dc299f2eb6822ea38c43f9922c3e2b7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Dec 2023 11:20:07 -0600 Subject: [PATCH 1/4] chore(deps): update web (#5502) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 140 +++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 0eef10223b..5f5a3982f6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -116,9 +116,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", - "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", "dev": true, "engines": { "node": ">=6.9.0" @@ -452,9 +452,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", - "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", "dev": true, "engines": { "node": ">=6.9.0" @@ -867,9 +867,9 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.3.tgz", - "integrity": "sha512-59GsVNavGxAXCDDbakWSMJhajASb4kBCqDjqJsv+p5nKdbz7istmZ3HrX3L2LuiI80+zsOADCvooqQH3qGCucQ==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.4.tgz", + "integrity": "sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", @@ -917,9 +917,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.3.tgz", - "integrity": "sha512-QPZxHrThbQia7UdvfpaRRlq/J9ciz1J4go0k+lPBXbgaNeY7IQrBj/9ceWjvMMI07/ZBzHl/F0R/2K0qH7jCVw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", + "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -948,9 +948,9 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.3.tgz", - "integrity": "sha512-PENDVxdr7ZxKPyi5Ffc0LjXdnJyrJxyqF5T5YjlVg4a0VFfQHW0r8iAtRiDXkfHlu1wwcvdtnndGYIeJLSuRMQ==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", + "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", "dev": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.22.15", @@ -965,9 +965,9 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.3.tgz", - "integrity": "sha512-FGEQmugvAEu2QtgtU0uTASXevfLMFfBeVCIIdcQhn/uBQsMTjBajdnAtanQlOcuihWh10PZ7+HWvc7NtBwP74w==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz", + "integrity": "sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", @@ -1050,9 +1050,9 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.3.tgz", - "integrity": "sha512-vTG+cTGxPFou12Rj7ll+eD5yWeNl5/8xvQvF08y5Gv3v4mZQoyFf8/n9zg4q5vvCWt5jmgymfzMAldO7orBn7A==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", + "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1082,9 +1082,9 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.3.tgz", - "integrity": "sha512-yCLhW34wpJWRdTxxWtFZASJisihrfyMOTOQexhVzA78jlU+dH7Dw+zQgcPepQ5F3C6bAIiblZZ+qBggJdHiBAg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", + "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1130,9 +1130,9 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.3.tgz", - "integrity": "sha512-H9Ej2OiISIZowZHaBwF0tsJOih1PftXJtE8EWqlEIwpc7LMTGq0rPOrywKLQ4nefzx8/HMR0D3JGXoMHYvhi0A==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", + "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1161,9 +1161,9 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.3.tgz", - "integrity": "sha512-+pD5ZbxofyOygEp+zZAfujY2ShNCXRpDRIPOiBmTO693hhyOEteZgl876Xs9SAHPQpcV0vz8LvA/T+w8AzyX8A==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", + "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1290,9 +1290,9 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.3.tgz", - "integrity": "sha512-xzg24Lnld4DYIdysyf07zJ1P+iIfJpxtVFOzX4g+bsJ3Ng5Le7rXx9KwqKzuyaUeRnt+I1EICwQITqc0E2PmpA==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", + "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1306,9 +1306,9 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.3.tgz", - "integrity": "sha512-s9GO7fIBi/BLsZ0v3Rftr6Oe4t0ctJ8h4CCXfPoEJwmvAPMyNrfkOOJzm6b9PX9YXcCJWWQd/sBF/N26eBiMVw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", + "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1322,9 +1322,9 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.3.tgz", - "integrity": "sha512-VxHt0ANkDmu8TANdE9Kc0rndo/ccsmfe2Cx2y5sI4hu3AukHQ5wAu4cM7j3ba8B9548ijVyclBU+nuDQftZsog==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", + "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", "dev": true, "dependencies": { "@babel/compat-data": "^7.23.3", @@ -1357,9 +1357,9 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.3.tgz", - "integrity": "sha512-LxYSb0iLjUamfm7f1D7GpiS4j0UAC8AOiehnsGAP8BEsIX8EOi3qV6bbctw8M7ZvLtcoZfZX5Z7rN9PlWk0m5A==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", + "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1373,9 +1373,9 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.3.tgz", - "integrity": "sha512-zvL8vIfIUgMccIAK1lxjvNv572JHFJIKb4MWBz5OGdBQA0fB0Xluix5rmOby48exiJc987neOmP/m9Fnpkz3Tg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", + "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1421,9 +1421,9 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.3.tgz", - "integrity": "sha512-a5m2oLNFyje2e/rGKjVfAELTVI5mbA0FeZpBnkOWWV7eSmKQ+T/XW0Vf+29ScLzSxX+rnsarvU0oie/4m6hkxA==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", + "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", @@ -1642,15 +1642,15 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.3.tgz", - "integrity": "sha512-ovzGc2uuyNfNAs/jyjIGxS8arOHS5FENZaNn4rtE7UdKMMkqHCvboHfcuhWLZNX5cB44QfcGNWjaevxMzzMf+Q==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.5.tgz", + "integrity": "sha512-0d/uxVD6tFGWXGDSfyMD1p2otoaKmu6+GD+NfAx0tMaH+dxORnp7T9TaVQ6mKyya7iBtCIVxHjWT7MuzzM9z+A==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.3", + "@babel/compat-data": "^7.23.5", "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", + "@babel/helper-validator-option": "^7.23.5", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.3", @@ -1674,25 +1674,25 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.3", + "@babel/plugin-transform-async-generator-functions": "^7.23.4", "@babel/plugin-transform-async-to-generator": "^7.23.3", "@babel/plugin-transform-block-scoped-functions": "^7.23.3", - "@babel/plugin-transform-block-scoping": "^7.23.3", + "@babel/plugin-transform-block-scoping": "^7.23.4", "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-class-static-block": "^7.23.3", - "@babel/plugin-transform-classes": "^7.23.3", + "@babel/plugin-transform-class-static-block": "^7.23.4", + "@babel/plugin-transform-classes": "^7.23.5", "@babel/plugin-transform-computed-properties": "^7.23.3", "@babel/plugin-transform-destructuring": "^7.23.3", "@babel/plugin-transform-dotall-regex": "^7.23.3", "@babel/plugin-transform-duplicate-keys": "^7.23.3", - "@babel/plugin-transform-dynamic-import": "^7.23.3", + "@babel/plugin-transform-dynamic-import": "^7.23.4", "@babel/plugin-transform-exponentiation-operator": "^7.23.3", - "@babel/plugin-transform-export-namespace-from": "^7.23.3", + "@babel/plugin-transform-export-namespace-from": "^7.23.4", "@babel/plugin-transform-for-of": "^7.23.3", "@babel/plugin-transform-function-name": "^7.23.3", - "@babel/plugin-transform-json-strings": "^7.23.3", + "@babel/plugin-transform-json-strings": "^7.23.4", "@babel/plugin-transform-literals": "^7.23.3", - "@babel/plugin-transform-logical-assignment-operators": "^7.23.3", + "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", "@babel/plugin-transform-member-expression-literals": "^7.23.3", "@babel/plugin-transform-modules-amd": "^7.23.3", "@babel/plugin-transform-modules-commonjs": "^7.23.3", @@ -1700,15 +1700,15 @@ "@babel/plugin-transform-modules-umd": "^7.23.3", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", "@babel/plugin-transform-new-target": "^7.23.3", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.3", - "@babel/plugin-transform-numeric-separator": "^7.23.3", - "@babel/plugin-transform-object-rest-spread": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", + "@babel/plugin-transform-numeric-separator": "^7.23.4", + "@babel/plugin-transform-object-rest-spread": "^7.23.4", "@babel/plugin-transform-object-super": "^7.23.3", - "@babel/plugin-transform-optional-catch-binding": "^7.23.3", - "@babel/plugin-transform-optional-chaining": "^7.23.3", + "@babel/plugin-transform-optional-catch-binding": "^7.23.4", + "@babel/plugin-transform-optional-chaining": "^7.23.4", "@babel/plugin-transform-parameters": "^7.23.3", "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.3", + "@babel/plugin-transform-private-property-in-object": "^7.23.4", "@babel/plugin-transform-property-literals": "^7.23.3", "@babel/plugin-transform-regenerator": "^7.23.3", "@babel/plugin-transform-reserved-words": "^7.23.3", @@ -11155,9 +11155,9 @@ } }, "node_modules/svelte": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.7.tgz", - "integrity": "sha512-UExR1KS7raTdycsUrKLtStayu4hpdV3VZQgM0akX8XbXgLBlosdE/Sf3crOgyh9xIjqSYB3UEBuUlIQKRQX2hg==", + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.8.tgz", + "integrity": "sha512-hU6dh1MPl8gh6klQZwK/n73GiAHiR95IkFsesLPbMeEZi36ydaXL/ZAb4g9sayT0MXzpxyZjR28yderJHxcmYA==", "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", From 84c5b08c255884c8f53ac69a3e5ff5c950fb8e9d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 5 Dec 2023 13:16:37 -0600 Subject: [PATCH 2/4] feat(web): UI/UX improvement for date time edit form (#5505) --- .../lib/components/elements/dropdown.svelte | 8 ++++- .../shared-components/change-date.svelte | 31 +++++++++++++++---- .../shared-components/confirm-dialogue.svelte | 8 +++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte index 6bf9b55d65..b69b191f25 100644 --- a/web/src/lib/components/elements/dropdown.svelte +++ b/web/src/lib/components/elements/dropdown.svelte @@ -15,8 +15,12 @@ import { fly } from 'svelte/transition'; import { createEventDispatcher } from 'svelte'; + let className = ''; + export { className as class }; + const dispatch = createEventDispatcher<{ select: T; + 'click-outside': void; }>(); export let options: T[]; @@ -36,6 +40,8 @@ if (!controlable) { showMenu = false; } + + dispatch('click-outside'); }; const handleSelectOption = (option: T) => { @@ -76,7 +82,7 @@ {#if showMenu}
{#each options as option (option)} {@const renderedOption = renderOption(option)} diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index ee350f7b12..d9cd810c3e 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -59,12 +59,27 @@ dispatch('confirm', value); } }; + const handleKeydown = (event: KeyboardEvent) => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { event.stopPropagation(); } }; + let isDropdownOpen = false; + let isSearching = false; + + const onSearchFocused = () => { + isSearching = true; + + openDropdown(); + }; + + const onSearchBlurred = () => { + isSearching = false; + + closeDropdown(); + }; const openDropdown = () => { isDropdownOpen = true; @@ -84,42 +99,46 @@ -
+
-
+
+
(item ? `${item.zone} (${item.offset})` : '(not selected)')} on:select={({ detail: item }) => handleSelectTz(item)} controlable={true} bind:showMenu={isDropdownOpen} + on:click-outside={isSearching ? null : closeDropdown} />
diff --git a/web/src/lib/components/shared-components/confirm-dialogue.svelte b/web/src/lib/components/shared-components/confirm-dialogue.svelte index ae4bd24a4f..1457e469b1 100644 --- a/web/src/lib/components/shared-components/confirm-dialogue.svelte +++ b/web/src/lib/components/shared-components/confirm-dialogue.svelte @@ -13,7 +13,7 @@ export let hideCancelButton = false; export let disabled = false; - const dispatch = createEventDispatcher<{ cancel: void; confirm: void }>(); + const dispatch = createEventDispatcher<{ cancel: void; confirm: void; 'click-outside': void }>(); let isConfirmButtonDisabled = false; @@ -28,9 +28,13 @@ isConfirmButtonDisabled = true; dispatch('confirm'); }; + + const handleClickOutside = () => { + dispatch('click-outside'); + }; - handleEscape()}> + handleEscape()}>
From 086a957a2b1ea50e2666ce36199e705b49c4325d Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 5 Dec 2023 19:34:37 +0000 Subject: [PATCH 3/4] feat(mobile): edit date time & location (#5461) * chore: text correction * fix: update activities stat only when the widget is mounted * feat(mobile): edit date time * feat(mobile): edit location * chore(build): update gradle wrapper - 7.6.3 * style: dropdownmenu styling * style: wrap locationpicker in singlechildscrollview * test: add unit test for getTZAdjustedTimeAndOffset * pr changes --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../gradle/wrapper/gradle-wrapper.properties | 4 +- mobile/assets/i18n/en-US.json | 18 +- mobile/lib/extensions/asset_extensions.dart | 36 +++ .../lib/extensions/duration_extensions.dart | 4 + .../providers/activity.provider.dart | 6 +- .../asset_viewer/ui/exif_bottom_sheet.dart | 240 ++++++++-------- .../home/ui/control_bottom_app_bar.dart | 16 ++ mobile/lib/modules/home/views/home_page.dart | 38 ++- .../modules/map/ui/map_location_picker.dart | 113 ++++++++ mobile/lib/modules/map/ui/map_thumbnail.dart | 21 +- .../map/utils/map_controller_hook.dart | 32 +++ mobile/lib/modules/map/views/map_page.dart | 4 +- .../modules/search/ui/curated_places_row.dart | 6 +- mobile/lib/routing/router.dart | 8 +- mobile/lib/routing/router.gr.dart | 55 ++++ mobile/lib/shared/models/asset.dart | 2 + mobile/lib/shared/services/asset.service.dart | 24 ++ mobile/lib/shared/ui/date_time_picker.dart | 257 ++++++++++++++++++ mobile/lib/shared/ui/location_picker.dart | 256 +++++++++++++++++ mobile/lib/shared/ui/scaffold_error_body.dart | 2 +- mobile/lib/utils/selection_handlers.dart | 62 +++++ mobile/test/asset_extensions_test.dart | 131 +++++++++ 22 files changed, 1211 insertions(+), 124 deletions(-) create mode 100644 mobile/lib/extensions/asset_extensions.dart create mode 100644 mobile/lib/extensions/duration_extensions.dart create mode 100644 mobile/lib/modules/map/ui/map_location_picker.dart create mode 100644 mobile/lib/modules/map/utils/map_controller_hook.dart create mode 100644 mobile/lib/shared/ui/date_time_picker.dart create mode 100644 mobile/lib/shared/ui/location_picker.dart create mode 100644 mobile/test/asset_extensions_test.dart diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties index 98bcc01e43..7787882b74 100644 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip -distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2 \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80 \ No newline at end of file diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 6d28890ebc..93436ab4e0 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -144,6 +144,8 @@ "control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_upload": "Upload", + "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_edit_location": "Edit Location", "create_album_page_untitled": "Untitled", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", @@ -165,6 +167,7 @@ "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", + "exif_bottom_sheet_location_add": "Add a location", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", @@ -461,5 +464,18 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack", - "scaffold_body_error_occured": "Error occured" + "scaffold_body_error_occurred": "Error occurred", + "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_timezone": "Timezone", + "action_common_cancel": "Cancel", + "action_common_update": "Update", + "edit_location_dialog_title": "Location", + "map_location_picker_page_use_location": "Use this location", + "location_picker_choose_on_map": "Choose on map", + "location_picker_latitude": "Latitude", + "location_picker_latitude_hint": "Enter your latitude here", + "location_picker_latitude_error": "Enter a valid latitude", + "location_picker_longitude": "Longitude", + "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_longitude_error": "Enter a valid longitude" } diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart new file mode 100644 index 0000000000..a755792bc9 --- /dev/null +++ b/mobile/lib/extensions/asset_extensions.dart @@ -0,0 +1,36 @@ +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:timezone/timezone.dart'; + +extension TZExtension on Asset { + /// Returns the created time of the asset from the exif info (if available) or from + /// the fileCreatedAt field, adjusted to the timezone value from the exif info along with + /// the timezone offset in [Duration] + (DateTime, Duration) getTZAdjustedTimeAndOffset() { + DateTime dt = fileCreatedAt.toLocal(); + if (exifInfo?.dateTimeOriginal != null) { + dt = exifInfo!.dateTimeOriginal!; + if (exifInfo?.timeZone != null) { + dt = dt.toUtc(); + try { + final location = getLocation(exifInfo!.timeZone!); + dt = TZDateTime.from(dt, location); + } on LocationNotFoundException { + RegExp re = RegExp( + r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', + caseSensitive: false, + ); + final m = re.firstMatch(exifInfo!.timeZone!); + if (m != null) { + final duration = Duration( + hours: int.parse(m.group(1) ?? '0'), + minutes: int.parse(m.group(2) ?? '0'), + ); + dt = dt.add(duration); + return (dt, duration); + } + } + } + } + return (dt, dt.timeZoneOffset); + } +} diff --git a/mobile/lib/extensions/duration_extensions.dart b/mobile/lib/extensions/duration_extensions.dart new file mode 100644 index 0000000000..68fb1b0689 --- /dev/null +++ b/mobile/lib/extensions/duration_extensions.dart @@ -0,0 +1,4 @@ +extension TZOffsetExtension on Duration { + String formatAsOffset() => + "${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; +} diff --git a/mobile/lib/modules/activities/providers/activity.provider.dart b/mobile/lib/modules/activities/providers/activity.provider.dart index c0fa5e628f..9d8a3429b1 100644 --- a/mobile/lib/modules/activities/providers/activity.provider.dart +++ b/mobile/lib/modules/activities/providers/activity.provider.dart @@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier { } Future fetchStatistics() async { - state = await _activityService.getStatistics(albumId, assetId: assetId); + final count = + await _activityService.getStatistics(albumId, assetId: assetId); + if (mounted) { + state = count; + } } Future addActivity() async { diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index 08a6a0515d..8c63c91616 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -4,14 +4,15 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:timezone/timezone.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; +import 'package:immich_mobile/utils/selection_handlers.dart'; import 'package:latlong2/latlong.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -21,111 +22,84 @@ class ExifBottomSheet extends HookConsumerWidget { const ExifBottomSheet({Key? key, required this.asset}) : super(key: key); - bool hasCoordinates(ExifInfo? exifInfo) => - exifInfo != null && - exifInfo.latitude != null && - exifInfo.longitude != null && - exifInfo.latitude != 0 && - exifInfo.longitude != 0; - - String formatTimeZone(Duration d) => - "GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; - - String get formattedDateTime { - DateTime dt = asset.fileCreatedAt.toLocal(); - String? timeZone; - if (asset.exifInfo?.dateTimeOriginal != null) { - dt = asset.exifInfo!.dateTimeOriginal!; - if (asset.exifInfo?.timeZone != null) { - dt = dt.toUtc(); - try { - final location = getLocation(asset.exifInfo!.timeZone!); - dt = TZDateTime.from(dt, location); - } on LocationNotFoundException { - RegExp re = RegExp( - r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', - caseSensitive: false, - ); - final m = re.firstMatch(asset.exifInfo!.timeZone!); - if (m != null) { - final duration = Duration( - hours: int.parse(m.group(1) ?? '0'), - minutes: int.parse(m.group(2) ?? '0'), - ); - dt = dt.add(duration); - timeZone = formatTimeZone(duration); - } - } - } - } - - final date = DateFormat.yMMMEd().format(dt); - final time = DateFormat.jm().format(dt); - timeZone ??= formatTimeZone(dt.timeZoneOffset); - - return '$date • $time $timeZone'; - } - - Future _createCoordinatesUri(ExifInfo? exifInfo) async { - if (!hasCoordinates(exifInfo)) { - return null; - } - - final double latitude = exifInfo!.latitude!; - final double longitude = exifInfo.longitude!; - - const zoomLevel = 16; - - if (Platform.isAndroid) { - Uri uri = Uri( - scheme: 'geo', - host: '$latitude,$longitude', - queryParameters: { - 'z': '$zoomLevel', - 'q': '$latitude,$longitude($formattedDateTime)', - }, - ); - if (await canLaunchUrl(uri)) { - return uri; - } - } else if (Platform.isIOS) { - var params = { - 'll': '$latitude,$longitude', - 'q': formattedDateTime, - 'z': '$zoomLevel', - }; - Uri uri = Uri.https('maps.apple.com', '/', params); - if (await canLaunchUrl(uri)) { - return uri; - } - } - - return Uri( - scheme: 'https', - host: 'openstreetmap.org', - queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'}, - fragment: 'map=$zoomLevel/$latitude/$longitude', - ); - } - @override Widget build(BuildContext context, WidgetRef ref) { final assetWithExif = ref.watch(assetDetailProvider(asset)); final exifInfo = (assetWithExif.value ?? asset).exifInfo; var textColor = context.isDarkTheme ? Colors.white : Colors.black; + bool hasCoordinates() => + exifInfo != null && + exifInfo.latitude != null && + exifInfo.longitude != null && + exifInfo.latitude != 0 && + exifInfo.longitude != 0; + + String formattedDateTime() { + final (dt, timeZone) = + (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + + return '$date • $time GMT${timeZone.formatAsOffset()}'; + } + + Future createCoordinatesUri() async { + if (!hasCoordinates()) { + return null; + } + + final double latitude = exifInfo!.latitude!; + final double longitude = exifInfo.longitude!; + + const zoomLevel = 16; + + if (Platform.isAndroid) { + Uri uri = Uri( + scheme: 'geo', + host: '$latitude,$longitude', + queryParameters: { + 'z': '$zoomLevel', + 'q': '$latitude,$longitude($formattedDateTime)', + }, + ); + if (await canLaunchUrl(uri)) { + return uri; + } + } else if (Platform.isIOS) { + var params = { + 'll': '$latitude,$longitude', + 'q': formattedDateTime, + 'z': '$zoomLevel', + }; + Uri uri = Uri.https('maps.apple.com', '/', params); + if (await canLaunchUrl(uri)) { + return uri; + } + } + + return Uri( + scheme: 'https', + host: 'openstreetmap.org', + queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'}, + fragment: 'map=$zoomLevel/$latitude/$longitude', + ); + } + buildMap() { return Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: LayoutBuilder( builder: (context, constraints) { return MapThumbnail( + showAttribution: false, coords: LatLng( exifInfo?.latitude ?? 0, exifInfo?.longitude ?? 0, ), height: 150, - zoom: 16.0, + width: constraints.maxWidth, + zoom: 12.0, markers: [ Marker( anchorPos: AnchorPos.align(AnchorAlign.top), @@ -139,7 +113,7 @@ class ExifBottomSheet extends HookConsumerWidget { ), ], onTap: (tapPosition, latLong) async { - Uri? uri = await _createCoordinatesUri(exifInfo); + Uri? uri = await createCoordinatesUri(); if (uri == null) { return; @@ -181,8 +155,26 @@ class ExifBottomSheet extends HookConsumerWidget { buildLocation() { // Guard no lat/lng - if (!hasCoordinates(exifInfo)) { - return Container(); + if (!hasCoordinates()) { + return asset.isRemote + ? ListTile( + minLeadingWidth: 0, + contentPadding: const EdgeInsets.all(0), + leading: const Icon(Icons.location_on), + title: Text( + "exif_bottom_sheet_location_add", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + onTap: () => handleEditLocation( + ref, + context, + [assetWithExif.value ?? asset], + ), + ) + : const SizedBox.shrink(); } return Column( @@ -191,13 +183,29 @@ class ExifBottomSheet extends HookConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "exif_bottom_sheet_location", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "exif_bottom_sheet_location", + style: context.textTheme.labelMedium?.copyWith( + color: + context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + if (asset.isRemote) + IconButton( + onPressed: () => handleEditLocation( + ref, + context, + [assetWithExif.value ?? asset], + ), + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ), buildMap(), RichText( text: TextSpan( @@ -233,12 +241,27 @@ class ExifBottomSheet extends HookConsumerWidget { } buildDate() { - return Text( - formattedDateTime, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formattedDateTime(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (asset.isRemote) + IconButton( + onPressed: () => handleEditDateTime( + ref, + context, + [assetWithExif.value ?? asset], + ), + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], ); } @@ -363,7 +386,7 @@ class ExifBottomSheet extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible( - flex: hasCoordinates(exifInfo) ? 5 : 0, + flex: hasCoordinates() ? 5 : 0, child: Padding( padding: const EdgeInsets.only(right: 8.0), child: buildLocation(), @@ -402,9 +425,8 @@ class ExifBottomSheet extends HookConsumerWidget { child: CircularProgressIndicator.adaptive(), ), ), - const SizedBox(height: 8.0), buildLocation(), - SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0), + SizedBox(height: hasCoordinates() ? 16.0 : 6.0), buildDetail(), const SizedBox(height: 50), ], diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 8ae7f98cd7..6b0b91c33a 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -19,6 +19,8 @@ class ControlBottomAppBar extends ConsumerWidget { final void Function() onCreateNewAlbum; final void Function() onUpload; final void Function() onStack; + final void Function() onEditTime; + final void Function() onEditLocation; final List albums; final List sharedAlbums; @@ -37,6 +39,8 @@ class ControlBottomAppBar extends ConsumerWidget { required this.onCreateNewAlbum, required this.onUpload, required this.onStack, + required this.onEditTime, + required this.onEditLocation, this.selectionAssetState = const SelectionAssetState(), this.enabled = true, }) : super(key: key); @@ -74,6 +78,18 @@ class ControlBottomAppBar extends ConsumerWidget { label: "control_bottom_app_bar_favorite".tr(), onPressed: enabled ? onFavorite : null, ), + if (hasRemote) + ControlBoxButton( + iconData: Icons.edit_calendar_outlined, + label: "control_bottom_app_bar_edit_time".tr(), + onPressed: enabled ? onEditTime : null, + ), + if (hasRemote) + ControlBoxButton( + iconData: Icons.edit_location_alt_outlined, + label: "control_bottom_app_bar_edit_location".tr(), + onPressed: enabled ? onEditLocation : null, + ), ControlBoxButton( iconData: Icons.delete_outline_rounded, label: "control_bottom_app_bar_delete".tr(), diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 58770ed5ca..c351d5708c 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -213,10 +213,10 @@ class HomePage extends HookConsumerWidget { processing.value = true; selectionEnabledHook.value = false; try { - ref.read(manualUploadProvider.notifier).uploadAssets( - context, - selection.value.where((a) => a.storage == AssetState.local), - ); + ref.read(manualUploadProvider.notifier).uploadAssets( + context, + selection.value.where((a) => a.storage == AssetState.local), + ); } finally { processing.value = false; } @@ -312,6 +312,34 @@ class HomePage extends HookConsumerWidget { } } + void onEditTime() async { + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_favorite_err_local'.tr(), + ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + handleEditDateTime(ref, context, remoteAssets.toList()); + } + } finally { + selectionEnabledHook.value = false; + } + } + + void onEditLocation() async { + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_favorite_err_local'.tr(), + ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + handleEditLocation(ref, context, remoteAssets.toList()); + } + } finally { + selectionEnabledHook.value = false; + } + } + Future refreshAssets() async { final fullRefresh = refreshCount.value > 0; await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); @@ -411,6 +439,8 @@ class HomePage extends HookConsumerWidget { enabled: !processing.value, selectionAssetState: selectionAssetState.value, onStack: onStack, + onEditTime: onEditTime, + onEditLocation: onEditLocation, ), ], ), diff --git a/mobile/lib/modules/map/ui/map_location_picker.dart b/mobile/lib/modules/map/ui/map_location_picker.dart new file mode 100644 index 0000000000..c3a2043aec --- /dev/null +++ b/mobile/lib/modules/map/ui/map_location_picker.dart @@ -0,0 +1,113 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/utils/immich_app_theme.dart'; +import 'package:latlong2/latlong.dart'; + +class MapLocationPickerPage extends HookConsumerWidget { + final LatLng? initialLatLng; + + const MapLocationPickerPage({super.key, this.initialLatLng}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedLatLng = useState(initialLatLng ?? LatLng(0, 0)); + final isDarkTheme = + ref.watch(mapStateNotifier.select((state) => state.isDarkTheme)); + final isLoading = + ref.watch(mapStateNotifier.select((state) => state.isLoading)); + final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom; + + return Theme( + // Override app theme based on map theme + data: isDarkTheme ? immichDarkTheme : immichLightTheme, + child: Scaffold( + extendBodyBehindAppBar: true, + body: Stack( + children: [ + if (!isLoading) + FlutterMap( + options: MapOptions( + maxBounds: + LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)), + interactiveFlags: InteractiveFlag.doubleTapZoom | + InteractiveFlag.drag | + InteractiveFlag.flingAnimation | + InteractiveFlag.pinchMove | + InteractiveFlag.pinchZoom, + center: LatLng(20, 20), + zoom: 2, + minZoom: 1, + maxZoom: maxZoom, + onTap: (tapPosition, point) => selectedLatLng.value = point, + ), + children: [ + ref.read(mapStateNotifier.notifier).getTileLayer(), + MarkerLayer( + markers: [ + Marker( + anchorPos: AnchorPos.align(AnchorAlign.top), + point: selectedLatLng.value, + builder: (ctx) => const Image( + image: AssetImage('assets/location-pin.png'), + ), + height: 40, + width: 40, + ), + ], + ), + ], + ), + if (isLoading) + Positioned( + top: context.height * 0.35, + left: context.width * 0.425, + child: const ImmichLoadingIndicator(), + ), + ], + ), + bottomSheet: BottomSheet( + onClosing: () {}, + builder: (context) => SizedBox( + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}", + style: context.textTheme.bodyLarge?.copyWith( + color: context.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => context.autoPop(selectedLatLng.value), + child: const Text("map_location_picker_page_use_location") + .tr(), + ), + ElevatedButton( + onPressed: () => context.autoPop(), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + ), + child: const Text("action_common_cancel").tr(), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/map/ui/map_thumbnail.dart b/mobile/lib/modules/map/ui/map_thumbnail.dart index d42d99de1a..e385eb9705 100644 --- a/mobile/lib/modules/map/ui/map_thumbnail.dart +++ b/mobile/lib/modules/map/ui/map_thumbnail.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_map/plugin_api.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; +import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -12,13 +14,15 @@ class MapThumbnail extends HookConsumerWidget { final double zoom; final List markers; final double height; + final double width; final bool showAttribution; final bool isDarkTheme; const MapThumbnail({ super.key, required this.coords, - required this.height, + this.height = 100, + this.width = 100, this.onTap, this.zoom = 1, this.showAttribution = true, @@ -28,18 +32,33 @@ class MapThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mapController = useMapController(); + final isMapReady = useRef(false); ref.watch(mapStateNotifier.select((s) => s.mapStyle)); + useEffect( + () { + if (isMapReady.value && mapController.center != coords) { + mapController.move(coords, zoom); + } + return null; + }, + [coords], + ); + return SizedBox( height: height, + width: width, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(15)), child: FlutterMap( + mapController: mapController, options: MapOptions( interactiveFlags: InteractiveFlag.none, center: coords, zoom: zoom, onTap: onTap, + onMapReady: () => isMapReady.value = true, ), nonRotatedChildren: [ if (showAttribution) diff --git a/mobile/lib/modules/map/utils/map_controller_hook.dart b/mobile/lib/modules/map/utils/map_controller_hook.dart new file mode 100644 index 0000000000..e5812c938b --- /dev/null +++ b/mobile/lib/modules/map/utils/map_controller_hook.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_map/flutter_map.dart'; + +MapController useMapController({ + String? debugLabel, + List? keys, +}) { + return use(_MapControllerHook(keys: keys)); +} + +class _MapControllerHook extends Hook { + const _MapControllerHook({List? keys}) : super(keys: keys); + + @override + HookState> createState() => + _MapControllerHookState(); +} + +class _MapControllerHookState + extends HookState { + late final controller = MapController(); + + @override + MapController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useMapController'; +} diff --git a/mobile/lib/modules/map/views/map_page.dart b/mobile/lib/modules/map/views/map_page.dart index b03c13e366..697ea41e06 100644 --- a/mobile/lib/modules/map/views/map_page.dart +++ b/mobile/lib/modules/map/views/map_page.dart @@ -55,6 +55,7 @@ class MapPageState extends ConsumerState { // in onMapEvent() since MapEventMove#id is not populated properly in the // current version of flutter_map(4.0.0) used bool forceAssetUpdate = false; + bool isMapReady = false; late final Debounce debounce; @override @@ -79,7 +80,7 @@ class MapPageState extends ConsumerState { bool forceReload = false, }) { try { - final bounds = mapController.bounds; + final bounds = isMapReady ? mapController.bounds : null; if (bounds != null) { final oldAssetsInBounds = assetsInBounds.toSet(); assetsInBounds = @@ -455,6 +456,7 @@ class MapPageState extends ConsumerState { minZoom: 1, maxZoom: maxZoom, onMapReady: () { + isMapReady = true; mapController.mapEventStream.listen(onMapEvent); }, ), diff --git a/mobile/lib/modules/search/ui/curated_places_row.dart b/mobile/lib/modules/search/ui/curated_places_row.dart index b0343f5ed5..133c0e1c89 100644 --- a/mobile/lib/modules/search/ui/curated_places_row.dart +++ b/mobile/lib/modules/search/ui/curated_places_row.dart @@ -29,9 +29,8 @@ class CuratedPlacesRow extends CuratedRow { onTap: () => context.autoPush( const MapRoute(), ), - child: SizedBox( - height: imageSize, - width: imageSize, + child: SizedBox.square( + dimension: imageSize, child: Stack( children: [ Padding( @@ -43,6 +42,7 @@ class CuratedPlacesRow extends CuratedRow { 5, ), height: imageSize, + width: imageSize, showAttribution: false, isDarkTheme: context.isDarkTheme, ), diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 67407b7e26..0773f7aa0a 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart'; import 'package:immich_mobile/modules/album/views/asset_selection_page.dart'; import 'package:immich_mobile/modules/album/views/create_album_page.dart'; import 'package:immich_mobile/modules/album/views/library_page.dart'; +import 'package:immich_mobile/modules/map/ui/map_location_picker.dart'; import 'package:immich_mobile/modules/map/views/map_page.dart'; import 'package:immich_mobile/modules/memories/models/memory.dart'; import 'package:immich_mobile/modules/memories/views/memory_page.dart'; @@ -57,7 +58,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart'; import 'package:isar/isar.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' hide LatLng; +import 'package:latlong2/latlong.dart'; part 'router.gr.dart'; @@ -172,6 +174,10 @@ part 'router.gr.dart'; transitionsBuilder: TransitionsBuilders.slideLeft, durationInMilliseconds: 200, ), + CustomRoute( + page: MapLocationPickerPage, + guards: [AuthGuard, DuplicateGuard], + ), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d54f8aeda1..05980054f5 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -360,6 +360,19 @@ class _$AppRouter extends RootStackRouter { barrierDismissible: false, ); }, + MapLocationPickerRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const MapLocationPickerRouteArgs()); + return CustomPage( + routeData: routeData, + child: MapLocationPickerPage( + key: args.key, + initialLatLng: args.initialLatLng, + ), + opaque: true, + barrierDismissible: false, + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -704,6 +717,14 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + MapLocationPickerRoute.name, + path: '/map-location-picker-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), ]; } @@ -1621,6 +1642,40 @@ class ActivitiesRouteArgs { } } +/// generated route for +/// [MapLocationPickerPage] +class MapLocationPickerRoute extends PageRouteInfo { + MapLocationPickerRoute({ + Key? key, + LatLng? initialLatLng, + }) : super( + MapLocationPickerRoute.name, + path: '/map-location-picker-page', + args: MapLocationPickerRouteArgs( + key: key, + initialLatLng: initialLatLng, + ), + ); + + static const String name = 'MapLocationPickerRoute'; +} + +class MapLocationPickerRouteArgs { + const MapLocationPickerRouteArgs({ + this.key, + this.initialLatLng, + }); + + final Key? key; + + final LatLng? initialLatLng; + + @override + String toString() { + return 'MapLocationPickerRouteArgs{key: $key, initialLatLng: $initialLatLng}'; + } +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 31b243e4d9..68234dab4b 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -256,6 +256,8 @@ class Asset { isFavorite != a.isFavorite || isArchived != a.isArchived || isTrashed != a.isTrashed || + a.exifInfo?.latitude != exifInfo?.latitude || + a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote ((stackCount == null && a.stackCount != null) || (stackCount != null && diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 8b1ee6a33f..a7bb4f019c 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; import 'package:isar/isar.dart'; +import 'package:latlong2/latlong.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -181,4 +182,27 @@ class AssetService { Future> changeArchiveStatus(List assets, bool isArchive) { return updateAssets(assets, UpdateAssetDto(isArchived: isArchive)); } + + Future> changeDateTime( + List assets, + String updatedDt, + ) { + return updateAssets( + assets, + UpdateAssetDto(dateTimeOriginal: updatedDt), + ); + } + + Future> changeLocation( + List assets, + LatLng location, + ) { + return updateAssets( + assets, + UpdateAssetDto( + latitude: location.latitude, + longitude: location.longitude, + ), + ); + } } diff --git a/mobile/lib/shared/ui/date_time_picker.dart b/mobile/lib/shared/ui/date_time_picker.dart new file mode 100644 index 0000000000..6c7ea16cee --- /dev/null +++ b/mobile/lib/shared/ui/date_time_picker.dart @@ -0,0 +1,257 @@ +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:timezone/timezone.dart'; + +Future showDateTimePicker({ + required BuildContext context, + DateTime? initialDateTime, + String? initialTZ, + Duration? initialTZOffset, +}) { + return showDialog( + context: context, + builder: (context) => _DateTimePicker( + initialDateTime: initialDateTime, + initialTZ: initialTZ, + initialTZOffset: initialTZOffset, + ), + ); +} + +String _getFormattedOffset(int offsetInMilli, tz.Location location) { + return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})"; +} + +class _DateTimePicker extends HookWidget { + final DateTime? initialDateTime; + final String? initialTZ; + final Duration? initialTZOffset; + + const _DateTimePicker({ + this.initialDateTime, + this.initialTZ, + this.initialTZOffset, + }); + + _TimeZoneOffset _getInitiationLocation() { + if (initialTZ != null) { + try { + return _TimeZoneOffset.fromLocation( + tz.timeZoneDatabase.get(initialTZ!), + ); + } on LocationNotFoundException { + // no-op + } + } + + Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset; + + if (tzOffset != null) { + final offsetInMilli = tzOffset.inMilliseconds; + // get all locations with matching offset + final locations = tz.timeZoneDatabase.locations.values.where( + (location) => location.currentTimeZone.offset == offsetInMilli, + ); + // Prefer locations with abbreviation first + final location = locations.firstWhereOrNull( + (e) => !e.currentTimeZone.abbreviation.contains("0"), + ) ?? + locations.firstOrNull; + if (location != null) { + return _TimeZoneOffset.fromLocation(location); + } + } + + return _TimeZoneOffset.fromLocation(tz.getLocation("UTC")); + } + + // returns a list of location along with it's offset in duration + List<_TimeZoneOffset> getAllTimeZones() { + return tz.timeZoneDatabase.locations.values + .where((l) => !l.currentTimeZone.abbreviation.contains("0")) + .map(_TimeZoneOffset.fromLocation) + .sorted() + .toList(); + } + + @override + Widget build(BuildContext context) { + final date = useState(initialDateTime ?? DateTime.now()); + final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation()); + final timeZones = useMemoized(() => getAllTimeZones(), const []); + + void pickDate() async { + final newDate = await showDatePicker( + context: context, + initialDate: date.value, + firstDate: DateTime(1800), + lastDate: DateTime.now(), + ); + if (newDate == null) { + return; + } + + final newTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(date.value), + ); + + if (newTime == null) { + return; + } + + date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute); + } + + void popWithDateTime() { + final formattedDateTime = + DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value); + final dtWithOffset = formattedDateTime + + Duration(milliseconds: tzOffset.value.offsetInMilliseconds) + .formatAsOffset(); + context.pop(dtWithOffset); + } + + return AlertDialog( + contentPadding: const EdgeInsets.all(30), + alignment: Alignment.center, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "edit_date_time_dialog_date_time", + textAlign: TextAlign.center, + ).tr(), + TextButton.icon( + onPressed: pickDate, + icon: Text( + DateFormat("dd-MM-yyyy hh:mm a").format(date.value), + style: context.textTheme.bodyLarge + ?.copyWith(color: context.primaryColor), + ), + label: const Icon( + Icons.edit_outlined, + size: 18, + ), + ), + const Text( + "edit_date_time_dialog_timezone", + textAlign: TextAlign.center, + ).tr(), + DropdownMenu( + menuHeight: 300, + width: 280, + inputDecorationTheme: const InputDecorationTheme( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + trailingIcon: Padding( + padding: const EdgeInsets.only(right: 10), + child: Icon( + Icons.arrow_drop_down, + color: context.primaryColor, + ), + ), + textStyle: context.textTheme.bodyLarge?.copyWith( + color: context.primaryColor, + ), + menuStyle: const MenuStyle( + fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)), + alignment: Alignment(-1.25, 0.5), + ), + onSelected: (value) => tzOffset.value = value!, + initialSelection: tzOffset.value, + dropdownMenuEntries: timeZones + .map( + (t) => DropdownMenuEntry<_TimeZoneOffset>( + value: t, + label: t.display, + style: ButtonStyle( + textStyle: MaterialStatePropertyAll( + context.textTheme.bodyMedium, + ), + ), + ), + ) + .toList(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "action_common_cancel", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colorScheme.error, + ), + ).tr(), + ), + TextButton( + onPressed: popWithDateTime, + child: Text( + "action_common_update", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + ), + ], + ); + } +} + +class _TimeZoneOffset implements Comparable<_TimeZoneOffset> { + final String display; + final Location location; + + const _TimeZoneOffset({ + required this.display, + required this.location, + }); + + _TimeZoneOffset copyWith({ + String? display, + Location? location, + }) { + return _TimeZoneOffset( + display: display ?? this.display, + location: location ?? this.location, + ); + } + + int get offsetInMilliseconds => location.currentTimeZone.offset; + + _TimeZoneOffset.fromLocation(tz.Location l) + : display = _getFormattedOffset(l.currentTimeZone.offset, l), + location = l; + + @override + int compareTo(_TimeZoneOffset other) { + return offsetInMilliseconds.compareTo(other.offsetInMilliseconds); + } + + @override + String toString() => + '_TimeZoneOffset(display: $display, location: $location)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is _TimeZoneOffset && + other.display == display && + other.offsetInMilliseconds == offsetInMilliseconds; + } + + @override + int get hashCode => + display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode; +} diff --git a/mobile/lib/shared/ui/location_picker.dart b/mobile/lib/shared/ui/location_picker.dart new file mode 100644 index 0000000000..9649c36adf --- /dev/null +++ b/mobile/lib/shared/ui/location_picker.dart @@ -0,0 +1,256 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_map/plugin_api.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:latlong2/latlong.dart'; + +Future showLocationPicker({ + required BuildContext context, + LatLng? initialLatLng, +}) { + return showDialog( + context: context, + useRootNavigator: false, + builder: (ctx) => _LocationPicker( + initialLatLng: initialLatLng, + ), + ); +} + +enum _LocationPickerMode { map, manual } + +bool _validateLat(String value) { + final l = double.tryParse(value); + return l != null && l > -90 && l < 90; +} + +bool _validateLong(String value) { + final l = double.tryParse(value); + return l != null && l > -180 && l < 180; +} + +class _LocationPicker extends HookWidget { + final LatLng? initialLatLng; + + const _LocationPicker({ + this.initialLatLng, + }); + + @override + Widget build(BuildContext context) { + final latitude = useState(initialLatLng?.latitude ?? 0.0); + final longitude = useState(initialLatLng?.longitude ?? 0.0); + final latlng = LatLng(latitude.value, longitude.value); + final pickerMode = useState(_LocationPickerMode.map); + final latitudeController = useTextEditingController(); + final isValidLatitude = useState(true); + final latitiudeFocusNode = useFocusNode(); + final longitudeController = useTextEditingController(); + final longitudeFocusNode = useFocusNode(); + final isValidLongitude = useState(true); + + void validateInputs() { + isValidLatitude.value = _validateLat(latitudeController.text); + if (isValidLatitude.value) { + latitude.value = latitudeController.text.toDouble(); + } + isValidLongitude.value = _validateLong(longitudeController.text); + if (isValidLongitude.value) { + longitude.value = longitudeController.text.toDouble(); + } + } + + void validateAndPop() { + if (pickerMode.value == _LocationPickerMode.manual) { + validateInputs(); + } + if (isValidLatitude.value && isValidLongitude.value) { + return context.pop(latlng); + } + } + + List buildMapPickerMode() { + return [ + TextButton.icon( + icon: Text( + "${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}", + ), + label: const Icon(Icons.edit_outlined, size: 16), + onPressed: () { + latitudeController.text = latitude.value.toStringAsFixed(4); + longitudeController.text = longitude.value.toStringAsFixed(4); + pickerMode.value = _LocationPickerMode.manual; + }, + ), + const SizedBox( + height: 12, + ), + MapThumbnail( + coords: latlng, + height: 200, + width: 200, + zoom: 6, + showAttribution: false, + onTap: (p0, p1) async { + final newLatLng = await context.autoPush( + MapLocationPickerRoute(initialLatLng: latlng), + ); + if (newLatLng != null) { + latitude.value = newLatLng.latitude; + longitude.value = newLatLng.longitude; + } + }, + markers: [ + Marker( + anchorPos: AnchorPos.align(AnchorAlign.top), + point: LatLng( + latitude.value, + longitude.value, + ), + builder: (ctx) => const Image( + image: AssetImage('assets/location-pin.png'), + ), + ), + ], + ), + ]; + } + + List buildManualPickerMode() { + return [ + TextButton.icon( + icon: const Text("location_picker_choose_on_map").tr(), + label: const Icon(Icons.map_outlined, size: 16), + onPressed: () { + validateInputs(); + if (isValidLatitude.value && isValidLongitude.value) { + pickerMode.value = _LocationPickerMode.map; + } + }, + ), + const SizedBox( + height: 12, + ), + TextField( + controller: latitudeController, + focusNode: latitiudeFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + labelText: 'location_picker_latitude'.tr(), + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + floatingLabelBehavior: FloatingLabelBehavior.auto, + border: const OutlineInputBorder(), + hintText: 'location_picker_latitude_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + errorText: isValidLatitude.value + ? null + : "location_picker_latitude_error".tr(), + ), + onEditingComplete: () { + isValidLatitude.value = _validateLat(latitudeController.text); + if (isValidLatitude.value) { + latitude.value = latitudeController.text.toDouble(); + longitudeFocusNode.requestFocus(); + } + }, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [LengthLimitingTextInputFormatter(8)], + onTapOutside: (_) => latitiudeFocusNode.unfocus(), + ), + const SizedBox( + height: 24, + ), + TextField( + controller: longitudeController, + focusNode: longitudeFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + labelText: 'location_picker_longitude'.tr(), + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + floatingLabelBehavior: FloatingLabelBehavior.auto, + border: const OutlineInputBorder(), + hintText: 'location_picker_longitude_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + errorText: isValidLongitude.value + ? null + : "location_picker_longitude_error".tr(), + ), + onEditingComplete: () { + isValidLongitude.value = _validateLong(longitudeController.text); + if (isValidLongitude.value) { + longitude.value = longitudeController.text.toDouble(); + longitudeFocusNode.unfocus(); + } + }, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [LengthLimitingTextInputFormatter(8)], + onTapOutside: (_) => longitudeFocusNode.unfocus(), + ), + ]; + } + + return AlertDialog( + contentPadding: const EdgeInsets.all(30), + alignment: Alignment.center, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "edit_location_dialog_title", + textAlign: TextAlign.center, + ).tr(), + const SizedBox( + height: 12, + ), + if (pickerMode.value == _LocationPickerMode.manual) + ...buildManualPickerMode(), + if (pickerMode.value == _LocationPickerMode.map) + ...buildMapPickerMode(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "action_common_cancel", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colorScheme.error, + ), + ).tr(), + ), + TextButton( + onPressed: validateAndPop, + child: Text( + "action_common_update", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/ui/scaffold_error_body.dart b/mobile/lib/shared/ui/scaffold_error_body.dart index fef6bef59e..ef0d9d5990 100644 --- a/mobile/lib/shared/ui/scaffold_error_body.dart +++ b/mobile/lib/shared/ui/scaffold_error_body.dart @@ -15,7 +15,7 @@ class ScaffoldErrorBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - "scaffold_body_error_occured", + "scaffold_body_error_occurred", style: context.textTheme.displayMedium, textAlign: TextAlign.center, ).tr(), diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index d52f3ac1db..47bf33e987 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/shared/services/share.service.dart'; +import 'package:immich_mobile/shared/ui/date_time_picker.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/shared/ui/location_picker.dart'; import 'package:immich_mobile/shared/ui/share_dialog.dart'; +import 'package:latlong2/latlong.dart'; void handleShareAssets( WidgetRef ref, @@ -85,3 +90,60 @@ Future handleFavoriteAssets( } } } + +Future handleEditDateTime( + WidgetRef ref, + BuildContext context, + List selection, +) async { + DateTime? initialDate; + String? timeZone; + Duration? offset; + if (selection.length == 1) { + final asset = selection.first; + final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset); + final (dt, oft) = assetWithExif.getTZAdjustedTimeAndOffset(); + initialDate = dt; + offset = oft; + timeZone = assetWithExif.exifInfo?.timeZone; + } + final dateTime = await showDateTimePicker( + context: context, + initialDateTime: initialDate, + initialTZ: timeZone, + initialTZOffset: offset, + ); + if (dateTime == null) { + return; + } + + ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime); +} + +Future handleEditLocation( + WidgetRef ref, + BuildContext context, + List selection, +) async { + LatLng? initialLatLng; + if (selection.length == 1) { + final asset = selection.first; + final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset); + if (assetWithExif.exifInfo?.latitude != null && + assetWithExif.exifInfo?.longitude != null) { + initialLatLng = LatLng( + assetWithExif.exifInfo!.latitude!, + assetWithExif.exifInfo!.longitude!, + ); + } + } + final location = await showLocationPicker( + context: context, + initialLatLng: initialLatLng, + ); + if (location == null) { + return; + } + + ref.read(assetServiceProvider).changeLocation(selection.toList(), location); +} diff --git a/mobile/test/asset_extensions_test.dart b/mobile/test/asset_extensions_test.dart new file mode 100644 index 0000000000..1e429b5ac1 --- /dev/null +++ b/mobile/test/asset_extensions_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/extensions/asset_extensions.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:timezone/data/latest.dart'; +import 'package:timezone/timezone.dart'; + +ExifInfo makeExif({ + DateTime? dateTimeOriginal, + String? timeZone, +}) { + return ExifInfo( + dateTimeOriginal: dateTimeOriginal, + timeZone: timeZone, + ); +} + +Asset makeAsset({ + required String id, + required DateTime createdAt, + ExifInfo? exifInfo, +}) { + return Asset( + checksum: '', + localId: id, + remoteId: id, + ownerId: 1, + fileCreatedAt: createdAt, + fileModifiedAt: DateTime.now(), + updatedAt: DateTime.now(), + durationInSeconds: 0, + type: AssetType.image, + fileName: id, + isFavorite: false, + isArchived: false, + isTrashed: false, + stackCount: 0, + exifInfo: exifInfo, + ); +} + +void main() { + // Init Timezone DB + initializeTimeZones(); + + group("Returns local time and offset if no exifInfo", () { + test('returns createdAt directly if in local', () { + final createdAt = DateTime(2023, 12, 12, 12, 12, 12); + final a = makeAsset(id: '1', createdAt: createdAt); + final (dt, tz) = a.getTZAdjustedTimeAndOffset(); + + expect(dt, createdAt); + expect(tz, createdAt.timeZoneOffset); + }); + + test('returns createdAt in local if in utc', () { + final createdAt = DateTime.utc(2023, 12, 12, 12, 12, 12); + final a = makeAsset(id: '1', createdAt: createdAt); + final (dt, tz) = a.getTZAdjustedTimeAndOffset(); + + final localCreatedAt = createdAt.toLocal(); + expect(dt, localCreatedAt); + expect(tz, localCreatedAt.timeZoneOffset); + }); + }); + + group("Returns dateTimeOriginal", () { + test('Returns dateTimeOriginal in UTC from exifInfo without timezone', () { + final createdAt = DateTime.parse("2023-01-27T14:00:00-0500"); + final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530"); + final e = makeExif(dateTimeOriginal: dateTimeOriginal); + final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e); + final (dt, tz) = a.getTZAdjustedTimeAndOffset(); + + final dateTimeInUTC = dateTimeOriginal.toUtc(); + expect(dt, dateTimeInUTC); + expect(tz, dateTimeInUTC.timeZoneOffset); + }); + + test('Returns dateTimeOriginal in UTC from exifInfo with invalid timezone', + () { + final createdAt = DateTime.parse("2023-01-27T14:00:00-0500"); + final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530"); + final e = makeExif( + dateTimeOriginal: dateTimeOriginal, + timeZone: "#_#", + ); // Invalid timezone + final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e); + final (dt, tz) = a.getTZAdjustedTimeAndOffset(); + + final dateTimeInUTC = dateTimeOriginal.toUtc(); + expect(dt, dateTimeInUTC); + expect(tz, dateTimeInUTC.timeZoneOffset); + }); + }); + + group("Returns adjusted time if timezone available", () { + test('With timezone as location', () { + final createdAt = DateTime.parse("2023-01-27T14:00:00-0500"); + final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530"); + const location = "Asia/Hong_Kong"; + final e = + makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: location); + final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e); + final (dt, tz) = a.getTZAdjustedTimeAndOffset(); + + final adjustedTime = + TZDateTime.from(dateTimeOriginal.toUtc(), getLocation(location)); + expect(dt, adjustedTime); + expect(tz, adjustedTime.timeZoneOffset); + }); + + test('With timezone as offset', () { + final createdAt = DateTime.parse("2023-01-27T14:00:00-0500"); + final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530"); + const offset = "utc+08:00"; + final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: offset); + final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e); + final (dt, tz) = a.getTZAdjustedTimeAndOffset(); + + final location = getLocation("Asia/Hong_Kong"); + final offsetFromLocation = + Duration(milliseconds: location.currentTimeZone.offset); + final adjustedTime = dateTimeOriginal.toUtc().add(offsetFromLocation); + + // Adds the offset to the actual time and returns the offset separately + expect(dt, adjustedTime); + expect(tz, offsetFromLocation); + }); + }); +} From 024fe1141b371a6a9c08013bc1833ffffd60a2aa Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Wed, 6 Dec 2023 00:05:22 +0100 Subject: [PATCH 4/4] fix(web): background when re-assigning faces (#5512) --- web/src/lib/components/faces-page/assign-face-side-panel.svelte | 2 +- web/src/lib/components/faces-page/person-side-panel.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 9b391e9389..a4d1319e4c 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -111,7 +111,7 @@
{#if !searchFaces} diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index ecd98fbfbc..14b60d7eea 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -160,7 +160,7 @@