From cd639c7347997b8782b8ae9b0db78cd00dc69eef Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 12 Apr 2026 16:01:29 -0700 Subject: [PATCH 1/4] RU-T50 Updated to Expo 54 --- app.config.ts | 1 + package.json | 99 +- yarn.lock | 2468 +++++++++++++++++++++++++++++++------------------ 3 files changed, 1634 insertions(+), 934 deletions(-) diff --git a/app.config.ts b/app.config.ts index cccf848..e9b8c55 100644 --- a/app.config.ts +++ b/app.config.ts @@ -219,6 +219,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ '@config-plugins/react-native-webrtc', '@config-plugins/react-native-callkeep', '@react-native-firebase/app', + '@sentry/react-native', './customGradle.plugin.js', './customManifest.plugin.js', './plugins/withForegroundNotifications.js', diff --git a/package.json b/package.json index ff1f45f..501beac 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@config-plugins/react-native-webrtc": "~12.0.0", "@dev-plugins/react-query": "~0.2.0", "@expo/html-elements": "~0.10.1", - "@expo/metro-runtime": "~5.0.5", + "@expo/metro-runtime": "~6.1.2", "@gluestack-ui/accordion": "~1.0.6", "@gluestack-ui/actionsheet": "~0.2.44", "@gluestack-ui/alert": "~0.1.15", @@ -102,8 +102,8 @@ "@react-native-firebase/messaging": "^23.5.0", "@rnmapbox/maps": "10.2.10", "@semantic-release/git": "^10.0.1", - "@sentry/react-native": "~6.14.0", - "@shopify/flash-list": "1.7.6", + "@sentry/react-native": "~7.2.0", + "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "~5.52.1", "app-icon-badge": "^0.1.2", "axios": "~1.12.0", @@ -112,37 +112,37 @@ "buffer": "^6.0.3", "countly-sdk-react-native-bridge": "25.4.1", "date-fns": "^4.1.0", - "expo": "~53.0.27", - "expo-application": "~6.1.5", - "expo-asset": "~11.1.7", - "expo-audio": "~0.4.9", - "expo-auth-session": "~6.2.1", - "expo-av": "~15.1.7", - "expo-build-properties": "~0.14.8", - "expo-constants": "~17.1.8", - "expo-crypto": "~14.1.5", - "expo-dev-client": "~5.2.4", - "expo-device": "~7.1.4", - "expo-document-picker": "~13.1.6", - "expo-file-system": "~18.1.11", - "expo-font": "~13.3.2", - "expo-image": "~2.4.1", - "expo-image-manipulator": "~13.1.7", - "expo-image-picker": "~16.1.4", - "expo-keep-awake": "~14.1.4", - "expo-linking": "~7.1.7", - "expo-localization": "~16.1.6", - "expo-location": "~18.1.6", - "expo-navigation-bar": "~4.2.8", - "expo-router": "~5.1.11", - "expo-screen-orientation": "~8.1.7", - "expo-secure-store": "~14.2.4", - "expo-sharing": "~13.1.5", - "expo-splash-screen": "~0.30.10", - "expo-status-bar": "~2.2.3", - "expo-system-ui": "~5.0.11", - "expo-task-manager": "~13.1.6", - "expo-web-browser": "~14.2.0", + "expo": "^54.0.33", + "expo-application": "~7.0.8", + "expo-asset": "~12.0.12", + "expo-audio": "~1.1.1", + "expo-auth-session": "~7.0.10", + "expo-av": "~16.0.8", + "expo-build-properties": "~1.0.10", + "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.8", + "expo-dev-client": "~6.0.20", + "expo-device": "~8.0.10", + "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.11", + "expo-image": "~3.0.11", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "~17.0.10", + "expo-keep-awake": "~15.0.8", + "expo-linking": "~8.0.11", + "expo-localization": "~17.0.8", + "expo-location": "~19.0.8", + "expo-navigation-bar": "~5.0.10", + "expo-router": "~6.0.23", + "expo-screen-orientation": "~9.0.8", + "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-system-ui": "~6.0.9", + "expo-task-manager": "~14.0.9", + "expo-web-browser": "~15.0.10", "geojson": "~0.5.0", "i18next": "~23.14.0", "livekit-client": "^2.15.7", @@ -153,28 +153,29 @@ "moti": "~0.29.0", "nativewind": "~4.1.21", "promise": "8.3.0", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", "react-error-boundary": "~4.0.13", "react-hook-form": "~7.53.0", "react-i18next": "~15.0.1", - "react-native": "0.79.6", + "react-native": "0.81.5", "react-native-base64": "~0.2.1", "react-native-ble-manager": "^12.1.5", "react-native-callkeep": "github:Irfanwani/react-native-callkeep#957193d0716f1c2dfdc18e627cbff0f8a0800971", "react-native-edge-to-edge": "1.6.0", "react-native-flash-message": "~0.4.2", - "react-native-gesture-handler": "~2.24.0", - "react-native-keyboard-controller": "^1.18.6", + "react-native-gesture-handler": "~2.28.0", + "react-native-keyboard-controller": "1.18.5", "react-native-logs": "~5.3.0", "react-native-mmkv": "~3.1.0", - "react-native-reanimated": "~3.17.4", + "react-native-reanimated": "~4.1.1", "react-native-restart": "0.0.27", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", - "react-native-svg": "15.11.2", - "react-native-web": "^0.20.0", - "react-native-webview": "~13.13.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-web": "^0.21.0", + "react-native-webview": "13.15.0", + "react-native-worklets": "0.5.1", "react-query-kit": "~3.3.0", "tailwind-variants": "~0.2.1", "zod": "~3.23.8", @@ -184,7 +185,7 @@ "@babel/core": "~7.26.0", "@commitlint/cli": "~19.2.2", "@commitlint/config-conventional": "~19.2.2", - "@expo/config": "^11.0.0", + "@expo/config": "~12.0.12", "@testing-library/jest-dom": "~6.5.0", "@testing-library/react-native": "~12.9.0", "@types/geojson": "~7946.0.16", @@ -192,7 +193,7 @@ "@types/jest": "~29.5.14", "@types/lodash.memoize": "~4.1.9", "@types/mapbox-gl": "3.4.1", - "@types/react": "~19.0.10", + "@types/react": "~19.1.10", "@types/react-native-base64": "~0.2.2", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -218,7 +219,7 @@ "eslint-plugin-unused-imports": "~2.0.0", "jest": "~29.7.0", "jest-environment-jsdom": "~29.7.0", - "jest-expo": "~53.0.14", + "jest-expo": "~54.0.17", "jest-junit": "~16.0.0", "lint-staged": "~15.2.9", "np": "~10.0.7", @@ -229,7 +230,7 @@ "tailwindcss": "3.4.4", "ts-jest": "~29.1.2", "ts-node": "~10.9.2", - "typescript": "5.8.x", + "typescript": "~5.9.2", "wait-on": "9.0.3" }, "repository": { diff --git a/yarn.lock b/yarn.lock index 3e75924..9822dfe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,7 +46,7 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/code-frame@^7.28.6": +"@babel/code-frame@^7.20.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== @@ -60,6 +60,11 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.4.tgz#96fdf1af1b8859c8474ab39c295312bfb7c24b04" integrity sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw== +"@babel/compat-data@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" + integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.24.4", "@babel/core@^7.25.2", "@babel/core@^7.27.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.4.tgz#12a550b8794452df4c8b084f95003bce1742d496" @@ -113,6 +118,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.29.0", "@babel/generator@^7.29.1": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": version "7.27.3" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" @@ -131,6 +147,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== + dependencies: + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.27.1": version "7.28.3" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz#3e747434ea007910c320c4d39a6b46f20f371d46" @@ -144,6 +171,19 @@ "@babel/traverse" "^7.28.3" semver "^6.3.1" +"@babel/helper-create-class-features-plugin@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz#611ff5482da9ef0db6291bcd24303400bca170fb" + integrity sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-member-expression-to-functions" "^7.28.5" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/traverse" "^7.28.6" + semver "^6.3.1" + "@babel/helper-create-regexp-features-plugin@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz#05b0882d97ba1d4d03519e4bce615d70afa18c53" @@ -177,6 +217,14 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" +"@babel/helper-member-expression-to-functions@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz#f3e07a10be37ed7a63461c63e6929575945a6150" + integrity sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg== + dependencies: + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + "@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" @@ -206,6 +254,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== +"@babel/helper-plugin-utils@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + "@babel/helper-remap-async-to-generator@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6" @@ -224,6 +277,15 @@ "@babel/helper-optimise-call-expression" "^7.27.1" "@babel/traverse" "^7.27.1" +"@babel/helper-replace-supers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz#94aa9a1d7423a00aead3f204f78834ce7d53fe44" + integrity sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.28.5" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/traverse" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56" @@ -293,6 +355,13 @@ dependencies: "@babel/types" "^7.29.0" +"@babel/parser@^7.29.0": + version "7.29.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.2.tgz#58bd50b9a7951d134988a1ae177a35ef9a703ba1" + integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== + dependencies: + "@babel/types" "^7.29.0" + "@babel/plugin-proposal-decorators@^7.12.9": version "7.28.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz#419c8acc31088e05a774344c021800f7ddc39bf0" @@ -464,6 +533,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-typescript@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-transform-arrow-functions@^7.0.0-0", "@babel/plugin-transform-arrow-functions@^7.24.7": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" @@ -496,7 +572,15 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-properties@^7.0.0-0", "@babel/plugin-transform-class-properties@^7.25.4": +"@babel/plugin-transform-class-properties@^7.0.0-0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz#d274a4478b6e782d9ea987fda09bdb6d28d66b72" + integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-class-properties@^7.25.4": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz#dd40a6a370dfd49d32362ae206ddaf2bb082a925" integrity sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA== @@ -504,7 +588,27 @@ "@babel/helper-create-class-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-classes@^7.0.0-0", "@babel/plugin-transform-classes@^7.25.4": +"@babel/plugin-transform-class-static-block@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz#1257491e8259c6d125ac4d9a6f39f9d2bf3dba70" + integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-classes@^7.0.0-0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz#8f6fb79ba3703978e701ce2a97e373aae7dda4b7" + integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/traverse" "^7.28.6" + +"@babel/plugin-transform-classes@^7.25.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz#75d66175486788c56728a73424d67cbc7473495c" integrity sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA== @@ -594,7 +698,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-nullish-coalescing-operator@^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": +"@babel/plugin-transform-nullish-coalescing-operator@^7.0.0-0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz#9bc62096e90ab7a887f3ca9c469f6adec5679757" + integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz#4f9d3153bf6782d73dd42785a9d22d03197bc91d" integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== @@ -626,7 +737,15 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-optional-chaining@^7.0.0-0", "@babel/plugin-transform-optional-chaining@^7.24.8": +"@babel/plugin-transform-optional-chaining@^7.0.0-0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz#926cf150bd421fc8362753e911b4a1b1ce4356cd" + integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + +"@babel/plugin-transform-optional-chaining@^7.24.8": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz#874ce3c4f06b7780592e946026eb76a32830454f" integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== @@ -764,6 +883,17 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.27.1" +"@babel/plugin-transform-typescript@^7.28.5": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz#1e93d96da8adbefdfdade1d4956f73afa201a158" + integrity sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.28.6" + "@babel/plugin-transform-unicode-regex@^7.0.0-0", "@babel/plugin-transform-unicode-regex@^7.24.7": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" @@ -784,7 +914,18 @@ "@babel/plugin-transform-react-jsx-development" "^7.27.1" "@babel/plugin-transform-react-pure-annotations" "^7.27.1" -"@babel/preset-typescript@^7.16.7", "@babel/preset-typescript@^7.23.0": +"@babel/preset-typescript@^7.16.7": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz#540359efa3028236958466342967522fd8f2a60c" + integrity sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.28.5" + +"@babel/preset-typescript@^7.23.0": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz#190742a6428d282306648a55b0529b561484f912" integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== @@ -809,7 +950,7 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/template@^7.25.9": +"@babel/template@^7.25.9", "@babel/template@^7.28.6": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== @@ -844,6 +985,19 @@ "@babel/types" "^7.28.4" debug "^4.3.1" +"@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + debug "^4.3.1" + "@babel/types@^7.0.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.23.0", "@babel/types@^7.25.2", "@babel/types@^7.26.10", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.3.3": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" @@ -852,7 +1006,7 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.28.6", "@babel/types@^7.29.0": +"@babel/types@^7.26.0", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== @@ -1292,30 +1446,30 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" -"@expo/cli@0.24.24": - version "0.24.24" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.24.24.tgz#383f5cef56b0e456ef8e1343c7d6a387ff3c6b94" - integrity sha512-XybHfF2QNPJNnHoUKHcG796iEkX5126UuTAs6MSpZuvZRRQRj/sGCLX+driCOVHbDOpcCOusMuHrhxHbtTApyg== +"@expo/cli@54.0.23": + version "54.0.23" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.23.tgz#e8a7dc4e1f2a8a5361afd80bcc352014b57a87ac" + integrity sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g== dependencies: "@0no-co/graphql.web" "^1.0.8" - "@babel/runtime" "^7.20.0" "@expo/code-signing-certificates" "^0.0.6" - "@expo/config" "~11.0.13" - "@expo/config-plugins" "~10.1.2" - "@expo/devcert" "^1.1.2" - "@expo/env" "~1.0.7" - "@expo/image-utils" "^0.7.6" - "@expo/json-file" "^9.1.5" - "@expo/metro-config" "~0.20.18" - "@expo/osascript" "^2.2.5" - "@expo/package-manager" "^1.8.6" - "@expo/plist" "^0.3.5" - "@expo/prebuild-config" "^9.0.12" - "@expo/schema-utils" "^0.1.0" + "@expo/config" "~12.0.13" + "@expo/config-plugins" "~54.0.4" + "@expo/devcert" "^1.2.1" + "@expo/env" "~2.0.8" + "@expo/image-utils" "^0.8.8" + "@expo/json-file" "^10.0.8" + "@expo/metro" "~54.2.0" + "@expo/metro-config" "~54.0.14" + "@expo/osascript" "^2.3.8" + "@expo/package-manager" "^1.9.10" + "@expo/plist" "^0.4.8" + "@expo/prebuild-config" "^54.0.8" + "@expo/schema-utils" "^0.1.8" "@expo/spawn-async" "^1.7.2" "@expo/ws-tunnel" "^1.0.1" "@expo/xcpretty" "^4.3.0" - "@react-native/dev-middleware" "0.79.6" + "@react-native/dev-middleware" "0.81.5" "@urql/core" "^5.0.6" "@urql/exchange-retry" "^1.3.0" accepts "^1.3.8" @@ -1329,9 +1483,10 @@ connect "^3.7.0" debug "^4.3.4" env-editor "^0.4.1" + expo-server "^1.0.5" freeport-async "^2.0.0" getenv "^2.0.0" - glob "^10.4.2" + glob "^13.0.0" lan-network "^0.1.6" minimatch "^9.0.0" node-forge "^1.3.3" @@ -1354,7 +1509,7 @@ source-map-support "~0.5.21" stacktrace-parser "^0.1.10" structured-headers "^0.4.1" - tar "^7.4.3" + tar "^7.5.2" terminal-link "^2.1.1" undici "^6.18.2" wrap-ansi "^7.0.0" @@ -1387,12 +1542,37 @@ xcode "^3.0.1" xml2js "0.6.0" +"@expo/config-plugins@~54.0.4": + version "54.0.4" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-54.0.4.tgz#b31cb16f6651342abcdafba600118245ecd9fb00" + integrity sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q== + dependencies: + "@expo/config-types" "^54.0.10" + "@expo/json-file" "~10.0.8" + "@expo/plist" "^0.4.8" + "@expo/sdk-runtime-versions" "^1.0.0" + chalk "^4.1.2" + debug "^4.3.5" + getenv "^2.0.0" + glob "^13.0.0" + resolve-from "^5.0.0" + semver "^7.5.4" + slash "^3.0.0" + slugify "^1.6.6" + xcode "^3.0.1" + xml2js "0.6.0" + "@expo/config-types@^53.0.5": version "53.0.5" resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-53.0.5.tgz#bba7e0712c2c5b1d8963348d68ea96339f858db4" integrity sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g== -"@expo/config@^11.0.0", "@expo/config@~11.0.12", "@expo/config@~11.0.13": +"@expo/config-types@^54.0.10": + version "54.0.10" + resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-54.0.10.tgz#688f4338255d2fdea970f44e2dfd8e8d37dec292" + integrity sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA== + +"@expo/config@~11.0.13": version "11.0.13" resolved "https://registry.yarnpkg.com/@expo/config/-/config-11.0.13.tgz#1cc490a5f667e0129db5f98755f6bc4d8921edb2" integrity sha512-TnGb4u/zUZetpav9sx/3fWK71oCPaOjZHoVED9NaEncktAd0Eonhq5NUghiJmkUGt3gGSjRAEBXiBbbY9/B1LA== @@ -1411,14 +1591,39 @@ slugify "^1.3.4" sucrase "3.35.0" -"@expo/devcert@^1.1.2": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.2.0.tgz#7b32c2d959e36baaa0649433395e5170c808b44f" - integrity sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA== +"@expo/config@~12.0.11", "@expo/config@~12.0.12", "@expo/config@~12.0.13": + version "12.0.13" + resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.13.tgz#8e696e6121c3c364e1dd527f595cf0a1d9386828" + integrity sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ== + dependencies: + "@babel/code-frame" "~7.10.4" + "@expo/config-plugins" "~54.0.4" + "@expo/config-types" "^54.0.10" + "@expo/json-file" "^10.0.8" + deepmerge "^4.3.1" + getenv "^2.0.0" + glob "^13.0.0" + require-from-string "^2.0.2" + resolve-from "^5.0.0" + resolve-workspace-root "^2.0.0" + semver "^7.6.0" + slugify "^1.3.4" + sucrase "~3.35.1" + +"@expo/devcert@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.2.1.tgz#1a687985bea1670866e54d5ba7c0ced963c354f4" + integrity sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA== dependencies: "@expo/sudo-prompt" "^9.3.1" debug "^3.1.0" - glob "^10.4.2" + +"@expo/devtools@0.1.8": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-0.1.8.tgz#bc5b297698f78b3b67037f04593a31e688330a7a" + integrity sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ== + dependencies: + chalk "^4.1.2" "@expo/env@~1.0.7": version "1.0.7" @@ -1431,18 +1636,28 @@ dotenv-expand "~11.0.6" getenv "^2.0.0" -"@expo/fingerprint@0.13.4": - version "0.13.4" - resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.13.4.tgz#380762d68e3d55718331ede813e24b8760ebb2b5" - integrity sha512-MYfPYBTMfrrNr07DALuLhG6EaLVNVrY/PXjEzsjWdWE4ZFn0yqI0IdHNkJG7t1gePT8iztHc7qnsx+oo/rDo6w== +"@expo/env@~2.0.8": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.0.11.tgz#3a10d9142b1833566bdfb39de1c062f7a8b8ac38" + integrity sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q== + dependencies: + chalk "^4.0.0" + debug "^4.3.4" + dotenv "~16.4.5" + dotenv-expand "~11.0.6" + getenv "^2.0.0" + +"@expo/fingerprint@0.15.4": + version "0.15.4" + resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.4.tgz#578bd1e1179a13313f7de682ebaaacb703b2b7ac" + integrity sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng== dependencies: "@expo/spawn-async" "^1.7.2" arg "^5.0.2" chalk "^4.1.2" debug "^4.3.4" - find-up "^5.0.0" getenv "^2.0.0" - glob "^10.4.2" + glob "^13.0.0" ignore "^5.3.1" minimatch "^9.0.0" p-limit "^3.1.0" @@ -1454,27 +1669,25 @@ resolved "https://registry.yarnpkg.com/@expo/html-elements/-/html-elements-0.10.1.tgz#ec2625370cf1d4cb78efa954df45d422532d5ab6" integrity sha512-3PTmtkV15D7+lykXVtvkH1jQ5Y6JE+e3zCaoMMux7z2cSLGQUNwDEUwG37gew3OEB1/E4/SEWgjvg8m7E6/e2Q== -"@expo/image-utils@^0.7.6": - version "0.7.6" - resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.7.6.tgz#b8442bef770e1c7b39997d57f666bffeeced0a7a" - integrity sha512-GKnMqC79+mo/1AFrmAcUcGfbsXXTRqOMNS1umebuevl3aaw+ztsYEFEiuNhHZW7PQ3Xs3URNT513ZxKhznDscw== +"@expo/image-utils@^0.8.8": + version "0.8.13" + resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.8.13.tgz#c7476352af9f576440e5ec8201c2f75f090a4804" + integrity sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA== dependencies: + "@expo/require-utils" "^55.0.4" "@expo/spawn-async" "^1.7.2" chalk "^4.0.0" getenv "^2.0.0" jimp-compact "0.16.1" parse-png "^2.1.0" - resolve-from "^5.0.0" semver "^7.6.0" - temp-dir "~2.0.0" - unique-string "~2.0.0" -"@expo/json-file@^10.0.7": - version "10.0.7" - resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.0.7.tgz#e4f58fdc03fc62f13610eeafe086d84e6e44fe01" - integrity sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw== +"@expo/json-file@^10.0.13", "@expo/json-file@^10.0.8", "@expo/json-file@~10.0.8": + version "10.0.13" + resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.0.13.tgz#1a9ac56333786e8672181b0b95aab08f8255a548" + integrity sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA== dependencies: - "@babel/code-frame" "~7.10.4" + "@babel/code-frame" "^7.20.0" json5 "^2.2.3" "@expo/json-file@^9.1.5", "@expo/json-file@~9.1.5": @@ -1485,7 +1698,7 @@ "@babel/code-frame" "~7.10.4" json5 "^2.2.3" -"@expo/metro-config@0.20.18", "@expo/metro-config@~0.20.18": +"@expo/metro-config@54.0.14", "@expo/metro-config@~0.20.18", "@expo/metro-config@~54.0.14": version "0.20.18" resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.20.18.tgz#8006a505978a014597d9d55f33bb09cfcb81bed1" integrity sha512-qPYq3Cq61KQO1CppqtmxA1NGKpzFOmdiL7WxwLhEVnz73LPSgneW7dV/3RZwVFkjThzjA41qB4a9pxDqtpepPg== @@ -1510,25 +1723,49 @@ postcss "~8.4.32" resolve-from "^5.0.0" -"@expo/metro-runtime@5.0.5", "@expo/metro-runtime@~5.0.5": - version "5.0.5" - resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-5.0.5.tgz#0b6d365e87034e3dde96fb2f7373fcb0de40af1e" - integrity sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A== +"@expo/metro-runtime@^6.1.2", "@expo/metro-runtime@~6.1.2": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz#5a4ff117df6115f9c9d4dcc561065e16d69c078b" + integrity sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g== + dependencies: + anser "^1.4.9" + pretty-format "^29.7.0" + stacktrace-parser "^0.1.10" + whatwg-fetch "^3.0.0" -"@expo/osascript@^2.2.5": - version "2.3.7" - resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.3.7.tgz#2d53ef06733593405c83767de7420510736e0fa9" - integrity sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ== +"@expo/metro@~54.2.0": + version "54.2.0" + resolved "https://registry.yarnpkg.com/@expo/metro/-/metro-54.2.0.tgz#6ecf4a77ae7553b73daca4206854728de76c854d" + integrity sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w== + dependencies: + metro "0.83.3" + metro-babel-transformer "0.83.3" + metro-cache "0.83.3" + metro-cache-key "0.83.3" + metro-config "0.83.3" + metro-core "0.83.3" + metro-file-map "0.83.3" + metro-minify-terser "0.83.3" + metro-resolver "0.83.3" + metro-runtime "0.83.3" + metro-source-map "0.83.3" + metro-symbolicate "0.83.3" + metro-transform-plugins "0.83.3" + metro-transform-worker "0.83.3" + +"@expo/osascript@^2.3.8": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.4.2.tgz#fe341cff1eb2c939da43cf58ade5504c8a5d77ca" + integrity sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw== dependencies: "@expo/spawn-async" "^1.7.2" - exec-async "^2.2.0" -"@expo/package-manager@^1.8.6": - version "1.9.8" - resolved "https://registry.yarnpkg.com/@expo/package-manager/-/package-manager-1.9.8.tgz#8f6b46a2f5f4bf4f2c78507b1a7a368e0c2e2126" - integrity sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA== +"@expo/package-manager@^1.9.10": + version "1.10.4" + resolved "https://registry.yarnpkg.com/@expo/package-manager/-/package-manager-1.10.4.tgz#1a16bd2ccf85a23865dd98392c11b9f75f9bbf7a" + integrity sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ== dependencies: - "@expo/json-file" "^10.0.7" + "@expo/json-file" "^10.0.13" "@expo/spawn-async" "^1.7.2" chalk "^4.0.0" npm-package-arg "^11.0.0" @@ -1544,42 +1781,50 @@ base64-js "^1.2.3" xmlbuilder "^15.1.1" -"@expo/prebuild-config@^9.0.10", "@expo/prebuild-config@^9.0.12": - version "9.0.12" - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-9.0.12.tgz#ee009b6b4e01ce93f90726f58b084016d2e820a3" - integrity sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q== +"@expo/plist@^0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.4.8.tgz#e014511a4a5008cf2b832b91caa8e9f2704127cc" + integrity sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ== dependencies: - "@expo/config" "~11.0.13" - "@expo/config-plugins" "~10.1.2" - "@expo/config-types" "^53.0.5" - "@expo/image-utils" "^0.7.6" - "@expo/json-file" "^9.1.5" - "@react-native/normalize-colors" "0.79.6" + "@xmldom/xmldom" "^0.8.8" + base64-js "^1.2.3" + xmlbuilder "^15.1.1" + +"@expo/prebuild-config@^54.0.8": + version "54.0.8" + resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz#509410345489cc52d1e6ece52742384efe7ad7c6" + integrity sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg== + dependencies: + "@expo/config" "~12.0.13" + "@expo/config-plugins" "~54.0.4" + "@expo/config-types" "^54.0.10" + "@expo/image-utils" "^0.8.8" + "@expo/json-file" "^10.0.8" + "@react-native/normalize-colors" "0.81.5" debug "^4.3.1" resolve-from "^5.0.0" semver "^7.6.0" xml2js "0.6.0" -"@expo/schema-utils@^0.1.0": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.7.tgz#38baa0effa0823cd4eca3f05e5eee3bde311da12" - integrity sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g== +"@expo/require-utils@^55.0.4": + version "55.0.4" + resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-55.0.4.tgz#cd474a8997ba6ecfa43d084a7f17bde0cb854179" + integrity sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA== + dependencies: + "@babel/code-frame" "^7.20.0" + "@babel/core" "^7.25.2" + "@babel/plugin-transform-modules-commonjs" "^7.24.8" + +"@expo/schema-utils@^0.1.8": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.8.tgz#8b9543d77fc4ac4954196e3fa00f8fcedd71426a" + integrity sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A== "@expo/sdk-runtime-versions@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz#d7ebd21b19f1c6b0395e50d78da4416941c57f7c" integrity sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ== -"@expo/server@^0.6.3": - version "0.6.3" - resolved "https://registry.yarnpkg.com/@expo/server/-/server-0.6.3.tgz#f5c1b52c8841527a242c656a763e280af8accc1a" - integrity sha512-Ea7NJn9Xk1fe4YeJ86rObHSv/bm3u/6WiQPXEqXJ2GrfYpVab2Swoh9/PnSM3KjR64JAgKjArDn1HiPjITCfHA== - dependencies: - abort-controller "^3.0.0" - debug "^4.3.4" - source-map-support "~0.5.21" - undici "^6.18.2 || ^7.0.0" - "@expo/spawn-async@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" @@ -1592,10 +1837,10 @@ resolved "https://registry.yarnpkg.com/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz#0fd2813402a42988e49145cab220e25bea74b308" integrity sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw== -"@expo/vector-icons@^14.0.0": - version "14.1.0" - resolved "https://registry.yarnpkg.com/@expo/vector-icons/-/vector-icons-14.1.0.tgz#d3dddad8b6ea60502e0fe5485b86751827606ce4" - integrity sha512-7T09UE9h8QDTsUeMGymB4i+iqvtEeaO5VvUjryFB4tugDTG/bkzViWA74hm5pfjjDEhYMXWaX112mcvhccmIwQ== +"@expo/vector-icons@^15.0.3": + version "15.1.1" + resolved "https://registry.yarnpkg.com/@expo/vector-icons/-/vector-icons-15.1.1.tgz#4b1d2c60493c0b0536972f0a5babd5f5c85b48f4" + integrity sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw== "@expo/ws-tunnel@^1.0.1": version "1.0.6" @@ -3572,11 +3817,126 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@radix-ui/primitive@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" + integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg== + +"@radix-ui/react-collection@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec" + integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-compose-refs@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== +"@radix-ui/react-context@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" + integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== + +"@radix-ui/react-dialog@^1.1.1": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632" + integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.11" + "@radix-ui/react-focus-guards" "1.1.3" + "@radix-ui/react-focus-scope" "1.1.7" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-portal" "1.1.9" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-use-controllable-state" "1.2.2" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-direction@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14" + integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== + +"@radix-ui/react-dismissable-layer@1.1.11": + version "1.1.11" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz#e33ab6f6bdaa00f8f7327c408d9f631376b88b37" + integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-escape-keydown" "1.1.1" + +"@radix-ui/react-focus-guards@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f" + integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw== + +"@radix-ui/react-focus-scope@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d" + integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-id@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7" + integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-portal@1.1.9": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472" + integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ== + dependencies: + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-presence@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db" + integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-primitive@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc" + integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ== + dependencies: + "@radix-ui/react-slot" "1.2.3" + +"@radix-ui/react-roving-focus@1.1.11": + version "1.1.11" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz#ef54384b7361afc6480dcf9907ef2fedb5080fd9" + integrity sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-collection" "1.1.7" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-slot@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba" @@ -3584,6 +3944,59 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.2" +"@radix-ui/react-slot@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1" + integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + +"@radix-ui/react-tabs@^1.1.12": + version "1.1.13" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15" + integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.11" + "@radix-ui/react-use-controllable-state" "1.2.2" + +"@radix-ui/react-use-callback-ref@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" + integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg== + +"@radix-ui/react-use-controllable-state@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190" + integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg== + dependencies: + "@radix-ui/react-use-effect-event" "0.0.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-effect-event@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907" + integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-escape-keydown@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29" + integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + "@react-aria/checkbox@3.2.1": version "3.2.1" resolved "https://registry.yarnpkg.com/@react-aria/checkbox/-/checkbox-3.2.1.tgz#493d9d584b4db474645a4565c4f899ee3a579f07" @@ -3919,23 +4332,23 @@ resolved "https://registry.yarnpkg.com/@react-native-firebase/messaging/-/messaging-23.5.0.tgz#8e39a44a90f7bf95a9a167649efa7f1e8251f9b7" integrity sha512-2EM28isDWgqCauar/kOnhpFQZ8ARnq9iE0N093TrS/sr+Mu6PHkPEDJElV9LFfp6nfxxjlm75h+x+nJrEDRkhQ== -"@react-native/assets-registry@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.79.6.tgz#cecc2a1140a9584d590000b951a08a0611ec30c3" - integrity sha512-UVSP1224PWg0X+mRlZNftV5xQwZGfawhivuW8fGgxNK9MS/U84xZ+16lkqcPh1ank6MOt239lIWHQ1S33CHgqA== +"@react-native/assets-registry@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d" + integrity sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w== -"@react-native/babel-plugin-codegen@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.79.6.tgz#2e86024a649072268b03b28da8555f9c81bdb51b" - integrity sha512-CS5OrgcMPixOyUJ/Sk/HSsKsKgyKT5P7y3CojimOQzWqRZBmoQfxdST4ugj7n1H+ebM2IKqbgovApFbqXsoX0g== +"@react-native/babel-plugin-codegen@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz#328d03f42c32b5a8cc2dee1aa84a7c48dddc5f18" + integrity sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ== dependencies: "@babel/traverse" "^7.25.3" - "@react-native/codegen" "0.79.6" + "@react-native/codegen" "0.81.5" -"@react-native/babel-preset@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/babel-preset/-/babel-preset-0.79.6.tgz#bc0e94a0b3403d237a60902161587ff90205835c" - integrity sha512-H+FRO+r2Ql6b5IwfE0E7D52JhkxjeGSBSUpCXAI5zQ60zSBJ54Hwh2bBJOohXWl4J+C7gKYSAd2JHMUETu+c/A== +"@react-native/babel-preset@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/babel-preset/-/babel-preset-0.81.5.tgz#e8b7969d21f87ef4e41e603248e8a70c44b4a5bb" + integrity sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA== dependencies: "@babel/core" "^7.25.2" "@babel/plugin-proposal-export-default-from" "^7.24.7" @@ -3978,141 +4391,144 @@ "@babel/plugin-transform-typescript" "^7.25.2" "@babel/plugin-transform-unicode-regex" "^7.24.7" "@babel/template" "^7.25.0" - "@react-native/babel-plugin-codegen" "0.79.6" - babel-plugin-syntax-hermes-parser "0.25.1" + "@react-native/babel-plugin-codegen" "0.81.5" + babel-plugin-syntax-hermes-parser "0.29.1" babel-plugin-transform-flow-enums "^0.0.2" react-refresh "^0.14.0" -"@react-native/codegen@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.79.6.tgz#25e9bb68ce02afcdb01b9b2b0bf8a3a7fd99bf8b" - integrity sha512-iRBX8Lgbqypwnfba7s6opeUwVyaR23mowh9ILw7EcT2oLz3RqMmjJdrbVpWhGSMGq2qkPfqAH7bhO8C7O+xfjQ== +"@react-native/codegen@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.81.5.tgz#d4dec668c94b9d58a5c2dbdbf026db331e1b6b27" + integrity sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g== dependencies: "@babel/core" "^7.25.2" "@babel/parser" "^7.25.3" glob "^7.1.1" - hermes-parser "0.25.1" + hermes-parser "0.29.1" invariant "^2.2.4" nullthrows "^1.1.1" yargs "^17.6.2" -"@react-native/community-cli-plugin@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.79.6.tgz#6d95bc10b0dff0150f8e971b4b0f0867b8c0c06c" - integrity sha512-ZHVst9vByGsegeaddkD2YbZ6NvYb4n3pD9H7Pit94u+NlByq2uBJghoOjT6EKqg+UVl8tLRdi88cU2pDPwdHqA== +"@react-native/community-cli-plugin@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz#617789cda4da419d03dda00e2a78c36188b4391e" + integrity sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw== dependencies: - "@react-native/dev-middleware" "0.79.6" - chalk "^4.0.0" - debug "^2.2.0" + "@react-native/dev-middleware" "0.81.5" + debug "^4.4.0" invariant "^2.2.4" - metro "^0.82.0" - metro-config "^0.82.0" - metro-core "^0.82.0" + metro "^0.83.1" + metro-config "^0.83.1" + metro-core "^0.83.1" semver "^7.1.3" -"@react-native/debugger-frontend@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.79.6.tgz#ec0ea9c2f140a564d26789a18dc097519f1b9c48" - integrity sha512-lIK/KkaH7ueM22bLO0YNaQwZbT/oeqhaghOvmZacaNVbJR1Cdh/XAqjT8FgCS+7PUnbxA8B55NYNKGZG3O2pYw== +"@react-native/debugger-frontend@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz#82ece0181e9a7a3dcbdfa86cf9decd654e13f81f" + integrity sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w== -"@react-native/dev-middleware@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.79.6.tgz#62a4c0b987e5d100eae3e8c95c58ae1c8abe377a" - integrity sha512-BK3GZBa9c7XSNR27EDRtxrgyyA3/mf1j3/y+mPk7Ac0Myu85YNrXnC9g3mL5Ytwo0g58TKrAIgs1fF2Q5Mn6mQ== +"@react-native/dev-middleware@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz#81e8ac545d7736ef6ebb2e59fdbaebc5cf9aedec" + integrity sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA== dependencies: "@isaacs/ttlcache" "^1.4.1" - "@react-native/debugger-frontend" "0.79.6" + "@react-native/debugger-frontend" "0.81.5" chrome-launcher "^0.15.2" chromium-edge-launcher "^0.2.0" connect "^3.6.5" - debug "^2.2.0" + debug "^4.4.0" invariant "^2.2.4" nullthrows "^1.1.1" open "^7.0.3" serve-static "^1.16.2" ws "^6.2.3" -"@react-native/gradle-plugin@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.79.6.tgz#02d996aae3df87512c2a56e1f5fefffc883c8a18" - integrity sha512-C5odetI6py3CSELeZEVz+i00M+OJuFZXYnjVD4JyvpLn462GesHRh+Se8mSkU5QSaz9cnpMnyFLJAx05dokWbA== +"@react-native/gradle-plugin@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz#a58830f38789f6254b64449a17fe57455b589d00" + integrity sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg== -"@react-native/js-polyfills@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.79.6.tgz#11dab284ace2708f0483833cfff0c9aee81274df" - integrity sha512-6wOaBh1namYj9JlCNgX2ILeGUIwc6OP6MWe3Y5jge7Xz9fVpRqWQk88Q5Y9VrAtTMTcxoX3CvhrfRr3tGtSfQw== +"@react-native/js-polyfills@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz#2ca68188c8fff9b951f507b1dec7efe928848274" + integrity sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w== -"@react-native/normalize-colors@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.79.6.tgz#e076519b6dba9150dad7f935c1b0a64ea0a90033" - integrity sha512-0v2/ruY7eeKun4BeKu+GcfO+SHBdl0LJn4ZFzTzjHdWES0Cn+ONqKljYaIv8p9MV2Hx/kcdEvbY4lWI34jC/mQ== +"@react-native/normalize-colors@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz#1ca6cb6772bb7324df2b11aab35227eacd6bdfe7" + integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== "@react-native/normalize-colors@^0.74.1": version "0.74.89" resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz#b8ac17d1bbccd3ef9a1f921665d04d42cff85976" integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== -"@react-native/virtualized-lists@0.79.6": - version "0.79.6" - resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.79.6.tgz#ab395e3a1edba1c8c564d3a85961f213cc164a99" - integrity sha512-khA/Hrbb+rB68YUHrLubfLgMOD9up0glJhw25UE3Kntj32YDyuO0Tqc81ryNTcCekFKJ8XrAaEjcfPg81zBGPw== +"@react-native/virtualized-lists@0.81.5": + version "0.81.5" + resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz#24123fded16992d7e46ecc4ccd473be939ea8c1b" + integrity sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw== dependencies: invariant "^2.2.4" nullthrows "^1.1.1" -"@react-navigation/bottom-tabs@^7.3.10": - version "7.4.7" - resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.4.7.tgz#c6fb80bfe25f47db27491918a764e01877f7efeb" - integrity sha512-SQ4KuYV9yr3SV/thefpLWhAD0CU2CrBMG1l0w/QKl3GYuGWdN5OQmdQdmaPZGtsjjVOb+N9Qo7Tf6210P4TlpA== +"@react-navigation/bottom-tabs@^7.4.0": + version "7.15.9" + resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.9.tgz#f9789b73d23f4e79f0a4a0cb5b61c38054d3c71d" + integrity sha512-Ou28A1aZLj5wiFQ3F93aIsrI4NCwn3IJzkkjNo9KLFXsc0Yks+UqrVaFlffHFLsrbajuGRG/OQpnMA1ljayY5Q== dependencies: - "@react-navigation/elements" "^2.6.4" + "@react-navigation/elements" "^2.9.14" color "^4.2.3" + sf-symbols-typescript "^2.1.0" -"@react-navigation/core@^7.12.4": - version "7.12.4" - resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.12.4.tgz#73cc4c0989455c93bf21d7aeecc89d3a7006ccde" - integrity sha512-xLFho76FA7v500XID5z/8YfGTvjQPw7/fXsq4BIrVSqetNe/o/v+KAocEw4ots6kyv3XvSTyiWKh2g3pN6xZ9Q== +"@react-navigation/core@^7.17.2": + version "7.17.2" + resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.17.2.tgz#8a17b73faf7c0688a4749dcac8c7350d8f93e943" + integrity sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA== dependencies: - "@react-navigation/routers" "^7.5.1" + "@react-navigation/routers" "^7.5.3" escape-string-regexp "^4.0.0" + fast-deep-equal "^3.1.3" nanoid "^3.3.11" query-string "^7.1.3" react-is "^19.1.0" use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/elements@^2.6.4": - version "2.6.4" - resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.6.4.tgz#f1dc8548b1289588fabcd2f0342c1391c689a49f" - integrity sha512-O3X9vWXOEhAO56zkQS7KaDzL8BvjlwZ0LGSteKpt1/k6w6HONG+2Wkblrb057iKmehTkEkQMzMLkXiuLmN5x9Q== +"@react-navigation/elements@^2.9.14": + version "2.9.14" + resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.9.14.tgz#48b9e3cf16e38818df1e633a13a17c0fa96e9c43" + integrity sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ== dependencies: color "^4.2.3" use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/native-stack@^7.3.10": - version "7.3.26" - resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.3.26.tgz#a08ee0626e49428a808da9d810f24db5b08deae9" - integrity sha512-EjaBWzLZ76HJGOOcWCFf+h/M+Zg7M1RalYioDOb6ZdXHz7AwYNidruT3OUAQgSzg3gVLqvu5OYO0jFsNDPCZxQ== +"@react-navigation/native-stack@^7.3.16": + version "7.14.10" + resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.14.10.tgz#f83ff598bbadcaec57412301f2258183af244085" + integrity sha512-mCbYbYhi7Em2R2nEgwYGdLU38smy+KK+HMMVcwuzllWsF3Qb+jOUEYbB6Or7LvE7SS77BZ6sHdx4HptCEv50hQ== dependencies: - "@react-navigation/elements" "^2.6.4" + "@react-navigation/elements" "^2.9.14" + color "^4.2.3" + sf-symbols-typescript "^2.1.0" warn-once "^0.1.1" -"@react-navigation/native@^7.1.6": - version "7.1.17" - resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.1.17.tgz#88d557c0f5000aa2741e4368c59719526f1394c4" - integrity sha512-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ== +"@react-navigation/native@^7.1.8": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.2.2.tgz#c9438fe8393454d74fdb7f959ac9abede52b1f8e" + integrity sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w== dependencies: - "@react-navigation/core" "^7.12.4" + "@react-navigation/core" "^7.17.2" escape-string-regexp "^4.0.0" fast-deep-equal "^3.1.3" nanoid "^3.3.11" use-latest-callback "^0.2.4" -"@react-navigation/routers@^7.5.1": - version "7.5.1" - resolved "https://registry.yarnpkg.com/@react-navigation/routers/-/routers-7.5.1.tgz#b8f6e9b491fdc1bc7164fdac4fa4faa82f397daf" - integrity sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w== +"@react-navigation/routers@^7.5.3": + version "7.5.3" + resolved "https://registry.yarnpkg.com/@react-navigation/routers/-/routers-7.5.3.tgz#8002930ef5f62351be2475d0dffde3ffaee174d7" + integrity sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg== dependencies: nanoid "^3.3.11" @@ -4597,96 +5013,96 @@ micromatch "^4.0.0" p-reduce "^2.0.0" -"@sentry-internal/browser-utils@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.54.0.tgz#2d68c7fa843db867ed98059faf1a750be3eca95a" - integrity sha512-DKWCqb4YQosKn6aD45fhKyzhkdG7N6goGFDeyTaJFREJDFVDXiNDsYZu30nJ6BxMM7uQIaARhPAC5BXfoED3pQ== +"@sentry-internal/browser-utils@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.12.0.tgz#aa3a05653e530d2693e307c0131571ee8a97b60d" + integrity sha512-dozbx389jhKynj0d657FsgbBVOar7pX3mb6GjqCxslXF0VKpZH2Xks0U32RgDY/nK27O+o095IWz7YvjVmPkDw== dependencies: - "@sentry/core" "8.54.0" + "@sentry/core" "10.12.0" -"@sentry-internal/feedback@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.54.0.tgz#52c3a63aa5b520eca7acfa1376621e8441984126" - integrity sha512-nQqRacOXoElpE0L0ADxUUII0I3A94niqG9Z4Fmsw6057QvyrV/LvTiMQBop6r5qLjwMqK+T33iR4/NQI5RhsXQ== +"@sentry-internal/feedback@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.12.0.tgz#a48039507f37fe62e19566128a894661a724ef0d" + integrity sha512-0+7ceO6yQPPqfxRc9ue/xoPHKcnB917ezPaehGQNfAFNQB9PNTG1y55+8mRu0Fw+ANbZeCt/HyoCmXuRdxmkpg== dependencies: - "@sentry/core" "8.54.0" + "@sentry/core" "10.12.0" -"@sentry-internal/replay-canvas@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.54.0.tgz#e57a3893db2bb0ea7ad9dc2a804bb035142fe3ba" - integrity sha512-K/On3OAUBeq/TV2n+1EvObKC+WMV9npVXpVyJqCCyn8HYMm8FUGzuxeajzm0mlW4wDTPCQor6mK9/IgOquUzCw== +"@sentry-internal/replay-canvas@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.12.0.tgz#f79dde92bcba67b4f706db6c217467e14d6348c5" + integrity sha512-W/z1/+69i3INNfPjD1KuinSNaRQaApjzwb37IFmiyF440F93hxmEYgXHk3poOlYYaigl2JMYbysGPWOiXnqUXA== dependencies: - "@sentry-internal/replay" "8.54.0" - "@sentry/core" "8.54.0" + "@sentry-internal/replay" "10.12.0" + "@sentry/core" "10.12.0" -"@sentry-internal/replay@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.54.0.tgz#b92990a51ffbe8d92998ff8188db9e3a6f9d1e18" - integrity sha512-8xuBe06IaYIGJec53wUC12tY2q4z2Z0RPS2s1sLtbA00EvK1YDGuXp96IDD+HB9mnDMrQ/jW5f97g9TvPsPQUg== +"@sentry-internal/replay@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.12.0.tgz#47ca89acdc621217991c7ed1d133fd37915a512d" + integrity sha512-/1093gSNGN5KlOBsuyAl33JkzGiG38kCnxswQLZWpPpR6LBbR1Ddb18HjhDpoQNNEZybJBgJC3a5NKl43C2TSQ== dependencies: - "@sentry-internal/browser-utils" "8.54.0" - "@sentry/core" "8.54.0" + "@sentry-internal/browser-utils" "10.12.0" + "@sentry/core" "10.12.0" -"@sentry/babel-plugin-component-annotate@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz#f47a7652e16f84556df82cbc38f0004bca1335d1" - integrity sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg== - -"@sentry/browser@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.54.0.tgz#5487075908aac564892e689e1b6d233fdb314f5b" - integrity sha512-BgUtvxFHin0fS0CmJVKTLXXZcke0Av729IVfi+2fJ4COX8HO7/HAP02RKaSQGmL2HmvWYTfNZ7529AnUtrM4Rg== - dependencies: - "@sentry-internal/browser-utils" "8.54.0" - "@sentry-internal/feedback" "8.54.0" - "@sentry-internal/replay" "8.54.0" - "@sentry-internal/replay-canvas" "8.54.0" - "@sentry/core" "8.54.0" - -"@sentry/cli-darwin@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.45.0.tgz#e3d6feae4fadcfdf91db9c7b9c4689a66d3d8d19" - integrity sha512-p4Uxfv/L2fQdP3/wYnKVVz9gzZJf/1Xp9D+6raax/3Bu5y87yHYUqcdt98y/VAXQD4ofp2QgmhGUVPofvQNZmg== - -"@sentry/cli-linux-arm64@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.45.0.tgz#384c8e17f7e7dc007d164033d0e7c75aa83a2e9b" - integrity sha512-gUcLoEjzg7AIc4QQGEZwRHri+EHf3Gcms9zAR1VHiNF3/C/jL4WeDPJF2YiWAQt6EtH84tHiyhw1Ab/R8XFClg== - -"@sentry/cli-linux-arm@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.45.0.tgz#b9d6f86f3934b4d9ced5b45a8158ff2ac2bdd25d" - integrity sha512-6sEskFLlFKJ+e0MOYgIclBTUX5jYMyYhHIxXahEkI/4vx6JO0uvpyRAkUJRpJkRh/lPog0FM+tbP3so+VxB2qQ== - -"@sentry/cli-linux-i686@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.45.0.tgz#39e22beb84cfa26e11bdc198364315fdfb4da4d5" - integrity sha512-VmmOaEAzSW23YdGNdy/+oQjCNAMY+HmOGA77A25/ep/9AV7PQB6FI7xO5Y1PVvlkxZFJ23e373njSsEeg4uDZw== - -"@sentry/cli-linux-x64@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.45.0.tgz#25cd3699297f9433835fb5edd42dad722c11f041" - integrity sha512-a0Oj68mrb25a0WjX/ShZ6AAd4PPiuLcgyzQr7bl2+DvYxIOajwkGbR+CZFEhOVZcfhTnixKy/qIXEzApEPHPQg== - -"@sentry/cli-win32-arm64@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.45.0.tgz#50c7d29ea2169bdb4d98bbde81c5f7dac0dd3955" - integrity sha512-vn+CwS4p+52pQSLNPoi20ZOrQmv01ZgAmuMnjkh1oUZfTyBAwWLrAh6Cy4cztcN8DfL5dOWKQBo8DBKURE4ttg== - -"@sentry/cli-win32-i686@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.45.0.tgz#201075c4aec37a3e797160e0b468641245437f0c" - integrity sha512-8mMoDdlwxtcdNIMtteMK7dbi7054jak8wKSHJ5yzMw8UmWxC5thc/gXBc1uPduiaI56VjoJV+phWHBKCD+6I4w== - -"@sentry/cli-win32-x64@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.45.0.tgz#2075e9e1ea3c3609e0fa1a758ca033e94e1c600f" - integrity sha512-ZvK9cIqFaq7vZ0jkHJ/xh5au6902Dr+AUxSk6L6vCL7JCe2p93KGL/4d8VFB5PD/P7Y9b+105G/e0QIFKzpeOw== - -"@sentry/cli@2.45.0": - version "2.45.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.45.0.tgz#35feed7a2fee54faf25daed73001a2a2a3143396" - integrity sha512-4sWu7zgzgHAjIxIjXUA/66qgeEf5ZOlloO+/JaGD5qXNSW0G7KMTR6iYjReNKMgdBCTH6bUUt9qiuA+Ex9Masw== +"@sentry/babel-plugin-component-annotate@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" + integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== + +"@sentry/browser@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.12.0.tgz#79dffc88e1f9241b9fdb5def5a7c6809f41230b3" + integrity sha512-lKyaB2NFmr7SxPjmMTLLhQ7xfxaY3kdkMhpzuRI5qwOngtKt4+FtvNYHRuz+PTtEFv4OaHhNNbRn6r91gWguQg== + dependencies: + "@sentry-internal/browser-utils" "10.12.0" + "@sentry-internal/feedback" "10.12.0" + "@sentry-internal/replay" "10.12.0" + "@sentry-internal/replay-canvas" "10.12.0" + "@sentry/core" "10.12.0" + +"@sentry/cli-darwin@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.55.0.tgz#79513547d15223d51905e94d8c1e2bc7377cfdf1" + integrity sha512-jGHE7SHHzqXUmnsmRLgorVH6nmMmTjQQXdPZbSL5tRtH8d3OIYrVNr5D72DSgD26XAPBDMV0ibqOQ9NKoiSpfA== + +"@sentry/cli-linux-arm64@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.55.0.tgz#5a2e8ea6e088f4884cbe272525f0781f8c748ff3" + integrity sha512-jNB/0/gFcOuDCaY/TqeuEpsy/k52dwyk1SOV3s1ku4DUsln6govTppeAGRewY3T1Rj9B2vgIWTrnB8KVh9+Rgg== + +"@sentry/cli-linux-arm@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.55.0.tgz#54c330471c4b23ff6769bfd886f092afba3380ce" + integrity sha512-ATjU0PsiWADSPLF/kZroLZ7FPKd5W9TDWHVkKNwIUNTei702LFgTjNeRwOIzTgSvG3yTmVEqtwFQfFN/7hnVXQ== + +"@sentry/cli-linux-i686@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.55.0.tgz#12c64453ef7014ba89c885497e3c9764a5f3e0a0" + integrity sha512-8LZjo6PncTM6bWdaggscNOi5r7F/fqRREsCwvd51dcjGj7Kp1plqo9feEzYQ+jq+KUzVCiWfHrUjddFmYyZJrg== + +"@sentry/cli-linux-x64@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.55.0.tgz#c640464fe533fe87e57d0ca5585e0c504e0ef5b3" + integrity sha512-5LUVvq74Yj2cZZy5g5o/54dcWEaX4rf3myTHy73AKhRj1PABtOkfexOLbF9xSrZy95WXWaXyeH+k5n5z/vtHfA== + +"@sentry/cli-win32-arm64@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.55.0.tgz#da1e8ba13083c281fcdf72d1dd35255df7fdb5c5" + integrity sha512-cWIQdzm1pfLwPARsV6dUb8TVd6Y3V1A2VWxjTons3Ift6GvtVmiAe0OWL8t2Yt95i8v61kTD/6Tq21OAaogqzA== + +"@sentry/cli-win32-i686@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.55.0.tgz#c2eae0a75fc55f101c31de0999214d7e613d65c4" + integrity sha512-ldepCn2t9r4I0wvgk7NRaA7coJyy4rTQAzM66u9j5nTEsUldf66xym6esd5ZZRAaJUjffqvHqUIr/lrieTIrVg== + +"@sentry/cli-win32-x64@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.55.0.tgz#499e6697663a3e1453ff2e921bf3876f774520b5" + integrity sha512-4hPc/I/9tXx+HLTdTGwlagtAfDSIa2AoTUP30tl32NAYQhx9a6niUbPAemK2qfxesiufJ7D2djX83rCw6WnJVA== + +"@sentry/cli@2.55.0": + version "2.55.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.55.0.tgz#c22d2edbd320242e7881c15ecf89649378c05387" + integrity sha512-cynvcIM2xL8ddwELyFRSpZQw4UtFZzoM2rId2l9vg7+wDREPDocMJB9lEQpBIo3eqhp9JswqUT037yjO6iJ5Sw== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -4694,62 +5110,53 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.45.0" - "@sentry/cli-linux-arm" "2.45.0" - "@sentry/cli-linux-arm64" "2.45.0" - "@sentry/cli-linux-i686" "2.45.0" - "@sentry/cli-linux-x64" "2.45.0" - "@sentry/cli-win32-arm64" "2.45.0" - "@sentry/cli-win32-i686" "2.45.0" - "@sentry/cli-win32-x64" "2.45.0" - -"@sentry/core@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.54.0.tgz#a2ebec965cadcb6de89e116689feeef79d5862a6" - integrity sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q== - -"@sentry/react-native@~6.14.0": - version "6.14.0" - resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-6.14.0.tgz#bc6bdaf03860bb8946f8c30570a9abd82ed6cfc0" - integrity sha512-BBqixN6oV6tCNp1ABXfzvD531zxj1fUAH0HDPvOR/jX0h9f9pYfxCyI64B+DoQbVZKFsg8nte0QIHkZDhRAW9A== - dependencies: - "@sentry/babel-plugin-component-annotate" "3.4.0" - "@sentry/browser" "8.54.0" - "@sentry/cli" "2.45.0" - "@sentry/core" "8.54.0" - "@sentry/react" "8.54.0" - "@sentry/types" "8.54.0" - "@sentry/utils" "8.54.0" - -"@sentry/react@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.54.0.tgz#16cec103b5d5697bdfebacf6e2d35f19699b3ab3" - integrity sha512-42T/fp8snYN19Fy/2P0Mwotu4gcdy+1Lx+uYCNcYP1o7wNGigJ7qb27sW7W34GyCCHjoCCfQgeOqDQsyY8LC9w== - dependencies: - "@sentry/browser" "8.54.0" - "@sentry/core" "8.54.0" - hoist-non-react-statics "^3.3.2" + "@sentry/cli-darwin" "2.55.0" + "@sentry/cli-linux-arm" "2.55.0" + "@sentry/cli-linux-arm64" "2.55.0" + "@sentry/cli-linux-i686" "2.55.0" + "@sentry/cli-linux-x64" "2.55.0" + "@sentry/cli-win32-arm64" "2.55.0" + "@sentry/cli-win32-i686" "2.55.0" + "@sentry/cli-win32-x64" "2.55.0" + +"@sentry/core@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.12.0.tgz#3f6a0f5c2f63f2c1761e3cf442a986d74adf6403" + integrity sha512-Jrf0Yo7DvmI/ZQcvBnA0xKNAFkJlVC/fMlvcin+5IrFNRcqOToZ2vtF+XqTgjRZymXQNE8s1QTD7IomPHk0TAw== -"@sentry/types@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.54.0.tgz#1d57bb094443081de4e0d8b638e6ebc40f5ddd36" - integrity sha512-wztdtr7dOXQKi0iRvKc8XJhJ7HaAfOv8lGu0yqFOFwBZucO/SHnu87GOPi8mvrTiy1bentQO5l+zXWAaMvG4uw== +"@sentry/react-native@~7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-7.2.0.tgz#d29deacc36fd55fc52119ffcf53f61c21cab3600" + integrity sha512-rjqYgEjntPz1sPysud78wi4B9ui7LBVPsG6qr8s/htLMYho9GPGFA5dF+eqsQWqMX8NDReAxNkLTC4+gCNklLQ== dependencies: - "@sentry/core" "8.54.0" + "@sentry/babel-plugin-component-annotate" "4.3.0" + "@sentry/browser" "10.12.0" + "@sentry/cli" "2.55.0" + "@sentry/core" "10.12.0" + "@sentry/react" "10.12.0" + "@sentry/types" "10.12.0" -"@sentry/utils@8.54.0": - version "8.54.0" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.54.0.tgz#5e28e03a249451b4a55200a0787f4e2c59bab2c5" - integrity sha512-JL8UDjrsKxKclTdLXfuHfE7B3KbrAPEYP7tMyN/xiO2vsF6D84fjwYyalO0ZMtuFZE6vpSze8ZOLEh6hLnPYsw== +"@sentry/react@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.12.0.tgz#c121f37bf582f4851108f67ef492de6a4c8f7a8d" + integrity sha512-TpqgdoYbkf5JynmmW2oQhHQ/h5w+XPYk0cEb/UrsGlvJvnBSR+5tgh0AqxCSi3gvtp82rAXI5w1TyRPBbhLDBw== dependencies: - "@sentry/core" "8.54.0" + "@sentry/browser" "10.12.0" + "@sentry/core" "10.12.0" + hoist-non-react-statics "^3.3.2" -"@shopify/flash-list@1.7.6": - version "1.7.6" - resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.7.6.tgz#367e76866c71d1f1be0ff70f0b28be4bbfbcf595" - integrity sha512-0kuuAbWgy4YSlN05mt0ScvxK8uiDixMsICWvDed+LTxvZ5+5iRyt3M8cRLUroB8sfiZlJJZWlxHrx0frBpsYOQ== +"@sentry/types@10.12.0": + version "10.12.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-10.12.0.tgz#86d24346efde7b0757474537af7ae4b3d931d6a8" + integrity sha512-sKGj3l3V8ZKISh2Tu88bHfnm5ztkRtSLdmpZ6TmCeJdSM9pV+RRd6CMJ0RnSEXmYHselPNUod521t2NQFd4W1w== + dependencies: + "@sentry/core" "10.12.0" + +"@shopify/flash-list@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.2.tgz#644748f883fccf8cf2e0ca251e0ef88673b89120" + integrity sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w== dependencies: - recyclerlistview "4.2.3" tslib "2.8.1" "@sinclair/typebox@^0.27.8": @@ -5418,10 +5825,10 @@ resolved "https://registry.yarnpkg.com/@types/react-native-base64/-/react-native-base64-0.2.2.tgz#d4e1d537e6d547d23d96a1e64627acc13587ae6b" integrity sha512-obr+/L9Jaxdr+xCVS/IQcYgreg5xtnui4Wqw/G1acBUtW2CnqVJj6lK6F/5F3+5d2oZEo5xDDLqy8GVn2HbEmw== -"@types/react@~19.0.10": - version "19.0.14" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.14.tgz#f2f62035290afd755095cb6644e10b599db72f4e" - integrity sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw== +"@types/react@~19.1.10": + version "19.1.17" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.17.tgz#8be0b9c546cede389b930a98eb3fad1897f209c3" + integrity sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA== dependencies: csstype "^3.0.2" @@ -5793,6 +6200,14 @@ accepts@^1.3.7, accepts@^1.3.8: mime-types "~2.1.34" negotiator "0.6.3" +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + acorn-globals@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" @@ -5851,16 +6266,6 @@ ajv-keywords@^3.4.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@8.11.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" - integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - ajv@^6.10.0, ajv@^6.12.0, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -6078,6 +6483,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.2.4: + version "1.2.6" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a" + integrity sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA== + dependencies: + tslib "^2.0.0" + aria-query@^5.0.0: version "5.3.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" @@ -6393,17 +6805,24 @@ babel-plugin-polyfill-regenerator@^0.6.5: dependencies: "@babel/helper-define-polyfill-provider" "^0.6.5" -babel-plugin-react-native-web@~0.19.13: - version "0.19.13" - resolved "https://registry.yarnpkg.com/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.19.13.tgz#bf919bd6f18c4689dd1a528a82bda507363b953d" - integrity sha512-4hHoto6xaN23LCyZgL9LJZc3olmAxd7b6jDzlZnKXAh4rRAbZRKNBJoOOdp46OBqgy+K0t0guTj5/mhA8inymQ== +babel-plugin-react-compiler@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz#bdf7360a23a4d5ebfca090255da3893efd07425f" + integrity sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw== + dependencies: + "@babel/types" "^7.26.0" + +babel-plugin-react-native-web@~0.21.0: + version "0.21.2" + resolved "https://registry.yarnpkg.com/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz#d2f7fd673278da82577aa583457edb55d9cccbe0" + integrity sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA== -babel-plugin-syntax-hermes-parser@0.25.1, babel-plugin-syntax-hermes-parser@^0.25.1: - version "0.25.1" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.25.1.tgz#58b539df973427fcfbb5176a3aec7e5dee793cb0" - integrity sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ== +babel-plugin-syntax-hermes-parser@0.29.1, babel-plugin-syntax-hermes-parser@^0.29.1: + version "0.29.1" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz#09ca9ecb0330eba1ef939b6d3f1f55bb06a9dc33" + integrity sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA== dependencies: - hermes-parser "0.25.1" + hermes-parser "0.29.1" babel-plugin-transform-flow-enums@^0.0.2: version "0.0.2" @@ -6441,15 +6860,16 @@ babel-preset-current-node-syntax@^1.0.0, babel-preset-current-node-syntax@^1.1.0 "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-expo@~13.2.5: - version "13.2.5" - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-13.2.5.tgz#9d9273eb394d547e3a32228086dcd252d703556d" - integrity sha512-YjVkP1bOLO2OgR2fyCedruYMPR7GFbAtCvvWITBW1UAp6e3ACYZtN6uoqkXgXP6PHQkb6M7qf2vZreBPEZK38A== +babel-preset-expo@~54.0.10: + version "54.0.10" + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz#3b70f4af3a5f65f945d7957ef511ee016e8f2fd6" + integrity sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw== dependencies: "@babel/helper-module-imports" "^7.25.9" "@babel/plugin-proposal-decorators" "^7.12.9" "@babel/plugin-proposal-export-default-from" "^7.24.7" "@babel/plugin-syntax-export-default-from" "^7.24.7" + "@babel/plugin-transform-class-static-block" "^7.27.1" "@babel/plugin-transform-export-namespace-from" "^7.25.9" "@babel/plugin-transform-flow-strip-types" "^7.25.2" "@babel/plugin-transform-modules-commonjs" "^7.24.8" @@ -6460,12 +6880,12 @@ babel-preset-expo@~13.2.5: "@babel/plugin-transform-runtime" "^7.24.7" "@babel/preset-react" "^7.22.15" "@babel/preset-typescript" "^7.23.0" - "@react-native/babel-preset" "0.79.6" - babel-plugin-react-native-web "~0.19.13" - babel-plugin-syntax-hermes-parser "^0.25.1" + "@react-native/babel-preset" "0.81.5" + babel-plugin-react-compiler "^1.0.0" + babel-plugin-react-native-web "~0.21.0" + babel-plugin-syntax-hermes-parser "^0.29.1" babel-plugin-transform-flow-enums "^0.0.2" debug "^4.3.4" - react-refresh "^0.14.2" resolve-from "^5.0.0" babel-preset-jest@30.0.1: @@ -6489,6 +6909,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -6590,6 +7015,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -6762,25 +7194,6 @@ call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" -caller-callsite@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" - integrity sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ== - dependencies: - callsites "^2.0.0" - -caller-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" - integrity sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A== - dependencies: - caller-callsite "^2.0.0" - -callsites@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" - integrity sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ== - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -7337,16 +7750,6 @@ cosmiconfig-typescript-loader@^6.1.0: dependencies: jiti "^2.4.1" -cosmiconfig@^5.0.5: - version "5.2.1" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" - integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== - dependencies: - import-fresh "^2.0.0" - is-directory "^0.3.1" - js-yaml "^3.13.1" - parse-json "^4.0.0" - cosmiconfig@^8.1.3, cosmiconfig@^8.3.6: version "8.3.6" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" @@ -7420,11 +7823,6 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== - css-in-js-utils@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz#640ae6a33646d401fc720c54fc61c42cd76ae2bb" @@ -7751,6 +8149,11 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" @@ -8762,7 +9165,7 @@ event-target-shim@6.0.2: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-6.0.2.tgz#ea5348c3618ee8b62ff1d344f01908ee2b8a2b71" integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA== -event-target-shim@^5.0.0, event-target-shim@^5.0.1: +event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== @@ -8782,11 +9185,6 @@ eventsource@^2.0.2: resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== -exec-async@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/exec-async/-/exec-async-2.2.0.tgz#c7c5ad2eef3478d38390c6dd3acfe8af0efc8301" - integrity sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw== - execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -8843,317 +9241,322 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" -expo-application@~6.1.5: - version "6.1.5" - resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-6.1.5.tgz#78e569ed8ab237c9bae67d693fec629dd447e53d" - integrity sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg== +expo-application@~7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db" + integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== -expo-asset@~11.1.7: - version "11.1.7" - resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-11.1.7.tgz#dfc61100312cc0dd394d0e0b33613bb0cc898746" - integrity sha512-b5P8GpjUh08fRCf6m5XPVAh7ra42cQrHBIMgH2UXP+xsj4Wufl6pLy6jRF5w6U7DranUMbsXm8TOyq4EHy7ADg== +expo-asset@~12.0.12: + version "12.0.12" + resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.12.tgz#15eb7d92cd43cc81c37149e5bbcdc3091875a85b" + integrity sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ== dependencies: - "@expo/image-utils" "^0.7.6" - expo-constants "~17.1.7" - -expo-audio@~0.4.9: - version "0.4.9" - resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-0.4.9.tgz#f15f64652785ecd416ad351bf42666315e1e0b69" - integrity sha512-J4mMYEt2mqRqqwmSsXFylMGlrNWa+MbCzGl1IZBs+smvPAMJ3Ni8fNplzCQ0I9RnRzygKhRwJNpnAVL+n4MuyA== + "@expo/image-utils" "^0.8.8" + expo-constants "~18.0.12" -expo-auth-session@~6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-6.2.1.tgz#27c645575ce98508ed8a0faf2c586b04e1a1ba15" - integrity sha512-9KgqrGpW7PoNOhxJ7toofi/Dz5BU2TE4Q+ktJZsmDXLoFcNOcvBokh2+mkhG58Qvd/xJ9Z5sAt/5QoOFaPb9wA== - dependencies: - expo-application "~6.1.5" - expo-constants "~17.1.7" - expo-crypto "~14.1.5" - expo-linking "~7.1.7" - expo-web-browser "~14.2.0" +expo-audio@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-1.1.1.tgz#7b9763118e321c5dfbf2771cd4a5b6790ce4fc8d" + integrity sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g== + +expo-auth-session@~7.0.10: + version "7.0.10" + resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-7.0.10.tgz#37250576baf5d56f16b861fb7c387a990f8eaf45" + integrity sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg== + dependencies: + expo-application "~7.0.8" + expo-constants "~18.0.11" + expo-crypto "~15.0.8" + expo-linking "~8.0.10" + expo-web-browser "~15.0.10" invariant "^2.2.4" -expo-av@~15.1.7: - version "15.1.7" - resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-15.1.7.tgz#a8422646eca9250c842e8a44fccccb1a4b070a05" - integrity sha512-NC+JR+65sxXfQN1mOHp3QBaXTL2J+BzNwVO27XgUEc5s9NaoBTdHWElYXrfxvik6xwytZ+a7abrqfNNgsbQzsA== +expo-av@~16.0.8: + version "16.0.8" + resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-16.0.8.tgz#b1671127f3b2ecaeb9c69fc2301cf791d4504dd6" + integrity sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ== -expo-build-properties@~0.14.8: - version "0.14.8" - resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.14.8.tgz#03aac5eb297c1f4ad3c5b95d8bf897340104d041" - integrity sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw== +expo-build-properties@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.10.tgz#2c3fb4248f78828e952defa636635a653e3ad546" + integrity sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q== dependencies: ajv "^8.11.0" semver "^7.6.0" -expo-constants@~17.1.7: - version "17.1.7" - resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-17.1.7.tgz#35194c1cef51f1ea756333418f1e077be79a012b" - integrity sha512-byBjGsJ6T6FrLlhOBxw4EaiMXrZEn/MlUYIj/JAd+FS7ll5X/S4qVRbIimSJtdW47hXMq0zxPfJX6njtA56hHA== +expo-constants@~18.0.11, expo-constants@~18.0.12, expo-constants@~18.0.13: + version "18.0.13" + resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d" + integrity sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ== dependencies: - "@expo/config" "~11.0.12" - "@expo/env" "~1.0.7" + "@expo/config" "~12.0.13" + "@expo/env" "~2.0.8" -expo-constants@~17.1.8: - version "17.1.8" - resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-17.1.8.tgz#6a59142b9967350bdded3f44859886ec34961aa4" - integrity sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA== +expo-crypto@~15.0.8: + version "15.0.8" + resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-15.0.8.tgz#339198aae149b3ccc0b44f7150d7261a3a1f5287" + integrity sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw== dependencies: - "@expo/config" "~11.0.13" - "@expo/env" "~1.0.7" + base64-js "^1.3.0" -expo-crypto@~14.1.5: - version "14.1.5" - resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-14.1.5.tgz#1c29ddd4657d96af6358a9ecdc85a0c344c9ae0c" - integrity sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q== +expo-dev-client@~6.0.20: + version "6.0.20" + resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.20.tgz#d5b65974785100ae7f2538e16701fa1ef55f5fad" + integrity sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA== dependencies: - base64-js "^1.3.0" + expo-dev-launcher "6.0.20" + expo-dev-menu "7.0.18" + expo-dev-menu-interface "2.0.0" + expo-manifests "~1.0.10" + expo-updates-interface "~2.0.0" -expo-dev-client@~5.2.4: - version "5.2.4" - resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-5.2.4.tgz#cdffaea81841b2903cb9585bdd1566dea275a097" - integrity sha512-s/N/nK5LPo0QZJpV4aPijxyrzV4O49S3dN8D2fljqrX2WwFZzWwFO6dX1elPbTmddxumdcpczsdUPY+Ms8g43g== - dependencies: - expo-dev-launcher "5.1.16" - expo-dev-menu "6.1.14" - expo-dev-menu-interface "1.10.0" - expo-manifests "~0.16.6" - expo-updates-interface "~1.1.0" - -expo-dev-launcher@5.1.16: - version "5.1.16" - resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-5.1.16.tgz#7f2b4f73421523f1deb5c7c832e56bbf8178335f" - integrity sha512-tbCske9pvbozaEblyxoyo/97D6od9Ma4yAuyUnXtRET1CKAPKYS+c4fiZ+I3B4qtpZwN3JNFUjG3oateN0y6Hg== - dependencies: - ajv "8.11.0" - expo-dev-menu "6.1.14" - expo-manifests "~0.16.6" - resolve-from "^5.0.0" +expo-dev-launcher@6.0.20: + version "6.0.20" + resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz#b2ce90ff6af4c4de28bc1ea595b0b504ed9b467d" + integrity sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA== + dependencies: + ajv "^8.11.0" + expo-dev-menu "7.0.18" + expo-manifests "~1.0.10" -expo-dev-menu-interface@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz#04671bda3c163d1d7b9438ce7095c3913a3f53f9" - integrity sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg== +expo-dev-menu-interface@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz#c0d6db65eb4abc44a2701bc2303744619ad05ca6" + integrity sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw== -expo-dev-menu@6.1.14: - version "6.1.14" - resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-6.1.14.tgz#c2ee13d0af2c335d47ca5057b080dcd594a40291" - integrity sha512-yonNMg2GHJZtuisVowdl1iQjZfYP85r1D1IO+ar9D9zlrBPBJhq2XEju52jd1rDmDkmDuEhBSbPNhzIcsBNiPg== +expo-dev-menu@7.0.18: + version "7.0.18" + resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz#4f3e2dc20b82fc495afb602301b83fa16430f6b8" + integrity sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA== dependencies: - expo-dev-menu-interface "1.10.0" + expo-dev-menu-interface "2.0.0" -expo-device@~7.1.4: - version "7.1.4" - resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-7.1.4.tgz#84ae7c2520cc45f15a9cb0433ae1226c33f7a8ef" - integrity sha512-HS04IiE1Fy0FRjBLurr9e5A6yj3kbmQB+2jCZvbSGpsjBnCLdSk/LCii4f5VFhPIBWJLyYuN5QqJyEAw6BcS4Q== +expo-device@~8.0.10: + version "8.0.10" + resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.10.tgz#88be854d6de5568392ed814b44dad0e19d1d50f8" + integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA== dependencies: ua-parser-js "^0.7.33" -expo-document-picker@~13.1.6: - version "13.1.6" - resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-13.1.6.tgz#d31a14eac7f0d44f6d1eee8d41403499ce07924d" - integrity sha512-8FTQPDOkyCvFN/i4xyqzH7ELW4AsB6B3XBZQjn1FEdqpozo6rpNJRr7sWFU/93WrLgA9FJEKpKbyr6XxczK6BA== +expo-document-picker@~14.0.8: + version "14.0.8" + resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-14.0.8.tgz#ca1d99cc432c48e69a6390eb035f3301557e3699" + integrity sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA== -expo-file-system@~18.1.11: - version "18.1.11" - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-18.1.11.tgz#a563c715c4bb5c18729d6d104e8c6cdfbd383e69" - integrity sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ== +expo-file-system@~19.0.21: + version "19.0.21" + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.21.tgz#e96a68107fb629cf0dd1906fe7b46b566ff13e10" + integrity sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg== -expo-font@~13.3.2: - version "13.3.2" - resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-13.3.2.tgz#1b5a1a14d6827a48a3f003577d5f7dc6b344a1d3" - integrity sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A== +expo-font@~14.0.11: + version "14.0.11" + resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-14.0.11.tgz#198743d17332520545107df026d8a261e6b2732f" + integrity sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg== dependencies: fontfaceobserver "^2.1.0" -expo-image-loader@~5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.1.0.tgz#f7d65f9b9a9714eaaf5d50a406cb34cb25262153" - integrity sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q== +expo-image-loader@~6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" + integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== -expo-image-manipulator@~13.1.7: - version "13.1.7" - resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-13.1.7.tgz#e891ce9b49d75962eafdf5b7d670116583379e76" - integrity sha512-DBy/Xdd0E/yFind14x36XmwfWuUxOHI/oH97/giKjjPaRc2dlyjQ3tuW3x699hX6gAs9Sixj5WEJ1qNf3c8sag== +expo-image-manipulator@~14.0.8: + version "14.0.8" + resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz#1c457acbd2bcabe987fbd650c0f29120c3366ba6" + integrity sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q== dependencies: - expo-image-loader "~5.1.0" + expo-image-loader "~6.0.0" -expo-image-picker@~16.1.4: - version "16.1.4" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.1.4.tgz#d4ac2d1f64f6ec9347c3f64f8435b40e6e4dcc40" - integrity sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA== +expo-image-picker@~17.0.10: + version "17.0.10" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.10.tgz#b4a714971378b2813e53d97d8ca81ab2c32cdf30" + integrity sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw== dependencies: - expo-image-loader "~5.1.0" + expo-image-loader "~6.0.0" -expo-image@~2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-2.4.1.tgz#c3f84795e33ea98d833fc4dad11ad750ea290b3e" - integrity sha512-yHp0Cy4ylOYyLR21CcH6i70DeRyLRPc0yAIPFPn4BT/BpkJNaX5QMXDppcHa58t4WI3Bb8QRJRLuAQaeCtDF8A== +expo-image@~3.0.11: + version "3.0.11" + resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-3.0.11.tgz#54195565dc710e632c10414c3609deebb7149ac5" + integrity sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA== expo-json-utils@~0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.15.0.tgz#6723574814b9e6b0a90e4e23662be76123ab6ae9" integrity sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ== -expo-keep-awake@~14.1.4: - version "14.1.4" - resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz#80197728563e0e17523e5a606fbd6fbed9639503" - integrity sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA== +expo-keep-awake@~15.0.8: + version "15.0.8" + resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d" + integrity sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ== -expo-linking@~7.1.7: - version "7.1.7" - resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-7.1.7.tgz#8e41ef1ca5d1190dfc01b7f4dbc4c3993bdc4523" - integrity sha512-ZJaH1RIch2G/M3hx2QJdlrKbYFUTOjVVW4g39hfxrE5bPX9xhZUYXqxqQtzMNl1ylAevw9JkgEfWbBWddbZ3UA== +expo-linking@~8.0.10, expo-linking@~8.0.11: + version "8.0.11" + resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.11.tgz#b13ca9fc409ef0536352443221eb50e5e2bee366" + integrity sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA== dependencies: - expo-constants "~17.1.7" + expo-constants "~18.0.12" invariant "^2.2.4" -expo-localization@~16.1.6: - version "16.1.6" - resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-16.1.6.tgz#0ad7bd6bb61f14d9d786e63fd0f7d190d44695cc" - integrity sha512-v4HwNzs8QvyKHwl40MvETNEKr77v1o9/eVC8WCBY++DIlBAvonHyJe2R9CfqpZbC4Tlpl7XV+07nLXc8O5PQsA== +expo-localization@~17.0.8: + version "17.0.8" + resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-17.0.8.tgz#eb74ae0f9b5b49596322d68d2005662346211100" + integrity sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g== dependencies: rtl-detect "^1.0.2" -expo-location@~18.1.6: - version "18.1.6" - resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-18.1.6.tgz#b855e14e8b4e29a1bde470fc4dc2a341abecf631" - integrity sha512-l5dQQ2FYkrBgNzaZN1BvSmdhhcztFOUucu2kEfDBMV4wSIuTIt/CKsho+F3RnAiWgvui1wb1WTTf80E8zq48hA== +expo-location@~19.0.8: + version "19.0.8" + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55" + integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA== -expo-manifests@~0.16.6: - version "0.16.6" - resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-0.16.6.tgz#a0e5b3225ee032999eac8408337494f464603209" - integrity sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w== +expo-manifests@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.10.tgz#5dfb3db1cdf6b46fee349f1d68a25edf5e087994" + integrity sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ== dependencies: - "@expo/config" "~11.0.12" + "@expo/config" "~12.0.11" expo-json-utils "~0.15.0" -expo-modules-autolinking@2.1.15: - version "2.1.15" - resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-2.1.15.tgz#d90f64272776c1dbb46737fde5be67cf513bad2a" - integrity sha512-IUITUERdkgooXjr9bXsX0PmhrZUIGTMyP6NtmQpAxN5+qtf/I7ewbwLx1/rX7tgiAOzaYme+PZOp/o6yqIhFsw== +expo-modules-autolinking@3.0.24: + version "3.0.24" + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz#55fdfe1ef5a053d5cc287582170a5f6d69ab0e30" + integrity sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ== dependencies: "@expo/spawn-async" "^1.7.2" chalk "^4.1.0" commander "^7.2.0" - find-up "^5.0.0" - glob "^10.4.2" require-from-string "^2.0.2" resolve-from "^5.0.0" -expo-modules-core@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-2.5.0.tgz#cc098607c9b0665e0cbd0a423c9542253caab58a" - integrity sha512-aIbQxZE2vdCKsolQUl6Q9Farlf8tjh/ROR4hfN1qT7QBGPl1XrJGnaOKkcgYaGrlzCPg/7IBe0Np67GzKMZKKQ== +expo-modules-core@3.0.29: + version "3.0.29" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-3.0.29.tgz#99287eba52f21784bcb2e4f4edd4fc4c21b5b265" + integrity sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q== dependencies: invariant "^2.2.4" -expo-navigation-bar@~4.2.8: - version "4.2.8" - resolved "https://registry.yarnpkg.com/expo-navigation-bar/-/expo-navigation-bar-4.2.8.tgz#6450500c2939454d53b637dd6d1fc6a850b6dfdd" - integrity sha512-Ykdz5/22el8Bf8c8llYBfpJvT8kjWJxvgMwNbyF0oZ/7ngoJu1HLijvblA1ppNW7WFLICL6iz6GczcizBVVo5g== +expo-navigation-bar@~5.0.10: + version "5.0.10" + resolved "https://registry.yarnpkg.com/expo-navigation-bar/-/expo-navigation-bar-5.0.10.tgz#64e4fdb91ff3872110373b56c8e65d196b40979c" + integrity sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg== dependencies: - "@react-native/normalize-colors" "0.79.6" + "@react-native/normalize-colors" "0.81.5" debug "^4.3.2" - react-native-edge-to-edge "1.6.0" - react-native-is-edge-to-edge "^1.1.6" + react-native-is-edge-to-edge "^1.2.1" -expo-router@~5.1.11: - version "5.1.11" - resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-5.1.11.tgz#ac17e23e563ae94cc216b7c97b85264edb6dada7" - integrity sha512-6YQGqQM2rviVSiU6++hrJDPMByHZ7Oiux4XmgoSaGdaHku5QOn9911f2puEUZh2H9ALKBipw5v3ZkrECBd6Zbw== +expo-router@~6.0.23: + version "6.0.23" + resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-6.0.23.tgz#480fbcb4901fd692f9d11762f33894280dcbd75a" + integrity sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA== dependencies: - "@expo/metro-runtime" "5.0.5" - "@expo/schema-utils" "^0.1.0" - "@expo/server" "^0.6.3" + "@expo/metro-runtime" "^6.1.2" + "@expo/schema-utils" "^0.1.8" "@radix-ui/react-slot" "1.2.0" - "@react-navigation/bottom-tabs" "^7.3.10" - "@react-navigation/native" "^7.1.6" - "@react-navigation/native-stack" "^7.3.10" + "@radix-ui/react-tabs" "^1.1.12" + "@react-navigation/bottom-tabs" "^7.4.0" + "@react-navigation/native" "^7.1.8" + "@react-navigation/native-stack" "^7.3.16" client-only "^0.0.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + expo-server "^1.0.5" + fast-deep-equal "^3.1.3" invariant "^2.2.4" + nanoid "^3.3.8" + query-string "^7.1.3" react-fast-compare "^3.2.2" react-native-is-edge-to-edge "^1.1.6" semver "~7.6.3" server-only "^0.0.1" + sf-symbols-typescript "^2.1.0" shallowequal "^1.1.0" + use-latest-callback "^0.2.1" + vaul "^1.1.2" -expo-screen-orientation@~8.1.7: - version "8.1.7" - resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-8.1.7.tgz#3751b441f2bfcbde798b1508c0ff9f099f4be911" - integrity sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA== +expo-screen-orientation@~9.0.8: + version "9.0.8" + resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.8.tgz#15b8f85bd4d183831943fc5a21e3020e17432867" + integrity sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw== -expo-secure-store@~14.2.4: - version "14.2.4" - resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-14.2.4.tgz#673743567a6459fb4b5f9406d57d9a3b16bca69f" - integrity sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw== +expo-secure-store@~15.0.8: + version "15.0.8" + resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae" + integrity sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw== -expo-sharing@~13.1.5: - version "13.1.5" - resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-13.1.5.tgz#73d86cdcc037b46ddc82be224dfd3d6bceec497c" - integrity sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w== +expo-server@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.5.tgz#2d52002199a2af99c2c8771d0657916004345ca9" + integrity sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA== + +expo-sharing@~14.0.8: + version "14.0.8" + resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-14.0.8.tgz#cfd5fcf77ab5f64cf3d192a40a925abb316d3545" + integrity sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q== -expo-splash-screen@~0.30.10: - version "0.30.10" - resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.30.10.tgz#d249d350f867b6513fa34a9c4cf6545260ab5208" - integrity sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw== +expo-splash-screen@~31.0.13: + version "31.0.13" + resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz#f41f1a4c8bb1ae7fcc52b760e7dd485d7ddec642" + integrity sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA== dependencies: - "@expo/prebuild-config" "^9.0.10" + "@expo/prebuild-config" "^54.0.8" -expo-status-bar@~2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-2.2.3.tgz#09385a866732328e0af3b4588c4f349a15fd7cd0" - integrity sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q== +expo-status-bar@~3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-3.0.9.tgz#87cfc803fa614f09a985b8e75e3dd7abd51ce2cb" + integrity sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw== dependencies: - react-native-edge-to-edge "1.6.0" - react-native-is-edge-to-edge "^1.1.6" + react-native-is-edge-to-edge "^1.2.1" -expo-system-ui@~5.0.11: - version "5.0.11" - resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-5.0.11.tgz#2bb70b2dd9f3f5137df85e43aa5d2d557432ad49" - integrity sha512-PG5VdaG5cwBe1Rj02mJdnsihKl9Iw/w/a6+qh2mH3f2K/IvQ+Hf7aG2kavSADtkGNCNj7CEIg7Rn4DQz/SE5rQ== +expo-system-ui@~6.0.9: + version "6.0.9" + resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-6.0.9.tgz#09b4a4301ab25ec594ae39beb7d24197c231a30c" + integrity sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg== dependencies: - "@react-native/normalize-colors" "0.79.6" + "@react-native/normalize-colors" "0.81.5" debug "^4.3.2" -expo-task-manager@~13.1.6: - version "13.1.6" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-13.1.6.tgz#73c99fb9e3bc6159d6b0a1173d92c9df0bbc61e0" - integrity sha512-sYNAftpIeZ+j6ur17Jo0OpSTk9ks/MDvTbrNCimXMyjIt69XXYL/kAPYf76bWuxOuN8bcJ8Ef8YvihkwFG9hDA== +expo-task-manager@~14.0.9: + version "14.0.9" + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-14.0.9.tgz#7e410711cf3fd0c465a191916d699c6560c93192" + integrity sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA== dependencies: - unimodules-app-loader "~5.1.3" + unimodules-app-loader "~6.0.8" -expo-updates-interface@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz#62497d4647b381da9fdb68868ed180203ae737ef" - integrity sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w== +expo-updates-interface@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749" + integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg== -expo-web-browser@~14.2.0: - version "14.2.0" - resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-14.2.0.tgz#d8fb521ae349aebbf5c0ca32448877480124c06c" - integrity sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw== +expo-web-browser@~15.0.10: + version "15.0.10" + resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.10.tgz#ee7fb59b4f143f262c13c020433a83444181f1a3" + integrity sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg== -expo@~53.0.27: - version "53.0.27" - resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.27.tgz#d42b14ad23388bd8480c3b84be7558b9a2224c9d" - integrity sha512-iQwe2uWLb88opUY4vBYEW1d2GUq3lsa43gsMBEdDV+6pw0Oek93l/4nDLe0ODDdrBRjIJm/rdhKqJC/ehHCUqw== +expo@^54.0.33: + version "54.0.33" + resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.33.tgz#f7d572857323f5a8250a9afe245a487d2ee2735f" + integrity sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "0.24.24" - "@expo/config" "~11.0.13" - "@expo/config-plugins" "~10.1.2" - "@expo/fingerprint" "0.13.4" - "@expo/metro-config" "0.20.18" - "@expo/vector-icons" "^14.0.0" - babel-preset-expo "~13.2.5" - expo-asset "~11.1.7" - expo-constants "~17.1.8" - expo-file-system "~18.1.11" - expo-font "~13.3.2" - expo-keep-awake "~14.1.4" - expo-modules-autolinking "2.1.15" - expo-modules-core "2.5.0" - react-native-edge-to-edge "1.6.0" + "@expo/cli" "54.0.23" + "@expo/config" "~12.0.13" + "@expo/config-plugins" "~54.0.4" + "@expo/devtools" "0.1.8" + "@expo/fingerprint" "0.15.4" + "@expo/metro" "~54.2.0" + "@expo/metro-config" "54.0.14" + "@expo/vector-icons" "^15.0.3" + "@ungap/structured-clone" "^1.3.0" + babel-preset-expo "~54.0.10" + expo-asset "~12.0.12" + expo-constants "~18.0.13" + expo-file-system "~19.0.21" + expo-font "~14.0.11" + expo-keep-awake "~15.0.8" + expo-modules-autolinking "3.0.24" + expo-modules-core "3.0.29" + pretty-format "^29.7.0" + react-refresh "^0.14.2" whatwg-url-without-unicode "8.0.0-3" exponential-backoff@^3.1.1: @@ -9677,6 +10080,11 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.4, get-intrinsic@ hasown "^2.0.2" math-intrinsics "^1.1.0" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" @@ -9793,6 +10201,15 @@ glob@^10.3.10, glob@^10.4.2: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^13.0.0: + version "13.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== + dependencies: + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" + glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -9992,22 +10409,20 @@ hermes-estree@0.20.1: resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.20.1.tgz#0b9a544cf883a779a8e1444b915fa365bef7f72d" integrity sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg== -hermes-estree@0.25.1: - version "0.25.1" - resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" - integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== - hermes-estree@0.29.1: version "0.29.1" resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.29.1.tgz#043c7db076e0e8ef8c5f6ed23828d1ba463ebcc5" integrity sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ== -hermes-parser@0.25.1: - version "0.25.1" - resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" - integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== - dependencies: - hermes-estree "0.25.1" +hermes-estree@0.32.0: + version "0.32.0" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.32.0.tgz#bb7da6613ab8e67e334a1854ea1e209f487d307b" + integrity sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ== + +hermes-estree@0.33.3: + version "0.33.3" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.33.3.tgz#6d6b593d4b471119772c82bdb0212dfadabb6f17" + integrity sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg== hermes-parser@0.29.1: version "0.29.1" @@ -10016,6 +10431,20 @@ hermes-parser@0.29.1: dependencies: hermes-estree "0.29.1" +hermes-parser@0.32.0: + version "0.32.0" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.32.0.tgz#7916984ef6fdce62e7415d354cf35392061cd303" + integrity sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw== + dependencies: + hermes-estree "0.32.0" + +hermes-parser@0.33.3: + version "0.33.3" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.33.3.tgz#da50ababb7a5ab636d339e7b2f6e3848e217e09d" + integrity sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA== + dependencies: + hermes-estree "0.33.3" + hermes-parser@^0.20.1: version "0.20.1" resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.20.1.tgz#ad10597b99f718b91e283f81cbe636c50c3cff92" @@ -10227,14 +10656,6 @@ image-size@^1.0.2: dependencies: queue "6.0.2" -import-fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" - integrity sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg== - dependencies: - caller-path "^2.0.0" - resolve-from "^3.0.0" - import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" @@ -10522,11 +10943,6 @@ is-date-object@^1.0.5, is-date-object@^1.1.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" -is-directory@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" - integrity sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw== - is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" @@ -11103,24 +11519,23 @@ jest-environment-node@^29.7.0: jest-mock "^29.7.0" jest-util "^29.7.0" -jest-expo@~53.0.14: - version "53.0.14" - resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-53.0.14.tgz#15b04ee3e8b6837cdc51f818e525acfdbaaf9069" - integrity sha512-BwE5ZTjkhTvO+ejJBBJNlwar2YiahWjbcwhSPQ3oYV5UyvVdTrpKvZ+KjFv/7N5OaC3cOhv4/Ve4cTgS6m6vcw== +jest-expo@~54.0.17: + version "54.0.17" + resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-54.0.17.tgz#c4b905097889340fe44f868d601c165c113ddc55" + integrity sha512-LyIhrsP4xvHEEcR1R024u/LBj3uPpAgB+UljgV+YXWkEHjprnr0KpE4tROsMNYCVTM1pPlAnPuoBmn5gnAN9KA== dependencies: - "@expo/config" "~11.0.13" - "@expo/json-file" "^9.1.5" + "@expo/config" "~12.0.13" + "@expo/json-file" "^10.0.8" "@jest/create-cache-key-function" "^29.2.1" "@jest/globals" "^29.2.1" babel-jest "^29.2.1" - find-up "^5.0.0" jest-environment-jsdom "^29.2.1" jest-snapshot "^29.2.1" jest-watch-select-projects "^2.0.0" jest-watch-typeahead "2.2.1" json5 "^2.2.3" lodash "^4.17.19" - react-test-renderer "19.0.0" + react-test-renderer "19.1.0" server-only "^0.0.1" stacktrace-js "^2.0.2" @@ -11572,11 +11987,6 @@ json-buffer@3.0.1: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-parse-better-errors@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== - json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" @@ -12030,7 +12440,7 @@ lodash.camelcase@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== -lodash.debounce@4.0.8, lodash.debounce@^4.0.8: +lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== @@ -12189,6 +12599,11 @@ lru-cache@^10.0.1, lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.3.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.3.tgz#d6c633c2a9657760fd30594d8d98da65330d9d78" + integrity sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -12347,60 +12762,125 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -metro-babel-transformer@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.82.5.tgz#a65ed29265d8257109ab8c37884e6e3a2edee86d" - integrity sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q== +metro-babel-transformer@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz#d8c134615530c9ee61364526d44ca4bb0c5343ea" + integrity sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g== dependencies: "@babel/core" "^7.25.2" flow-enums-runtime "^0.0.6" - hermes-parser "0.29.1" + hermes-parser "0.32.0" nullthrows "^1.1.1" -metro-cache-key@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.82.5.tgz#290a0054b28a708266fb91c8028cf94be04f99c9" - integrity sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA== +metro-babel-transformer@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.83.5.tgz#91f3fa269171ad5189ebba625f1f0aa124ce06ea" + integrity sha512-d9FfmgUEVejTiSb7bkQeLRGl6aeno2UpuPm3bo3rCYwxewj03ymvOn8s8vnS4fBqAPQ+cE9iQM40wh7nGXR+eA== + dependencies: + "@babel/core" "^7.25.2" + flow-enums-runtime "^0.0.6" + hermes-parser "0.33.3" + nullthrows "^1.1.1" + +metro-cache-key@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.83.3.tgz#ae6c5d4eb1ad8d06a92bf7294ca730a8d880b573" + integrity sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw== + dependencies: + flow-enums-runtime "^0.0.6" + +metro-cache-key@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.83.5.tgz#96896a1768f0494a375e1d5957b7ad487e508a4c" + integrity sha512-Ycl8PBajB7bhbAI7Rt0xEyiF8oJ0RWX8EKkolV1KfCUlC++V/GStMSGpPLwnnBZXZWkCC5edBPzv1Hz1Yi0Euw== dependencies: flow-enums-runtime "^0.0.6" -metro-cache@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.82.5.tgz#4c8fe58cd5fa30b87db0b2b6a650a771ec6fe162" - integrity sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q== +metro-cache@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.83.3.tgz#f1245cc48570c47d8944495e61d67f0228f10172" + integrity sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q== dependencies: exponential-backoff "^3.1.1" flow-enums-runtime "^0.0.6" https-proxy-agent "^7.0.5" - metro-core "0.82.5" + metro-core "0.83.3" + +metro-cache@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.83.5.tgz#5675f4ad56905aa78fff3dec1b6bf213e0b6c86d" + integrity sha512-oH+s4U+IfZyg8J42bne2Skc90rcuESIYf86dYittcdWQtPfcaFXWpByPyTuWk3rR1Zz3Eh5HOrcVImfEhhJLng== + dependencies: + exponential-backoff "^3.1.1" + flow-enums-runtime "^0.0.6" + https-proxy-agent "^7.0.5" + metro-core "0.83.5" + +metro-config@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.83.3.tgz#a30e7a69b5cf8c4ac4c4b68b1b4c33649ae129a2" + integrity sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA== + dependencies: + connect "^3.6.5" + flow-enums-runtime "^0.0.6" + jest-validate "^29.7.0" + metro "0.83.3" + metro-cache "0.83.3" + metro-core "0.83.3" + metro-runtime "0.83.3" + yaml "^2.6.1" -metro-config@0.82.5, metro-config@^0.82.0: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.82.5.tgz#07366f32c3fe6203d630af7df4781900816c7c85" - integrity sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g== +metro-config@0.83.5, metro-config@^0.83.1: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.83.5.tgz#a3dd20fc5d5582aa4ad3704678e52abcf4d46b2b" + integrity sha512-JQ/PAASXH7yczgV6OCUSRhZYME+NU8NYjI2RcaG5ga4QfQ3T/XdiLzpSb3awWZYlDCcQb36l4Vl7i0Zw7/Tf9w== dependencies: connect "^3.6.5" - cosmiconfig "^5.0.5" flow-enums-runtime "^0.0.6" jest-validate "^29.7.0" - metro "0.82.5" - metro-cache "0.82.5" - metro-core "0.82.5" - metro-runtime "0.82.5" + metro "0.83.5" + metro-cache "0.83.5" + metro-core "0.83.5" + metro-runtime "0.83.5" + yaml "^2.6.1" + +metro-core@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.3.tgz#007e93f7d1983777da8988dfb103ad897c9835b8" + integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== + dependencies: + flow-enums-runtime "^0.0.6" + lodash.throttle "^4.1.1" + metro-resolver "0.83.3" -metro-core@0.82.5, metro-core@^0.82.0: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.82.5.tgz#fda1b2f7365e3a09055dd72ba1681f8fc1f6f492" - integrity sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA== +metro-core@0.83.5, metro-core@^0.83.1: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.5.tgz#1592033633034feb5d368d22bf18e38052146970" + integrity sha512-YcVcLCrf0ed4mdLa82Qob0VxYqfhmlRxUS8+TO4gosZo/gLwSvtdeOjc/Vt0pe/lvMNrBap9LlmvZM8FIsMgJQ== dependencies: flow-enums-runtime "^0.0.6" lodash.throttle "^4.1.1" - metro-resolver "0.82.5" + metro-resolver "0.83.5" + +metro-file-map@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.83.3.tgz#3d79fbb1d379ab178dd895ce54cb5ecb183d74a2" + integrity sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA== + dependencies: + debug "^4.4.0" + fb-watchman "^2.0.0" + flow-enums-runtime "^0.0.6" + graceful-fs "^4.2.4" + invariant "^2.2.4" + jest-worker "^29.7.0" + micromatch "^4.0.4" + nullthrows "^1.1.1" + walker "^1.0.7" -metro-file-map@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.82.5.tgz#3e47410a9ce8f6c913480970226a17371c2d2974" - integrity sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ== +metro-file-map@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.83.5.tgz#394aa61d54b3822f10e68c18cbd1318f18865d20" + integrity sha512-ZEt8s3a1cnYbn40nyCD+CsZdYSlwtFh2kFym4lo+uvfM+UMMH+r/BsrC6rbNClSrt+B7rU9T+Te/sh/NL8ZZKQ== dependencies: debug "^4.4.0" fb-watchman "^2.0.0" @@ -12412,61 +12892,111 @@ metro-file-map@0.82.5: nullthrows "^1.1.1" walker "^1.0.7" -metro-minify-terser@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.82.5.tgz#5dc77d53b6ef4079bd9c752ae046d557df4ae584" - integrity sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg== +metro-minify-terser@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz#c1c70929c86b14c8bf03e6321b4f9310bc8dbe87" + integrity sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ== + dependencies: + flow-enums-runtime "^0.0.6" + terser "^5.15.0" + +metro-minify-terser@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.83.5.tgz#ee43a11a9d3442760781434c599d45eb1274e6fd" + integrity sha512-Toe4Md1wS1PBqbvB0cFxBzKEVyyuYTUb0sgifAZh/mSvLH84qA1NAWik9sISWatzvfWf3rOGoUoO5E3f193a3Q== dependencies: flow-enums-runtime "^0.0.6" terser "^5.15.0" -metro-resolver@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.82.5.tgz#cb810038d488a47334df444312b23f0090eca5c3" - integrity sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g== +metro-resolver@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.83.3.tgz#06207bdddc280b9335722a8c992aeec017413942" + integrity sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ== + dependencies: + flow-enums-runtime "^0.0.6" + +metro-resolver@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.83.5.tgz#72340ca8071941eafe92ff2dcb8e33c581870ef7" + integrity sha512-7p3GtzVUpbAweJeCcUJihJeOQl1bDuimO5ueo1K0BUpUtR41q5EilbQ3klt16UTPPMpA+tISWBtsrqU556mY1A== dependencies: flow-enums-runtime "^0.0.6" -metro-runtime@0.82.5, metro-runtime@^0.82.0: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.82.5.tgz#97840760e4cee49f08948dd918dbeba08dd0d0ec" - integrity sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g== +metro-runtime@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.83.3.tgz#ff504df5d93f38b1af396715b327e589ba8d184d" + integrity sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw== dependencies: "@babel/runtime" "^7.25.0" flow-enums-runtime "^0.0.6" -metro-source-map@0.82.5, metro-source-map@^0.82.0: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.82.5.tgz#85e2e9672bff6d6cefb3b65b96fcc69f929c69c6" - integrity sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw== +metro-runtime@0.83.5, metro-runtime@^0.83.1: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.83.5.tgz#52c1edafc6cc82e57729cc9c21700ab1e53a1777" + integrity sha512-f+b3ue9AWTVlZe2Xrki6TAoFtKIqw30jwfk7GQ1rDUBQaE0ZQ+NkiMEtb9uwH7uAjJ87U7Tdx1Jg1OJqUfEVlA== + dependencies: + "@babel/runtime" "^7.25.0" + flow-enums-runtime "^0.0.6" + +metro-source-map@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.83.3.tgz#04bb464f7928ea48bcdfd18912c8607cf317c898" + integrity sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg== dependencies: "@babel/traverse" "^7.25.3" "@babel/traverse--for-generate-function-map" "npm:@babel/traverse@^7.25.3" "@babel/types" "^7.25.2" flow-enums-runtime "^0.0.6" invariant "^2.2.4" - metro-symbolicate "0.82.5" + metro-symbolicate "0.83.3" + nullthrows "^1.1.1" + ob1 "0.83.3" + source-map "^0.5.6" + vlq "^1.0.0" + +metro-source-map@0.83.5, metro-source-map@^0.83.1: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.83.5.tgz#384f311f83fa2bf51cbec08d77210aa951bf9ee3" + integrity sha512-VT9bb2KO2/4tWY9Z2yeZqTUao7CicKAOps9LUg2aQzsz+04QyuXL3qgf1cLUVRjA/D6G5u1RJAlN1w9VNHtODQ== + dependencies: + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + flow-enums-runtime "^0.0.6" + invariant "^2.2.4" + metro-symbolicate "0.83.5" + nullthrows "^1.1.1" + ob1 "0.83.5" + source-map "^0.5.6" + vlq "^1.0.0" + +metro-symbolicate@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz#67af03950f0dfe19a7c059e3983e39a31e95d03a" + integrity sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw== + dependencies: + flow-enums-runtime "^0.0.6" + invariant "^2.2.4" + metro-source-map "0.83.3" nullthrows "^1.1.1" - ob1 "0.82.5" source-map "^0.5.6" vlq "^1.0.0" -metro-symbolicate@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.82.5.tgz#b53255cad11f1e6795f319ca4b41857bfe295d65" - integrity sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw== +metro-symbolicate@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.83.5.tgz#62167db423be6c68b4b9f39935c9cb7330cc9526" + integrity sha512-EMIkrjNRz/hF+p0RDdxoE60+dkaTLPN3vaaGkFmX5lvFdO6HPfHA/Ywznzkev+za0VhPQ5KSdz49/MALBRteHA== dependencies: flow-enums-runtime "^0.0.6" invariant "^2.2.4" - metro-source-map "0.82.5" + metro-source-map "0.83.5" nullthrows "^1.1.1" source-map "^0.5.6" vlq "^1.0.0" -metro-transform-plugins@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.82.5.tgz#678da4d0f9085b2a3fc0b4350062f19cc625c5fc" - integrity sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA== +metro-transform-plugins@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz#2c59ba841e269363cf3acb13138cb992f0c75013" + integrity sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A== dependencies: "@babel/core" "^7.25.2" "@babel/generator" "^7.25.0" @@ -12475,29 +13005,60 @@ metro-transform-plugins@0.82.5: flow-enums-runtime "^0.0.6" nullthrows "^1.1.1" -metro-transform-worker@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.82.5.tgz#aabdccf17aaa584ec0fd97b5283e806958fb3418" - integrity sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw== +metro-transform-plugins@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.83.5.tgz#ba21c6a5fa9bf6c5c2c222e2c8e7a668ffb3d341" + integrity sha512-KxYKzZL+lt3Os5H2nx7YkbkWVduLZL5kPrE/Yq+Prm/DE1VLhpfnO6HtPs8vimYFKOa58ncl60GpoX0h7Wm0Vw== + dependencies: + "@babel/core" "^7.25.2" + "@babel/generator" "^7.29.1" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + flow-enums-runtime "^0.0.6" + nullthrows "^1.1.1" + +metro-transform-worker@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz#ca6ae4a02b0f61b33299e6e56bacaba32dcd607f" + integrity sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA== dependencies: "@babel/core" "^7.25.2" "@babel/generator" "^7.25.0" "@babel/parser" "^7.25.3" "@babel/types" "^7.25.2" flow-enums-runtime "^0.0.6" - metro "0.82.5" - metro-babel-transformer "0.82.5" - metro-cache "0.82.5" - metro-cache-key "0.82.5" - metro-minify-terser "0.82.5" - metro-source-map "0.82.5" - metro-transform-plugins "0.82.5" + metro "0.83.3" + metro-babel-transformer "0.83.3" + metro-cache "0.83.3" + metro-cache-key "0.83.3" + metro-minify-terser "0.83.3" + metro-source-map "0.83.3" + metro-transform-plugins "0.83.3" nullthrows "^1.1.1" -metro@0.82.5, metro@^0.82.0: - version "0.82.5" - resolved "https://registry.yarnpkg.com/metro/-/metro-0.82.5.tgz#a27fbc08dd283a14ae58496288c10adaae65f461" - integrity sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg== +metro-transform-worker@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.83.5.tgz#8616b54282e727027fdb5c475aade719394a8e8a" + integrity sha512-8N4pjkNXc6ytlP9oAM6MwqkvUepNSW39LKYl9NjUMpRDazBQ7oBpQDc8Sz4aI8jnH6AGhF7s1m/ayxkN1t04yA== + dependencies: + "@babel/core" "^7.25.2" + "@babel/generator" "^7.29.1" + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + flow-enums-runtime "^0.0.6" + metro "0.83.5" + metro-babel-transformer "0.83.5" + metro-cache "0.83.5" + metro-cache-key "0.83.5" + metro-minify-terser "0.83.5" + metro-source-map "0.83.5" + metro-transform-plugins "0.83.5" + nullthrows "^1.1.1" + +metro@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/metro/-/metro-0.83.3.tgz#1e7e04c15519af746f8932c7f9c553d92c39e922" + integrity sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q== dependencies: "@babel/code-frame" "^7.24.7" "@babel/core" "^7.25.2" @@ -12514,24 +13075,24 @@ metro@0.82.5, metro@^0.82.0: error-stack-parser "^2.0.6" flow-enums-runtime "^0.0.6" graceful-fs "^4.2.4" - hermes-parser "0.29.1" + hermes-parser "0.32.0" image-size "^1.0.2" invariant "^2.2.4" jest-worker "^29.7.0" jsc-safe-url "^0.2.2" lodash.throttle "^4.1.1" - metro-babel-transformer "0.82.5" - metro-cache "0.82.5" - metro-cache-key "0.82.5" - metro-config "0.82.5" - metro-core "0.82.5" - metro-file-map "0.82.5" - metro-resolver "0.82.5" - metro-runtime "0.82.5" - metro-source-map "0.82.5" - metro-symbolicate "0.82.5" - metro-transform-plugins "0.82.5" - metro-transform-worker "0.82.5" + metro-babel-transformer "0.83.3" + metro-cache "0.83.3" + metro-cache-key "0.83.3" + metro-config "0.83.3" + metro-core "0.83.3" + metro-file-map "0.83.3" + metro-resolver "0.83.3" + metro-runtime "0.83.3" + metro-source-map "0.83.3" + metro-symbolicate "0.83.3" + metro-transform-plugins "0.83.3" + metro-transform-worker "0.83.3" mime-types "^2.1.27" nullthrows "^1.1.1" serialize-error "^2.1.0" @@ -12540,6 +13101,52 @@ metro@0.82.5, metro@^0.82.0: ws "^7.5.10" yargs "^17.6.2" +metro@0.83.5, metro@^0.83.1: + version "0.83.5" + resolved "https://registry.yarnpkg.com/metro/-/metro-0.83.5.tgz#f5441075d5211c980ac8c79109e9e6fa2df68924" + integrity sha512-BgsXevY1MBac/3ZYv/RfNFf/4iuW9X7f4H8ZNkiH+r667HD9sVujxcmu4jvEzGCAm4/WyKdZCuyhAcyhTHOucQ== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/core" "^7.25.2" + "@babel/generator" "^7.29.1" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + accepts "^2.0.0" + chalk "^4.0.0" + ci-info "^2.0.0" + connect "^3.6.5" + debug "^4.4.0" + error-stack-parser "^2.0.6" + flow-enums-runtime "^0.0.6" + graceful-fs "^4.2.4" + hermes-parser "0.33.3" + image-size "^1.0.2" + invariant "^2.2.4" + jest-worker "^29.7.0" + jsc-safe-url "^0.2.2" + lodash.throttle "^4.1.1" + metro-babel-transformer "0.83.5" + metro-cache "0.83.5" + metro-cache-key "0.83.5" + metro-config "0.83.5" + metro-core "0.83.5" + metro-file-map "0.83.5" + metro-resolver "0.83.5" + metro-runtime "0.83.5" + metro-source-map "0.83.5" + metro-symbolicate "0.83.5" + metro-transform-plugins "0.83.5" + metro-transform-worker "0.83.5" + mime-types "^3.0.1" + nullthrows "^1.1.1" + serialize-error "^2.1.0" + source-map "^0.5.6" + throat "^5.0.0" + ws "^7.5.10" + yargs "^17.6.2" + micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8, micromatch@~4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" @@ -12553,7 +13160,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -"mime-db@>= 1.43.0 < 2": +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: version "1.54.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== @@ -12565,6 +13172,13 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.34: dependencies: mime-db "1.52.0" +mime-types@^3.0.0, mime-types@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + mime@1.6.0, mime@^1.3.4: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -12624,6 +13238,13 @@ minimatch@^10.0.3: dependencies: "@isaacs/brace-expansion" "^5.0.0" +minimatch@^10.2.2: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -12718,6 +13339,11 @@ minipass@^5.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + minizlib@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -12794,7 +13420,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.1, nanoid@^3.3.11, nanoid@^3.3.7: +nanoid@^3.3.1, nanoid@^3.3.11, nanoid@^3.3.7, nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -13049,10 +13675,17 @@ nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.22.tgz#109f9530cda6c156d6a713cdf5939e9f0de98b9d" integrity sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ== -ob1@0.82.5: - version "0.82.5" - resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.82.5.tgz#a2860e39385f4602bc2666c46f331b7531b94a8b" - integrity sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ== +ob1@0.83.3: + version "0.83.3" + resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.83.3.tgz#2208e20c9070e9beff3ad067f2db458fa6b07014" + integrity sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA== + dependencies: + flow-enums-runtime "^0.0.6" + +ob1@0.83.5: + version "0.83.5" + resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.83.5.tgz#f9c289d759142b76577948eea7fd1f07d36f825f" + integrity sha512-vNKPYC8L5ycVANANpF/S+WZHpfnRWKx/F3AYP4QMn6ZJTh+l2HOrId0clNkEmua58NB9vmI9Qh7YOoV/4folYg== dependencies: flow-enums-runtime "^0.0.6" @@ -13420,14 +14053,6 @@ parse-headers@^2.0.0: resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.6.tgz#7940f0abe5fe65df2dd25d4ce8800cb35b49d01c" integrity sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A== -parse-json@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" - integrity sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw== - dependencies: - error-ex "^1.3.1" - json-parse-better-errors "^1.0.1" - parse-json@^5.0.0, parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -13562,6 +14187,14 @@ path-scurry@^1.11.1, path-scurry@^1.6.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -13616,6 +14249,11 @@ picomatch@^4.0.2, picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + pidtree@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" @@ -13871,7 +14509,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@15.8.1, prop-types@^15.8.1: +prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -14014,7 +14652,7 @@ rc@1.2.8, rc@~1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-devtools-core@^6.1.1: +react-devtools-core@^6.1.5: version "6.1.5" resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-6.1.5.tgz#c5eca79209dab853a03b2158c034c5166975feee" integrity sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA== @@ -14022,12 +14660,12 @@ react-devtools-core@^6.1.1: shell-quote "^1.6.1" ws "^7" -react-dom@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" - integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== +react-dom@19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623" + integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== dependencies: - scheduler "^0.25.0" + scheduler "^0.26.0" react-error-boundary@~4.0.13: version "4.0.13" @@ -14069,7 +14707,7 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-is@^19.0.0, react-is@^19.1.0: +react-is@^19.1.0: version "19.1.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.1.1.tgz#038ebe313cf18e1fd1235d51c87360eb87f7c36a" integrity sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA== @@ -14113,10 +14751,10 @@ react-native-flash-message@~0.4.2: prop-types "^15.8.1" react-native-iphone-screen-helper "^2.0.2" -react-native-gesture-handler@~2.24.0: - version "2.24.0" - resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz#b6e1f13ec9bf8dfa5f4911854b6e0d73d882a81a" - integrity sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw== +react-native-gesture-handler@~2.28.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz#07fb4f5eae72f810aac3019b060d26c1835bfd0c" + integrity sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A== dependencies: "@egjs/hammerjs" "^2.0.17" hoist-non-react-statics "^3.3.0" @@ -14127,20 +14765,15 @@ react-native-iphone-screen-helper@^2.0.2: resolved "https://registry.yarnpkg.com/react-native-iphone-screen-helper/-/react-native-iphone-screen-helper-2.2.1.tgz#34125de16426f1011ecec595be01876be9ccf2bf" integrity sha512-gMHawcFa9O8St9rJ6zugW55O/sM3UUTLqc4MDaZQewBTIDjfvHzLmk2A/+PxySl5ZTfv6fyIV5g+2ozLRW5kcw== -react-native-is-edge-to-edge@1.1.7: - version "1.1.7" - resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz#28947688f9fafd584e73a4f935ea9603bd9b1939" - integrity sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w== - -react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.1.7, react-native-is-edge-to-edge@^1.2.1: +react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358" integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== -react-native-keyboard-controller@^1.18.6: - version "1.18.6" - resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.18.6.tgz#ee7449d669bb599c29399d7f5f8cfc72c8528e45" - integrity sha512-K/RMw3MdtuykkACFN5d9RTapAcO0v4T34gmSyHkEraU5UsX+fxEHd6j4MvL7KUihvmLLod0NV/mQC0nL4cOurw== +react-native-keyboard-controller@1.18.5: + version "1.18.5" + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.18.5.tgz#ae12131f2019c574178479d2c55784f55e08bb68" + integrity sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ== dependencies: react-native-is-edge-to-edge "^1.2.1" @@ -14161,41 +14794,31 @@ react-native-quick-base64@2.1.1: dependencies: base64-js "^1.5.1" -react-native-reanimated@~3.17.4: - version "3.17.5" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz#09ebe3c9e3379c5c0c588b7ab30c131ea29b60f0" - integrity sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw== +react-native-reanimated@~4.1.1: + version "4.1.7" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz#b4e8524503a1b6ec1b5a40c460ee807a6a9fd2cf" + integrity sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg== dependencies: - "@babel/plugin-transform-arrow-functions" "^7.0.0-0" - "@babel/plugin-transform-class-properties" "^7.0.0-0" - "@babel/plugin-transform-classes" "^7.0.0-0" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.0.0-0" - "@babel/plugin-transform-optional-chaining" "^7.0.0-0" - "@babel/plugin-transform-shorthand-properties" "^7.0.0-0" - "@babel/plugin-transform-template-literals" "^7.0.0-0" - "@babel/plugin-transform-unicode-regex" "^7.0.0-0" - "@babel/preset-typescript" "^7.16.7" - convert-source-map "^2.0.0" - invariant "^2.2.4" - react-native-is-edge-to-edge "1.1.7" + react-native-is-edge-to-edge "^1.2.1" + semver "^7.7.2" react-native-restart@0.0.27: version "0.0.27" resolved "https://registry.yarnpkg.com/react-native-restart/-/react-native-restart-0.0.27.tgz#43aa8210312c9dfa5ec7bd4b2f35238ad7972b19" integrity sha512-8KScVICrXwcTSJ1rjWkqVTHyEKQIttm5AIMGSK1QG1+RS5owYlE4z/1DykOTdWfVl9l16FIk0w9Xzk9ZO6jxlA== -react-native-safe-area-context@5.4.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz#04b51940408c114f75628a12a93569d30c525454" - integrity sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA== +react-native-safe-area-context@~5.6.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz#283e006f5b434fb247fcb4be0971ad7473d5c560" + integrity sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg== -react-native-screens@~4.11.1: - version "4.11.1" - resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.11.1.tgz#7d0f3d313d8ddc1e55437c5e038f15f8805dc991" - integrity sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw== +react-native-screens@~4.16.0: + version "4.16.0" + resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.16.0.tgz#efa42e77a092aa0b5277c9ae41391ea0240e0870" + integrity sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q== dependencies: react-freeze "^1.0.0" - react-native-is-edge-to-edge "^1.1.7" + react-native-is-edge-to-edge "^1.2.1" warn-once "^0.1.0" react-native-svg-transformer@~1.5.1: @@ -14208,10 +14831,10 @@ react-native-svg-transformer@~1.5.1: "@svgr/plugin-svgo" "^8.1.0" path-dirname "^1.0.2" -react-native-svg@15.11.2: - version "15.11.2" - resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.11.2.tgz#7540e8e1eabc4dcd3b1e35ada5a1d9f1b96d37c4" - integrity sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw== +react-native-svg@15.12.1: + version "15.12.1" + resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.12.1.tgz#7ba756dd6a235f86a2c312a1e7911f9b0d18ad3a" + integrity sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g== dependencies: css-select "^5.1.0" css-tree "^1.1.3" @@ -14224,10 +14847,10 @@ react-native-url-polyfill@^1.3.0: dependencies: whatwg-url-without-unicode "8.0.0-3" -react-native-web@^0.20.0: - version "0.20.0" - resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.20.0.tgz#3fb0591999ed4b54d7822a2785547415e8a5c031" - integrity sha512-OOSgrw+aON6R3hRosCau/xVxdLzbjEcsLysYedka0ZON4ZZe6n9xgeN9ZkoejhARM36oTlUgHIQqxGutEJ9Wxg== +react-native-web@^0.21.0: + version "0.21.2" + resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.21.2.tgz#0f6983dfea600d9cc1c66fda87ff9ca585eaa647" + integrity sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg== dependencies: "@babel/runtime" "^7.18.6" "@react-native/normalize-colors" "^0.74.1" @@ -14238,50 +14861,65 @@ react-native-web@^0.20.0: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native-webview@~13.13.1: - version "13.13.5" - resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.13.5.tgz#4ef5f9310ddff5747f884a6655228ec9c7d52c73" - integrity sha512-MfC2B+woL4Hlj2WCzcb1USySKk+SteXnUKmKktOk/H/AQy5+LuVdkPKm8SknJ0/RxaxhZ48WBoTRGaqgR137hw== +react-native-webview@13.15.0: + version "13.15.0" + resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.15.0.tgz#b6d2f8d8dd65897db76659ddd8198d2c74ec5a79" + integrity sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ== dependencies: escape-string-regexp "^4.0.0" invariant "2.2.4" -react-native@0.79.6: - version "0.79.6" - resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.6.tgz#ee95428f67da2f62ede473eaa6e8a2f4ee40e272" - integrity sha512-kvIWSmf4QPfY41HC25TR285N7Fv0Pyn3DAEK8qRL9dA35usSaxsJkHfw+VqnonqJjXOaoKCEanwudRAJ60TBGA== +react-native-worklets@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.5.1.tgz#d153242655e3757b6c62a474768831157316ad33" + integrity sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w== + dependencies: + "@babel/plugin-transform-arrow-functions" "^7.0.0-0" + "@babel/plugin-transform-class-properties" "^7.0.0-0" + "@babel/plugin-transform-classes" "^7.0.0-0" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.0.0-0" + "@babel/plugin-transform-optional-chaining" "^7.0.0-0" + "@babel/plugin-transform-shorthand-properties" "^7.0.0-0" + "@babel/plugin-transform-template-literals" "^7.0.0-0" + "@babel/plugin-transform-unicode-regex" "^7.0.0-0" + "@babel/preset-typescript" "^7.16.7" + convert-source-map "^2.0.0" + semver "7.7.2" + +react-native@0.81.5: + version "0.81.5" + resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.81.5.tgz#6c963f137d3979b22aef2d8482067775c8fe2fed" + integrity sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw== dependencies: "@jest/create-cache-key-function" "^29.7.0" - "@react-native/assets-registry" "0.79.6" - "@react-native/codegen" "0.79.6" - "@react-native/community-cli-plugin" "0.79.6" - "@react-native/gradle-plugin" "0.79.6" - "@react-native/js-polyfills" "0.79.6" - "@react-native/normalize-colors" "0.79.6" - "@react-native/virtualized-lists" "0.79.6" + "@react-native/assets-registry" "0.81.5" + "@react-native/codegen" "0.81.5" + "@react-native/community-cli-plugin" "0.81.5" + "@react-native/gradle-plugin" "0.81.5" + "@react-native/js-polyfills" "0.81.5" + "@react-native/normalize-colors" "0.81.5" + "@react-native/virtualized-lists" "0.81.5" abort-controller "^3.0.0" anser "^1.4.9" ansi-regex "^5.0.0" babel-jest "^29.7.0" - babel-plugin-syntax-hermes-parser "0.25.1" + babel-plugin-syntax-hermes-parser "0.29.1" base64-js "^1.5.1" - chalk "^4.0.0" commander "^12.0.0" - event-target-shim "^5.0.1" flow-enums-runtime "^0.0.6" glob "^7.1.1" invariant "^2.2.4" jest-environment-node "^29.7.0" memoize-one "^5.0.0" - metro-runtime "^0.82.0" - metro-source-map "^0.82.0" + metro-runtime "^0.83.1" + metro-source-map "^0.83.1" nullthrows "^1.1.1" pretty-format "^29.7.0" promise "^8.3.0" - react-devtools-core "^6.1.1" + react-devtools-core "^6.1.5" react-refresh "^0.14.0" regenerator-runtime "^0.13.2" - scheduler "0.25.0" + scheduler "0.26.0" semver "^7.1.3" stacktrace-parser "^0.1.10" whatwg-fetch "^3.0.0" @@ -14298,6 +14936,25 @@ react-refresh@^0.14.0, react-refresh@^0.14.2: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== + dependencies: + react-style-singleton "^2.2.2" + tslib "^2.0.0" + +react-remove-scroll@^2.6.3: + version "2.7.2" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz#6442da56791117661978ae99cd29be9026fecca0" + integrity sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-stately@^3.21.0: version "3.41.0" resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.41.0.tgz#e8239f520cf2cbaa037c0fd9ddf274a9a6bcb3bd" @@ -14330,18 +14987,26 @@ react-stately@^3.21.0: "@react-stately/tree" "^3.9.2" "@react-types/shared" "^3.32.0" -react-test-renderer@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.0.0.tgz#ca6fa322c58d4bfa34635788fe242a8c3daa4c7d" - integrity sha512-oX5u9rOQlHzqrE/64CNr0HB0uWxkCQmZNSfozlYvwE71TLVgeZxVf0IjouGEr1v7r1kcDifdAJBeOhdhxsG/DA== +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== + dependencies: + get-nonce "^1.0.0" + tslib "^2.0.0" + +react-test-renderer@19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.1.0.tgz#89e1baa9e45a6da064b9760f92251d5b8e1f34ab" + integrity sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw== dependencies: - react-is "^19.0.0" - scheduler "^0.25.0" + react-is "^19.1.0" + scheduler "^0.26.0" -react@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" - integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== +react@19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75" + integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== read-binary-file-arch@^1.0.6: version "1.0.6" @@ -14430,15 +15095,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -recyclerlistview@4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/recyclerlistview/-/recyclerlistview-4.2.3.tgz#14032e7ad2f24396e24d5b3060c6ba76b567f000" - integrity sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g== - dependencies: - lodash.debounce "4.0.8" - prop-types "15.8.1" - ts-object-utils "0.0.5" - redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -14588,11 +15244,6 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw== - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -14837,10 +15488,10 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@0.25.0, scheduler@^0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" - integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== +scheduler@0.26.0, scheduler@^0.26.0: + version "0.26.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337" + integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== scoped-regex@^3.0.0: version "3.0.0" @@ -14867,22 +15518,22 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== +semver@7.7.2, semver@^7.1.3, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.1: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + semver@^6.2.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.1.3, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.1: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - semver@^7.3.2, semver@~7.7.3: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== -semver@^7.7.3: +semver@^7.7.2, semver@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -15013,6 +15664,11 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +sf-symbols-typescript@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz#926d6e0715e3d8784cadf7658431e36581254208" + integrity sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw== + shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" @@ -15694,6 +16350,19 @@ sucrase@3.35.0, sucrase@^3.32.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" +sucrase@~3.35.1: + version "3.35.1" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1" + integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + tinyglobby "^0.2.11" + ts-interface-checker "^0.1.9" + sumchecker@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42" @@ -15867,10 +16536,16 @@ tar@^7.4.3: minizlib "^3.1.0" yallist "^5.0.0" -temp-dir@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" - integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== +tar@^7.5.2: + version "7.5.13" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.13.tgz#0d214ed56781a26edc313581c0e2d929ceeb866d" + integrity sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng== + dependencies: + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.1.0" + yallist "^5.0.0" temp-file@^3.4.0: version "3.4.0" @@ -15971,6 +16646,14 @@ tinyexec@^1.0.0: resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.1.tgz#70c31ab7abbb4aea0a24f55d120e5990bfa1e0b1" integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== +tinyglobby@^0.2.11: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tinyglobby@^0.2.12, tinyglobby@^0.2.13, tinyglobby@^0.2.15: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" @@ -16110,11 +16793,6 @@ ts-node@~10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -ts-object-utils@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/ts-object-utils/-/ts-object-utils-0.0.5.tgz#95361cdecd7e52167cfc5e634c76345e90a26077" - integrity sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA== - tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -16135,7 +16813,7 @@ tsconfig@7: strip-bom "^3.0.0" strip-json-comments "^2.0.0" -tslib@2.8.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -16266,16 +16944,16 @@ typed-emitter@^2.1.0: optionalDependencies: rxjs "^7.5.2" -typescript@5.8.x: - version "5.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" - integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== - typescript@^5.6.3: version "5.9.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A== +typescript@~5.9.2: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + ua-parser-js@^0.7.33: version "0.7.41" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz#9f6dee58c389e8afababa62a4a2dc22edb69a452" @@ -16311,11 +16989,6 @@ undici@^6.18.2: resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a" integrity sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw== -"undici@^6.18.2 || ^7.0.0": - version "7.16.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a" - integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== - unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" @@ -16344,10 +17017,10 @@ unicorn-magic@^0.1.0: resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== -unimodules-app-loader@~5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-5.1.3.tgz#c3be527cd36120fc77d6843253075c8a9246f622" - integrity sha512-nPUkwfkpJWvdOQrVvyQSUol93/UdmsCVd9Hkx9RgAevmKSVYdZI+S87W73NGKl6QbwK9L1BDSY5OrQuo8Oq15g== +unimodules-app-loader@~6.0.8: + version "6.0.8" + resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz#81c868b726e24b7e37d708fe0117e1869c721cdb" + integrity sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA== unique-filename@^4.0.0: version "4.0.0" @@ -16363,13 +17036,6 @@ unique-slug@^5.0.0: dependencies: imurmurhash "^0.1.4" -unique-string@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -16456,11 +17122,31 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== + dependencies: + tslib "^2.0.0" + +use-latest-callback@^0.2.1: + version "0.2.6" + resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.6.tgz#e5ea752808c86219acc179ace0ae3c1203255e77" + integrity sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg== + use-latest-callback@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.4.tgz#35c0f028f85a3f4cf025b06011110e87cc18f57e" integrity sha512-LS2s2n1usUUnDq4oVh1ca6JFX9uSqUncTfAm44WMg0v6TxL7POUTk1B044NH8TeLkFbNajIsgDHcgNpNzZucdg== +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" @@ -16537,6 +17223,13 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +vaul@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9" + integrity sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA== + dependencies: + "@radix-ui/react-dialog" "^1.1.1" + verror@^1.10.0: version "1.10.1" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.1.tgz#4bf09eeccf4563b109ed4b3d458380c972b0cdeb" @@ -16985,6 +17678,11 @@ yaml@^2.2.2, yaml@^2.3.4: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79" integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw== +yaml@^2.6.1: + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== + yaml@~2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773" From 50b8f93e46b7eb6f8d72425f77132b1fc7b6bc4a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 26 Apr 2026 17:13:44 -0700 Subject: [PATCH 2/4] RU-T50 Updating features and bug fixes for Expo 54 --- app.config.ts | 1 + package.json | 1 + plugins/withCheckInLiveActivity.js | 263 ++++++ src/api/call-video-feeds/call-video-feeds.ts | 70 ++ src/api/calls/calls.ts | 4 + src/api/check-in-timers/check-in-timers.ts | 62 ++ src/api/dispatch/dispatch.ts | 17 +- src/api/mapping/mapping.ts | 38 + src/api/weather-alerts/weather-alerts.ts | 53 ++ src/app/(app)/_layout.tsx | 20 +- src/app/(app)/index.tsx | 29 +- src/app/(app)/routes.tsx | 156 +--- src/app/(app)/weather-alerts.tsx | 119 +++ src/app/call/[id].tsx | 68 +- src/app/call/[id]/edit.tsx | 37 +- src/app/call/__tests__/[id].security.test.tsx | 23 + src/app/call/__tests__/[id].test.tsx | 23 + src/app/call/new/index.tsx | 54 +- src/app/routes/_layout.tsx | 1 + src/app/routes/history/instance/[id].tsx | 18 +- src/app/routes/index.tsx | 145 +-- src/app/routes/poi/[id].tsx | 157 ++++ src/app/weather-alert/[id].tsx | 193 ++++ .../__tests__/video-feed-card.test.tsx | 147 +++ .../__tests__/video-feed-tab-content.test.tsx | 207 +++++ .../call-video-feeds/feed-format-utils.ts | 67 ++ .../call-video-feeds/video-feed-card.tsx | 79 ++ .../video-feed-form-sheet.tsx | 257 ++++++ .../video-feed-tab-content.tsx | 128 +++ .../call-video-feeds/video-player-modal.tsx | 95 ++ src/components/calls/call-card.tsx | 63 +- .../calls/destination-poi-selector.tsx | 77 ++ .../__tests__/check-in-bottom-sheet.test.tsx | 122 +++ .../__tests__/check-in-timer-card.test.tsx | 130 +++ .../check-in-timers/check-in-bottom-sheet.tsx | 100 +++ .../check-in-timers/check-in-history-list.tsx | 42 + .../check-in-timers/check-in-tab-content.tsx | 78 ++ .../check-in-timers/check-in-timer-card.tsx | 105 +++ .../maps/__tests__/pin-actions.test.tsx | 6 +- src/components/maps/pin-detail-modal.tsx | 38 +- src/components/routes/active-routes-list.tsx | 159 ++++ src/components/routes/poi-card.tsx | 66 ++ src/components/routes/poi-list-content.tsx | 133 +++ src/components/routes/routes-home.tsx | 33 + .../sidebar/__tests__/call-sidebar.test.tsx | 1 + .../sidebar/check-in-sidebar-widget.tsx | 69 ++ src/components/sidebar/sidebar-content.tsx | 4 + .../__tests__/status-bottom-sheet.test.tsx | 114 ++- .../__tests__/status-gps-debug.test.tsx | 6 + .../status-gps-integration-working.test.tsx | 4 + .../__tests__/status-gps-integration.test.tsx | 4 + src/components/status/status-bottom-sheet.tsx | 835 +++++++++++------- .../__tests__/weather-alert-banner.test.tsx | 81 ++ .../__tests__/weather-alert-card.test.tsx | 80 ++ .../weather-alerts/severity-filter-tabs.tsx | 58 ++ .../weather-alerts/weather-alert-banner.tsx | 59 ++ .../weather-alerts/weather-alert-card.tsx | 70 ++ .../weather-alert-detail-map.tsx | 115 +++ .../__tests__/use-quick-check-in.test.ts | 113 +++ src/hooks/use-check-in-timer-polling.ts | 74 ++ src/hooks/use-quick-check-in.ts | 44 + src/lib/__tests__/poi-utils.test.ts | 141 +++ .../native-modules/check-in-live-activity.ts | 45 + src/lib/poi-utils.ts | 102 +++ src/lib/weather-alert-utils.ts | 115 +++ src/models/offline-queue/queued-event.ts | 16 +- .../v4/callVideoFeeds/callVideoFeedResult.ts | 6 + .../callVideoFeeds/callVideoFeedResultData.ts | 17 + .../callVideoFeeds/saveCallVideoFeedResult.ts | 5 + src/models/v4/calls/callResultData.ts | 8 + src/models/v4/checkIn/checkInRecordResult.ts | 6 + .../v4/checkIn/checkInRecordResultData.ts | 12 + .../v4/checkIn/checkInTimerStatusResult.ts | 6 + .../checkIn/checkInTimerStatusResultData.ts | 12 + src/models/v4/checkIn/performCheckInResult.ts | 5 + .../v4/checkIn/resolvedCheckInTimerResult.ts | 6 + .../checkIn/resolvedCheckInTimerResultData.ts | 11 + .../customStatuses/customStateDetailTypes.ts | 22 + .../v4/destinations/destinationEntityTypes.ts | 6 + .../v4/dispatch/getSetUnitStateResultData.ts | 3 + .../v4/dispatch/newCallFormResultData.ts | 3 + .../v4/mapping/getMapDataAndMarkersData.ts | 18 +- src/models/v4/mapping/poiResultData.ts | 32 + src/models/v4/mapping/poiResults.ts | 15 + .../getCurrentStatusResultData.ts | 7 +- .../v4/unitStatus/saveUnitStatusInput.ts | 1 + .../v4/unitStatus/unitStatusResultData.ts | 15 +- .../activeWeatherAlertsResult.ts | 6 + .../v4/weatherAlerts/weatherAlertEnums.ts | 44 + .../v4/weatherAlerts/weatherAlertResult.ts | 6 + .../weatherAlerts/weatherAlertResultData.ts | 28 + .../weatherAlerts/weatherAlertSettingsData.ts | 8 + .../weatherAlertSettingsResult.ts | 6 + .../weatherAlertZoneResultData.ts | 9 + .../weatherAlerts/weatherAlertZonesResult.ts | 6 + .../offline-event-manager-gps.test.ts | 11 + .../offline-event-manager.service.test.ts | 7 + .../__tests__/push-notification.test.ts | 22 +- src/services/check-in-notification.service.ts | 111 +++ src/services/offline-event-manager.service.ts | 48 +- src/services/push-notification.ts | 29 + .../call-video-feeds/__tests__/store.test.ts | 198 +++++ src/stores/call-video-feeds/store.ts | 94 ++ src/stores/calls/store.ts | 28 + .../check-in-timers/__tests__/store.test.ts | 193 ++++ src/stores/check-in-timers/store.ts | 139 +++ src/stores/pois/store.ts | 144 +++ src/stores/signalr/signalr-store.ts | 20 +- src/stores/status/__tests__/store.test.ts | 67 +- src/stores/status/store.ts | 159 ++-- .../weather-alerts/__tests__/store.test.ts | 239 +++++ src/stores/weather-alerts/store.ts | 137 +++ src/translations/ar.json | 102 ++- src/translations/de.json | 102 ++- src/translations/en.json | 199 ++++- src/translations/es.json | 102 ++- src/translations/fr.json | 102 ++- src/translations/it.json | 102 ++- src/translations/pl.json | 102 ++- src/translations/sv.json | 102 ++- src/translations/uk.json | 102 ++- yarn.lock | 5 + 122 files changed, 7989 insertions(+), 840 deletions(-) create mode 100644 plugins/withCheckInLiveActivity.js create mode 100644 src/api/call-video-feeds/call-video-feeds.ts create mode 100644 src/api/check-in-timers/check-in-timers.ts create mode 100644 src/api/weather-alerts/weather-alerts.ts create mode 100644 src/app/(app)/weather-alerts.tsx create mode 100644 src/app/routes/poi/[id].tsx create mode 100644 src/app/weather-alert/[id].tsx create mode 100644 src/components/call-video-feeds/__tests__/video-feed-card.test.tsx create mode 100644 src/components/call-video-feeds/__tests__/video-feed-tab-content.test.tsx create mode 100644 src/components/call-video-feeds/feed-format-utils.ts create mode 100644 src/components/call-video-feeds/video-feed-card.tsx create mode 100644 src/components/call-video-feeds/video-feed-form-sheet.tsx create mode 100644 src/components/call-video-feeds/video-feed-tab-content.tsx create mode 100644 src/components/call-video-feeds/video-player-modal.tsx create mode 100644 src/components/calls/destination-poi-selector.tsx create mode 100644 src/components/check-in-timers/__tests__/check-in-bottom-sheet.test.tsx create mode 100644 src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx create mode 100644 src/components/check-in-timers/check-in-bottom-sheet.tsx create mode 100644 src/components/check-in-timers/check-in-history-list.tsx create mode 100644 src/components/check-in-timers/check-in-tab-content.tsx create mode 100644 src/components/check-in-timers/check-in-timer-card.tsx create mode 100644 src/components/routes/active-routes-list.tsx create mode 100644 src/components/routes/poi-card.tsx create mode 100644 src/components/routes/poi-list-content.tsx create mode 100644 src/components/routes/routes-home.tsx create mode 100644 src/components/sidebar/check-in-sidebar-widget.tsx create mode 100644 src/components/weather-alerts/__tests__/weather-alert-banner.test.tsx create mode 100644 src/components/weather-alerts/__tests__/weather-alert-card.test.tsx create mode 100644 src/components/weather-alerts/severity-filter-tabs.tsx create mode 100644 src/components/weather-alerts/weather-alert-banner.tsx create mode 100644 src/components/weather-alerts/weather-alert-card.tsx create mode 100644 src/components/weather-alerts/weather-alert-detail-map.tsx create mode 100644 src/hooks/__tests__/use-quick-check-in.test.ts create mode 100644 src/hooks/use-check-in-timer-polling.ts create mode 100644 src/hooks/use-quick-check-in.ts create mode 100644 src/lib/__tests__/poi-utils.test.ts create mode 100644 src/lib/native-modules/check-in-live-activity.ts create mode 100644 src/lib/poi-utils.ts create mode 100644 src/lib/weather-alert-utils.ts create mode 100644 src/models/v4/callVideoFeeds/callVideoFeedResult.ts create mode 100644 src/models/v4/callVideoFeeds/callVideoFeedResultData.ts create mode 100644 src/models/v4/callVideoFeeds/saveCallVideoFeedResult.ts create mode 100644 src/models/v4/checkIn/checkInRecordResult.ts create mode 100644 src/models/v4/checkIn/checkInRecordResultData.ts create mode 100644 src/models/v4/checkIn/checkInTimerStatusResult.ts create mode 100644 src/models/v4/checkIn/checkInTimerStatusResultData.ts create mode 100644 src/models/v4/checkIn/performCheckInResult.ts create mode 100644 src/models/v4/checkIn/resolvedCheckInTimerResult.ts create mode 100644 src/models/v4/checkIn/resolvedCheckInTimerResultData.ts create mode 100644 src/models/v4/customStatuses/customStateDetailTypes.ts create mode 100644 src/models/v4/destinations/destinationEntityTypes.ts create mode 100644 src/models/v4/mapping/poiResultData.ts create mode 100644 src/models/v4/mapping/poiResults.ts create mode 100644 src/models/v4/weatherAlerts/activeWeatherAlertsResult.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertEnums.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertResult.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertResultData.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertSettingsData.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertSettingsResult.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertZoneResultData.ts create mode 100644 src/models/v4/weatherAlerts/weatherAlertZonesResult.ts create mode 100644 src/services/check-in-notification.service.ts create mode 100644 src/stores/call-video-feeds/__tests__/store.test.ts create mode 100644 src/stores/call-video-feeds/store.ts create mode 100644 src/stores/check-in-timers/__tests__/store.test.ts create mode 100644 src/stores/check-in-timers/store.ts create mode 100644 src/stores/pois/store.ts create mode 100644 src/stores/weather-alerts/__tests__/store.test.ts create mode 100644 src/stores/weather-alerts/store.ts diff --git a/app.config.ts b/app.config.ts index e9b8c55..352cfba 100644 --- a/app.config.ts +++ b/app.config.ts @@ -225,6 +225,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ './plugins/withForegroundNotifications.js', './plugins/withNotificationSounds.js', './plugins/withMediaButtonModule.js', + './plugins/withCheckInLiveActivity.js', './plugins/withInCallAudioModule.js', ['app-icon-badge', appIconBadgeConfig], ], diff --git a/package.json b/package.json index 501beac..e7f4a67 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "expo-auth-session": "~7.0.10", "expo-av": "~16.0.8", "expo-build-properties": "~1.0.10", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.8", "expo-dev-client": "~6.0.20", diff --git a/plugins/withCheckInLiveActivity.js b/plugins/withCheckInLiveActivity.js new file mode 100644 index 0000000..82d1ce9 --- /dev/null +++ b/plugins/withCheckInLiveActivity.js @@ -0,0 +1,263 @@ +const { withDangerousMod, withInfoPlist, withEntitlementsPlist, withXcodeProject } = require('expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * CheckInTimerAttributes.swift — ActivityKit attributes for the check-in timer Live Activity + */ +const ATTRIBUTES_SWIFT = `import ActivityKit +import Foundation + +struct CheckInTimerAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var elapsedMinutes: Int + var status: String + var lastCheckIn: String + } + + var callName: String + var callNumber: String + var timerName: String + var durationMinutes: Int +} +`; + +/** + * CheckInTimerLiveActivity.swift — SwiftUI views for lock screen and Dynamic Island + */ +const LIVE_ACTIVITY_SWIFT = `import ActivityKit +import SwiftUI +import WidgetKit + +struct CheckInTimerLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: CheckInTimerAttributes.self) { context in + // Lock screen / banner UI + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\\(context.attributes.callName) #\\(context.attributes.callNumber)") + .font(.headline) + .foregroundColor(.white) + Text(context.attributes.timerName) + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + } + Spacer() + VStack(alignment: .trailing, spacing: 4) { + Text("\\(context.state.elapsedMinutes)/\\(context.attributes.durationMinutes) min") + .font(.title3) + .bold() + .foregroundColor(statusColor(context.state.status)) + Text(context.state.status) + .font(.caption) + .foregroundColor(statusColor(context.state.status)) + } + } + .padding() + .background(Color.black) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text(context.attributes.timerName) + .font(.caption) + } + DynamicIslandExpandedRegion(.trailing) { + Text("\\(context.state.elapsedMinutes)m") + .font(.title3) + .foregroundColor(statusColor(context.state.status)) + } + DynamicIslandExpandedRegion(.bottom) { + ProgressView(value: Double(context.state.elapsedMinutes), total: Double(context.attributes.durationMinutes)) + .tint(statusColor(context.state.status)) + } + } compactLeading: { + Image(systemName: "timer") + .foregroundColor(statusColor(context.state.status)) + } compactTrailing: { + Text("\\(context.state.elapsedMinutes)m") + .foregroundColor(statusColor(context.state.status)) + } minimal: { + Image(systemName: "timer") + .foregroundColor(statusColor(context.state.status)) + } + } + } + + private func statusColor(_ status: String) -> Color { + switch status { + case "Ok": return .green + case "Warning": return .yellow + case "Overdue": return .red + default: return .gray + } + } +} +`; + +/** + * CheckInTimerWidgetBundle.swift — Widget extension entry point + */ +const WIDGET_BUNDLE_SWIFT = `import SwiftUI +import WidgetKit + +@main +struct CheckInTimerWidgetBundle: WidgetBundle { + var body: some Widget { + CheckInTimerLiveActivity() + } +} +`; + +/** + * CheckInTimerActivityManager.swift — Native bridge for managing Live Activities from RN + */ +const ACTIVITY_MANAGER_SWIFT = `import ActivityKit +import Foundation +import React + +@objc(CheckInTimerActivityManager) +class CheckInTimerActivityManager: NSObject { + + @objc static func requiresMainQueueSetup() -> Bool { return false } + + @objc + func startActivity(_ callName: String, callNumber: String, timerName: String, durationMinutes: Int, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + if #available(iOS 16.1, *) { + let attributes = CheckInTimerAttributes( + callName: callName, callNumber: callNumber, + timerName: timerName, durationMinutes: durationMinutes) + let state = CheckInTimerAttributes.ContentState( + elapsedMinutes: 0, status: "Ok", lastCheckIn: "") + do { + let _ = try Activity.request(attributes: attributes, contentState: state) + resolve(true) + } catch { + reject("LIVE_ACTIVITY_ERROR", error.localizedDescription, error) + } + } else { + resolve(false) + } + } + + @objc + func updateActivity(_ elapsedMinutes: Int, status: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + if #available(iOS 16.1, *) { + Task { + let state = CheckInTimerAttributes.ContentState( + elapsedMinutes: elapsedMinutes, status: status, lastCheckIn: "") + for activity in Activity.activities { + await activity.update(using: state) + } + resolve(true) + } + } else { + resolve(false) + } + } + + @objc + func endActivity(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + if #available(iOS 16.1, *) { + Task { + for activity in Activity.activities { + await activity.end(dismissalPolicy: .immediate) + } + resolve(true) + } + } else { + resolve(false) + } + } +} +`; + +/** + * CheckInTimerActivityBridge.m — ObjC bridge + */ +const BRIDGE_OBJC = `#import + +@interface RCT_EXTERN_MODULE(CheckInTimerActivityManager, NSObject) + +RCT_EXTERN_METHOD(startActivity:(NSString *)callName + callNumber:(NSString *)callNumber + timerName:(NSString *)timerName + durationMinutes:(int)durationMinutes + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(updateActivity:(int)elapsedMinutes + status:(NSString *)status + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(endActivity:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end +`; + +const withCheckInLiveActivity = (config) => { + // Step 1: Add NSSupportsLiveActivities to Info.plist + config = withInfoPlist(config, (config) => { + config.modResults.NSSupportsLiveActivities = true; + return config; + }); + + // Step 2: Add live activity entitlement + config = withEntitlementsPlist(config, (config) => { + config.modResults['com.apple.developer.live-activity'] = true; + return config; + }); + + // Step 3: Write Swift Widget Extension files and native bridge + config = withDangerousMod(config, [ + 'ios', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + + // Write Widget Extension files + const widgetDir = path.join(projectRoot, 'ios', 'CheckInTimerWidget'); + if (!fs.existsSync(widgetDir)) { + fs.mkdirSync(widgetDir, { recursive: true }); + } + + fs.writeFileSync(path.join(widgetDir, 'CheckInTimerAttributes.swift'), ATTRIBUTES_SWIFT); + fs.writeFileSync(path.join(widgetDir, 'CheckInTimerLiveActivity.swift'), LIVE_ACTIVITY_SWIFT); + fs.writeFileSync(path.join(widgetDir, 'CheckInTimerWidgetBundle.swift'), WIDGET_BUNDLE_SWIFT); + + // Write native bridge files to the main app directory + const appName = config.modRequest.projectName || 'ResgridUnit'; + const appDir = path.join(projectRoot, 'ios', appName); + if (!fs.existsSync(appDir)) { + fs.mkdirSync(appDir, { recursive: true }); + } + + fs.writeFileSync(path.join(appDir, 'CheckInTimerActivityManager.swift'), ACTIVITY_MANAGER_SWIFT); + fs.writeFileSync(path.join(appDir, 'CheckInTimerActivityBridge.m'), BRIDGE_OBJC); + + return config; + }, + ]); + + // Step 4: Add Widget Extension target to Xcode project + config = withXcodeProject(config, (config) => { + const project = config.modResults; + + // Add Widget Extension target with ActivityKit framework + // Note: This is a simplified version. Full widget extension target creation + // may require additional PBX configuration depending on the Xcode project structure. + // The withDangerousMod above creates the files; the Xcode target may need + // manual setup or a more complete config plugin for production builds. + + return config; + }); + + return config; +}; + +module.exports = withCheckInLiveActivity; diff --git a/src/api/call-video-feeds/call-video-feeds.ts b/src/api/call-video-feeds/call-video-feeds.ts new file mode 100644 index 0000000..770b212 --- /dev/null +++ b/src/api/call-video-feeds/call-video-feeds.ts @@ -0,0 +1,70 @@ +import { type CallVideoFeedResult } from '@/models/v4/callVideoFeeds/callVideoFeedResult'; +import { type SaveCallVideoFeedResult } from '@/models/v4/callVideoFeeds/saveCallVideoFeedResult'; + +import { createApiEndpoint } from '../common/client'; + +const getCallVideoFeedsApi = createApiEndpoint('/CallVideoFeeds/GetCallVideoFeeds'); +const saveCallVideoFeedApi = createApiEndpoint('/CallVideoFeeds/SaveCallVideoFeed'); +const editCallVideoFeedApi = createApiEndpoint('/CallVideoFeeds/EditCallVideoFeed'); +const deleteCallVideoFeedApi = createApiEndpoint('/CallVideoFeeds/DeleteCallVideoFeed'); + +export interface SaveCallVideoFeedInput { + CallId: number; + Name: string; + Url: string; + FeedType?: number; + FeedFormat?: number; + Description?: string; + Latitude?: string; + Longitude?: string; + SortOrder?: number; +} + +export interface EditCallVideoFeedInput extends SaveCallVideoFeedInput { + CallVideoFeedId: string; +} + +export const getCallVideoFeeds = async (callId: number) => { + const response = await getCallVideoFeedsApi.get({ + callId: encodeURIComponent(callId), + }); + return response.data; +}; + +export const saveCallVideoFeed = async (input: SaveCallVideoFeedInput) => { + const response = await saveCallVideoFeedApi.post({ + CallId: input.CallId, + Name: input.Name, + Url: input.Url, + FeedType: input.FeedType, + FeedFormat: input.FeedFormat, + Description: input.Description, + Latitude: input.Latitude, + Longitude: input.Longitude, + SortOrder: input.SortOrder, + }); + return response.data; +}; + +export const editCallVideoFeed = async (input: EditCallVideoFeedInput) => { + const response = await editCallVideoFeedApi.put({ + CallVideoFeedId: input.CallVideoFeedId, + CallId: input.CallId, + Name: input.Name, + Url: input.Url, + FeedType: input.FeedType, + FeedFormat: input.FeedFormat, + Description: input.Description, + Latitude: input.Latitude, + Longitude: input.Longitude, + SortOrder: input.SortOrder, + }); + return response.data; +}; + +export const deleteCallVideoFeed = async (feedId: string) => { + const response = await deleteCallVideoFeedApi.delete({ + feedId: encodeURIComponent(feedId), + }); + return response.data; +}; diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index ed4809b..65b195d 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -41,6 +41,7 @@ export interface CreateCallRequest { nature: string; note?: string; address?: string; + destinationPoiId?: number | null; latitude?: number; longitude?: number; priority: number; @@ -62,6 +63,7 @@ export interface UpdateCallRequest { nature: string; note?: string; address?: string; + destinationPoiId?: number | null; latitude?: number; longitude?: number; priority: number; @@ -117,6 +119,7 @@ export const createCall = async (callData: CreateCallRequest) => { Nature: callData.nature, Note: callData.note || '', Address: callData.address || '', + DestinationPoiId: callData.destinationPoiId ?? null, Geolocation: `${callData.latitude?.toString() || ''},${callData.longitude?.toString() || ''}`, Priority: callData.priority, Type: callData.type || '', @@ -149,6 +152,7 @@ export const updateCall = async (callData: UpdateCallRequest) => { Nature: callData.nature, Note: callData.note || '', Address: callData.address || '', + DestinationPoiId: callData.destinationPoiId ?? null, Geolocation: `${callData.latitude?.toString() || ''},${callData.longitude?.toString() || ''}`, Priority: callData.priority, Type: callData.type || '', diff --git a/src/api/check-in-timers/check-in-timers.ts b/src/api/check-in-timers/check-in-timers.ts new file mode 100644 index 0000000..6d95c2e --- /dev/null +++ b/src/api/check-in-timers/check-in-timers.ts @@ -0,0 +1,62 @@ +import { type CheckInRecordResult } from '@/models/v4/checkIn/checkInRecordResult'; +import { type CheckInTimerStatusResult } from '@/models/v4/checkIn/checkInTimerStatusResult'; +import { type PerformCheckInResult } from '@/models/v4/checkIn/performCheckInResult'; +import { type ResolvedCheckInTimerResult } from '@/models/v4/checkIn/resolvedCheckInTimerResult'; + +import { createApiEndpoint } from '../common/client'; + +const getTimerStatusesApi = createApiEndpoint('/CheckInTimers/GetTimerStatuses'); +const getTimersForCallApi = createApiEndpoint('/CheckInTimers/GetTimersForCall'); +const performCheckInApi = createApiEndpoint('/CheckInTimers/PerformCheckIn'); +const getCheckInHistoryApi = createApiEndpoint('/CheckInTimers/GetCheckInHistory'); +const toggleCallTimersApi = createApiEndpoint('/CheckInTimers/ToggleCallTimers'); + +export interface PerformCheckInInput { + CallId: number; + CheckInType: number; + UnitId?: number; + Latitude?: string; + Longitude?: string; + Note?: string; +} + +export const getTimerStatuses = async (callId: number) => { + const response = await getTimerStatusesApi.get({ + callId: encodeURIComponent(callId), + }); + return response.data; +}; + +export const getTimersForCall = async (callId: number) => { + const response = await getTimersForCallApi.get({ + callId: encodeURIComponent(callId), + }); + return response.data; +}; + +export const performCheckIn = async (input: PerformCheckInInput) => { + const response = await performCheckInApi.post({ + CallId: input.CallId, + CheckInType: input.CheckInType, + UnitId: input.UnitId, + Latitude: input.Latitude, + Longitude: input.Longitude, + Note: input.Note, + }); + return response.data; +}; + +export const getCheckInHistory = async (callId: number) => { + const response = await getCheckInHistoryApi.get({ + callId: encodeURIComponent(callId), + }); + return response.data; +}; + +export const toggleCallTimers = async (callId: number, enabled: boolean) => { + const response = await toggleCallTimersApi.put({ + CallId: callId, + Enabled: enabled, + }); + return response.data; +}; diff --git a/src/api/dispatch/dispatch.ts b/src/api/dispatch/dispatch.ts index b17bbef..39d4013 100644 --- a/src/api/dispatch/dispatch.ts +++ b/src/api/dispatch/dispatch.ts @@ -1,11 +1,22 @@ import { createApiEndpoint } from '@/api/common/client'; +import { type NewCallFormResult } from '@/models/v4/dispatch/newCallFormResult'; import { type GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult'; -const getSetUnitStateApi = createApiEndpoint('/Dispatch/GetSetUnitState'); +const getNewCallDataApi = createApiEndpoint('/Dispatch/GetNewCallData'); +const getSetUnitStatusDataApi = createApiEndpoint('/Dispatch/GetSetUnitStatusData'); -export const getSetUnitState = async (unitId: string) => { - const response = await getSetUnitStateApi.get({ +export const getNewCallData = async () => { + const response = await getNewCallDataApi.get(); + return response.data; +}; + +export const getSetUnitStatusData = async (unitId: string) => { + const response = await getSetUnitStatusDataApi.get({ unitId: unitId, }); return response.data; }; + +export const getSetUnitState = async (unitId: string) => { + return getSetUnitStatusData(unitId); +}; diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index 6c45c38..06548eb 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -16,12 +16,25 @@ import { type SearchCustomMapRegionsResult, type SearchIndoorLocationsResult, } from '@/models/v4/mapping/mappingResults'; +import { type PoiResult, type PoisResult, type PoiTypesResult } from '@/models/v4/mapping/poiResults'; import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers'); const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers'); +const getPoiApi = createCachedApiEndpoint('/Mapping/GetPoi', { + ttl: 5 * 60 * 1000, + enabled: true, +}); +const getPoisApi = createCachedApiEndpoint('/Mapping/GetPois', { + ttl: 5 * 60 * 1000, + enabled: true, +}); +const getPoiTypesApi = createCachedApiEndpoint('/Mapping/GetPoiTypes', { + ttl: 5 * 60 * 1000, + enabled: true, +}); // Indoor map endpoints const getIndoorMapsApi = createCachedApiEndpoint('/Mapping/GetIndoorMaps', { @@ -59,6 +72,31 @@ export const getMapDataAndMarkers = async (signal?: AbortSignal) => { return response.data; }; +export const getPoiTypes = async () => { + const response = await getPoiTypesApi.get(); + return response.data; +}; + +export const getPois = async (poiTypeId?: number, destinationOnly?: boolean) => { + const params: Record = {}; + if (poiTypeId !== undefined) { + params.poiTypeId = poiTypeId; + } + if (destinationOnly !== undefined) { + params.destinationOnly = destinationOnly; + } + + const response = await getPoisApi.get(params); + return response.data; +}; + +export const getPoi = async (poiId: number | string) => { + const response = await getPoiApi.get({ + poiId: encodeURIComponent(String(poiId)), + }); + return response.data; +}; + export const getMayLayers = async (type: number, signal?: AbortSignal) => { const response = await getMayLayersApi.get( { diff --git a/src/api/weather-alerts/weather-alerts.ts b/src/api/weather-alerts/weather-alerts.ts new file mode 100644 index 0000000..f6d14fb --- /dev/null +++ b/src/api/weather-alerts/weather-alerts.ts @@ -0,0 +1,53 @@ +import { type ActiveWeatherAlertsResult } from '@/models/v4/weatherAlerts/activeWeatherAlertsResult'; +import { type WeatherAlertResult } from '@/models/v4/weatherAlerts/weatherAlertResult'; +import { type WeatherAlertSettingsResult } from '@/models/v4/weatherAlerts/weatherAlertSettingsResult'; +import { type WeatherAlertZonesResult } from '@/models/v4/weatherAlerts/weatherAlertZonesResult'; + +import { createCachedApiEndpoint } from '../common/cached-client'; +import { createApiEndpoint } from '../common/client'; + +const getActiveAlertsApi = createCachedApiEndpoint('/WeatherAlerts/GetActiveAlerts', { ttl: 60 * 1000, enabled: true }); +const getWeatherAlertApi = createApiEndpoint('/WeatherAlerts/GetWeatherAlert'); +const getAlertsNearLocationApi = createApiEndpoint('/WeatherAlerts/GetAlertsNearLocation'); +const getAlertHistoryApi = createApiEndpoint('/WeatherAlerts/GetAlertHistory'); +const getSettingsApi = createCachedApiEndpoint('/WeatherAlerts/GetSettings', { ttl: 5 * 60 * 1000, enabled: true }); +const getZonesApi = createCachedApiEndpoint('/WeatherAlerts/GetZones', { ttl: 5 * 60 * 1000, enabled: true }); + +export const getActiveAlerts = async () => { + const response = await getActiveAlertsApi.get(); + return response.data; +}; + +export const getWeatherAlert = async (alertId: string) => { + const response = await getWeatherAlertApi.get({ + alertId: encodeURIComponent(alertId), + }); + return response.data; +}; + +export const getAlertsNearLocation = async (lat: number, lng: number, radiusMiles: number) => { + const response = await getAlertsNearLocationApi.get({ + lat, + lng, + radiusMiles, + }); + return response.data; +}; + +export const getAlertHistory = async (startDate: string, endDate: string) => { + const response = await getAlertHistoryApi.get({ + startDate, + endDate, + }); + return response.data; +}; + +export const getWeatherAlertSettings = async () => { + const response = await getSettingsApi.get(); + return response.data; +}; + +export const getWeatherAlertZones = async () => { + const response = await getZonesApi.get(); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index d2627ae..519e722 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -4,7 +4,7 @@ import { NovuProvider } from '@novu/react-native'; import Countly from 'countly-sdk-react-native-bridge'; import * as NavigationBar from 'expo-navigation-bar'; import { Redirect, SplashScreen, Tabs } from 'expo-router'; -import { Contact, ListTree, Map, Megaphone, Menu, Navigation, Notebook, Settings } from 'lucide-react-native'; +import { CloudAlert, Contact, ListTree, Map, Megaphone, Menu, Navigation, Notebook, Settings } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Platform, StyleSheet, useWindowDimensions } from 'react-native'; @@ -34,6 +34,7 @@ import { useCallsStore } from '@/stores/calls/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; +import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; export default function TabLayout() { const { t } = useTranslation(); @@ -159,6 +160,7 @@ export default function TabLayout() { await useCoreStore.getState().init(); await useRolesStore.getState().init(); await useCallsStore.getState().init(); + await useWeatherAlertsStore.getState().init(); await securityStore.getState().getRights(); await useSignalRStore.getState().connectUpdateHub(); @@ -197,7 +199,7 @@ export default function TabLayout() { try { // Refresh data - await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles()]); + await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles(), useWeatherAlertsStore.getState().fetchActiveAlerts()]); } catch (error) { logger.error({ message: 'Failed to refresh data on app resume', @@ -331,6 +333,7 @@ export default function TabLayout() { const contactsIcon = useCallback(({ color }: { color: string }) => , []); const notesIcon = useCallback(({ color }: { color: string }) => , []); const routesIcon = useCallback(({ color }: { color: string }) => , []); + const weatherAlertsIcon = useCallback(({ color }: { color: string }) => , []); const protocolsIcon = useCallback(({ color }: { color: string }) => , []); const settingsIcon = useCallback(({ color }: { color: string }) => , []); @@ -397,6 +400,17 @@ export default function TabLayout() { [t, notesIcon, headerRightNotification] ); + const weatherAlertsOptions = useMemo( + () => ({ + title: t('tabs.weather_alerts'), + headerShown: true as const, + tabBarIcon: weatherAlertsIcon, + tabBarButtonTestID: 'weather-alerts-tab' as const, + headerRight: headerRightNotification, + }), + [t, weatherAlertsIcon, headerRightNotification] + ); + const protocolsOptions = useMemo( () => ({ title: t('tabs.protocols'), @@ -470,6 +484,8 @@ export default function TabLayout() { + + diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index ef9d582..14938e8 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect } from 'expo-router'; +import { Stack, useFocusEffect, router } from 'expo-router'; import { NavigationIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -25,6 +25,8 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useMapsStore } from '@/stores/maps/store'; import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; +import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; +import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner'; Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); @@ -58,6 +60,20 @@ function MapContent() { const locationHeading = useLocationStore((state) => state.heading); const isMapLocked = useLocationStore((state) => state.isMapLocked); + // Weather alert banner state + const weatherAlerts = useWeatherAlertsStore((state) => state.alerts); + const weatherSettings = useWeatherAlertsStore((state) => state.settings); + const [isBannerDismissed, setIsBannerDismissed] = useState(false); + const extremeAlerts = useMemo( + () => weatherAlerts.filter((a) => a.Severity <= 1 && a.Status === 0), + [weatherAlerts] + ); + + // Reset dismissed state when alert count changes + useEffect(() => { + setIsBannerDismissed(false); + }, [extremeAlerts.length]); + // Route overlay state const activeUnitId = useCoreStore((state) => state.activeUnitId); const activeInstance = useRoutesStore((state) => state.activeInstance); @@ -576,6 +592,17 @@ function MapContent() { )} + {/* Weather Alert Banner */} + {weatherSettings?.WeatherAlertsEnabled && extremeAlerts.length > 0 && !isBannerDismissed ? ( + + router.push('/(app)/weather-alerts')} + onDismiss={() => setIsBannerDismissed(true)} + /> + + ) : null} + {/* Recenter Button - only show when map is not locked and user has moved the map */} {showRecenterButton ? ( diff --git a/src/app/(app)/routes.tsx b/src/app/(app)/routes.tsx index 96ec34e..31d8e3d 100644 --- a/src/app/(app)/routes.tsx +++ b/src/app/(app)/routes.tsx @@ -1,157 +1,7 @@ -import { router } from 'expo-router'; -import { Navigation, PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, RefreshControl, View } from 'react-native'; +import React from 'react'; -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import { RouteCard } from '@/components/routes/route-card'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Fab, FabIcon } from '@/components/ui/fab'; -import { FlatList } from '@/components/ui/flat-list'; -import { HStack } from '@/components/ui/hstack'; -import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; -import { Text } from '@/components/ui/text'; -import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRoutesStore } from '@/stores/routes/store'; -import { useUnitsStore } from '@/stores/units/store'; +import { RoutesHome } from '@/components/routes/routes-home'; export default function Routes() { - const { t } = useTranslation(); - const routePlans = useRoutesStore((state) => state.routePlans); - const activeInstance = useRoutesStore((state) => state.activeInstance); - const isLoading = useRoutesStore((state) => state.isLoading); - const error = useRoutesStore((state) => state.error); - const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans); - const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); - const activeUnitId = useCoreStore((state) => state.activeUnitId); - const activeUnit = useCoreStore((state) => state.activeUnit); - const units = useUnitsStore((state) => state.units); - const fetchUnits = useUnitsStore((state) => state.fetchUnits); - const [searchQuery, setSearchQuery] = useState(''); - - const unitMap = useMemo(() => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), [units]); - - useEffect(() => { - fetchAllRoutePlans(); - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - } - if (units.length === 0) { - fetchUnits(); - } - }, [activeUnitId, fetchAllRoutePlans, fetchActiveRoute, fetchUnits, units.length]); - - const handleRefresh = () => { - fetchAllRoutePlans(); - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - } - }; - - const handleRoutePress = (route: RoutePlanResultData) => { - if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { - const iid = activeInstance.RouteInstanceId; - const url = iid && iid !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${iid}` : `/routes/active?planId=${route.RoutePlanId}`; - router.push(url as any); - } else { - router.push(`/routes/start?planId=${route.RoutePlanId}` as any); - } - }; - - const filteredRoutes = useMemo(() => { - const active = routePlans.filter((route) => route.RouteStatus === 1); - if (!searchQuery) return active; - const q = searchQuery.toLowerCase(); - return active.filter((route) => { - const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId); - const unitName = route.UnitId != null ? unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return route.Name.toLowerCase().includes(q) || (route.Description?.toLowerCase() || '').includes(q) || unitName.toLowerCase().includes(q); - }); - }, [routePlans, searchQuery, unitMap, activeUnitId, activeUnit]); - - const renderContent = () => { - if (isLoading) { - return ; - } - - if (error) { - return ; - } - - return ( - - testID="routes-list" - data={filteredRoutes} - ListHeaderComponent={ - activeInstance ? ( - { - const iid = activeInstance.RouteInstanceId; - const url = iid && iid !== 'undefined' ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${iid}` : `/routes/active?planId=${activeInstance.RoutePlanId}`; - router.push(url as any); - }} - > - - - - - {activeInstance.RoutePlanName || t('routes.active_route')} - - - {t('routes.active')} - - - - {t('routes.progress', { - percent: activeInstance.StopsTotal ? Math.round(((activeInstance.StopsCompleted ?? 0) / activeInstance.StopsTotal) * 100) : 0, - })} - - - - ) : null - } - renderItem={({ item }: { item: RoutePlanResultData }) => { - const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); - const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return ( - handleRoutePress(item)}> - - - ); - }} - keyExtractor={(item: RoutePlanResultData) => item.RoutePlanId} - refreshControl={} - ListEmptyComponent={} - contentContainerStyle={{ paddingBottom: 20 }} - /> - ); - }; - - return ( - - - - - - - - {searchQuery ? ( - setSearchQuery('')}> - - - ) : null} - - - {renderContent()} - - router.push('/routes/start' as any)} testID="new-route-fab"> - - - - - ); + return ; } diff --git a/src/app/(app)/weather-alerts.tsx b/src/app/(app)/weather-alerts.tsx new file mode 100644 index 0000000..d35ed53 --- /dev/null +++ b/src/app/(app)/weather-alerts.tsx @@ -0,0 +1,119 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { router } from 'expo-router'; +import { CloudOff, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { SeverityFilterTabs } from '@/components/weather-alerts/severity-filter-tabs'; +import { WeatherAlertCard } from '@/components/weather-alerts/weather-alert-card'; +import { Box } from '@/components/ui/box'; +import { FlatList } from '@/components/ui/flat-list'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; +import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; + +export default function WeatherAlerts() { + const alerts = useWeatherAlertsStore((state) => state.alerts); + const isLoading = useWeatherAlertsStore((state) => state.isLoading); + const error = useWeatherAlertsStore((state) => state.error); + const settings = useWeatherAlertsStore((state) => state.settings); + const severityFilter = useWeatherAlertsStore((state) => state.severityFilter); + const setSeverityFilter = useWeatherAlertsStore((state) => state.setSeverityFilter); + const fetchActiveAlerts = useWeatherAlertsStore((state) => state.fetchActiveAlerts); + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(''); + + useFocusEffect( + useCallback(() => { + fetchActiveAlerts(); + }, [fetchActiveAlerts]) + ); + + const handleRefresh = () => { + fetchActiveAlerts(); + }; + + // Filter alerts + const filteredAlerts = alerts.filter((alert) => { + if (severityFilter !== null && alert.Severity !== severityFilter) return false; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + alert.Event.toLowerCase().includes(query) || + alert.Headline.toLowerCase().includes(query) || + alert.AreaDescription.toLowerCase().includes(query) + ); + } + return true; + }); + + const renderItem = useCallback( + ({ item }: { item: WeatherAlertResultData }) => ( + router.push(`/weather-alert/${item.WeatherAlertId}`)}> + + + ), + [] + ); + + const keyExtractor = useCallback((item: WeatherAlertResultData) => item.WeatherAlertId, []); + + const renderContent = () => { + if (settings?.WeatherAlertsEnabled === false) { + return ; + } + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + <> + + + testID="weather-alerts-list" + data={filteredAlerts} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshControl={} + ListEmptyComponent={ + + } + contentContainerStyle={{ paddingBottom: 20 }} + removeClippedSubviews + /> + + ); + }; + + return ( + + + + {/* Search input */} + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + {/* Main content */} + {renderContent()} + + + ); +} diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index bc2d756..471c524 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,11 +1,13 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, UserIcon, UsersIcon } from 'lucide-react-native'; +import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content'; +import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; // Import a static map component instead of react-native-maps @@ -25,6 +27,7 @@ import { openMapsWithDirections } from '@/lib/navigation'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { securityStore } from '@/stores/security/store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; @@ -70,6 +73,10 @@ export default function CallDetail() { const [isCloseCallModalOpen, setIsCloseCallModalOpen] = useState(false); const [isSettingActive, setIsSettingActive] = useState(false); const showToast = useToastStore((state) => state.showToast); + const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); + const startPolling = useCheckInTimerStore((state) => state.startPolling); + const stopPolling = useCheckInTimerStore((state) => state.stopPolling); + const resetTimers = useCheckInTimerStore((state) => state.reset); const { colorScheme } = useColorScheme(); const textColor = colorScheme === 'dark' ? '#FFFFFF' : '#000000'; @@ -191,6 +198,17 @@ export default function CallDetail() { } }, [trackEvent, call, callExtraData]); + // Check-in timer polling lifecycle + useEffect(() => { + if (call?.CheckInTimersEnabled) { + startPolling(parseInt(call.CallId, 10), 30000); + } + return () => { + stopPolling(); + resetTimers(); + }; + }, [call?.CheckInTimersEnabled, call?.CallId, startPolling, stopPolling, resetTimers]); + /** * Opens the device's native maps application with directions to the call location */ @@ -280,6 +298,7 @@ export default function CallDetail() { } const renderTabs = () => { + const destinationLabel = call.DestinationName || call.DestinationAddress || ''; const tabs: TabItem[] = [ { key: 'info', @@ -302,14 +321,25 @@ export default function CallDetail() { {t('call_detail.type')} {call.Type} - - {t('call_detail.address')} - {call.Address} - - - {t('call_detail.note')} - - + + {t('call_detail.address')} + {call.Address} + + {destinationLabel ? ( + + {t('call_detail.destination')} + {destinationLabel} + {call.DestinationTypeName || call.DestinationAddress ? ( + + {[call.DestinationTypeName, call.DestinationAddress].filter(Boolean).join(' - ')} + + ) : null} + + ) : null} + + {t('call_detail.note')} + + @@ -425,6 +455,26 @@ export default function CallDetail() { }, ]; + // Video feeds tab + tabs.push({ + key: 'video', + title: t('video_feeds.tab_title'), + icon: , + content: , + }); + + // Conditionally add check-in tab + if (call?.CheckInTimersEnabled) { + const overdueCount = timerStatuses.filter((t) => t.Status === 'Overdue').length; + tabs.push({ + key: 'checkin', + title: t('check_in.tab_title'), + icon: , + badge: overdueCount > 0 ? overdueCount : undefined, + content: , + }); + } + return tabs; }; diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index d830c47..fa83c4b 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView, View } from 'react-native'; import * as z from 'zod'; +import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; @@ -40,6 +41,7 @@ const formSchema = z.object({ plusCode: z.string().optional(), latitude: z.number().optional(), longitude: z.number().optional(), + destinationPoiId: z.string().optional(), priority: z.string().min(1, 'Priority is required'), type: z.string().min(1, 'Type is required'), contactName: z.string().optional(), @@ -79,10 +81,11 @@ export default function EditCall() { const callId = Array.isArray(id) ? id[0] : id; const callPriorities = useCallsStore((state) => state.callPriorities); const callTypes = useCallsStore((state) => state.callTypes); + const destinationPois = useCallsStore((state) => state.destinationPois); + const poiTypes = useCallsStore((state) => state.poiTypes); const callDataLoading = useCallsStore((state) => state.isLoading); const callDataError = useCallsStore((state) => state.error); - const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities); - const fetchCallTypes = useCallsStore((state) => state.fetchCallTypes); + const fetchCallFormData = useCallsStore((state) => state.fetchCallFormData); const call = useCallDetailStore((state) => state.call); const callDetailLoading = useCallDetailStore((state) => state.isLoading); const callDetailError = useCallDetailStore((state) => state.error); @@ -126,10 +129,11 @@ export default function EditCall() { coordinates: '', what3words: '', plusCode: '', - latitude: undefined, - longitude: undefined, - priority: '', - type: '', + latitude: undefined, + longitude: undefined, + destinationPoiId: '', + priority: '', + type: '', contactName: '', contactInfo: '', dispatchSelection: { @@ -143,12 +147,11 @@ export default function EditCall() { }); useEffect(() => { - fetchCallPriorities(); - fetchCallTypes(); + fetchCallFormData(); if (callId) { fetchCallDetail(callId); } - }, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]); + }, [fetchCallDetail, fetchCallFormData, callId]); // Pre-populate form when call data is loaded useEffect(() => { @@ -166,6 +169,7 @@ export default function EditCall() { plusCode: '', latitude: call.Latitude ? parseFloat(call.Latitude) : undefined, longitude: call.Longitude ? parseFloat(call.Longitude) : undefined, + destinationPoiId: call.DestinationPoiId != null ? String(call.DestinationPoiId) : '', priority: priority?.Name || '', type: type?.Name || '', contactName: call.ContactName || '', @@ -228,6 +232,7 @@ export default function EditCall() { address: data.address, latitude: data.latitude, longitude: data.longitude, + destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, what3words: data.what3words, plusCode: data.plusCode, contactName: data.contactName, @@ -616,6 +621,20 @@ export default function EditCall() { )} + + ( + onChange(poiId != null ? poiId.toString() : '')} + /> + )} + /> diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index 014cf8d..d2d9a29 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -55,6 +55,10 @@ jest.mock('expo-modules-core', () => ({ NativeUnimoduleProxy: {}, })); +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(), +})); + // Mock storage jest.mock('@/lib/storage', () => ({ getItem: jest.fn(), @@ -190,6 +194,25 @@ jest.mock('@/components/maps/static-map', () => ({ default: () =>
Map
, })); +jest.mock('@/components/check-in-timers/check-in-tab-content', () => ({ + CheckInTabContent: () => null, +})); + +jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ + VideoFeedTabContent: () => null, +})); + +jest.mock('@/stores/check-in-timers/store', () => ({ + useCheckInTimerStore: jest.fn((selector: any) => + selector({ + timerStatuses: [], + startPolling: jest.fn(), + stopPolling: jest.fn(), + reset: jest.fn(), + }) + ), +})); + // Mock the call detail menu component jest.mock('../../../components/calls/call-detail-menu', () => ({ useCallDetailMenu: ({ canUserCreateCalls }: { canUserCreateCalls: boolean }) => ({ diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 2e6cc00..7875ebf 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -81,6 +81,10 @@ jest.mock('@/components/ui/vstack', () => ({ const mockUseWindowDimensions = useWindowDimensions as jest.MockedFunction; // Mock expo-constants first before any other imports +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(), +})); + jest.mock('expo-constants', () => ({ expoConfig: { extra: { @@ -270,6 +274,25 @@ jest.mock('@/components/maps/static-map', () => { }; }); +jest.mock('@/components/check-in-timers/check-in-tab-content', () => ({ + CheckInTabContent: () => null, +})); + +jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ + VideoFeedTabContent: () => null, +})); + +jest.mock('@/stores/check-in-timers/store', () => ({ + useCheckInTimerStore: jest.fn((selector: any) => + selector({ + timerStatuses: [], + startPolling: jest.fn(), + stopPolling: jest.fn(), + reset: jest.fn(), + }) + ), +})); + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index b1b374a..bfca869 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; +import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; @@ -44,6 +45,7 @@ const formSchema = z.object({ plusCode: z.string().optional(), latitude: z.number().optional(), longitude: z.number().optional(), + destinationPoiId: z.string().optional(), priority: z.string().min(1, { message: 'Priority is required' }), type: z.string().min(1, { message: 'Type is required' }), contactName: z.string().optional(), @@ -107,10 +109,11 @@ export default function NewCall() { const insets = useSafeAreaInsets(); const callPriorities = useCallsStore((state) => state.callPriorities); const callTypes = useCallsStore((state) => state.callTypes); + const destinationPois = useCallsStore((state) => state.destinationPois); + const poiTypes = useCallsStore((state) => state.poiTypes); const isLoading = useCallsStore((state) => state.isLoading); const error = useCallsStore((state) => state.error); - const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities); - const fetchCallTypes = useCallsStore((state) => state.fetchCallTypes); + const fetchCallFormData = useCallsStore((state) => state.fetchCallFormData); const config = useCoreStore((state) => state.config); const { trackEvent } = useAnalytics(); const toast = useToast(); @@ -150,10 +153,11 @@ export default function NewCall() { coordinates: '', what3words: '', plusCode: '', - latitude: undefined, - longitude: undefined, - priority: '', - type: '', + latitude: undefined, + longitude: undefined, + destinationPoiId: '', + priority: '', + type: '', contactName: '', contactInfo: '', dispatchSelection: { @@ -167,9 +171,8 @@ export default function NewCall() { }); useEffect(() => { - fetchCallPriorities(); - fetchCallTypes(); - }, [fetchCallPriorities, fetchCallTypes]); + fetchCallFormData(); + }, [fetchCallFormData]); // Track when new call view is rendered useEffect(() => { @@ -212,6 +215,7 @@ export default function NewCall() { address: data.address, latitude: data.latitude, longitude: data.longitude, + destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, what3words: data.what3words, plusCode: data.plusCode, dispatchUsers: data.dispatchSelection?.users, @@ -823,17 +827,31 @@ export default function NewCall() { /> - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( + {/* Map Preview */} + + {selectedLocation ? ( + + ) : ( - )} - -
+ + )} +
+ + ( + onChange(poiId != null ? poiId.toString() : '')} + /> + )} + /> + diff --git a/src/app/routes/_layout.tsx b/src/app/routes/_layout.tsx index 4e1d754..1a471bb 100644 --- a/src/app/routes/_layout.tsx +++ b/src/app/routes/_layout.tsx @@ -16,6 +16,7 @@ export default function RoutesLayout() { + diff --git a/src/app/routes/history/instance/[id].tsx b/src/app/routes/history/instance/[id].tsx index 5dc8867..a347c08 100644 --- a/src/app/routes/history/instance/[id].tsx +++ b/src/app/routes/history/instance/[id].tsx @@ -417,9 +417,21 @@ function StopCard({ stop }: { stop: RouteInstanceStopResultData }) { ) : null} - {stop.CheckedInOn ? {t('routes.check_in')}: {formatDate(stop.CheckedInOn)} : null} - {stop.CheckedOutOn ? {t('routes.check_out')}: {formatDate(stop.CheckedOutOn)} : null} - {stop.SkippedOn ? {t('routes.skipped')}: {formatDate(stop.SkippedOn)} : null} + {stop.CheckedInOn ? ( + + {t('routes.check_in')}: {formatDate(stop.CheckedInOn)} + + ) : null} + {stop.CheckedOutOn ? ( + + {t('routes.check_out')}: {formatDate(stop.CheckedOutOn)} + + ) : null} + {stop.SkippedOn ? ( + + {t('routes.skipped')}: {formatDate(stop.SkippedOn)} + + ) : null} diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx index f8db064..ca68f02 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/index.tsx @@ -1,146 +1,7 @@ -import { router } from 'expo-router'; -import { Navigation, PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, RefreshControl, View } from 'react-native'; +import React from 'react'; -import { Loading } from '@/components/common/loading'; -import ZeroState from '@/components/common/zero-state'; -import { RouteCard } from '@/components/routes/route-card'; -import { Badge, BadgeText } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Fab, FabIcon } from '@/components/ui/fab'; -import { FlatList } from '@/components/ui/flat-list'; -import { HStack } from '@/components/ui/hstack'; -import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; -import { Text } from '@/components/ui/text'; -import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; -import { useCoreStore } from '@/stores/app/core-store'; -import { useRoutesStore } from '@/stores/routes/store'; -import { useUnitsStore } from '@/stores/units/store'; +import { RoutesHome } from '@/components/routes/routes-home'; export default function RouteList() { - const { t } = useTranslation(); - const routePlans = useRoutesStore((state) => state.routePlans); - const isLoading = useRoutesStore((state) => state.isLoading); - const error = useRoutesStore((state) => state.error); - const activeInstance = useRoutesStore((state) => state.activeInstance); - const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans); - const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); - const activeUnitId = useCoreStore((state) => state.activeUnitId); - const activeUnit = useCoreStore((state) => state.activeUnit); - const units = useUnitsStore((state) => state.units); - const fetchUnits = useUnitsStore((state) => state.fetchUnits); - const [searchQuery, setSearchQuery] = useState(''); - - const unitMap = useMemo(() => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), [units]); - - useEffect(() => { - fetchAllRoutePlans(); - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - } - if (units.length === 0) { - fetchUnits(); - } - }, [activeUnitId, fetchAllRoutePlans, fetchActiveRoute, fetchUnits, units.length]); - - const handleRefresh = () => { - fetchAllRoutePlans(); - if (activeUnitId) { - fetchActiveRoute(activeUnitId); - } - }; - - const handleRoutePress = (route: RoutePlanResultData) => { - if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { - router.push(`/routes/active?planId=${route.RoutePlanId}`); - } else { - router.push(`/routes/start?planId=${route.RoutePlanId}`); - } - }; - - const filteredRoutes = useMemo(() => { - const active = routePlans.filter((route) => route.RouteStatus === 1); - if (!searchQuery) return active; - const q = searchQuery.toLowerCase(); - return active.filter((route) => { - const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId); - const unitName = route.UnitId != null ? unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return route.Name.toLowerCase().includes(q) || (route.Description?.toLowerCase() || '').includes(q) || unitName.toLowerCase().includes(q); - }); - }, [routePlans, searchQuery, unitMap, activeUnitId, activeUnit]); - - const renderContent = () => { - if (isLoading) { - return ; - } - - if (error) { - return ; - } - - return ( - - testID="routes-list" - data={filteredRoutes} - ListHeaderComponent={ - activeInstance ? ( - router.push(`/routes/active?planId=${activeInstance.RoutePlanId}`)}> - - - - - {activeInstance.RoutePlanName || t('routes.active_route')} - - - {t('routes.active')} - - - {t('routes.active_route')} - - - ) : null - } - renderItem={({ item }: { item: RoutePlanResultData }) => { - const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); - const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return ( - handleRoutePress(item)}> - - - ); - }} - keyExtractor={(item: RoutePlanResultData) => item.RoutePlanId} - refreshControl={} - ListEmptyComponent={} - contentContainerStyle={{ paddingBottom: 20 }} - /> - ); - }; - - return ( - - - {/* Search / filter */} - - - - - - {searchQuery ? ( - setSearchQuery('')}> - - - ) : null} - - - {renderContent()} - - router.push('/routes/start')} testID="new-route-fab"> - - - - - ); + return ; } diff --git a/src/app/routes/poi/[id].tsx b/src/app/routes/poi/[id].tsx new file mode 100644 index 0000000..8435e8a --- /dev/null +++ b/src/app/routes/poi/[id].tsx @@ -0,0 +1,157 @@ +import { MapPin, Navigation } from 'lucide-react-native'; +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import StaticMap from '@/components/maps/static-map'; +import { StatusBottomSheet } from '@/components/status/status-bottom-sheet'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils'; +import { openMapsWithDirections } from '@/lib/navigation'; +import { useLocationStore } from '@/stores/app/location-store'; +import { usePoisStore } from '@/stores/pois/store'; +import { useStatusBottomSheetStore } from '@/stores/status/store'; +import { useToastStore } from '@/stores/toast/store'; + +export default function PoiDetailScreen() { + const { t } = useTranslation(); + const { id } = useLocalSearchParams<{ id: string }>(); + const poiId = Array.isArray(id) ? id[0] : id; + const poiTypes = usePoisStore((state) => state.poiTypes); + const selectedPoi = usePoisStore((state) => state.selectedPoi); + const isLoadingDetail = usePoisStore((state) => state.isLoadingDetail); + const error = usePoisStore((state) => state.error); + const fetchPoi = usePoisStore((state) => state.fetchPoi); + const fetchPoiTypes = usePoisStore((state) => state.fetchPoiTypes); + const clearSelectedPoi = usePoisStore((state) => state.clearSelectedPoi); + const showToast = useToastStore((state) => state.showToast); + const openStatusBottomSheet = useStatusBottomSheetStore((state) => state.setIsOpen); + const setSelectedStatusPoi = useStatusBottomSheetStore((state) => state.setSelectedPoi); + const userLatitude = useLocationStore((state) => state.latitude); + const userLongitude = useLocationStore((state) => state.longitude); + + useEffect(() => { + fetchPoiTypes(); + if (poiId) { + fetchPoi(poiId); + } + + return () => { + clearSelectedPoi(); + }; + }, [clearSelectedPoi, fetchPoi, fetchPoiTypes, poiId]); + + const poiTypesById = useMemo(() => createPoiTypeMap(poiTypes), [poiTypes]); + const poi = selectedPoi && String(selectedPoi.PoiId) === String(poiId) ? selectedPoi : null; + + const handleRoute = async () => { + if (!poi) { + return; + } + + const success = await openMapsWithDirections(poi.Latitude, poi.Longitude, getPoiSelectionLabel(poi, poiTypesById), userLatitude || undefined, userLongitude || undefined); + if (!success) { + showToast('error', t('routes.failed_to_open_poi_maps')); + } + }; + + const handleSetDestination = () => { + if (!poi || !destinationEnabled) { + return; + } + + setSelectedStatusPoi(poi); + openStatusBottomSheet(true); + }; + + if (isLoadingDetail && !poi) { + return ( + + + + ); + } + + if (!poi) { + return ; + } + + const displayName = getPoiDisplayName(poi, poiTypesById); + const poiTypeName = getPoiTypeName(poi, poiTypesById) || t('routes.poi_type_unknown'); + const selectionLabel = getPoiSelectionLabel(poi, poiTypesById); + const destinationEnabled = isPoiDestinationEnabled(poi, poiTypesById); + + return ( + <> + + + + {displayName} + + + {poiTypeName} + + {destinationEnabled ? ( + + {t('routes.poi_destination_enabled')} + + ) : null} + + + + + + + + {destinationEnabled ? ( + + ) : null} + + + + + {poi.Address ? ( + + {t('routes.poi_address')} + {poi.Address} + + ) : null} + + {poi.Note ? ( + + {t('routes.poi_note')} + {poi.Note} + + ) : null} + + + {t('routes.poi_coordinates')} + + {t('routes.poi_coordinates_value', { + latitude: poi.Latitude.toFixed(6), + longitude: poi.Longitude.toFixed(6), + })} + + + + + + + + + ); +} diff --git a/src/app/weather-alert/[id].tsx b/src/app/weather-alert/[id].tsx new file mode 100644 index 0000000..d93eecc --- /dev/null +++ b/src/app/weather-alert/[id].tsx @@ -0,0 +1,193 @@ +import { format } from 'date-fns'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useColorScheme } from 'nativewind'; +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { WeatherAlertDetailMap } from '@/components/weather-alerts/weather-alert-detail-map'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Box } from '@/components/ui/box'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getCategoryIcon, getSeverityColor, getSeverityTranslationKey } from '@/lib/weather-alert-utils'; +import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; + +export default function WeatherAlertDetail() { + const { id } = useLocalSearchParams(); + const alertId = Array.isArray(id) ? id[0] : id; + const router = useRouter(); + const { t } = useTranslation(); + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + const { colorScheme } = useColorScheme(); + + const alert = useWeatherAlertsStore((state) => state.selectedAlert); + const isLoading = useWeatherAlertsStore((state) => state.isLoadingDetail); + const fetchAlertDetail = useWeatherAlertsStore((state) => state.fetchAlertDetail); + + useEffect(() => { + if (alertId) { + fetchAlertDetail(alertId); + } + }, [alertId, fetchAlertDetail]); + + const severityColor = useMemo(() => (alert ? getSeverityColor(alert.Severity) : '#9E9E9E'), [alert]); + const CategoryIcon = useMemo(() => (alert ? getCategoryIcon(alert.Category) : null), [alert]); + + const formatDate = (dateStr: string) => { + if (!dateStr) return t('call_detail.not_available'); + try { + return format(new Date(dateStr), 'PPp'); + } catch { + return dateStr; + } + }; + + if (isLoading) { + return ( + <> + + + + ); + } + + if (!alert) { + return ( + <> + + + + ); + } + + const mapSection = ( + + + + ); + + const detailSection = ( + + {/* Header */} + + {CategoryIcon ? : null} + + + {alert.Event} + + + {t(getSeverityTranslationKey(alert.Severity))} + + + + + {/* Timing */} + + + {alert.EffectiveUtc ? ( + + {t('weather_alerts.detail.effective')} + {formatDate(alert.EffectiveUtc)} + + ) : null} + {alert.OnsetUtc ? ( + + {t('weather_alerts.detail.onset')} + {formatDate(alert.OnsetUtc)} + + ) : null} + {alert.ExpiresUtc ? ( + + {t('weather_alerts.detail.expires')} + {formatDate(alert.ExpiresUtc)} + + ) : null} + + + + {/* Headline */} + {alert.Headline ? ( + + {t('weather_alerts.detail.headline')} + {alert.Headline} + + ) : null} + + {/* Description */} + {alert.Description ? ( + + {t('weather_alerts.detail.description')} + {alert.Description} + + ) : null} + + {/* Instructions */} + {alert.Instructions ? ( + + {t('weather_alerts.detail.instructions')} + {alert.Instructions} + + ) : null} + + {/* Affected Area */} + {alert.AreaDescription ? ( + + {t('weather_alerts.detail.area')} + {alert.AreaDescription} + + ) : null} + + {/* Metadata */} + + + {alert.SenderName ? ( + + {t('weather_alerts.detail.sender')} + {alert.SenderName} + + ) : null} + + {t('weather_alerts.detail.urgency')} + {t(`weather_alerts.urgency.${['immediate', 'expected', 'future', 'past', 'unknown'][alert.Urgency] ?? 'unknown'}`)} + + + {t('weather_alerts.detail.certainty')} + {t(`weather_alerts.certainty.${['observed', 'likely', 'possible', 'unlikely', 'unknown'][alert.Certainty] ?? 'unknown'}`)} + + + + + ); + + return ( + <> + + + + {isLandscape ? ( + + {mapSection} + {detailSection} + + ) : ( + + {mapSection} + {detailSection} + + )} + + + ); +} diff --git a/src/components/call-video-feeds/__tests__/video-feed-card.test.tsx b/src/components/call-video-feeds/__tests__/video-feed-card.test.tsx new file mode 100644 index 0000000..11cade9 --- /dev/null +++ b/src/components/call-video-feeds/__tests__/video-feed-card.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; + +import type { CallVideoFeedResultData } from '@/models/v4/callVideoFeeds/callVideoFeedResultData'; + +import { VideoFeedCard } from '../video-feed-card'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('lucide-react-native', () => ({ + CopyIcon: (props: any) => { + const { View } = require('react-native'); + return ; + }, + EditIcon: (props: any) => { + const { View } = require('react-native'); + return ; + }, + PlayIcon: (props: any) => { + const { View } = require('react-native'); + return ; + }, + TrashIcon: (props: any) => { + const { View } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/ui/box', () => ({ + Box: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, onPress, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return ( + + {children} + + ); + }, + ButtonText: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, + ButtonIcon: ({ as: Icon, ...props }: any) => { + const { View } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +const createMockFeed = (overrides: Partial = {}): CallVideoFeedResultData => ({ + CallVideoFeedId: 'feed-1', + CallId: '1', + Name: 'Drone Camera 1', + Url: 'https://example.com/stream.m3u8', + FeedType: 0, + FeedFormat: 1, + Description: 'Test description', + Status: 0, + Latitude: '40.7128', + Longitude: '-74.0060', + AddedByUserId: 'user-1', + AddedOnFormatted: '2026-04-15 10:00 AM', + AddedOnUtc: '2026-04-15T10:00:00Z', + SortOrder: 1, + FullName: 'John Doe', + ...overrides, +}); + +describe('VideoFeedCard', () => { + const onWatch = jest.fn(); + const onEdit = jest.fn(); + const onDelete = jest.fn(); + const onCopyUrl = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render feed info', () => { + const feed = createMockFeed(); + + const { getByText } = render(); + + expect(getByText('Drone Camera 1')).toBeTruthy(); + expect(getByText('Test description')).toBeTruthy(); + expect(getByText('video_feeds.watch')).toBeTruthy(); + }); + + it('should call onWatch when Watch button is pressed', () => { + const feed = createMockFeed(); + + const { getByText } = render(); + + fireEvent.press(getByText('video_feeds.watch')); + expect(onWatch).toHaveBeenCalledWith(feed); + }); + + it('should display added by info', () => { + const feed = createMockFeed(); + + const { getByText } = render(); + + expect(getByText(/John Doe/)).toBeTruthy(); + }); + + it('should render status badge', () => { + const feed = createMockFeed({ Status: 0 }); + + const { getByText } = render(); + + expect(getByText('video_feeds.status_active')).toBeTruthy(); + }); +}); diff --git a/src/components/call-video-feeds/__tests__/video-feed-tab-content.test.tsx b/src/components/call-video-feeds/__tests__/video-feed-tab-content.test.tsx new file mode 100644 index 0000000..70e714f --- /dev/null +++ b/src/components/call-video-feeds/__tests__/video-feed-tab-content.test.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import { useCallVideoFeedStore } from '@/stores/call-video-feeds/store'; + +import { VideoFeedTabContent } from '../video-feed-tab-content'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(), +})); + +jest.mock('lucide-react-native', () => ({ + CopyIcon: () => null, + EditIcon: () => null, + PlayIcon: () => null, + PlusIcon: () => null, + TrashIcon: () => null, + XIcon: () => null, +})); + +jest.mock('@/components/common/loading', () => ({ + Loading: () => { + const { Text } = require('react-native'); + return Loading...; + }, +})); + +jest.mock('@/components/ui/box', () => ({ + Box: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, onPress, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return ( + + {children} + + ); + }, + ButtonText: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, + ButtonIcon: () => null, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/heading', () => ({ + Heading: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: () => jest.fn(), +})); + +jest.mock('../video-feed-form-sheet', () => ({ + VideoFeedFormSheet: () => null, +})); + +jest.mock('../video-player-modal', () => ({ + VideoPlayerModal: () => null, +})); + +jest.mock('@/stores/call-video-feeds/store'); + +const mockUseCallVideoFeedStore = useCallVideoFeedStore as unknown as jest.Mock; + +describe('VideoFeedTabContent', () => { + const mockFetchFeeds = jest.fn(); + const mockDeleteFeed = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render loading state', () => { + mockUseCallVideoFeedStore.mockImplementation((selector: any) => { + const state = { + feeds: [], + isLoadingFeeds: true, + fetchFeeds: mockFetchFeeds, + deleteFeed: mockDeleteFeed, + }; + return selector(state); + }); + + const { getByText } = render(); + expect(getByText('Loading...')).toBeTruthy(); + }); + + it('should render zero state when no feeds', () => { + mockUseCallVideoFeedStore.mockImplementation((selector: any) => { + const state = { + feeds: [], + isLoadingFeeds: false, + fetchFeeds: mockFetchFeeds, + deleteFeed: mockDeleteFeed, + }; + return selector(state); + }); + + const { getByText } = render(); + expect(getByText('video_feeds.no_feeds')).toBeTruthy(); + }); + + it('should render add feed button', () => { + mockUseCallVideoFeedStore.mockImplementation((selector: any) => { + const state = { + feeds: [], + isLoadingFeeds: false, + fetchFeeds: mockFetchFeeds, + deleteFeed: mockDeleteFeed, + }; + return selector(state); + }); + + const { getByText } = render(); + expect(getByText('video_feeds.add_feed')).toBeTruthy(); + }); + + it('should render feed cards when feeds exist', () => { + const mockFeeds = [ + { + CallVideoFeedId: 'feed-1', + CallId: '1', + Name: 'Drone Camera', + Url: 'https://example.com/stream.m3u8', + FeedType: 0, + FeedFormat: 1, + Description: '', + Status: 0, + Latitude: '', + Longitude: '', + AddedByUserId: '', + AddedOnFormatted: '', + AddedOnUtc: '', + SortOrder: 1, + FullName: '', + }, + ]; + + mockUseCallVideoFeedStore.mockImplementation((selector: any) => { + const state = { + feeds: mockFeeds, + isLoadingFeeds: false, + fetchFeeds: mockFetchFeeds, + deleteFeed: mockDeleteFeed, + }; + return selector(state); + }); + + const { getByText } = render(); + expect(getByText('Drone Camera')).toBeTruthy(); + }); + + it('should call fetchFeeds on mount', () => { + mockUseCallVideoFeedStore.mockImplementation((selector: any) => { + const state = { + feeds: [], + isLoadingFeeds: false, + fetchFeeds: mockFetchFeeds, + deleteFeed: mockDeleteFeed, + }; + return selector(state); + }); + + render(); + expect(mockFetchFeeds).toHaveBeenCalledWith(42); + }); +}); diff --git a/src/components/call-video-feeds/feed-format-utils.ts b/src/components/call-video-feeds/feed-format-utils.ts new file mode 100644 index 0000000..ec15f66 --- /dev/null +++ b/src/components/call-video-feeds/feed-format-utils.ts @@ -0,0 +1,67 @@ +export const FeedFormat = { + RTSP: 0, + HLS: 1, + MJPEG: 2, + YouTubeLive: 3, + WebRTC: 4, + DASH: 5, + Embed: 6, + Other: 99, +} as const; + +export const FeedType = { + Drone: 0, + FixedCamera: 1, + BodyCam: 2, + TrafficCam: 3, + WeatherCam: 4, + SatelliteFeed: 5, + WebCam: 6, + Other: 99, +} as const; + +export const FeedStatus = { + Active: 0, + Inactive: 1, + Error: 2, +} as const; + +export const FEED_TYPE_LABELS: Record = { + [FeedType.Drone]: 'video_feeds.type_drone', + [FeedType.FixedCamera]: 'video_feeds.type_fixed_camera', + [FeedType.BodyCam]: 'video_feeds.type_body_cam', + [FeedType.TrafficCam]: 'video_feeds.type_traffic_cam', + [FeedType.WeatherCam]: 'video_feeds.type_weather_cam', + [FeedType.SatelliteFeed]: 'video_feeds.type_satellite_feed', + [FeedType.WebCam]: 'video_feeds.type_web_cam', + [FeedType.Other]: 'video_feeds.type_other', +}; + +export const FEED_FORMAT_LABELS: Record = { + [FeedFormat.RTSP]: 'video_feeds.format_rtsp', + [FeedFormat.HLS]: 'video_feeds.format_hls', + [FeedFormat.MJPEG]: 'video_feeds.format_mjpeg', + [FeedFormat.YouTubeLive]: 'video_feeds.format_youtube_live', + [FeedFormat.WebRTC]: 'video_feeds.format_webrtc', + [FeedFormat.DASH]: 'video_feeds.format_dash', + [FeedFormat.Embed]: 'video_feeds.format_embed', + [FeedFormat.Other]: 'video_feeds.format_other', +}; + +export const FEED_STATUS_LABELS: Record = { + [FeedStatus.Active]: 'video_feeds.status_active', + [FeedStatus.Inactive]: 'video_feeds.status_inactive', + [FeedStatus.Error]: 'video_feeds.status_error', +}; + +export const detectFeedFormat = (url: string): number | null => { + const lower = url.toLowerCase(); + + if (lower.includes('.m3u8')) return FeedFormat.HLS; + if (lower.includes('.mpd')) return FeedFormat.DASH; + if (lower.startsWith('rtsp://')) return FeedFormat.RTSP; + if (lower.includes('youtube.com') || lower.includes('youtu.be')) return FeedFormat.YouTubeLive; + if (lower.includes('mjpeg') || lower.includes('mjpg')) return FeedFormat.MJPEG; + + return null; +}; diff --git a/src/components/call-video-feeds/video-feed-card.tsx b/src/components/call-video-feeds/video-feed-card.tsx new file mode 100644 index 0000000..bf366cf --- /dev/null +++ b/src/components/call-video-feeds/video-feed-card.tsx @@ -0,0 +1,79 @@ +import { CopyIcon, EditIcon, PlayIcon, TrashIcon } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CallVideoFeedResultData } from '@/models/v4/callVideoFeeds/callVideoFeedResultData'; + +import { FEED_FORMAT_LABELS, FEED_STATUS_LABELS, FEED_TYPE_LABELS, FeedStatus } from './feed-format-utils'; + +const STATUS_COLORS: Record = { + [FeedStatus.Active]: '#22C55E', + [FeedStatus.Inactive]: '#9CA3AF', + [FeedStatus.Error]: '#EF4444', +}; + +interface VideoFeedCardProps { + feed: CallVideoFeedResultData; + onWatch: (feed: CallVideoFeedResultData) => void; + onEdit: (feed: CallVideoFeedResultData) => void; + onDelete: (feed: CallVideoFeedResultData) => void; + onCopyUrl: (feed: CallVideoFeedResultData) => void; +} + +export const VideoFeedCard: React.FC = ({ feed, onWatch, onEdit, onDelete, onCopyUrl }) => { + const { t } = useTranslation(); + const statusColor = STATUS_COLORS[feed.Status] ?? '#9CA3AF'; + const typeLabel = FEED_TYPE_LABELS[feed.FeedType] ?? 'video_feeds.type_other'; + const formatLabel = FEED_FORMAT_LABELS[feed.FeedFormat] ?? 'video_feeds.format_other'; + const statusLabel = FEED_STATUS_LABELS[feed.Status] ?? 'video_feeds.status_inactive'; + + return ( + + + + {feed.Name} + + {t(typeLabel)} + + {t(formatLabel)} + + + + + {t(statusLabel)} + + + + + {feed.Description ? {feed.Description} : null} + + {feed.FullName ? ( + + {t('video_feeds.added_by')}: {feed.FullName} + {feed.AddedOnFormatted ? ` • ${feed.AddedOnFormatted}` : ''} + + ) : null} + + + + + + + + + ); +}; diff --git a/src/components/call-video-feeds/video-feed-form-sheet.tsx b/src/components/call-video-feeds/video-feed-form-sheet.tsx new file mode 100644 index 0000000..319aae5 --- /dev/null +++ b/src/components/call-video-feeds/video-feed-form-sheet.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { ScrollView, TextInput } from 'react-native'; + +import type { EditCallVideoFeedInput, SaveCallVideoFeedInput } from '@/api/call-video-feeds/call-video-feeds'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CallVideoFeedResultData } from '@/models/v4/callVideoFeeds/callVideoFeedResultData'; +import { useCallVideoFeedStore } from '@/stores/call-video-feeds/store'; +import { useToastStore } from '@/stores/toast/store'; + +import { detectFeedFormat, FEED_FORMAT_LABELS, FEED_TYPE_LABELS, FeedFormat, FeedType } from './feed-format-utils'; + +interface VideoFeedFormSheetProps { + isOpen: boolean; + onClose: () => void; + callId: number; + existingFeed?: CallVideoFeedResultData; +} + +interface FormValues { + Name: string; + Url: string; + FeedType: number; + FeedFormat: number; + Description: string; + Latitude: string; + Longitude: string; +} + +const FEED_TYPES = [ + { value: FeedType.Drone, key: 'type_drone' }, + { value: FeedType.FixedCamera, key: 'type_fixed_camera' }, + { value: FeedType.BodyCam, key: 'type_body_cam' }, + { value: FeedType.TrafficCam, key: 'type_traffic_cam' }, + { value: FeedType.WeatherCam, key: 'type_weather_cam' }, + { value: FeedType.SatelliteFeed, key: 'type_satellite_feed' }, + { value: FeedType.WebCam, key: 'type_web_cam' }, + { value: FeedType.Other, key: 'type_other' }, +]; + +const FEED_FORMATS = [ + { value: FeedFormat.HLS, key: 'format_hls' }, + { value: FeedFormat.RTSP, key: 'format_rtsp' }, + { value: FeedFormat.MJPEG, key: 'format_mjpeg' }, + { value: FeedFormat.YouTubeLive, key: 'format_youtube_live' }, + { value: FeedFormat.WebRTC, key: 'format_webrtc' }, + { value: FeedFormat.DASH, key: 'format_dash' }, + { value: FeedFormat.Embed, key: 'format_embed' }, + { value: FeedFormat.Other, key: 'format_other' }, +]; + +export const VideoFeedFormSheet: React.FC = ({ isOpen, onClose, callId, existingFeed }) => { + const { t } = useTranslation(); + const saveFeed = useCallVideoFeedStore((state) => state.saveFeed); + const editFeed = useCallVideoFeedStore((state) => state.editFeed); + const isSaving = useCallVideoFeedStore((state) => state.isSaving); + const showToast = useToastStore((state) => state.showToast); + + const { control, handleSubmit, reset, setValue, watch } = useForm({ + defaultValues: { + Name: existingFeed?.Name ?? '', + Url: existingFeed?.Url ?? '', + FeedType: existingFeed?.FeedType ?? FeedType.Other, + FeedFormat: existingFeed?.FeedFormat ?? FeedFormat.Other, + Description: existingFeed?.Description ?? '', + Latitude: existingFeed?.Latitude ?? '', + Longitude: existingFeed?.Longitude ?? '', + }, + }); + + useEffect(() => { + if (isOpen) { + reset({ + Name: existingFeed?.Name ?? '', + Url: existingFeed?.Url ?? '', + FeedType: existingFeed?.FeedType ?? FeedType.Other, + FeedFormat: existingFeed?.FeedFormat ?? FeedFormat.Other, + Description: existingFeed?.Description ?? '', + Latitude: existingFeed?.Latitude ?? '', + Longitude: existingFeed?.Longitude ?? '', + }); + } + }, [isOpen, existingFeed, reset]); + + const selectedFeedType = watch('FeedType'); + const selectedFeedFormat = watch('FeedFormat'); + + const handleUrlBlur = useCallback( + (url: string) => { + if (!url) return; + const detected = detectFeedFormat(url); + if (detected !== null) { + setValue('FeedFormat', detected); + } + }, + [setValue] + ); + + const onSubmit = useCallback( + async (data: FormValues) => { + let success: boolean; + + if (existingFeed) { + const input: EditCallVideoFeedInput = { + CallVideoFeedId: existingFeed.CallVideoFeedId, + CallId: callId, + Name: data.Name, + Url: data.Url, + FeedType: data.FeedType, + FeedFormat: data.FeedFormat, + Description: data.Description || undefined, + Latitude: data.Latitude || undefined, + Longitude: data.Longitude || undefined, + }; + success = await editFeed(input); + } else { + const input: SaveCallVideoFeedInput = { + CallId: callId, + Name: data.Name, + Url: data.Url, + FeedType: data.FeedType, + FeedFormat: data.FeedFormat, + Description: data.Description || undefined, + Latitude: data.Latitude || undefined, + Longitude: data.Longitude || undefined, + }; + success = await saveFeed(input); + } + + if (success) { + showToast('success', t('video_feeds.save_success')); + onClose(); + } else { + showToast('error', t('video_feeds.save_error')); + } + }, + [existingFeed, callId, saveFeed, editFeed, showToast, t, onClose] + ); + + return ( + + + + {existingFeed ? t('video_feeds.edit_feed') : t('video_feeds.add_feed')} + + {/* Name */} + + {t('video_feeds.name')} * + ( + + + + )} + /> + + + {/* URL */} + + {t('video_feeds.url')} * + ( + + handleUrlBlur(value)} placeholder="https://" autoCapitalize="none" keyboardType="url" /> + + )} + /> + + + {/* Feed Type */} + + {t('video_feeds.feed_type')} + + {FEED_TYPES.map((type) => ( + + ))} + + + + {/* Feed Format */} + + {t('video_feeds.feed_format')} + + {FEED_FORMATS.map((fmt) => ( + + ))} + + + + {/* Description */} + + {t('video_feeds.description')} + ( + + + + )} + /> + + + {/* Latitude / Longitude */} + + + {t('video_feeds.latitude')} + ( + + + + )} + /> + + + {t('video_feeds.longitude')} + ( + + + + )} + /> + + + + {/* Submit */} + + + + + ); +}; diff --git a/src/components/call-video-feeds/video-feed-tab-content.tsx b/src/components/call-video-feeds/video-feed-tab-content.tsx new file mode 100644 index 0000000..b8169f7 --- /dev/null +++ b/src/components/call-video-feeds/video-feed-tab-content.tsx @@ -0,0 +1,128 @@ +import * as Clipboard from 'expo-clipboard'; +import { PlusIcon } from 'lucide-react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, FlatList } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CallVideoFeedResultData } from '@/models/v4/callVideoFeeds/callVideoFeedResultData'; +import { useCallVideoFeedStore } from '@/stores/call-video-feeds/store'; +import { useToastStore } from '@/stores/toast/store'; + +import { VideoFeedCard } from './video-feed-card'; +import { VideoFeedFormSheet } from './video-feed-form-sheet'; +import { VideoPlayerModal } from './video-player-modal'; + +interface VideoFeedTabContentProps { + callId: number; +} + +export const VideoFeedTabContent: React.FC = ({ callId }) => { + const { t } = useTranslation(); + const feeds = useCallVideoFeedStore((state) => state.feeds); + const isLoadingFeeds = useCallVideoFeedStore((state) => state.isLoadingFeeds); + const fetchFeeds = useCallVideoFeedStore((state) => state.fetchFeeds); + const deleteFeedAction = useCallVideoFeedStore((state) => state.deleteFeed); + const showToast = useToastStore((state) => state.showToast); + + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingFeed, setEditingFeed] = useState(undefined); + const [playerFeed, setPlayerFeed] = useState(null); + const [isPlayerOpen, setIsPlayerOpen] = useState(false); + + useEffect(() => { + fetchFeeds(callId); + }, [callId, fetchFeeds]); + + const handleAddFeed = useCallback(() => { + setEditingFeed(undefined); + setIsFormOpen(true); + }, []); + + const handleWatch = useCallback((feed: CallVideoFeedResultData) => { + setPlayerFeed(feed); + setIsPlayerOpen(true); + }, []); + + const handleEdit = useCallback((feed: CallVideoFeedResultData) => { + setEditingFeed(feed); + setIsFormOpen(true); + }, []); + + const handleCopyUrl = useCallback( + async (feed: CallVideoFeedResultData) => { + await Clipboard.setStringAsync(feed.Url); + showToast('success', t('video_feeds.url_copied')); + }, + [showToast, t] + ); + + const handleDelete = useCallback( + (feed: CallVideoFeedResultData) => { + Alert.alert(t('video_feeds.delete_confirm_title'), t('video_feeds.delete_confirm_message'), [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('video_feeds.delete_feed'), + style: 'destructive', + onPress: async () => { + const success = await deleteFeedAction(feed.CallVideoFeedId, callId); + if (success) { + showToast('success', t('video_feeds.delete_success')); + } else { + showToast('error', t('video_feeds.delete_error')); + } + }, + }, + ]); + }, + [deleteFeedAction, callId, showToast, t] + ); + + const handleCloseForm = useCallback(() => { + setIsFormOpen(false); + setEditingFeed(undefined); + }, []); + + const handleClosePlayer = useCallback(() => { + setIsPlayerOpen(false); + setPlayerFeed(null); + }, []); + + const renderFeedCard = useCallback( + ({ item }: { item: CallVideoFeedResultData }) => , + [handleWatch, handleEdit, handleDelete, handleCopyUrl] + ); + + const keyExtractor = useCallback((item: CallVideoFeedResultData) => item.CallVideoFeedId, []); + + if (isLoadingFeeds && feeds.length === 0) { + return ( + + + + ); + } + + return ( + + + + {feeds.length === 0 ? ( + {t('video_feeds.no_feeds')} + ) : ( + + )} + + + + + + ); +}; diff --git a/src/components/call-video-feeds/video-player-modal.tsx b/src/components/call-video-feeds/video-player-modal.tsx new file mode 100644 index 0000000..bbfbe92 --- /dev/null +++ b/src/components/call-video-feeds/video-player-modal.tsx @@ -0,0 +1,95 @@ +import { ResizeMode, Video } from 'expo-av'; +import { CopyIcon, XIcon } from 'lucide-react-native'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, StyleSheet } from 'react-native'; +import { WebView } from 'react-native-webview'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CallVideoFeedResultData } from '@/models/v4/callVideoFeeds/callVideoFeedResultData'; + +import { FeedFormat } from './feed-format-utils'; + +interface VideoPlayerModalProps { + isOpen: boolean; + onClose: () => void; + feed: CallVideoFeedResultData | null; + onCopyUrl: (feed: CallVideoFeedResultData) => void; +} + +export const VideoPlayerModal: React.FC = ({ isOpen, onClose, feed, onCopyUrl }) => { + const { t } = useTranslation(); + + const handleCopy = useCallback(() => { + if (feed) { + onCopyUrl(feed); + } + }, [feed, onCopyUrl]); + + if (!feed) return null; + + const renderPlayer = () => { + switch (feed.FeedFormat) { + case FeedFormat.HLS: + case FeedFormat.DASH: + return - - {getTimeAgoUtc(call.LoggedOnUtc)} - + + {showTimerIcon ? ( + + + + ) : null} + + {getTimeAgoUtc(call.LoggedOnUtc)} + + {/* Call Details */} @@ -90,6 +115,20 @@ export const CallCard: React.FC = ({ call, priority }) => { + {destinationLabel ? ( + + + + {t('calls.destination')}: {destinationLabel} + + + ) : null} + {/* Dispatched Time */} {/* Disabling this for now, ideally a list of disptched items would be ideal here but there is a perf issue getting that data. -SJ diff --git a/src/components/calls/destination-poi-selector.tsx b/src/components/calls/destination-poi-selector.tsx new file mode 100644 index 0000000..6ee9f0e --- /dev/null +++ b/src/components/calls/destination-poi-selector.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { groupPoisByType, getPoiSelectionLabel, createPoiTypeMap } from '@/lib/poi-utils'; +import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; + +import { CustomBottomSheet } from '../ui/bottom-sheet'; +import { Box } from '../ui/box'; +import { Button, ButtonText } from '../ui/button'; +import { Text } from '../ui/text'; +import { VStack } from '../ui/vstack'; + +interface DestinationPoiSelectorProps { + destinationPois: PoiResultData[]; + poiTypes: PoiTypeResultData[]; + selectedPoiId: number | null; + isLoading: boolean; + onChange: (poiId: number | null) => void; +} + +export const DestinationPoiSelector: React.FC = ({ destinationPois, poiTypes, selectedPoiId, isLoading, onChange }) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = React.useState(false); + + const poiTypesById = React.useMemo(() => createPoiTypeMap(poiTypes), [poiTypes]); + const groupedDestinationPois = React.useMemo(() => groupPoisByType(destinationPois, poiTypes), [destinationPois, poiTypes]); + const selectedPoi = React.useMemo(() => destinationPois.find((poi) => poi.PoiId === selectedPoiId) ?? null, [destinationPois, selectedPoiId]); + + const selectedLabel = selectedPoi ? getPoiSelectionLabel(selectedPoi, poiTypesById) : t('calls.destination_poi_none'); + + const handleSelect = (poiId: number | null) => { + onChange(poiId); + setIsOpen(false); + }; + + return ( + <> + + {t('calls.destination_poi')} + + + + setIsOpen(false)} isLoading={isLoading} loadingText={t('calls.loading_destination_pois')}> + + {t('calls.select_destination_poi')} + + + + {groupedDestinationPois.length > 0 ? ( + groupedDestinationPois.map((group) => ( + + {group.title} + {group.items.map((poi) => ( + + ))} + + )) + ) : ( + {t('calls.no_destination_pois_available')} + )} + + + + + ); +}; diff --git a/src/components/check-in-timers/__tests__/check-in-bottom-sheet.test.tsx b/src/components/check-in-timers/__tests__/check-in-bottom-sheet.test.tsx new file mode 100644 index 0000000..f0a3664 --- /dev/null +++ b/src/components/check-in-timers/__tests__/check-in-bottom-sheet.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import { CheckInBottomSheet } from '../check-in-bottom-sheet'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: jest.fn((selector: any) => selector({ activeUnit: null })), +})); + +jest.mock('@/stores/app/location-store', () => ({ + useLocationStore: jest.fn((selector: any) => selector({ latitude: 40.7128, longitude: -74.006 })), +})); + +const mockPerformCheckIn = jest.fn().mockResolvedValue(true) as any; + +jest.mock('@/stores/check-in-timers/store', () => ({ + useCheckInTimerStore: jest.fn((selector: any) => + selector({ + performCheckIn: mockPerformCheckIn, + isCheckingIn: false, + }) + ), +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: jest.fn((selector: any) => selector({ showToast: jest.fn() })), +})); + +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => { + const { View } = require('react-native'); + return isOpen ? {children} : null; + }, +})); + +jest.mock('@/components/ui/box', () => ({ + Box: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, onPress, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return ( + + {children} + + ); + }, + ButtonText: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/heading', () => ({ + Heading: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +describe('CheckInBottomSheet', () => { + it('should not render content when closed', () => { + const { queryByText } = render(); + + expect(queryByText('check_in.perform_check_in')).toBeNull(); + }); + + it('should render content when open', () => { + const { getByText } = render(); + + expect(getByText('check_in.perform_check_in')).toBeTruthy(); + expect(getByText('check_in.select_type')).toBeTruthy(); + expect(getByText('check_in.confirm')).toBeTruthy(); + }); + + it('should render all check-in type buttons', () => { + const { getByText } = render(); + + expect(getByText('check_in.type_personnel')).toBeTruthy(); + expect(getByText('check_in.type_unit')).toBeTruthy(); + expect(getByText('check_in.type_ic')).toBeTruthy(); + expect(getByText('check_in.type_par')).toBeTruthy(); + expect(getByText('check_in.type_hazmat')).toBeTruthy(); + expect(getByText('check_in.type_sector_rotation')).toBeTruthy(); + expect(getByText('check_in.type_rehab')).toBeTruthy(); + }); +}); diff --git a/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx b/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx new file mode 100644 index 0000000..1bf3742 --- /dev/null +++ b/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; + +import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; + +import { CheckInTimerCard } from '../check-in-timer-card'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('lucide-react-native', () => ({ + Timer: (props: any) => { + const { View } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/ui/box', () => ({ + Box: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, onPress, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return ( + + {children} + + ); + }, + ButtonText: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +const createMockTimer = (overrides: Partial = {}): CheckInTimerStatusResultData => ({ + TargetType: 0, + TargetTypeName: 'Unit', + TargetEntityId: '1', + TargetName: 'Engine 1', + UnitId: '1', + LastCheckIn: '2026-04-12T10:00:00Z', + DurationMinutes: 30, + WarningThresholdMinutes: 20, + ElapsedMinutes: 10, + Status: 'Ok', + ...overrides, +}); + +describe('CheckInTimerCard', () => { + it('should render timer info', () => { + const timer = createMockTimer(); + const onCheckIn = jest.fn(); + + const { getByText } = render(); + + expect(getByText('Engine 1')).toBeTruthy(); + expect(getByText('Unit')).toBeTruthy(); + expect(getByText('check_in.perform_check_in')).toBeTruthy(); + }); + + it('should call onCheckIn when button pressed', () => { + const timer = createMockTimer(); + const onCheckIn = jest.fn(); + + const { getByText } = render(); + + fireEvent.press(getByText('check_in.perform_check_in')); + expect(onCheckIn).toHaveBeenCalledTimes(1); + }); + + it('should hide check-in button when showCheckInButton is false', () => { + const timer = createMockTimer(); + const onCheckIn = jest.fn(); + + const { queryByText } = render(); + + expect(queryByText('check_in.perform_check_in')).toBeNull(); + }); + + it('should render warning status', () => { + const timer = createMockTimer({ Status: 'Warning', ElapsedMinutes: 22 }); + const onCheckIn = jest.fn(); + + const { getByText } = render(); + + expect(getByText('check_in.status_warning')).toBeTruthy(); + }); + + it('should render overdue status', () => { + const timer = createMockTimer({ Status: 'Overdue', ElapsedMinutes: 35 }); + const onCheckIn = jest.fn(); + + const { getByText } = render(); + + expect(getByText('check_in.status_overdue')).toBeTruthy(); + }); +}); diff --git a/src/components/check-in-timers/check-in-bottom-sheet.tsx b/src/components/check-in-timers/check-in-bottom-sheet.tsx new file mode 100644 index 0000000..510e5f4 --- /dev/null +++ b/src/components/check-in-timers/check-in-bottom-sheet.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TextInput } from 'react-native'; + +import type { PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; +import { useToastStore } from '@/stores/toast/store'; + +const CHECK_IN_TYPES = [ + { value: 0, key: 'type_personnel' }, + { value: 1, key: 'type_unit' }, + { value: 2, key: 'type_ic' }, + { value: 3, key: 'type_par' }, + { value: 4, key: 'type_hazmat' }, + { value: 5, key: 'type_sector_rotation' }, + { value: 6, key: 'type_rehab' }, +]; + +interface CheckInBottomSheetProps { + isOpen: boolean; + onClose: () => void; + callId: number; +} + +export const CheckInBottomSheet: React.FC = ({ isOpen, onClose, callId }) => { + const { t } = useTranslation(); + const activeUnit = useCoreStore((state) => state.activeUnit); + const latitude = useLocationStore((state) => state.latitude); + const longitude = useLocationStore((state) => state.longitude); + const performCheckInAction = useCheckInTimerStore((state) => state.performCheckIn); + const isCheckingIn = useCheckInTimerStore((state) => state.isCheckingIn); + const showToast = useToastStore((state) => state.showToast); + + const defaultType = activeUnit ? 1 : 0; + const [selectedType, setSelectedType] = useState(defaultType); + const [note, setNote] = useState(''); + + const handleConfirm = useCallback(async () => { + const input: PerformCheckInInput = { + CallId: callId, + CheckInType: selectedType, + UnitId: activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined, + Latitude: latitude?.toString(), + Longitude: longitude?.toString(), + Note: note || undefined, + }; + + const success = await performCheckInAction(input); + + if (success) { + showToast('success', t('check_in.check_in_success')); + setNote(''); + onClose(); + } else { + showToast('error', t('check_in.check_in_error')); + } + }, [callId, selectedType, activeUnit, latitude, longitude, note, performCheckInAction, showToast, t, onClose]); + + return ( + + + {t('check_in.perform_check_in')} + + {/* Type selector */} + + {t('check_in.select_type')} + + {CHECK_IN_TYPES.map((type) => ( + + ))} + + + + {/* Note input */} + + {t('check_in.add_note')} + + + + + + {/* Confirm */} + + + + ); +}; diff --git a/src/components/check-in-timers/check-in-history-list.tsx b/src/components/check-in-timers/check-in-history-list.tsx new file mode 100644 index 0000000..2035421 --- /dev/null +++ b/src/components/check-in-timers/check-in-history-list.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CheckInRecordResultData } from '@/models/v4/checkIn/checkInRecordResultData'; + +interface CheckInHistoryListProps { + records: CheckInRecordResultData[]; +} + +const renderItem = ({ item }: { item: CheckInRecordResultData }) => ( + + + + {item.CheckInTypeName} + {item.UnitId ? `Unit: ${item.UnitId}` : `User: ${item.UserId}`} + {item.Note ? {item.Note} : null} + + {new Date(item.Timestamp).toLocaleString()} + + +); + +const keyExtractor = (item: CheckInRecordResultData) => item.CheckInRecordId; + +export const CheckInHistoryList: React.FC = ({ records }) => { + const { t } = useTranslation(); + + if (records.length === 0) { + return ( + + {t('check_in.history')} + + ); + } + + return ; +}; diff --git a/src/components/check-in-timers/check-in-tab-content.tsx b/src/components/check-in-timers/check-in-tab-content.tsx new file mode 100644 index 0000000..1565532 --- /dev/null +++ b/src/components/check-in-timers/check-in-tab-content.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useQuickCheckIn } from '@/hooks/use-quick-check-in'; +import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; + +import { CheckInBottomSheet } from './check-in-bottom-sheet'; +import { CheckInHistoryList } from './check-in-history-list'; +import { CheckInTimerCard } from './check-in-timer-card'; + +interface CheckInTabContentProps { + callId: number; +} + +export const CheckInTabContent: React.FC = ({ callId }) => { + const { t } = useTranslation(); + const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); + const checkInHistory = useCheckInTimerStore((state) => state.checkInHistory); + const isLoadingStatuses = useCheckInTimerStore((state) => state.isLoadingStatuses); + const fetchCheckInHistory = useCheckInTimerStore((state) => state.fetchCheckInHistory); + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const { quickCheckIn, isCheckingIn } = useQuickCheckIn(callId); + + useEffect(() => { + if (showHistory) { + fetchCheckInHistory(callId); + } + }, [showHistory, callId, fetchCheckInHistory]); + + const handleCardCheckIn = useCallback(() => { + setIsBottomSheetOpen(true); + }, []); + + const renderTimerCard = useCallback(({ item }: { item: CheckInTimerStatusResultData }) => , [handleCardCheckIn]); + + const keyExtractor = useCallback((item: CheckInTimerStatusResultData) => `${item.TargetEntityId}-${item.TargetType}`, []); + + if (timerStatuses.length === 0 && !isLoadingStatuses) { + return ( + + {t('check_in.no_timers')} + + ); + } + + return ( + + {/* Quick Check-In button */} + + + {/* Timer cards */} + + + {/* History section */} + + {t('check_in.history')} + + + + {showHistory ? : null} + + setIsBottomSheetOpen(false)} callId={callId} /> + + ); +}; diff --git a/src/components/check-in-timers/check-in-timer-card.tsx b/src/components/check-in-timers/check-in-timer-card.tsx new file mode 100644 index 0000000..9fa2b54 --- /dev/null +++ b/src/components/check-in-timers/check-in-timer-card.tsx @@ -0,0 +1,105 @@ +import { Timer } from 'lucide-react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Animated, StyleSheet } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; + +const STATUS_COLORS: Record = { + Ok: '#22C55E', + Warning: '#F59E0B', + Overdue: '#EF4444', +}; + +interface CheckInTimerCardProps { + timer: CheckInTimerStatusResultData; + onCheckIn: () => void; + showCheckInButton?: boolean; +} + +export const CheckInTimerCard: React.FC = ({ timer, onCheckIn, showCheckInButton = true }) => { + const { t } = useTranslation(); + const [localElapsed, setLocalElapsed] = useState(timer.ElapsedMinutes); + const pulseAnim = useRef(new Animated.Value(1)).current; + + // Tick locally between polls for smooth countdown + useEffect(() => { + setLocalElapsed(timer.ElapsedMinutes); + }, [timer.ElapsedMinutes]); + + useEffect(() => { + const interval = setInterval(() => { + setLocalElapsed((prev) => prev + 1 / 60); + }, 1000); + return () => clearInterval(interval); + }, []); + + // Pulse animation for overdue + useEffect(() => { + if (timer.Status === 'Overdue') { + const animation = Animated.loop( + Animated.sequence([Animated.timing(pulseAnim, { toValue: 0.5, duration: 800, useNativeDriver: true }), Animated.timing(pulseAnim, { toValue: 1, duration: 800, useNativeDriver: true })]) + ); + animation.start(); + return () => animation.stop(); + } else { + pulseAnim.setValue(1); + } + }, [timer.Status, pulseAnim]); + + const statusColor = STATUS_COLORS[timer.Status] ?? '#808080'; + const progress = Math.min(localElapsed / timer.DurationMinutes, 1); + const minutesAgo = Math.floor(localElapsed); + + return ( + + + + + + + + {timer.TargetName} + {timer.TargetTypeName} + + + + + {t(`check_in.status_${timer.Status.toLowerCase()}`)} + + + + + {/* Progress bar */} + + + + + + + {t('check_in.last_check_in')}: {minutesAgo} {t('check_in.minutes_ago')} + + + {Math.floor(localElapsed)}/{timer.DurationMinutes} {t('check_in.duration')} + + + + {showCheckInButton ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + progressBar: { + minWidth: 4, + }, +}); diff --git a/src/components/maps/__tests__/pin-actions.test.tsx b/src/components/maps/__tests__/pin-actions.test.tsx index 12061f4..8ac4412 100644 --- a/src/components/maps/__tests__/pin-actions.test.tsx +++ b/src/components/maps/__tests__/pin-actions.test.tsx @@ -149,7 +149,7 @@ const mockCallPin = { Latitude: 40.7128, Longitude: -74.0060, ImagePath: 'call', - Type: 1, + Type: 0, InfoWindowContent: 'Medical emergency at Main St', Color: '#ff0000', zIndex: '1', @@ -436,7 +436,7 @@ describe('Pin Actions Integration Tests', () => { const callPinByType = { ...mockCallPin, ImagePath: 'other', - Type: 1, + Type: 0, }; render( @@ -586,4 +586,4 @@ describe('Pin Actions Integration Tests', () => { expect(screen.getByText('map.view_call_details')).toBeTruthy(); }); }); -}); \ No newline at end of file +}); diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index f5f9985..d538292 100644 --- a/src/components/maps/pin-detail-modal.tsx +++ b/src/components/maps/pin-detail-modal.tsx @@ -35,7 +35,8 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC if (!pin) return null; - const isCallPin = pin.ImagePath?.toLowerCase() === 'call' || pin.Type === 1; + const isCallPin = pin.ImagePath?.toLowerCase() === 'call' || pin.Type === 0; + const isPoiPin = pin.Type === 4; const handleRouteToLocation = async () => { if (!pin.Latitude || !pin.Longitude) { @@ -61,6 +62,13 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC } }; + const handleViewPoiDetails = () => { + if (isPoiPin && pin.Id) { + router.push(`/routes/poi/${pin.Id}` as any); + onClose(); + } + }; + const handleSetAsCurrentCall = () => { if (isCallPin && onSetAsCurrentCall) { onSetAsCurrentCall(pin); @@ -92,6 +100,27 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC )} + {pin.Address ? ( + + {t('map.pin_address')} + {pin.Address} + + ) : null} + + {pin.Note ? ( + + {t('map.pin_note')} + {pin.Note} + + ) : null} + + {pin.PoiTypeName ? ( + + {t('map.pin_type')} + {pin.PoiTypeName} + + ) : null} + {pin.Color && ( @@ -123,6 +152,13 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC )} + + {isPoiPin ? ( + + ) : null} diff --git a/src/components/routes/active-routes-list.tsx b/src/components/routes/active-routes-list.tsx new file mode 100644 index 0000000..69bb80d --- /dev/null +++ b/src/components/routes/active-routes-list.tsx @@ -0,0 +1,159 @@ +import { router } from 'expo-router'; +import { Navigation, PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { RouteCard } from '@/components/routes/route-card'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Fab, FabIcon } from '@/components/ui/fab'; +import { FlatList } from '@/components/ui/flat-list'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useRoutesStore } from '@/stores/routes/store'; +import { useUnitsStore } from '@/stores/units/store'; + +export const ActiveRoutesList: React.FC = () => { + const { t } = useTranslation(); + const routePlans = useRoutesStore((state) => state.routePlans); + const activeInstance = useRoutesStore((state) => state.activeInstance); + const isLoading = useRoutesStore((state) => state.isLoading); + const error = useRoutesStore((state) => state.error); + const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans); + const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeUnit = useCoreStore((state) => state.activeUnit); + const units = useUnitsStore((state) => state.units); + const fetchUnits = useUnitsStore((state) => state.fetchUnits); + const [searchQuery, setSearchQuery] = useState(''); + + const unitMap = useMemo(() => Object.fromEntries(units.map((unit) => [unit.UnitId, unit.Name])), [units]); + + useEffect(() => { + fetchAllRoutePlans(); + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + } + if (units.length === 0) { + fetchUnits(); + } + }, [activeUnitId, fetchActiveRoute, fetchAllRoutePlans, fetchUnits, units.length]); + + const handleRefresh = () => { + fetchAllRoutePlans(); + if (activeUnitId) { + fetchActiveRoute(activeUnitId); + } + }; + + const handleRoutePress = (route: RoutePlanResultData) => { + if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { + const routeInstanceId = activeInstance.RouteInstanceId; + const activeRouteUrl = + routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`; + router.push(activeRouteUrl as any); + return; + } + + router.push(`/routes/start?planId=${route.RoutePlanId}` as any); + }; + + const filteredRoutes = useMemo(() => { + const activeRoutes = routePlans.filter((route) => route.RouteStatus === 1); + if (!searchQuery) { + return activeRoutes; + } + + const normalizedQuery = searchQuery.toLowerCase(); + return activeRoutes.filter((route) => { + const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId); + const unitName = route.UnitId != null ? unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '') : ''; + return route.Name.toLowerCase().includes(normalizedQuery) || (route.Description?.toLowerCase() || '').includes(normalizedQuery) || unitName.toLowerCase().includes(normalizedQuery); + }); + }, [activeUnit, activeUnitId, routePlans, searchQuery, unitMap]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + + + testID="routes-list" + data={filteredRoutes} + ListHeaderComponent={ + activeInstance ? ( + { + const routeInstanceId = activeInstance.RouteInstanceId; + const activeRouteUrl = + routeInstanceId && routeInstanceId !== 'undefined' + ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${routeInstanceId}` + : `/routes/active?planId=${activeInstance.RoutePlanId}`; + router.push(activeRouteUrl as any); + }} + > + + + + + {activeInstance.RoutePlanName || t('routes.active_route')} + + + {t('routes.active')} + + + + {t('routes.progress', { + percent: activeInstance.StopsTotal ? Math.round(((activeInstance.StopsCompleted ?? 0) / activeInstance.StopsTotal) * 100) : 0, + })} + + + + ) : null + } + renderItem={({ item }) => { + const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); + const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : ''; + return ( + handleRoutePress(item)}> + + + ); + }} + keyExtractor={(item) => item.RoutePlanId} + refreshControl={} + ListEmptyComponent={} + contentContainerStyle={{ paddingBottom: 20 }} + /> + + + router.push('/routes/start' as any)} testID="new-route-fab"> + + + + ); +}; diff --git a/src/components/routes/poi-card.tsx b/src/components/routes/poi-card.tsx new file mode 100644 index 0000000..08daad5 --- /dev/null +++ b/src/components/routes/poi-card.tsx @@ -0,0 +1,66 @@ +import { MapPin, Navigation, Tag } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; + +interface PoiCardProps { + poi: PoiResultData; + poiTypeLabel: string; + displayName: string; + isDestinationEnabled: boolean; + onPress: () => void; +} + +export const PoiCard: React.FC = ({ poi, poiTypeLabel, displayName, isDestinationEnabled, onPress }) => { + const { t } = useTranslation(); + + return ( + + + + + {displayName} + + {poi.Address ? ( + + + {poi.Address} + + ) : null} + + {poi.Note ? ( + + {poi.Note} + + ) : null} + + + + {poiTypeLabel} + + {isDestinationEnabled ? ( + + {t('routes.poi_destination_enabled')} + + ) : null} + + + + + + + {t('routes.view_on_map')} + + {t('routes.poi_coordinates_compact', { latitude: poi.Latitude.toFixed(4), longitude: poi.Longitude.toFixed(4) })} + + + + ); +}; diff --git a/src/components/routes/poi-list-content.tsx b/src/components/routes/poi-list-content.tsx new file mode 100644 index 0000000..2286e67 --- /dev/null +++ b/src/components/routes/poi-list-content.tsx @@ -0,0 +1,133 @@ +import { router } from 'expo-router'; +import { ChevronDownIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl } from 'react-native'; + +import { filterPois, getPoiDisplayName, getPoiTypeName, isPoiDestinationEnabled, sortPois, type PoiSortOption } from '@/lib/poi-utils'; +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { PoiCard } from '@/components/routes/poi-card'; +import { Box } from '@/components/ui/box'; +import { FlatList } from '@/components/ui/flat-list'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { usePoisStore } from '@/stores/pois/store'; + +export const PoiListContent: React.FC = () => { + const { t } = useTranslation(); + const poiTypes = usePoisStore((state) => state.poiTypes); + const pois = usePoisStore((state) => state.pois); + const isLoading = usePoisStore((state) => state.isLoading); + const error = usePoisStore((state) => state.error); + const fetchAllPoiData = usePoisStore((state) => state.fetchAllPoiData); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedPoiTypeId, setSelectedPoiTypeId] = useState('all'); + const [sortBy, setSortBy] = useState('display'); + + useEffect(() => { + fetchAllPoiData(); + }, [fetchAllPoiData]); + + const poiTypesById = useMemo(() => { + return poiTypes.reduce>((accumulator, poiType) => { + accumulator[poiType.PoiTypeId] = poiType; + return accumulator; + }, {}); + }, [poiTypes]); + + const visiblePois = useMemo(() => { + const filteredPois = filterPois(pois, { + poiTypesById, + searchQuery, + poiTypeId: selectedPoiTypeId === 'all' ? null : Number(selectedPoiTypeId), + }); + return sortPois(filteredPois, poiTypesById, sortBy); + }, [poiTypesById, pois, searchQuery, selectedPoiTypeId, sortBy]); + + const handleRefresh = () => { + fetchAllPoiData(true); + }; + + if (isLoading && pois.length === 0) { + return ; + } + + if (error && pois.length === 0) { + return ; + } + + return ( + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + + + + + + + + testID="pois-list" + data={visiblePois} + keyExtractor={(item) => String(item.PoiId)} + refreshControl={} + renderItem={({ item }) => ( + router.push(`/routes/poi/${item.PoiId}` as any)} + /> + )} + ListEmptyComponent={ + + } + contentContainerStyle={{ paddingBottom: 20 }} + /> + + ); +}; diff --git a/src/components/routes/routes-home.tsx b/src/components/routes/routes-home.tsx new file mode 100644 index 0000000..59dd483 --- /dev/null +++ b/src/components/routes/routes-home.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { ActiveRoutesList } from '@/components/routes/active-routes-list'; +import { PoiListContent } from '@/components/routes/poi-list-content'; +import { Box } from '@/components/ui/box'; +import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; + +export const RoutesHome: React.FC = () => { + const tabs = React.useMemo( + () => [ + { + key: 'routes', + title: 'routes.routes_tab', + content: , + }, + { + key: 'pois', + title: 'routes.pois_tab', + content: , + }, + ], + [] + ); + + return ( + + + + + + ); +}; diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index 808bc70..5af66c6 100644 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx @@ -153,6 +153,7 @@ const mockCall: CallResultData = { DispatchedOnUtc: '2023-01-01T10:05:00Z', Latitude: '40.7128', Longitude: '-74.0060', + CheckInTimersEnabled: false, }; const mockPriority: CallPriorityResultData = { diff --git a/src/components/sidebar/check-in-sidebar-widget.tsx b/src/components/sidebar/check-in-sidebar-widget.tsx new file mode 100644 index 0000000..63645c0 --- /dev/null +++ b/src/components/sidebar/check-in-sidebar-widget.tsx @@ -0,0 +1,69 @@ +import { useRouter } from 'expo-router'; +import { Timer } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useQuickCheckIn } from '@/hooks/use-quick-check-in'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; + +const STATUS_COLORS: Record = { + Ok: '#22C55E', + Warning: '#F59E0B', + Overdue: '#EF4444', +}; + +export const CheckInSidebarWidget: React.FC = () => { + const { t } = useTranslation(); + const router = useRouter(); + const activeCall = useCoreStore((state) => state.activeCall); + const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); + + const callId = activeCall ? parseInt(activeCall.CallId, 10) : 0; + const { quickCheckIn, isCheckingIn } = useQuickCheckIn(callId); + + // Only render when there's an active call with timers + if (!activeCall?.CheckInTimersEnabled || timerStatuses.length === 0) { + return null; + } + + // Get most urgent timer + const urgentTimer = timerStatuses[0]; + const statusColor = STATUS_COLORS[urgentTimer.Status] ?? '#808080'; + + const handleNavigateToCheckIn = () => { + router.push(`/call/${activeCall.CallId}`); + }; + + return ( + + + + + + + + {urgentTimer.TargetName} + + + + + {Math.floor(urgentTimer.ElapsedMinutes)}/{urgentTimer.DurationMinutes} {t('check_in.duration')} + + + + + + + + + ); +}; diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index 8307298..a0464ec 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -14,6 +14,7 @@ import { useStatusBottomSheetStore } from '@/stores/status/store'; import ZeroState from '../common/zero-state'; import { StatusBottomSheet } from '../status/status-bottom-sheet'; import { SidebarCallCard } from './call-sidebar'; +import { CheckInSidebarWidget } from './check-in-sidebar-widget'; import { SidebarRolesCard } from './roles-sidebar'; import { SidebarStatusCard } from './status-sidebar'; import { SidebarUnitCard } from './unit-sidebar'; @@ -50,6 +51,9 @@ const Sidebar = ({ onClose }: SidebarProps) => { {/* Second row - Single card */} + {/* Check-in timer widget */} + + {/* Third row - Status buttons or empty state */} {isActiveStatusesEmpty ? ( { const mockSetCurrentStep = jest.fn(); const mockSetSelectedCall = jest.fn(); const mockSetSelectedStation = jest.fn(); + const mockSetSelectedPoi = jest.fn(); const mockSetSelectedDestinationType = jest.fn(); const mockSetNote = jest.fn(); const mockFetchDestinationData = jest.fn(); @@ -296,17 +304,21 @@ describe('StatusBottomSheet', () => { currentStep: 'select-destination' as const, selectedCall: null, selectedStation: null, + selectedPoi: null, selectedDestinationType: 'none' as const, selectedStatus: null, cameFromStatusSelection: false, note: '', availableCalls: [], availableStations: [], + availablePois: [], + availablePoiTypes: [], isLoading: false, setIsOpen: jest.fn(), setCurrentStep: mockSetCurrentStep, setSelectedCall: mockSetSelectedCall, setSelectedStation: mockSetSelectedStation, + setSelectedPoi: mockSetSelectedPoi, setSelectedDestinationType: mockSetSelectedDestinationType, setSelectedStatus: jest.fn(), setNote: mockSetNote, @@ -1491,7 +1503,7 @@ describe('StatusBottomSheet', () => { const selectedStatus = { Id: 'status-1', Text: 'Responding', - Detail: 3, // Both calls and stations + Detail: 2, // Calls only Note: 0, }; @@ -1540,7 +1552,7 @@ describe('StatusBottomSheet', () => { const selectedStatus = { Id: 'status-1', Text: 'Responding', - Detail: 3, // Both calls and stations + Detail: 1, // Stations only Note: 0, }; @@ -1562,10 +1574,6 @@ describe('StatusBottomSheet', () => { render(); - // Switch to stations tab first - const stationsTab = screen.getByText('Stations'); - fireEvent.press(stationsTab); - // Select station - should clear call selection const stationOption = screen.getByText('Fire Station 1'); fireEvent.press(stationOption); @@ -1575,6 +1583,51 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedCall).toHaveBeenCalledWith(null); }); + it('should select a POI destination and clear call and station selections', () => { + const mockPoi = { + PoiId: 42, + PoiTypeId: 7, + PoiTypeName: 'Hospital', + Name: 'Mercy Hospital', + Address: '789 Care Way', + Note: '', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Transporting', + Detail: 4, // POIs only + Note: 0, + }; + + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { + const store = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + availablePois: [mockPoi], + availablePoiTypes: [{ PoiTypeId: 7, Name: 'Hospital', IsDestination: true }], + selectedCall: { CallId: 'call-1', Number: 'C001', Name: 'Emergency Call', Address: '123 Main St' }, + selectedStation: { GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }, + selectedDestinationType: 'none', + }; + if (selector) { + return selector(store); + } + return store; + }); + + render(); + + const poiOption = screen.getByText('Mercy Hospital - 789 Care Way'); + fireEvent.press(poiOption); + + expect(mockSetSelectedPoi).toHaveBeenCalledWith(mockPoi); + expect(mockSetSelectedCall).toHaveBeenCalledWith(null); + expect(mockSetSelectedStation).toHaveBeenCalledWith(null); + expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('poi'); + }); + it('should render many items without height constraints for proper scrolling', () => { // Create many mock calls to test scrolling const manyCalls = Array.from({ length: 10 }, (_, index) => ({ @@ -1999,9 +2052,9 @@ describe('StatusBottomSheet', () => { render(); - // Should NOT pre-select the active call since destination type is already set to station + // Invalid destination types should be cleared before any pre-selection happens expect(mockSetSelectedCall).not.toHaveBeenCalled(); - expect(mockSetSelectedDestinationType).not.toHaveBeenCalled(); + expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('none'); }); it('should not pre-select active call when still loading', () => { @@ -2852,13 +2905,6 @@ describe('StatusBottomSheet', () => { // Should show some calls on the Calls tab (default) expect(screen.getByText('C001 - Emergency Call 1')).toBeTruthy(); - // Switch to Stations tab - const stationsTab = screen.getByText('Stations'); - fireEvent.press(stationsTab); - - // Should show stations - expect(screen.getByText('Fire Station 1')).toBeTruthy(); - // Next button should still be accessible expect(screen.getByText('Next')).toBeTruthy(); }); @@ -3655,4 +3701,4 @@ describe('StatusBottomSheet', () => { fireEvent.press(nextButton); // Should not throw or fail to find the button }); -}); \ No newline at end of file +}); diff --git a/src/components/status/__tests__/status-gps-debug.test.tsx b/src/components/status/__tests__/status-gps-debug.test.tsx index d266fc3..b0835c1 100644 --- a/src/components/status/__tests__/status-gps-debug.test.tsx +++ b/src/components/status/__tests__/status-gps-debug.test.tsx @@ -114,17 +114,23 @@ describe('Status GPS Debug Test', () => { currentStep: 'select-destination' as const, selectedCall: null, selectedStation: null, + selectedPoi: null, selectedDestinationType: 'none' as const, selectedStatus: null, + cameFromStatusSelection: false, note: '', availableCalls: [], availableStations: [], + availablePois: [], + availablePoiTypes: [], isLoading: false, setIsOpen: jest.fn(), setCurrentStep: jest.fn(), setSelectedCall: jest.fn(), setSelectedStation: jest.fn(), + setSelectedPoi: jest.fn(), setSelectedDestinationType: jest.fn(), + setSelectedStatus: jest.fn(), setNote: jest.fn(), fetchDestinationData: jest.fn(), reset: jest.fn(), diff --git a/src/components/status/__tests__/status-gps-integration-working.test.tsx b/src/components/status/__tests__/status-gps-integration-working.test.tsx index 72f2483..6eae2a5 100644 --- a/src/components/status/__tests__/status-gps-integration-working.test.tsx +++ b/src/components/status/__tests__/status-gps-integration-working.test.tsx @@ -295,6 +295,7 @@ describe('Status GPS Integration', () => { '2', 'Offline GPS status', '', + null, [], { latitude: '40.7128', @@ -326,6 +327,7 @@ describe('Status GPS Integration', () => { '3', '', '', + null, [], undefined ); @@ -488,6 +490,7 @@ describe('Status GPS Integration', () => { '4', 'Partial GPS', '', + null, [], { latitude: '35.6762', @@ -533,6 +536,7 @@ describe('Status GPS Integration', () => { '5', 'Complex status with GPS', 'call123', + null, [{ roleId: 'role1', userId: 'user1' }], { latitude: '51.5074', diff --git a/src/components/status/__tests__/status-gps-integration.test.tsx b/src/components/status/__tests__/status-gps-integration.test.tsx index ecf066f..5c27e75 100644 --- a/src/components/status/__tests__/status-gps-integration.test.tsx +++ b/src/components/status/__tests__/status-gps-integration.test.tsx @@ -203,6 +203,7 @@ describe('Status GPS Integration', () => { '2', 'Offline GPS status', '', + null, [], { latitude: '40.7128', @@ -234,6 +235,7 @@ describe('Status GPS Integration', () => { '3', '', '', + null, [], undefined ); @@ -395,6 +397,7 @@ describe('Status GPS Integration', () => { '4', 'Partial GPS', '', + null, [], { latitude: '35.6762', @@ -440,6 +443,7 @@ describe('Status GPS Integration', () => { '5', 'Complex status with GPS', 'call123', + null, [{ roleId: 'role1', userId: 'user1' }], { latitude: '51.5074', diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index 490ae08..f6994af 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -4,10 +4,11 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, TouchableOpacity } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; -import { useShallow } from 'zustand/react/shallow'; +import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; import { invertColor } from '@/lib/utils'; -import { type CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData'; +import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes'; +import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { offlineEventManager } from '@/services/offline-event-manager.service'; import { useCoreStore } from '@/stores/app/core-store'; @@ -25,35 +26,98 @@ import { Text } from '../ui/text'; import { Textarea, TextareaInput } from '../ui/textarea'; import { VStack } from '../ui/vstack'; +type DestinationTab = 'call' | 'station' | 'poi'; + +const getDestinationTabs = (detail: number): DestinationTab[] => { + const tabs: DestinationTab[] = []; + + if (statusDetailAllowsCalls(detail)) { + tabs.push('call'); + } + + if (statusDetailAllowsStations(detail)) { + tabs.push('station'); + } + + if (statusDetailAllowsPois(detail)) { + tabs.push('poi'); + } + + return tabs; +}; + +const getDestinationTabTranslationKey = (tab: DestinationTab): string => { + switch (tab) { + case 'call': + return 'status.calls_tab'; + case 'station': + return 'status.stations_tab'; + case 'poi': + return 'status.pois_tab'; + } +}; + +const getPreferredDestinationTab = ({ + tabs, + selectedDestinationType, + hasSelectedCall, + hasSelectedStation, + hasSelectedPoi, +}: { + tabs: DestinationTab[]; + selectedDestinationType: 'none' | 'call' | 'station' | 'poi'; + hasSelectedCall: boolean; + hasSelectedStation: boolean; + hasSelectedPoi: boolean; +}): DestinationTab => { + if (selectedDestinationType !== 'none' && tabs.includes(selectedDestinationType)) { + return selectedDestinationType; + } + + if (hasSelectedCall && tabs.includes('call')) { + return 'call'; + } + + if (hasSelectedStation && tabs.includes('station')) { + return 'station'; + } + + if (hasSelectedPoi && tabs.includes('poi')) { + return 'poi'; + } + + return tabs[0] ?? 'call'; +}; + export const StatusBottomSheet = () => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); - const [selectedTab, setSelectedTab] = React.useState<'calls' | 'stations'>('calls'); + const [selectedTab, setSelectedTab] = React.useState('call'); const [isSubmitting, setIsSubmitting] = React.useState(false); - const hasPreselectedRef = React.useRef(false); const showToast = useToastStore((state) => state.showToast); - // Initialize offline event manager on mount React.useEffect(() => { offlineEventManager.initialize(); }, []); - // Use individual selectors to avoid whole-store subscriptions (React 19 compatibility) const isOpen = useStatusBottomSheetStore((state) => state.isOpen); const currentStep = useStatusBottomSheetStore((state) => state.currentStep); const selectedCall = useStatusBottomSheetStore((state) => state.selectedCall); const selectedStation = useStatusBottomSheetStore((state) => state.selectedStation); + const selectedPoi = useStatusBottomSheetStore((state) => state.selectedPoi); const selectedDestinationType = useStatusBottomSheetStore((state) => state.selectedDestinationType); const selectedStatus = useStatusBottomSheetStore((state) => state.selectedStatus); const cameFromStatusSelection = useStatusBottomSheetStore((state) => state.cameFromStatusSelection); const note = useStatusBottomSheetStore((state) => state.note); const availableCalls = useStatusBottomSheetStore((state) => state.availableCalls); const availableStations = useStatusBottomSheetStore((state) => state.availableStations); + const availablePois = useStatusBottomSheetStore((state) => state.availablePois); + const availablePoiTypes = useStatusBottomSheetStore((state) => state.availablePoiTypes); const isLoading = useStatusBottomSheetStore((state) => state.isLoading); - const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen); const setCurrentStep = useStatusBottomSheetStore((state) => state.setCurrentStep); const setSelectedCall = useStatusBottomSheetStore((state) => state.setSelectedCall); const setSelectedStation = useStatusBottomSheetStore((state) => state.setSelectedStation); + const setSelectedPoi = useStatusBottomSheetStore((state) => state.setSelectedPoi); const setSelectedDestinationType = useStatusBottomSheetStore((state) => state.setSelectedDestinationType); const setSelectedStatus = useStatusBottomSheetStore((state) => state.setSelectedStatus); const setNote = useStatusBottomSheetStore((state) => state.setNote); @@ -74,60 +138,211 @@ export const StatusBottomSheet = () => { const altitude = useLocationStore((state) => state.altitude); const timestamp = useLocationStore((state) => state.timestamp); - // Set default tab based on DetailType when status changes - React.useEffect(() => { - if (selectedStatus) { - // DetailType 1 = stations only, so default to stations tab - // DetailType 2 = calls only, so default to calls tab - // DetailType 3 = both, default to calls tab - if (selectedStatus.Detail === 1) { - setSelectedTab('stations'); - } else { - setSelectedTab('calls'); - } - } - }, [selectedStatus]); + const poiTypesById = React.useMemo(() => createPoiTypeMap(availablePoiTypes), [availablePoiTypes]); - // Helper function to safely get status properties const getStatusProperty = React.useCallback( (prop: 'Detail' | 'Note', defaultValue: number): number => { - if (!selectedStatus) return defaultValue; - return selectedStatus[prop] ?? defaultValue; + if (!selectedStatus) { + return defaultValue; + } + + const value = Number(selectedStatus[prop]); + return Number.isNaN(value) ? defaultValue : value; }, [selectedStatus] ); const getStatusId = React.useCallback((): string => { - if (!selectedStatus) return '0'; + if (!selectedStatus) { + return '0'; + } + return selectedStatus.Id.toString(); }, [selectedStatus]); + const detailLevel = getStatusProperty('Detail', 0); + const shouldShowDestinationStep = detailLevel > 0; + const destinationTabs = React.useMemo(() => getDestinationTabs(detailLevel), [detailLevel]); + const noteType = getStatusProperty('Note', 0); + const isNoteRequired = noteType === 2; + const isNoteOptional = noteType === 1; + const activeCallCandidate = React.useMemo(() => { + if (!activeCallId) { + return null; + } + + return availableCalls.find((call) => call.CallId === activeCallId) ?? null; + }, [activeCallId, availableCalls]); + + React.useEffect(() => { + if (isOpen && activeUnit) { + fetchDestinationData(activeUnit.UnitId); + } + }, [activeUnit, fetchDestinationData, isOpen]); + + React.useEffect(() => { + if (!selectedStatus) { + return; + } + + const allowsCalls = statusDetailAllowsCalls(detailLevel); + const allowsStations = statusDetailAllowsStations(detailLevel); + const allowsPois = statusDetailAllowsPois(detailLevel); + + if (!allowsCalls && selectedCall) { + setSelectedCall(null); + } + + if (!allowsStations && selectedStation) { + setSelectedStation(null); + } + + if (!allowsPois && selectedPoi) { + setSelectedPoi(null); + } + + const selectedTypeAllowed = + selectedDestinationType === 'none' || + (selectedDestinationType === 'call' && allowsCalls) || + (selectedDestinationType === 'station' && allowsStations) || + (selectedDestinationType === 'poi' && allowsPois); + + if (!selectedTypeAllowed) { + setSelectedDestinationType('none'); + } + }, [ + detailLevel, + selectedCall, + selectedDestinationType, + selectedPoi, + selectedStation, + selectedStatus, + setSelectedCall, + setSelectedDestinationType, + setSelectedPoi, + setSelectedStation, + ]); + + React.useEffect(() => { + if (!selectedStatus || selectedDestinationType !== 'none') { + return; + } + + if (selectedCall && statusDetailAllowsCalls(detailLevel)) { + setSelectedDestinationType('call'); + return; + } + + if (selectedStation && statusDetailAllowsStations(detailLevel)) { + setSelectedDestinationType('station'); + return; + } + + if (selectedPoi && statusDetailAllowsPois(detailLevel)) { + setSelectedDestinationType('poi'); + } + }, [detailLevel, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedDestinationType]); + + React.useEffect(() => { + if (!isOpen || !selectedStatus || !statusDetailAllowsCalls(detailLevel) || !activeCallCandidate) { + return; + } + + if (selectedCall || selectedStation || selectedPoi || selectedDestinationType !== 'none') { + return; + } + + setSelectedCall(activeCallCandidate); + setSelectedDestinationType('call'); + }, [activeCallCandidate, detailLevel, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]); + + React.useEffect(() => { + if (destinationTabs.length === 0) { + return; + } + + const preferredTab = getPreferredDestinationTab({ + tabs: destinationTabs, + selectedDestinationType, + hasSelectedCall: !!selectedCall, + hasSelectedStation: !!selectedStation, + hasSelectedPoi: !!selectedPoi, + }); + + if (preferredTab !== selectedTab) { + setSelectedTab(preferredTab); + } + }, [destinationTabs, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedTab]); + + const getStatusDetailDescription = React.useCallback( + (detail: number): string | null => { + switch (detail) { + case CustomStateDetailTypes.Stations: + return t('status.station_destination_enabled'); + case CustomStateDetailTypes.Calls: + return t('status.call_destination_enabled'); + case CustomStateDetailTypes.CallsAndStations: + return t('status.both_destinations_enabled'); + case CustomStateDetailTypes.Pois: + return t('status.poi_destination_enabled'); + case CustomStateDetailTypes.CallsAndPois: + return t('status.calls_and_pois_destinations_enabled'); + case CustomStateDetailTypes.StationsAndPois: + return t('status.stations_and_pois_destinations_enabled'); + case CustomStateDetailTypes.CallsStationsAndPois: + return t('status.calls_stations_pois_destinations_enabled'); + default: + return null; + } + }, + [t] + ); + const handleClose = () => { reset(); }; const handleCallSelect = (callId: string) => { - const call = availableCalls.find((c) => c.CallId === callId); - if (call) { - setSelectedCall(call); - setSelectedDestinationType('call'); - setSelectedStation(null); + const call = availableCalls.find((item) => item.CallId === callId); + if (!call) { + return; } + + setSelectedCall(call); + setSelectedStation(null); + setSelectedPoi(null); + setSelectedDestinationType('call'); }; const handleStationSelect = (stationId: string) => { - const station = availableStations.find((s) => s.GroupId === stationId); - if (station) { - setSelectedStation(station); - setSelectedDestinationType('station'); - setSelectedCall(null); + const station = availableStations.find((item) => item.GroupId === stationId); + if (!station) { + return; + } + + setSelectedStation(station); + setSelectedCall(null); + setSelectedPoi(null); + setSelectedDestinationType('station'); + }; + + const handlePoiSelect = (poiId: number) => { + const poi = availablePois.find((item) => item.PoiId === poiId); + if (!poi) { + return; } + + setSelectedPoi(poi); + setSelectedCall(null); + setSelectedStation(null); + setSelectedDestinationType('poi'); }; const handleNoDestinationSelect = () => { setSelectedDestinationType('none'); setSelectedCall(null); setSelectedStation(null); + setSelectedPoi(null); }; const handleNext = () => { @@ -136,29 +351,24 @@ export const StatusBottomSheet = () => { } if (currentStep === 'select-status') { - // Move to destination selection after status is selected - const detailLevel = getStatusProperty('Detail', 0); if (detailLevel > 0) { setCurrentStep('select-destination'); + return; + } + + if (noteType === 0) { + void handleSubmit(); } else { - // Check if note is required/optional based on selectedStatus - const noteType = getStatusProperty('Note', 0); - if (noteType === 0) { - // No note step, go straight to submission - handleSubmit(); - } else { - // Note step required (noteType 1 = optional, noteType 2 = required) - setCurrentStep('add-note'); - } + setCurrentStep('add-note'); } - } else if (currentStep === 'select-destination') { - // Check if note is required/optional based on selectedStatus - const noteType = getStatusProperty('Note', 0); + + return; + } + + if (currentStep === 'select-destination') { if (noteType === 0) { - // No note step, go straight to submission - handleSubmit(); + void handleSubmit(); } else { - // Note step required (noteType 1 = optional, noteType 2 = required) setCurrentStep('add-note'); } } @@ -166,57 +376,63 @@ export const StatusBottomSheet = () => { const handlePrevious = () => { if (currentStep === 'add-note') { - const detailLevel = getStatusProperty('Detail', 0); if (detailLevel > 0) { setCurrentStep('select-destination'); } else { setCurrentStep('select-status'); } - } else if (currentStep === 'select-destination') { + return; + } + + if (currentStep === 'select-destination') { setCurrentStep('select-status'); } }; const handleStatusSelect = (statusId: string) => { - if (activeStatuses?.Statuses) { - const status = activeStatuses.Statuses.find((s) => s.Id.toString() === statusId); - if (status) { - setSelectedStatus(status); - } + const status = activeStatuses?.Statuses?.find((item) => item.Id.toString() === statusId); + if (!status) { + return; } + + setSelectedStatus(status); }; const handleSubmit = React.useCallback(async () => { - if (isSubmitting) return; // Prevent double submission + if (isSubmitting || !selectedStatus || !activeUnit) { + return; + } try { - if (!selectedStatus || !activeUnit) return; - setIsSubmitting(true); const input = new SaveUnitStatusInput(); input.Id = activeUnit.UnitId; input.Type = getStatusId(); input.Note = note; + input.RespondingTo = '0'; + input.RespondingToType = null; - // Set RespondingTo based on destination selection if (selectedDestinationType === 'call' && selectedCall) { input.RespondingTo = selectedCall.CallId; + input.RespondingToType = DestinationEntityTypes.Call; } else if (selectedDestinationType === 'station' && selectedStation) { input.RespondingTo = selectedStation.GroupId; + input.RespondingToType = DestinationEntityTypes.Station; + } else if (selectedDestinationType === 'poi' && selectedPoi) { + input.RespondingTo = selectedPoi.PoiId.toString(); + input.RespondingToType = DestinationEntityTypes.Poi; } - // Include GPS coordinates if available if (latitude !== null && longitude !== null) { input.Latitude = latitude.toString(); input.Longitude = longitude.toString(); input.Accuracy = accuracy?.toString() || '0'; input.Altitude = altitude?.toString() || '0'; - input.AltitudeAccuracy = ''; // Location store doesn't provide altitude accuracy + input.AltitudeAccuracy = ''; input.Speed = speed?.toString() || '0'; input.Heading = heading?.toString() || '0'; - // Set timestamp from location if available, otherwise use current time if (timestamp) { const locationDate = new Date(timestamp); input.Timestamp = locationDate.toISOString(); @@ -224,7 +440,6 @@ export const StatusBottomSheet = () => { } } - // Add role assignments input.Roles = unitRoleAssignments.map((assignment) => { const roleInput = new SaveUnitStatusRoleInput(); roleInput.RoleId = assignment.UnitRoleId; @@ -232,119 +447,63 @@ export const StatusBottomSheet = () => { return roleInput; }); - // Set active call if a call was selected and it's different from the current active call if (selectedDestinationType === 'call' && selectedCall && activeCallId !== selectedCall.CallId) { setActiveCall(selectedCall.CallId); } await saveUnitStatus(input); - - // Show success toast showToast('success', t('status.status_saved_successfully')); - reset(); } catch (error) { console.error('Failed to save unit status:', error); - // Show error toast showToast('error', t('status.failed_to_save_status')); } finally { setIsSubmitting(false); } }, [ - isSubmitting, - selectedStatus, + accuracy, + activeCallId, activeUnit, - note, - selectedDestinationType, - selectedCall, - selectedStation, - unitRoleAssignments, - saveUnitStatus, - reset, + altitude, getStatusId, + heading, + isSubmitting, latitude, longitude, - heading, - accuracy, - speed, - altitude, - timestamp, - activeCallId, + note, + reset, + saveUnitStatus, + selectedCall, + selectedDestinationType, + selectedPoi, + selectedStation, + selectedStatus, setActiveCall, showToast, + speed, t, + timestamp, + unitRoleAssignments, ]); - // Fetch destination data when status bottom sheet opens - React.useEffect(() => { - if (isOpen && activeUnit && selectedStatus) { - fetchDestinationData(activeUnit.UnitId); - } - }, [isOpen, activeUnit, selectedStatus, fetchDestinationData]); - - // Pre-select active call when opening with calls enabled - React.useLayoutEffect(() => { - // Reset the pre-selection flag when bottom sheet closes - if (!isOpen) { - hasPreselectedRef.current = false; - return; - } - - // Immediate pre-selection: if we have the conditions met, pre-select right away - // This runs on every render to catch the case where availableCalls loads in - if (isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall && selectedDestinationType === 'none' && !hasPreselectedRef.current) { - // Check if we have calls available (loaded) or should wait - if (!isLoading && availableCalls.length > 0) { - const activeCall = availableCalls.find((call) => call.CallId === activeCallId); - if (activeCall) { - // Update both states immediately in the same render cycle - setSelectedDestinationType('call'); - setSelectedCall(activeCall); - hasPreselectedRef.current = true; - } - } else if (isLoading || availableCalls.length === 0) { - // If still loading, immediately set destination type to 'call' to prevent "No Destination" from showing - // We'll set the actual call once it loads - setSelectedDestinationType('call'); - hasPreselectedRef.current = true; - } - } - - // Handle case where destination type is already 'call' but call hasn't been set yet - // This covers the scenario from the removed redundant effect - if (isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall && selectedDestinationType === 'call' && !isLoading && availableCalls.length > 0) { - const activeCall = availableCalls.find((call) => call.CallId === activeCallId); - if (activeCall) { - setSelectedCall(activeCall); - } - } - }, [isOpen, isLoading, selectedStatus, activeCallId, availableCalls, selectedCall, selectedDestinationType, setSelectedCall, setSelectedDestinationType]); - - // Smart logic: only show "No Destination" as selected if we truly want no destination - // Don't show it as selected if we're about to pre-select an active call or already have one selected const shouldShowNoDestinationAsSelected = React.useMemo(() => { - // If something else is already selected, don't show no destination as selected - if (selectedCall || selectedStation) { + if (selectedCall || selectedStation || selectedPoi) { return false; } - // If we're in a state where we should pre-select an active call, don't show no destination as selected - const shouldPreSelectActiveCall = isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall; + const shouldPreSelectActiveCall = + isOpen && + !!selectedStatus && + statusDetailAllowsCalls(detailLevel) && + !!activeCallId && + (isLoading || !!activeCallCandidate); if (shouldPreSelectActiveCall) { return false; } - // Otherwise, show it as selected only if explicitly set to 'none' return selectedDestinationType === 'none'; - }, [selectedDestinationType, selectedCall, selectedStation, isOpen, selectedStatus, activeCallId]); - - // Determine step logic - const detailLevel = getStatusProperty('Detail', 0); - const shouldShowDestinationStep = detailLevel > 0; - const noteType = getStatusProperty('Note', 0); - const isNoteRequired = noteType === 2; // NoteType 2 = required - const isNoteOptional = noteType === 1; // NoteType 1 = optional + }, [activeCallCandidate, activeCallId, detailLevel, isLoading, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus]); const getStepTitle = () => { switch (currentStep) { @@ -364,15 +523,13 @@ export const StatusBottomSheet = () => { case 'select-status': return 1; case 'select-destination': - return cameFromStatusSelection ? 2 : 1; // Step 2 if from status selection, step 1 if pre-selected + return cameFromStatusSelection ? 2 : 1; case 'add-note': if (cameFromStatusSelection) { - // New flow: step 1 = status, step 2 = destination, step 3 = note return shouldShowDestinationStep ? 3 : 2; - } else { - // Old flow: step 1 = destination, step 2 = note - return shouldShowDestinationStep ? 2 : 1; } + + return shouldShowDestinationStep ? 2 : 1; default: return 1; } @@ -380,64 +537,69 @@ export const StatusBottomSheet = () => { const getTotalSteps = () => { if (cameFromStatusSelection) { - // New flow calculation - let totalSteps = 1; // Always have status selection + let totalSteps = 1; if (selectedStatus) { - // We can determine exact steps based on the selected status const hasDestinationSelection = getStatusProperty('Detail', 0) > 0; - const noteType = getStatusProperty('Note', 0); - const hasNoteStep = noteType > 0; // Show note step for noteType 1 (optional) or 2 (required) + const currentNoteType = getStatusProperty('Note', 0); + const hasNoteStep = currentNoteType > 0; - if (hasDestinationSelection) totalSteps++; - if (hasNoteStep) totalSteps++; - } else { - // Conservative estimate when no status is selected yet - // Look at available statuses to determine potential steps - if (activeStatuses?.Statuses && activeStatuses.Statuses.length > 0) { - const hasAnyDestination = activeStatuses.Statuses.some((s) => s.Detail > 0); - const hasAnyNote = activeStatuses.Statuses.some((s) => s.Note > 0); - - if (hasAnyDestination) totalSteps++; - if (hasAnyNote) totalSteps++; - } else { - // Fallback: assume all steps - totalSteps = 3; + if (hasDestinationSelection) { + totalSteps += 1; } + + if (hasNoteStep) { + totalSteps += 1; + } + } else if (activeStatuses?.Statuses && activeStatuses.Statuses.length > 0) { + const hasAnyDestination = activeStatuses.Statuses.some((status) => Number(status.Detail) > 0); + const hasAnyNote = activeStatuses.Statuses.some((status) => Number(status.Note) > 0); + + if (hasAnyDestination) { + totalSteps += 1; + } + + if (hasAnyNote) { + totalSteps += 1; + } + } else { + totalSteps = 3; } return totalSteps; - } else { - // Old flow calculation - const hasDestinationSelection = shouldShowDestinationStep; - const hasNoteStep = isNoteRequired || isNoteOptional; + } - let totalSteps = 0; - if (hasDestinationSelection) totalSteps++; - if (hasNoteStep) totalSteps++; + let totalSteps = 0; - return Math.max(totalSteps, 1); + if (shouldShowDestinationStep) { + totalSteps += 1; } + + if (isNoteRequired || isNoteOptional) { + totalSteps += 1; + } + + return Math.max(totalSteps, 1); }; const canProceedFromCurrentStep = () => { - if (isSubmitting) return false; // Can't proceed while submitting + if (isSubmitting) { + return false; + } switch (currentStep) { case 'select-status': - return !!selectedStatus; // Must have a status selected + return !!selectedStatus; case 'select-destination': - return true; // Can proceed with any selection including none + return true; case 'add-note': - return !isNoteRequired || note.trim().length > 0; // Note required check + return !isNoteRequired || note.trim().length > 0; default: return false; } }; const getSelectedDestinationDisplay = () => { - // First, check if we have a selected call or station regardless of destination type - // This handles cases where the destination type might be temporarily incorrect if (selectedCall) { return `${selectedCall.Number} - ${selectedCall.Name}`; } @@ -446,24 +608,28 @@ export const StatusBottomSheet = () => { return selectedStation.Name; } - // Then check destination type for other scenarios + if (selectedPoi) { + return getPoiSelectionLabel(selectedPoi, poiTypesById); + } + if (selectedDestinationType === 'call') { - if (activeCallId) { - // Fallback: if we're supposed to have a call selected but selectedCall is null, - // try to find it in availableCalls - const activeCall = availableCalls.find((call) => call.CallId === activeCallId); - if (activeCall) { - return `${activeCall.Number} - ${activeCall.Name}`; - } else { - // Still loading or call not found, show loading state - return t('calls.loading_calls'); - } + if (activeCallCandidate) { + return `${activeCallCandidate.Number} - ${activeCallCandidate.Name}`; + } + + if (isLoading || (!!activeCallId && availableCalls.length === 0)) { + return t('calls.loading_calls'); } } return t('status.no_destination'); }; + const shouldShowDestinationTabs = destinationTabs.length > 1; + const showCalls = destinationTabs.includes('call') && (!shouldShowDestinationTabs || selectedTab === 'call'); + const showStations = destinationTabs.includes('station') && (!shouldShowDestinationTabs || selectedTab === 'station'); + const showPois = destinationTabs.includes('poi') && (!shouldShowDestinationTabs || selectedTab === 'poi'); + return ( @@ -473,7 +639,6 @@ export const StatusBottomSheet = () => { - {/* Step indicator */} {t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()} @@ -484,45 +649,45 @@ export const StatusBottomSheet = () => { {getStepTitle()} - {currentStep === 'select-status' && ( + {currentStep === 'select-status' ? ( {t('status.select_status_type')} {activeStatuses?.Statuses && activeStatuses.Statuses.length > 0 ? ( - activeStatuses.Statuses.map((status) => ( - handleStatusSelect(status.Id.toString())} - className={`mb-3 rounded-lg border-2 p-3 ${selectedStatus?.Id.toString() === status.Id.toString() ? 'border-blue-500' : 'border-gray-200 dark:border-gray-700'}`} - style={{ - backgroundColor: status.BColor || (selectedStatus?.Id.toString() === status.Id.toString() ? '#dbeafe' : '#ffffff'), - }} - > - - - - - {status.Text} - - {status.Detail > 0 && ( - - {status.Detail === 1 && t('status.station_destination_enabled')} - {status.Detail === 2 && t('status.call_destination_enabled')} - {status.Detail === 3 && t('status.both_destinations_enabled')} + activeStatuses.Statuses.map((status) => { + const statusDetailDescription = getStatusDetailDescription(Number(status.Detail)); + const isSelected = selectedStatus?.Id.toString() === status.Id.toString(); + + return ( + handleStatusSelect(status.Id.toString())} + className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-blue-500' : 'border-gray-200 dark:border-gray-700'}`} + style={{ + backgroundColor: status.BColor || (isSelected ? '#dbeafe' : '#ffffff'), + }} + > + + + + + {status.Text} - )} - {status.Note > 0 && ( - - {status.Note === 1 && t('status.note_optional')} - {status.Note === 2 && t('status.note_required')} - - )} - - - - )) + {Number(status.Detail) > 0 ? ( + {statusDetailDescription} + ) : null} + {Number(status.Note) > 0 ? ( + + {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} + + ) : null} + + + + ); + }) ) : ( {t('status.no_statuses_available')} )} @@ -535,17 +700,16 @@ export const StatusBottomSheet = () => { - )} + ) : null} - {currentStep === 'select-destination' && shouldShowDestinationStep && ( + {currentStep === 'select-destination' && shouldShowDestinationStep ? ( {t('status.select_destination_type')} - {/* No Destination Option */} {
- {/* Show destination options based on DetailType: 1=stations only, 2=calls only, 3=both */} - {detailLevel > 0 && ( - <> - {/* Tab Headers - only show for DetailType 3 (both calls and stations) */} - {detailLevel === 3 && ( - - setSelectedTab('calls')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'calls' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}> - {t('status.calls_tab')} - - setSelectedTab('stations')} className={`flex-1 rounded-lg py-3 ${selectedTab === 'stations' ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}> - {t('status.stations_tab')} - - - )} + {shouldShowDestinationTabs ? ( + + {destinationTabs.map((tab) => ( + setSelectedTab(tab)} + className={`flex-1 rounded-lg py-3 ${selectedTab === tab ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`} + > + {t(getDestinationTabTranslationKey(tab))} + + ))} + + ) : null} - {/* Tab Content */} - - {/* Show calls only for DetailType 2 (calls only) or DetailType 3 with calls tab selected */} - {(detailLevel === 2 || (detailLevel === 3 && selectedTab === 'calls')) && ( - - {isLoading ? ( - - - {t('calls.loading_calls')} - - ) : availableCalls && availableCalls.length > 0 ? ( - availableCalls.map((call) => ( - handleCallSelect(call.CallId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} - > - - - - - {call.Number} - {call.Name} - - {call.Address} - - - - )) - ) : ( - {t('calls.no_calls_available')} - )} + + {showCalls ? ( + + {isLoading ? ( + + + {t('calls.loading_calls')} + ) : availableCalls.length > 0 ? ( + availableCalls.map((call) => ( + handleCallSelect(call.CallId)} + className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} + > + + + + + {call.Number} - {call.Name} + + {call.Address} + + + + )) + ) : ( + {t('calls.no_calls_available')} )} - - {/* Show stations only for DetailType 1 (stations only) or DetailType 3 with stations tab selected */} - {(detailLevel === 1 || (detailLevel === 3 && selectedTab === 'stations')) && ( - - {isLoading ? ( - - - {t('status.loading_stations')} - - ) : availableStations && availableStations.length > 0 ? ( - availableStations.map((station) => ( - handleStationSelect(station.GroupId)} - className={`mb-3 rounded-lg border-2 p-3 ${selectedStation?.GroupId === station.GroupId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} - > - - - - {station.Name} - {station.Address && {station.Address}} - {station.GroupType && {station.GroupType}} - - - - )) - ) : ( - {t('status.no_stations_available')} - )} + + ) : null} + + {showStations ? ( + + {isLoading ? ( + + + {t('status.loading_stations')} + ) : availableStations.length > 0 ? ( + availableStations.map((station) => ( + handleStationSelect(station.GroupId)} + className={`mb-3 rounded-lg border-2 p-3 ${selectedStation?.GroupId === station.GroupId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} + > + + + + {station.Name} + {station.Address ? {station.Address} : null} + {station.GroupType ? {station.GroupType} : null} + + + + )) + ) : ( + {t('status.no_stations_available')} )} - - - )} + + ) : null} + + {showPois ? ( + + {isLoading ? ( + + + {t('status.loading_pois')} + + ) : availablePois.length > 0 ? ( + availablePois.map((poi) => { + const poiTypeName = poiTypesById[poi.PoiTypeId]?.Name || poi.PoiTypeName; + const poiSecondaryText = poi.Address || poi.Note || poiTypeName; + + return ( + handlePoiSelect(poi.PoiId)} + className={`mb-3 rounded-lg border-2 p-3 ${selectedPoi?.PoiId === poi.PoiId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} + > + + + + {getPoiSelectionLabel(poi, poiTypesById)} + {poiSecondaryText ? {poiSecondaryText} : null} + + + + ); + }) + ) : ( + {t('status.no_pois_available')} + )} + + ) : null} + {cameFromStatusSelection ? ( ) : ( @@ -655,14 +847,13 @@ export const StatusBottomSheet = () => { )} - )} + ) : null} - {currentStep === 'select-destination' && !shouldShowDestinationStep && ( - // If Detail = 0, skip destination step and show note step directly + {currentStep === 'select-destination' && !shouldShowDestinationStep ? ( {isNoteRequired || isNoteOptional ? ( <> @@ -683,18 +874,17 @@ export const StatusBottomSheet = () => { {t('common.cancel')} )} - - )} + ) : null} - {currentStep === 'add-note' && ( + {currentStep === 'add-note' ? ( - {/* Selected Status */} {t('status.selected_status')}: @@ -704,7 +894,6 @@ export const StatusBottomSheet = () => { - {/* Selected Destination */} {t('status.selected_destination')}: {getSelectedDestinationDisplay()} @@ -724,14 +913,14 @@ export const StatusBottomSheet = () => { {t('common.previous')} - - )} + ) : null} diff --git a/src/components/weather-alerts/__tests__/weather-alert-banner.test.tsx b/src/components/weather-alerts/__tests__/weather-alert-banner.test.tsx new file mode 100644 index 0000000..ee35bc6 --- /dev/null +++ b/src/components/weather-alerts/__tests__/weather-alert-banner.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { WeatherAlertBanner } from '../weather-alert-banner'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: any) => (params ? `${key} ${JSON.stringify(params)}` : key), + }), +})); + +jest.mock('@/lib/weather-alert-utils', () => ({ + getSeverityColor: jest.fn(() => '#D32F2F'), +})); + +const createMockAlert = (overrides = {}) => ({ + WeatherAlertId: 'alert-1', + DepartmentId: 1, + Event: 'Tornado Warning', + Headline: 'Tornado Warning for County', + Description: '', + Instructions: '', + Severity: 0, + Category: 0, + Urgency: 0, + Certainty: 0, + Status: 0, + SourceType: 0, + SourceAlertId: '', + SenderName: '', + AreaDescription: '', + Polygon: '', + CenterGeoLocation: '', + EffectiveUtc: '', + OnsetUtc: '', + ExpiresUtc: '', + Ends: '', + ReceivedOnUtc: '', + UpdatedOnUtc: '', + WebUrl: '', + ZoneCode: '', + MessageType: '', + ...overrides, +}); + +describe('WeatherAlertBanner', () => { + const mockOnPress = jest.fn(); + const mockOnDismiss = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when no alerts', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('should render top alert headline', () => { + render(); + expect(screen.getByText('Tornado Warning for County')).toBeTruthy(); + }); + + it('should show +N more badge when multiple alerts', () => { + const alerts = [createMockAlert({ WeatherAlertId: 'a1' }), createMockAlert({ WeatherAlertId: 'a2' }), createMockAlert({ WeatherAlertId: 'a3' })]; + render(); + // The badge should show +2 more + expect(screen.getByText(/more_alerts/)).toBeTruthy(); + }); + + it('should call onPress when banner is pressed', () => { + render(); + fireEvent.press(screen.getByText('Tornado Warning for County')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('should use event name when headline is empty', () => { + render(); + expect(screen.getByText('Tornado Warning')).toBeTruthy(); + }); +}); diff --git a/src/components/weather-alerts/__tests__/weather-alert-card.test.tsx b/src/components/weather-alerts/__tests__/weather-alert-card.test.tsx new file mode 100644 index 0000000..81aa443 --- /dev/null +++ b/src/components/weather-alerts/__tests__/weather-alert-card.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { WeatherAlertCard } from '../weather-alert-card'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('@/lib/utils', () => ({ + getTimeAgoUtc: jest.fn(() => '2h ago'), +})); + +jest.mock('@/lib/weather-alert-utils', () => ({ + getSeverityColor: jest.fn(() => '#D32F2F'), + getSeverityTranslationKey: jest.fn(() => 'weather_alerts.severity.severe'), + getCategoryIcon: jest.fn(() => { + const { View } = require('react-native'); + return (props: any) => ; + }), +})); + +const createMockAlert = (overrides = {}) => ({ + WeatherAlertId: 'alert-1', + DepartmentId: 1, + Event: 'Tornado Warning', + Headline: 'Tornado Warning for County', + Description: 'A tornado has been spotted.', + Instructions: 'Take shelter.', + Severity: 1, + Category: 0, + Urgency: 0, + Certainty: 0, + Status: 0, + SourceType: 0, + SourceAlertId: '', + SenderName: 'NWS', + AreaDescription: 'County A', + Polygon: '', + CenterGeoLocation: '', + EffectiveUtc: '2026-04-15T10:00:00Z', + OnsetUtc: '', + ExpiresUtc: '2026-04-15T14:00:00Z', + Ends: '', + ReceivedOnUtc: '', + UpdatedOnUtc: '', + WebUrl: '', + ZoneCode: '', + MessageType: '', + ...overrides, +}); + +describe('WeatherAlertCard', () => { + it('should render alert event name', () => { + render(); + expect(screen.getByText('Tornado Warning')).toBeTruthy(); + }); + + it('should render headline', () => { + render(); + expect(screen.getByText('Tornado Warning for County')).toBeTruthy(); + }); + + it('should render area description', () => { + render(); + expect(screen.getByText('County A')).toBeTruthy(); + }); + + it('should render severity badge', () => { + render(); + expect(screen.getByText('weather_alerts.severity.severe')).toBeTruthy(); + }); + + it('should render without headline when empty', () => { + render(); + expect(screen.getByText('Tornado Warning')).toBeTruthy(); + }); +}); diff --git a/src/components/weather-alerts/severity-filter-tabs.tsx b/src/components/weather-alerts/severity-filter-tabs.tsx new file mode 100644 index 0000000..6148e78 --- /dev/null +++ b/src/components/weather-alerts/severity-filter-tabs.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { Text } from '@/components/ui/text'; +import { getSeverityColor } from '@/lib/weather-alert-utils'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; +import { WeatherAlertSeverity } from '@/models/v4/weatherAlerts/weatherAlertEnums'; + +interface SeverityFilterTabsProps { + selectedFilter: number | null; + onFilterChange: (severity: number | null) => void; + alerts: WeatherAlertResultData[]; +} + +const FILTERS: Array<{ severity: number | null; labelKey: string }> = [ + { severity: null, labelKey: 'weather_alerts.filter.all' }, + { severity: WeatherAlertSeverity.Extreme, labelKey: 'weather_alerts.severity.extreme' }, + { severity: WeatherAlertSeverity.Severe, labelKey: 'weather_alerts.severity.severe' }, + { severity: WeatherAlertSeverity.Moderate, labelKey: 'weather_alerts.severity.moderate' }, + { severity: WeatherAlertSeverity.Minor, labelKey: 'weather_alerts.severity.minor' }, +]; + +export const SeverityFilterTabs: React.FC = ({ selectedFilter, onFilterChange, alerts }) => { + const { t } = useTranslation(); + + const getCount = (severity: number | null): number => { + if (severity === null) return alerts.length; + return alerts.filter((a) => a.Severity === severity).length; + }; + + return ( + + {FILTERS.map((filter) => { + const isActive = selectedFilter === filter.severity; + const count = getCount(filter.severity); + const chipColor = filter.severity !== null ? getSeverityColor(filter.severity) : '#3b82f6'; + + return ( + onFilterChange(filter.severity)}> + + + {t(filter.labelKey)} ({count}) + + + + ); + })} + + ); +}; diff --git a/src/components/weather-alerts/weather-alert-banner.tsx b/src/components/weather-alerts/weather-alert-banner.tsx new file mode 100644 index 0000000..409fd5d --- /dev/null +++ b/src/components/weather-alerts/weather-alert-banner.tsx @@ -0,0 +1,59 @@ +import { AlertTriangle, X } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable } from 'react-native'; + +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { getSeverityColor } from '@/lib/weather-alert-utils'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; + +interface WeatherAlertBannerProps { + alerts: WeatherAlertResultData[]; + onPress: () => void; + onDismiss: () => void; +} + +export const WeatherAlertBanner: React.FC = ({ alerts, onPress, onDismiss }) => { + if (alerts.length === 0) { + return null; + } + + const { t } = useTranslation(); + const topAlert = alerts[0]; + const bgColor = getSeverityColor(topAlert.Severity); + + return ( + + + + + + + {topAlert.Headline || topAlert.Event} + + + + {alerts.length > 1 ? ( + + + {t('weather_alerts.banner.more_alerts', { count: alerts.length - 1 })} + + + ) : null} + + { + e.stopPropagation?.(); + onDismiss(); + }} + > + + + + + + ); +}; diff --git a/src/components/weather-alerts/weather-alert-card.tsx b/src/components/weather-alerts/weather-alert-card.tsx new file mode 100644 index 0000000..79c12b7 --- /dev/null +++ b/src/components/weather-alerts/weather-alert-card.tsx @@ -0,0 +1,70 @@ +import { Clock } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { getCategoryIcon, getSeverityColor, getSeverityTranslationKey } from '@/lib/weather-alert-utils'; +import { getTimeAgoUtc } from '@/lib/utils'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; + +interface WeatherAlertCardProps { + alert: WeatherAlertResultData; +} + +const WeatherAlertCardComponent: React.FC = ({ alert }) => { + const { t } = useTranslation(); + const severityColor = getSeverityColor(alert.Severity); + const CategoryIcon = getCategoryIcon(alert.Category); + + return ( + + {/* Header */} + + + + + {t(getSeverityTranslationKey(alert.Severity))} + + + + + {getTimeAgoUtc(alert.EffectiveUtc)} + + + + {/* Event name */} + {alert.Event} + + {/* Headline */} + {alert.Headline ? ( + + {alert.Headline} + + ) : null} + + {/* Area */} + {alert.AreaDescription ? ( + + {alert.AreaDescription} + + ) : null} + + {/* Expiry */} + {alert.ExpiresUtc ? ( + + + {t('weather_alerts.detail.expires')}: {new Date(alert.ExpiresUtc).toLocaleString()} + + + ) : null} + + ); +}; + +export const WeatherAlertCard = React.memo(WeatherAlertCardComponent); diff --git a/src/components/weather-alerts/weather-alert-detail-map.tsx b/src/components/weather-alerts/weather-alert-detail-map.tsx new file mode 100644 index 0000000..5965218 --- /dev/null +++ b/src/components/weather-alerts/weather-alert-detail-map.tsx @@ -0,0 +1,115 @@ +import React, { useMemo, useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Mapbox from '@/components/maps/mapbox'; +import { getSeverityColor } from '@/lib/weather-alert-utils'; +import { parseCenterLocation, parsePolygonGeoJSON } from '@/lib/weather-alert-utils'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; + +interface WeatherAlertDetailMapProps { + alert: WeatherAlertResultData; +} + +export const WeatherAlertDetailMap: React.FC = ({ alert }) => { + const cameraRef = useRef(null); + const severityColor = getSeverityColor(alert.Severity); + + const polygonGeoJSON = useMemo(() => parsePolygonGeoJSON(alert.Polygon), [alert.Polygon]); + const centerLocation = useMemo(() => parseCenterLocation(alert.CenterGeoLocation), [alert.CenterGeoLocation]); + + // Compute bounds from polygon for camera + const bounds = useMemo(() => { + if (!polygonGeoJSON || !polygonGeoJSON.geometry) return null; + + const geometry = polygonGeoJSON.geometry as GeoJSON.Polygon; + const coords = geometry.coordinates?.[0]; + if (!coords || coords.length === 0) return null; + + let minLng = Infinity, + maxLng = -Infinity, + minLat = Infinity, + maxLat = -Infinity; + for (const [lng, lat] of coords) { + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + + return { + ne: [maxLng, maxLat] as [number, number], + sw: [minLng, minLat] as [number, number], + paddingTop: 40, + paddingBottom: 40, + paddingLeft: 40, + paddingRight: 40, + }; + }, [polygonGeoJSON]); + + const cameraProps = useMemo(() => { + if (bounds) { + return { bounds }; + } + if (centerLocation) { + return { + centerCoordinate: [centerLocation.longitude, centerLocation.latitude] as [number, number], + zoomLevel: 8, + }; + } + return { zoomLevel: 4 }; + }, [bounds, centerLocation]); + + return ( + + + + + {polygonGeoJSON ? ( + + + + + ) : null} + + {!polygonGeoJSON && centerLocation ? ( + + + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: 200, + borderRadius: 12, + overflow: 'hidden', + }, + map: { + flex: 1, + }, + marker: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 3, + borderColor: '#FFFFFF', + }, +}); diff --git a/src/hooks/__tests__/use-quick-check-in.test.ts b/src/hooks/__tests__/use-quick-check-in.test.ts new file mode 100644 index 0000000..6e424ea --- /dev/null +++ b/src/hooks/__tests__/use-quick-check-in.test.ts @@ -0,0 +1,113 @@ +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: jest.fn((specifics: any) => specifics.ios || specifics.default), + Version: 17, + }, +})); + +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + set: jest.fn(), + getString: jest.fn(), + delete: jest.fn(), + })), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const mockPerformCheckIn = jest.fn() as any; +const mockShowToast = jest.fn() as any; + +jest.mock('@/stores/check-in-timers/store', () => ({ + useCheckInTimerStore: jest.fn((selector: any) => + selector({ + isCheckingIn: false, + performCheckIn: mockPerformCheckIn, + }) + ), +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: jest.fn((selector: any) => + selector({ + activeUnit: { UnitId: '42' }, + }) + ), +})); + +jest.mock('@/stores/app/location-store', () => ({ + useLocationStore: jest.fn((selector: any) => + selector({ + latitude: 40.7128, + longitude: -74.006, + }) + ), +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: jest.fn((selector: any) => + selector({ + showToast: mockShowToast, + }) + ), +})); + +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react-native'; + +import { useQuickCheckIn } from '../use-quick-check-in'; + +describe('useQuickCheckIn', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should auto-detect Unit type when active unit exists', async () => { + mockPerformCheckIn.mockResolvedValue(true); + + const { result } = renderHook(() => useQuickCheckIn(123)); + + await act(async () => { + await result.current.quickCheckIn(); + }); + + expect(mockPerformCheckIn).toHaveBeenCalledWith( + expect.objectContaining({ + CallId: 123, + CheckInType: 1, // Unit type + UnitId: 42, + Latitude: '40.7128', + Longitude: '-74.006', + }) + ); + }); + + it('should show success toast on successful check-in', async () => { + mockPerformCheckIn.mockResolvedValue(true); + + const { result } = renderHook(() => useQuickCheckIn(123)); + + await act(async () => { + await result.current.quickCheckIn(); + }); + + expect(mockShowToast).toHaveBeenCalledWith('success', 'check_in.check_in_success'); + }); + + it('should show error toast on failed check-in', async () => { + mockPerformCheckIn.mockResolvedValue(false); + + const { result } = renderHook(() => useQuickCheckIn(123)); + + await act(async () => { + await result.current.quickCheckIn(); + }); + + expect(mockShowToast).toHaveBeenCalledWith('error', 'check_in.check_in_error'); + }); +}); diff --git a/src/hooks/use-check-in-timer-polling.ts b/src/hooks/use-check-in-timer-polling.ts new file mode 100644 index 0000000..0912dd7 --- /dev/null +++ b/src/hooks/use-check-in-timer-polling.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; + +import { checkInLiveActivity } from '@/lib/native-modules/check-in-live-activity'; +import { checkInNotificationService } from '@/services/check-in-notification.service'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; + +export function useCheckInTimerPolling() { + const activeCall = useCoreStore((state) => state.activeCall); + const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); + const startPolling = useCheckInTimerStore((state) => state.startPolling); + const stopPolling = useCheckInTimerStore((state) => state.stopPolling); + const liveActivityStarted = useRef(false); + + // Start/stop polling based on active call + useEffect(() => { + if (activeCall?.CheckInTimersEnabled) { + startPolling(parseInt(activeCall.CallId, 10), 30000); + } else { + stopPolling(); + } + return () => { + stopPolling(); + }; + }, [activeCall?.CheckInTimersEnabled, activeCall?.CallId, startPolling, stopPolling]); + + // Update OS-level indicators when timer statuses change + useEffect(() => { + if (!activeCall || timerStatuses.length === 0) { + // Clean up + if (liveActivityStarted.current) { + checkInLiveActivity.end(); + checkInNotificationService.stopNotification(); + liveActivityStarted.current = false; + } + return; + } + + const urgentTimer = timerStatuses[0]; + const secondsRemaining = Math.max(0, (urgentTimer.DurationMinutes - urgentTimer.ElapsedMinutes) * 60); + + if (Platform.OS === 'ios') { + if (!liveActivityStarted.current) { + checkInLiveActivity.start({ + callName: activeCall.Name, + callNumber: activeCall.Number, + timerName: urgentTimer.TargetName, + durationMinutes: urgentTimer.DurationMinutes, + }); + liveActivityStarted.current = true; + } else { + checkInLiveActivity.update(Math.floor(urgentTimer.ElapsedMinutes), urgentTimer.Status); + } + } + + if (Platform.OS === 'android') { + if (!liveActivityStarted.current) { + checkInNotificationService.startNotification(activeCall.Name, activeCall.Number, urgentTimer.TargetName, secondsRemaining, urgentTimer.Status); + liveActivityStarted.current = true; + } else { + checkInNotificationService.updateNotification(secondsRemaining, urgentTimer.Status); + } + } + }, [activeCall, timerStatuses]); + + // Cleanup on unmount + useEffect(() => { + return () => { + checkInLiveActivity.end(); + checkInNotificationService.stopNotification(); + }; + }, []); +} diff --git a/src/hooks/use-quick-check-in.ts b/src/hooks/use-quick-check-in.ts new file mode 100644 index 0000000..f4a85ea --- /dev/null +++ b/src/hooks/use-quick-check-in.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; +import { useToastStore } from '@/stores/toast/store'; + +// Check-in types +const CHECK_IN_TYPE_PERSONNEL = 0; +const CHECK_IN_TYPE_UNIT = 1; + +export function useQuickCheckIn(callId: number) { + const { t } = useTranslation(); + const isCheckingIn = useCheckInTimerStore((state) => state.isCheckingIn); + const performCheckInAction = useCheckInTimerStore((state) => state.performCheckIn); + const activeUnit = useCoreStore((state) => state.activeUnit); + const latitude = useLocationStore((state) => state.latitude); + const longitude = useLocationStore((state) => state.longitude); + const showToast = useToastStore((state) => state.showToast); + + const quickCheckIn = useCallback(async () => { + const input: PerformCheckInInput = { + CallId: callId, + CheckInType: activeUnit ? CHECK_IN_TYPE_UNIT : CHECK_IN_TYPE_PERSONNEL, + UnitId: activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined, + Latitude: latitude?.toString(), + Longitude: longitude?.toString(), + }; + + const success = await performCheckInAction(input); + + if (success) { + showToast('success', t('check_in.check_in_success')); + } else { + showToast('error', t('check_in.check_in_error')); + } + + return success; + }, [callId, activeUnit, latitude, longitude, performCheckInAction, showToast, t]); + + return { quickCheckIn, isCheckingIn }; +} diff --git a/src/lib/__tests__/poi-utils.test.ts b/src/lib/__tests__/poi-utils.test.ts new file mode 100644 index 0000000..74e4e5b --- /dev/null +++ b/src/lib/__tests__/poi-utils.test.ts @@ -0,0 +1,141 @@ +import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, groupPoisByType, isPoiDestinationEnabled, sortPois } from '../poi-utils'; + +describe('poi-utils', () => { + const poiTypes = [ + { + PoiTypeId: 1, + Name: 'Hospital', + IsDestination: true, + }, + { + PoiTypeId: 2, + Name: 'Shelter', + IsDestination: false, + }, + ] as any; + + const poiTypesById = createPoiTypeMap(poiTypes); + + it('uses the expected display-name fallback order', () => { + expect( + getPoiDisplayName( + { + PoiId: 1, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: '', + Address: '123 Main St', + Note: 'Back entrance', + } as any, + poiTypesById + ) + ).toBe('123 Main St'); + + expect( + getPoiDisplayName( + { + PoiId: 2, + PoiTypeId: 2, + PoiTypeName: '', + Name: '', + Address: '', + Note: 'Temporary shelter', + } as any, + poiTypesById + ) + ).toBe('Temporary shelter'); + }); + + it('builds selection labels from name and address when both exist', () => { + expect( + getPoiSelectionLabel( + { + PoiId: 3, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: 'Mercy Hospital', + Address: '789 Care Way', + Note: '', + } as any, + poiTypesById + ) + ).toBe('Mercy Hospital - 789 Care Way'); + }); + + it('resolves destination eligibility from the poi type when needed', () => { + expect( + isPoiDestinationEnabled( + { + PoiId: 4, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + IsDestination: false, + } as any, + poiTypesById + ) + ).toBe(true); + + expect( + isPoiDestinationEnabled( + { + PoiId: 5, + PoiTypeId: 2, + PoiTypeName: 'Shelter', + IsDestination: false, + } as any, + poiTypesById + ) + ).toBe(false); + }); + + it('groups and sorts POIs by type', () => { + const grouped = groupPoisByType( + [ + { + PoiId: 10, + PoiTypeId: 2, + PoiTypeName: 'Shelter', + Name: 'North Shelter', + Address: '', + Note: '', + }, + { + PoiId: 11, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: 'Mercy Hospital', + Address: '', + Note: '', + }, + ] as any, + poiTypes as any + ); + + expect(grouped.map((group) => group.title)).toEqual(['Hospital', 'Shelter']); + expect(grouped[0].items[0].PoiId).toBe(11); + + const sorted = sortPois( + [ + { + PoiId: 20, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: 'Zulu Hospital', + Address: '', + Note: '', + }, + { + PoiId: 21, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: 'Alpha Hospital', + Address: '', + Note: '', + }, + ] as any, + poiTypesById + ); + + expect(sorted.map((poi) => poi.PoiId)).toEqual([21, 20]); + }); +}); diff --git a/src/lib/native-modules/check-in-live-activity.ts b/src/lib/native-modules/check-in-live-activity.ts new file mode 100644 index 0000000..36a6e97 --- /dev/null +++ b/src/lib/native-modules/check-in-live-activity.ts @@ -0,0 +1,45 @@ +import { NativeModules, Platform } from 'react-native'; + +const { CheckInTimerActivityManager } = NativeModules; + +interface CheckInLiveActivityParams { + callName: string; + callNumber: string; + timerName: string; + durationMinutes: number; +} + +export const checkInLiveActivity = { + start: async (params: CheckInLiveActivityParams): Promise => { + if (Platform.OS !== 'ios' || !CheckInTimerActivityManager) { + return false; + } + try { + return await CheckInTimerActivityManager.startActivity(params.callName, params.callNumber, params.timerName, params.durationMinutes); + } catch { + return false; + } + }, + + update: async (elapsedMinutes: number, status: string): Promise => { + if (Platform.OS !== 'ios' || !CheckInTimerActivityManager) { + return false; + } + try { + return await CheckInTimerActivityManager.updateActivity(elapsedMinutes, status); + } catch { + return false; + } + }, + + end: async (): Promise => { + if (Platform.OS !== 'ios' || !CheckInTimerActivityManager) { + return false; + } + try { + return await CheckInTimerActivityManager.endActivity(); + } catch { + return false; + } + }, +}; diff --git a/src/lib/poi-utils.ts b/src/lib/poi-utils.ts new file mode 100644 index 0000000..20fd46c --- /dev/null +++ b/src/lib/poi-utils.ts @@ -0,0 +1,102 @@ +import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; + +export type PoiSortOption = 'display' | 'type'; + +export interface PoiGroup { + poiTypeId: number; + title: string; + items: PoiResultData[]; +} + +const normalizeText = (value?: string | null): string => { + return value?.trim() ?? ''; +}; + +export const createPoiTypeMap = (poiTypes: PoiTypeResultData[]): Record => { + return poiTypes.reduce>((accumulator, poiType) => { + accumulator[poiType.PoiTypeId] = poiType; + return accumulator; + }, {}); +}; + +export const getPoiTypeName = (poi: Pick, poiTypesById?: Record): string => { + return normalizeText(poi.PoiTypeName) || normalizeText(poiTypesById?.[poi.PoiTypeId]?.Name); +}; + +export const getPoiDisplayName = (poi: PoiResultData, poiTypesById?: Record): string => { + return normalizeText(poi.Name) || normalizeText(poi.Address) || normalizeText(poi.Note) || getPoiTypeName(poi, poiTypesById); +}; + +export const getPoiSelectionLabel = (poi: PoiResultData, poiTypesById?: Record): string => { + const name = normalizeText(poi.Name); + const address = normalizeText(poi.Address); + + if (name && address) { + return `${name} - ${address}`; + } + + return getPoiDisplayName(poi, poiTypesById); +}; + +export const isPoiDestinationEnabled = (poi: PoiResultData, poiTypesById?: Record): boolean => { + return poi.IsDestination || !!poiTypesById?.[poi.PoiTypeId]?.IsDestination; +}; + +export const filterPois = (pois: PoiResultData[], options: { poiTypesById?: Record; searchQuery?: string; poiTypeId?: number | null }): PoiResultData[] => { + const normalizedQuery = normalizeText(options.searchQuery).toLowerCase(); + + return pois.filter((poi) => { + if (options.poiTypeId != null && poi.PoiTypeId !== options.poiTypeId) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + const searchableValues = [ + getPoiDisplayName(poi, options.poiTypesById), + getPoiSelectionLabel(poi, options.poiTypesById), + normalizeText(poi.Address), + normalizeText(poi.Note), + getPoiTypeName(poi, options.poiTypesById), + ] + .join(' ') + .toLowerCase(); + + return searchableValues.includes(normalizedQuery); + }); +}; + +export const sortPois = (pois: PoiResultData[], poiTypesById?: Record, sortBy: PoiSortOption = 'display'): PoiResultData[] => { + return [...pois].sort((left, right) => { + if (sortBy === 'type') { + const leftType = getPoiTypeName(left, poiTypesById); + const rightType = getPoiTypeName(right, poiTypesById); + const typeCompare = leftType.localeCompare(rightType, undefined, { sensitivity: 'base' }); + if (typeCompare !== 0) { + return typeCompare; + } + } + + return getPoiDisplayName(left, poiTypesById).localeCompare(getPoiDisplayName(right, poiTypesById), undefined, { sensitivity: 'base' }); + }); +}; + +export const groupPoisByType = (pois: PoiResultData[], poiTypes: PoiTypeResultData[]): PoiGroup[] => { + const poiTypesById = createPoiTypeMap(poiTypes); + const groups = pois.reduce>((accumulator, poi) => { + const currentGroup = accumulator.get(poi.PoiTypeId) ?? []; + currentGroup.push(poi); + accumulator.set(poi.PoiTypeId, currentGroup); + return accumulator; + }, new Map()); + + return [...groups.entries()] + .map(([poiTypeId, items]) => ({ + poiTypeId, + title: getPoiTypeName({ PoiTypeId: poiTypeId, PoiTypeName: '' }, poiTypesById) || `Type ${poiTypeId}`, + items: sortPois(items, poiTypesById, 'display'), + })) + .sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' })); +}; diff --git a/src/lib/weather-alert-utils.ts b/src/lib/weather-alert-utils.ts new file mode 100644 index 0000000..8bebffb --- /dev/null +++ b/src/lib/weather-alert-utils.ts @@ -0,0 +1,115 @@ +import { AlertTriangle, CloudLightning, Flame, Heart, Leaf, type LucideIcon } from 'lucide-react-native'; + +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; +import { WeatherAlertCategory, WeatherAlertSeverity, WeatherAlertStatus } from '@/models/v4/weatherAlerts/weatherAlertEnums'; + +export const SEVERITY_COLORS: Record = { + [WeatherAlertSeverity.Extreme]: '#7B1FA2', + [WeatherAlertSeverity.Severe]: '#D32F2F', + [WeatherAlertSeverity.Moderate]: '#F57C00', + [WeatherAlertSeverity.Minor]: '#FBC02D', + [WeatherAlertSeverity.Unknown]: '#9E9E9E', +}; + +export const SEVERITY_DARK_BG: Record = { + [WeatherAlertSeverity.Extreme]: 'rgba(123,31,162,0.2)', + [WeatherAlertSeverity.Severe]: 'rgba(211,47,47,0.2)', + [WeatherAlertSeverity.Moderate]: 'rgba(245,124,0,0.2)', + [WeatherAlertSeverity.Minor]: 'rgba(251,192,45,0.2)', + [WeatherAlertSeverity.Unknown]: 'rgba(158,158,158,0.2)', +}; + +export const getSeverityColor = (severity: number): string => { + return SEVERITY_COLORS[severity] ?? SEVERITY_COLORS[WeatherAlertSeverity.Unknown]; +}; + +export const getSeverityTranslationKey = (severity: number): string => { + const keys: Record = { + [WeatherAlertSeverity.Extreme]: 'weather_alerts.severity.extreme', + [WeatherAlertSeverity.Severe]: 'weather_alerts.severity.severe', + [WeatherAlertSeverity.Moderate]: 'weather_alerts.severity.moderate', + [WeatherAlertSeverity.Minor]: 'weather_alerts.severity.minor', + [WeatherAlertSeverity.Unknown]: 'weather_alerts.severity.unknown', + }; + return keys[severity] ?? keys[WeatherAlertSeverity.Unknown]; +}; + +export const getCategoryIcon = (category: number): LucideIcon => { + const icons: Record = { + [WeatherAlertCategory.Met]: CloudLightning, + [WeatherAlertCategory.Fire]: Flame, + [WeatherAlertCategory.Health]: Heart, + [WeatherAlertCategory.Env]: Leaf, + [WeatherAlertCategory.Other]: AlertTriangle, + }; + return icons[category] ?? icons[WeatherAlertCategory.Other]; +}; + +export const parsePolygonGeoJSON = (polygonStr: string): GeoJSON.Feature | null => { + if (!polygonStr) return null; + + try { + // Try parsing as GeoJSON first + const parsed = JSON.parse(polygonStr); + if (parsed.type === 'Feature') return parsed; + if (parsed.type === 'Polygon' || parsed.type === 'MultiPolygon') { + return { type: 'Feature', properties: {}, geometry: parsed }; + } + return null; + } catch { + // Try parsing as coordinate pairs "lat,lng lat,lng ..." + try { + const coords = polygonStr + .trim() + .split(/\s+/) + .map((pair) => { + const [lat, lng] = pair.split(',').map(Number); + return [lng, lat]; + }); + + if (coords.length < 3) return null; + + // Close the polygon if needed + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + coords.push([...first]); + } + + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [coords] }, + }; + } catch { + return null; + } + } +}; + +export const parseCenterLocation = (centerStr: string): { latitude: number; longitude: number } | null => { + if (!centerStr) return null; + + try { + const [lat, lng] = centerStr.split(',').map(Number); + if (isNaN(lat) || isNaN(lng)) return null; + return { latitude: lat, longitude: lng }; + } catch { + return null; + } +}; + +export const sortAlertsBySeverity = (alerts: WeatherAlertResultData[]): WeatherAlertResultData[] => { + return [...alerts].sort((a, b) => { + if (a.Severity !== b.Severity) return a.Severity - b.Severity; + return new Date(b.EffectiveUtc).getTime() - new Date(a.EffectiveUtc).getTime(); + }); +}; + +export const isAlertActive = (alert: WeatherAlertResultData): boolean => { + if (alert.Status !== WeatherAlertStatus.Active) return false; + if (alert.ExpiresUtc) { + return new Date(alert.ExpiresUtc).getTime() > Date.now(); + } + return true; +}; diff --git a/src/models/offline-queue/queued-event.ts b/src/models/offline-queue/queued-event.ts index 53b90cc..9ad90af 100644 --- a/src/models/offline-queue/queued-event.ts +++ b/src/models/offline-queue/queued-event.ts @@ -2,7 +2,7 @@ export enum QueuedEventType { UNIT_STATUS = 'unit_status', LOCATION_UPDATE = 'location_update', CALL_IMAGE_UPLOAD = 'call_image_upload', - // Add other event types as needed + CHECK_IN = 'check_in', } export enum QueuedEventStatus { @@ -32,6 +32,7 @@ export interface QueuedUnitStatusEvent extends Omit { statusType: string; note?: string; respondingTo?: string; + respondingToType?: number | string | null; timestamp: string; timestampUtc: string; roles?: { @@ -73,3 +74,16 @@ export interface QueuedCallImageUploadEvent extends Omit { filePath: string; }; } + +export interface QueuedCheckInEvent extends Omit { + type: QueuedEventType.CHECK_IN; + data: { + callId: number; + checkInType: number; + unitId?: number; + latitude?: string; + longitude?: string; + note?: string; + timestamp: string; + }; +} diff --git a/src/models/v4/callVideoFeeds/callVideoFeedResult.ts b/src/models/v4/callVideoFeeds/callVideoFeedResult.ts new file mode 100644 index 0000000..9e2c623 --- /dev/null +++ b/src/models/v4/callVideoFeeds/callVideoFeedResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type CallVideoFeedResultData } from './callVideoFeedResultData'; + +export class CallVideoFeedResult extends BaseV4Request { + public Data: CallVideoFeedResultData[] = []; +} diff --git a/src/models/v4/callVideoFeeds/callVideoFeedResultData.ts b/src/models/v4/callVideoFeeds/callVideoFeedResultData.ts new file mode 100644 index 0000000..1111c5d --- /dev/null +++ b/src/models/v4/callVideoFeeds/callVideoFeedResultData.ts @@ -0,0 +1,17 @@ +export class CallVideoFeedResultData { + public CallVideoFeedId: string = ''; + public CallId: string = ''; + public Name: string = ''; + public Url: string = ''; + public FeedType: number = 0; + public FeedFormat: number = 0; + public Description: string = ''; + public Status: number = 0; + public Latitude: string = ''; + public Longitude: string = ''; + public AddedByUserId: string = ''; + public AddedOnFormatted: string = ''; + public AddedOnUtc: string = ''; + public SortOrder: number = 0; + public FullName: string = ''; +} diff --git a/src/models/v4/callVideoFeeds/saveCallVideoFeedResult.ts b/src/models/v4/callVideoFeeds/saveCallVideoFeedResult.ts new file mode 100644 index 0000000..ad1acbd --- /dev/null +++ b/src/models/v4/callVideoFeeds/saveCallVideoFeedResult.ts @@ -0,0 +1,5 @@ +import { BaseV4Request } from '../baseV4Request'; + +export class SaveCallVideoFeedResult extends BaseV4Request { + public Id: string = ''; +} diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index 782c709..e9d6f80 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -5,6 +5,13 @@ export class CallResultData { public Nature: string = ''; public Note: string = ''; public Address: string = ''; + public DestinationPoiId?: number | null = null; + public DestinationName?: string = ''; + public DestinationAddress?: string = ''; + public DestinationTypeName?: string = ''; + public DestinationPoiTypeId?: number | null = null; + public DestinationLatitude?: number | null = null; + public DestinationLongitude?: number | null = null; public Geolocation: string = ''; public LoggedOn: string = ''; public State: string = ''; @@ -26,4 +33,5 @@ export class CallResultData { public DispatchedOnUtc: string = ''; public Latitude: string = ''; public Longitude: string = ''; + public CheckInTimersEnabled: boolean = false; } diff --git a/src/models/v4/checkIn/checkInRecordResult.ts b/src/models/v4/checkIn/checkInRecordResult.ts new file mode 100644 index 0000000..2bd9d9f --- /dev/null +++ b/src/models/v4/checkIn/checkInRecordResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type CheckInRecordResultData } from './checkInRecordResultData'; + +export class CheckInRecordResult extends BaseV4Request { + public Data: CheckInRecordResultData[] = []; +} diff --git a/src/models/v4/checkIn/checkInRecordResultData.ts b/src/models/v4/checkIn/checkInRecordResultData.ts new file mode 100644 index 0000000..651e39c --- /dev/null +++ b/src/models/v4/checkIn/checkInRecordResultData.ts @@ -0,0 +1,12 @@ +export class CheckInRecordResultData { + public CheckInRecordId: string = ''; + public CallId: number = 0; + public CheckInType: number = 0; + public CheckInTypeName: string = ''; + public UserId: string = ''; + public UnitId: string = ''; + public Latitude: string = ''; + public Longitude: string = ''; + public Timestamp: string = ''; + public Note: string = ''; +} diff --git a/src/models/v4/checkIn/checkInTimerStatusResult.ts b/src/models/v4/checkIn/checkInTimerStatusResult.ts new file mode 100644 index 0000000..677c89c --- /dev/null +++ b/src/models/v4/checkIn/checkInTimerStatusResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type CheckInTimerStatusResultData } from './checkInTimerStatusResultData'; + +export class CheckInTimerStatusResult extends BaseV4Request { + public Data: CheckInTimerStatusResultData[] = []; +} diff --git a/src/models/v4/checkIn/checkInTimerStatusResultData.ts b/src/models/v4/checkIn/checkInTimerStatusResultData.ts new file mode 100644 index 0000000..a102e3f --- /dev/null +++ b/src/models/v4/checkIn/checkInTimerStatusResultData.ts @@ -0,0 +1,12 @@ +export class CheckInTimerStatusResultData { + public TargetType: number = 0; + public TargetTypeName: string = ''; + public TargetEntityId: string = ''; + public TargetName: string = ''; + public UnitId: string = ''; + public LastCheckIn: string = ''; + public DurationMinutes: number = 0; + public WarningThresholdMinutes: number = 0; + public ElapsedMinutes: number = 0; + public Status: string = ''; +} diff --git a/src/models/v4/checkIn/performCheckInResult.ts b/src/models/v4/checkIn/performCheckInResult.ts new file mode 100644 index 0000000..311f89b --- /dev/null +++ b/src/models/v4/checkIn/performCheckInResult.ts @@ -0,0 +1,5 @@ +import { BaseV4Request } from '../baseV4Request'; + +export class PerformCheckInResult extends BaseV4Request { + public Data: Record = {}; +} diff --git a/src/models/v4/checkIn/resolvedCheckInTimerResult.ts b/src/models/v4/checkIn/resolvedCheckInTimerResult.ts new file mode 100644 index 0000000..40e4fd8 --- /dev/null +++ b/src/models/v4/checkIn/resolvedCheckInTimerResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type ResolvedCheckInTimerResultData } from './resolvedCheckInTimerResultData'; + +export class ResolvedCheckInTimerResult extends BaseV4Request { + public Data: ResolvedCheckInTimerResultData[] = []; +} diff --git a/src/models/v4/checkIn/resolvedCheckInTimerResultData.ts b/src/models/v4/checkIn/resolvedCheckInTimerResultData.ts new file mode 100644 index 0000000..02ac382 --- /dev/null +++ b/src/models/v4/checkIn/resolvedCheckInTimerResultData.ts @@ -0,0 +1,11 @@ +export class ResolvedCheckInTimerResultData { + public TargetType: number = 0; + public TargetTypeName: string = ''; + public UnitTypeId: string = ''; + public TargetEntityId: string = ''; + public TargetName: string = ''; + public DurationMinutes: number = 0; + public WarningThresholdMinutes: number = 0; + public IsFromOverride: boolean = false; + public ActiveForStates: string = ''; +} diff --git a/src/models/v4/customStatuses/customStateDetailTypes.ts b/src/models/v4/customStatuses/customStateDetailTypes.ts new file mode 100644 index 0000000..c1c518a --- /dev/null +++ b/src/models/v4/customStatuses/customStateDetailTypes.ts @@ -0,0 +1,22 @@ +export enum CustomStateDetailTypes { + None = 0, + Stations = 1, + Calls = 2, + CallsAndStations = 3, + Pois = 4, + CallsAndPois = 5, + StationsAndPois = 6, + CallsStationsAndPois = 7, +} + +export const statusDetailAllowsCalls = (detail: number): boolean => { + return detail === CustomStateDetailTypes.Calls || detail === CustomStateDetailTypes.CallsAndStations || detail === CustomStateDetailTypes.CallsAndPois || detail === CustomStateDetailTypes.CallsStationsAndPois; +}; + +export const statusDetailAllowsStations = (detail: number): boolean => { + return detail === CustomStateDetailTypes.Stations || detail === CustomStateDetailTypes.CallsAndStations || detail === CustomStateDetailTypes.StationsAndPois || detail === CustomStateDetailTypes.CallsStationsAndPois; +}; + +export const statusDetailAllowsPois = (detail: number): boolean => { + return detail === CustomStateDetailTypes.Pois || detail === CustomStateDetailTypes.CallsAndPois || detail === CustomStateDetailTypes.StationsAndPois || detail === CustomStateDetailTypes.CallsStationsAndPois; +}; diff --git a/src/models/v4/destinations/destinationEntityTypes.ts b/src/models/v4/destinations/destinationEntityTypes.ts new file mode 100644 index 0000000..856a640 --- /dev/null +++ b/src/models/v4/destinations/destinationEntityTypes.ts @@ -0,0 +1,6 @@ +export enum DestinationEntityTypes { + None = 0, + Station = 1, + Call = 2, + Poi = 3, +} diff --git a/src/models/v4/dispatch/getSetUnitStateResultData.ts b/src/models/v4/dispatch/getSetUnitStateResultData.ts index 3e22f5a..94f2cb4 100644 --- a/src/models/v4/dispatch/getSetUnitStateResultData.ts +++ b/src/models/v4/dispatch/getSetUnitStateResultData.ts @@ -1,11 +1,14 @@ import { type CallResultData } from '../calls/callResultData'; import { type CustomStatusResultData } from '../customStatuses/customStatusResultData'; import { type GroupResultData } from '../groups/groupsResultData'; +import { type PoiResultData, type PoiTypeResultData } from '../mapping/poiResultData'; export class GetSetUnitStateResultData { public UnitId: string = ''; public UnitName: string = ''; public Stations: GroupResultData[] = []; public Calls: CallResultData[] = []; + public DestinationPois: PoiResultData[] = []; + public PoiTypes: PoiTypeResultData[] = []; public Statuses: CustomStatusResultData[] = []; } diff --git a/src/models/v4/dispatch/newCallFormResultData.ts b/src/models/v4/dispatch/newCallFormResultData.ts index 0cf87b1..3dd1b00 100644 --- a/src/models/v4/dispatch/newCallFormResultData.ts +++ b/src/models/v4/dispatch/newCallFormResultData.ts @@ -3,6 +3,7 @@ import { type CallTypeResultData } from '../callTypes/callTypeResultData'; import { type CustomStatusResultData } from '../customStatuses/customStatusResultData'; import { type GroupResultData } from '../groups/groupsResultData'; import { type PersonnelInfoResultData } from '../personnel/personnelInfoResultData'; +import { type PoiResultData, type PoiTypeResultData } from '../mapping/poiResultData'; import { type RoleResultData } from '../roles/roleResultData'; import { type UnitRoleResultData } from '../unitRoles/unitRoleResultData'; import { type UnitResultData } from '../units/unitResultData'; @@ -18,4 +19,6 @@ export class NewCallFormResultData { public UnitRoles: UnitRoleResultData[] = []; public Priorities: CallPriorityResultData[] = []; public CallTypes: CallTypeResultData[] = []; + public PoiTypes: PoiTypeResultData[] = []; + public DestinationPois: PoiResultData[] = []; } diff --git a/src/models/v4/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts index cf3ecb2..4f69720 100644 --- a/src/models/v4/mapping/getMapDataAndMarkersData.ts +++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts @@ -1,8 +1,11 @@ +import { PoiLayerData } from './poiResultData'; + export class MapDataAndMarkersData { - public CenterLat: string = ''; - public CenterLon: string = ''; - public ZoomLevel: string = ''; + public CenterLat: number | string = 0; + public CenterLon: number | string = 0; + public ZoomLevel: number | string = 0; public MapMakerInfos: MapMakerInfoData[] = []; + public PoiLayers?: PoiLayerData[] = []; } export class MapMakerInfoData { @@ -10,9 +13,16 @@ export class MapMakerInfoData { public Longitude: number = 0; public Latitude: number = 0; public Title: string = ''; - public zIndex: string = ''; + public zIndex: number | string = 0; public ImagePath: string = ''; public InfoWindowContent: string = ''; public Color: string = ''; public Type: number = 0; + public Marker?: string = ''; + public PoiTypeId?: number | null = null; + public PoiTypeName?: string = ''; + public Address?: string = ''; + public Note?: string = ''; + public LayerId?: string = ''; + public LayerName?: string = ''; } diff --git a/src/models/v4/mapping/poiResultData.ts b/src/models/v4/mapping/poiResultData.ts new file mode 100644 index 0000000..b87927c --- /dev/null +++ b/src/models/v4/mapping/poiResultData.ts @@ -0,0 +1,32 @@ +export class PoiTypeResultData { + public PoiTypeId: number = 0; + public Name: string = ''; + public Color: string = ''; + public ImagePath: string = ''; + public Marker: string = ''; + public IsDestination: boolean = false; +} + +export class PoiResultData { + public PoiId: number = 0; + public PoiTypeId: number = 0; + public PoiTypeName: string = ''; + public Name: string = ''; + public Address: string = ''; + public Note: string = ''; + public Latitude: number = 0; + public Longitude: number = 0; + public Color: string = ''; + public ImagePath: string = ''; + public Marker: string = ''; + public IsDestination: boolean = false; +} + +export class PoiLayerData { + public PoiTypeId: number = 0; + public Name: string = ''; + public Color: string = ''; + public ImagePath: string = ''; + public Marker: string = ''; + public IsDestination: boolean = false; +} diff --git a/src/models/v4/mapping/poiResults.ts b/src/models/v4/mapping/poiResults.ts new file mode 100644 index 0000000..105f047 --- /dev/null +++ b/src/models/v4/mapping/poiResults.ts @@ -0,0 +1,15 @@ +import { BaseV4Request } from '../baseV4Request'; + +import { PoiResultData, PoiTypeResultData } from './poiResultData'; + +export class PoiResult extends BaseV4Request { + public Data: PoiResultData = new PoiResultData(); +} + +export class PoisResult extends BaseV4Request { + public Data: PoiResultData[] = []; +} + +export class PoiTypesResult extends BaseV4Request { + public Data: PoiTypeResultData[] = []; +} diff --git a/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts b/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts index 3ee0447..b8afa90 100644 --- a/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts +++ b/src/models/v4/personnelStatuses/getCurrentStatusResultData.ts @@ -5,7 +5,10 @@ export class GetCurrentStatusResultData { public TimestampUtc: string = ''; public Timestamp: string = ''; public Note: string = ''; - public DestinationId: string = ''; - public DestinationType: string = ''; + public DestinationId: number | string | null = null; + public DestinationType: number | string | null = null; + public DestinationName: string = ''; + public DestinationAddress: string = ''; + public DestinationTypeName: string = ''; public GeoLocationData: string = ''; } diff --git a/src/models/v4/unitStatus/saveUnitStatusInput.ts b/src/models/v4/unitStatus/saveUnitStatusInput.ts index 03d00dd..623c795 100644 --- a/src/models/v4/unitStatus/saveUnitStatusInput.ts +++ b/src/models/v4/unitStatus/saveUnitStatusInput.ts @@ -2,6 +2,7 @@ export class SaveUnitStatusInput { public Id: string = ''; public Type: string = ''; public RespondingTo: string = ''; + public RespondingToType: number | null = null; public TimestampUtc: string = ''; public Timestamp: string = ''; public Note: string = ''; diff --git a/src/models/v4/unitStatus/unitStatusResultData.ts b/src/models/v4/unitStatus/unitStatusResultData.ts index 4672503..3673ca0 100644 --- a/src/models/v4/unitStatus/unitStatusResultData.ts +++ b/src/models/v4/unitStatus/unitStatusResultData.ts @@ -1,16 +1,21 @@ export class UnitStatusResultData { + public UnitId: string = ''; public Name: string = ''; public Type: string = ''; public State: string = ''; public StateCss: string = ''; public StateStyle: string = ''; public Timestamp: string = ''; - public DestinationId: string = ''; + public TimestampUtc?: string = ''; + public DestinationId?: number | string | null = null; + public DestinationType?: number | string | null = null; + public DestinationName?: string = ''; + public DestinationAddress?: string = ''; + public DestinationTypeName?: string = ''; public Note: string = ''; - public Latitude: string = ''; - public Longitude: string = ''; + public Latitude: number | string | null = null; + public Longitude: number | string | null = null; public GroupName: string = ''; - public GroupId: string = ''; + public GroupId: number | string = ''; public Eta: string = ''; - public UnitId: string = ''; } diff --git a/src/models/v4/weatherAlerts/activeWeatherAlertsResult.ts b/src/models/v4/weatherAlerts/activeWeatherAlertsResult.ts new file mode 100644 index 0000000..d4361cc --- /dev/null +++ b/src/models/v4/weatherAlerts/activeWeatherAlertsResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type WeatherAlertResultData } from './weatherAlertResultData'; + +export class ActiveWeatherAlertsResult extends BaseV4Request { + public Data: WeatherAlertResultData[] = []; +} diff --git a/src/models/v4/weatherAlerts/weatherAlertEnums.ts b/src/models/v4/weatherAlerts/weatherAlertEnums.ts new file mode 100644 index 0000000..d06a4a2 --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertEnums.ts @@ -0,0 +1,44 @@ +export enum WeatherAlertSeverity { + Extreme = 0, + Severe = 1, + Moderate = 2, + Minor = 3, + Unknown = 4, +} + +export enum WeatherAlertCategory { + Met = 0, + Fire = 1, + Health = 2, + Env = 3, + Other = 4, +} + +export enum WeatherAlertUrgency { + Immediate = 0, + Expected = 1, + Future = 2, + Past = 3, + Unknown = 4, +} + +export enum WeatherAlertCertainty { + Observed = 0, + Likely = 1, + Possible = 2, + Unlikely = 3, + Unknown = 4, +} + +export enum WeatherAlertStatus { + Active = 0, + Updated = 1, + Expired = 2, + Cancelled = 3, +} + +export enum WeatherAlertSourceType { + NWS = 0, + EnvironmentCanada = 1, + MeteoAlarm = 2, +} diff --git a/src/models/v4/weatherAlerts/weatherAlertResult.ts b/src/models/v4/weatherAlerts/weatherAlertResult.ts new file mode 100644 index 0000000..8920f49 --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { WeatherAlertResultData } from './weatherAlertResultData'; + +export class WeatherAlertResult extends BaseV4Request { + public Data: WeatherAlertResultData = new WeatherAlertResultData(); +} diff --git a/src/models/v4/weatherAlerts/weatherAlertResultData.ts b/src/models/v4/weatherAlerts/weatherAlertResultData.ts new file mode 100644 index 0000000..4aa0596 --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertResultData.ts @@ -0,0 +1,28 @@ +export class WeatherAlertResultData { + public WeatherAlertId: string = ''; + public DepartmentId: number = 0; + public Event: string = ''; + public Headline: string = ''; + public Description: string = ''; + public Instructions: string = ''; + public Severity: number = 4; + public Category: number = 4; + public Urgency: number = 4; + public Certainty: number = 4; + public Status: number = 0; + public SourceType: number = 0; + public SourceAlertId: string = ''; + public SenderName: string = ''; + public AreaDescription: string = ''; + public Polygon: string = ''; + public CenterGeoLocation: string = ''; + public EffectiveUtc: string = ''; + public OnsetUtc: string = ''; + public ExpiresUtc: string = ''; + public Ends: string = ''; + public ReceivedOnUtc: string = ''; + public UpdatedOnUtc: string = ''; + public WebUrl: string = ''; + public ZoneCode: string = ''; + public MessageType: string = ''; +} diff --git a/src/models/v4/weatherAlerts/weatherAlertSettingsData.ts b/src/models/v4/weatherAlerts/weatherAlertSettingsData.ts new file mode 100644 index 0000000..1d958fc --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertSettingsData.ts @@ -0,0 +1,8 @@ +export class WeatherAlertSettingsData { + public WeatherAlertsEnabled: boolean = false; + public MinimumSeverity: number = 4; + public AutoMessageSeverity: number = 0; + public CallIntegrationEnabled: boolean = false; + public AutoMessageSchedule: string[] = []; + public ExcludedEvents: string = ''; +} diff --git a/src/models/v4/weatherAlerts/weatherAlertSettingsResult.ts b/src/models/v4/weatherAlerts/weatherAlertSettingsResult.ts new file mode 100644 index 0000000..abe2aa1 --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertSettingsResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { WeatherAlertSettingsData } from './weatherAlertSettingsData'; + +export class WeatherAlertSettingsResult extends BaseV4Request { + public Data: WeatherAlertSettingsData = new WeatherAlertSettingsData(); +} diff --git a/src/models/v4/weatherAlerts/weatherAlertZoneResultData.ts b/src/models/v4/weatherAlerts/weatherAlertZoneResultData.ts new file mode 100644 index 0000000..9468203 --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertZoneResultData.ts @@ -0,0 +1,9 @@ +export class WeatherAlertZoneResultData { + public WeatherAlertZoneId: string = ''; + public Name: string = ''; + public ZoneCode: string = ''; + public CenterGeoLocation: string = ''; + public RadiusMiles: number = 0; + public IsActive: boolean = false; + public IsPrimary: boolean = false; +} diff --git a/src/models/v4/weatherAlerts/weatherAlertZonesResult.ts b/src/models/v4/weatherAlerts/weatherAlertZonesResult.ts new file mode 100644 index 0000000..5dec4da --- /dev/null +++ b/src/models/v4/weatherAlerts/weatherAlertZonesResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type WeatherAlertZoneResultData } from './weatherAlertZoneResultData'; + +export class WeatherAlertZonesResult extends BaseV4Request { + public Data: WeatherAlertZoneResultData[] = []; +} diff --git a/src/services/__tests__/offline-event-manager-gps.test.ts b/src/services/__tests__/offline-event-manager-gps.test.ts index c7b3fbc..ada3157 100644 --- a/src/services/__tests__/offline-event-manager-gps.test.ts +++ b/src/services/__tests__/offline-event-manager-gps.test.ts @@ -68,6 +68,7 @@ describe('Offline Event Manager GPS Integration', () => { 'available', 'GPS enabled status', 'call-123', + 2, [{ roleId: 'role-1', userId: 'user-1' }], gpsData ); @@ -80,6 +81,7 @@ describe('Offline Event Manager GPS Integration', () => { statusType: 'available', note: 'GPS enabled status', respondingTo: 'call-123', + respondingToType: 2, roles: [{ roleId: 'role-1', userId: 'user-1' }], latitude: '40.7128', longitude: '-74.0060', @@ -108,6 +110,7 @@ describe('Offline Event Manager GPS Integration', () => { 'Partial GPS', undefined, undefined, + undefined, gpsData ); @@ -119,6 +122,7 @@ describe('Offline Event Manager GPS Integration', () => { statusType: 'en-route', note: 'Partial GPS', respondingTo: undefined, + respondingToType: undefined, roles: undefined, latitude: '51.5074', longitude: '-0.1278', @@ -147,6 +151,7 @@ describe('Offline Event Manager GPS Integration', () => { statusType: 'on-scene', note: undefined, respondingTo: undefined, + respondingToType: undefined, roles: undefined, latitude: undefined, longitude: undefined, @@ -177,6 +182,7 @@ describe('Offline Event Manager GPS Integration', () => { 'Edge case GPS', undefined, undefined, + undefined, gpsData ); @@ -212,6 +218,7 @@ describe('Offline Event Manager GPS Integration', () => { statusType: 'available', note: 'Test note', respondingTo: 'call-123', + respondingToType: 2, timestamp: '2023-01-01T00:00:00Z', timestampUtc: 'Sun, 01 Jan 2023 00:00:00 GMT', roles: [{ roleId: 'role-1', userId: 'user-1' }], @@ -236,6 +243,7 @@ describe('Offline Event Manager GPS Integration', () => { Type: 'available', Note: 'Test note', RespondingTo: 'call-123', + RespondingToType: 2, Timestamp: '2023-01-01T00:00:00Z', TimestampUtc: 'Sun, 01 Jan 2023 00:00:00 GMT', Latitude: '40.7128', @@ -474,6 +482,7 @@ describe('Offline Event Manager GPS Integration', () => { 'responding', 'Tokyo location', 'emergency-call', + 2, [{ roleId: 'medic', userId: 'user-medic' }], gpsData ); @@ -485,6 +494,7 @@ describe('Offline Event Manager GPS Integration', () => { statusType: 'responding', note: 'Tokyo location', respondingTo: 'emergency-call', + respondingToType: 2, roles: [{ roleId: 'medic', userId: 'user-medic' }], latitude: '35.6762', longitude: '139.6503', @@ -516,6 +526,7 @@ describe('Offline Event Manager GPS Integration', () => { Type: 'responding', Note: 'Tokyo location', RespondingTo: 'emergency-call', + RespondingToType: 2, Latitude: '35.6762', Longitude: '139.6503', Accuracy: '12', diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index 02e6f58..e7781b4 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -28,6 +28,10 @@ jest.mock('@/api/units/unitStatuses', () => ({ saveUnitStatus: jest.fn(), })); +jest.mock('@/api/check-in-timers/check-in-timers', () => ({ + performCheckIn: jest.fn(), +})); + // Mock the offline queue store jest.mock('@/stores/offline-queue/store', () => ({ useOfflineQueueStore: { @@ -139,6 +143,7 @@ describe('OfflineEventManager', () => { 'available', 'Test note', 'call-1', + 2, [{ roleId: 'role-1', userId: 'user-1' }] ); @@ -150,6 +155,7 @@ describe('OfflineEventManager', () => { statusType: 'available', note: 'Test note', respondingTo: 'call-1', + respondingToType: 2, roles: [{ roleId: 'role-1', userId: 'user-1' }], timestamp: expect.any(String), timestampUtc: expect.any(String), @@ -168,6 +174,7 @@ describe('OfflineEventManager', () => { statusType: 'available', note: undefined, respondingTo: undefined, + respondingToType: undefined, roles: undefined, }) ); diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index 88e4df8..62f9a1e 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -58,6 +58,23 @@ jest.mock('@/stores/security/store', () => ({ }), })); +jest.mock('@/stores/check-in-timers/store', () => ({ + useCheckInTimerStore: { + getState: jest.fn(() => ({ + performCheckIn: jest.fn(), + })), + }, +})); + +jest.mock('@/stores/app/location-store', () => ({ + useLocationStore: { + getState: jest.fn(() => ({ + latitude: null, + longitude: null, + })), + }, +})); + // Mock Firebase messaging const mockFcmUnsubscribe = jest.fn(); const mockOnMessage = jest.fn(() => mockFcmUnsubscribe); @@ -556,9 +573,10 @@ describe('Push Notification Service Integration', () => { // Verify channels were created // Standard channels: calls, 0-3, notif, message = 7 + // Check-in timers channel = 1 // Custom channels: c1-c25 = 25 - // Total: 32 channels - expect(mockCreateChannel).toHaveBeenCalledTimes(32); + // Total: 33 channels + expect(mockCreateChannel).toHaveBeenCalledTimes(33); }); }); }); diff --git a/src/services/check-in-notification.service.ts b/src/services/check-in-notification.service.ts new file mode 100644 index 0000000..3408912 --- /dev/null +++ b/src/services/check-in-notification.service.ts @@ -0,0 +1,111 @@ +import notifee, { AndroidImportance } from '@notifee/react-native'; +import { Platform } from 'react-native'; + +import { logger } from '@/lib/logging'; + +const CHANNEL_ID = 'check-in-timers'; +const NOTIFICATION_ID = 'check-in-timer-notification'; + +const STATUS_LABELS: Record = { + Ok: 'OK', + Warning: 'WARNING', + Overdue: 'OVERDUE', +}; + +class CheckInNotificationService { + private static instance: CheckInNotificationService; + private countdownInterval: ReturnType | null = null; + private currentSeconds: number = 0; + private currentStatus: string = 'Ok'; + private channelCreated: boolean = false; + + static getInstance(): CheckInNotificationService { + if (!CheckInNotificationService.instance) { + CheckInNotificationService.instance = new CheckInNotificationService(); + } + return CheckInNotificationService.instance; + } + + private async ensureChannel(): Promise { + if (this.channelCreated || Platform.OS !== 'android') return; + + await notifee.createChannel({ + id: CHANNEL_ID, + name: 'Check-In Timers', + description: 'Timer notifications for call check-ins', + importance: AndroidImportance.LOW, + }); + this.channelCreated = true; + } + + async startNotification(callName: string, callNumber: string, timerName: string, secondsRemaining: number, status: string): Promise { + if (Platform.OS !== 'android') return; + + await this.ensureChannel(); + this.currentSeconds = secondsRemaining; + this.currentStatus = status; + + await this.displayNotification(callName, callNumber, timerName); + + // Local 1s countdown for smooth updates + this.stopCountdown(); + this.countdownInterval = setInterval(async () => { + this.currentSeconds = Math.max(0, this.currentSeconds - 1); + await this.displayNotification(callName, callNumber, timerName); + }, 1000); + } + + async updateNotification(secondsRemaining: number, status: string): Promise { + this.currentSeconds = secondsRemaining; + this.currentStatus = status; + } + + async stopNotification(): Promise { + this.stopCountdown(); + if (Platform.OS === 'android') { + try { + await notifee.cancelNotification(NOTIFICATION_ID); + } catch (error) { + logger.error({ message: 'Failed to cancel check-in notification', context: { error } }); + } + } + } + + private async displayNotification(callName: string, callNumber: string, timerName: string): Promise { + const minutes = Math.floor(this.currentSeconds / 60); + const seconds = this.currentSeconds % 60; + const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; + const statusLabel = STATUS_LABELS[this.currentStatus] ?? this.currentStatus; + + try { + await notifee.displayNotification({ + id: NOTIFICATION_ID, + title: `${callName} #${callNumber}`, + body: `${timerName} - ${timeStr} remaining [${statusLabel}]`, + android: { + channelId: CHANNEL_ID, + ongoing: true, + smallIcon: 'ic_notification', + pressAction: { id: 'default' }, + actions: [ + { + title: 'Check In', + pressAction: { id: 'check-in' }, + }, + ], + }, + }); + } catch (error) { + logger.error({ message: 'Failed to display check-in notification', context: { error } }); + } + } + + private stopCountdown(): void { + if (this.countdownInterval) { + clearInterval(this.countdownInterval); + this.countdownInterval = null; + } + } +} + +export const checkInNotificationService = CheckInNotificationService.getInstance(); diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index 54cb9d4..55a7a44 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -1,10 +1,19 @@ import { AppState, type AppStateStatus } from 'react-native'; import { saveCallImage } from '@/api/calls/callFiles'; +import { performCheckIn } from '@/api/check-in-timers/check-in-timers'; import { setUnitLocation } from '@/api/units/unitLocation'; import { saveUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; -import { type QueuedCallImageUploadEvent, type QueuedEvent, QueuedEventStatus, QueuedEventType, type QueuedLocationUpdateEvent, type QueuedUnitStatusEvent } from '@/models/offline-queue/queued-event'; +import { + type QueuedCallImageUploadEvent, + type QueuedCheckInEvent, + type QueuedEvent, + QueuedEventStatus, + QueuedEventType, + type QueuedLocationUpdateEvent, + type QueuedUnitStatusEvent, +} from '@/models/offline-queue/queued-event'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { useOfflineQueueStore } from '@/stores/offline-queue/store'; @@ -87,6 +96,7 @@ class OfflineEventManager { statusType: string, note?: string, respondingTo?: string, + respondingToType?: number | string | null, roles?: { roleId: string; userId: string }[], gpsData?: { latitude?: string; @@ -104,6 +114,7 @@ class OfflineEventManager { statusType, note, respondingTo, + respondingToType, timestamp: date.toISOString(), timestampUtc: date.toUTCString().replace('UTC', 'GMT'), roles, @@ -153,6 +164,37 @@ class OfflineEventManager { return useOfflineQueueStore.getState().addEvent(QueuedEventType.CALL_IMAGE_UPLOAD, data); } + /** + * Add a check-in event to the queue + */ + public queueCheckInEvent(callId: number, checkInType: number, unitId?: number, latitude?: string, longitude?: string, note?: string): string { + const data = { + callId, + checkInType, + unitId, + latitude, + longitude, + note, + timestamp: new Date().toISOString(), + }; + + return useOfflineQueueStore.getState().addEvent(QueuedEventType.CHECK_IN, data); + } + + /** + * Process check-in event + */ + private async processCheckInEvent(event: QueuedCheckInEvent): Promise { + await performCheckIn({ + CallId: event.data.callId, + CheckInType: event.data.checkInType, + UnitId: event.data.unitId, + Latitude: event.data.latitude, + Longitude: event.data.longitude, + Note: event.data.note, + }); + } + /** * Process queued events */ @@ -229,6 +271,9 @@ class OfflineEventManager { case QueuedEventType.CALL_IMAGE_UPLOAD: await this.processCallImageUploadEvent(event as QueuedCallImageUploadEvent); break; + case QueuedEventType.CHECK_IN: + await this.processCheckInEvent(event as QueuedCheckInEvent); + break; default: throw new Error(`Unknown event type: ${event.type}`); } @@ -266,6 +311,7 @@ class OfflineEventManager { input.Type = event.data.statusType; input.Note = event.data.note || ''; input.RespondingTo = event.data.respondingTo || '0'; + input.RespondingToType = event.data.respondingToType == null || event.data.respondingToType === '' ? null : Number(event.data.respondingToType); input.Timestamp = event.data.timestamp; input.TimestampUtc = event.data.timestampUtc; diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 07d4c68..4695c5f 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -61,6 +61,15 @@ class PushNotificationService { await this.createNotificationChannel('notif', 'Notification', 'Notifications', undefined, false); await this.createNotificationChannel('message', 'Message', 'Messages', undefined, false); + // Check-in timers channel (silent updates) + await notifee.createChannel({ + id: 'check-in-timers', + name: 'Check-In Timers', + description: 'Timer notifications for call check-ins', + importance: AndroidImportance.LOW, + vibration: false, + }); + // Custom call channels (c1-c25) for (let i = 1; i <= 25; i++) { const channelId = `c${i}`; @@ -188,6 +197,26 @@ class PushNotificationService { context: { type, detail: { id: detail.notification?.id, data: detail.notification?.data } }, }); + // Handle check-in action press + if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') { + logger.info({ message: 'Check-in action pressed from notification' }); + // Trigger quick check-in — store will handle it + const { useCheckInTimerStore } = require('@/stores/check-in-timers/store'); + const { useCoreStore } = require('@/stores/app/core-store'); + const { useLocationStore } = require('@/stores/app/location-store'); + const activeCall = useCoreStore.getState().activeCall; + const activeUnit = useCoreStore.getState().activeUnit; + if (activeCall) { + await useCheckInTimerStore.getState().performCheckIn({ + CallId: parseInt(activeCall.CallId, 10), + CheckInType: activeUnit ? 1 : 0, + UnitId: activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined, + Latitude: useLocationStore.getState().latitude?.toString(), + Longitude: useLocationStore.getState().longitude?.toString(), + }); + } + } + // Handle notification press if (type === EventType.PRESS && detail.notification) { const eventCode = detail.notification.data?.eventCode as string | undefined; diff --git a/src/stores/call-video-feeds/__tests__/store.test.ts b/src/stores/call-video-feeds/__tests__/store.test.ts new file mode 100644 index 0000000..faecb0f --- /dev/null +++ b/src/stores/call-video-feeds/__tests__/store.test.ts @@ -0,0 +1,198 @@ +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: jest.fn((specifics: any) => specifics.ios || specifics.default), + Version: 17, + }, + AppState: { + addEventListener: jest.fn(), + currentState: 'active', + }, +})); + +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + set: jest.fn(), + getString: jest.fn(), + delete: jest.fn(), + })), +})); + +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { deleteCallVideoFeed, editCallVideoFeed, getCallVideoFeeds, saveCallVideoFeed } from '@/api/call-video-feeds/call-video-feeds'; +import { useCallVideoFeedStore } from '../store'; + +jest.mock('@/api/call-video-feeds/call-video-feeds'); +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockGetCallVideoFeeds = getCallVideoFeeds as jest.MockedFunction; +const mockSaveCallVideoFeed = saveCallVideoFeed as jest.MockedFunction; +const mockEditCallVideoFeed = editCallVideoFeed as jest.MockedFunction; +const mockDeleteCallVideoFeed = deleteCallVideoFeed as jest.MockedFunction; + +const emptyResult = { PageSize: 0, Timestamp: '', Version: '', Node: '', RequestId: '', Status: '', Environment: '' }; + +describe('useCallVideoFeedStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useCallVideoFeedStore.getState().reset(); + }); + + it('should have correct initial state', () => { + const state = useCallVideoFeedStore.getState(); + expect(state.feeds).toEqual([]); + expect(state.isLoadingFeeds).toBe(false); + expect(state.isSaving).toBe(false); + expect(state.isDeleting).toBe(false); + expect(state.feedsError).toBeNull(); + expect(state.saveError).toBeNull(); + }); + + describe('fetchFeeds', () => { + it('should fetch and sort feeds by SortOrder', async () => { + const mockData = [ + { CallVideoFeedId: '2', Name: 'Feed B', SortOrder: 2, CallId: '1', Url: '', FeedType: 0, FeedFormat: 0, Description: '', Status: 0, Latitude: '', Longitude: '', AddedByUserId: '', AddedOnFormatted: '', AddedOnUtc: '', FullName: '' }, + { CallVideoFeedId: '1', Name: 'Feed A', SortOrder: 1, CallId: '1', Url: '', FeedType: 0, FeedFormat: 0, Description: '', Status: 0, Latitude: '', Longitude: '', AddedByUserId: '', AddedOnFormatted: '', AddedOnUtc: '', FullName: '' }, + ]; + + mockGetCallVideoFeeds.mockResolvedValue({ ...emptyResult, Data: mockData }); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + await act(async () => { + await result.current.fetchFeeds(1); + }); + + await waitFor(() => { + expect(result.current.feeds).toHaveLength(2); + expect(result.current.feeds[0].Name).toBe('Feed A'); + expect(result.current.feeds[1].Name).toBe('Feed B'); + expect(result.current.isLoadingFeeds).toBe(false); + }); + }); + + it('should handle fetch errors', async () => { + mockGetCallVideoFeeds.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + await act(async () => { + await result.current.fetchFeeds(1); + }); + + await waitFor(() => { + expect(result.current.feedsError).toBe('Network error'); + expect(result.current.isLoadingFeeds).toBe(false); + }); + }); + }); + + describe('saveFeed', () => { + it('should save feed and re-fetch', async () => { + mockSaveCallVideoFeed.mockResolvedValue({ ...emptyResult, Id: 'new-id' }); + mockGetCallVideoFeeds.mockResolvedValue({ ...emptyResult, Data: [] }); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + let success = false; + await act(async () => { + success = await result.current.saveFeed({ CallId: 1, Name: 'Test', Url: 'https://example.com/stream.m3u8' }); + }); + + expect(success).toBe(true); + expect(mockSaveCallVideoFeed).toHaveBeenCalled(); + expect(result.current.isSaving).toBe(false); + }); + + it('should handle save errors', async () => { + mockSaveCallVideoFeed.mockRejectedValue(new Error('Save failed')); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + let success = true; + await act(async () => { + success = await result.current.saveFeed({ CallId: 1, Name: 'Test', Url: 'https://example.com' }); + }); + + expect(success).toBe(false); + expect(result.current.saveError).toBe('Save failed'); + }); + }); + + describe('editFeed', () => { + it('should edit feed and re-fetch', async () => { + mockEditCallVideoFeed.mockResolvedValue({ ...emptyResult, Id: 'feed-1' }); + mockGetCallVideoFeeds.mockResolvedValue({ ...emptyResult, Data: [] }); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + let success = false; + await act(async () => { + success = await result.current.editFeed({ CallVideoFeedId: 'feed-1', CallId: 1, Name: 'Updated', Url: 'https://example.com' }); + }); + + expect(success).toBe(true); + expect(mockEditCallVideoFeed).toHaveBeenCalled(); + }); + }); + + describe('deleteFeed', () => { + it('should delete feed and re-fetch', async () => { + mockDeleteCallVideoFeed.mockResolvedValue({ ...emptyResult, Id: 'feed-1' }); + mockGetCallVideoFeeds.mockResolvedValue({ ...emptyResult, Data: [] }); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + let success = false; + await act(async () => { + success = await result.current.deleteFeed('feed-1', 1); + }); + + expect(success).toBe(true); + expect(mockDeleteCallVideoFeed).toHaveBeenCalledWith('feed-1'); + expect(result.current.isDeleting).toBe(false); + }); + + it('should handle delete errors', async () => { + mockDeleteCallVideoFeed.mockRejectedValue(new Error('Delete failed')); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + let success = true; + await act(async () => { + success = await result.current.deleteFeed('feed-1', 1); + }); + + expect(success).toBe(false); + expect(result.current.isDeleting).toBe(false); + }); + }); + + describe('reset', () => { + it('should reset state to initial values', () => { + useCallVideoFeedStore.setState({ + feeds: [{ CallVideoFeedId: '1', Name: 'Test' }] as any, + isLoadingFeeds: true, + feedsError: 'some error', + }); + + const { result } = renderHook(() => useCallVideoFeedStore()); + + act(() => { + result.current.reset(); + }); + + expect(result.current.feeds).toEqual([]); + expect(result.current.isLoadingFeeds).toBe(false); + expect(result.current.feedsError).toBeNull(); + }); + }); +}); diff --git a/src/stores/call-video-feeds/store.ts b/src/stores/call-video-feeds/store.ts new file mode 100644 index 0000000..cdd0bfe --- /dev/null +++ b/src/stores/call-video-feeds/store.ts @@ -0,0 +1,94 @@ +import { create } from 'zustand'; + +import { deleteCallVideoFeed, editCallVideoFeed, type EditCallVideoFeedInput, getCallVideoFeeds, saveCallVideoFeed, type SaveCallVideoFeedInput } from '@/api/call-video-feeds/call-video-feeds'; +import { logger } from '@/lib/logging'; +import type { CallVideoFeedResultData } from '@/models/v4/callVideoFeeds/callVideoFeedResultData'; + +interface CallVideoFeedState { + feeds: CallVideoFeedResultData[]; + isLoadingFeeds: boolean; + isSaving: boolean; + isDeleting: boolean; + feedsError: string | null; + saveError: string | null; + + fetchFeeds: (callId: number) => Promise; + saveFeed: (input: SaveCallVideoFeedInput) => Promise; + editFeed: (input: EditCallVideoFeedInput) => Promise; + deleteFeed: (feedId: string, callId: number) => Promise; + reset: () => void; +} + +const initialState = { + feeds: [], + isLoadingFeeds: false, + isSaving: false, + isDeleting: false, + feedsError: null, + saveError: null, +}; + +export const useCallVideoFeedStore = create((set, get) => ({ + ...initialState, + + fetchFeeds: async (callId: number) => { + set({ isLoadingFeeds: true, feedsError: null }); + try { + const result = await getCallVideoFeeds(callId); + const sorted = [...result.Data].sort((a, b) => a.SortOrder - b.SortOrder); + set({ feeds: sorted, isLoadingFeeds: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch video feeds'; + logger.error({ message: 'Failed to fetch video feeds', context: { error, callId } }); + set({ feedsError: message, isLoadingFeeds: false }); + } + }, + + saveFeed: async (input: SaveCallVideoFeedInput) => { + set({ isSaving: true, saveError: null }); + try { + await saveCallVideoFeed(input); + set({ isSaving: false }); + get().fetchFeeds(input.CallId); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save video feed'; + logger.error({ message: 'Failed to save video feed', context: { error, input } }); + set({ saveError: message, isSaving: false }); + return false; + } + }, + + editFeed: async (input: EditCallVideoFeedInput) => { + set({ isSaving: true, saveError: null }); + try { + await editCallVideoFeed(input); + set({ isSaving: false }); + get().fetchFeeds(input.CallId); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to edit video feed'; + logger.error({ message: 'Failed to edit video feed', context: { error, input } }); + set({ saveError: message, isSaving: false }); + return false; + } + }, + + deleteFeed: async (feedId: string, callId: number) => { + set({ isDeleting: true }); + try { + await deleteCallVideoFeed(feedId); + set({ isDeleting: false }); + get().fetchFeeds(callId); + return true; + } catch (error) { + logger.error({ message: 'Failed to delete video feed', context: { error, feedId } }); + set({ isDeleting: false }); + return false; + } + }, + + reset: () => { + set({ ...initialState }); + }, +})); diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index 82290a2..d6a7abc 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -3,14 +3,18 @@ import { create } from 'zustand'; import { getCallPriorities } from '@/api/calls/callPriorities'; import { getCalls } from '@/api/calls/calls'; import { getCallTypes } from '@/api/calls/callTypes'; +import { getNewCallData } from '@/api/dispatch/dispatch'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type CallTypeResultData } from '@/models/v4/callTypes/callTypeResultData'; +import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; interface CallsState { calls: CallResultData[]; callPriorities: CallPriorityResultData[]; callTypes: CallTypeResultData[]; + destinationPois: PoiResultData[]; + poiTypes: PoiTypeResultData[]; isLoading: boolean; isInitialized: boolean; error: string | null; @@ -18,6 +22,7 @@ interface CallsState { fetchCalls: () => Promise; fetchCallPriorities: () => Promise; fetchCallTypes: () => Promise; + fetchCallFormData: () => Promise; init: () => Promise; } @@ -25,6 +30,8 @@ export const useCallsStore = create((set, get) => ({ calls: [], callPriorities: [], callTypes: [], + destinationPois: [], + poiTypes: [], isLoading: false, isInitialized: false, error: null, @@ -80,4 +87,25 @@ export const useCallsStore = create((set, get) => ({ set({ error: 'Failed to fetch call types', isLoading: false }); } }, + fetchCallFormData: async () => { + const { callPriorities, callTypes, destinationPois, poiTypes } = get(); + if (callPriorities.length > 0 && callTypes.length > 0 && destinationPois.length > 0 && poiTypes.length > 0) { + return; + } + + set({ isLoading: true, error: null }); + try { + const response = await getNewCallData(); + const data = response.Data; + set({ + callPriorities: Array.isArray(data?.Priorities) ? data.Priorities : [], + callTypes: Array.isArray(data?.CallTypes) ? data.CallTypes : [], + destinationPois: Array.isArray(data?.DestinationPois) ? data.DestinationPois : [], + poiTypes: Array.isArray(data?.PoiTypes) ? data.PoiTypes : [], + isLoading: false, + }); + } catch (error) { + set({ error: 'Failed to fetch call form data', isLoading: false }); + } + }, })); diff --git a/src/stores/check-in-timers/__tests__/store.test.ts b/src/stores/check-in-timers/__tests__/store.test.ts new file mode 100644 index 0000000..84eb15c --- /dev/null +++ b/src/stores/check-in-timers/__tests__/store.test.ts @@ -0,0 +1,193 @@ +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: jest.fn((specifics: any) => specifics.ios || specifics.default), + Version: 17, + }, + AppState: { + addEventListener: jest.fn(), + currentState: 'active', + }, +})); + +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + set: jest.fn(), + getString: jest.fn(), + delete: jest.fn(), + })), +})); + +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { getCheckInHistory, getTimerStatuses, getTimersForCall, performCheckIn } from '@/api/check-in-timers/check-in-timers'; +import { useCheckInTimerStore } from '../store'; + +jest.mock('@/api/check-in-timers/check-in-timers'); +jest.mock('@/services/offline-event-manager.service', () => ({ + offlineEventManager: { + queueCheckInEvent: jest.fn(), + }, +})); +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockGetTimerStatuses = getTimerStatuses as jest.MockedFunction; +const mockGetTimersForCall = getTimersForCall as jest.MockedFunction; +const mockGetCheckInHistory = getCheckInHistory as jest.MockedFunction; +const mockPerformCheckIn = performCheckIn as jest.MockedFunction; + +describe('useCheckInTimerStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + useCheckInTimerStore.getState().reset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should have correct initial state', () => { + const state = useCheckInTimerStore.getState(); + expect(state.timerStatuses).toEqual([]); + expect(state.resolvedTimers).toEqual([]); + expect(state.checkInHistory).toEqual([]); + expect(state.isLoadingStatuses).toBe(false); + expect(state.isLoadingHistory).toBe(false); + expect(state.isCheckingIn).toBe(false); + expect(state.statusError).toBeNull(); + expect(state.checkInError).toBeNull(); + }); + + describe('fetchTimerStatuses', () => { + it('should fetch and sort timer statuses by severity', async () => { + const mockData = [ + { TargetEntityId: '1', Status: 'Ok', ElapsedMinutes: 5, DurationMinutes: 30, TargetType: 0, TargetTypeName: 'Unit', TargetName: 'Engine 1', UnitId: '1', LastCheckIn: '', WarningThresholdMinutes: 20 }, + { TargetEntityId: '2', Status: 'Overdue', ElapsedMinutes: 35, DurationMinutes: 30, TargetType: 0, TargetTypeName: 'Unit', TargetName: 'Ladder 1', UnitId: '2', LastCheckIn: '', WarningThresholdMinutes: 20 }, + { TargetEntityId: '3', Status: 'Warning', ElapsedMinutes: 22, DurationMinutes: 30, TargetType: 0, TargetTypeName: 'Unit', TargetName: 'Rescue 1', UnitId: '3', LastCheckIn: '', WarningThresholdMinutes: 20 }, + ]; + + mockGetTimerStatuses.mockResolvedValue({ Data: mockData, PageSize: 0, Timestamp: '', Version: '', Node: '', RequestId: '', Status: '', Environment: '' }); + + const { result } = renderHook(() => useCheckInTimerStore()); + + await act(async () => { + await result.current.fetchTimerStatuses(1); + }); + + await waitFor(() => { + expect(result.current.timerStatuses).toHaveLength(3); + expect(result.current.timerStatuses[0].Status).toBe('Overdue'); + expect(result.current.timerStatuses[1].Status).toBe('Warning'); + expect(result.current.timerStatuses[2].Status).toBe('Ok'); + expect(result.current.isLoadingStatuses).toBe(false); + }); + }); + + it('should handle fetch errors', async () => { + mockGetTimerStatuses.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useCheckInTimerStore()); + + await act(async () => { + await result.current.fetchTimerStatuses(1); + }); + + await waitFor(() => { + expect(result.current.statusError).toBe('Network error'); + expect(result.current.isLoadingStatuses).toBe(false); + }); + }); + }); + + describe('performCheckIn', () => { + it('should perform check-in and re-fetch statuses', async () => { + mockPerformCheckIn.mockResolvedValue({ Data: {}, PageSize: 0, Timestamp: '', Version: '', Node: '', RequestId: '', Status: '', Environment: '' }); + mockGetTimerStatuses.mockResolvedValue({ Data: [], PageSize: 0, Timestamp: '', Version: '', Node: '', RequestId: '', Status: '', Environment: '' }); + + const { result } = renderHook(() => useCheckInTimerStore()); + + let success: boolean = false; + await act(async () => { + success = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); + }); + + expect(success).toBe(true); + expect(mockPerformCheckIn).toHaveBeenCalledWith({ CallId: 1, CheckInType: 0 }); + expect(result.current.isCheckingIn).toBe(false); + }); + + it('should handle check-in errors', async () => { + mockPerformCheckIn.mockRejectedValue(new Error('Server error')); + + const { result } = renderHook(() => useCheckInTimerStore()); + + let success: boolean = true; + await act(async () => { + success = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); + }); + + expect(success).toBe(false); + expect(result.current.checkInError).toBe('Server error'); + }); + }); + + describe('startPolling / stopPolling', () => { + it('should start and stop polling', async () => { + mockGetTimerStatuses.mockResolvedValue({ Data: [], PageSize: 0, Timestamp: '', Version: '', Node: '', RequestId: '', Status: '', Environment: '' }); + + const { result } = renderHook(() => useCheckInTimerStore()); + + act(() => { + result.current.startPolling(1, 5000); + }); + + // Should have fetched immediately + expect(mockGetTimerStatuses).toHaveBeenCalledTimes(1); + + // Advance timer + await act(async () => { + jest.advanceTimersByTime(5000); + }); + expect(mockGetTimerStatuses).toHaveBeenCalledTimes(2); + + // Stop polling + act(() => { + result.current.stopPolling(); + }); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + // Should not have increased + expect(mockGetTimerStatuses).toHaveBeenCalledTimes(2); + }); + }); + + describe('reset', () => { + it('should reset state to initial values', () => { + useCheckInTimerStore.setState({ + timerStatuses: [{ TargetEntityId: '1', Status: 'Ok' }] as any, + isLoadingStatuses: true, + statusError: 'some error', + }); + + const { result } = renderHook(() => useCheckInTimerStore()); + + act(() => { + result.current.reset(); + }); + + expect(result.current.timerStatuses).toEqual([]); + expect(result.current.isLoadingStatuses).toBe(false); + expect(result.current.statusError).toBeNull(); + }); + }); +}); diff --git a/src/stores/check-in-timers/store.ts b/src/stores/check-in-timers/store.ts new file mode 100644 index 0000000..e111106 --- /dev/null +++ b/src/stores/check-in-timers/store.ts @@ -0,0 +1,139 @@ +import { create } from 'zustand'; + +import { getCheckInHistory, getTimersForCall, getTimerStatuses, performCheckIn, type PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; +import { logger } from '@/lib/logging'; +import type { CheckInRecordResultData } from '@/models/v4/checkIn/checkInRecordResultData'; +import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; +import type { ResolvedCheckInTimerResultData } from '@/models/v4/checkIn/resolvedCheckInTimerResultData'; +import { offlineEventManager } from '@/services/offline-event-manager.service'; + +const STATUS_SEVERITY: Record = { + Overdue: 0, + Warning: 1, + Ok: 2, +}; + +interface CheckInTimerState { + timerStatuses: CheckInTimerStatusResultData[]; + resolvedTimers: ResolvedCheckInTimerResultData[]; + checkInHistory: CheckInRecordResultData[]; + isLoadingStatuses: boolean; + isLoadingHistory: boolean; + isCheckingIn: boolean; + statusError: string | null; + checkInError: string | null; + _pollingInterval: ReturnType | null; + + fetchTimerStatuses: (callId: number) => Promise; + fetchResolvedTimers: (callId: number) => Promise; + fetchCheckInHistory: (callId: number) => Promise; + performCheckIn: (input: PerformCheckInInput) => Promise; + startPolling: (callId: number, intervalMs?: number) => void; + stopPolling: () => void; + reset: () => void; +} + +const initialState = { + timerStatuses: [], + resolvedTimers: [], + checkInHistory: [], + isLoadingStatuses: false, + isLoadingHistory: false, + isCheckingIn: false, + statusError: null, + checkInError: null, + _pollingInterval: null, +}; + +export const useCheckInTimerStore = create((set, get) => ({ + ...initialState, + + fetchTimerStatuses: async (callId: number) => { + set({ isLoadingStatuses: true, statusError: null }); + try { + const result = await getTimerStatuses(callId); + const sorted = [...result.Data].sort((a, b) => (STATUS_SEVERITY[a.Status] ?? 3) - (STATUS_SEVERITY[b.Status] ?? 3)); + set({ timerStatuses: sorted, isLoadingStatuses: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch timer statuses'; + logger.error({ message: 'Failed to fetch timer statuses', context: { error, callId } }); + set({ statusError: message, isLoadingStatuses: false }); + } + }, + + fetchResolvedTimers: async (callId: number) => { + try { + const result = await getTimersForCall(callId); + set({ resolvedTimers: result.Data }); + } catch (error) { + logger.error({ message: 'Failed to fetch resolved timers', context: { error, callId } }); + } + }, + + fetchCheckInHistory: async (callId: number) => { + set({ isLoadingHistory: true }); + try { + const result = await getCheckInHistory(callId); + set({ checkInHistory: result.Data, isLoadingHistory: false }); + } catch (error) { + logger.error({ message: 'Failed to fetch check-in history', context: { error, callId } }); + set({ isLoadingHistory: false }); + } + }, + + performCheckIn: async (input: PerformCheckInInput) => { + set({ isCheckingIn: true, checkInError: null }); + try { + await performCheckIn(input); + set({ isCheckingIn: false }); + // Re-fetch statuses after successful check-in + get().fetchTimerStatuses(input.CallId); + return true; + } catch (error) { + const isNetworkError = error instanceof Error && (error.message.includes('Network') || error.message.includes('timeout')); + if (isNetworkError) { + // Queue offline + offlineEventManager.queueCheckInEvent(input.CallId, input.CheckInType, input.UnitId, input.Latitude, input.Longitude, input.Note); + logger.info({ message: 'Check-in queued offline', context: { input } }); + set({ isCheckingIn: false }); + return true; + } + const message = error instanceof Error ? error.message : 'Failed to perform check-in'; + logger.error({ message: 'Failed to perform check-in', context: { error, input } }); + set({ checkInError: message, isCheckingIn: false }); + return false; + } + }, + + startPolling: (callId: number, intervalMs: number = 30000) => { + const { _pollingInterval } = get(); + if (_pollingInterval) { + clearInterval(_pollingInterval); + } + + // Fetch immediately + get().fetchTimerStatuses(callId); + + const interval = setInterval(() => { + get().fetchTimerStatuses(callId); + }, intervalMs); + + set({ _pollingInterval: interval }); + }, + + stopPolling: () => { + const { _pollingInterval } = get(); + if (_pollingInterval) { + clearInterval(_pollingInterval); + set({ _pollingInterval: null }); + } + }, + + reset: () => { + const { _pollingInterval } = get(); + if (_pollingInterval) { + clearInterval(_pollingInterval); + } + set({ ...initialState }); + }, +})); diff --git a/src/stores/pois/store.ts b/src/stores/pois/store.ts new file mode 100644 index 0000000..1897e0f --- /dev/null +++ b/src/stores/pois/store.ts @@ -0,0 +1,144 @@ +import { create } from 'zustand'; + +import { getPoi, getPois, getPoiTypes } from '@/api/mapping/mapping'; +import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; + +const STORE_TTL_MS = 5 * 60 * 1000; + +const mergePoiDetails = (existingPois: Record, pois: PoiResultData[]): Record => { + return pois.reduce>((accumulator, poi) => { + accumulator[poi.PoiId] = poi; + return accumulator; + }, { ...existingPois }); +}; + +interface PoisState { + poiTypes: PoiTypeResultData[]; + pois: PoiResultData[]; + destinationPois: PoiResultData[]; + poiDetails: Record; + selectedPoi: PoiResultData | null; + isLoading: boolean; + isLoadingDetail: boolean; + error: string | null; + lastFetchedAt: number; + fetchPoiTypes: (force?: boolean) => Promise; + fetchDestinationPois: (force?: boolean) => Promise; + fetchAllPoiData: (force?: boolean) => Promise; + fetchPoi: (poiId: number | string, force?: boolean) => Promise; + clearSelectedPoi: () => void; + clearError: () => void; +} + +export const usePoisStore = create((set, get) => ({ + poiTypes: [], + pois: [], + destinationPois: [], + poiDetails: {}, + selectedPoi: null, + isLoading: false, + isLoadingDetail: false, + error: null, + lastFetchedAt: 0, + fetchPoiTypes: async (force = false) => { + const { poiTypes, lastFetchedAt } = get(); + const isFresh = poiTypes.length > 0 && Date.now() - lastFetchedAt < STORE_TTL_MS; + if (!force && isFresh) { + return poiTypes; + } + + set({ isLoading: true, error: null }); + try { + const response = await getPoiTypes(); + const nextPoiTypes = Array.isArray(response.Data) ? response.Data : []; + set({ poiTypes: nextPoiTypes, isLoading: false, lastFetchedAt: Date.now() }); + return nextPoiTypes; + } catch (error) { + set({ error: 'Failed to fetch POI types', isLoading: false }); + return []; + } + }, + fetchDestinationPois: async (force = false) => { + const { destinationPois, lastFetchedAt } = get(); + const isFresh = destinationPois.length > 0 && Date.now() - lastFetchedAt < STORE_TTL_MS; + if (!force && isFresh) { + return destinationPois; + } + + set({ isLoading: true, error: null }); + try { + const response = await getPois(undefined, true); + const nextDestinationPois = Array.isArray(response.Data) ? response.Data : []; + set((state) => ({ + destinationPois: nextDestinationPois, + poiDetails: mergePoiDetails(state.poiDetails, nextDestinationPois), + isLoading: false, + lastFetchedAt: Date.now(), + })); + return nextDestinationPois; + } catch (error) { + set({ error: 'Failed to fetch destination POIs', isLoading: false }); + return []; + } + }, + fetchAllPoiData: async (force = false) => { + const { poiTypes, pois, destinationPois, lastFetchedAt } = get(); + const isFresh = poiTypes.length > 0 && pois.length > 0 && destinationPois.length > 0 && Date.now() - lastFetchedAt < STORE_TTL_MS; + + if (!force && isFresh) { + return; + } + + set({ isLoading: true, error: null }); + try { + const [poiTypesResponse, poisResponse, destinationPoisResponse] = await Promise.all([getPoiTypes(), getPois(undefined, false), getPois(undefined, true)]); + const nextPoiTypes = Array.isArray(poiTypesResponse.Data) ? poiTypesResponse.Data : []; + const nextPois = Array.isArray(poisResponse.Data) ? poisResponse.Data : []; + const nextDestinationPois = Array.isArray(destinationPoisResponse.Data) ? destinationPoisResponse.Data : []; + + set((state) => ({ + poiTypes: nextPoiTypes, + pois: nextPois, + destinationPois: nextDestinationPois, + poiDetails: mergePoiDetails(state.poiDetails, [...nextPois, ...nextDestinationPois]), + isLoading: false, + lastFetchedAt: Date.now(), + })); + } catch (error) { + set({ error: 'Failed to fetch POIs', isLoading: false }); + } + }, + fetchPoi: async (poiId: number | string, force = false) => { + const normalizedPoiId = Number(poiId); + const cachedPoi = get().poiDetails[normalizedPoiId]; + + if (!force && cachedPoi) { + set({ selectedPoi: cachedPoi }); + return cachedPoi; + } + + set({ isLoadingDetail: true, error: null }); + try { + const response = await getPoi(normalizedPoiId); + const poi = response.Data; + + if (!poi || !poi.PoiId) { + set({ selectedPoi: null, isLoadingDetail: false }); + return null; + } + + set((state) => ({ + selectedPoi: poi, + poiDetails: mergePoiDetails(state.poiDetails, [poi]), + isLoadingDetail: false, + })); + + return poi; + } catch (error) { + set({ error: 'Failed to fetch POI', isLoadingDetail: false }); + return null; + } + }, + clearSelectedPoi: () => set({ selectedPoi: null }), + clearError: () => set({ error: null }), +})); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 98fb15b..7f9090e 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -7,6 +7,7 @@ import { signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { securityStore, useSecurityStore } from '../security/store'; +import { useWeatherAlertsStore } from '../weather-alerts/store'; interface SignalRState { isUpdateHubConnected: boolean; @@ -53,7 +54,7 @@ export const useSignalRStore = create((set, get) => ({ // Remove any previously registered handlers to prevent accumulation // across reconnections or repeated connectUpdateHub calls - const updateEvents = ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'onConnected']; + const updateEvents = ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', 'onConnected']; updateEvents.forEach((event) => signalRService.removeAllListeners(event)); // Connect to the eventing hub @@ -61,7 +62,7 @@ export const useSignalRStore = create((set, get) => ({ name: Env.CHANNEL_HUB_NAME, eventingUrl: eventingUrl, hubName: Env.CHANNEL_HUB_NAME, - methods: ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'onConnected'], + methods: ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', 'onConnected'], }); await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', parseInt(securityStore.getState().rights?.DepartmentId ?? '0')); @@ -91,6 +92,21 @@ export const useSignalRStore = create((set, get) => ({ set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); }); + signalRService.on('weatherAlertReceived', (message) => { + set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + useWeatherAlertsStore.getState().handleAlertReceived(message as string); + }); + + signalRService.on('weatherAlertUpdated', (message) => { + set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + useWeatherAlertsStore.getState().handleAlertUpdated(message as string); + }); + + signalRService.on('weatherAlertExpired', (message) => { + set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + useWeatherAlertsStore.getState().handleAlertExpired(message as string); + }); + signalRService.on('onConnected', () => { logger.info({ message: 'Connected to update SignalR hub', diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts index bb35749..607f3be 100644 --- a/src/stores/status/__tests__/store.test.ts +++ b/src/stores/status/__tests__/store.test.ts @@ -19,12 +19,10 @@ jest.mock('react-native-mmkv', () => ({ import { act, renderHook } from '@testing-library/react-native'; -import { getCalls } from '@/api/calls/calls'; -import { getAllGroups } from '@/api/groups/groups'; +import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; import { saveUnitStatus } from '@/api/units/unitStatuses'; -import { ActiveCallsResult } from '@/models/v4/calls/activeCallsResult'; import { CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData'; -import { GroupsResult } from '@/models/v4/groups/groupsResult'; +import { GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult'; import { UnitTypeStatusesResult } from '@/models/v4/statuses/unitTypeStatusesResult'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { offlineEventManager } from '@/services/offline-event-manager.service'; @@ -33,8 +31,7 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useStatusBottomSheetStore, useStatusesStore } from '../store'; // Mock the API calls -jest.mock('@/api/calls/calls'); -jest.mock('@/api/groups/groups'); +jest.mock('@/api/dispatch/dispatch'); jest.mock('@/api/units/unitStatuses'); jest.mock('@/stores/app/core-store'); jest.mock('@/stores/app/location-store', () => ({ @@ -57,12 +54,13 @@ jest.mock('@/stores/roles/store', () => ({ }, })); jest.mock('@/stores/calls/store', () => ({ - useCallsStore: { - getState: jest.fn(() => ({ - calls: [], - })), - setState: jest.fn(), - }, + useCallsStore: { + getState: jest.fn(() => ({ + calls: [], + lastFetchedAt: 0, + })), + setState: jest.fn(), + }, })); jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { @@ -77,8 +75,7 @@ jest.mock('@/lib/logging', () => ({ }, })); -const mockGetCalls = getCalls as jest.MockedFunction; -const mockGetAllGroups = getAllGroups as jest.MockedFunction; +const mockGetSetUnitStatusData = getSetUnitStatusData as jest.MockedFunction; const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction; const mockUseCoreStore = useCoreStore as jest.MockedFunction; const mockOfflineEventManager = offlineEventManager as jest.Mocked; @@ -95,11 +92,14 @@ describe('StatusBottomSheetStore', () => { expect(result.current.currentStep).toBe('select-destination'); expect(result.current.selectedCall).toBe(null); expect(result.current.selectedStation).toBe(null); + expect(result.current.selectedPoi).toBe(null); expect(result.current.selectedDestinationType).toBe('none'); expect(result.current.selectedStatus).toBe(null); expect(result.current.note).toBe(''); expect(result.current.availableCalls).toEqual([]); expect(result.current.availableStations).toEqual([]); + expect(result.current.availablePois).toEqual([]); + expect(result.current.availablePoiTypes).toEqual([]); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); }); @@ -122,8 +122,8 @@ describe('StatusBottomSheetStore', () => { }); it('fetches destination data successfully', async () => { - const mockCallsResponse = new ActiveCallsResult(); - mockCallsResponse.Data = [ + const mockResponse = new GetSetUnitStateResult(); + mockResponse.Data.Calls = [ { CallId: '1', Number: 'CALL001', @@ -131,18 +131,31 @@ describe('StatusBottomSheetStore', () => { Address: '123 Test St', } as any, ]; - - const mockGroupsResponse = new GroupsResult(); - mockGroupsResponse.Data = [ + mockResponse.Data.Stations = [ { GroupId: '1', Name: 'Station 1', Address: '456 Station Ave', } as any, ]; + mockResponse.Data.DestinationPois = [ + { + PoiId: 9, + PoiTypeId: 1, + PoiTypeName: 'Hospital', + Name: 'Mercy Hospital', + Address: '789 Care Way', + } as any, + ]; + mockResponse.Data.PoiTypes = [ + { + PoiTypeId: 1, + Name: 'Hospital', + IsDestination: true, + } as any, + ]; - mockGetCalls.mockResolvedValueOnce(mockCallsResponse); - mockGetAllGroups.mockResolvedValueOnce(mockGroupsResponse); + mockGetSetUnitStatusData.mockResolvedValueOnce(mockResponse); const { result } = renderHook(() => useStatusBottomSheetStore()); @@ -150,10 +163,11 @@ describe('StatusBottomSheetStore', () => { await result.current.fetchDestinationData('unit1'); }); - expect(mockGetCalls).toHaveBeenCalledWith(); - expect(mockGetAllGroups).toHaveBeenCalledWith(); - expect(result.current.availableCalls).toEqual(mockCallsResponse.Data); - expect(result.current.availableStations).toEqual(mockGroupsResponse.Data); + expect(mockGetSetUnitStatusData).toHaveBeenCalledWith('unit1'); + expect(result.current.availableCalls).toEqual(mockResponse.Data.Calls); + expect(result.current.availableStations).toEqual(mockResponse.Data.Stations); + expect(result.current.availablePois).toEqual(mockResponse.Data.DestinationPois); + expect(result.current.availablePoiTypes).toEqual(mockResponse.Data.PoiTypes); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); }); @@ -184,6 +198,7 @@ describe('StatusBottomSheetStore', () => { expect(result.current.currentStep).toBe('select-destination'); expect(result.current.selectedCall).toBe(null); expect(result.current.selectedStation).toBe(null); + expect(result.current.selectedPoi).toBe(null); expect(result.current.selectedDestinationType).toBe('none'); expect(result.current.selectedStatus).toBe(null); expect(result.current.note).toBe(''); @@ -272,6 +287,7 @@ describe('StatusesStore', () => { '1', 'Test note', 'call1', + null, [{ roleId: 'role1', userId: 'user1' }], undefined ); @@ -333,6 +349,7 @@ describe('StatusesStore', () => { '1', '', // Note defaults to empty string '', // RespondingTo defaults to empty string + null, [], // Roles defaults to empty array which maps to empty array undefined ); diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index cdf268d..960b78d 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -1,12 +1,12 @@ import { create } from 'zustand'; -import { getCalls } from '@/api/calls/calls'; -import { getAllGroups } from '@/api/groups/groups'; +import { getSetUnitStatusData } from '@/api/dispatch/dispatch'; import { saveUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData'; import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; +import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { type SaveUnitStatusInput, type SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { offlineEventManager } from '@/services/offline-event-manager.service'; @@ -14,15 +14,12 @@ import { offlineEventManager } from '@/services/offline-event-manager.service'; import { useCoreStore } from '../app/core-store'; import { useLocationStore } from '../app/location-store'; import { useCallsStore } from '../calls/store'; -import { useRolesStore } from '../roles/store'; type StatusStep = 'select-status' | 'select-destination' | 'add-note'; -type DestinationType = 'none' | 'call' | 'station'; +type DestinationType = 'none' | 'call' | 'station' | 'poi'; -// Status type that can accept both custom statuses and regular statuses type StatusType = CustomStatusResultData | StatusesResultData; -// Store TTL: 5 minutes in milliseconds const STORE_TTL_MS = 5 * 60 * 1000; interface StatusBottomSheetStore { @@ -30,18 +27,23 @@ interface StatusBottomSheetStore { currentStep: StatusStep; selectedCall: CallResultData | null; selectedStation: GroupResultData | null; + selectedPoi: PoiResultData | null; selectedDestinationType: DestinationType; selectedStatus: StatusType | null; - cameFromStatusSelection: boolean; // Track whether we came from status selection flow + cameFromStatusSelection: boolean; note: string; availableCalls: CallResultData[]; availableStations: GroupResultData[]; + availablePois: PoiResultData[]; + availablePoiTypes: PoiTypeResultData[]; + lastFetchedAt: number; isLoading: boolean; error: string | null; setIsOpen: (isOpen: boolean, status?: StatusType) => void; setCurrentStep: (step: StatusStep) => void; setSelectedCall: (call: CallResultData | null) => void; setSelectedStation: (station: GroupResultData | null) => void; + setSelectedPoi: (poi: PoiResultData | null) => void; setSelectedDestinationType: (type: DestinationType) => void; setSelectedStatus: (status: StatusType | null) => void; setNote: (note: string) => void; @@ -49,71 +51,85 @@ interface StatusBottomSheetStore { reset: () => void; } +const hasFreshDestinationData = (lastFetchedAt: number): boolean => { + return lastFetchedAt > 0 && Date.now() - lastFetchedAt <= STORE_TTL_MS; +}; + export const useStatusBottomSheetStore = create((set, get) => ({ isOpen: false, currentStep: 'select-destination', selectedCall: null, selectedStation: null, + selectedPoi: null, selectedDestinationType: 'none', selectedStatus: null, cameFromStatusSelection: false, note: '', availableCalls: [], availableStations: [], + availablePois: [], + availablePoiTypes: [], + lastFetchedAt: 0, isLoading: false, error: null, setIsOpen: (isOpen, status) => { - if (isOpen && !status) { - // If no status is provided, start with status selection - set({ isOpen, selectedStatus: null, currentStep: 'select-status', cameFromStatusSelection: true }); - } else { - // If status is provided, start with destination selection - set({ isOpen, selectedStatus: status || null, currentStep: 'select-destination', cameFromStatusSelection: false }); + if (!isOpen) { + set({ isOpen: false }); + return; } + + if (!status) { + set({ + isOpen, + selectedStatus: null, + currentStep: 'select-status', + cameFromStatusSelection: true, + }); + return; + } + + set({ + isOpen, + selectedStatus: status, + currentStep: 'select-destination', + cameFromStatusSelection: false, + }); }, setCurrentStep: (step) => set({ currentStep: step }), setSelectedCall: (call) => set({ selectedCall: call }), setSelectedStation: (station) => set({ selectedStation: station }), + setSelectedPoi: (poi) => set({ selectedPoi: poi }), setSelectedDestinationType: (type) => set({ selectedDestinationType: type }), setSelectedStatus: (status) => set({ selectedStatus: status }), setNote: (note) => set({ note }), fetchDestinationData: async (unitId: string) => { - set({ isLoading: true, error: null }); - try { - // Check if we already have calls in the calls store and if they're still fresh - const callsStore = useCallsStore.getState(); - const existingCalls = callsStore.calls; - const lastFetchedAt = callsStore.lastFetchedAt || 0; - const isStale = !lastFetchedAt || Date.now() - lastFetchedAt > STORE_TTL_MS; - - // Fetch calls if we don't have any or if they're stale - // Groups are cached (2 day TTL) so getAllGroups is already fast - const needsCallsFetch = existingCalls.length === 0 || isStale; + if (get().isLoading || hasFreshDestinationData(get().lastFetchedAt)) { + return; + } - if (needsCallsFetch) { - // Fetch calls and groups in parallel - const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]); + set({ isLoading: true, error: null }); - // Update the calls store with fresh data and timestamp - useCallsStore.setState({ calls: callsResponse.Data || [], lastFetchedAt: Date.now() }); + try { + const response = await getSetUnitStatusData(unitId); + const data = response.Data; + const lastFetchedAt = Date.now(); + const availableCalls = data?.Calls ?? []; - // Set availableCalls from the fresh response - set({ - availableCalls: callsResponse.Data || [], - availableStations: groupsResponse.Data || [], - isLoading: false, - }); - } else { - // Use existing calls, only fetch groups (which is cached) - const groupsResponse = await getAllGroups(); + useCallsStore.setState({ + calls: availableCalls, + lastFetchedAt, + }); - set({ - availableCalls: existingCalls, - availableStations: groupsResponse.Data || [], - isLoading: false, - }); - } - } catch (error) { + set({ + availableCalls, + availableStations: data?.Stations ?? [], + availablePois: data?.DestinationPois ?? [], + availablePoiTypes: data?.PoiTypes ?? [], + lastFetchedAt, + isLoading: false, + error: null, + }); + } catch { set({ error: 'Failed to fetch destination data', isLoading: false, @@ -126,12 +142,16 @@ export const useStatusBottomSheetStore = create((set, ge currentStep: 'select-destination', selectedCall: null, selectedStation: null, + selectedPoi: null, selectedDestinationType: 'none', selectedStatus: null, cameFromStatusSelection: false, note: '', availableCalls: [], availableStations: [], + availablePois: [], + availablePoiTypes: [], + lastFetchedAt: 0, isLoading: false, error: null, }), @@ -148,12 +168,12 @@ export const useStatusesStore = create((set) => ({ error: null, saveUnitStatus: async (input: SaveUnitStatusInput) => { set({ isLoading: true, error: null }); + try { const date = new Date(); input.Timestamp = date.toISOString(); input.TimestampUtc = date.toUTCString().replace('UTC', 'GMT'); - // Populate GPS coordinates from location store if not already set if ((!input.Latitude && !input.Longitude) || (input.Latitude === '' && input.Longitude === '')) { const locationState = useLocationStore.getState(); @@ -162,11 +182,10 @@ export const useStatusesStore = create((set) => ({ input.Longitude = locationState.longitude.toString(); input.Accuracy = locationState.accuracy?.toString() || ''; input.Altitude = locationState.altitude?.toString() || ''; - input.AltitudeAccuracy = ''; // Location store doesn't provide altitude accuracy + input.AltitudeAccuracy = ''; input.Speed = locationState.speed?.toString() || ''; input.Heading = locationState.heading?.toString() || ''; } else { - // Ensure empty strings when no GPS data input.Latitude = ''; input.Longitude = ''; input.Accuracy = ''; @@ -178,10 +197,8 @@ export const useStatusesStore = create((set) => ({ } try { - // Try to save directly first await saveUnitStatus(input); - // Set loading to false immediately after successful save set({ isLoading: false }); logger.info({ @@ -189,8 +206,6 @@ export const useStatusesStore = create((set) => ({ context: { unitId: input.Id, statusType: input.Type }, }); - // Refresh the active unit status in the background (don't await) - // This allows the UI to be responsive while the data refreshes const activeUnit = useCoreStore.getState().activeUnit; if (activeUnit) { const refreshPromise = useCoreStore.getState().setActiveUnitWithFetch(activeUnit.UnitId); @@ -204,20 +219,28 @@ export const useStatusesStore = create((set) => ({ } } } catch (error) { - // If direct save fails, queue for offline processing logger.warn({ message: 'Direct unit status save failed, queuing for offline processing', context: { unitId: input.Id, statusType: input.Type, error }, }); - // Extract role data for queuing - const roles = input.Roles?.map((role) => ({ - roleId: role.RoleId, - userId: role.UserId, - })); + const roles = + input.Roles?.map((role) => ({ + roleId: role.RoleId, + userId: role.UserId, + })) ?? []; - // Extract GPS data for queuing - use location store if input doesn't have GPS data - let gpsData = undefined; + let gpsData: + | { + latitude?: string; + longitude?: string; + accuracy?: string; + altitude?: string; + altitudeAccuracy?: string; + speed?: string; + heading?: string; + } + | undefined; if (input.Latitude && input.Longitude) { gpsData = { @@ -230,7 +253,6 @@ export const useStatusesStore = create((set) => ({ heading: input.Heading, }; } else { - // Try to get GPS data from location store const locationState = useLocationStore.getState(); if (locationState.latitude !== null && locationState.longitude !== null) { gpsData = { @@ -238,15 +260,22 @@ export const useStatusesStore = create((set) => ({ longitude: locationState.longitude.toString(), accuracy: locationState.accuracy?.toString(), altitude: locationState.altitude?.toString(), - altitudeAccuracy: undefined, // Not available in location store + altitudeAccuracy: undefined, speed: locationState.speed?.toString(), heading: locationState.heading?.toString(), }; } } - // Queue the event - const eventId = offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note, input.RespondingTo, roles, gpsData); + const eventId = offlineEventManager.queueUnitStatusEvent( + input.Id, + input.Type, + input.Note || '', + input.RespondingTo || '', + input.RespondingToType, + roles, + gpsData + ); logger.info({ message: 'Unit status queued for offline processing', @@ -261,7 +290,7 @@ export const useStatusesStore = create((set) => ({ context: { error }, }); set({ error: 'Failed to save unit status', isLoading: false }); - throw error; // Re-throw to allow calling code to handle error + throw error; } }, })); diff --git a/src/stores/weather-alerts/__tests__/store.test.ts b/src/stores/weather-alerts/__tests__/store.test.ts new file mode 100644 index 0000000..c5705e8 --- /dev/null +++ b/src/stores/weather-alerts/__tests__/store.test.ts @@ -0,0 +1,239 @@ +import { getActiveAlerts, getWeatherAlert, getWeatherAlertSettings } from '@/api/weather-alerts/weather-alerts'; +import { useWeatherAlertsStore } from '../store'; + +jest.mock('@/api/weather-alerts/weather-alerts'); + +const mockGetActiveAlerts = getActiveAlerts as jest.MockedFunction; +const mockGetWeatherAlert = getWeatherAlert as jest.MockedFunction; +const mockGetWeatherAlertSettings = getWeatherAlertSettings as jest.MockedFunction; + +const createMockAlert = (overrides = {}) => ({ + WeatherAlertId: 'alert-1', + DepartmentId: 1, + Event: 'Tornado Warning', + Headline: 'Tornado Warning for County', + Description: 'A tornado has been spotted.', + Instructions: 'Take shelter immediately.', + Severity: 0, + Category: 0, + Urgency: 0, + Certainty: 0, + Status: 0, + SourceType: 0, + SourceAlertId: 'nws-1', + SenderName: 'NWS', + AreaDescription: 'County A', + Polygon: '', + CenterGeoLocation: '35.0,-97.0', + EffectiveUtc: '2026-04-15T10:00:00Z', + OnsetUtc: '2026-04-15T10:00:00Z', + ExpiresUtc: '2026-04-15T14:00:00Z', + Ends: '', + ReceivedOnUtc: '2026-04-15T10:00:00Z', + UpdatedOnUtc: '', + WebUrl: '', + ZoneCode: 'OKC001', + MessageType: 'Alert', + ...overrides, +}); + +const createMockSettings = (overrides = {}) => ({ + WeatherAlertsEnabled: true, + MinimumSeverity: 4, + AutoMessageSeverity: 0, + CallIntegrationEnabled: false, + AutoMessageSchedule: [], + ExcludedEvents: '', + ...overrides, +}); + +describe('useWeatherAlertsStore', () => { + beforeEach(() => { + useWeatherAlertsStore.getState().reset(); + jest.clearAllMocks(); + }); + + describe('init', () => { + it('should fetch settings and alerts when enabled', async () => { + mockGetWeatherAlertSettings.mockResolvedValue({ + Data: createMockSettings(), + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + mockGetActiveAlerts.mockResolvedValue({ + Data: [createMockAlert()], + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + await useWeatherAlertsStore.getState().init(); + + expect(mockGetWeatherAlertSettings).toHaveBeenCalledTimes(1); + expect(mockGetActiveAlerts).toHaveBeenCalledTimes(1); + expect(useWeatherAlertsStore.getState().alerts).toHaveLength(1); + expect(useWeatherAlertsStore.getState().isInitialized).toBe(true); + expect(useWeatherAlertsStore.getState().settings?.WeatherAlertsEnabled).toBe(true); + }); + + it('should not fetch alerts when disabled', async () => { + mockGetWeatherAlertSettings.mockResolvedValue({ + Data: createMockSettings({ WeatherAlertsEnabled: false }), + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + await useWeatherAlertsStore.getState().init(); + + expect(mockGetActiveAlerts).not.toHaveBeenCalled(); + expect(useWeatherAlertsStore.getState().alerts).toHaveLength(0); + expect(useWeatherAlertsStore.getState().isInitialized).toBe(true); + }); + + it('should not re-initialize if already initialized', async () => { + mockGetWeatherAlertSettings.mockResolvedValue({ + Data: createMockSettings(), + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + mockGetActiveAlerts.mockResolvedValue({ + Data: [], + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + await useWeatherAlertsStore.getState().init(); + await useWeatherAlertsStore.getState().init(); + + expect(mockGetWeatherAlertSettings).toHaveBeenCalledTimes(1); + }); + }); + + describe('fetchActiveAlerts', () => { + it('should update alerts sorted by severity', async () => { + const alerts = [ + createMockAlert({ WeatherAlertId: 'a1', Severity: 2 }), + createMockAlert({ WeatherAlertId: 'a2', Severity: 0 }), + ]; + mockGetActiveAlerts.mockResolvedValue({ + Data: alerts, + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + await useWeatherAlertsStore.getState().fetchActiveAlerts(); + + const state = useWeatherAlertsStore.getState(); + expect(state.alerts[0].WeatherAlertId).toBe('a2'); + expect(state.alerts[1].WeatherAlertId).toBe('a1'); + expect(state.isLoading).toBe(false); + }); + + it('should handle errors', async () => { + mockGetActiveAlerts.mockRejectedValue(new Error('Network error')); + + await useWeatherAlertsStore.getState().fetchActiveAlerts(); + + expect(useWeatherAlertsStore.getState().error).toBe('Failed to fetch weather alerts'); + }); + }); + + describe('handleAlertReceived', () => { + it('should prepend new alert to list', async () => { + useWeatherAlertsStore.setState({ alerts: [createMockAlert({ WeatherAlertId: 'existing' })] }); + const newAlert = createMockAlert({ WeatherAlertId: 'new-alert', Severity: 1 }); + mockGetWeatherAlert.mockResolvedValue({ + Data: newAlert, + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + await useWeatherAlertsStore.getState().handleAlertReceived('new-alert'); + + expect(useWeatherAlertsStore.getState().alerts).toHaveLength(2); + }); + }); + + describe('handleAlertExpired', () => { + it('should remove alert from list', () => { + useWeatherAlertsStore.setState({ + alerts: [ + createMockAlert({ WeatherAlertId: 'a1' }), + createMockAlert({ WeatherAlertId: 'a2' }), + ], + }); + + useWeatherAlertsStore.getState().handleAlertExpired('a1'); + + const alerts = useWeatherAlertsStore.getState().alerts; + expect(alerts).toHaveLength(1); + expect(alerts[0].WeatherAlertId).toBe('a2'); + }); + }); + + describe('filters and sorting', () => { + it('should set severity filter', () => { + useWeatherAlertsStore.getState().setSeverityFilter(1); + expect(useWeatherAlertsStore.getState().severityFilter).toBe(1); + + useWeatherAlertsStore.getState().setSeverityFilter(null); + expect(useWeatherAlertsStore.getState().severityFilter).toBeNull(); + }); + + it('should set sort mode', () => { + useWeatherAlertsStore.getState().setSortBy('newest'); + expect(useWeatherAlertsStore.getState().sortBy).toBe('newest'); + }); + }); + + describe('reset', () => { + it('should clear all state', () => { + useWeatherAlertsStore.setState({ + alerts: [createMockAlert()], + isInitialized: true, + settings: createMockSettings(), + }); + + useWeatherAlertsStore.getState().reset(); + + const state = useWeatherAlertsStore.getState(); + expect(state.alerts).toHaveLength(0); + expect(state.isInitialized).toBe(false); + expect(state.settings).toBeNull(); + }); + }); +}); diff --git a/src/stores/weather-alerts/store.ts b/src/stores/weather-alerts/store.ts new file mode 100644 index 0000000..bc5e606 --- /dev/null +++ b/src/stores/weather-alerts/store.ts @@ -0,0 +1,137 @@ +import { create } from 'zustand'; + +import { getActiveAlerts, getWeatherAlert, getWeatherAlertSettings } from '@/api/weather-alerts/weather-alerts'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; +import { type WeatherAlertSettingsData } from '@/models/v4/weatherAlerts/weatherAlertSettingsData'; +import { sortAlertsBySeverity } from '@/lib/weather-alert-utils'; + +interface WeatherAlertsState { + alerts: WeatherAlertResultData[]; + isLoading: boolean; + isInitialized: boolean; + error: string | null; + lastFetchedAt: number; + selectedAlert: WeatherAlertResultData | null; + isLoadingDetail: boolean; + settings: WeatherAlertSettingsData | null; + severityFilter: number | null; + sortBy: 'severity' | 'newest' | 'expiring'; + init: () => Promise; + fetchActiveAlerts: () => Promise; + fetchAlertDetail: (alertId: string) => Promise; + fetchSettings: () => Promise; + setSeverityFilter: (severity: number | null) => void; + setSortBy: (sortBy: 'severity' | 'newest' | 'expiring') => void; + handleAlertReceived: (alertId: string) => Promise; + handleAlertUpdated: (alertId: string) => Promise; + handleAlertExpired: (alertId: string) => void; + reset: () => void; +} + +export const useWeatherAlertsStore = create((set, get) => ({ + alerts: [], + isLoading: false, + isInitialized: false, + error: null, + lastFetchedAt: 0, + selectedAlert: null, + isLoadingDetail: false, + settings: null, + severityFilter: null, + sortBy: 'severity', + init: async () => { + if (get().isInitialized || get().isLoading) { + return; + } + set({ isLoading: true, error: null }); + try { + const settingsResponse = await getWeatherAlertSettings(); + const settings = settingsResponse.Data; + set({ settings }); + + if (settings.WeatherAlertsEnabled) { + const alertsResponse = await getActiveAlerts(); + const alerts = Array.isArray(alertsResponse.Data) ? sortAlertsBySeverity(alertsResponse.Data) : []; + set({ alerts }); + } + + set({ isLoading: false, isInitialized: true, lastFetchedAt: Date.now() }); + } catch (error) { + set({ error: 'Failed to initialize weather alerts', isLoading: false, isInitialized: true }); + } + }, + fetchActiveAlerts: async () => { + set({ isLoading: true, error: null }); + try { + const response = await getActiveAlerts(); + const alerts = Array.isArray(response.Data) ? sortAlertsBySeverity(response.Data) : []; + set({ alerts, isLoading: false, lastFetchedAt: Date.now() }); + } catch (error) { + set({ error: 'Failed to fetch weather alerts', isLoading: false }); + } + }, + fetchAlertDetail: async (alertId: string) => { + set({ isLoadingDetail: true }); + try { + const response = await getWeatherAlert(alertId); + set({ selectedAlert: response.Data, isLoadingDetail: false }); + } catch (error) { + set({ isLoadingDetail: false }); + } + }, + fetchSettings: async () => { + try { + const response = await getWeatherAlertSettings(); + set({ settings: response.Data }); + } catch (error) { + // Settings fetch failure is non-critical + } + }, + setSeverityFilter: (severity: number | null) => { + set({ severityFilter: severity }); + }, + setSortBy: (sortBy: 'severity' | 'newest' | 'expiring') => { + set({ sortBy }); + }, + handleAlertReceived: async (alertId: string) => { + try { + const response = await getWeatherAlert(alertId); + const newAlert = response.Data; + set((state) => ({ + alerts: sortAlertsBySeverity([newAlert, ...state.alerts]), + })); + } catch (error) { + // Silently fail for SignalR handler + } + }, + handleAlertUpdated: async (alertId: string) => { + try { + const response = await getWeatherAlert(alertId); + const updatedAlert = response.Data; + set((state) => ({ + alerts: sortAlertsBySeverity(state.alerts.map((a) => (a.WeatherAlertId === alertId ? updatedAlert : a))), + })); + } catch (error) { + // Silently fail for SignalR handler + } + }, + handleAlertExpired: (alertId: string) => { + set((state) => ({ + alerts: state.alerts.filter((a) => a.WeatherAlertId !== alertId), + })); + }, + reset: () => { + set({ + alerts: [], + isLoading: false, + isInitialized: false, + error: null, + lastFetchedAt: 0, + selectedAlert: null, + isLoadingDetail: false, + settings: null, + severityFilter: null, + sortBy: 'severity', + }); + }, +})); diff --git a/src/translations/ar.json b/src/translations/ar.json index ef026ff..784d01d 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -90,6 +90,35 @@ "volumeDown": "مستوى الصوت -", "volumeUp": "مستوى الصوت +" }, + "check_in": { + "tab_title": "تسجيل الحضور", + "timer_status": "حالة المؤقت", + "perform_check_in": "تسجيل", + "quick_check_in": "تسجيل سريع", + "check_in_success": "تم تسجيل الحضور بنجاح", + "check_in_error": "فشل تسجيل الحضور", + "last_check_in": "آخر تسجيل", + "elapsed": "المنقضي", + "duration": "دقيقة", + "status_ok": "جيد", + "status_warning": "تحذير", + "status_overdue": "متأخر", + "history": "السجل", + "no_timers": "لا توجد مؤقتات تسجيل حضور نشطة", + "timers_disabled": "مؤقتات تسجيل الحضور معطلة لهذه المكالمة", + "type_personnel": "الأفراد", + "type_unit": "الوحدة", + "type_ic": "قائد الحادث", + "type_par": "PAR", + "type_hazmat": "مواد خطرة", + "type_sector_rotation": "تناوب القطاع", + "type_rehab": "إعادة تأهيل", + "add_note": "إضافة ملاحظة", + "confirm": "تأكيد التسجيل", + "minutes_ago": "دقيقة مضت", + "select_type": "اختر نوع التسجيل", + "queued_offline": "تم وضع التسجيل في قائمة الانتظار لحين استعادة الاتصال" + }, "callImages": { "add": "إضافة صورة", "add_new": "إضافة صورة جديدة", @@ -193,6 +222,7 @@ "dispatched": "المرسلة", "info": "المعلومات", "protocols": "البروتوكولات", + "check_in": "تسجيل الحضور", "timeline": "النشاط" }, "timestamp": "الطابع الزمني", @@ -822,7 +852,75 @@ "notes": "الملاحظات", "protocols": "البروتوكولات", "routes": "المسارات", - "settings": "الإعدادات" + "settings": "الإعدادات", + "weather_alerts": "الطقس" + }, + "weather_alerts": { + "title": "تنبيهات الطقس", + "loading": "جاري تحميل تنبيهات الطقس...", + "no_alerts": "لا توجد تنبيهات طقس", + "no_alerts_description": "لا توجد تنبيهات طقس نشطة لمنطقتك.", + "feature_disabled": "تنبيهات الطقس معطلة", + "feature_disabled_description": "تنبيهات الطقس غير مفعلة لقسمك.", + "search": "البحث في تنبيهات الطقس...", + "severity": { "extreme": "شديد للغاية", "severe": "شديد", "moderate": "معتدل", "minor": "طفيف", "unknown": "غير معروف" }, + "category": { "met": "أرصاد جوية", "fire": "حريق", "health": "صحة", "env": "بيئي", "other": "أخرى" }, + "urgency": { "immediate": "فوري", "expected": "متوقع", "future": "مستقبلي", "past": "سابق", "unknown": "غير معروف" }, + "certainty": { "observed": "مرصود", "likely": "مرجح", "possible": "محتمل", "unlikely": "غير مرجح", "unknown": "غير معروف" }, + "status": { "active": "نشط", "updated": "محدث", "expired": "منتهي", "cancelled": "ملغى" }, + "detail": { "headline": "العنوان", "description": "الوصف", "instructions": "التعليمات", "area": "المنطقة المتأثرة", "effective": "ساري من", "onset": "البداية", "expires": "ينتهي", "sender": "المرسل", "urgency": "الإلحاح", "certainty": "اليقين" }, + "filter": { "all": "الكل", "nearby": "بالقرب" }, + "sort": { "severity": "الشدة", "expires": "ينتهي قريباً", "newest": "الأحدث" }, + "banner": { "more_alerts": "+{{count}} المزيد" } }, - "welcome": "مرحبًا بك في موقع تطبيق obytes" + "welcome": "مرحبًا بك في موقع تطبيق obytes", + "video_feeds": { + "tab_title": "فيديو", + "no_feeds": "لا توجد بثوث فيديو متاحة", + "add_feed": "إضافة بث", + "edit_feed": "تعديل البث", + "watch": "مشاهدة", + "copy_url": "نسخ الرابط", + "url_copied": "تم نسخ الرابط إلى الحافظة", + "delete_feed": "حذف البث", + "delete_confirm_title": "حذف بث الفيديو", + "delete_confirm_message": "هل أنت متأكد أنك تريد حذف هذا البث؟", + "save_success": "تم حفظ بث الفيديو بنجاح", + "save_error": "فشل حفظ بث الفيديو", + "delete_success": "تم حذف بث الفيديو بنجاح", + "delete_error": "فشل حذف بث الفيديو", + "name": "الاسم", + "url": "الرابط", + "feed_type": "نوع البث", + "feed_format": "صيغة البث", + "description": "الوصف", + "latitude": "خط العرض", + "longitude": "خط الطول", + "added_by": "أضافه", + "added_on": "أُضيف في", + "status_active": "نشط", + "status_inactive": "غير نشط", + "status_error": "خطأ", + "type_drone": "طائرة بدون طيار", + "type_fixed_camera": "كاميرا ثابتة", + "type_body_cam": "كاميرا جسدية", + "type_traffic_cam": "كاميرا مرور", + "type_weather_cam": "كاميرا طقس", + "type_satellite_feed": "بث فضائي", + "type_web_cam": "كاميرا ويب", + "type_other": "أخرى", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "يوتيوب مباشر", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "مضمّن", + "format_other": "أخرى", + "rtsp_not_supported": "لا يمكن تشغيل بثوث RTSP مباشرة. انسخ الرابط لاستخدامه في مشغل مخصص.", + "webrtc_not_supported": "تشغيل WebRTC قريبًا", + "player_title": "مشغل الفيديو", + "loading_video": "جارٍ تحميل الفيديو...", + "video_error": "فشل تحميل الفيديو" + } } diff --git a/src/translations/de.json b/src/translations/de.json index cd32d76..06b89cf 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -90,6 +90,35 @@ "volumeDown": "Lautstärke -", "volumeUp": "Lautstärke +" }, + "check_in": { + "tab_title": "Check-In", + "timer_status": "Timer-Status", + "perform_check_in": "Einchecken", + "quick_check_in": "Schnell-Check-In", + "check_in_success": "Check-In erfolgreich aufgezeichnet", + "check_in_error": "Check-In konnte nicht aufgezeichnet werden", + "last_check_in": "Letzter Check-In", + "elapsed": "Vergangen", + "duration": "Min", + "status_ok": "OK", + "status_warning": "Warnung", + "status_overdue": "Überfällig", + "history": "Verlauf", + "no_timers": "Keine Check-In-Timer aktiv", + "timers_disabled": "Check-In-Timer sind für diesen Einsatz deaktiviert", + "type_personnel": "Personal", + "type_unit": "Einheit", + "type_ic": "EL", + "type_par": "PAR", + "type_hazmat": "Gefahrgut", + "type_sector_rotation": "Sektorwechsel", + "type_rehab": "Reha", + "add_note": "Notiz hinzufügen", + "confirm": "Check-In bestätigen", + "minutes_ago": "Min. her", + "select_type": "Check-In-Typ wählen", + "queued_offline": "Check-In wird bei Verbindung nachgeholt" + }, "callImages": { "add": "Bild hinzufügen", "add_new": "Neues Bild hinzufügen", @@ -193,6 +222,7 @@ "dispatched": "Entsendet", "info": "Info", "protocols": "Protokolle", + "check_in": "Check-In", "timeline": "Aktivität" }, "timestamp": "Zeitstempel", @@ -822,7 +852,75 @@ "notes": "Notizen", "protocols": "Protokolle", "routes": "Routen", - "settings": "Einstellungen" + "settings": "Einstellungen", + "weather_alerts": "Wetter" + }, + "weather_alerts": { + "title": "Wetterwarnungen", + "loading": "Wetterwarnungen werden geladen...", + "no_alerts": "Keine Wetterwarnungen", + "no_alerts_description": "Es gibt keine aktiven Wetterwarnungen für Ihr Gebiet.", + "feature_disabled": "Wetterwarnungen deaktiviert", + "feature_disabled_description": "Wetterwarnungen sind für Ihre Abteilung nicht aktiviert.", + "search": "Wetterwarnungen suchen...", + "severity": { "extreme": "Extrem", "severe": "Schwer", "moderate": "Mäßig", "minor": "Gering", "unknown": "Unbekannt" }, + "category": { "met": "Meteorologisch", "fire": "Feuer", "health": "Gesundheit", "env": "Umwelt", "other": "Sonstiges" }, + "urgency": { "immediate": "Sofort", "expected": "Erwartet", "future": "Zukünftig", "past": "Vergangen", "unknown": "Unbekannt" }, + "certainty": { "observed": "Beobachtet", "likely": "Wahrscheinlich", "possible": "Möglich", "unlikely": "Unwahrscheinlich", "unknown": "Unbekannt" }, + "status": { "active": "Aktiv", "updated": "Aktualisiert", "expired": "Abgelaufen", "cancelled": "Aufgehoben" }, + "detail": { "headline": "Überschrift", "description": "Beschreibung", "instructions": "Anweisungen", "area": "Betroffenes Gebiet", "effective": "Gültig ab", "onset": "Beginn", "expires": "Läuft ab", "sender": "Absender", "urgency": "Dringlichkeit", "certainty": "Sicherheit" }, + "filter": { "all": "Alle", "nearby": "In der Nähe" }, + "sort": { "severity": "Schweregrad", "expires": "Bald ablaufend", "newest": "Neueste" }, + "banner": { "more_alerts": "+{{count}} weitere" } }, - "welcome": "Willkommen bei obytes app site" + "welcome": "Willkommen bei obytes app site", + "video_feeds": { + "tab_title": "Video", + "no_feeds": "Keine Video-Feeds verfügbar", + "add_feed": "Feed hinzufügen", + "edit_feed": "Feed bearbeiten", + "watch": "Ansehen", + "copy_url": "URL kopieren", + "url_copied": "URL in Zwischenablage kopiert", + "delete_feed": "Feed löschen", + "delete_confirm_title": "Video-Feed löschen", + "delete_confirm_message": "Möchten Sie diesen Video-Feed wirklich löschen?", + "save_success": "Video-Feed erfolgreich gespeichert", + "save_error": "Video-Feed konnte nicht gespeichert werden", + "delete_success": "Video-Feed erfolgreich gelöscht", + "delete_error": "Video-Feed konnte nicht gelöscht werden", + "name": "Name", + "url": "URL", + "feed_type": "Feed-Typ", + "feed_format": "Feed-Format", + "description": "Beschreibung", + "latitude": "Breitengrad", + "longitude": "Längengrad", + "added_by": "Hinzugefügt von", + "added_on": "Hinzugefügt am", + "status_active": "Aktiv", + "status_inactive": "Inaktiv", + "status_error": "Fehler", + "type_drone": "Drohne", + "type_fixed_camera": "Feste Kamera", + "type_body_cam": "Bodycam", + "type_traffic_cam": "Verkehrskamera", + "type_weather_cam": "Wetterkamera", + "type_satellite_feed": "Satelliten-Feed", + "type_web_cam": "Webcam", + "type_other": "Sonstige", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube Live", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Eingebettet", + "format_other": "Sonstige", + "rtsp_not_supported": "RTSP-Streams können nicht direkt abgespielt werden. Kopieren Sie die URL für einen dedizierten Player.", + "webrtc_not_supported": "WebRTC-Wiedergabe kommt bald", + "player_title": "Videoplayer", + "loading_video": "Video wird geladen...", + "video_error": "Video konnte nicht geladen werden" + } } diff --git a/src/translations/en.json b/src/translations/en.json index c16527f..0935e00 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -90,6 +90,35 @@ "volumeDown": "Volume -", "volumeUp": "Volume +" }, + "check_in": { + "tab_title": "Check-In", + "timer_status": "Timer Status", + "perform_check_in": "Check In", + "quick_check_in": "Quick Check-In", + "check_in_success": "Check-in recorded successfully", + "check_in_error": "Failed to record check-in", + "last_check_in": "Last check-in", + "elapsed": "Elapsed", + "duration": "min", + "status_ok": "OK", + "status_warning": "Warning", + "status_overdue": "Overdue", + "history": "History", + "no_timers": "No check-in timers active", + "timers_disabled": "Check-in timers are disabled for this call", + "type_personnel": "Personnel", + "type_unit": "Unit", + "type_ic": "IC", + "type_par": "PAR", + "type_hazmat": "Hazmat", + "type_sector_rotation": "Sector Rotation", + "type_rehab": "Rehab", + "add_note": "Add note", + "confirm": "Confirm Check-In", + "minutes_ago": "min ago", + "select_type": "Select check-in type", + "queued_offline": "Check-in queued for when connection is restored" + }, "callImages": { "add": "Add Image", "add_new": "Add New Image", @@ -144,6 +173,7 @@ "contact_info": "Contact Info", "contact_name": "Contact Name", "contact_phone": "Phone", + "destination": "Destination", "edit_call": "Edit Call", "external_id": "External ID", "failed_to_open_maps": "Failed to open maps application", @@ -193,6 +223,7 @@ "dispatched": "Dispatched", "info": "Info", "protocols": "Protocols", + "check_in": "Check-In", "timeline": "Activity" }, "timestamp": "Timestamp", @@ -235,6 +266,9 @@ "description": "Description", "description_placeholder": "Enter the description of the call", "deselect": "Deselect", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Directions", "dispatch_to": "Dispatch To", "dispatch_to_everyone": "Dispatch to all available personnel", @@ -252,6 +286,7 @@ "invalid_type": "Invalid type selected. Please select a valid call type.", "loading": "Loading calls...", "loading_calls": "Loading calls...", + "loading_destination_pois": "Loading destination POIs...", "name": "Name", "name_placeholder": "Enter the name of the call", "nature": "Nature", @@ -263,6 +298,7 @@ "no_calls": "No active calls", "no_calls_available": "No calls available", "no_calls_description": "No active calls found. Select an active call to view details.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "This call does not have location data available for navigation.", "no_location_title": "No Location Available", "no_open_calls": "No open calls available", @@ -283,6 +319,7 @@ "select_address_placeholder": "Select the address of the call", "select_description": "Select Description", "select_dispatch_recipients": "Select Dispatch Recipients", + "select_destination_poi": "Select Destination POI", "select_location": "Select Location on Map", "select_name": "Select Name", "select_nature": "Select Nature", @@ -498,10 +535,14 @@ "failed_to_open_maps": "Failed to open maps application", "failed_to_set_current_call": "Failed to set call as current call", "no_location_for_routing": "No location data available for routing", + "pin_address": "Address", "pin_color": "Pin Color", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Recenter Map", "set_as_current_call": "Set as Current Call", - "view_call_details": "View Call Details" + "view_call_details": "View Call Details", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Active Layers", @@ -635,6 +676,8 @@ "in_progress": "In Progress", "instance_detail": "Route Instance", "loading": "Loading routes...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_directions": "Loading directions...", "loading_stops": "Loading stops...", "location": "Location", @@ -649,6 +692,9 @@ "no_routes": "No Routes", "no_routes_description": "No route plans are available for your unit.", "no_routes_description_all": "No route plans are available.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_stops": "No stops available", "notes": "Notes", "notes_placeholder": "Enter notes for this stop...", @@ -667,9 +713,12 @@ "progress": "{{percent}}% complete", "remaining_steps": "Remaining Steps", "resume_route": "Resume Route", + "route_to_poi": "Route to POI", "route_summary": "Route Summary", + "routes_tab": "Routes", "schedule": "Schedule", "search": "Search routes...", + "search_pois": "Search POIs...", "select_unit": "Select Unit", "skip": "Skip", "skip_reason": "Skip Reason", @@ -699,7 +748,25 @@ "unit": "Unit", "unit_required": "A unit must be selected to start the route", "view_contact": "View Contact", - "view_route": "View Route" + "view_on_map": "View on map", + "view_route": "View Route", + "failed_to_open_poi_maps": "Failed to open maps for this POI", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_note": "Note", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", + "set_poi_destination": "Set Destination" }, "settings": { "about": "About", @@ -794,16 +861,22 @@ "add_note": "Add Note", "both_destinations_enabled": "Can respond to calls or stations", "call_destination_enabled": "Can respond to calls", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Calls", "failed_to_save_status": "Failed to save status. Please try again.", "general_status": "General status without specific destination", + "loading_pois": "Loading POIs...", "loading_stations": "Loading stations...", "no_destination": "No Destination", + "no_pois_available": "No POIs available", "no_stations_available": "No stations available", "no_statuses_available": "No statuses available", "note": "Note", "note_optional": "Add an optional note for this status update", "note_required": "Please enter a note for this status update", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Select Destination for {{status}}", "select_destination_type": "Where would you like to respond?", "select_status": "Select Status", @@ -812,6 +885,7 @@ "selected_status": "Selected Status", "set_status": "Set Status", "station_destination_enabled": "Can respond to stations", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stations", "status_saved_successfully": "Status saved successfully!" }, @@ -822,7 +896,124 @@ "notes": "Notes", "protocols": "Protocols", "routes": "Routes", - "settings": "Settings" + "settings": "Settings", + "weather_alerts": "Weather" + }, + "weather_alerts": { + "title": "Weather Alerts", + "loading": "Loading weather alerts...", + "no_alerts": "No Weather Alerts", + "no_alerts_description": "There are no active weather alerts for your area.", + "feature_disabled": "Weather Alerts Disabled", + "feature_disabled_description": "Weather alerts are not enabled for your department.", + "search": "Search weather alerts...", + "severity": { + "extreme": "Extreme", + "severe": "Severe", + "moderate": "Moderate", + "minor": "Minor", + "unknown": "Unknown" + }, + "category": { + "met": "Meteorological", + "fire": "Fire", + "health": "Health", + "env": "Environmental", + "other": "Other" + }, + "urgency": { + "immediate": "Immediate", + "expected": "Expected", + "future": "Future", + "past": "Past", + "unknown": "Unknown" + }, + "certainty": { + "observed": "Observed", + "likely": "Likely", + "possible": "Possible", + "unlikely": "Unlikely", + "unknown": "Unknown" + }, + "status": { + "active": "Active", + "updated": "Updated", + "expired": "Expired", + "cancelled": "Cancelled" + }, + "detail": { + "headline": "Headline", + "description": "Description", + "instructions": "Instructions", + "area": "Affected Area", + "effective": "Effective", + "onset": "Onset", + "expires": "Expires", + "sender": "Sender", + "urgency": "Urgency", + "certainty": "Certainty" + }, + "filter": { + "all": "All", + "nearby": "Nearby" + }, + "sort": { + "severity": "Severity", + "expires": "Expiring Soon", + "newest": "Newest" + }, + "banner": { + "more_alerts": "+{{count}} more" + } }, - "welcome": "Welcome to obytes app site" + "welcome": "Welcome to obytes app site", + "video_feeds": { + "tab_title": "Video", + "no_feeds": "No video feeds available", + "add_feed": "Add Feed", + "edit_feed": "Edit Feed", + "watch": "Watch", + "copy_url": "Copy URL", + "url_copied": "URL copied to clipboard", + "delete_feed": "Delete Feed", + "delete_confirm_title": "Delete Video Feed", + "delete_confirm_message": "Are you sure you want to delete this video feed?", + "save_success": "Video feed saved successfully", + "save_error": "Failed to save video feed", + "delete_success": "Video feed deleted successfully", + "delete_error": "Failed to delete video feed", + "name": "Name", + "url": "URL", + "feed_type": "Feed Type", + "feed_format": "Feed Format", + "description": "Description", + "latitude": "Latitude", + "longitude": "Longitude", + "added_by": "Added by", + "added_on": "Added on", + "status_active": "Active", + "status_inactive": "Inactive", + "status_error": "Error", + "type_drone": "Drone", + "type_fixed_camera": "Fixed Camera", + "type_body_cam": "Body Cam", + "type_traffic_cam": "Traffic Cam", + "type_weather_cam": "Weather Cam", + "type_satellite_feed": "Satellite Feed", + "type_web_cam": "Web Cam", + "type_other": "Other", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube Live", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Embed", + "format_other": "Other", + "rtsp_not_supported": "RTSP streams cannot be played directly. Copy the URL to use in a dedicated player.", + "webrtc_not_supported": "WebRTC playback coming soon", + "player_title": "Video Player", + "loading_video": "Loading video...", + "video_error": "Failed to load video" + } } diff --git a/src/translations/es.json b/src/translations/es.json index 6097359..cadfe19 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -90,6 +90,35 @@ "volumeDown": "Volumen -", "volumeUp": "Volumen +" }, + "check_in": { + "tab_title": "Registro", + "timer_status": "Estado del temporizador", + "perform_check_in": "Registrar", + "quick_check_in": "Registro rápido", + "check_in_success": "Registro completado exitosamente", + "check_in_error": "Error al completar el registro", + "last_check_in": "Último registro", + "elapsed": "Transcurrido", + "duration": "min", + "status_ok": "OK", + "status_warning": "Advertencia", + "status_overdue": "Vencido", + "history": "Historial", + "no_timers": "No hay temporizadores de registro activos", + "timers_disabled": "Los temporizadores de registro están desactivados para esta llamada", + "type_personnel": "Personal", + "type_unit": "Unidad", + "type_ic": "CI", + "type_par": "PAR", + "type_hazmat": "Materiales peligrosos", + "type_sector_rotation": "Rotación de sector", + "type_rehab": "Rehabilitación", + "add_note": "Agregar nota", + "confirm": "Confirmar registro", + "minutes_ago": "min atrás", + "select_type": "Seleccionar tipo de registro", + "queued_offline": "Registro en cola para cuando se restaure la conexión" + }, "callImages": { "add": "Añadir imagen", "add_new": "Añadir nueva imagen", @@ -193,6 +222,7 @@ "dispatched": "Despachadas", "info": "Información", "protocols": "Protocolos", + "check_in": "Registro", "timeline": "Actividad" }, "timestamp": "Marca de tiempo", @@ -822,7 +852,75 @@ "notes": "Notas", "protocols": "Protocolos", "routes": "Rutas", - "settings": "Configuración" + "settings": "Configuración", + "weather_alerts": "Clima" + }, + "weather_alerts": { + "title": "Alertas Meteorológicas", + "loading": "Cargando alertas meteorológicas...", + "no_alerts": "Sin Alertas Meteorológicas", + "no_alerts_description": "No hay alertas meteorológicas activas para su área.", + "feature_disabled": "Alertas Meteorológicas Deshabilitadas", + "feature_disabled_description": "Las alertas meteorológicas no están habilitadas para su departamento.", + "search": "Buscar alertas meteorológicas...", + "severity": { "extreme": "Extremo", "severe": "Severo", "moderate": "Moderado", "minor": "Menor", "unknown": "Desconocido" }, + "category": { "met": "Meteorológico", "fire": "Incendio", "health": "Salud", "env": "Ambiental", "other": "Otro" }, + "urgency": { "immediate": "Inmediato", "expected": "Esperado", "future": "Futuro", "past": "Pasado", "unknown": "Desconocido" }, + "certainty": { "observed": "Observado", "likely": "Probable", "possible": "Posible", "unlikely": "Improbable", "unknown": "Desconocido" }, + "status": { "active": "Activo", "updated": "Actualizado", "expired": "Expirado", "cancelled": "Cancelado" }, + "detail": { "headline": "Titular", "description": "Descripción", "instructions": "Instrucciones", "area": "Área Afectada", "effective": "Efectivo", "onset": "Inicio", "expires": "Expira", "sender": "Remitente", "urgency": "Urgencia", "certainty": "Certeza" }, + "filter": { "all": "Todos", "nearby": "Cercanos" }, + "sort": { "severity": "Severidad", "expires": "Por expirar", "newest": "Más recientes" }, + "banner": { "more_alerts": "+{{count}} más" } }, - "welcome": "Bienvenido al sitio de la aplicación obytes" + "welcome": "Bienvenido al sitio de la aplicación obytes", + "video_feeds": { + "tab_title": "Video", + "no_feeds": "No hay transmisiones de video disponibles", + "add_feed": "Agregar transmisión", + "edit_feed": "Editar transmisión", + "watch": "Ver", + "copy_url": "Copiar URL", + "url_copied": "URL copiada al portapapeles", + "delete_feed": "Eliminar transmisión", + "delete_confirm_title": "Eliminar transmisión de video", + "delete_confirm_message": "¿Está seguro de que desea eliminar esta transmisión de video?", + "save_success": "Transmisión de video guardada exitosamente", + "save_error": "Error al guardar la transmisión de video", + "delete_success": "Transmisión de video eliminada exitosamente", + "delete_error": "Error al eliminar la transmisión de video", + "name": "Nombre", + "url": "URL", + "feed_type": "Tipo de transmisión", + "feed_format": "Formato de transmisión", + "description": "Descripción", + "latitude": "Latitud", + "longitude": "Longitud", + "added_by": "Agregado por", + "added_on": "Agregado el", + "status_active": "Activo", + "status_inactive": "Inactivo", + "status_error": "Error", + "type_drone": "Dron", + "type_fixed_camera": "Cámara fija", + "type_body_cam": "Cámara corporal", + "type_traffic_cam": "Cámara de tráfico", + "type_weather_cam": "Cámara meteorológica", + "type_satellite_feed": "Transmisión satelital", + "type_web_cam": "Cámara web", + "type_other": "Otro", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube en vivo", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Embebido", + "format_other": "Otro", + "rtsp_not_supported": "Las transmisiones RTSP no se pueden reproducir directamente. Copie la URL para usar en un reproductor dedicado.", + "webrtc_not_supported": "Reproducción WebRTC próximamente", + "player_title": "Reproductor de video", + "loading_video": "Cargando video...", + "video_error": "Error al cargar el video" + } } diff --git a/src/translations/fr.json b/src/translations/fr.json index a61feb5..0bf6bd9 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -90,6 +90,35 @@ "volumeDown": "Volume -", "volumeUp": "Volume +" }, + "check_in": { + "tab_title": "Pointage", + "timer_status": "État du minuteur", + "perform_check_in": "Pointer", + "quick_check_in": "Pointage rapide", + "check_in_success": "Pointage enregistré avec succès", + "check_in_error": "Échec de l'enregistrement du pointage", + "last_check_in": "Dernier pointage", + "elapsed": "Écoulé", + "duration": "min", + "status_ok": "OK", + "status_warning": "Avertissement", + "status_overdue": "En retard", + "history": "Historique", + "no_timers": "Aucun minuteur de pointage actif", + "timers_disabled": "Les minuteurs de pointage sont désactivés pour cet appel", + "type_personnel": "Personnel", + "type_unit": "Unité", + "type_ic": "CI", + "type_par": "PAR", + "type_hazmat": "Matières dangereuses", + "type_sector_rotation": "Rotation de secteur", + "type_rehab": "Réhabilitation", + "add_note": "Ajouter une note", + "confirm": "Confirmer le pointage", + "minutes_ago": "min", + "select_type": "Sélectionner le type de pointage", + "queued_offline": "Pointage mis en file d'attente pour la restauration de la connexion" + }, "callImages": { "add": "Ajouter une image", "add_new": "Ajouter une nouvelle image", @@ -193,6 +222,7 @@ "dispatched": "Envoyé", "info": "Info", "protocols": "Protocoles", + "check_in": "Pointage", "timeline": "Activité" }, "timestamp": "Horodatage", @@ -822,7 +852,75 @@ "notes": "Notes", "protocols": "Protocoles", "routes": "Itinéraires", - "settings": "Paramètres" + "settings": "Paramètres", + "weather_alerts": "Météo" + }, + "weather_alerts": { + "title": "Alertes Météo", + "loading": "Chargement des alertes météo...", + "no_alerts": "Aucune Alerte Météo", + "no_alerts_description": "Il n'y a aucune alerte météo active pour votre zone.", + "feature_disabled": "Alertes Météo Désactivées", + "feature_disabled_description": "Les alertes météo ne sont pas activées pour votre département.", + "search": "Rechercher des alertes météo...", + "severity": { "extreme": "Extrême", "severe": "Sévère", "moderate": "Modéré", "minor": "Mineur", "unknown": "Inconnu" }, + "category": { "met": "Météorologique", "fire": "Incendie", "health": "Santé", "env": "Environnemental", "other": "Autre" }, + "urgency": { "immediate": "Immédiat", "expected": "Attendu", "future": "Futur", "past": "Passé", "unknown": "Inconnu" }, + "certainty": { "observed": "Observé", "likely": "Probable", "possible": "Possible", "unlikely": "Improbable", "unknown": "Inconnu" }, + "status": { "active": "Actif", "updated": "Mis à jour", "expired": "Expiré", "cancelled": "Annulé" }, + "detail": { "headline": "Titre", "description": "Description", "instructions": "Instructions", "area": "Zone Affectée", "effective": "Effectif", "onset": "Début", "expires": "Expire", "sender": "Expéditeur", "urgency": "Urgence", "certainty": "Certitude" }, + "filter": { "all": "Tous", "nearby": "À proximité" }, + "sort": { "severity": "Sévérité", "expires": "Expire bientôt", "newest": "Plus récents" }, + "banner": { "more_alerts": "+{{count}} de plus" } }, - "welcome": "Bienvenue sur l'application obytes" + "welcome": "Bienvenue sur l'application obytes", + "video_feeds": { + "tab_title": "Vidéo", + "no_feeds": "Aucun flux vidéo disponible", + "add_feed": "Ajouter un flux", + "edit_feed": "Modifier le flux", + "watch": "Regarder", + "copy_url": "Copier l'URL", + "url_copied": "URL copiée dans le presse-papiers", + "delete_feed": "Supprimer le flux", + "delete_confirm_title": "Supprimer le flux vidéo", + "delete_confirm_message": "Êtes-vous sûr de vouloir supprimer ce flux vidéo ?", + "save_success": "Flux vidéo enregistré avec succès", + "save_error": "Échec de l'enregistrement du flux vidéo", + "delete_success": "Flux vidéo supprimé avec succès", + "delete_error": "Échec de la suppression du flux vidéo", + "name": "Nom", + "url": "URL", + "feed_type": "Type de flux", + "feed_format": "Format du flux", + "description": "Description", + "latitude": "Latitude", + "longitude": "Longitude", + "added_by": "Ajouté par", + "added_on": "Ajouté le", + "status_active": "Actif", + "status_inactive": "Inactif", + "status_error": "Erreur", + "type_drone": "Drone", + "type_fixed_camera": "Caméra fixe", + "type_body_cam": "Caméra corporelle", + "type_traffic_cam": "Caméra de circulation", + "type_weather_cam": "Caméra météo", + "type_satellite_feed": "Flux satellite", + "type_web_cam": "Webcam", + "type_other": "Autre", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube Live", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Intégré", + "format_other": "Autre", + "rtsp_not_supported": "Les flux RTSP ne peuvent pas être lus directement. Copiez l'URL pour l'utiliser dans un lecteur dédié.", + "webrtc_not_supported": "Lecture WebRTC bientôt disponible", + "player_title": "Lecteur vidéo", + "loading_video": "Chargement de la vidéo...", + "video_error": "Échec du chargement de la vidéo" + } } diff --git a/src/translations/it.json b/src/translations/it.json index 65c69cc..34e4f58 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -90,6 +90,35 @@ "volumeDown": "Volume -", "volumeUp": "Volume +" }, + "check_in": { + "tab_title": "Check-In", + "timer_status": "Stato timer", + "perform_check_in": "Registra", + "quick_check_in": "Check-In rapido", + "check_in_success": "Check-in registrato con successo", + "check_in_error": "Impossibile registrare il check-in", + "last_check_in": "Ultimo check-in", + "elapsed": "Trascorso", + "duration": "min", + "status_ok": "OK", + "status_warning": "Avviso", + "status_overdue": "Scaduto", + "history": "Cronologia", + "no_timers": "Nessun timer di check-in attivo", + "timers_disabled": "I timer di check-in sono disabilitati per questa chiamata", + "type_personnel": "Personale", + "type_unit": "Unità", + "type_ic": "CI", + "type_par": "PAR", + "type_hazmat": "Materiali pericolosi", + "type_sector_rotation": "Rotazione settore", + "type_rehab": "Riabilitazione", + "add_note": "Aggiungi nota", + "confirm": "Conferma check-in", + "minutes_ago": "min fa", + "select_type": "Seleziona tipo di check-in", + "queued_offline": "Check-in in coda per quando la connessione sarà ripristinata" + }, "callImages": { "add": "Aggiungi immagine", "add_new": "Aggiungi nuova immagine", @@ -193,6 +222,7 @@ "dispatched": "Inviato", "info": "Info", "protocols": "Protocolli", + "check_in": "Check-In", "timeline": "Attività" }, "timestamp": "Timestamp", @@ -822,7 +852,75 @@ "notes": "Note", "protocols": "Protocolli", "routes": "Percorsi", - "settings": "Impostazioni" + "settings": "Impostazioni", + "weather_alerts": "Meteo" + }, + "weather_alerts": { + "title": "Allerte Meteo", + "loading": "Caricamento allerte meteo...", + "no_alerts": "Nessuna Allerta Meteo", + "no_alerts_description": "Non ci sono allerte meteo attive per la tua area.", + "feature_disabled": "Allerte Meteo Disabilitate", + "feature_disabled_description": "Le allerte meteo non sono abilitate per il tuo dipartimento.", + "search": "Cerca allerte meteo...", + "severity": { "extreme": "Estremo", "severe": "Grave", "moderate": "Moderato", "minor": "Lieve", "unknown": "Sconosciuto" }, + "category": { "met": "Meteorologico", "fire": "Incendio", "health": "Salute", "env": "Ambientale", "other": "Altro" }, + "urgency": { "immediate": "Immediato", "expected": "Previsto", "future": "Futuro", "past": "Passato", "unknown": "Sconosciuto" }, + "certainty": { "observed": "Osservato", "likely": "Probabile", "possible": "Possibile", "unlikely": "Improbabile", "unknown": "Sconosciuto" }, + "status": { "active": "Attivo", "updated": "Aggiornato", "expired": "Scaduto", "cancelled": "Annullato" }, + "detail": { "headline": "Titolo", "description": "Descrizione", "instructions": "Istruzioni", "area": "Area Interessata", "effective": "Effettivo", "onset": "Inizio", "expires": "Scade", "sender": "Mittente", "urgency": "Urgenza", "certainty": "Certezza" }, + "filter": { "all": "Tutti", "nearby": "Vicini" }, + "sort": { "severity": "Gravità", "expires": "In scadenza", "newest": "Più recenti" }, + "banner": { "more_alerts": "+{{count}} altre" } }, - "welcome": "Benvenuto nell'app obytes" + "welcome": "Benvenuto nell'app obytes", + "video_feeds": { + "tab_title": "Video", + "no_feeds": "Nessun feed video disponibile", + "add_feed": "Aggiungi feed", + "edit_feed": "Modifica feed", + "watch": "Guarda", + "copy_url": "Copia URL", + "url_copied": "URL copiato negli appunti", + "delete_feed": "Elimina feed", + "delete_confirm_title": "Elimina feed video", + "delete_confirm_message": "Sei sicuro di voler eliminare questo feed video?", + "save_success": "Feed video salvato con successo", + "save_error": "Impossibile salvare il feed video", + "delete_success": "Feed video eliminato con successo", + "delete_error": "Impossibile eliminare il feed video", + "name": "Nome", + "url": "URL", + "feed_type": "Tipo di feed", + "feed_format": "Formato del feed", + "description": "Descrizione", + "latitude": "Latitudine", + "longitude": "Longitudine", + "added_by": "Aggiunto da", + "added_on": "Aggiunto il", + "status_active": "Attivo", + "status_inactive": "Inattivo", + "status_error": "Errore", + "type_drone": "Drone", + "type_fixed_camera": "Telecamera fissa", + "type_body_cam": "Bodycam", + "type_traffic_cam": "Telecamera traffico", + "type_weather_cam": "Telecamera meteo", + "type_satellite_feed": "Feed satellitare", + "type_web_cam": "Webcam", + "type_other": "Altro", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube Live", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Incorporato", + "format_other": "Altro", + "rtsp_not_supported": "I flussi RTSP non possono essere riprodotti direttamente. Copia l'URL per utilizzarlo in un lettore dedicato.", + "webrtc_not_supported": "Riproduzione WebRTC in arrivo", + "player_title": "Lettore video", + "loading_video": "Caricamento video...", + "video_error": "Impossibile caricare il video" + } } diff --git a/src/translations/pl.json b/src/translations/pl.json index 0e8f510..e58a284 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -90,6 +90,35 @@ "volumeDown": "Głośność -", "volumeUp": "Głośność +" }, + "check_in": { + "tab_title": "Meldunek", + "timer_status": "Status timera", + "perform_check_in": "Zamelduj się", + "quick_check_in": "Szybki meldunek", + "check_in_success": "Meldunek zarejestrowany pomyślnie", + "check_in_error": "Nie udało się zarejestrować meldunku", + "last_check_in": "Ostatni meldunek", + "elapsed": "Upłynęło", + "duration": "min", + "status_ok": "OK", + "status_warning": "Ostrzeżenie", + "status_overdue": "Zaległy", + "history": "Historia", + "no_timers": "Brak aktywnych timerów meldunków", + "timers_disabled": "Timery meldunków są wyłączone dla tego zgłoszenia", + "type_personnel": "Personel", + "type_unit": "Jednostka", + "type_ic": "KI", + "type_par": "PAR", + "type_hazmat": "Materiały niebezpieczne", + "type_sector_rotation": "Rotacja sektorów", + "type_rehab": "Rehabilitacja", + "add_note": "Dodaj notatkę", + "confirm": "Potwierdź meldunek", + "minutes_ago": "min temu", + "select_type": "Wybierz typ meldunku", + "queued_offline": "Meldunek zakolejkowany do momentu przywrócenia połączenia" + }, "callImages": { "add": "Dodaj zdjęcie", "add_new": "Dodaj nowe zdjęcie", @@ -193,6 +222,7 @@ "dispatched": "Wysłane", "info": "Info", "protocols": "Protokoły", + "check_in": "Meldunek", "timeline": "Aktywność" }, "timestamp": "Znacznik czasu", @@ -822,7 +852,75 @@ "notes": "Notatki", "protocols": "Protokoły", "routes": "Trasy", - "settings": "Ustawienia" + "settings": "Ustawienia", + "weather_alerts": "Pogoda" + }, + "weather_alerts": { + "title": "Alerty Pogodowe", + "loading": "Ładowanie alertów pogodowych...", + "no_alerts": "Brak Alertów Pogodowych", + "no_alerts_description": "Nie ma aktywnych alertów pogodowych dla Twojego obszaru.", + "feature_disabled": "Alerty Pogodowe Wyłączone", + "feature_disabled_description": "Alerty pogodowe nie są włączone dla Twojego działu.", + "search": "Szukaj alertów pogodowych...", + "severity": { "extreme": "Ekstremalny", "severe": "Poważny", "moderate": "Umiarkowany", "minor": "Niewielki", "unknown": "Nieznany" }, + "category": { "met": "Meteorologiczny", "fire": "Pożar", "health": "Zdrowie", "env": "Środowiskowy", "other": "Inny" }, + "urgency": { "immediate": "Natychmiastowy", "expected": "Oczekiwany", "future": "Przyszły", "past": "Przeszły", "unknown": "Nieznany" }, + "certainty": { "observed": "Zaobserwowany", "likely": "Prawdopodobny", "possible": "Możliwy", "unlikely": "Mało prawdopodobny", "unknown": "Nieznany" }, + "status": { "active": "Aktywny", "updated": "Zaktualizowany", "expired": "Wygasły", "cancelled": "Anulowany" }, + "detail": { "headline": "Nagłówek", "description": "Opis", "instructions": "Instrukcje", "area": "Dotknięty Obszar", "effective": "Obowiązuje od", "onset": "Początek", "expires": "Wygasa", "sender": "Nadawca", "urgency": "Pilność", "certainty": "Pewność" }, + "filter": { "all": "Wszystkie", "nearby": "W pobliżu" }, + "sort": { "severity": "Dotkliwość", "expires": "Wkrótce wygasające", "newest": "Najnowsze" }, + "banner": { "more_alerts": "+{{count}} więcej" } }, - "welcome": "Witamy w aplikacji obytes" + "welcome": "Witamy w aplikacji obytes", + "video_feeds": { + "tab_title": "Wideo", + "no_feeds": "Brak dostępnych transmisji wideo", + "add_feed": "Dodaj transmisję", + "edit_feed": "Edytuj transmisję", + "watch": "Oglądaj", + "copy_url": "Kopiuj URL", + "url_copied": "URL skopiowany do schowka", + "delete_feed": "Usuń transmisję", + "delete_confirm_title": "Usuń transmisję wideo", + "delete_confirm_message": "Czy na pewno chcesz usunąć tę transmisję wideo?", + "save_success": "Transmisja wideo zapisana pomyślnie", + "save_error": "Nie udało się zapisać transmisji wideo", + "delete_success": "Transmisja wideo usunięta pomyślnie", + "delete_error": "Nie udało się usunąć transmisji wideo", + "name": "Nazwa", + "url": "URL", + "feed_type": "Typ transmisji", + "feed_format": "Format transmisji", + "description": "Opis", + "latitude": "Szerokość geograficzna", + "longitude": "Długość geograficzna", + "added_by": "Dodane przez", + "added_on": "Dodano", + "status_active": "Aktywny", + "status_inactive": "Nieaktywny", + "status_error": "Błąd", + "type_drone": "Dron", + "type_fixed_camera": "Kamera stała", + "type_body_cam": "Kamera nasobna", + "type_traffic_cam": "Kamera drogowa", + "type_weather_cam": "Kamera pogodowa", + "type_satellite_feed": "Transmisja satelitarna", + "type_web_cam": "Kamera internetowa", + "type_other": "Inne", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube na żywo", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Osadzony", + "format_other": "Inne", + "rtsp_not_supported": "Strumienie RTSP nie mogą być odtwarzane bezpośrednio. Skopiuj URL, aby użyć go w dedykowanym odtwarzaczu.", + "webrtc_not_supported": "Odtwarzanie WebRTC wkrótce", + "player_title": "Odtwarzacz wideo", + "loading_video": "Ładowanie wideo...", + "video_error": "Nie udało się załadować wideo" + } } diff --git a/src/translations/sv.json b/src/translations/sv.json index 198a852..e64f8dc 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -90,6 +90,35 @@ "volumeDown": "Volym -", "volumeUp": "Volym +" }, + "check_in": { + "tab_title": "Incheckning", + "timer_status": "Timerstatus", + "perform_check_in": "Checka in", + "quick_check_in": "Snabb incheckning", + "check_in_success": "Incheckning registrerad", + "check_in_error": "Kunde inte registrera incheckning", + "last_check_in": "Senaste incheckning", + "elapsed": "Förfluten", + "duration": "min", + "status_ok": "OK", + "status_warning": "Varning", + "status_overdue": "Försenad", + "history": "Historik", + "no_timers": "Inga aktiva inchecknings-timers", + "timers_disabled": "Inchecknings-timers är inaktiverade för detta samtal", + "type_personnel": "Personal", + "type_unit": "Enhet", + "type_ic": "IC", + "type_par": "PAR", + "type_hazmat": "Farligt gods", + "type_sector_rotation": "Sektorsrotation", + "type_rehab": "Rehabilitering", + "add_note": "Lägg till anteckning", + "confirm": "Bekräfta incheckning", + "minutes_ago": "min sedan", + "select_type": "Välj incheckningstyp", + "queued_offline": "Incheckning köad för när anslutningen återställs" + }, "callImages": { "add": "Lägg till bild", "add_new": "Lägg till ny bild", @@ -193,6 +222,7 @@ "dispatched": "Utskickad", "info": "Info", "protocols": "Protokoll", + "check_in": "Incheckning", "timeline": "Aktivitet" }, "timestamp": "Tidsstämpel", @@ -822,7 +852,75 @@ "notes": "Anteckningar", "protocols": "Protokoll", "routes": "Rutter", - "settings": "Inställningar" + "settings": "Inställningar", + "weather_alerts": "Väder" + }, + "weather_alerts": { + "title": "Vädervarningar", + "loading": "Laddar vädervarningar...", + "no_alerts": "Inga Vädervarningar", + "no_alerts_description": "Det finns inga aktiva vädervarningar för ditt område.", + "feature_disabled": "Vädervarningar Inaktiverade", + "feature_disabled_description": "Vädervarningar är inte aktiverade för din avdelning.", + "search": "Sök vädervarningar...", + "severity": { "extreme": "Extrem", "severe": "Allvarlig", "moderate": "Måttlig", "minor": "Mindre", "unknown": "Okänd" }, + "category": { "met": "Meteorologisk", "fire": "Brand", "health": "Hälsa", "env": "Miljö", "other": "Övrigt" }, + "urgency": { "immediate": "Omedelbar", "expected": "Förväntad", "future": "Framtida", "past": "Förfluten", "unknown": "Okänd" }, + "certainty": { "observed": "Observerad", "likely": "Trolig", "possible": "Möjlig", "unlikely": "Osannolik", "unknown": "Okänd" }, + "status": { "active": "Aktiv", "updated": "Uppdaterad", "expired": "Utgången", "cancelled": "Avbruten" }, + "detail": { "headline": "Rubrik", "description": "Beskrivning", "instructions": "Instruktioner", "area": "Påverkat Område", "effective": "Gäller från", "onset": "Början", "expires": "Upphör", "sender": "Avsändare", "urgency": "Brådska", "certainty": "Säkerhet" }, + "filter": { "all": "Alla", "nearby": "Nära" }, + "sort": { "severity": "Allvarlighetsgrad", "expires": "Upphör snart", "newest": "Senaste" }, + "banner": { "more_alerts": "+{{count}} fler" } }, - "welcome": "Välkommen till obytes app site" + "welcome": "Välkommen till obytes app site", + "video_feeds": { + "tab_title": "Video", + "no_feeds": "Inga videoflöden tillgängliga", + "add_feed": "Lägg till flöde", + "edit_feed": "Redigera flöde", + "watch": "Titta", + "copy_url": "Kopiera URL", + "url_copied": "URL kopierad till urklipp", + "delete_feed": "Ta bort flöde", + "delete_confirm_title": "Ta bort videoflöde", + "delete_confirm_message": "Är du säker på att du vill ta bort detta videoflöde?", + "save_success": "Videoflöde sparat", + "save_error": "Kunde inte spara videoflödet", + "delete_success": "Videoflöde borttaget", + "delete_error": "Kunde inte ta bort videoflödet", + "name": "Namn", + "url": "URL", + "feed_type": "Flödestyp", + "feed_format": "Flödesformat", + "description": "Beskrivning", + "latitude": "Latitud", + "longitude": "Longitud", + "added_by": "Tillagd av", + "added_on": "Tillagd", + "status_active": "Aktiv", + "status_inactive": "Inaktiv", + "status_error": "Fel", + "type_drone": "Drönare", + "type_fixed_camera": "Fast kamera", + "type_body_cam": "Kroppskamera", + "type_traffic_cam": "Trafikkamera", + "type_weather_cam": "Väderkamera", + "type_satellite_feed": "Satellitflöde", + "type_web_cam": "Webbkamera", + "type_other": "Övrigt", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube Live", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Inbäddad", + "format_other": "Övrigt", + "rtsp_not_supported": "RTSP-strömmar kan inte spelas direkt. Kopiera URL:en för att använda i en dedikerad spelare.", + "webrtc_not_supported": "WebRTC-uppspelning kommer snart", + "player_title": "Videospelare", + "loading_video": "Laddar video...", + "video_error": "Kunde inte ladda videon" + } } diff --git a/src/translations/uk.json b/src/translations/uk.json index 14a7fe5..f3affaf 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -90,6 +90,35 @@ "volumeDown": "Гучність -", "volumeUp": "Гучність +" }, + "check_in": { + "tab_title": "Реєстрація", + "timer_status": "Статус таймера", + "perform_check_in": "Зареєструватися", + "quick_check_in": "Швидка реєстрація", + "check_in_success": "Реєстрацію успішно записано", + "check_in_error": "Не вдалося записати реєстрацію", + "last_check_in": "Остання реєстрація", + "elapsed": "Минуло", + "duration": "хв", + "status_ok": "ОК", + "status_warning": "Попередження", + "status_overdue": "Прострочено", + "history": "Історія", + "no_timers": "Немає активних таймерів реєстрації", + "timers_disabled": "Таймери реєстрації вимкнені для цього виклику", + "type_personnel": "Персонал", + "type_unit": "Підрозділ", + "type_ic": "КІ", + "type_par": "PAR", + "type_hazmat": "Небезпечні матеріали", + "type_sector_rotation": "Ротація секторів", + "type_rehab": "Реабілітація", + "add_note": "Додати примітку", + "confirm": "Підтвердити реєстрацію", + "minutes_ago": "хв тому", + "select_type": "Оберіть тип реєстрації", + "queued_offline": "Реєстрацію поставлено в чергу до відновлення з'єднання" + }, "callImages": { "add": "Додати зображення", "add_new": "Додати нове зображення", @@ -193,6 +222,7 @@ "dispatched": "Відправлено", "info": "Інфо", "protocols": "Протоколи", + "check_in": "Реєстрація", "timeline": "Активність" }, "timestamp": "Мітка часу", @@ -822,7 +852,75 @@ "notes": "Примітки", "protocols": "Протоколи", "routes": "Маршрути", - "settings": "Налаштування" + "settings": "Налаштування", + "weather_alerts": "Погода" + }, + "weather_alerts": { + "title": "Погодні Сповіщення", + "loading": "Завантаження погодних сповіщень...", + "no_alerts": "Немає Погодних Сповіщень", + "no_alerts_description": "Немає активних погодних сповіщень для вашого району.", + "feature_disabled": "Погодні Сповіщення Вимкнено", + "feature_disabled_description": "Погодні сповіщення не увімкнено для вашого відділу.", + "search": "Шукати погодні сповіщення...", + "severity": { "extreme": "Екстремальний", "severe": "Серйозний", "moderate": "Помірний", "minor": "Незначний", "unknown": "Невідомий" }, + "category": { "met": "Метеорологічний", "fire": "Пожежа", "health": "Здоров'я", "env": "Екологічний", "other": "Інше" }, + "urgency": { "immediate": "Негайний", "expected": "Очікуваний", "future": "Майбутній", "past": "Минулий", "unknown": "Невідомий" }, + "certainty": { "observed": "Спостережений", "likely": "Ймовірний", "possible": "Можливий", "unlikely": "Малоймовірний", "unknown": "Невідомий" }, + "status": { "active": "Активний", "updated": "Оновлений", "expired": "Закінчився", "cancelled": "Скасований" }, + "detail": { "headline": "Заголовок", "description": "Опис", "instructions": "Інструкції", "area": "Постраждалий Район", "effective": "Чинний з", "onset": "Початок", "expires": "Закінчується", "sender": "Відправник", "urgency": "Терміновість", "certainty": "Впевненість" }, + "filter": { "all": "Всі", "nearby": "Поблизу" }, + "sort": { "severity": "Серйозність", "expires": "Скоро закінчується", "newest": "Найновіші" }, + "banner": { "more_alerts": "+{{count}} більше" } }, - "welcome": "Ласкаво просимо до додатку obytes" + "welcome": "Ласкаво просимо до додатку obytes", + "video_feeds": { + "tab_title": "Відео", + "no_feeds": "Немає доступних відеопотоків", + "add_feed": "Додати потік", + "edit_feed": "Редагувати потік", + "watch": "Дивитися", + "copy_url": "Копіювати URL", + "url_copied": "URL скопійовано до буфера обміну", + "delete_feed": "Видалити потік", + "delete_confirm_title": "Видалити відеопотік", + "delete_confirm_message": "Ви впевнені, що хочете видалити цей відеопотік?", + "save_success": "Відеопотік успішно збережено", + "save_error": "Не вдалося зберегти відеопотік", + "delete_success": "Відеопотік успішно видалено", + "delete_error": "Не вдалося видалити відеопотік", + "name": "Назва", + "url": "URL", + "feed_type": "Тип потоку", + "feed_format": "Формат потоку", + "description": "Опис", + "latitude": "Широта", + "longitude": "Довгота", + "added_by": "Додав", + "added_on": "Додано", + "status_active": "Активний", + "status_inactive": "Неактивний", + "status_error": "Помилка", + "type_drone": "Дрон", + "type_fixed_camera": "Стаціонарна камера", + "type_body_cam": "Натільна камера", + "type_traffic_cam": "Камера руху", + "type_weather_cam": "Камера погоди", + "type_satellite_feed": "Супутниковий потік", + "type_web_cam": "Вебкамера", + "type_other": "Інше", + "format_rtsp": "RTSP", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_youtube_live": "YouTube наживо", + "format_webrtc": "WebRTC", + "format_dash": "DASH", + "format_embed": "Вбудований", + "format_other": "Інше", + "rtsp_not_supported": "Потоки RTSP не можна відтворювати безпосередньо. Скопіюйте URL для використання у спеціалізованому плеєрі.", + "webrtc_not_supported": "Відтворення WebRTC незабаром", + "player_title": "Відеоплеєр", + "loading_video": "Завантаження відео...", + "video_error": "Не вдалося завантажити відео" + } } diff --git a/yarn.lock b/yarn.lock index 9822dfe..21fe5a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9284,6 +9284,11 @@ expo-build-properties@~1.0.10: ajv "^8.11.0" semver "^7.6.0" +expo-clipboard@~8.0.8: + version "8.0.8" + resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7" + integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== + expo-constants@~18.0.11, expo-constants@~18.0.12, expo-constants@~18.0.13: version "18.0.13" resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d" From 2f8539407fe841978ebeac16bd0a95bbf582faa9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 26 Apr 2026 20:56:21 -0700 Subject: [PATCH 3/4] RU-T51 PR#235 fixes --- plugins/withCheckInLiveActivity.js | 201 +++++++++++- src/api/dispatch/dispatch.ts | 2 +- src/app/(app)/index.tsx | 15 +- src/app/(app)/protocols.tsx | 1 - src/app/(app)/weather-alerts.tsx | 25 +- src/app/call/[id].tsx | 34 +- src/app/call/[id]/edit.tsx | 10 +- src/app/call/new/index.tsx | 56 ++-- src/app/onboarding.tsx | 4 +- src/app/routes/poi/[id].tsx | 4 +- src/app/weather-alert/[id].tsx | 4 +- .../calls/__tests__/call-files-modal.test.tsx | 4 +- .../__tests__/call-images-modal.test.tsx | 6 +- src/components/calls/call-files-modal.tsx | 11 +- src/components/calls/call-images-modal.tsx | 11 +- .../calls/destination-poi-selector.tsx | 2 +- .../check-in-timers/check-in-bottom-sheet.tsx | 11 +- .../check-in-timers/check-in-timer-card.tsx | 6 +- .../notifications/NotificationInbox.tsx | 1 - src/components/routes/active-routes-list.tsx | 7 +- src/components/routes/poi-list-content.tsx | 10 +- ...luetooth-device-selection-bottom-sheet.tsx | 10 +- src/components/status/status-bottom-sheet.tsx | 41 +-- src/components/ui/flat-list/index.tsx | 2 +- .../weather-alerts/severity-filter-tabs.tsx | 14 +- .../weather-alerts/weather-alert-banner.tsx | 7 +- .../weather-alerts/weather-alert-card.tsx | 7 +- .../weather-alert-detail-map.tsx | 5 +- .../__tests__/use-quick-check-in.test.ts | 6 +- src/hooks/use-check-in-timer-polling.ts | 37 ++- src/hooks/use-quick-check-in.ts | 9 +- src/lib/poi-utils.ts | 8 +- src/lib/weather-alert-utils.ts | 2 +- .../v4/dispatch/newCallFormResultData.ts | 2 +- .../v4/mapping/getMapDataAndMarkersData.ts | 2 +- src/models/v4/mapping/poiResults.ts | 3 +- src/services/check-in-notification.service.ts | 33 +- src/services/push-notification.ts | 35 ++- src/services/push-notification.web.ts | 9 +- src/stores/calls/store.ts | 6 +- .../check-in-timers/__tests__/store.test.ts | 12 +- src/stores/check-in-timers/store.ts | 18 +- src/stores/pois/store.ts | 11 +- src/stores/signalr/signalr-store.ts | 63 +++- src/stores/status/store.ts | 10 +- src/stores/weather-alerts/store.ts | 10 +- src/translations/ar.json | 263 +++++++++++----- src/translations/de.json | 263 +++++++++++----- src/translations/en.json | 292 +++++++++--------- src/translations/es.json | 263 +++++++++++----- src/translations/fr.json | 263 +++++++++++----- src/translations/it.json | 263 +++++++++++----- src/translations/pl.json | 263 +++++++++++----- src/translations/sv.json | 263 +++++++++++----- src/translations/uk.json | 263 +++++++++++----- 55 files changed, 2084 insertions(+), 1099 deletions(-) diff --git a/plugins/withCheckInLiveActivity.js b/plugins/withCheckInLiveActivity.js index 82d1ce9..ff0f712 100644 --- a/plugins/withCheckInLiveActivity.js +++ b/plugins/withCheckInLiveActivity.js @@ -2,6 +2,68 @@ const { withDangerousMod, withInfoPlist, withEntitlementsPlist, withXcodeProject const fs = require('fs'); const path = require('path'); +/** + * Resolves the iOS app target name so bridge files land in the correct folder. + * + * Resolution order: + * 1. config.modRequest.projectName (set by Expo during prebuild — preferred) + * 2. Parse ios/.xcodeproj/project.pbxproj and find the PBXNativeTarget + * whose productType is "com.apple.product-type.application" + * + * Throws an explicit error if neither source yields a name, so the developer is + * informed immediately instead of files being silently written to the wrong path. + * + * @param {object} config - Expo config object inside withDangerousMod callback + * @param {string} projectRoot - absolute path to the project root + * @returns {string} iOS app target / folder name + */ +function resolveIosAppName(config, projectRoot) { + // 1. Trust Expo's own projectName first (present during `expo prebuild`) + if (config.modRequest.projectName) { + return config.modRequest.projectName; + } + + // 2. Derive the name by parsing project.pbxproj + const iosDir = path.join(projectRoot, 'ios'); + if (fs.existsSync(iosDir)) { + let pbxprojPath = null; + try { + const entries = fs.readdirSync(iosDir); + const xcodeprojDir = entries.find((e) => e.endsWith('.xcodeproj')); + if (xcodeprojDir) { + pbxprojPath = path.join(iosDir, xcodeprojDir, 'project.pbxproj'); + } + } catch (_) { + // iosDir not readable — fall through to throw below + } + + if (pbxprojPath && fs.existsSync(pbxprojPath)) { + const pbxContent = fs.readFileSync(pbxprojPath, 'utf8'); + // Within a PBXNativeTarget block the fields appear in this order: + // name = TargetName; + // productName = TargetName; + // productReference = /* TargetName.app */; + // productType = "com.apple.product-type.application"; + // The `s` (dotAll) flag lets the pattern span newlines. + const match = pbxContent.match( + /name\s*=\s*([^\s;]+)\s*;\s*productName\s*=\s*[^;]+;\s*productReference\s*=\s*[^;]+;\s*productType\s*=\s*"com\.apple\.product-type\.application"/s + ); + if (match) { + return match[1].trim(); + } + } + } + + throw new Error( + '[withCheckInLiveActivity] Cannot determine the iOS app target name.\n' + + ' • config.modRequest.projectName is not set\n' + + ' • No PBXNativeTarget with productType=com.apple.product-type.application\n' + + ' was found in ios/*.xcodeproj/project.pbxproj\n' + + 'Ensure the iOS project has been initialised via `npx expo prebuild` before\n' + + 'running this plugin, or set the `name` field in your app.config.' + ); +} + /** * CheckInTimerAttributes.swift — ActivityKit attributes for the check-in timer Live Activity */ @@ -201,6 +263,41 @@ RCT_EXTERN_METHOD(endActivity:(RCTPromiseResolveBlock)resolve @end `; +/** + * Info.plist for the CheckInTimerWidget extension target. + * Required by Xcode; bundle metadata is resolved at build time via build settings. + */ +const WIDGET_INFO_PLIST = ` + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + CheckInTimerWidget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + +`; + const withCheckInLiveActivity = (config) => { // Step 1: Add NSSupportsLiveActivities to Info.plist config = withInfoPlist(config, (config) => { @@ -229,9 +326,12 @@ const withCheckInLiveActivity = (config) => { fs.writeFileSync(path.join(widgetDir, 'CheckInTimerAttributes.swift'), ATTRIBUTES_SWIFT); fs.writeFileSync(path.join(widgetDir, 'CheckInTimerLiveActivity.swift'), LIVE_ACTIVITY_SWIFT); fs.writeFileSync(path.join(widgetDir, 'CheckInTimerWidgetBundle.swift'), WIDGET_BUNDLE_SWIFT); + fs.writeFileSync(path.join(widgetDir, 'Info.plist'), WIDGET_INFO_PLIST); - // Write native bridge files to the main app directory - const appName = config.modRequest.projectName || 'ResgridUnit'; + // Write native bridge files to the main app directory. + // resolveIosAppName throws explicitly if the target cannot be determined, + // preventing files from being written to a wrong/hardcoded path. + const appName = resolveIosAppName(config, projectRoot); const appDir = path.join(projectRoot, 'ios', appName); if (!fs.existsSync(appDir)) { fs.mkdirSync(appDir, { recursive: true }); @@ -244,15 +344,100 @@ const withCheckInLiveActivity = (config) => { }, ]); - // Step 4: Add Widget Extension target to Xcode project + // Step 4: Add Widget Extension target to Xcode project and wire all required + // build phases, source files, and frameworks so Live Activities actually build. config = withXcodeProject(config, (config) => { const project = config.modResults; + const projectRoot = config.modRequest.projectRoot; + const appBundleId = config.ios?.bundleIdentifier; + + if (!appBundleId) { + throw new Error( + '[withCheckInLiveActivity] config.ios.bundleIdentifier is required ' + + 'to derive the widget extension bundle identifier.' + ); + } + + const WIDGET_NAME = 'CheckInTimerWidget'; + const widgetBundleId = `${appBundleId}.${WIDGET_NAME}`; + + // Idempotent: skip if the target was already added in a previous prebuild run. + // addTarget stores names with surrounding quotes in the comment key, so check both forms. + if (project.pbxTargetByName(WIDGET_NAME) || project.pbxTargetByName(`"${WIDGET_NAME}"`)) { + return config; + } + + // 1. Create the PBXNativeTarget. + // addTarget('app_extension') also: + // - adds an "Embed App Extensions" CopyFiles phase to the main target + // - adds a PBXTargetDependency from main app → widget + // - creates Debug/Release XCBuildConfigurations with basic defaults + const widgetTarget = project.addTarget(WIDGET_NAME, 'app_extension', WIDGET_NAME, widgetBundleId); + + // 2. Add the three build phases the widget target needs. + // These must be added before files/frameworks are wired, because the + // addSourceFile / addFramework helpers find phases by scanning the + // target's buildPhases array. + project.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', widgetTarget.uuid); + project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', widgetTarget.uuid); + project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', widgetTarget.uuid); + + // 3. Create a PBX group for the widget folder and attach it to the project's + // main group so the files appear in the Xcode file navigator. + const { uuid: widgetGroupUuid } = project.addPbxGroup([], WIDGET_NAME, WIDGET_NAME); + const { firstProject } = project.getFirstProject(); + const mainGroup = project.getPBXGroupByKey(firstProject.mainGroup); + if (mainGroup && !mainGroup.children.find((c) => c.comment === WIDGET_NAME)) { + mainGroup.children.push({ value: widgetGroupUuid, comment: WIDGET_NAME }); + } + + // 4. Add Swift source files to the widget group and to the widget's Sources phase. + // Passing the group key as the third argument to addSourceFile ensures the + // file reference lands in the right PBX group; opt.target routes the build + // file to the widget's PBXSourcesBuildPhase rather than the main app's. + const SWIFT_SOURCES = [ + 'CheckInTimerAttributes.swift', + 'CheckInTimerLiveActivity.swift', + 'CheckInTimerWidgetBundle.swift', + ]; + for (const filename of SWIFT_SOURCES) { + project.addSourceFile( + `${WIDGET_NAME}/${filename}`, + { target: widgetTarget.uuid }, + widgetGroupUuid + ); + } - // Add Widget Extension target with ActivityKit framework - // Note: This is a simplified version. Full widget extension target creation - // may require additional PBX configuration depending on the Xcode project structure. - // The withDangerousMod above creates the files; the Xcode target may need - // manual setup or a more complete config plugin for production builds. + // 5. Link WidgetKit and ActivityKit into the widget's Frameworks phase. + // opt.target directs addToPbxFrameworksBuildPhase to use the widget's + // PBXFrameworksBuildPhase (added above) instead of the main app's. + project.addFramework('WidgetKit.framework', { target: widgetTarget.uuid }); + project.addFramework('ActivityKit.framework', { target: widgetTarget.uuid }); + + // 6. Patch build settings on both Debug and Release configurations so the + // widget compiles as a Swift 5 app-extension targeting iOS 16.1+. + const targetSection = project.pbxNativeTargetSection(); + const buildConfigListId = targetSection[widgetTarget.uuid].buildConfigurationList; + const buildConfigList = project.pbxXCConfigurationList()[buildConfigListId]; + if (buildConfigList) { + for (const { value: configUuid } of buildConfigList.buildConfigurations) { + const buildConfig = project.pbxXCBuildConfigurationSection()[configUuid]; + if (buildConfig) { + Object.assign(buildConfig.buildSettings, { + // Override the default addTarget placeholder (TargetName-Info.plist) + INFOPLIST_FILE: `"${WIDGET_NAME}/Info.plist"`, + SWIFT_VERSION: '"5.0"', + TARGETED_DEVICE_FAMILY: '"1,2"', + // ActivityKit requires iOS 16.1 or later + IPHONEOS_DEPLOYMENT_TARGET: '16.1', + SKIP_INSTALL: 'YES', + CODE_SIGN_STYLE: 'Automatic', + MARKETING_VERSION: '"1.0"', + CURRENT_PROJECT_VERSION: '1', + }); + } + } + } return config; }); diff --git a/src/api/dispatch/dispatch.ts b/src/api/dispatch/dispatch.ts index 39d4013..1e6f1df 100644 --- a/src/api/dispatch/dispatch.ts +++ b/src/api/dispatch/dispatch.ts @@ -1,6 +1,6 @@ import { createApiEndpoint } from '@/api/common/client'; -import { type NewCallFormResult } from '@/models/v4/dispatch/newCallFormResult'; import { type GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult'; +import { type NewCallFormResult } from '@/models/v4/dispatch/newCallFormResult'; const getNewCallDataApi = createApiEndpoint('/Dispatch/GetNewCallData'); const getSetUnitStatusDataApi = createApiEndpoint('/Dispatch/GetSetUnitStatusData'); diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 14938e8..bbd6eae 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect, router } from 'expo-router'; +import { router, Stack, useFocusEffect } from 'expo-router'; import { NavigationIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -13,6 +13,7 @@ import Mapbox from '@/components/maps/mapbox'; import PinDetailModal from '@/components/maps/pin-detail-modal'; import { StopMarker } from '@/components/routes/stop-marker'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; @@ -26,7 +27,6 @@ import { useMapsStore } from '@/stores/maps/store'; import { useRoutesStore } from '@/stores/routes/store'; import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; -import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner'; Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); @@ -64,10 +64,7 @@ function MapContent() { const weatherAlerts = useWeatherAlertsStore((state) => state.alerts); const weatherSettings = useWeatherAlertsStore((state) => state.settings); const [isBannerDismissed, setIsBannerDismissed] = useState(false); - const extremeAlerts = useMemo( - () => weatherAlerts.filter((a) => a.Severity <= 1 && a.Status === 0), - [weatherAlerts] - ); + const extremeAlerts = useMemo(() => weatherAlerts.filter((a) => a.Severity <= 1 && a.Status === 0), [weatherAlerts]); // Reset dismissed state when alert count changes useEffect(() => { @@ -595,11 +592,7 @@ function MapContent() { {/* Weather Alert Banner */} {weatherSettings?.WeatherAlertsEnabled && extremeAlerts.length > 0 && !isBannerDismissed ? ( - router.push('/(app)/weather-alerts')} - onDismiss={() => setIsBannerDismissed(true)} - /> + router.push('/(app)/weather-alerts')} onDismiss={() => setIsBannerDismissed(true)} /> ) : null} diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx index 0042951..c9d5150 100644 --- a/src/app/(app)/protocols.tsx +++ b/src/app/(app)/protocols.tsx @@ -87,7 +87,6 @@ export default function Protocols() { contentContainerStyle={{ paddingBottom: 100 }} refreshControl={} extraData={handleProtocolPress} - estimatedItemSize={120} /> ) : ( diff --git a/src/app/(app)/weather-alerts.tsx b/src/app/(app)/weather-alerts.tsx index d35ed53..e6b10ec 100644 --- a/src/app/(app)/weather-alerts.tsx +++ b/src/app/(app)/weather-alerts.tsx @@ -7,12 +7,12 @@ import { Pressable, RefreshControl, View } from 'react-native'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; -import { SeverityFilterTabs } from '@/components/weather-alerts/severity-filter-tabs'; -import { WeatherAlertCard } from '@/components/weather-alerts/weather-alert-card'; import { Box } from '@/components/ui/box'; import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { SeverityFilterTabs } from '@/components/weather-alerts/severity-filter-tabs'; +import { WeatherAlertCard } from '@/components/weather-alerts/weather-alert-card'; import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; @@ -26,6 +26,7 @@ export default function WeatherAlerts() { const fetchActiveAlerts = useWeatherAlertsStore((state) => state.fetchActiveAlerts); const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); + const [refreshing, setRefreshing] = useState(false); useFocusEffect( useCallback(() => { @@ -33,8 +34,10 @@ export default function WeatherAlerts() { }, [fetchActiveAlerts]) ); - const handleRefresh = () => { - fetchActiveAlerts(); + const handleRefresh = async () => { + setRefreshing(true); + await fetchActiveAlerts(); + setRefreshing(false); }; // Filter alerts @@ -42,11 +45,7 @@ export default function WeatherAlerts() { if (severityFilter !== null && alert.Severity !== severityFilter) return false; if (searchQuery) { const query = searchQuery.toLowerCase(); - return ( - alert.Event.toLowerCase().includes(query) || - alert.Headline.toLowerCase().includes(query) || - alert.AreaDescription.toLowerCase().includes(query) - ); + return alert.Event.toLowerCase().includes(query) || alert.Headline.toLowerCase().includes(query) || alert.AreaDescription.toLowerCase().includes(query); } return true; }); @@ -67,7 +66,7 @@ export default function WeatherAlerts() { return ; } - if (isLoading) { + if (isLoading && alerts.length === 0) { return ; } @@ -83,10 +82,8 @@ export default function WeatherAlerts() { data={filteredAlerts} renderItem={renderItem} keyExtractor={keyExtractor} - refreshControl={} - ListEmptyComponent={ - - } + refreshControl={} + ListEmptyComponent={} contentContainerStyle={{ paddingBottom: 20 }} removeClippedSubviews /> diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 471c524..761e0af 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -321,25 +321,21 @@ export default function CallDetail() { {t('call_detail.type')} {call.Type} - - {t('call_detail.address')} - {call.Address} - - {destinationLabel ? ( - - {t('call_detail.destination')} - {destinationLabel} - {call.DestinationTypeName || call.DestinationAddress ? ( - - {[call.DestinationTypeName, call.DestinationAddress].filter(Boolean).join(' - ')} - - ) : null} - - ) : null} - - {t('call_detail.note')} - - + + {t('call_detail.address')} + {call.Address} + + {destinationLabel ? ( + + {t('call_detail.destination')} + {destinationLabel} + {call.DestinationTypeName || call.DestinationAddress ? {[call.DestinationTypeName, call.DestinationAddress].filter(Boolean).join(' - ')} : null} + + ) : null} + + {t('call_detail.note')} + + diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index fa83c4b..8a79ef5 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -129,11 +129,11 @@ export default function EditCall() { coordinates: '', what3words: '', plusCode: '', - latitude: undefined, - longitude: undefined, - destinationPoiId: '', - priority: '', - type: '', + latitude: undefined, + longitude: undefined, + destinationPoiId: '', + priority: '', + type: '', contactName: '', contactInfo: '', dispatchSelection: { diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index bfca869..1a26241 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -153,11 +153,11 @@ export default function NewCall() { coordinates: '', what3words: '', plusCode: '', - latitude: undefined, - longitude: undefined, - destinationPoiId: '', - priority: '', - type: '', + latitude: undefined, + longitude: undefined, + destinationPoiId: '', + priority: '', + type: '', contactName: '', contactInfo: '', dispatchSelection: { @@ -827,31 +827,31 @@ export default function NewCall() { /> - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( + {/* Map Preview */} + + {selectedLocation ? ( + + ) : ( - )} - - - ( - onChange(poiId != null ? poiId.toString() : '')} - /> - )} - /> - + + )} + + + ( + onChange(poiId != null ? poiId.toString() : '')} + /> + )} + /> + diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 5d46855..ae4dbc2 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -7,7 +7,7 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-na import { FocusAwareStatusBar, SafeAreaView, View } from '@/components/ui'; import { Button, ButtonText } from '@/components/ui/button'; -import { FlatList } from '@/components/ui/flat-list'; +import { type FlashListRef, FlatList } from '@/components/ui/flat-list'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { useAuthStore } from '@/lib/auth'; @@ -66,7 +66,7 @@ export default function Onboarding() { const setIsOnboarding = useAuthStore((state) => state.setIsOnboarding); const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(0); - const flatListRef = useRef>(null); + const flatListRef = useRef>(null); const buttonOpacity = useSharedValue(0); const { colorScheme } = useColorScheme(); diff --git a/src/app/routes/poi/[id].tsx b/src/app/routes/poi/[id].tsx index 8435e8a..25ee61f 100644 --- a/src/app/routes/poi/[id].tsx +++ b/src/app/routes/poi/[id].tsx @@ -1,8 +1,8 @@ +import { useLocalSearchParams } from 'expo-router'; import { MapPin, Navigation } from 'lucide-react-native'; import React, { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, View } from 'react-native'; -import { useLocalSearchParams } from 'expo-router'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; @@ -15,8 +15,8 @@ import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils'; import { openMapsWithDirections } from '@/lib/navigation'; +import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils'; import { useLocationStore } from '@/stores/app/location-store'; import { usePoisStore } from '@/stores/pois/store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; diff --git a/src/app/weather-alert/[id].tsx b/src/app/weather-alert/[id].tsx index d93eecc..f4d5db4 100644 --- a/src/app/weather-alert/[id].tsx +++ b/src/app/weather-alert/[id].tsx @@ -7,13 +7,13 @@ import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native' import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; -import { WeatherAlertDetailMap } from '@/components/weather-alerts/weather-alert-detail-map'; -import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { Box } from '@/components/ui/box'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { WeatherAlertDetailMap } from '@/components/weather-alerts/weather-alert-detail-map'; import { getCategoryIcon, getSeverityColor, getSeverityTranslationKey } from '@/lib/weather-alert-utils'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx index 56dba83..9c6463e 100644 --- a/src/components/calls/__tests__/call-files-modal.test.tsx +++ b/src/components/calls/__tests__/call-files-modal.test.tsx @@ -78,7 +78,7 @@ jest.mock('@/hooks/use-analytics', () => ({ })); // Mock expo modules -jest.mock('expo-file-system', () => ({ +jest.mock('expo-file-system/legacy', () => ({ documentDirectory: '/mock/documents/', writeAsStringAsync: jest.fn(), EncodingType: { @@ -506,7 +506,7 @@ describe('CallFilesModal', () => { describe('File Download', () => { const mockGetCallAttachmentFile = require('@/api/calls/callFiles').getCallAttachmentFile; - const mockWriteAsStringAsync = require('expo-file-system').writeAsStringAsync; + const mockWriteAsStringAsync = require('expo-file-system/legacy').writeAsStringAsync; const mockShareAsync = require('expo-sharing').shareAsync; beforeEach(() => { diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx index 3a6a214..d1cc8ed 100644 --- a/src/components/calls/__tests__/call-images-modal.test.tsx +++ b/src/components/calls/__tests__/call-images-modal.test.tsx @@ -33,7 +33,7 @@ jest.mock('expo-image-picker', () => ({ }, })); -jest.mock('expo-file-system', () => ({ +jest.mock('expo-file-system/legacy', () => ({ readAsStringAsync: jest.fn(), EncodingType: { Base64: 'base64', @@ -269,7 +269,7 @@ const mockTrackEvent = jest.fn(); const mockReadAsStringAsync = jest.fn(); const mockManipulateAsync = jest.fn(); -jest.mock('expo-file-system', () => ({ +jest.mock('expo-file-system/legacy', () => ({ readAsStringAsync: mockReadAsStringAsync, EncodingType: { Base64: 'base64', @@ -1211,4 +1211,4 @@ describe('CallImagesModal', () => { expect(contentContainerStyle.flexGrow).toBe(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx index 7dbf740..4d91d85 100644 --- a/src/components/calls/call-files-modal.tsx +++ b/src/components/calls/call-files-modal.tsx @@ -1,6 +1,6 @@ import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetBackdrop, BottomSheetView } from '@gorhom/bottom-sheet'; -import * as FileSystem from 'expo-file-system'; +import { documentDirectory, EncodingType, writeAsStringAsync } from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing'; import { Download, File, X } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -118,7 +118,10 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, // Create a temporary file const fileName = file.FileName || file.Name || `file_${file.Id}`; - const fileUri = `${FileSystem.documentDirectory}${fileName}`; + if (!documentDirectory) { + throw new Error('Document directory is unavailable'); + } + const fileUri = `${documentDirectory}${fileName}`; // Convert blob to base64 const base64Data = await new Promise((resolve, reject) => { @@ -134,8 +137,8 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, }); // Write file to device - await FileSystem.writeAsStringAsync(fileUri, base64Data, { - encoding: FileSystem.EncodingType.Base64, + await writeAsStringAsync(fileUri, base64Data, { + encoding: EncodingType.Base64, }); // Share/open the file diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index 07acf0c..68e1f45 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -1,4 +1,4 @@ -import * as FileSystem from 'expo-file-system'; +import { EncodingType, readAsStringAsync } from 'expo-file-system/legacy'; import { Image } from 'expo-image'; import * as ImageManipulator from 'expo-image-manipulator'; import * as ImagePicker from 'expo-image-picker'; @@ -11,7 +11,7 @@ import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; -import { FlatList } from '@/components/ui/flat-list'; +import { type FlashListRef, FlatList } from '@/components/ui/flat-list'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib'; import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; @@ -51,7 +51,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call const [isAddingImage, setIsAddingImage] = useState(false); const [imageErrors, setImageErrors] = useState>(new Set()); const [fullScreenImage, setFullScreenImage] = useState<{ source: any; name?: string } | null>(null); - const flatListRef = useRef>(null); + const flatListRef = useRef>(null); const callImages = useCallDetailStore((state) => state.callImages); const isLoadingImages = useCallDetailStore((state) => state.isLoadingImages); @@ -176,8 +176,8 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call ); // Read the manipulated image as base64 - const base64Image = await FileSystem.readAsStringAsync(manipulatedImage.uri, { - encoding: FileSystem.EncodingType.Base64, + const base64Image = await readAsStringAsync(manipulatedImage.uri, { + encoding: EncodingType.Base64, }); // Get current location if available @@ -432,7 +432,6 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call itemVisiblePercentThreshold: 50, minimumViewTime: 100, }} - estimatedItemSize={width} className="w-full" contentContainerStyle={{ paddingHorizontal: 0 }} initialScrollIndex={0} diff --git a/src/components/calls/destination-poi-selector.tsx b/src/components/calls/destination-poi-selector.tsx index 6ee9f0e..87dc8f6 100644 --- a/src/components/calls/destination-poi-selector.tsx +++ b/src/components/calls/destination-poi-selector.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; -import { groupPoisByType, getPoiSelectionLabel, createPoiTypeMap } from '@/lib/poi-utils'; +import { createPoiTypeMap, getPoiSelectionLabel, groupPoisByType } from '@/lib/poi-utils'; import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; import { CustomBottomSheet } from '../ui/bottom-sheet'; diff --git a/src/components/check-in-timers/check-in-bottom-sheet.tsx b/src/components/check-in-timers/check-in-bottom-sheet.tsx index 510e5f4..fdb5512 100644 --- a/src/components/check-in-timers/check-in-bottom-sheet.tsx +++ b/src/components/check-in-timers/check-in-bottom-sheet.tsx @@ -12,6 +12,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; +import type { CheckInResult } from '@/stores/check-in-timers/store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { useToastStore } from '@/stores/toast/store'; @@ -54,12 +55,16 @@ export const CheckInBottomSheet: React.FC = ({ isOpen, Note: note || undefined, }; - const success = await performCheckInAction(input); + const result: CheckInResult = await performCheckInAction(input); - if (success) { + if (result === 'success') { showToast('success', t('check_in.check_in_success')); setNote(''); onClose(); + } else if (result === 'queued') { + showToast('info', t('check_in.queued_offline')); + setNote(''); + onClose(); } else { showToast('error', t('check_in.check_in_error')); } @@ -91,7 +96,7 @@ export const CheckInBottomSheet: React.FC = ({ isOpen, {/* Confirm */} - diff --git a/src/components/check-in-timers/check-in-timer-card.tsx b/src/components/check-in-timers/check-in-timer-card.tsx index 9fa2b54..6877019 100644 --- a/src/components/check-in-timers/check-in-timer-card.tsx +++ b/src/components/check-in-timers/check-in-timer-card.tsx @@ -53,7 +53,9 @@ export const CheckInTimerCard: React.FC = ({ timer, onChe }, [timer.Status, pulseAnim]); const statusColor = STATUS_COLORS[timer.Status] ?? '#808080'; - const progress = Math.min(localElapsed / timer.DurationMinutes, 1); + const duration = timer.DurationMinutes ? Number(timer.DurationMinutes) : 0; + const progress = duration > 0 ? Math.min(localElapsed / duration, 1) : 0; + const safeStatusLower = typeof timer.Status === 'string' ? timer.Status.toLowerCase() : ''; const minutesAgo = Math.floor(localElapsed); return ( @@ -70,7 +72,7 @@ export const CheckInTimerCard: React.FC = ({ timer, onChe - {t(`check_in.status_${timer.Status.toLowerCase()}`)} + {t(`check_in.status_${safeStatusLower}`)} diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index 4c86da0..53f55a1 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -307,7 +307,6 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ListFooterComponent={renderFooter} ListEmptyComponent={renderEmpty} refreshControl={} - estimatedItemSize={80} /> )} diff --git a/src/components/routes/active-routes-list.tsx b/src/components/routes/active-routes-list.tsx index 69bb80d..9dbdef9 100644 --- a/src/components/routes/active-routes-list.tsx +++ b/src/components/routes/active-routes-list.tsx @@ -55,8 +55,7 @@ export const ActiveRoutesList: React.FC = () => { const handleRoutePress = (route: RoutePlanResultData) => { if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { const routeInstanceId = activeInstance.RouteInstanceId; - const activeRouteUrl = - routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`; + const activeRouteUrl = routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`; router.push(activeRouteUrl as any); return; } @@ -110,9 +109,7 @@ export const ActiveRoutesList: React.FC = () => { onPress={() => { const routeInstanceId = activeInstance.RouteInstanceId; const activeRouteUrl = - routeInstanceId && routeInstanceId !== 'undefined' - ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${routeInstanceId}` - : `/routes/active?planId=${activeInstance.RoutePlanId}`; + routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${activeInstance.RoutePlanId}`; router.push(activeRouteUrl as any); }} > diff --git a/src/components/routes/poi-list-content.tsx b/src/components/routes/poi-list-content.tsx index 2286e67..1c36812 100644 --- a/src/components/routes/poi-list-content.tsx +++ b/src/components/routes/poi-list-content.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshControl } from 'react-native'; -import { filterPois, getPoiDisplayName, getPoiTypeName, isPoiDestinationEnabled, sortPois, type PoiSortOption } from '@/lib/poi-utils'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { PoiCard } from '@/components/routes/poi-card'; @@ -13,6 +12,7 @@ import { FlatList } from '@/components/ui/flat-list'; import { HStack } from '@/components/ui/hstack'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { filterPois, getPoiDisplayName, getPoiTypeName, isPoiDestinationEnabled, type PoiSortOption, sortPois } from '@/lib/poi-utils'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { usePoisStore } from '@/stores/pois/store'; @@ -116,15 +116,11 @@ export const PoiListContent: React.FC = () => { poiTypeLabel={getPoiTypeName(item, poiTypesById) || t('routes.poi_type_unknown')} displayName={getPoiDisplayName(item, poiTypesById)} isDestinationEnabled={isPoiDestinationEnabled(item, poiTypesById)} - onPress={() => router.push(`/routes/poi/${item.PoiId}` as any)} + onPress={() => router.push({ pathname: '/routes/poi/[id]', params: { id: item.PoiId } })} /> )} ListEmptyComponent={ - + } contentContainerStyle={{ paddingBottom: 20 }} /> diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx index 0c25fa6..6cb7da7 100644 --- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx +++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx @@ -300,15 +300,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo {/* Device List */} - item.id} - ListEmptyComponent={renderEmptyState} - showsVerticalScrollIndicator={false} - estimatedItemSize={60} - extraData={connectingDeviceId} - /> + item.id} ListEmptyComponent={renderEmptyState} showsVerticalScrollIndicator={false} extraData={connectingDeviceId} /> {/* Bluetooth State Info */} diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index f6994af..643d836 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -202,26 +202,12 @@ export const StatusBottomSheet = () => { } const selectedTypeAllowed = - selectedDestinationType === 'none' || - (selectedDestinationType === 'call' && allowsCalls) || - (selectedDestinationType === 'station' && allowsStations) || - (selectedDestinationType === 'poi' && allowsPois); + selectedDestinationType === 'none' || (selectedDestinationType === 'call' && allowsCalls) || (selectedDestinationType === 'station' && allowsStations) || (selectedDestinationType === 'poi' && allowsPois); if (!selectedTypeAllowed) { setSelectedDestinationType('none'); } - }, [ - detailLevel, - selectedCall, - selectedDestinationType, - selectedPoi, - selectedStation, - selectedStatus, - setSelectedCall, - setSelectedDestinationType, - setSelectedPoi, - setSelectedStation, - ]); + }, [detailLevel, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType, setSelectedPoi, setSelectedStation]); React.useEffect(() => { if (!selectedStatus || selectedDestinationType !== 'none') { @@ -491,12 +477,7 @@ export const StatusBottomSheet = () => { return false; } - const shouldPreSelectActiveCall = - isOpen && - !!selectedStatus && - statusDetailAllowsCalls(detailLevel) && - !!activeCallId && - (isLoading || !!activeCallCandidate); + const shouldPreSelectActiveCall = isOpen && !!selectedStatus && statusDetailAllowsCalls(detailLevel) && !!activeCallId && (isLoading || !!activeCallCandidate); if (shouldPreSelectActiveCall) { return false; @@ -675,14 +656,8 @@ export const StatusBottomSheet = () => { {status.Text} - {Number(status.Detail) > 0 ? ( - {statusDetailDescription} - ) : null} - {Number(status.Note) > 0 ? ( - - {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} - - ) : null} + {Number(status.Detail) > 0 ? {statusDetailDescription} : null} + {Number(status.Note) > 0 ? {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} : null} @@ -726,11 +701,7 @@ export const StatusBottomSheet = () => { {shouldShowDestinationTabs ? ( {destinationTabs.map((tab) => ( - setSelectedTab(tab)} - className={`flex-1 rounded-lg py-3 ${selectedTab === tab ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`} - > + setSelectedTab(tab)} className={`flex-1 rounded-lg py-3 ${selectedTab === tab ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}> {t(getDestinationTabTranslationKey(tab))} ))} diff --git a/src/components/ui/flat-list/index.tsx b/src/components/ui/flat-list/index.tsx index d3e006f..7584ecc 100644 --- a/src/components/ui/flat-list/index.tsx +++ b/src/components/ui/flat-list/index.tsx @@ -1,2 +1,2 @@ 'use client'; -export { FlashList as FlatList } from '@shopify/flash-list'; +export { type FlashListRef, FlashList as FlatList } from '@shopify/flash-list'; diff --git a/src/components/weather-alerts/severity-filter-tabs.tsx b/src/components/weather-alerts/severity-filter-tabs.tsx index 6148e78..c3d79dc 100644 --- a/src/components/weather-alerts/severity-filter-tabs.tsx +++ b/src/components/weather-alerts/severity-filter-tabs.tsx @@ -5,8 +5,8 @@ import { Pressable, ScrollView } from 'react-native'; import { Box } from '@/components/ui/box'; import { Text } from '@/components/ui/text'; import { getSeverityColor } from '@/lib/weather-alert-utils'; -import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; import { WeatherAlertSeverity } from '@/models/v4/weatherAlerts/weatherAlertEnums'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; interface SeverityFilterTabsProps { selectedFilter: number | null; @@ -14,7 +14,7 @@ interface SeverityFilterTabsProps { alerts: WeatherAlertResultData[]; } -const FILTERS: Array<{ severity: number | null; labelKey: string }> = [ +const FILTERS: { severity: number | null; labelKey: string }[] = [ { severity: null, labelKey: 'weather_alerts.filter.all' }, { severity: WeatherAlertSeverity.Extreme, labelKey: 'weather_alerts.severity.extreme' }, { severity: WeatherAlertSeverity.Severe, labelKey: 'weather_alerts.severity.severe' }, @@ -39,14 +39,8 @@ export const SeverityFilterTabs: React.FC = ({ selected return ( onFilterChange(filter.severity)}> - - + + {t(filter.labelKey)} ({count}) diff --git a/src/components/weather-alerts/weather-alert-banner.tsx b/src/components/weather-alerts/weather-alert-banner.tsx index 409fd5d..38f0584 100644 --- a/src/components/weather-alerts/weather-alert-banner.tsx +++ b/src/components/weather-alerts/weather-alert-banner.tsx @@ -17,11 +17,12 @@ interface WeatherAlertBannerProps { } export const WeatherAlertBanner: React.FC = ({ alerts, onPress, onDismiss }) => { + const { t } = useTranslation(); + if (alerts.length === 0) { return null; } - const { t } = useTranslation(); const topAlert = alerts[0]; const bgColor = getSeverityColor(topAlert.Severity); @@ -38,9 +39,7 @@ export const WeatherAlertBanner: React.FC = ({ alerts, {alerts.length > 1 ? ( - - {t('weather_alerts.banner.more_alerts', { count: alerts.length - 1 })} - + {t('weather_alerts.banner.more_alerts', { count: alerts.length - 1 })} ) : null} diff --git a/src/components/weather-alerts/weather-alert-card.tsx b/src/components/weather-alerts/weather-alert-card.tsx index 79c12b7..a85afa7 100644 --- a/src/components/weather-alerts/weather-alert-card.tsx +++ b/src/components/weather-alerts/weather-alert-card.tsx @@ -6,8 +6,8 @@ import { Box } from '@/components/ui/box'; import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { getCategoryIcon, getSeverityColor, getSeverityTranslationKey } from '@/lib/weather-alert-utils'; import { getTimeAgoUtc } from '@/lib/utils'; +import { getCategoryIcon, getSeverityColor, getSeverityTranslationKey } from '@/lib/weather-alert-utils'; import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; interface WeatherAlertCardProps { @@ -20,10 +20,7 @@ const WeatherAlertCardComponent: React.FC = ({ alert }) = const CategoryIcon = getCategoryIcon(alert.Category); return ( - + {/* Header */} diff --git a/src/components/weather-alerts/weather-alert-detail-map.tsx b/src/components/weather-alerts/weather-alert-detail-map.tsx index 5965218..23f1144 100644 --- a/src/components/weather-alerts/weather-alert-detail-map.tsx +++ b/src/components/weather-alerts/weather-alert-detail-map.tsx @@ -84,10 +84,7 @@ export const WeatherAlertDetailMap: React.FC = ({ al ) : null} {!polygonGeoJSON && centerLocation ? ( - + ) : null} diff --git a/src/hooks/__tests__/use-quick-check-in.test.ts b/src/hooks/__tests__/use-quick-check-in.test.ts index 6e424ea..c616a92 100644 --- a/src/hooks/__tests__/use-quick-check-in.test.ts +++ b/src/hooks/__tests__/use-quick-check-in.test.ts @@ -68,7 +68,7 @@ describe('useQuickCheckIn', () => { }); it('should auto-detect Unit type when active unit exists', async () => { - mockPerformCheckIn.mockResolvedValue(true); + mockPerformCheckIn.mockResolvedValue('success'); const { result } = renderHook(() => useQuickCheckIn(123)); @@ -88,7 +88,7 @@ describe('useQuickCheckIn', () => { }); it('should show success toast on successful check-in', async () => { - mockPerformCheckIn.mockResolvedValue(true); + mockPerformCheckIn.mockResolvedValue('success'); const { result } = renderHook(() => useQuickCheckIn(123)); @@ -100,7 +100,7 @@ describe('useQuickCheckIn', () => { }); it('should show error toast on failed check-in', async () => { - mockPerformCheckIn.mockResolvedValue(false); + mockPerformCheckIn.mockResolvedValue('failed'); const { result } = renderHook(() => useQuickCheckIn(123)); diff --git a/src/hooks/use-check-in-timer-polling.ts b/src/hooks/use-check-in-timer-polling.ts index 0912dd7..a6a079c 100644 --- a/src/hooks/use-check-in-timer-polling.ts +++ b/src/hooks/use-check-in-timer-polling.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { Platform } from 'react-native'; import { checkInLiveActivity } from '@/lib/native-modules/check-in-live-activity'; @@ -11,7 +12,9 @@ export function useCheckInTimerPolling() { const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); const startPolling = useCheckInTimerStore((state) => state.startPolling); const stopPolling = useCheckInTimerStore((state) => state.stopPolling); + const { t } = useTranslation(); const liveActivityStarted = useRef(false); + const prevCallId = useRef(undefined); // Start/stop polling based on active call useEffect(() => { @@ -34,12 +37,21 @@ export function useCheckInTimerPolling() { checkInNotificationService.stopNotification(); liveActivityStarted.current = false; } + prevCallId.current = undefined; return; } const urgentTimer = timerStatuses[0]; const secondsRemaining = Math.max(0, (urgentTimer.DurationMinutes - urgentTimer.ElapsedMinutes) * 60); + // When the active call changes, tear down the previous activity/notification + // before starting a fresh one for the new call. + if (activeCall.CallId !== prevCallId.current && liveActivityStarted.current) { + checkInLiveActivity.end(); + checkInNotificationService.stopNotification(); + liveActivityStarted.current = false; + } + if (Platform.OS === 'ios') { if (!liveActivityStarted.current) { checkInLiveActivity.start({ @@ -49,6 +61,7 @@ export function useCheckInTimerPolling() { durationMinutes: urgentTimer.DurationMinutes, }); liveActivityStarted.current = true; + prevCallId.current = activeCall.CallId; } else { checkInLiveActivity.update(Math.floor(urgentTimer.ElapsedMinutes), urgentTimer.Status); } @@ -56,13 +69,31 @@ export function useCheckInTimerPolling() { if (Platform.OS === 'android') { if (!liveActivityStarted.current) { - checkInNotificationService.startNotification(activeCall.Name, activeCall.Number, urgentTimer.TargetName, secondsRemaining, urgentTimer.Status); + checkInNotificationService.startNotification(activeCall.Name, activeCall.Number, urgentTimer.TargetName, secondsRemaining, urgentTimer.Status, { + statusLabels: { + Ok: t('check_in.status_ok'), + Warning: t('check_in.status_warning'), + Overdue: t('check_in.status_overdue'), + }, + channelName: t('check_in.notification_channel_name'), + channelDescription: t('check_in.notification_channel_description'), + actionText: t('check_in.perform_check_in'), + }); liveActivityStarted.current = true; + prevCallId.current = activeCall.CallId; } else { - checkInNotificationService.updateNotification(secondsRemaining, urgentTimer.Status); + checkInNotificationService.updateNotification(secondsRemaining, urgentTimer.Status, { + Ok: t('check_in.status_ok'), + Warning: t('check_in.status_warning'), + Overdue: t('check_in.status_overdue'), + }); } } - }, [activeCall, timerStatuses]); + // Deps are intentionally narrowed to the specific activeCall fields consumed + // by this effect; listing the full `activeCall` object would cause spurious + // reruns whenever any unrelated call property changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeCall?.CallId, activeCall?.Name, activeCall?.Number, timerStatuses, t]); // Cleanup on unmount useEffect(() => { diff --git a/src/hooks/use-quick-check-in.ts b/src/hooks/use-quick-check-in.ts index f4a85ea..b8df7c4 100644 --- a/src/hooks/use-quick-check-in.ts +++ b/src/hooks/use-quick-check-in.ts @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import type { PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; +import type { CheckInResult } from '@/stores/check-in-timers/store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { useToastStore } from '@/stores/toast/store'; @@ -29,15 +30,17 @@ export function useQuickCheckIn(callId: number) { Longitude: longitude?.toString(), }; - const success = await performCheckInAction(input); + const result: CheckInResult = await performCheckInAction(input); - if (success) { + if (result === 'success') { showToast('success', t('check_in.check_in_success')); + } else if (result === 'queued') { + showToast('info', t('check_in.queued_offline')); } else { showToast('error', t('check_in.check_in_error')); } - return success; + return result; }, [callId, activeUnit, latitude, longitude, performCheckInAction, showToast, t]); return { quickCheckIn, isCheckingIn }; diff --git a/src/lib/poi-utils.ts b/src/lib/poi-utils.ts index 20fd46c..a40a84c 100644 --- a/src/lib/poi-utils.ts +++ b/src/lib/poi-utils.ts @@ -54,13 +54,7 @@ export const filterPois = (pois: PoiResultData[], options: { poiTypesById?: Reco return true; } - const searchableValues = [ - getPoiDisplayName(poi, options.poiTypesById), - getPoiSelectionLabel(poi, options.poiTypesById), - normalizeText(poi.Address), - normalizeText(poi.Note), - getPoiTypeName(poi, options.poiTypesById), - ] + const searchableValues = [getPoiDisplayName(poi, options.poiTypesById), getPoiSelectionLabel(poi, options.poiTypesById), normalizeText(poi.Address), normalizeText(poi.Note), getPoiTypeName(poi, options.poiTypesById)] .join(' ') .toLowerCase(); diff --git a/src/lib/weather-alert-utils.ts b/src/lib/weather-alert-utils.ts index 8bebffb..804a415 100644 --- a/src/lib/weather-alert-utils.ts +++ b/src/lib/weather-alert-utils.ts @@ -1,7 +1,7 @@ import { AlertTriangle, CloudLightning, Flame, Heart, Leaf, type LucideIcon } from 'lucide-react-native'; -import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; import { WeatherAlertCategory, WeatherAlertSeverity, WeatherAlertStatus } from '@/models/v4/weatherAlerts/weatherAlertEnums'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; export const SEVERITY_COLORS: Record = { [WeatherAlertSeverity.Extreme]: '#7B1FA2', diff --git a/src/models/v4/dispatch/newCallFormResultData.ts b/src/models/v4/dispatch/newCallFormResultData.ts index 3dd1b00..40f9894 100644 --- a/src/models/v4/dispatch/newCallFormResultData.ts +++ b/src/models/v4/dispatch/newCallFormResultData.ts @@ -2,8 +2,8 @@ import { type CallPriorityResultData } from '../callPriorities/callPriorityResul import { type CallTypeResultData } from '../callTypes/callTypeResultData'; import { type CustomStatusResultData } from '../customStatuses/customStatusResultData'; import { type GroupResultData } from '../groups/groupsResultData'; -import { type PersonnelInfoResultData } from '../personnel/personnelInfoResultData'; import { type PoiResultData, type PoiTypeResultData } from '../mapping/poiResultData'; +import { type PersonnelInfoResultData } from '../personnel/personnelInfoResultData'; import { type RoleResultData } from '../roles/roleResultData'; import { type UnitRoleResultData } from '../unitRoles/unitRoleResultData'; import { type UnitResultData } from '../units/unitResultData'; diff --git a/src/models/v4/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts index 4f69720..e305ee7 100644 --- a/src/models/v4/mapping/getMapDataAndMarkersData.ts +++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts @@ -1,4 +1,4 @@ -import { PoiLayerData } from './poiResultData'; +import { type PoiLayerData } from './poiResultData'; export class MapDataAndMarkersData { public CenterLat: number | string = 0; diff --git a/src/models/v4/mapping/poiResults.ts b/src/models/v4/mapping/poiResults.ts index 105f047..6716df4 100644 --- a/src/models/v4/mapping/poiResults.ts +++ b/src/models/v4/mapping/poiResults.ts @@ -1,6 +1,5 @@ import { BaseV4Request } from '../baseV4Request'; - -import { PoiResultData, PoiTypeResultData } from './poiResultData'; +import { PoiResultData, type PoiTypeResultData } from './poiResultData'; export class PoiResult extends BaseV4Request { public Data: PoiResultData = new PoiResultData(); diff --git a/src/services/check-in-notification.service.ts b/src/services/check-in-notification.service.ts index 3408912..36b10e0 100644 --- a/src/services/check-in-notification.service.ts +++ b/src/services/check-in-notification.service.ts @@ -6,17 +6,19 @@ import { logger } from '@/lib/logging'; const CHANNEL_ID = 'check-in-timers'; const NOTIFICATION_ID = 'check-in-timer-notification'; -const STATUS_LABELS: Record = { - Ok: 'OK', - Warning: 'WARNING', - Overdue: 'OVERDUE', -}; +export interface NotificationLabels { + statusLabels: Record; + channelName: string; + channelDescription: string; + actionText: string; +} class CheckInNotificationService { private static instance: CheckInNotificationService; private countdownInterval: ReturnType | null = null; private currentSeconds: number = 0; private currentStatus: string = 'Ok'; + private currentLabels: NotificationLabels | null = null; private channelCreated: boolean = false; static getInstance(): CheckInNotificationService { @@ -26,22 +28,23 @@ class CheckInNotificationService { return CheckInNotificationService.instance; } - private async ensureChannel(): Promise { + private async ensureChannel(channelName: string, channelDescription: string): Promise { if (this.channelCreated || Platform.OS !== 'android') return; await notifee.createChannel({ id: CHANNEL_ID, - name: 'Check-In Timers', - description: 'Timer notifications for call check-ins', + name: channelName, + description: channelDescription, importance: AndroidImportance.LOW, }); this.channelCreated = true; } - async startNotification(callName: string, callNumber: string, timerName: string, secondsRemaining: number, status: string): Promise { + async startNotification(callName: string, callNumber: string, timerName: string, secondsRemaining: number, status: string, labels: NotificationLabels): Promise { if (Platform.OS !== 'android') return; - await this.ensureChannel(); + this.currentLabels = labels; + await this.ensureChannel(labels.channelName, labels.channelDescription); this.currentSeconds = secondsRemaining; this.currentStatus = status; @@ -55,9 +58,12 @@ class CheckInNotificationService { }, 1000); } - async updateNotification(secondsRemaining: number, status: string): Promise { + async updateNotification(secondsRemaining: number, status: string, statusLabels: Record): Promise { this.currentSeconds = secondsRemaining; this.currentStatus = status; + if (this.currentLabels) { + this.currentLabels = { ...this.currentLabels, statusLabels }; + } } async stopNotification(): Promise { @@ -75,7 +81,8 @@ class CheckInNotificationService { const minutes = Math.floor(this.currentSeconds / 60); const seconds = this.currentSeconds % 60; const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; - const statusLabel = STATUS_LABELS[this.currentStatus] ?? this.currentStatus; + const statusLabel = this.currentLabels?.statusLabels[this.currentStatus] ?? this.currentStatus; + const actionText = this.currentLabels?.actionText ?? 'Check In'; try { await notifee.displayNotification({ @@ -89,7 +96,7 @@ class CheckInNotificationService { pressAction: { id: 'default' }, actions: [ { - title: 'Check In', + title: actionText, pressAction: { id: 'check-in' }, }, ], diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 4695c5f..0f21008 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -8,9 +8,16 @@ import { registerUnitDevice } from '@/api/devices/push'; import { logger } from '@/lib/logging'; import { getDeviceUuid } from '@/lib/storage/app'; import { useCoreStore } from '@/stores/app/core-store'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { usePushNotificationModalStore } from '@/stores/push-notification/store'; import { securityStore } from '@/stores/security/store'; +// Numeric values for the CheckInType field expected by the API. +// 0 = Personnel check-in (no unit), 1 = Unit check-in. +const CHECK_IN_TYPE_PERSONNEL = 0; +const CHECK_IN_TYPE_UNIT = 1; + // Define notification response types export interface PushNotificationData { title?: string; @@ -200,20 +207,26 @@ class PushNotificationService { // Handle check-in action press if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') { logger.info({ message: 'Check-in action pressed from notification' }); - // Trigger quick check-in — store will handle it - const { useCheckInTimerStore } = require('@/stores/check-in-timers/store'); - const { useCoreStore } = require('@/stores/app/core-store'); - const { useLocationStore } = require('@/stores/app/location-store'); const activeCall = useCoreStore.getState().activeCall; const activeUnit = useCoreStore.getState().activeUnit; if (activeCall) { - await useCheckInTimerStore.getState().performCheckIn({ - CallId: parseInt(activeCall.CallId, 10), - CheckInType: activeUnit ? 1 : 0, - UnitId: activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined, - Latitude: useLocationStore.getState().latitude?.toString(), - Longitude: useLocationStore.getState().longitude?.toString(), - }); + const callId = parseInt(activeCall.CallId, 10); + if (Number.isNaN(callId)) { + logger.error({ message: 'Check-in action aborted: invalid CallId', context: { CallId: activeCall.CallId } }); + } else { + const unitId = activeUnit ? parseInt(activeUnit.UnitId, 10) : undefined; + if (activeUnit && Number.isNaN(unitId)) { + logger.error({ message: 'Check-in action aborted: invalid UnitId', context: { UnitId: activeUnit.UnitId } }); + } else { + await useCheckInTimerStore.getState().performCheckIn({ + CallId: callId, + CheckInType: activeUnit ? CHECK_IN_TYPE_UNIT : CHECK_IN_TYPE_PERSONNEL, + UnitId: unitId, + Latitude: useLocationStore.getState().latitude?.toString(), + Longitude: useLocationStore.getState().longitude?.toString(), + }); + } + } } } diff --git a/src/services/push-notification.web.ts b/src/services/push-notification.web.ts index caab70c..7149cf2 100644 --- a/src/services/push-notification.web.ts +++ b/src/services/push-notification.web.ts @@ -177,7 +177,7 @@ class WebPushNotificationService { // Subscribe to push manager this.pushSubscription = await this.registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: this.urlBase64ToUint8Array(vapidPublicKey), + applicationServerKey: this.urlBase64ToArrayBuffer(vapidPublicKey), }); logger.info({ @@ -298,15 +298,16 @@ class WebPushNotificationService { /** * Convert VAPID key from base64 to Uint8Array */ - private urlBase64ToUint8Array(base64String: string): Uint8Array { + private urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); + const buffer = new ArrayBuffer(rawData.length); + const outputArray = new Uint8Array(buffer); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } - return outputArray; + return buffer; } /** diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index d6a7abc..bda2769 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -17,6 +17,7 @@ interface CallsState { poiTypes: PoiTypeResultData[]; isLoading: boolean; isInitialized: boolean; + isCallFormDataLoaded: boolean; error: string | null; lastFetchedAt: number; fetchCalls: () => Promise; @@ -34,6 +35,7 @@ export const useCallsStore = create((set, get) => ({ poiTypes: [], isLoading: false, isInitialized: false, + isCallFormDataLoaded: false, error: null, lastFetchedAt: 0, init: async () => { @@ -88,8 +90,7 @@ export const useCallsStore = create((set, get) => ({ } }, fetchCallFormData: async () => { - const { callPriorities, callTypes, destinationPois, poiTypes } = get(); - if (callPriorities.length > 0 && callTypes.length > 0 && destinationPois.length > 0 && poiTypes.length > 0) { + if (get().isCallFormDataLoaded) { return; } @@ -102,6 +103,7 @@ export const useCallsStore = create((set, get) => ({ callTypes: Array.isArray(data?.CallTypes) ? data.CallTypes : [], destinationPois: Array.isArray(data?.DestinationPois) ? data.DestinationPois : [], poiTypes: Array.isArray(data?.PoiTypes) ? data.PoiTypes : [], + isCallFormDataLoaded: true, isLoading: false, }); } catch (error) { diff --git a/src/stores/check-in-timers/__tests__/store.test.ts b/src/stores/check-in-timers/__tests__/store.test.ts index 84eb15c..218226a 100644 --- a/src/stores/check-in-timers/__tests__/store.test.ts +++ b/src/stores/check-in-timers/__tests__/store.test.ts @@ -114,12 +114,12 @@ describe('useCheckInTimerStore', () => { const { result } = renderHook(() => useCheckInTimerStore()); - let success: boolean = false; + let checkInResult: string = ''; await act(async () => { - success = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); + checkInResult = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); }); - expect(success).toBe(true); + expect(checkInResult).toBe('success'); expect(mockPerformCheckIn).toHaveBeenCalledWith({ CallId: 1, CheckInType: 0 }); expect(result.current.isCheckingIn).toBe(false); }); @@ -129,12 +129,12 @@ describe('useCheckInTimerStore', () => { const { result } = renderHook(() => useCheckInTimerStore()); - let success: boolean = true; + let checkInResult: string = ''; await act(async () => { - success = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); + checkInResult = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); }); - expect(success).toBe(false); + expect(checkInResult).toBe('failed'); expect(result.current.checkInError).toBe('Server error'); }); }); diff --git a/src/stores/check-in-timers/store.ts b/src/stores/check-in-timers/store.ts index e111106..0a2e91c 100644 --- a/src/stores/check-in-timers/store.ts +++ b/src/stores/check-in-timers/store.ts @@ -1,3 +1,4 @@ +import { isAxiosError } from 'axios'; import { create } from 'zustand'; import { getCheckInHistory, getTimersForCall, getTimerStatuses, performCheckIn, type PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; @@ -7,6 +8,8 @@ import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTi import type { ResolvedCheckInTimerResultData } from '@/models/v4/checkIn/resolvedCheckInTimerResultData'; import { offlineEventManager } from '@/services/offline-event-manager.service'; +export type CheckInResult = 'success' | 'queued' | 'failed'; + const STATUS_SEVERITY: Record = { Overdue: 0, Warning: 1, @@ -27,7 +30,7 @@ interface CheckInTimerState { fetchTimerStatuses: (callId: number) => Promise; fetchResolvedTimers: (callId: number) => Promise; fetchCheckInHistory: (callId: number) => Promise; - performCheckIn: (input: PerformCheckInInput) => Promise; + performCheckIn: (input: PerformCheckInInput) => Promise; startPolling: (callId: number, intervalMs?: number) => void; stopPolling: () => void; reset: () => void; @@ -88,20 +91,19 @@ export const useCheckInTimerStore = create((set, get) => ({ set({ isCheckingIn: false }); // Re-fetch statuses after successful check-in get().fetchTimerStatuses(input.CallId); - return true; + return 'success' as CheckInResult; } catch (error) { - const isNetworkError = error instanceof Error && (error.message.includes('Network') || error.message.includes('timeout')); - if (isNetworkError) { - // Queue offline + const isOffline = isAxiosError(error) && (!error.response || error.code === 'ERR_NETWORK' || error.code === 'ECONNABORTED'); + if (isOffline) { offlineEventManager.queueCheckInEvent(input.CallId, input.CheckInType, input.UnitId, input.Latitude, input.Longitude, input.Note); - logger.info({ message: 'Check-in queued offline', context: { input } }); + logger.info({ message: 'Check-in queued for offline sync', context: { input } }); set({ isCheckingIn: false }); - return true; + return 'queued' as CheckInResult; } const message = error instanceof Error ? error.message : 'Failed to perform check-in'; logger.error({ message: 'Failed to perform check-in', context: { error, input } }); set({ checkInError: message, isCheckingIn: false }); - return false; + return 'failed' as CheckInResult; } }, diff --git a/src/stores/pois/store.ts b/src/stores/pois/store.ts index 1897e0f..75d790e 100644 --- a/src/stores/pois/store.ts +++ b/src/stores/pois/store.ts @@ -6,10 +6,13 @@ import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/ const STORE_TTL_MS = 5 * 60 * 1000; const mergePoiDetails = (existingPois: Record, pois: PoiResultData[]): Record => { - return pois.reduce>((accumulator, poi) => { - accumulator[poi.PoiId] = poi; - return accumulator; - }, { ...existingPois }); + return pois.reduce>( + (accumulator, poi) => { + accumulator[poi.PoiId] = poi; + return accumulator; + }, + { ...existingPois } + ); }; interface PoisState { diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 7f9090e..f82ecc8 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -9,6 +9,22 @@ import { useCoreStore } from '../app/core-store'; import { securityStore, useSecurityStore } from '../security/store'; import { useWeatherAlertsStore } from '../weather-alerts/store'; +/** Minimal shape of the SignalR weather alert payload. The server sends + * WeatherAlertId as the primary identifier, matching WeatherAlertResultData. */ +interface WeatherAlertSignalRMessage { + WeatherAlertId?: string; + /** Fallback for servers that use a lower-camel field name. */ + alertId?: string; +} + +function extractAlertId(message: unknown): string | undefined { + if (message !== null && typeof message === 'object') { + const m = message as WeatherAlertSignalRMessage; + return m.WeatherAlertId ?? m.alertId; + } + return undefined; +} + interface SignalRState { isUpdateHubConnected: boolean; lastUpdateMessage: unknown; @@ -54,7 +70,18 @@ export const useSignalRStore = create((set, get) => ({ // Remove any previously registered handlers to prevent accumulation // across reconnections or repeated connectUpdateHub calls - const updateEvents = ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', 'onConnected']; + const updateEvents = [ + 'personnelStatusUpdated', + 'personnelStaffingUpdated', + 'unitStatusUpdated', + 'callsUpdated', + 'callAdded', + 'callClosed', + 'weatherAlertReceived', + 'weatherAlertUpdated', + 'weatherAlertExpired', + 'onConnected', + ]; updateEvents.forEach((event) => signalRService.removeAllListeners(event)); // Connect to the eventing hub @@ -62,7 +89,18 @@ export const useSignalRStore = create((set, get) => ({ name: Env.CHANNEL_HUB_NAME, eventingUrl: eventingUrl, hubName: Env.CHANNEL_HUB_NAME, - methods: ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'weatherAlertReceived', 'weatherAlertUpdated', 'weatherAlertExpired', 'onConnected'], + methods: [ + 'personnelStatusUpdated', + 'personnelStaffingUpdated', + 'unitStatusUpdated', + 'callsUpdated', + 'callAdded', + 'callClosed', + 'weatherAlertReceived', + 'weatherAlertUpdated', + 'weatherAlertExpired', + 'onConnected', + ], }); await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', parseInt(securityStore.getState().rights?.DepartmentId ?? '0')); @@ -94,17 +132,32 @@ export const useSignalRStore = create((set, get) => ({ signalRService.on('weatherAlertReceived', (message) => { set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); - useWeatherAlertsStore.getState().handleAlertReceived(message as string); + const alertId = extractAlertId(message); + if (alertId) { + useWeatherAlertsStore.getState().handleAlertReceived(alertId); + } else { + logger.warn({ message: 'weatherAlertReceived: could not extract alertId from message', context: { message } }); + } }); signalRService.on('weatherAlertUpdated', (message) => { set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); - useWeatherAlertsStore.getState().handleAlertUpdated(message as string); + const alertId = extractAlertId(message); + if (alertId) { + useWeatherAlertsStore.getState().handleAlertUpdated(alertId); + } else { + logger.warn({ message: 'weatherAlertUpdated: could not extract alertId from message', context: { message } }); + } }); signalRService.on('weatherAlertExpired', (message) => { set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); - useWeatherAlertsStore.getState().handleAlertExpired(message as string); + const alertId = extractAlertId(message); + if (alertId) { + useWeatherAlertsStore.getState().handleAlertExpired(alertId); + } else { + logger.warn({ message: 'weatherAlertExpired: could not extract alertId from message', context: { message } }); + } }); signalRService.on('onConnected', () => { diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 960b78d..dc31beb 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -267,15 +267,7 @@ export const useStatusesStore = create((set) => ({ } } - const eventId = offlineEventManager.queueUnitStatusEvent( - input.Id, - input.Type, - input.Note || '', - input.RespondingTo || '', - input.RespondingToType, - roles, - gpsData - ); + const eventId = offlineEventManager.queueUnitStatusEvent(input.Id, input.Type, input.Note || '', input.RespondingTo || '', input.RespondingToType, roles, gpsData); logger.info({ message: 'Unit status queued for offline processing', diff --git a/src/stores/weather-alerts/store.ts b/src/stores/weather-alerts/store.ts index bc5e606..3fc089f 100644 --- a/src/stores/weather-alerts/store.ts +++ b/src/stores/weather-alerts/store.ts @@ -1,9 +1,9 @@ import { create } from 'zustand'; import { getActiveAlerts, getWeatherAlert, getWeatherAlertSettings } from '@/api/weather-alerts/weather-alerts'; +import { sortAlertsBySeverity } from '@/lib/weather-alert-utils'; import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; import { type WeatherAlertSettingsData } from '@/models/v4/weatherAlerts/weatherAlertSettingsData'; -import { sortAlertsBySeverity } from '@/lib/weather-alert-utils'; interface WeatherAlertsState { alerts: WeatherAlertResultData[]; @@ -97,9 +97,11 @@ export const useWeatherAlertsStore = create((set, get) => ({ try { const response = await getWeatherAlert(alertId); const newAlert = response.Data; - set((state) => ({ - alerts: sortAlertsBySeverity([newAlert, ...state.alerts]), - })); + set((state) => { + const exists = state.alerts.some((a) => a.WeatherAlertId === newAlert.WeatherAlertId); + const updated = exists ? state.alerts.map((a) => (a.WeatherAlertId === newAlert.WeatherAlertId ? newAlert : a)) : [newAlert, ...state.alerts]; + return { alerts: sortAlertsBySeverity(updated) }; + }); } catch (error) { // Silently fail for SignalR handler } diff --git a/src/translations/ar.json b/src/translations/ar.json index 784d01d..96a5275 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -90,35 +90,6 @@ "volumeDown": "مستوى الصوت -", "volumeUp": "مستوى الصوت +" }, - "check_in": { - "tab_title": "تسجيل الحضور", - "timer_status": "حالة المؤقت", - "perform_check_in": "تسجيل", - "quick_check_in": "تسجيل سريع", - "check_in_success": "تم تسجيل الحضور بنجاح", - "check_in_error": "فشل تسجيل الحضور", - "last_check_in": "آخر تسجيل", - "elapsed": "المنقضي", - "duration": "دقيقة", - "status_ok": "جيد", - "status_warning": "تحذير", - "status_overdue": "متأخر", - "history": "السجل", - "no_timers": "لا توجد مؤقتات تسجيل حضور نشطة", - "timers_disabled": "مؤقتات تسجيل الحضور معطلة لهذه المكالمة", - "type_personnel": "الأفراد", - "type_unit": "الوحدة", - "type_ic": "قائد الحادث", - "type_par": "PAR", - "type_hazmat": "مواد خطرة", - "type_sector_rotation": "تناوب القطاع", - "type_rehab": "إعادة تأهيل", - "add_note": "إضافة ملاحظة", - "confirm": "تأكيد التسجيل", - "minutes_ago": "دقيقة مضت", - "select_type": "اختر نوع التسجيل", - "queued_offline": "تم وضع التسجيل في قائمة الانتظار لحين استعادة الاتصال" - }, "callImages": { "add": "إضافة صورة", "add_new": "إضافة صورة جديدة", @@ -173,6 +144,7 @@ "contact_info": "معلومات الاتصال", "contact_name": "اسم جهة الاتصال", "contact_phone": "الهاتف", + "destination": "Destination", "edit_call": "تعديل المكالمة", "external_id": "المعرف الخارجي", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", @@ -218,11 +190,11 @@ "setting_active": "جاري التعيين كنشط...", "status": "الحالة", "tabs": { + "check_in": "تسجيل الحضور", "contact": "جهة الاتصال", "dispatched": "المرسلة", "info": "المعلومات", "protocols": "البروتوكولات", - "check_in": "تسجيل الحضور", "timeline": "النشاط" }, "timestamp": "الطابع الزمني", @@ -265,6 +237,9 @@ "description": "الوصف", "description_placeholder": "أدخل وصف المكالمة", "deselect": "إلغاء التحديد", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "الاتجاهات", "dispatch_to": "إرسال إلى", "dispatch_to_everyone": "إرسال إلى جميع الموظفين المتاحين", @@ -282,6 +257,7 @@ "invalid_type": "نوع غير صحيح محدد. يرجى اختيار نوع مكالمة صحيح.", "loading": "جاري تحميل المكالمات...", "loading_calls": "جاري تحميل المكالمات...", + "loading_destination_pois": "Loading destination POIs...", "name": "الاسم", "name_placeholder": "أدخل اسم المكالمة", "nature": "الطبيعة", @@ -293,6 +269,7 @@ "no_calls": "لا توجد مكالمات نشطة", "no_calls_available": "لا توجد مكالمات متاحة", "no_calls_description": "لم يتم العثور على مكالمات نشطة. اختر مكالمة نشطة لعرض التفاصيل.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "هذه المكالمة لا تحتوي على بيانات موقع متاحة للملاحة.", "no_location_title": "الموقع غير متاح", "no_open_calls": "لا توجد مكالمات مفتوحة متاحة", @@ -312,6 +289,7 @@ "select_address": "اختر العنوان", "select_address_placeholder": "اختر عنوان المكالمة", "select_description": "اختر الوصف", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "اختيار مستقبلي الإرسال", "select_location": "اختر الموقع على الخريطة", "select_name": "اختر الاسم", @@ -336,6 +314,37 @@ "what3words_placeholder": "أدخل عنوان what3words (مثال: filled.count.soap)", "what3words_required": "يرجى إدخال عنوان what3words للبحث" }, + "check_in": { + "add_note": "إضافة ملاحظة", + "check_in_error": "فشل تسجيل الحضور", + "check_in_success": "تم تسجيل الحضور بنجاح", + "confirm": "تأكيد التسجيل", + "duration": "دقيقة", + "elapsed": "المنقضي", + "history": "السجل", + "last_check_in": "آخر تسجيل", + "minutes_ago": "دقيقة مضت", + "no_timers": "لا توجد مؤقتات تسجيل حضور نشطة", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "تسجيل", + "queued_offline": "تم وضع التسجيل في قائمة الانتظار لحين استعادة الاتصال", + "quick_check_in": "تسجيل سريع", + "select_type": "اختر نوع التسجيل", + "status_ok": "جيد", + "status_overdue": "متأخر", + "status_warning": "تحذير", + "tab_title": "تسجيل الحضور", + "timer_status": "حالة المؤقت", + "timers_disabled": "مؤقتات تسجيل الحضور معطلة لهذه المكالمة", + "type_hazmat": "مواد خطرة", + "type_ic": "قائد الحادث", + "type_par": "PAR", + "type_personnel": "الأفراد", + "type_rehab": "إعادة تأهيل", + "type_sector_rotation": "تناوب القطاع", + "type_unit": "الوحدة" + }, "common": { "add": "إضافة", "back": "رجوع", @@ -528,10 +537,14 @@ "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", "failed_to_set_current_call": "فشل في تعيين المكالمة كمكالمة حالية", "no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه", + "pin_address": "Address", "pin_color": "لون الدبوس", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "إعادة توسيط الخريطة", "set_as_current_call": "تعيين كمكالمة حالية", - "view_call_details": "عرض تفاصيل المكالمة" + "view_call_details": "عرض تفاصيل المكالمة", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "الطبقات النشطة", @@ -660,12 +673,15 @@ "eta": "الوقت المتوقع للوصول", "eta_to_next": "الوقت المتوقع للمحطة التالية", "exit": "مخرج", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "نطاق السياج الجغرافي", "history": "سجل المسار", "in_progress": "قيد التنفيذ", "instance_detail": "تفاصيل المسار", "loading": "جارٍ تحميل المسارات...", "loading_directions": "جارٍ تحميل الاتجاهات...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "جارٍ تحميل المحطات...", "location": "الموقع", "min": "دق", @@ -676,6 +692,9 @@ "no_directions": "لا تتوفر اتجاهات", "no_history": "لا يوجد سجل مسارات", "no_history_description": "ستظهر المسارات المكتملة هنا.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "لا توجد مسارات", "no_routes_description": "لا توجد خطط مسارات متاحة لوحدتك.", "no_routes_description_all": "لا توجد خطط مسار متاحة.", @@ -688,6 +707,21 @@ "pending": "معلق", "planned_arrival": "الوصول المخطط", "planned_departure": "المغادرة المخططة", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "الأولوية", "priority_critical": "حرج", "priority_high": "عالٍ", @@ -698,9 +732,13 @@ "remaining_steps": "الخطوات المتبقية", "resume_route": "استئناف المسار", "route_summary": "ملخص المسار", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "جدول", "search": "البحث في المسارات...", + "search_pois": "Search POIs...", "select_unit": "اختر الوحدة", + "set_poi_destination": "Set Destination", "skip": "تخطي", "skip_reason": "سبب التخطي", "skip_reason_placeholder": "أدخل سبب تخطي هذه المحطة", @@ -729,6 +767,7 @@ "unit": "الوحدة", "unit_required": "يجب اختيار وحدة لبدء المسار", "view_contact": "عرض جهة الاتصال", + "view_on_map": "View on map", "view_route": "عرض المسار" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "إضافة ملاحظة", "both_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات", "call_destination_enabled": "يمكن الاستجابة للمكالمات", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "المكالمات", "failed_to_save_status": "فشل في حفظ الحالة. يرجى المحاولة مرة أخرى.", "general_status": "حالة عامة بدون وجهة محددة", + "loading_pois": "Loading POIs...", "loading_stations": "جاري تحميل المحطات...", "no_destination": "بدون وجهة", + "no_pois_available": "No POIs available", "no_stations_available": "لا توجد محطات متاحة", "no_statuses_available": "لا توجد حالات متاحة", "note": "ملاحظة", "note_optional": "أضف ملاحظة اختيارية لتحديث الحالة هذا", "note_required": "يرجى إدخال ملاحظة لتحديث الحالة هذا", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "اختر الوجهة لـ {{status}}", "select_destination_type": "أين تريد الاستجابة؟", "select_status": "اختر الحالة", @@ -842,6 +887,7 @@ "selected_status": "الحالة المختارة", "set_status": "تعيين الحالة", "station_destination_enabled": "يمكن الاستجابة للمحطات", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "المحطات", "status_saved_successfully": "تم حفظ الحالة بنجاح!" }, @@ -855,72 +901,121 @@ "settings": "الإعدادات", "weather_alerts": "الطقس" }, - "weather_alerts": { - "title": "تنبيهات الطقس", - "loading": "جاري تحميل تنبيهات الطقس...", - "no_alerts": "لا توجد تنبيهات طقس", - "no_alerts_description": "لا توجد تنبيهات طقس نشطة لمنطقتك.", - "feature_disabled": "تنبيهات الطقس معطلة", - "feature_disabled_description": "تنبيهات الطقس غير مفعلة لقسمك.", - "search": "البحث في تنبيهات الطقس...", - "severity": { "extreme": "شديد للغاية", "severe": "شديد", "moderate": "معتدل", "minor": "طفيف", "unknown": "غير معروف" }, - "category": { "met": "أرصاد جوية", "fire": "حريق", "health": "صحة", "env": "بيئي", "other": "أخرى" }, - "urgency": { "immediate": "فوري", "expected": "متوقع", "future": "مستقبلي", "past": "سابق", "unknown": "غير معروف" }, - "certainty": { "observed": "مرصود", "likely": "مرجح", "possible": "محتمل", "unlikely": "غير مرجح", "unknown": "غير معروف" }, - "status": { "active": "نشط", "updated": "محدث", "expired": "منتهي", "cancelled": "ملغى" }, - "detail": { "headline": "العنوان", "description": "الوصف", "instructions": "التعليمات", "area": "المنطقة المتأثرة", "effective": "ساري من", "onset": "البداية", "expires": "ينتهي", "sender": "المرسل", "urgency": "الإلحاح", "certainty": "اليقين" }, - "filter": { "all": "الكل", "nearby": "بالقرب" }, - "sort": { "severity": "الشدة", "expires": "ينتهي قريباً", "newest": "الأحدث" }, - "banner": { "more_alerts": "+{{count}} المزيد" } - }, - "welcome": "مرحبًا بك في موقع تطبيق obytes", "video_feeds": { - "tab_title": "فيديو", - "no_feeds": "لا توجد بثوث فيديو متاحة", "add_feed": "إضافة بث", - "edit_feed": "تعديل البث", - "watch": "مشاهدة", + "added_by": "أضافه", + "added_on": "أُضيف في", "copy_url": "نسخ الرابط", - "url_copied": "تم نسخ الرابط إلى الحافظة", - "delete_feed": "حذف البث", - "delete_confirm_title": "حذف بث الفيديو", "delete_confirm_message": "هل أنت متأكد أنك تريد حذف هذا البث؟", - "save_success": "تم حفظ بث الفيديو بنجاح", - "save_error": "فشل حفظ بث الفيديو", - "delete_success": "تم حذف بث الفيديو بنجاح", + "delete_confirm_title": "حذف بث الفيديو", "delete_error": "فشل حذف بث الفيديو", - "name": "الاسم", - "url": "الرابط", - "feed_type": "نوع البث", - "feed_format": "صيغة البث", + "delete_feed": "حذف البث", + "delete_success": "تم حذف بث الفيديو بنجاح", "description": "الوصف", + "edit_feed": "تعديل البث", + "feed_format": "صيغة البث", + "feed_type": "نوع البث", + "format_dash": "DASH", + "format_embed": "مضمّن", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "أخرى", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "يوتيوب مباشر", "latitude": "خط العرض", + "loading_video": "جارٍ تحميل الفيديو...", "longitude": "خط الطول", - "added_by": "أضافه", - "added_on": "أُضيف في", + "name": "الاسم", + "no_feeds": "لا توجد بثوث فيديو متاحة", + "player_title": "مشغل الفيديو", + "rtsp_not_supported": "لا يمكن تشغيل بثوث RTSP مباشرة. انسخ الرابط لاستخدامه في مشغل مخصص.", + "save_error": "فشل حفظ بث الفيديو", + "save_success": "تم حفظ بث الفيديو بنجاح", "status_active": "نشط", - "status_inactive": "غير نشط", "status_error": "خطأ", + "status_inactive": "غير نشط", + "tab_title": "فيديو", + "type_body_cam": "كاميرا جسدية", "type_drone": "طائرة بدون طيار", "type_fixed_camera": "كاميرا ثابتة", - "type_body_cam": "كاميرا جسدية", + "type_other": "أخرى", + "type_satellite_feed": "بث فضائي", "type_traffic_cam": "كاميرا مرور", "type_weather_cam": "كاميرا طقس", - "type_satellite_feed": "بث فضائي", "type_web_cam": "كاميرا ويب", - "type_other": "أخرى", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "يوتيوب مباشر", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "مضمّن", - "format_other": "أخرى", - "rtsp_not_supported": "لا يمكن تشغيل بثوث RTSP مباشرة. انسخ الرابط لاستخدامه في مشغل مخصص.", - "webrtc_not_supported": "تشغيل WebRTC قريبًا", - "player_title": "مشغل الفيديو", - "loading_video": "جارٍ تحميل الفيديو...", - "video_error": "فشل تحميل الفيديو" - } + "url": "الرابط", + "url_copied": "تم نسخ الرابط إلى الحافظة", + "video_error": "فشل تحميل الفيديو", + "watch": "مشاهدة", + "webrtc_not_supported": "تشغيل WebRTC قريبًا" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} المزيد" + }, + "category": { + "env": "بيئي", + "fire": "حريق", + "health": "صحة", + "met": "أرصاد جوية", + "other": "أخرى" + }, + "certainty": { + "likely": "مرجح", + "observed": "مرصود", + "possible": "محتمل", + "unknown": "غير معروف", + "unlikely": "غير مرجح" + }, + "detail": { + "area": "المنطقة المتأثرة", + "certainty": "اليقين", + "description": "الوصف", + "effective": "ساري من", + "expires": "ينتهي", + "headline": "العنوان", + "instructions": "التعليمات", + "onset": "البداية", + "sender": "المرسل", + "urgency": "الإلحاح" + }, + "feature_disabled": "تنبيهات الطقس معطلة", + "feature_disabled_description": "تنبيهات الطقس غير مفعلة لقسمك.", + "filter": { + "all": "الكل", + "nearby": "بالقرب" + }, + "loading": "جاري تحميل تنبيهات الطقس...", + "no_alerts": "لا توجد تنبيهات طقس", + "no_alerts_description": "لا توجد تنبيهات طقس نشطة لمنطقتك.", + "search": "البحث في تنبيهات الطقس...", + "severity": { + "extreme": "شديد للغاية", + "minor": "طفيف", + "moderate": "معتدل", + "severe": "شديد", + "unknown": "غير معروف" + }, + "sort": { + "expires": "ينتهي قريباً", + "newest": "الأحدث", + "severity": "الشدة" + }, + "status": { + "active": "نشط", + "cancelled": "ملغى", + "expired": "منتهي", + "updated": "محدث" + }, + "title": "تنبيهات الطقس", + "urgency": { + "expected": "متوقع", + "future": "مستقبلي", + "immediate": "فوري", + "past": "سابق", + "unknown": "غير معروف" + } + }, + "welcome": "مرحبًا بك في موقع تطبيق obytes" } diff --git a/src/translations/de.json b/src/translations/de.json index 06b89cf..ce580ce 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -90,35 +90,6 @@ "volumeDown": "Lautstärke -", "volumeUp": "Lautstärke +" }, - "check_in": { - "tab_title": "Check-In", - "timer_status": "Timer-Status", - "perform_check_in": "Einchecken", - "quick_check_in": "Schnell-Check-In", - "check_in_success": "Check-In erfolgreich aufgezeichnet", - "check_in_error": "Check-In konnte nicht aufgezeichnet werden", - "last_check_in": "Letzter Check-In", - "elapsed": "Vergangen", - "duration": "Min", - "status_ok": "OK", - "status_warning": "Warnung", - "status_overdue": "Überfällig", - "history": "Verlauf", - "no_timers": "Keine Check-In-Timer aktiv", - "timers_disabled": "Check-In-Timer sind für diesen Einsatz deaktiviert", - "type_personnel": "Personal", - "type_unit": "Einheit", - "type_ic": "EL", - "type_par": "PAR", - "type_hazmat": "Gefahrgut", - "type_sector_rotation": "Sektorwechsel", - "type_rehab": "Reha", - "add_note": "Notiz hinzufügen", - "confirm": "Check-In bestätigen", - "minutes_ago": "Min. her", - "select_type": "Check-In-Typ wählen", - "queued_offline": "Check-In wird bei Verbindung nachgeholt" - }, "callImages": { "add": "Bild hinzufügen", "add_new": "Neues Bild hinzufügen", @@ -173,6 +144,7 @@ "contact_info": "Kontaktinfo", "contact_name": "Kontaktname", "contact_phone": "Telefon", + "destination": "Destination", "edit_call": "Anruf bearbeiten", "external_id": "Externe ID", "failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden", @@ -218,11 +190,11 @@ "setting_active": "Wird als aktiv gesetzt...", "status": "Status", "tabs": { + "check_in": "Check-In", "contact": "Kontakt", "dispatched": "Entsendet", "info": "Info", "protocols": "Protokolle", - "check_in": "Check-In", "timeline": "Aktivität" }, "timestamp": "Zeitstempel", @@ -265,6 +237,9 @@ "description": "Beschreibung", "description_placeholder": "Beschreibung des Anrufs eingeben", "deselect": "Abwählen", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Wegbeschreibung", "dispatch_to": "Disponieren an", "dispatch_to_everyone": "An alle verfügbaren Einsatzkräfte disponieren", @@ -282,6 +257,7 @@ "invalid_type": "Ungültiger Typ ausgewählt. Bitte einen gültigen Anruftyp auswählen.", "loading": "Anrufe werden geladen...", "loading_calls": "Anrufe werden geladen...", + "loading_destination_pois": "Loading destination POIs...", "name": "Name", "name_placeholder": "Namen des Anrufs eingeben", "nature": "Art", @@ -293,6 +269,7 @@ "no_calls": "Keine aktiven Anrufe", "no_calls_available": "Keine Anrufe verfügbar", "no_calls_description": "Keine aktiven Anrufe gefunden. Einen aktiven Anruf auswählen, um Details anzuzeigen.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "Für diesen Anruf sind keine Standortdaten für die Navigation verfügbar.", "no_location_title": "Kein Standort verfügbar", "no_open_calls": "Keine offenen Anrufe verfügbar", @@ -312,6 +289,7 @@ "select_address": "Adresse auswählen", "select_address_placeholder": "Adresse des Anrufs auswählen", "select_description": "Beschreibung auswählen", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Disponierende Empfänger auswählen", "select_location": "Standort auf der Karte auswählen", "select_name": "Namen auswählen", @@ -336,6 +314,37 @@ "what3words_placeholder": "what3words-Adresse eingeben (z. B. filled.count.soap)", "what3words_required": "Bitte eine what3words-Adresse zum Suchen eingeben" }, + "check_in": { + "add_note": "Notiz hinzufügen", + "check_in_error": "Check-In konnte nicht aufgezeichnet werden", + "check_in_success": "Check-In erfolgreich aufgezeichnet", + "confirm": "Check-In bestätigen", + "duration": "Min", + "elapsed": "Vergangen", + "history": "Verlauf", + "last_check_in": "Letzter Check-In", + "minutes_ago": "Min. her", + "no_timers": "Keine Check-In-Timer aktiv", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Einchecken", + "queued_offline": "Check-In wird bei Verbindung nachgeholt", + "quick_check_in": "Schnell-Check-In", + "select_type": "Check-In-Typ wählen", + "status_ok": "OK", + "status_overdue": "Überfällig", + "status_warning": "Warnung", + "tab_title": "Check-In", + "timer_status": "Timer-Status", + "timers_disabled": "Check-In-Timer sind für diesen Einsatz deaktiviert", + "type_hazmat": "Gefahrgut", + "type_ic": "EL", + "type_par": "PAR", + "type_personnel": "Personal", + "type_rehab": "Reha", + "type_sector_rotation": "Sektorwechsel", + "type_unit": "Einheit" + }, "common": { "add": "Hinzufügen", "back": "Zurück", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden", "failed_to_set_current_call": "Anruf konnte nicht als aktueller Anruf gesetzt werden", "no_location_for_routing": "Keine Standortdaten für die Navigation verfügbar", + "pin_address": "Address", "pin_color": "Pin-Farbe", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Karte neu zentrieren", "set_as_current_call": "Als aktuellen Anruf setzen", - "view_call_details": "Anrufdetails anzeigen" + "view_call_details": "Anrufdetails anzeigen", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Aktive Ebenen", @@ -660,12 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA bis zum nächsten Stopp", "exit": "Ausgang", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Geofence-Radius", "history": "Routenverlauf", "in_progress": "In Bearbeitung", "instance_detail": "Routeninstanz", "loading": "Routen werden geladen...", "loading_directions": "Wegbeschreibung wird geladen...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Stopps werden geladen...", "location": "Standort", "min": "Min", @@ -676,6 +692,9 @@ "no_directions": "Keine Wegbeschreibung verfügbar", "no_history": "Kein Routenverlauf verfügbar", "no_history_description": "Abgeschlossene Routen werden hier angezeigt.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Keine Routen", "no_routes_description": "Für Ihre Einheit sind keine Routenpläne verfügbar.", "no_routes_description_all": "Keine Routenpläne verfügbar.", @@ -688,6 +707,21 @@ "pending": "Ausstehend", "planned_arrival": "Geplante Ankunft", "planned_departure": "Geplante Abfahrt", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Priorität", "priority_critical": "Kritisch", "priority_high": "Hoch", @@ -698,9 +732,13 @@ "remaining_steps": "Verbleibende Schritte", "resume_route": "Route fortsetzen", "route_summary": "Routenzusammenfassung", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Zeitplan", "search": "Routen suchen...", + "search_pois": "Search POIs...", "select_unit": "Einheit auswählen", + "set_poi_destination": "Set Destination", "skip": "Überspringen", "skip_reason": "Grund zum Überspringen", "skip_reason_placeholder": "Grund für das Überspringen dieses Stopps eingeben", @@ -729,6 +767,7 @@ "unit": "Einheit", "unit_required": "Es muss eine Einheit ausgewählt werden, um die Route zu starten", "view_contact": "Kontakt anzeigen", + "view_on_map": "View on map", "view_route": "Route anzeigen" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Notiz hinzufügen", "both_destinations_enabled": "Kann auf Anrufe oder Stationen reagieren", "call_destination_enabled": "Kann auf Anrufe reagieren", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Anrufe", "failed_to_save_status": "Status konnte nicht gespeichert werden. Bitte erneut versuchen.", "general_status": "Allgemeiner Status ohne spezifisches Ziel", + "loading_pois": "Loading POIs...", "loading_stations": "Stationen werden geladen...", "no_destination": "Kein Ziel", + "no_pois_available": "No POIs available", "no_stations_available": "Keine Stationen verfügbar", "no_statuses_available": "Keine Status verfügbar", "note": "Notiz", "note_optional": "Eine optionale Notiz für diese Statusaktualisierung hinzufügen", "note_required": "Bitte eine Notiz für diese Statusaktualisierung eingeben", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Ziel für {{status}} auswählen", "select_destination_type": "Wohin möchten Sie reagieren?", "select_status": "Status auswählen", @@ -842,6 +887,7 @@ "selected_status": "Ausgewählter Status", "set_status": "Status festlegen", "station_destination_enabled": "Kann auf Stationen reagieren", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stationen", "status_saved_successfully": "Status erfolgreich gespeichert!" }, @@ -855,72 +901,121 @@ "settings": "Einstellungen", "weather_alerts": "Wetter" }, - "weather_alerts": { - "title": "Wetterwarnungen", - "loading": "Wetterwarnungen werden geladen...", - "no_alerts": "Keine Wetterwarnungen", - "no_alerts_description": "Es gibt keine aktiven Wetterwarnungen für Ihr Gebiet.", - "feature_disabled": "Wetterwarnungen deaktiviert", - "feature_disabled_description": "Wetterwarnungen sind für Ihre Abteilung nicht aktiviert.", - "search": "Wetterwarnungen suchen...", - "severity": { "extreme": "Extrem", "severe": "Schwer", "moderate": "Mäßig", "minor": "Gering", "unknown": "Unbekannt" }, - "category": { "met": "Meteorologisch", "fire": "Feuer", "health": "Gesundheit", "env": "Umwelt", "other": "Sonstiges" }, - "urgency": { "immediate": "Sofort", "expected": "Erwartet", "future": "Zukünftig", "past": "Vergangen", "unknown": "Unbekannt" }, - "certainty": { "observed": "Beobachtet", "likely": "Wahrscheinlich", "possible": "Möglich", "unlikely": "Unwahrscheinlich", "unknown": "Unbekannt" }, - "status": { "active": "Aktiv", "updated": "Aktualisiert", "expired": "Abgelaufen", "cancelled": "Aufgehoben" }, - "detail": { "headline": "Überschrift", "description": "Beschreibung", "instructions": "Anweisungen", "area": "Betroffenes Gebiet", "effective": "Gültig ab", "onset": "Beginn", "expires": "Läuft ab", "sender": "Absender", "urgency": "Dringlichkeit", "certainty": "Sicherheit" }, - "filter": { "all": "Alle", "nearby": "In der Nähe" }, - "sort": { "severity": "Schweregrad", "expires": "Bald ablaufend", "newest": "Neueste" }, - "banner": { "more_alerts": "+{{count}} weitere" } - }, - "welcome": "Willkommen bei obytes app site", "video_feeds": { - "tab_title": "Video", - "no_feeds": "Keine Video-Feeds verfügbar", "add_feed": "Feed hinzufügen", - "edit_feed": "Feed bearbeiten", - "watch": "Ansehen", + "added_by": "Hinzugefügt von", + "added_on": "Hinzugefügt am", "copy_url": "URL kopieren", - "url_copied": "URL in Zwischenablage kopiert", - "delete_feed": "Feed löschen", - "delete_confirm_title": "Video-Feed löschen", "delete_confirm_message": "Möchten Sie diesen Video-Feed wirklich löschen?", - "save_success": "Video-Feed erfolgreich gespeichert", - "save_error": "Video-Feed konnte nicht gespeichert werden", - "delete_success": "Video-Feed erfolgreich gelöscht", + "delete_confirm_title": "Video-Feed löschen", "delete_error": "Video-Feed konnte nicht gelöscht werden", - "name": "Name", - "url": "URL", - "feed_type": "Feed-Typ", - "feed_format": "Feed-Format", + "delete_feed": "Feed löschen", + "delete_success": "Video-Feed erfolgreich gelöscht", "description": "Beschreibung", + "edit_feed": "Feed bearbeiten", + "feed_format": "Feed-Format", + "feed_type": "Feed-Typ", + "format_dash": "DASH", + "format_embed": "Eingebettet", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Sonstige", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube Live", "latitude": "Breitengrad", + "loading_video": "Video wird geladen...", "longitude": "Längengrad", - "added_by": "Hinzugefügt von", - "added_on": "Hinzugefügt am", + "name": "Name", + "no_feeds": "Keine Video-Feeds verfügbar", + "player_title": "Videoplayer", + "rtsp_not_supported": "RTSP-Streams können nicht direkt abgespielt werden. Kopieren Sie die URL für einen dedizierten Player.", + "save_error": "Video-Feed konnte nicht gespeichert werden", + "save_success": "Video-Feed erfolgreich gespeichert", "status_active": "Aktiv", - "status_inactive": "Inaktiv", "status_error": "Fehler", + "status_inactive": "Inaktiv", + "tab_title": "Video", + "type_body_cam": "Bodycam", "type_drone": "Drohne", "type_fixed_camera": "Feste Kamera", - "type_body_cam": "Bodycam", + "type_other": "Sonstige", + "type_satellite_feed": "Satelliten-Feed", "type_traffic_cam": "Verkehrskamera", "type_weather_cam": "Wetterkamera", - "type_satellite_feed": "Satelliten-Feed", "type_web_cam": "Webcam", - "type_other": "Sonstige", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube Live", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Eingebettet", - "format_other": "Sonstige", - "rtsp_not_supported": "RTSP-Streams können nicht direkt abgespielt werden. Kopieren Sie die URL für einen dedizierten Player.", - "webrtc_not_supported": "WebRTC-Wiedergabe kommt bald", - "player_title": "Videoplayer", - "loading_video": "Video wird geladen...", - "video_error": "Video konnte nicht geladen werden" - } + "url": "URL", + "url_copied": "URL in Zwischenablage kopiert", + "video_error": "Video konnte nicht geladen werden", + "watch": "Ansehen", + "webrtc_not_supported": "WebRTC-Wiedergabe kommt bald" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} weitere" + }, + "category": { + "env": "Umwelt", + "fire": "Feuer", + "health": "Gesundheit", + "met": "Meteorologisch", + "other": "Sonstiges" + }, + "certainty": { + "likely": "Wahrscheinlich", + "observed": "Beobachtet", + "possible": "Möglich", + "unknown": "Unbekannt", + "unlikely": "Unwahrscheinlich" + }, + "detail": { + "area": "Betroffenes Gebiet", + "certainty": "Sicherheit", + "description": "Beschreibung", + "effective": "Gültig ab", + "expires": "Läuft ab", + "headline": "Überschrift", + "instructions": "Anweisungen", + "onset": "Beginn", + "sender": "Absender", + "urgency": "Dringlichkeit" + }, + "feature_disabled": "Wetterwarnungen deaktiviert", + "feature_disabled_description": "Wetterwarnungen sind für Ihre Abteilung nicht aktiviert.", + "filter": { + "all": "Alle", + "nearby": "In der Nähe" + }, + "loading": "Wetterwarnungen werden geladen...", + "no_alerts": "Keine Wetterwarnungen", + "no_alerts_description": "Es gibt keine aktiven Wetterwarnungen für Ihr Gebiet.", + "search": "Wetterwarnungen suchen...", + "severity": { + "extreme": "Extrem", + "minor": "Gering", + "moderate": "Mäßig", + "severe": "Schwer", + "unknown": "Unbekannt" + }, + "sort": { + "expires": "Bald ablaufend", + "newest": "Neueste", + "severity": "Schweregrad" + }, + "status": { + "active": "Aktiv", + "cancelled": "Aufgehoben", + "expired": "Abgelaufen", + "updated": "Aktualisiert" + }, + "title": "Wetterwarnungen", + "urgency": { + "expected": "Erwartet", + "future": "Zukünftig", + "immediate": "Sofort", + "past": "Vergangen", + "unknown": "Unbekannt" + } + }, + "welcome": "Willkommen bei obytes app site" } diff --git a/src/translations/en.json b/src/translations/en.json index 0935e00..6bad140 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -90,35 +90,6 @@ "volumeDown": "Volume -", "volumeUp": "Volume +" }, - "check_in": { - "tab_title": "Check-In", - "timer_status": "Timer Status", - "perform_check_in": "Check In", - "quick_check_in": "Quick Check-In", - "check_in_success": "Check-in recorded successfully", - "check_in_error": "Failed to record check-in", - "last_check_in": "Last check-in", - "elapsed": "Elapsed", - "duration": "min", - "status_ok": "OK", - "status_warning": "Warning", - "status_overdue": "Overdue", - "history": "History", - "no_timers": "No check-in timers active", - "timers_disabled": "Check-in timers are disabled for this call", - "type_personnel": "Personnel", - "type_unit": "Unit", - "type_ic": "IC", - "type_par": "PAR", - "type_hazmat": "Hazmat", - "type_sector_rotation": "Sector Rotation", - "type_rehab": "Rehab", - "add_note": "Add note", - "confirm": "Confirm Check-In", - "minutes_ago": "min ago", - "select_type": "Select check-in type", - "queued_offline": "Check-in queued for when connection is restored" - }, "callImages": { "add": "Add Image", "add_new": "Add New Image", @@ -219,11 +190,11 @@ "setting_active": "Setting Active...", "status": "Status", "tabs": { + "check_in": "Check-In", "contact": "Contact", "dispatched": "Dispatched", "info": "Info", "protocols": "Protocols", - "check_in": "Check-In", "timeline": "Activity" }, "timestamp": "Timestamp", @@ -318,8 +289,8 @@ "select_address": "Select Address", "select_address_placeholder": "Select the address of the call", "select_description": "Select Description", - "select_dispatch_recipients": "Select Dispatch Recipients", "select_destination_poi": "Select Destination POI", + "select_dispatch_recipients": "Select Dispatch Recipients", "select_location": "Select Location on Map", "select_name": "Select Name", "select_nature": "Select Nature", @@ -343,6 +314,37 @@ "what3words_placeholder": "Enter what3words address (e.g., filled.count.soap)", "what3words_required": "Please enter a what3words address to search" }, + "check_in": { + "add_note": "Add note", + "check_in_error": "Failed to record check-in", + "check_in_success": "Check-in recorded successfully", + "confirm": "Confirm Check-In", + "duration": "min", + "elapsed": "Elapsed", + "history": "History", + "last_check_in": "Last check-in", + "minutes_ago": "min ago", + "no_timers": "No check-in timers active", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Check In", + "queued_offline": "Check-in queued for when connection is restored", + "quick_check_in": "Quick Check-In", + "select_type": "Select check-in type", + "status_ok": "OK", + "status_overdue": "Overdue", + "status_warning": "Warning", + "tab_title": "Check-In", + "timer_status": "Timer Status", + "timers_disabled": "Check-in timers are disabled for this call", + "type_hazmat": "Hazmat", + "type_ic": "IC", + "type_par": "PAR", + "type_personnel": "Personnel", + "type_rehab": "Rehab", + "type_sector_rotation": "Sector Rotation", + "type_unit": "Unit" + }, "common": { "add": "Add", "back": "Back", @@ -671,14 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA to next stop", "exit": "Exit", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Geofence Radius", "history": "Route History", "in_progress": "In Progress", "instance_detail": "Route Instance", "loading": "Loading routes...", + "loading_directions": "Loading directions...", "loading_poi": "Loading POI...", "loading_pois": "Loading POIs...", - "loading_directions": "Loading directions...", "loading_stops": "Loading stops...", "location": "Location", "min": "min", @@ -689,12 +692,12 @@ "no_directions": "No directions available", "no_history": "No route history available", "no_history_description": "Completed routes will appear here.", - "no_routes": "No Routes", - "no_routes_description": "No route plans are available for your unit.", - "no_routes_description_all": "No route plans are available.", "no_pois": "No POIs", "no_pois_description": "No POIs are available for your department.", "no_pois_filtered_description": "No POIs match the current filters.", + "no_routes": "No Routes", + "no_routes_description": "No route plans are available for your unit.", + "no_routes_description_all": "No route plans are available.", "no_stops": "No stops available", "notes": "Notes", "notes_placeholder": "Enter notes for this stop...", @@ -704,6 +707,21 @@ "pending": "Pending", "planned_arrival": "Planned Arrival", "planned_departure": "Planned Departure", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Priority", "priority_critical": "Critical", "priority_high": "High", @@ -713,13 +731,14 @@ "progress": "{{percent}}% complete", "remaining_steps": "Remaining Steps", "resume_route": "Resume Route", - "route_to_poi": "Route to POI", "route_summary": "Route Summary", + "route_to_poi": "Route to POI", "routes_tab": "Routes", "schedule": "Schedule", "search": "Search routes...", "search_pois": "Search POIs...", "select_unit": "Select Unit", + "set_poi_destination": "Set Destination", "skip": "Skip", "skip_reason": "Skip Reason", "skip_reason_placeholder": "Enter reason for skipping this stop", @@ -749,24 +768,7 @@ "unit_required": "A unit must be selected to start the route", "view_contact": "View Contact", "view_on_map": "View on map", - "view_route": "View Route", - "failed_to_open_poi_maps": "Failed to open maps for this POI", - "poi_address": "Address", - "poi_coordinates": "Coordinates", - "poi_coordinates_compact": "{{latitude}}, {{longitude}}", - "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", - "poi_destination_enabled": "Destination enabled", - "poi_filter_all_types": "All POI types", - "poi_filter_placeholder": "Filter by type", - "poi_note": "Note", - "poi_not_found": "POI not found", - "poi_not_found_description": "This POI could not be found.", - "poi_sort_display": "Name", - "poi_sort_placeholder": "Sort POIs", - "poi_sort_type": "Type", - "poi_type_unknown": "Unknown type", - "pois_tab": "POIs", - "set_poi_destination": "Set Destination" + "view_route": "View Route" }, "settings": { "about": "About", @@ -899,121 +901,121 @@ "settings": "Settings", "weather_alerts": "Weather" }, + "video_feeds": { + "add_feed": "Add Feed", + "added_by": "Added by", + "added_on": "Added on", + "copy_url": "Copy URL", + "delete_confirm_message": "Are you sure you want to delete this video feed?", + "delete_confirm_title": "Delete Video Feed", + "delete_error": "Failed to delete video feed", + "delete_feed": "Delete Feed", + "delete_success": "Video feed deleted successfully", + "description": "Description", + "edit_feed": "Edit Feed", + "feed_format": "Feed Format", + "feed_type": "Feed Type", + "format_dash": "DASH", + "format_embed": "Embed", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Other", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube Live", + "latitude": "Latitude", + "loading_video": "Loading video...", + "longitude": "Longitude", + "name": "Name", + "no_feeds": "No video feeds available", + "player_title": "Video Player", + "rtsp_not_supported": "RTSP streams cannot be played directly. Copy the URL to use in a dedicated player.", + "save_error": "Failed to save video feed", + "save_success": "Video feed saved successfully", + "status_active": "Active", + "status_error": "Error", + "status_inactive": "Inactive", + "tab_title": "Video", + "type_body_cam": "Body Cam", + "type_drone": "Drone", + "type_fixed_camera": "Fixed Camera", + "type_other": "Other", + "type_satellite_feed": "Satellite Feed", + "type_traffic_cam": "Traffic Cam", + "type_weather_cam": "Weather Cam", + "type_web_cam": "Web Cam", + "url": "URL", + "url_copied": "URL copied to clipboard", + "video_error": "Failed to load video", + "watch": "Watch", + "webrtc_not_supported": "WebRTC playback coming soon" + }, "weather_alerts": { - "title": "Weather Alerts", - "loading": "Loading weather alerts...", - "no_alerts": "No Weather Alerts", - "no_alerts_description": "There are no active weather alerts for your area.", - "feature_disabled": "Weather Alerts Disabled", - "feature_disabled_description": "Weather alerts are not enabled for your department.", - "search": "Search weather alerts...", - "severity": { - "extreme": "Extreme", - "severe": "Severe", - "moderate": "Moderate", - "minor": "Minor", - "unknown": "Unknown" + "banner": { + "more_alerts": "+{{count}} more" }, "category": { - "met": "Meteorological", + "env": "Environmental", "fire": "Fire", "health": "Health", - "env": "Environmental", + "met": "Meteorological", "other": "Other" }, - "urgency": { - "immediate": "Immediate", - "expected": "Expected", - "future": "Future", - "past": "Past", - "unknown": "Unknown" - }, "certainty": { - "observed": "Observed", "likely": "Likely", + "observed": "Observed", "possible": "Possible", - "unlikely": "Unlikely", - "unknown": "Unknown" - }, - "status": { - "active": "Active", - "updated": "Updated", - "expired": "Expired", - "cancelled": "Cancelled" + "unknown": "Unknown", + "unlikely": "Unlikely" }, "detail": { - "headline": "Headline", - "description": "Description", - "instructions": "Instructions", "area": "Affected Area", + "certainty": "Certainty", + "description": "Description", "effective": "Effective", - "onset": "Onset", "expires": "Expires", + "headline": "Headline", + "instructions": "Instructions", + "onset": "Onset", "sender": "Sender", - "urgency": "Urgency", - "certainty": "Certainty" + "urgency": "Urgency" }, + "feature_disabled": "Weather Alerts Disabled", + "feature_disabled_description": "Weather alerts are not enabled for your department.", "filter": { "all": "All", "nearby": "Nearby" }, + "loading": "Loading weather alerts...", + "no_alerts": "No Weather Alerts", + "no_alerts_description": "There are no active weather alerts for your area.", + "search": "Search weather alerts...", + "severity": { + "extreme": "Extreme", + "minor": "Minor", + "moderate": "Moderate", + "severe": "Severe", + "unknown": "Unknown" + }, "sort": { - "severity": "Severity", "expires": "Expiring Soon", - "newest": "Newest" + "newest": "Newest", + "severity": "Severity" }, - "banner": { - "more_alerts": "+{{count}} more" + "status": { + "active": "Active", + "cancelled": "Cancelled", + "expired": "Expired", + "updated": "Updated" + }, + "title": "Weather Alerts", + "urgency": { + "expected": "Expected", + "future": "Future", + "immediate": "Immediate", + "past": "Past", + "unknown": "Unknown" } }, - "welcome": "Welcome to obytes app site", - "video_feeds": { - "tab_title": "Video", - "no_feeds": "No video feeds available", - "add_feed": "Add Feed", - "edit_feed": "Edit Feed", - "watch": "Watch", - "copy_url": "Copy URL", - "url_copied": "URL copied to clipboard", - "delete_feed": "Delete Feed", - "delete_confirm_title": "Delete Video Feed", - "delete_confirm_message": "Are you sure you want to delete this video feed?", - "save_success": "Video feed saved successfully", - "save_error": "Failed to save video feed", - "delete_success": "Video feed deleted successfully", - "delete_error": "Failed to delete video feed", - "name": "Name", - "url": "URL", - "feed_type": "Feed Type", - "feed_format": "Feed Format", - "description": "Description", - "latitude": "Latitude", - "longitude": "Longitude", - "added_by": "Added by", - "added_on": "Added on", - "status_active": "Active", - "status_inactive": "Inactive", - "status_error": "Error", - "type_drone": "Drone", - "type_fixed_camera": "Fixed Camera", - "type_body_cam": "Body Cam", - "type_traffic_cam": "Traffic Cam", - "type_weather_cam": "Weather Cam", - "type_satellite_feed": "Satellite Feed", - "type_web_cam": "Web Cam", - "type_other": "Other", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube Live", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Embed", - "format_other": "Other", - "rtsp_not_supported": "RTSP streams cannot be played directly. Copy the URL to use in a dedicated player.", - "webrtc_not_supported": "WebRTC playback coming soon", - "player_title": "Video Player", - "loading_video": "Loading video...", - "video_error": "Failed to load video" - } + "welcome": "Welcome to obytes app site" } diff --git a/src/translations/es.json b/src/translations/es.json index cadfe19..f567b20 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -90,35 +90,6 @@ "volumeDown": "Volumen -", "volumeUp": "Volumen +" }, - "check_in": { - "tab_title": "Registro", - "timer_status": "Estado del temporizador", - "perform_check_in": "Registrar", - "quick_check_in": "Registro rápido", - "check_in_success": "Registro completado exitosamente", - "check_in_error": "Error al completar el registro", - "last_check_in": "Último registro", - "elapsed": "Transcurrido", - "duration": "min", - "status_ok": "OK", - "status_warning": "Advertencia", - "status_overdue": "Vencido", - "history": "Historial", - "no_timers": "No hay temporizadores de registro activos", - "timers_disabled": "Los temporizadores de registro están desactivados para esta llamada", - "type_personnel": "Personal", - "type_unit": "Unidad", - "type_ic": "CI", - "type_par": "PAR", - "type_hazmat": "Materiales peligrosos", - "type_sector_rotation": "Rotación de sector", - "type_rehab": "Rehabilitación", - "add_note": "Agregar nota", - "confirm": "Confirmar registro", - "minutes_ago": "min atrás", - "select_type": "Seleccionar tipo de registro", - "queued_offline": "Registro en cola para cuando se restaure la conexión" - }, "callImages": { "add": "Añadir imagen", "add_new": "Añadir nueva imagen", @@ -173,6 +144,7 @@ "contact_info": "Información de contacto", "contact_name": "Nombre del contacto", "contact_phone": "Teléfono", + "destination": "Destination", "edit_call": "Editar llamada", "external_id": "ID externo", "failed_to_open_maps": "Error al abrir la aplicación de mapas", @@ -218,11 +190,11 @@ "setting_active": "Estableciendo como activa...", "status": "Estado", "tabs": { + "check_in": "Registro", "contact": "Contacto", "dispatched": "Despachadas", "info": "Información", "protocols": "Protocolos", - "check_in": "Registro", "timeline": "Actividad" }, "timestamp": "Marca de tiempo", @@ -265,6 +237,9 @@ "description": "Descripción", "description_placeholder": "Introduce la descripción de la llamada", "deselect": "Deseleccionar", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Direcciones", "dispatch_to": "Despachar A", "dispatch_to_everyone": "Despachar a todo el personal disponible", @@ -282,6 +257,7 @@ "invalid_type": "Tipo inválido seleccionado. Por favor seleccione un tipo de llamada válido.", "loading": "Cargando llamadas...", "loading_calls": "Cargando llamadas...", + "loading_destination_pois": "Loading destination POIs...", "name": "Nombre", "name_placeholder": "Introduce el nombre de la llamada", "nature": "Naturaleza", @@ -293,6 +269,7 @@ "no_calls": "No hay llamadas activas", "no_calls_available": "No hay llamadas disponibles", "no_calls_description": "No se encontraron llamadas activas. Seleccione una llamada activa para ver los detalles.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "Esta llamada no tiene datos de ubicación disponibles para navegación.", "no_location_title": "Ubicación No Disponible", "no_open_calls": "No hay llamadas abiertas disponibles", @@ -312,6 +289,7 @@ "select_address": "Seleccionar dirección", "select_address_placeholder": "Selecciona la dirección de la llamada", "select_description": "Seleccionar descripción", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Seleccionar Destinatarios de Despacho", "select_location": "Seleccionar ubicación en el mapa", "select_name": "Seleccionar nombre", @@ -336,6 +314,37 @@ "what3words_placeholder": "Introduce dirección what3words (ej: filled.count.soap)", "what3words_required": "Por favor introduce una dirección what3words para buscar" }, + "check_in": { + "add_note": "Agregar nota", + "check_in_error": "Error al completar el registro", + "check_in_success": "Registro completado exitosamente", + "confirm": "Confirmar registro", + "duration": "min", + "elapsed": "Transcurrido", + "history": "Historial", + "last_check_in": "Último registro", + "minutes_ago": "min atrás", + "no_timers": "No hay temporizadores de registro activos", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Registrar", + "queued_offline": "Registro en cola para cuando se restaure la conexión", + "quick_check_in": "Registro rápido", + "select_type": "Seleccionar tipo de registro", + "status_ok": "OK", + "status_overdue": "Vencido", + "status_warning": "Advertencia", + "tab_title": "Registro", + "timer_status": "Estado del temporizador", + "timers_disabled": "Los temporizadores de registro están desactivados para esta llamada", + "type_hazmat": "Materiales peligrosos", + "type_ic": "CI", + "type_par": "PAR", + "type_personnel": "Personal", + "type_rehab": "Rehabilitación", + "type_sector_rotation": "Rotación de sector", + "type_unit": "Unidad" + }, "common": { "add": "Añadir", "back": "Atrás", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Error al abrir la aplicación de mapas", "failed_to_set_current_call": "Error al establecer la llamada como llamada actual", "no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento", + "pin_address": "Address", "pin_color": "Color del pin", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Recentrar mapa", "set_as_current_call": "Establecer como llamada actual", - "view_call_details": "Ver detalles de la llamada" + "view_call_details": "Ver detalles de la llamada", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Capas Activas", @@ -660,12 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA a la siguiente parada", "exit": "Salida", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Radio de Geocerca", "history": "Historial de Ruta", "in_progress": "En Progreso", "instance_detail": "Instancia de Ruta", "loading": "Cargando rutas...", "loading_directions": "Cargando direcciones...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Cargando paradas...", "location": "Ubicación", "min": "min", @@ -676,6 +692,9 @@ "no_directions": "No hay direcciones disponibles", "no_history": "No hay historial de rutas disponible", "no_history_description": "Las rutas completadas aparecerán aquí.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Sin Rutas", "no_routes_description": "No hay planes de ruta disponibles para su unidad.", "no_routes_description_all": "No hay planes de ruta disponibles.", @@ -688,6 +707,21 @@ "pending": "Pendiente", "planned_arrival": "Llegada Planificada", "planned_departure": "Salida Planificada", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Prioridad", "priority_critical": "Crítica", "priority_high": "Alta", @@ -698,9 +732,13 @@ "remaining_steps": "Pasos Restantes", "resume_route": "Reanudar Ruta", "route_summary": "Resumen de Ruta", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Horario", "search": "Buscar rutas...", + "search_pois": "Search POIs...", "select_unit": "Seleccionar unidad", + "set_poi_destination": "Set Destination", "skip": "Omitir", "skip_reason": "Razón de Omisión", "skip_reason_placeholder": "Ingrese la razón para omitir esta parada", @@ -729,6 +767,7 @@ "unit": "Unidad", "unit_required": "Se debe seleccionar una unidad para iniciar la ruta", "view_contact": "Ver Contacto", + "view_on_map": "View on map", "view_route": "Ver Ruta" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Añadir Nota", "both_destinations_enabled": "Puede responder a llamadas o estaciones", "call_destination_enabled": "Puede responder a llamadas", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Llamadas", "failed_to_save_status": "Error al guardar el estado. Por favor, inténtelo de nuevo.", "general_status": "Estado general sin destino específico", + "loading_pois": "Loading POIs...", "loading_stations": "Cargando estaciones...", "no_destination": "Sin Destino", + "no_pois_available": "No POIs available", "no_stations_available": "No hay estaciones disponibles", "no_statuses_available": "No hay estados disponibles", "note": "Nota", "note_optional": "Añade una nota opcional para esta actualización de estado", "note_required": "Por favor ingresa una nota para esta actualización de estado", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Seleccionar Destino para {{status}}", "select_destination_type": "¿A dónde te gustaría responder?", "select_status": "Seleccionar Estado", @@ -842,6 +887,7 @@ "selected_status": "Estado Seleccionado", "set_status": "Establecer Estado", "station_destination_enabled": "Puede responder a estaciones", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Estaciones", "status_saved_successfully": "¡Estado guardado exitosamente!" }, @@ -855,72 +901,121 @@ "settings": "Configuración", "weather_alerts": "Clima" }, - "weather_alerts": { - "title": "Alertas Meteorológicas", - "loading": "Cargando alertas meteorológicas...", - "no_alerts": "Sin Alertas Meteorológicas", - "no_alerts_description": "No hay alertas meteorológicas activas para su área.", - "feature_disabled": "Alertas Meteorológicas Deshabilitadas", - "feature_disabled_description": "Las alertas meteorológicas no están habilitadas para su departamento.", - "search": "Buscar alertas meteorológicas...", - "severity": { "extreme": "Extremo", "severe": "Severo", "moderate": "Moderado", "minor": "Menor", "unknown": "Desconocido" }, - "category": { "met": "Meteorológico", "fire": "Incendio", "health": "Salud", "env": "Ambiental", "other": "Otro" }, - "urgency": { "immediate": "Inmediato", "expected": "Esperado", "future": "Futuro", "past": "Pasado", "unknown": "Desconocido" }, - "certainty": { "observed": "Observado", "likely": "Probable", "possible": "Posible", "unlikely": "Improbable", "unknown": "Desconocido" }, - "status": { "active": "Activo", "updated": "Actualizado", "expired": "Expirado", "cancelled": "Cancelado" }, - "detail": { "headline": "Titular", "description": "Descripción", "instructions": "Instrucciones", "area": "Área Afectada", "effective": "Efectivo", "onset": "Inicio", "expires": "Expira", "sender": "Remitente", "urgency": "Urgencia", "certainty": "Certeza" }, - "filter": { "all": "Todos", "nearby": "Cercanos" }, - "sort": { "severity": "Severidad", "expires": "Por expirar", "newest": "Más recientes" }, - "banner": { "more_alerts": "+{{count}} más" } - }, - "welcome": "Bienvenido al sitio de la aplicación obytes", "video_feeds": { - "tab_title": "Video", - "no_feeds": "No hay transmisiones de video disponibles", "add_feed": "Agregar transmisión", - "edit_feed": "Editar transmisión", - "watch": "Ver", + "added_by": "Agregado por", + "added_on": "Agregado el", "copy_url": "Copiar URL", - "url_copied": "URL copiada al portapapeles", - "delete_feed": "Eliminar transmisión", - "delete_confirm_title": "Eliminar transmisión de video", "delete_confirm_message": "¿Está seguro de que desea eliminar esta transmisión de video?", - "save_success": "Transmisión de video guardada exitosamente", - "save_error": "Error al guardar la transmisión de video", - "delete_success": "Transmisión de video eliminada exitosamente", + "delete_confirm_title": "Eliminar transmisión de video", "delete_error": "Error al eliminar la transmisión de video", - "name": "Nombre", - "url": "URL", - "feed_type": "Tipo de transmisión", - "feed_format": "Formato de transmisión", + "delete_feed": "Eliminar transmisión", + "delete_success": "Transmisión de video eliminada exitosamente", "description": "Descripción", + "edit_feed": "Editar transmisión", + "feed_format": "Formato de transmisión", + "feed_type": "Tipo de transmisión", + "format_dash": "DASH", + "format_embed": "Embebido", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Otro", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube en vivo", "latitude": "Latitud", + "loading_video": "Cargando video...", "longitude": "Longitud", - "added_by": "Agregado por", - "added_on": "Agregado el", + "name": "Nombre", + "no_feeds": "No hay transmisiones de video disponibles", + "player_title": "Reproductor de video", + "rtsp_not_supported": "Las transmisiones RTSP no se pueden reproducir directamente. Copie la URL para usar en un reproductor dedicado.", + "save_error": "Error al guardar la transmisión de video", + "save_success": "Transmisión de video guardada exitosamente", "status_active": "Activo", - "status_inactive": "Inactivo", "status_error": "Error", + "status_inactive": "Inactivo", + "tab_title": "Video", + "type_body_cam": "Cámara corporal", "type_drone": "Dron", "type_fixed_camera": "Cámara fija", - "type_body_cam": "Cámara corporal", + "type_other": "Otro", + "type_satellite_feed": "Transmisión satelital", "type_traffic_cam": "Cámara de tráfico", "type_weather_cam": "Cámara meteorológica", - "type_satellite_feed": "Transmisión satelital", "type_web_cam": "Cámara web", - "type_other": "Otro", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube en vivo", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Embebido", - "format_other": "Otro", - "rtsp_not_supported": "Las transmisiones RTSP no se pueden reproducir directamente. Copie la URL para usar en un reproductor dedicado.", - "webrtc_not_supported": "Reproducción WebRTC próximamente", - "player_title": "Reproductor de video", - "loading_video": "Cargando video...", - "video_error": "Error al cargar el video" - } + "url": "URL", + "url_copied": "URL copiada al portapapeles", + "video_error": "Error al cargar el video", + "watch": "Ver", + "webrtc_not_supported": "Reproducción WebRTC próximamente" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} más" + }, + "category": { + "env": "Ambiental", + "fire": "Incendio", + "health": "Salud", + "met": "Meteorológico", + "other": "Otro" + }, + "certainty": { + "likely": "Probable", + "observed": "Observado", + "possible": "Posible", + "unknown": "Desconocido", + "unlikely": "Improbable" + }, + "detail": { + "area": "Área Afectada", + "certainty": "Certeza", + "description": "Descripción", + "effective": "Efectivo", + "expires": "Expira", + "headline": "Titular", + "instructions": "Instrucciones", + "onset": "Inicio", + "sender": "Remitente", + "urgency": "Urgencia" + }, + "feature_disabled": "Alertas Meteorológicas Deshabilitadas", + "feature_disabled_description": "Las alertas meteorológicas no están habilitadas para su departamento.", + "filter": { + "all": "Todos", + "nearby": "Cercanos" + }, + "loading": "Cargando alertas meteorológicas...", + "no_alerts": "Sin Alertas Meteorológicas", + "no_alerts_description": "No hay alertas meteorológicas activas para su área.", + "search": "Buscar alertas meteorológicas...", + "severity": { + "extreme": "Extremo", + "minor": "Menor", + "moderate": "Moderado", + "severe": "Severo", + "unknown": "Desconocido" + }, + "sort": { + "expires": "Por expirar", + "newest": "Más recientes", + "severity": "Severidad" + }, + "status": { + "active": "Activo", + "cancelled": "Cancelado", + "expired": "Expirado", + "updated": "Actualizado" + }, + "title": "Alertas Meteorológicas", + "urgency": { + "expected": "Esperado", + "future": "Futuro", + "immediate": "Inmediato", + "past": "Pasado", + "unknown": "Desconocido" + } + }, + "welcome": "Bienvenido al sitio de la aplicación obytes" } diff --git a/src/translations/fr.json b/src/translations/fr.json index 0bf6bd9..eb8b6aa 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -90,35 +90,6 @@ "volumeDown": "Volume -", "volumeUp": "Volume +" }, - "check_in": { - "tab_title": "Pointage", - "timer_status": "État du minuteur", - "perform_check_in": "Pointer", - "quick_check_in": "Pointage rapide", - "check_in_success": "Pointage enregistré avec succès", - "check_in_error": "Échec de l'enregistrement du pointage", - "last_check_in": "Dernier pointage", - "elapsed": "Écoulé", - "duration": "min", - "status_ok": "OK", - "status_warning": "Avertissement", - "status_overdue": "En retard", - "history": "Historique", - "no_timers": "Aucun minuteur de pointage actif", - "timers_disabled": "Les minuteurs de pointage sont désactivés pour cet appel", - "type_personnel": "Personnel", - "type_unit": "Unité", - "type_ic": "CI", - "type_par": "PAR", - "type_hazmat": "Matières dangereuses", - "type_sector_rotation": "Rotation de secteur", - "type_rehab": "Réhabilitation", - "add_note": "Ajouter une note", - "confirm": "Confirmer le pointage", - "minutes_ago": "min", - "select_type": "Sélectionner le type de pointage", - "queued_offline": "Pointage mis en file d'attente pour la restauration de la connexion" - }, "callImages": { "add": "Ajouter une image", "add_new": "Ajouter une nouvelle image", @@ -173,6 +144,7 @@ "contact_info": "Infos contact", "contact_name": "Nom du contact", "contact_phone": "Téléphone", + "destination": "Destination", "edit_call": "Modifier l'appel", "external_id": "ID externe", "failed_to_open_maps": "Échec de l'ouverture de l'application de cartes", @@ -218,11 +190,11 @@ "setting_active": "Définition comme actif...", "status": "Statut", "tabs": { + "check_in": "Pointage", "contact": "Contact", "dispatched": "Envoyé", "info": "Info", "protocols": "Protocoles", - "check_in": "Pointage", "timeline": "Activité" }, "timestamp": "Horodatage", @@ -265,6 +237,9 @@ "description": "Description", "description_placeholder": "Saisir la description de l'appel", "deselect": "Désélectionner", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Itinéraire", "dispatch_to": "Envoyer à", "dispatch_to_everyone": "Envoyer à tout le personnel disponible", @@ -282,6 +257,7 @@ "invalid_type": "Type invalide sélectionné. Veuillez sélectionner un type d'appel valide.", "loading": "Chargement des appels...", "loading_calls": "Chargement des appels...", + "loading_destination_pois": "Loading destination POIs...", "name": "Nom", "name_placeholder": "Saisir le nom de l'appel", "nature": "Nature", @@ -293,6 +269,7 @@ "no_calls": "Aucun appel actif", "no_calls_available": "Aucun appel disponible", "no_calls_description": "Aucun appel actif trouvé. Sélectionnez un appel actif pour voir les détails.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "Cet appel n'a pas de données de localisation disponibles pour la navigation.", "no_location_title": "Aucune localisation disponible", "no_open_calls": "Aucun appel ouvert disponible", @@ -312,6 +289,7 @@ "select_address": "Sélectionner l'adresse", "select_address_placeholder": "Sélectionner l'adresse de l'appel", "select_description": "Sélectionner la description", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Sélectionner les destinataires", "select_location": "Sélectionner un emplacement sur la carte", "select_name": "Sélectionner le nom", @@ -336,6 +314,37 @@ "what3words_placeholder": "Saisir l'adresse what3words (ex. filled.count.soap)", "what3words_required": "Veuillez saisir une adresse what3words à rechercher" }, + "check_in": { + "add_note": "Ajouter une note", + "check_in_error": "Échec de l'enregistrement du pointage", + "check_in_success": "Pointage enregistré avec succès", + "confirm": "Confirmer le pointage", + "duration": "min", + "elapsed": "Écoulé", + "history": "Historique", + "last_check_in": "Dernier pointage", + "minutes_ago": "min", + "no_timers": "Aucun minuteur de pointage actif", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Pointer", + "queued_offline": "Pointage mis en file d'attente pour la restauration de la connexion", + "quick_check_in": "Pointage rapide", + "select_type": "Sélectionner le type de pointage", + "status_ok": "OK", + "status_overdue": "En retard", + "status_warning": "Avertissement", + "tab_title": "Pointage", + "timer_status": "État du minuteur", + "timers_disabled": "Les minuteurs de pointage sont désactivés pour cet appel", + "type_hazmat": "Matières dangereuses", + "type_ic": "CI", + "type_par": "PAR", + "type_personnel": "Personnel", + "type_rehab": "Réhabilitation", + "type_sector_rotation": "Rotation de secteur", + "type_unit": "Unité" + }, "common": { "add": "Ajouter", "back": "Retour", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Échec de l'ouverture de l'application de cartes", "failed_to_set_current_call": "Échec de la définition de l'appel comme actuel", "no_location_for_routing": "Aucune donnée de localisation disponible pour la navigation", + "pin_address": "Address", "pin_color": "Couleur de l'épingle", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Recentrer la carte", "set_as_current_call": "Définir comme appel actuel", - "view_call_details": "Voir les détails de l'appel" + "view_call_details": "Voir les détails de l'appel", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Couches actives", @@ -660,12 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA vers le prochain arrêt", "exit": "Sortie", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Rayon de géorepérage", "history": "Historique des itinéraires", "in_progress": "En cours", "instance_detail": "Instance d'itinéraire", "loading": "Chargement des itinéraires...", "loading_directions": "Chargement de l'itinéraire...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Chargement des arrêts...", "location": "Emplacement", "min": "min", @@ -676,6 +692,9 @@ "no_directions": "Aucun itinéraire disponible", "no_history": "Aucun historique d'itinéraire disponible", "no_history_description": "Les itinéraires terminés apparaîtront ici.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Aucun itinéraire", "no_routes_description": "Aucun plan d'itinéraire disponible pour votre unité.", "no_routes_description_all": "Aucun plan d'itinéraire disponible.", @@ -688,6 +707,21 @@ "pending": "En attente", "planned_arrival": "Arrivée prévue", "planned_departure": "Départ prévu", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Priorité", "priority_critical": "Critique", "priority_high": "Élevé", @@ -698,9 +732,13 @@ "remaining_steps": "Étapes restantes", "resume_route": "Reprendre l'itinéraire", "route_summary": "Résumé de l'itinéraire", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Calendrier", "search": "Rechercher des itinéraires...", + "search_pois": "Search POIs...", "select_unit": "Sélectionner l'unité", + "set_poi_destination": "Set Destination", "skip": "Ignorer", "skip_reason": "Raison de l'ignorance", "skip_reason_placeholder": "Saisir la raison d'ignorer cet arrêt", @@ -729,6 +767,7 @@ "unit": "Unité", "unit_required": "Une unité doit être sélectionnée pour démarrer l'itinéraire", "view_contact": "Voir le contact", + "view_on_map": "View on map", "view_route": "Voir l'itinéraire" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Ajouter une note", "both_destinations_enabled": "Peut répondre aux appels ou aux stations", "call_destination_enabled": "Peut répondre aux appels", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Appels", "failed_to_save_status": "Échec de l'enregistrement du statut. Veuillez réessayer.", "general_status": "Statut général sans destination spécifique", + "loading_pois": "Loading POIs...", "loading_stations": "Chargement des stations...", "no_destination": "Aucune destination", + "no_pois_available": "No POIs available", "no_stations_available": "Aucune station disponible", "no_statuses_available": "Aucun statut disponible", "note": "Note", "note_optional": "Ajouter une note optionnelle pour cette mise à jour de statut", "note_required": "Veuillez saisir une note pour cette mise à jour de statut", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Sélectionner la destination pour {{status}}", "select_destination_type": "Où souhaitez-vous intervenir ?", "select_status": "Sélectionner le statut", @@ -842,6 +887,7 @@ "selected_status": "Statut sélectionné", "set_status": "Définir le statut", "station_destination_enabled": "Peut répondre aux stations", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stations", "status_saved_successfully": "Statut enregistré avec succès !" }, @@ -855,72 +901,121 @@ "settings": "Paramètres", "weather_alerts": "Météo" }, - "weather_alerts": { - "title": "Alertes Météo", - "loading": "Chargement des alertes météo...", - "no_alerts": "Aucune Alerte Météo", - "no_alerts_description": "Il n'y a aucune alerte météo active pour votre zone.", - "feature_disabled": "Alertes Météo Désactivées", - "feature_disabled_description": "Les alertes météo ne sont pas activées pour votre département.", - "search": "Rechercher des alertes météo...", - "severity": { "extreme": "Extrême", "severe": "Sévère", "moderate": "Modéré", "minor": "Mineur", "unknown": "Inconnu" }, - "category": { "met": "Météorologique", "fire": "Incendie", "health": "Santé", "env": "Environnemental", "other": "Autre" }, - "urgency": { "immediate": "Immédiat", "expected": "Attendu", "future": "Futur", "past": "Passé", "unknown": "Inconnu" }, - "certainty": { "observed": "Observé", "likely": "Probable", "possible": "Possible", "unlikely": "Improbable", "unknown": "Inconnu" }, - "status": { "active": "Actif", "updated": "Mis à jour", "expired": "Expiré", "cancelled": "Annulé" }, - "detail": { "headline": "Titre", "description": "Description", "instructions": "Instructions", "area": "Zone Affectée", "effective": "Effectif", "onset": "Début", "expires": "Expire", "sender": "Expéditeur", "urgency": "Urgence", "certainty": "Certitude" }, - "filter": { "all": "Tous", "nearby": "À proximité" }, - "sort": { "severity": "Sévérité", "expires": "Expire bientôt", "newest": "Plus récents" }, - "banner": { "more_alerts": "+{{count}} de plus" } - }, - "welcome": "Bienvenue sur l'application obytes", "video_feeds": { - "tab_title": "Vidéo", - "no_feeds": "Aucun flux vidéo disponible", "add_feed": "Ajouter un flux", - "edit_feed": "Modifier le flux", - "watch": "Regarder", + "added_by": "Ajouté par", + "added_on": "Ajouté le", "copy_url": "Copier l'URL", - "url_copied": "URL copiée dans le presse-papiers", - "delete_feed": "Supprimer le flux", - "delete_confirm_title": "Supprimer le flux vidéo", "delete_confirm_message": "Êtes-vous sûr de vouloir supprimer ce flux vidéo ?", - "save_success": "Flux vidéo enregistré avec succès", - "save_error": "Échec de l'enregistrement du flux vidéo", - "delete_success": "Flux vidéo supprimé avec succès", + "delete_confirm_title": "Supprimer le flux vidéo", "delete_error": "Échec de la suppression du flux vidéo", - "name": "Nom", - "url": "URL", - "feed_type": "Type de flux", - "feed_format": "Format du flux", + "delete_feed": "Supprimer le flux", + "delete_success": "Flux vidéo supprimé avec succès", "description": "Description", + "edit_feed": "Modifier le flux", + "feed_format": "Format du flux", + "feed_type": "Type de flux", + "format_dash": "DASH", + "format_embed": "Intégré", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Autre", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube Live", "latitude": "Latitude", + "loading_video": "Chargement de la vidéo...", "longitude": "Longitude", - "added_by": "Ajouté par", - "added_on": "Ajouté le", + "name": "Nom", + "no_feeds": "Aucun flux vidéo disponible", + "player_title": "Lecteur vidéo", + "rtsp_not_supported": "Les flux RTSP ne peuvent pas être lus directement. Copiez l'URL pour l'utiliser dans un lecteur dédié.", + "save_error": "Échec de l'enregistrement du flux vidéo", + "save_success": "Flux vidéo enregistré avec succès", "status_active": "Actif", - "status_inactive": "Inactif", "status_error": "Erreur", + "status_inactive": "Inactif", + "tab_title": "Vidéo", + "type_body_cam": "Caméra corporelle", "type_drone": "Drone", "type_fixed_camera": "Caméra fixe", - "type_body_cam": "Caméra corporelle", + "type_other": "Autre", + "type_satellite_feed": "Flux satellite", "type_traffic_cam": "Caméra de circulation", "type_weather_cam": "Caméra météo", - "type_satellite_feed": "Flux satellite", "type_web_cam": "Webcam", - "type_other": "Autre", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube Live", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Intégré", - "format_other": "Autre", - "rtsp_not_supported": "Les flux RTSP ne peuvent pas être lus directement. Copiez l'URL pour l'utiliser dans un lecteur dédié.", - "webrtc_not_supported": "Lecture WebRTC bientôt disponible", - "player_title": "Lecteur vidéo", - "loading_video": "Chargement de la vidéo...", - "video_error": "Échec du chargement de la vidéo" - } + "url": "URL", + "url_copied": "URL copiée dans le presse-papiers", + "video_error": "Échec du chargement de la vidéo", + "watch": "Regarder", + "webrtc_not_supported": "Lecture WebRTC bientôt disponible" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} de plus" + }, + "category": { + "env": "Environnemental", + "fire": "Incendie", + "health": "Santé", + "met": "Météorologique", + "other": "Autre" + }, + "certainty": { + "likely": "Probable", + "observed": "Observé", + "possible": "Possible", + "unknown": "Inconnu", + "unlikely": "Improbable" + }, + "detail": { + "area": "Zone Affectée", + "certainty": "Certitude", + "description": "Description", + "effective": "Effectif", + "expires": "Expire", + "headline": "Titre", + "instructions": "Instructions", + "onset": "Début", + "sender": "Expéditeur", + "urgency": "Urgence" + }, + "feature_disabled": "Alertes Météo Désactivées", + "feature_disabled_description": "Les alertes météo ne sont pas activées pour votre département.", + "filter": { + "all": "Tous", + "nearby": "À proximité" + }, + "loading": "Chargement des alertes météo...", + "no_alerts": "Aucune Alerte Météo", + "no_alerts_description": "Il n'y a aucune alerte météo active pour votre zone.", + "search": "Rechercher des alertes météo...", + "severity": { + "extreme": "Extrême", + "minor": "Mineur", + "moderate": "Modéré", + "severe": "Sévère", + "unknown": "Inconnu" + }, + "sort": { + "expires": "Expire bientôt", + "newest": "Plus récents", + "severity": "Sévérité" + }, + "status": { + "active": "Actif", + "cancelled": "Annulé", + "expired": "Expiré", + "updated": "Mis à jour" + }, + "title": "Alertes Météo", + "urgency": { + "expected": "Attendu", + "future": "Futur", + "immediate": "Immédiat", + "past": "Passé", + "unknown": "Inconnu" + } + }, + "welcome": "Bienvenue sur l'application obytes" } diff --git a/src/translations/it.json b/src/translations/it.json index 34e4f58..0cf8d75 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -90,35 +90,6 @@ "volumeDown": "Volume -", "volumeUp": "Volume +" }, - "check_in": { - "tab_title": "Check-In", - "timer_status": "Stato timer", - "perform_check_in": "Registra", - "quick_check_in": "Check-In rapido", - "check_in_success": "Check-in registrato con successo", - "check_in_error": "Impossibile registrare il check-in", - "last_check_in": "Ultimo check-in", - "elapsed": "Trascorso", - "duration": "min", - "status_ok": "OK", - "status_warning": "Avviso", - "status_overdue": "Scaduto", - "history": "Cronologia", - "no_timers": "Nessun timer di check-in attivo", - "timers_disabled": "I timer di check-in sono disabilitati per questa chiamata", - "type_personnel": "Personale", - "type_unit": "Unità", - "type_ic": "CI", - "type_par": "PAR", - "type_hazmat": "Materiali pericolosi", - "type_sector_rotation": "Rotazione settore", - "type_rehab": "Riabilitazione", - "add_note": "Aggiungi nota", - "confirm": "Conferma check-in", - "minutes_ago": "min fa", - "select_type": "Seleziona tipo di check-in", - "queued_offline": "Check-in in coda per quando la connessione sarà ripristinata" - }, "callImages": { "add": "Aggiungi immagine", "add_new": "Aggiungi nuova immagine", @@ -173,6 +144,7 @@ "contact_info": "Info contatto", "contact_name": "Nome contatto", "contact_phone": "Telefono", + "destination": "Destination", "edit_call": "Modifica chiamata", "external_id": "ID esterno", "failed_to_open_maps": "Impossibile aprire l'applicazione mappe", @@ -218,11 +190,11 @@ "setting_active": "Impostazione attiva...", "status": "Stato", "tabs": { + "check_in": "Check-In", "contact": "Contatto", "dispatched": "Inviato", "info": "Info", "protocols": "Protocolli", - "check_in": "Check-In", "timeline": "Attività" }, "timestamp": "Timestamp", @@ -265,6 +237,9 @@ "description": "Descrizione", "description_placeholder": "Inserisci la descrizione della chiamata", "deselect": "Deseleziona", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Indicazioni", "dispatch_to": "Invia a", "dispatch_to_everyone": "Invia a tutto il personale disponibile", @@ -282,6 +257,7 @@ "invalid_type": "Tipo non valido selezionato. Selezionare un tipo di chiamata valido.", "loading": "Caricamento chiamate...", "loading_calls": "Caricamento chiamate...", + "loading_destination_pois": "Loading destination POIs...", "name": "Nome", "name_placeholder": "Inserisci il nome della chiamata", "nature": "Natura", @@ -293,6 +269,7 @@ "no_calls": "Nessuna chiamata attiva", "no_calls_available": "Nessuna chiamata disponibile", "no_calls_description": "Nessuna chiamata attiva trovata. Seleziona una chiamata attiva per visualizzare i dettagli.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "Questa chiamata non dispone di dati di posizione per la navigazione.", "no_location_title": "Nessuna posizione disponibile", "no_open_calls": "Nessuna chiamata aperta disponibile", @@ -312,6 +289,7 @@ "select_address": "Seleziona indirizzo", "select_address_placeholder": "Seleziona l'indirizzo della chiamata", "select_description": "Seleziona descrizione", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Seleziona destinatari dell'invio", "select_location": "Seleziona posizione sulla mappa", "select_name": "Seleziona nome", @@ -336,6 +314,37 @@ "what3words_placeholder": "Inserisci indirizzo what3words (es. filled.count.soap)", "what3words_required": "Inserisci un indirizzo what3words da cercare" }, + "check_in": { + "add_note": "Aggiungi nota", + "check_in_error": "Impossibile registrare il check-in", + "check_in_success": "Check-in registrato con successo", + "confirm": "Conferma check-in", + "duration": "min", + "elapsed": "Trascorso", + "history": "Cronologia", + "last_check_in": "Ultimo check-in", + "minutes_ago": "min fa", + "no_timers": "Nessun timer di check-in attivo", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Registra", + "queued_offline": "Check-in in coda per quando la connessione sarà ripristinata", + "quick_check_in": "Check-In rapido", + "select_type": "Seleziona tipo di check-in", + "status_ok": "OK", + "status_overdue": "Scaduto", + "status_warning": "Avviso", + "tab_title": "Check-In", + "timer_status": "Stato timer", + "timers_disabled": "I timer di check-in sono disabilitati per questa chiamata", + "type_hazmat": "Materiali pericolosi", + "type_ic": "CI", + "type_par": "PAR", + "type_personnel": "Personale", + "type_rehab": "Riabilitazione", + "type_sector_rotation": "Rotazione settore", + "type_unit": "Unità" + }, "common": { "add": "Aggiungi", "back": "Indietro", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Impossibile aprire l'applicazione mappe", "failed_to_set_current_call": "Impossibile impostare la chiamata come corrente", "no_location_for_routing": "Nessun dato di posizione disponibile per la navigazione", + "pin_address": "Address", "pin_color": "Colore pin", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Ricentra mappa", "set_as_current_call": "Imposta come chiamata corrente", - "view_call_details": "Visualizza dettagli chiamata" + "view_call_details": "Visualizza dettagli chiamata", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Livelli attivi", @@ -660,12 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA prossima fermata", "exit": "Uscita", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Raggio geofence", "history": "Cronologia percorsi", "in_progress": "In corso", "instance_detail": "Istanza percorso", "loading": "Caricamento percorsi...", "loading_directions": "Caricamento indicazioni...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Caricamento fermate...", "location": "Posizione", "min": "min", @@ -676,6 +692,9 @@ "no_directions": "Nessuna indicazione disponibile", "no_history": "Nessuna cronologia percorsi disponibile", "no_history_description": "I percorsi completati appariranno qui.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Nessun percorso", "no_routes_description": "Nessun piano di percorso disponibile per la tua unità.", "no_routes_description_all": "Nessun piano di percorso disponibile.", @@ -688,6 +707,21 @@ "pending": "In attesa", "planned_arrival": "Arrivo pianificato", "planned_departure": "Partenza pianificata", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Priorità", "priority_critical": "Critico", "priority_high": "Alto", @@ -698,9 +732,13 @@ "remaining_steps": "Passi rimanenti", "resume_route": "Riprendi percorso", "route_summary": "Riepilogo percorso", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Programma", "search": "Cerca percorsi...", + "search_pois": "Search POIs...", "select_unit": "Seleziona unità", + "set_poi_destination": "Set Destination", "skip": "Salta", "skip_reason": "Motivo salto", "skip_reason_placeholder": "Inserisci il motivo per saltare questa fermata", @@ -729,6 +767,7 @@ "unit": "Unità", "unit_required": "È necessario selezionare un'unità per avviare il percorso", "view_contact": "Visualizza contatto", + "view_on_map": "View on map", "view_route": "Visualizza percorso" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Aggiungi nota", "both_destinations_enabled": "Può rispondere a chiamate o stazioni", "call_destination_enabled": "Può rispondere a chiamate", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Chiamate", "failed_to_save_status": "Impossibile salvare lo stato. Riprovare.", "general_status": "Stato generale senza destinazione specifica", + "loading_pois": "Loading POIs...", "loading_stations": "Caricamento stazioni...", "no_destination": "Nessuna destinazione", + "no_pois_available": "No POIs available", "no_stations_available": "Nessuna stazione disponibile", "no_statuses_available": "Nessuno stato disponibile", "note": "Nota", "note_optional": "Aggiungi una nota opzionale per questo aggiornamento di stato", "note_required": "Inserisci una nota per questo aggiornamento di stato", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Seleziona destinazione per {{status}}", "select_destination_type": "Dove vorresti rispondere?", "select_status": "Seleziona stato", @@ -842,6 +887,7 @@ "selected_status": "Stato selezionato", "set_status": "Imposta stato", "station_destination_enabled": "Può rispondere a stazioni", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stazioni", "status_saved_successfully": "Stato salvato con successo!" }, @@ -855,72 +901,121 @@ "settings": "Impostazioni", "weather_alerts": "Meteo" }, - "weather_alerts": { - "title": "Allerte Meteo", - "loading": "Caricamento allerte meteo...", - "no_alerts": "Nessuna Allerta Meteo", - "no_alerts_description": "Non ci sono allerte meteo attive per la tua area.", - "feature_disabled": "Allerte Meteo Disabilitate", - "feature_disabled_description": "Le allerte meteo non sono abilitate per il tuo dipartimento.", - "search": "Cerca allerte meteo...", - "severity": { "extreme": "Estremo", "severe": "Grave", "moderate": "Moderato", "minor": "Lieve", "unknown": "Sconosciuto" }, - "category": { "met": "Meteorologico", "fire": "Incendio", "health": "Salute", "env": "Ambientale", "other": "Altro" }, - "urgency": { "immediate": "Immediato", "expected": "Previsto", "future": "Futuro", "past": "Passato", "unknown": "Sconosciuto" }, - "certainty": { "observed": "Osservato", "likely": "Probabile", "possible": "Possibile", "unlikely": "Improbabile", "unknown": "Sconosciuto" }, - "status": { "active": "Attivo", "updated": "Aggiornato", "expired": "Scaduto", "cancelled": "Annullato" }, - "detail": { "headline": "Titolo", "description": "Descrizione", "instructions": "Istruzioni", "area": "Area Interessata", "effective": "Effettivo", "onset": "Inizio", "expires": "Scade", "sender": "Mittente", "urgency": "Urgenza", "certainty": "Certezza" }, - "filter": { "all": "Tutti", "nearby": "Vicini" }, - "sort": { "severity": "Gravità", "expires": "In scadenza", "newest": "Più recenti" }, - "banner": { "more_alerts": "+{{count}} altre" } - }, - "welcome": "Benvenuto nell'app obytes", "video_feeds": { - "tab_title": "Video", - "no_feeds": "Nessun feed video disponibile", "add_feed": "Aggiungi feed", - "edit_feed": "Modifica feed", - "watch": "Guarda", + "added_by": "Aggiunto da", + "added_on": "Aggiunto il", "copy_url": "Copia URL", - "url_copied": "URL copiato negli appunti", - "delete_feed": "Elimina feed", - "delete_confirm_title": "Elimina feed video", "delete_confirm_message": "Sei sicuro di voler eliminare questo feed video?", - "save_success": "Feed video salvato con successo", - "save_error": "Impossibile salvare il feed video", - "delete_success": "Feed video eliminato con successo", + "delete_confirm_title": "Elimina feed video", "delete_error": "Impossibile eliminare il feed video", - "name": "Nome", - "url": "URL", - "feed_type": "Tipo di feed", - "feed_format": "Formato del feed", + "delete_feed": "Elimina feed", + "delete_success": "Feed video eliminato con successo", "description": "Descrizione", + "edit_feed": "Modifica feed", + "feed_format": "Formato del feed", + "feed_type": "Tipo di feed", + "format_dash": "DASH", + "format_embed": "Incorporato", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Altro", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube Live", "latitude": "Latitudine", + "loading_video": "Caricamento video...", "longitude": "Longitudine", - "added_by": "Aggiunto da", - "added_on": "Aggiunto il", + "name": "Nome", + "no_feeds": "Nessun feed video disponibile", + "player_title": "Lettore video", + "rtsp_not_supported": "I flussi RTSP non possono essere riprodotti direttamente. Copia l'URL per utilizzarlo in un lettore dedicato.", + "save_error": "Impossibile salvare il feed video", + "save_success": "Feed video salvato con successo", "status_active": "Attivo", - "status_inactive": "Inattivo", "status_error": "Errore", + "status_inactive": "Inattivo", + "tab_title": "Video", + "type_body_cam": "Bodycam", "type_drone": "Drone", "type_fixed_camera": "Telecamera fissa", - "type_body_cam": "Bodycam", + "type_other": "Altro", + "type_satellite_feed": "Feed satellitare", "type_traffic_cam": "Telecamera traffico", "type_weather_cam": "Telecamera meteo", - "type_satellite_feed": "Feed satellitare", "type_web_cam": "Webcam", - "type_other": "Altro", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube Live", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Incorporato", - "format_other": "Altro", - "rtsp_not_supported": "I flussi RTSP non possono essere riprodotti direttamente. Copia l'URL per utilizzarlo in un lettore dedicato.", - "webrtc_not_supported": "Riproduzione WebRTC in arrivo", - "player_title": "Lettore video", - "loading_video": "Caricamento video...", - "video_error": "Impossibile caricare il video" - } + "url": "URL", + "url_copied": "URL copiato negli appunti", + "video_error": "Impossibile caricare il video", + "watch": "Guarda", + "webrtc_not_supported": "Riproduzione WebRTC in arrivo" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} altre" + }, + "category": { + "env": "Ambientale", + "fire": "Incendio", + "health": "Salute", + "met": "Meteorologico", + "other": "Altro" + }, + "certainty": { + "likely": "Probabile", + "observed": "Osservato", + "possible": "Possibile", + "unknown": "Sconosciuto", + "unlikely": "Improbabile" + }, + "detail": { + "area": "Area Interessata", + "certainty": "Certezza", + "description": "Descrizione", + "effective": "Effettivo", + "expires": "Scade", + "headline": "Titolo", + "instructions": "Istruzioni", + "onset": "Inizio", + "sender": "Mittente", + "urgency": "Urgenza" + }, + "feature_disabled": "Allerte Meteo Disabilitate", + "feature_disabled_description": "Le allerte meteo non sono abilitate per il tuo dipartimento.", + "filter": { + "all": "Tutti", + "nearby": "Vicini" + }, + "loading": "Caricamento allerte meteo...", + "no_alerts": "Nessuna Allerta Meteo", + "no_alerts_description": "Non ci sono allerte meteo attive per la tua area.", + "search": "Cerca allerte meteo...", + "severity": { + "extreme": "Estremo", + "minor": "Lieve", + "moderate": "Moderato", + "severe": "Grave", + "unknown": "Sconosciuto" + }, + "sort": { + "expires": "In scadenza", + "newest": "Più recenti", + "severity": "Gravità" + }, + "status": { + "active": "Attivo", + "cancelled": "Annullato", + "expired": "Scaduto", + "updated": "Aggiornato" + }, + "title": "Allerte Meteo", + "urgency": { + "expected": "Previsto", + "future": "Futuro", + "immediate": "Immediato", + "past": "Passato", + "unknown": "Sconosciuto" + } + }, + "welcome": "Benvenuto nell'app obytes" } diff --git a/src/translations/pl.json b/src/translations/pl.json index e58a284..e383769 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -90,35 +90,6 @@ "volumeDown": "Głośność -", "volumeUp": "Głośność +" }, - "check_in": { - "tab_title": "Meldunek", - "timer_status": "Status timera", - "perform_check_in": "Zamelduj się", - "quick_check_in": "Szybki meldunek", - "check_in_success": "Meldunek zarejestrowany pomyślnie", - "check_in_error": "Nie udało się zarejestrować meldunku", - "last_check_in": "Ostatni meldunek", - "elapsed": "Upłynęło", - "duration": "min", - "status_ok": "OK", - "status_warning": "Ostrzeżenie", - "status_overdue": "Zaległy", - "history": "Historia", - "no_timers": "Brak aktywnych timerów meldunków", - "timers_disabled": "Timery meldunków są wyłączone dla tego zgłoszenia", - "type_personnel": "Personel", - "type_unit": "Jednostka", - "type_ic": "KI", - "type_par": "PAR", - "type_hazmat": "Materiały niebezpieczne", - "type_sector_rotation": "Rotacja sektorów", - "type_rehab": "Rehabilitacja", - "add_note": "Dodaj notatkę", - "confirm": "Potwierdź meldunek", - "minutes_ago": "min temu", - "select_type": "Wybierz typ meldunku", - "queued_offline": "Meldunek zakolejkowany do momentu przywrócenia połączenia" - }, "callImages": { "add": "Dodaj zdjęcie", "add_new": "Dodaj nowe zdjęcie", @@ -173,6 +144,7 @@ "contact_info": "Dane kontaktowe", "contact_name": "Nazwa kontaktu", "contact_phone": "Telefon", + "destination": "Destination", "edit_call": "Edytuj zgłoszenie", "external_id": "Zewnętrzny ID", "failed_to_open_maps": "Nie udało się otworzyć aplikacji map", @@ -218,11 +190,11 @@ "setting_active": "Ustawianie jako aktywne...", "status": "Status", "tabs": { + "check_in": "Meldunek", "contact": "Kontakt", "dispatched": "Wysłane", "info": "Info", "protocols": "Protokoły", - "check_in": "Meldunek", "timeline": "Aktywność" }, "timestamp": "Znacznik czasu", @@ -265,6 +237,9 @@ "description": "Opis", "description_placeholder": "Wpisz opis zgłoszenia", "deselect": "Odznacz", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Wskazówki", "dispatch_to": "Wyślij do", "dispatch_to_everyone": "Wyślij do wszystkich dostępnych", @@ -282,6 +257,7 @@ "invalid_type": "Wybrano nieprawidłowy typ. Wybierz prawidłowy typ zgłoszenia.", "loading": "Ładowanie zgłoszeń...", "loading_calls": "Ładowanie zgłoszeń...", + "loading_destination_pois": "Loading destination POIs...", "name": "Nazwa", "name_placeholder": "Wpisz nazwę zgłoszenia", "nature": "Charakter", @@ -293,6 +269,7 @@ "no_calls": "Brak aktywnych zgłoszeń", "no_calls_available": "Brak dostępnych zgłoszeń", "no_calls_description": "Nie znaleziono aktywnych zgłoszeń. Wybierz aktywne zgłoszenie, aby wyświetlić szczegóły.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "To zgłoszenie nie ma danych lokalizacji do nawigacji.", "no_location_title": "Brak dostępnej lokalizacji", "no_open_calls": "Brak otwartych zgłoszeń", @@ -312,6 +289,7 @@ "select_address": "Wybierz adres", "select_address_placeholder": "Wybierz adres zgłoszenia", "select_description": "Wybierz opis", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Wybierz odbiorców wysyłki", "select_location": "Wybierz lokalizację na mapie", "select_name": "Wybierz nazwę", @@ -336,6 +314,37 @@ "what3words_placeholder": "Wpisz adres what3words (np. filled.count.soap)", "what3words_required": "Wpisz adres what3words do wyszukania" }, + "check_in": { + "add_note": "Dodaj notatkę", + "check_in_error": "Nie udało się zarejestrować meldunku", + "check_in_success": "Meldunek zarejestrowany pomyślnie", + "confirm": "Potwierdź meldunek", + "duration": "min", + "elapsed": "Upłynęło", + "history": "Historia", + "last_check_in": "Ostatni meldunek", + "minutes_ago": "min temu", + "no_timers": "Brak aktywnych timerów meldunków", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Zamelduj się", + "queued_offline": "Meldunek zakolejkowany do momentu przywrócenia połączenia", + "quick_check_in": "Szybki meldunek", + "select_type": "Wybierz typ meldunku", + "status_ok": "OK", + "status_overdue": "Zaległy", + "status_warning": "Ostrzeżenie", + "tab_title": "Meldunek", + "timer_status": "Status timera", + "timers_disabled": "Timery meldunków są wyłączone dla tego zgłoszenia", + "type_hazmat": "Materiały niebezpieczne", + "type_ic": "KI", + "type_par": "PAR", + "type_personnel": "Personel", + "type_rehab": "Rehabilitacja", + "type_sector_rotation": "Rotacja sektorów", + "type_unit": "Jednostka" + }, "common": { "add": "Dodaj", "back": "Wstecz", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Nie udało się otworzyć aplikacji map", "failed_to_set_current_call": "Nie udało się ustawić zgłoszenia jako bieżące", "no_location_for_routing": "Brak danych lokalizacji do nawigacji", + "pin_address": "Address", "pin_color": "Kolor pinezki", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Wyśrodkuj mapę", "set_as_current_call": "Ustaw jako bieżące zgłoszenie", - "view_call_details": "Wyświetl szczegóły zgłoszenia" + "view_call_details": "Wyświetl szczegóły zgłoszenia", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Aktywne warstwy", @@ -660,12 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA do następnego postoju", "exit": "Wyjście", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Promień geofence", "history": "Historia tras", "in_progress": "W toku", "instance_detail": "Instancja trasy", "loading": "Ładowanie tras...", "loading_directions": "Ładowanie wskazówek...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Ładowanie postojów...", "location": "Lokalizacja", "min": "min", @@ -676,6 +692,9 @@ "no_directions": "Brak dostępnych wskazówek", "no_history": "Brak historii tras", "no_history_description": "Ukończone trasy będą tutaj widoczne.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Brak tras", "no_routes_description": "Brak planów tras dostępnych dla Twojej jednostki.", "no_routes_description_all": "Brak dostępnych planów tras.", @@ -688,6 +707,21 @@ "pending": "Oczekujące", "planned_arrival": "Planowany przyjazd", "planned_departure": "Planowany odjazd", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Priorytet", "priority_critical": "Krytyczny", "priority_high": "Wysoki", @@ -698,9 +732,13 @@ "remaining_steps": "Pozostałe kroki", "resume_route": "Wznów trasę", "route_summary": "Podsumowanie trasy", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Harmonogram", "search": "Szukaj tras...", + "search_pois": "Search POIs...", "select_unit": "Wybierz jednostkę", + "set_poi_destination": "Set Destination", "skip": "Pomiń", "skip_reason": "Powód pominięcia", "skip_reason_placeholder": "Wpisz powód pominięcia tego postoju", @@ -729,6 +767,7 @@ "unit": "Jednostka", "unit_required": "Aby rozpocząć trasę, należy wybrać jednostkę", "view_contact": "Wyświetl kontakt", + "view_on_map": "View on map", "view_route": "Wyświetl trasę" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Dodaj notatkę", "both_destinations_enabled": "Może reagować na zgłoszenia lub stacje", "call_destination_enabled": "Może reagować na zgłoszenia", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Zgłoszenia", "failed_to_save_status": "Nie udało się zapisać statusu. Spróbuj ponownie.", "general_status": "Ogólny status bez określonego celu", + "loading_pois": "Loading POIs...", "loading_stations": "Ładowanie stacji...", "no_destination": "Brak celu", + "no_pois_available": "No POIs available", "no_stations_available": "Brak dostępnych stacji", "no_statuses_available": "Brak dostępnych statusów", "note": "Notatka", "note_optional": "Dodaj opcjonalną notatkę do tej aktualizacji statusu", "note_required": "Wpisz notatkę do tej aktualizacji statusu", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Wybierz cel dla {{status}}", "select_destination_type": "Gdzie chcesz reagować?", "select_status": "Wybierz status", @@ -842,6 +887,7 @@ "selected_status": "Wybrany status", "set_status": "Ustaw status", "station_destination_enabled": "Może reagować na stacje", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stacje", "status_saved_successfully": "Status zapisany pomyślnie!" }, @@ -855,72 +901,121 @@ "settings": "Ustawienia", "weather_alerts": "Pogoda" }, - "weather_alerts": { - "title": "Alerty Pogodowe", - "loading": "Ładowanie alertów pogodowych...", - "no_alerts": "Brak Alertów Pogodowych", - "no_alerts_description": "Nie ma aktywnych alertów pogodowych dla Twojego obszaru.", - "feature_disabled": "Alerty Pogodowe Wyłączone", - "feature_disabled_description": "Alerty pogodowe nie są włączone dla Twojego działu.", - "search": "Szukaj alertów pogodowych...", - "severity": { "extreme": "Ekstremalny", "severe": "Poważny", "moderate": "Umiarkowany", "minor": "Niewielki", "unknown": "Nieznany" }, - "category": { "met": "Meteorologiczny", "fire": "Pożar", "health": "Zdrowie", "env": "Środowiskowy", "other": "Inny" }, - "urgency": { "immediate": "Natychmiastowy", "expected": "Oczekiwany", "future": "Przyszły", "past": "Przeszły", "unknown": "Nieznany" }, - "certainty": { "observed": "Zaobserwowany", "likely": "Prawdopodobny", "possible": "Możliwy", "unlikely": "Mało prawdopodobny", "unknown": "Nieznany" }, - "status": { "active": "Aktywny", "updated": "Zaktualizowany", "expired": "Wygasły", "cancelled": "Anulowany" }, - "detail": { "headline": "Nagłówek", "description": "Opis", "instructions": "Instrukcje", "area": "Dotknięty Obszar", "effective": "Obowiązuje od", "onset": "Początek", "expires": "Wygasa", "sender": "Nadawca", "urgency": "Pilność", "certainty": "Pewność" }, - "filter": { "all": "Wszystkie", "nearby": "W pobliżu" }, - "sort": { "severity": "Dotkliwość", "expires": "Wkrótce wygasające", "newest": "Najnowsze" }, - "banner": { "more_alerts": "+{{count}} więcej" } - }, - "welcome": "Witamy w aplikacji obytes", "video_feeds": { - "tab_title": "Wideo", - "no_feeds": "Brak dostępnych transmisji wideo", "add_feed": "Dodaj transmisję", - "edit_feed": "Edytuj transmisję", - "watch": "Oglądaj", + "added_by": "Dodane przez", + "added_on": "Dodano", "copy_url": "Kopiuj URL", - "url_copied": "URL skopiowany do schowka", - "delete_feed": "Usuń transmisję", - "delete_confirm_title": "Usuń transmisję wideo", "delete_confirm_message": "Czy na pewno chcesz usunąć tę transmisję wideo?", - "save_success": "Transmisja wideo zapisana pomyślnie", - "save_error": "Nie udało się zapisać transmisji wideo", - "delete_success": "Transmisja wideo usunięta pomyślnie", + "delete_confirm_title": "Usuń transmisję wideo", "delete_error": "Nie udało się usunąć transmisji wideo", - "name": "Nazwa", - "url": "URL", - "feed_type": "Typ transmisji", - "feed_format": "Format transmisji", + "delete_feed": "Usuń transmisję", + "delete_success": "Transmisja wideo usunięta pomyślnie", "description": "Opis", + "edit_feed": "Edytuj transmisję", + "feed_format": "Format transmisji", + "feed_type": "Typ transmisji", + "format_dash": "DASH", + "format_embed": "Osadzony", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Inne", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube na żywo", "latitude": "Szerokość geograficzna", + "loading_video": "Ładowanie wideo...", "longitude": "Długość geograficzna", - "added_by": "Dodane przez", - "added_on": "Dodano", + "name": "Nazwa", + "no_feeds": "Brak dostępnych transmisji wideo", + "player_title": "Odtwarzacz wideo", + "rtsp_not_supported": "Strumienie RTSP nie mogą być odtwarzane bezpośrednio. Skopiuj URL, aby użyć go w dedykowanym odtwarzaczu.", + "save_error": "Nie udało się zapisać transmisji wideo", + "save_success": "Transmisja wideo zapisana pomyślnie", "status_active": "Aktywny", - "status_inactive": "Nieaktywny", "status_error": "Błąd", + "status_inactive": "Nieaktywny", + "tab_title": "Wideo", + "type_body_cam": "Kamera nasobna", "type_drone": "Dron", "type_fixed_camera": "Kamera stała", - "type_body_cam": "Kamera nasobna", + "type_other": "Inne", + "type_satellite_feed": "Transmisja satelitarna", "type_traffic_cam": "Kamera drogowa", "type_weather_cam": "Kamera pogodowa", - "type_satellite_feed": "Transmisja satelitarna", "type_web_cam": "Kamera internetowa", - "type_other": "Inne", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube na żywo", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Osadzony", - "format_other": "Inne", - "rtsp_not_supported": "Strumienie RTSP nie mogą być odtwarzane bezpośrednio. Skopiuj URL, aby użyć go w dedykowanym odtwarzaczu.", - "webrtc_not_supported": "Odtwarzanie WebRTC wkrótce", - "player_title": "Odtwarzacz wideo", - "loading_video": "Ładowanie wideo...", - "video_error": "Nie udało się załadować wideo" - } + "url": "URL", + "url_copied": "URL skopiowany do schowka", + "video_error": "Nie udało się załadować wideo", + "watch": "Oglądaj", + "webrtc_not_supported": "Odtwarzanie WebRTC wkrótce" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} więcej" + }, + "category": { + "env": "Środowiskowy", + "fire": "Pożar", + "health": "Zdrowie", + "met": "Meteorologiczny", + "other": "Inny" + }, + "certainty": { + "likely": "Prawdopodobny", + "observed": "Zaobserwowany", + "possible": "Możliwy", + "unknown": "Nieznany", + "unlikely": "Mało prawdopodobny" + }, + "detail": { + "area": "Dotknięty Obszar", + "certainty": "Pewność", + "description": "Opis", + "effective": "Obowiązuje od", + "expires": "Wygasa", + "headline": "Nagłówek", + "instructions": "Instrukcje", + "onset": "Początek", + "sender": "Nadawca", + "urgency": "Pilność" + }, + "feature_disabled": "Alerty Pogodowe Wyłączone", + "feature_disabled_description": "Alerty pogodowe nie są włączone dla Twojego działu.", + "filter": { + "all": "Wszystkie", + "nearby": "W pobliżu" + }, + "loading": "Ładowanie alertów pogodowych...", + "no_alerts": "Brak Alertów Pogodowych", + "no_alerts_description": "Nie ma aktywnych alertów pogodowych dla Twojego obszaru.", + "search": "Szukaj alertów pogodowych...", + "severity": { + "extreme": "Ekstremalny", + "minor": "Niewielki", + "moderate": "Umiarkowany", + "severe": "Poważny", + "unknown": "Nieznany" + }, + "sort": { + "expires": "Wkrótce wygasające", + "newest": "Najnowsze", + "severity": "Dotkliwość" + }, + "status": { + "active": "Aktywny", + "cancelled": "Anulowany", + "expired": "Wygasły", + "updated": "Zaktualizowany" + }, + "title": "Alerty Pogodowe", + "urgency": { + "expected": "Oczekiwany", + "future": "Przyszły", + "immediate": "Natychmiastowy", + "past": "Przeszły", + "unknown": "Nieznany" + } + }, + "welcome": "Witamy w aplikacji obytes" } diff --git a/src/translations/sv.json b/src/translations/sv.json index e64f8dc..c7c8907 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -90,35 +90,6 @@ "volumeDown": "Volym -", "volumeUp": "Volym +" }, - "check_in": { - "tab_title": "Incheckning", - "timer_status": "Timerstatus", - "perform_check_in": "Checka in", - "quick_check_in": "Snabb incheckning", - "check_in_success": "Incheckning registrerad", - "check_in_error": "Kunde inte registrera incheckning", - "last_check_in": "Senaste incheckning", - "elapsed": "Förfluten", - "duration": "min", - "status_ok": "OK", - "status_warning": "Varning", - "status_overdue": "Försenad", - "history": "Historik", - "no_timers": "Inga aktiva inchecknings-timers", - "timers_disabled": "Inchecknings-timers är inaktiverade för detta samtal", - "type_personnel": "Personal", - "type_unit": "Enhet", - "type_ic": "IC", - "type_par": "PAR", - "type_hazmat": "Farligt gods", - "type_sector_rotation": "Sektorsrotation", - "type_rehab": "Rehabilitering", - "add_note": "Lägg till anteckning", - "confirm": "Bekräfta incheckning", - "minutes_ago": "min sedan", - "select_type": "Välj incheckningstyp", - "queued_offline": "Incheckning köad för när anslutningen återställs" - }, "callImages": { "add": "Lägg till bild", "add_new": "Lägg till ny bild", @@ -173,6 +144,7 @@ "contact_info": "Kontaktinfo", "contact_name": "Kontaktnamn", "contact_phone": "Telefon", + "destination": "Destination", "edit_call": "Redigera samtal", "external_id": "Externt ID", "failed_to_open_maps": "Det gick inte att öppna kartor", @@ -218,11 +190,11 @@ "setting_active": "Anger aktiv...", "status": "Status", "tabs": { + "check_in": "Incheckning", "contact": "Kontakt", "dispatched": "Utskickad", "info": "Info", "protocols": "Protokoll", - "check_in": "Incheckning", "timeline": "Aktivitet" }, "timestamp": "Tidsstämpel", @@ -265,6 +237,9 @@ "description": "Beskrivning", "description_placeholder": "Ange beskrivning av samtalet", "deselect": "Avmarkera", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Vägbeskrivning", "dispatch_to": "Skicka till", "dispatch_to_everyone": "Skicka till all tillgänglig personal", @@ -282,6 +257,7 @@ "invalid_type": "Ogiltig typ vald. Välj en giltig samtalstyp.", "loading": "Laddar samtal...", "loading_calls": "Laddar samtal...", + "loading_destination_pois": "Loading destination POIs...", "name": "Namn", "name_placeholder": "Ange samtalets namn", "nature": "Art", @@ -293,6 +269,7 @@ "no_calls": "Inga aktiva samtal", "no_calls_available": "Inga samtal tillgängliga", "no_calls_description": "Inga aktiva samtal hittades. Välj ett aktivt samtal för att se detaljer.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "Detta samtal har ingen platsdata tillgänglig för navigering.", "no_location_title": "Ingen plats tillgänglig", "no_open_calls": "Inga öppna samtal tillgängliga", @@ -312,6 +289,7 @@ "select_address": "Välj adress", "select_address_placeholder": "Välj samtalets adress", "select_description": "Välj beskrivning", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Välj utskickningsmottagare", "select_location": "Välj plats på kartan", "select_name": "Välj namn", @@ -336,6 +314,37 @@ "what3words_placeholder": "Ange what3words-adress (t.ex. filled.count.soap)", "what3words_required": "Ange en what3words-adress att söka efter" }, + "check_in": { + "add_note": "Lägg till anteckning", + "check_in_error": "Kunde inte registrera incheckning", + "check_in_success": "Incheckning registrerad", + "confirm": "Bekräfta incheckning", + "duration": "min", + "elapsed": "Förfluten", + "history": "Historik", + "last_check_in": "Senaste incheckning", + "minutes_ago": "min sedan", + "no_timers": "Inga aktiva inchecknings-timers", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Checka in", + "queued_offline": "Incheckning köad för när anslutningen återställs", + "quick_check_in": "Snabb incheckning", + "select_type": "Välj incheckningstyp", + "status_ok": "OK", + "status_overdue": "Försenad", + "status_warning": "Varning", + "tab_title": "Incheckning", + "timer_status": "Timerstatus", + "timers_disabled": "Inchecknings-timers är inaktiverade för detta samtal", + "type_hazmat": "Farligt gods", + "type_ic": "IC", + "type_par": "PAR", + "type_personnel": "Personal", + "type_rehab": "Rehabilitering", + "type_sector_rotation": "Sektorsrotation", + "type_unit": "Enhet" + }, "common": { "add": "Lägg till", "back": "Tillbaka", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Det gick inte att öppna kartappen", "failed_to_set_current_call": "Det gick inte att ange samtalet som aktuellt", "no_location_for_routing": "Ingen platsdata tillgänglig för navigering", + "pin_address": "Address", "pin_color": "Nålfärg", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Centrera kartan", "set_as_current_call": "Ange som aktuellt samtal", - "view_call_details": "Visa samtalsdetaljer" + "view_call_details": "Visa samtalsdetaljer", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Aktiva lager", @@ -660,12 +673,15 @@ "eta": "ETA", "eta_to_next": "ETA till nästa stopp", "exit": "Utgång", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Geostängselsradie", "history": "Rutthistorik", "in_progress": "Pågår", "instance_detail": "Ruttinstans", "loading": "Laddar rutter...", "loading_directions": "Laddar vägbeskrivning...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Laddar stopp...", "location": "Plats", "min": "min", @@ -676,6 +692,9 @@ "no_directions": "Ingen vägbeskrivning tillgänglig", "no_history": "Ingen rutthistorik tillgänglig", "no_history_description": "Slutförda rutter visas här.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Inga rutter", "no_routes_description": "Inga ruttplaner är tillgängliga för din enhet.", "no_routes_description_all": "Inga ruttplaner är tillgängliga.", @@ -688,6 +707,21 @@ "pending": "Väntande", "planned_arrival": "Planerad ankomst", "planned_departure": "Planerad avgång", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Prioritet", "priority_critical": "Kritisk", "priority_high": "Hög", @@ -698,9 +732,13 @@ "remaining_steps": "Återstående steg", "resume_route": "Återuppta rutt", "route_summary": "Ruttsammanfattning", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Schema", "search": "Sök rutter...", + "search_pois": "Search POIs...", "select_unit": "Välj enhet", + "set_poi_destination": "Set Destination", "skip": "Hoppa över", "skip_reason": "Anledning att hoppa över", "skip_reason_placeholder": "Ange anledning till att hoppa över detta stopp", @@ -729,6 +767,7 @@ "unit": "Enhet", "unit_required": "En enhet måste väljas för att starta rutten", "view_contact": "Visa kontakt", + "view_on_map": "View on map", "view_route": "Visa rutt" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Lägg till anteckning", "both_destinations_enabled": "Kan svara på samtal eller stationer", "call_destination_enabled": "Kan svara på samtal", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Samtal", "failed_to_save_status": "Det gick inte att spara status. Försök igen.", "general_status": "Allmän status utan specifikt mål", + "loading_pois": "Loading POIs...", "loading_stations": "Laddar stationer...", "no_destination": "Inget mål", + "no_pois_available": "No POIs available", "no_stations_available": "Inga stationer tillgängliga", "no_statuses_available": "Inga statusar tillgängliga", "note": "Anteckning", "note_optional": "Lägg till en valfri anteckning för denna statusuppdatering", "note_required": "Ange en anteckning för denna statusuppdatering", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Välj mål för {{status}}", "select_destination_type": "Var vill du svara?", "select_status": "Välj status", @@ -842,6 +887,7 @@ "selected_status": "Vald status", "set_status": "Ange status", "station_destination_enabled": "Kan svara på stationer", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stationer", "status_saved_successfully": "Status sparades framgångsrikt!" }, @@ -855,72 +901,121 @@ "settings": "Inställningar", "weather_alerts": "Väder" }, - "weather_alerts": { - "title": "Vädervarningar", - "loading": "Laddar vädervarningar...", - "no_alerts": "Inga Vädervarningar", - "no_alerts_description": "Det finns inga aktiva vädervarningar för ditt område.", - "feature_disabled": "Vädervarningar Inaktiverade", - "feature_disabled_description": "Vädervarningar är inte aktiverade för din avdelning.", - "search": "Sök vädervarningar...", - "severity": { "extreme": "Extrem", "severe": "Allvarlig", "moderate": "Måttlig", "minor": "Mindre", "unknown": "Okänd" }, - "category": { "met": "Meteorologisk", "fire": "Brand", "health": "Hälsa", "env": "Miljö", "other": "Övrigt" }, - "urgency": { "immediate": "Omedelbar", "expected": "Förväntad", "future": "Framtida", "past": "Förfluten", "unknown": "Okänd" }, - "certainty": { "observed": "Observerad", "likely": "Trolig", "possible": "Möjlig", "unlikely": "Osannolik", "unknown": "Okänd" }, - "status": { "active": "Aktiv", "updated": "Uppdaterad", "expired": "Utgången", "cancelled": "Avbruten" }, - "detail": { "headline": "Rubrik", "description": "Beskrivning", "instructions": "Instruktioner", "area": "Påverkat Område", "effective": "Gäller från", "onset": "Början", "expires": "Upphör", "sender": "Avsändare", "urgency": "Brådska", "certainty": "Säkerhet" }, - "filter": { "all": "Alla", "nearby": "Nära" }, - "sort": { "severity": "Allvarlighetsgrad", "expires": "Upphör snart", "newest": "Senaste" }, - "banner": { "more_alerts": "+{{count}} fler" } - }, - "welcome": "Välkommen till obytes app site", "video_feeds": { - "tab_title": "Video", - "no_feeds": "Inga videoflöden tillgängliga", "add_feed": "Lägg till flöde", - "edit_feed": "Redigera flöde", - "watch": "Titta", + "added_by": "Tillagd av", + "added_on": "Tillagd", "copy_url": "Kopiera URL", - "url_copied": "URL kopierad till urklipp", - "delete_feed": "Ta bort flöde", - "delete_confirm_title": "Ta bort videoflöde", "delete_confirm_message": "Är du säker på att du vill ta bort detta videoflöde?", - "save_success": "Videoflöde sparat", - "save_error": "Kunde inte spara videoflödet", - "delete_success": "Videoflöde borttaget", + "delete_confirm_title": "Ta bort videoflöde", "delete_error": "Kunde inte ta bort videoflödet", - "name": "Namn", - "url": "URL", - "feed_type": "Flödestyp", - "feed_format": "Flödesformat", + "delete_feed": "Ta bort flöde", + "delete_success": "Videoflöde borttaget", "description": "Beskrivning", + "edit_feed": "Redigera flöde", + "feed_format": "Flödesformat", + "feed_type": "Flödestyp", + "format_dash": "DASH", + "format_embed": "Inbäddad", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Övrigt", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube Live", "latitude": "Latitud", + "loading_video": "Laddar video...", "longitude": "Longitud", - "added_by": "Tillagd av", - "added_on": "Tillagd", + "name": "Namn", + "no_feeds": "Inga videoflöden tillgängliga", + "player_title": "Videospelare", + "rtsp_not_supported": "RTSP-strömmar kan inte spelas direkt. Kopiera URL:en för att använda i en dedikerad spelare.", + "save_error": "Kunde inte spara videoflödet", + "save_success": "Videoflöde sparat", "status_active": "Aktiv", - "status_inactive": "Inaktiv", "status_error": "Fel", + "status_inactive": "Inaktiv", + "tab_title": "Video", + "type_body_cam": "Kroppskamera", "type_drone": "Drönare", "type_fixed_camera": "Fast kamera", - "type_body_cam": "Kroppskamera", + "type_other": "Övrigt", + "type_satellite_feed": "Satellitflöde", "type_traffic_cam": "Trafikkamera", "type_weather_cam": "Väderkamera", - "type_satellite_feed": "Satellitflöde", "type_web_cam": "Webbkamera", - "type_other": "Övrigt", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube Live", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Inbäddad", - "format_other": "Övrigt", - "rtsp_not_supported": "RTSP-strömmar kan inte spelas direkt. Kopiera URL:en för att använda i en dedikerad spelare.", - "webrtc_not_supported": "WebRTC-uppspelning kommer snart", - "player_title": "Videospelare", - "loading_video": "Laddar video...", - "video_error": "Kunde inte ladda videon" - } + "url": "URL", + "url_copied": "URL kopierad till urklipp", + "video_error": "Kunde inte ladda videon", + "watch": "Titta", + "webrtc_not_supported": "WebRTC-uppspelning kommer snart" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} fler" + }, + "category": { + "env": "Miljö", + "fire": "Brand", + "health": "Hälsa", + "met": "Meteorologisk", + "other": "Övrigt" + }, + "certainty": { + "likely": "Trolig", + "observed": "Observerad", + "possible": "Möjlig", + "unknown": "Okänd", + "unlikely": "Osannolik" + }, + "detail": { + "area": "Påverkat Område", + "certainty": "Säkerhet", + "description": "Beskrivning", + "effective": "Gäller från", + "expires": "Upphör", + "headline": "Rubrik", + "instructions": "Instruktioner", + "onset": "Början", + "sender": "Avsändare", + "urgency": "Brådska" + }, + "feature_disabled": "Vädervarningar Inaktiverade", + "feature_disabled_description": "Vädervarningar är inte aktiverade för din avdelning.", + "filter": { + "all": "Alla", + "nearby": "Nära" + }, + "loading": "Laddar vädervarningar...", + "no_alerts": "Inga Vädervarningar", + "no_alerts_description": "Det finns inga aktiva vädervarningar för ditt område.", + "search": "Sök vädervarningar...", + "severity": { + "extreme": "Extrem", + "minor": "Mindre", + "moderate": "Måttlig", + "severe": "Allvarlig", + "unknown": "Okänd" + }, + "sort": { + "expires": "Upphör snart", + "newest": "Senaste", + "severity": "Allvarlighetsgrad" + }, + "status": { + "active": "Aktiv", + "cancelled": "Avbruten", + "expired": "Utgången", + "updated": "Uppdaterad" + }, + "title": "Vädervarningar", + "urgency": { + "expected": "Förväntad", + "future": "Framtida", + "immediate": "Omedelbar", + "past": "Förfluten", + "unknown": "Okänd" + } + }, + "welcome": "Välkommen till obytes app site" } diff --git a/src/translations/uk.json b/src/translations/uk.json index f3affaf..155ed0e 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -90,35 +90,6 @@ "volumeDown": "Гучність -", "volumeUp": "Гучність +" }, - "check_in": { - "tab_title": "Реєстрація", - "timer_status": "Статус таймера", - "perform_check_in": "Зареєструватися", - "quick_check_in": "Швидка реєстрація", - "check_in_success": "Реєстрацію успішно записано", - "check_in_error": "Не вдалося записати реєстрацію", - "last_check_in": "Остання реєстрація", - "elapsed": "Минуло", - "duration": "хв", - "status_ok": "ОК", - "status_warning": "Попередження", - "status_overdue": "Прострочено", - "history": "Історія", - "no_timers": "Немає активних таймерів реєстрації", - "timers_disabled": "Таймери реєстрації вимкнені для цього виклику", - "type_personnel": "Персонал", - "type_unit": "Підрозділ", - "type_ic": "КІ", - "type_par": "PAR", - "type_hazmat": "Небезпечні матеріали", - "type_sector_rotation": "Ротація секторів", - "type_rehab": "Реабілітація", - "add_note": "Додати примітку", - "confirm": "Підтвердити реєстрацію", - "minutes_ago": "хв тому", - "select_type": "Оберіть тип реєстрації", - "queued_offline": "Реєстрацію поставлено в чергу до відновлення з'єднання" - }, "callImages": { "add": "Додати зображення", "add_new": "Додати нове зображення", @@ -173,6 +144,7 @@ "contact_info": "Контактна інформація", "contact_name": "Ім'я контакту", "contact_phone": "Телефон", + "destination": "Destination", "edit_call": "Редагувати виклик", "external_id": "Зовнішній ID", "failed_to_open_maps": "Не вдалося відкрити додаток карт", @@ -218,11 +190,11 @@ "setting_active": "Встановлення активним...", "status": "Статус", "tabs": { + "check_in": "Реєстрація", "contact": "Контакт", "dispatched": "Відправлено", "info": "Інфо", "protocols": "Протоколи", - "check_in": "Реєстрація", "timeline": "Активність" }, "timestamp": "Мітка часу", @@ -265,6 +237,9 @@ "description": "Опис", "description_placeholder": "Введіть опис виклику", "deselect": "Скасувати вибір", + "destination": "Destination", + "destination_poi": "Destination POI", + "destination_poi_none": "No destination selected", "directions": "Маршрут", "dispatch_to": "Відправити до", "dispatch_to_everyone": "Відправити до всього доступного персоналу", @@ -282,6 +257,7 @@ "invalid_type": "Вибрано неправильний тип. Виберіть правильний тип виклику.", "loading": "Завантаження викликів...", "loading_calls": "Завантаження викликів...", + "loading_destination_pois": "Loading destination POIs...", "name": "Назва", "name_placeholder": "Введіть назву виклику", "nature": "Характер", @@ -293,6 +269,7 @@ "no_calls": "Немає активних викликів", "no_calls_available": "Немає доступних викликів", "no_calls_description": "Активних викликів не знайдено. Виберіть активний виклик для перегляду деталей.", + "no_destination_pois_available": "No destination POIs available", "no_location_message": "Цей виклик не має даних про місцезнаходження для навігації.", "no_location_title": "Місцезнаходження недоступне", "no_open_calls": "Немає відкритих викликів", @@ -312,6 +289,7 @@ "select_address": "Вибрати адресу", "select_address_placeholder": "Виберіть адресу виклику", "select_description": "Вибрати опис", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Вибрати одержувачів відправки", "select_location": "Вибрати місцезнаходження на карті", "select_name": "Вибрати назву", @@ -336,6 +314,37 @@ "what3words_placeholder": "Введіть адресу what3words (напр. filled.count.soap)", "what3words_required": "Введіть адресу what3words для пошуку" }, + "check_in": { + "add_note": "Додати примітку", + "check_in_error": "Не вдалося записати реєстрацію", + "check_in_success": "Реєстрацію успішно записано", + "confirm": "Підтвердити реєстрацію", + "duration": "хв", + "elapsed": "Минуло", + "history": "Історія", + "last_check_in": "Остання реєстрація", + "minutes_ago": "хв тому", + "no_timers": "Немає активних таймерів реєстрації", + "notification_channel_description": "Timer notifications for call check-ins", + "notification_channel_name": "Check-In Timers", + "perform_check_in": "Зареєструватися", + "queued_offline": "Реєстрацію поставлено в чергу до відновлення з'єднання", + "quick_check_in": "Швидка реєстрація", + "select_type": "Оберіть тип реєстрації", + "status_ok": "ОК", + "status_overdue": "Прострочено", + "status_warning": "Попередження", + "tab_title": "Реєстрація", + "timer_status": "Статус таймера", + "timers_disabled": "Таймери реєстрації вимкнені для цього виклику", + "type_hazmat": "Небезпечні матеріали", + "type_ic": "КІ", + "type_par": "PAR", + "type_personnel": "Персонал", + "type_rehab": "Реабілітація", + "type_sector_rotation": "Ротація секторів", + "type_unit": "Підрозділ" + }, "common": { "add": "Додати", "back": "Назад", @@ -528,10 +537,14 @@ "failed_to_open_maps": "Не вдалося відкрити додаток карт", "failed_to_set_current_call": "Не вдалося встановити виклик як поточний", "no_location_for_routing": "Немає даних про місцезнаходження для навігації", + "pin_address": "Address", "pin_color": "Колір мітки", + "pin_note": "Note", + "pin_type": "POI Type", "recenter_map": "Відцентрувати карту", "set_as_current_call": "Встановити як поточний виклик", - "view_call_details": "Переглянути деталі виклику" + "view_call_details": "Переглянути деталі виклику", + "view_poi_details": "View POI Details" }, "maps": { "active_layers": "Активні шари", @@ -660,12 +673,15 @@ "eta": "ОЧП", "eta_to_next": "Орієнтовний час до наступної зупинки", "exit": "Вихід", + "failed_to_open_poi_maps": "Failed to open maps for this POI", "geofence_radius": "Радіус геозони", "history": "Історія маршрутів", "in_progress": "В процесі", "instance_detail": "Екземпляр маршруту", "loading": "Завантаження маршрутів...", "loading_directions": "Завантаження маршруту...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Завантаження зупинок...", "location": "Розташування", "min": "хв", @@ -676,6 +692,9 @@ "no_directions": "Маршрут недоступний", "no_history": "Немає історії маршрутів", "no_history_description": "Завершені маршрути з'являться тут.", + "no_pois": "No POIs", + "no_pois_description": "No POIs are available for your department.", + "no_pois_filtered_description": "No POIs match the current filters.", "no_routes": "Немає маршрутів", "no_routes_description": "Для вашого підрозділу немає доступних планів маршрутів.", "no_routes_description_all": "Немає доступних планів маршрутів.", @@ -688,6 +707,21 @@ "pending": "Очікує", "planned_arrival": "Запланований приїзд", "planned_departure": "Запланований від'їзд", + "poi_address": "Address", + "poi_coordinates": "Coordinates", + "poi_coordinates_compact": "{{latitude}}, {{longitude}}", + "poi_coordinates_value": "Lat {{latitude}}, Lon {{longitude}}", + "poi_destination_enabled": "Destination enabled", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_display": "Name", + "poi_sort_placeholder": "Sort POIs", + "poi_sort_type": "Type", + "poi_type_unknown": "Unknown type", + "pois_tab": "POIs", "priority": "Пріоритет", "priority_critical": "Критичний", "priority_high": "Високий", @@ -698,9 +732,13 @@ "remaining_steps": "Кроки, що залишилися", "resume_route": "Відновити маршрут", "route_summary": "Підсумок маршруту", + "route_to_poi": "Route to POI", + "routes_tab": "Routes", "schedule": "Розклад", "search": "Пошук маршрутів...", + "search_pois": "Search POIs...", "select_unit": "Вибрати підрозділ", + "set_poi_destination": "Set Destination", "skip": "Пропустити", "skip_reason": "Причина пропуску", "skip_reason_placeholder": "Введіть причину пропуску цієї зупинки", @@ -729,6 +767,7 @@ "unit": "Підрозділ", "unit_required": "Для початку маршруту необхідно вибрати підрозділ", "view_contact": "Переглянути контакт", + "view_on_map": "View on map", "view_route": "Переглянути маршрут" }, "settings": { @@ -824,16 +863,22 @@ "add_note": "Додати примітку", "both_destinations_enabled": "Може реагувати на виклики або станції", "call_destination_enabled": "Може реагувати на виклики", + "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", + "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Виклики", "failed_to_save_status": "Не вдалося зберегти статус. Спробуйте ще раз.", "general_status": "Загальний статус без конкретного призначення", + "loading_pois": "Loading POIs...", "loading_stations": "Завантаження станцій...", "no_destination": "Без призначення", + "no_pois_available": "No POIs available", "no_stations_available": "Немає доступних станцій", "no_statuses_available": "Немає доступних статусів", "note": "Примітка", "note_optional": "Додайте необов'язкову примітку до цього оновлення статусу", "note_required": "Введіть примітку для цього оновлення статусу", + "poi_destination_enabled": "Can respond to POIs", + "pois_tab": "POIs", "select_destination": "Вибрати призначення для {{status}}", "select_destination_type": "Куди ви хочете реагувати?", "select_status": "Вибрати статус", @@ -842,6 +887,7 @@ "selected_status": "Вибраний статус", "set_status": "Встановити статус", "station_destination_enabled": "Може реагувати на станції", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Станції", "status_saved_successfully": "Статус успішно збережено!" }, @@ -855,72 +901,121 @@ "settings": "Налаштування", "weather_alerts": "Погода" }, - "weather_alerts": { - "title": "Погодні Сповіщення", - "loading": "Завантаження погодних сповіщень...", - "no_alerts": "Немає Погодних Сповіщень", - "no_alerts_description": "Немає активних погодних сповіщень для вашого району.", - "feature_disabled": "Погодні Сповіщення Вимкнено", - "feature_disabled_description": "Погодні сповіщення не увімкнено для вашого відділу.", - "search": "Шукати погодні сповіщення...", - "severity": { "extreme": "Екстремальний", "severe": "Серйозний", "moderate": "Помірний", "minor": "Незначний", "unknown": "Невідомий" }, - "category": { "met": "Метеорологічний", "fire": "Пожежа", "health": "Здоров'я", "env": "Екологічний", "other": "Інше" }, - "urgency": { "immediate": "Негайний", "expected": "Очікуваний", "future": "Майбутній", "past": "Минулий", "unknown": "Невідомий" }, - "certainty": { "observed": "Спостережений", "likely": "Ймовірний", "possible": "Можливий", "unlikely": "Малоймовірний", "unknown": "Невідомий" }, - "status": { "active": "Активний", "updated": "Оновлений", "expired": "Закінчився", "cancelled": "Скасований" }, - "detail": { "headline": "Заголовок", "description": "Опис", "instructions": "Інструкції", "area": "Постраждалий Район", "effective": "Чинний з", "onset": "Початок", "expires": "Закінчується", "sender": "Відправник", "urgency": "Терміновість", "certainty": "Впевненість" }, - "filter": { "all": "Всі", "nearby": "Поблизу" }, - "sort": { "severity": "Серйозність", "expires": "Скоро закінчується", "newest": "Найновіші" }, - "banner": { "more_alerts": "+{{count}} більше" } - }, - "welcome": "Ласкаво просимо до додатку obytes", "video_feeds": { - "tab_title": "Відео", - "no_feeds": "Немає доступних відеопотоків", "add_feed": "Додати потік", - "edit_feed": "Редагувати потік", - "watch": "Дивитися", + "added_by": "Додав", + "added_on": "Додано", "copy_url": "Копіювати URL", - "url_copied": "URL скопійовано до буфера обміну", - "delete_feed": "Видалити потік", - "delete_confirm_title": "Видалити відеопотік", "delete_confirm_message": "Ви впевнені, що хочете видалити цей відеопотік?", - "save_success": "Відеопотік успішно збережено", - "save_error": "Не вдалося зберегти відеопотік", - "delete_success": "Відеопотік успішно видалено", + "delete_confirm_title": "Видалити відеопотік", "delete_error": "Не вдалося видалити відеопотік", - "name": "Назва", - "url": "URL", - "feed_type": "Тип потоку", - "feed_format": "Формат потоку", + "delete_feed": "Видалити потік", + "delete_success": "Відеопотік успішно видалено", "description": "Опис", + "edit_feed": "Редагувати потік", + "feed_format": "Формат потоку", + "feed_type": "Тип потоку", + "format_dash": "DASH", + "format_embed": "Вбудований", + "format_hls": "HLS", + "format_mjpeg": "MJPEG", + "format_other": "Інше", + "format_rtsp": "RTSP", + "format_webrtc": "WebRTC", + "format_youtube_live": "YouTube наживо", "latitude": "Широта", + "loading_video": "Завантаження відео...", "longitude": "Довгота", - "added_by": "Додав", - "added_on": "Додано", + "name": "Назва", + "no_feeds": "Немає доступних відеопотоків", + "player_title": "Відеоплеєр", + "rtsp_not_supported": "Потоки RTSP не можна відтворювати безпосередньо. Скопіюйте URL для використання у спеціалізованому плеєрі.", + "save_error": "Не вдалося зберегти відеопотік", + "save_success": "Відеопотік успішно збережено", "status_active": "Активний", - "status_inactive": "Неактивний", "status_error": "Помилка", + "status_inactive": "Неактивний", + "tab_title": "Відео", + "type_body_cam": "Натільна камера", "type_drone": "Дрон", "type_fixed_camera": "Стаціонарна камера", - "type_body_cam": "Натільна камера", + "type_other": "Інше", + "type_satellite_feed": "Супутниковий потік", "type_traffic_cam": "Камера руху", "type_weather_cam": "Камера погоди", - "type_satellite_feed": "Супутниковий потік", "type_web_cam": "Вебкамера", - "type_other": "Інше", - "format_rtsp": "RTSP", - "format_hls": "HLS", - "format_mjpeg": "MJPEG", - "format_youtube_live": "YouTube наживо", - "format_webrtc": "WebRTC", - "format_dash": "DASH", - "format_embed": "Вбудований", - "format_other": "Інше", - "rtsp_not_supported": "Потоки RTSP не можна відтворювати безпосередньо. Скопіюйте URL для використання у спеціалізованому плеєрі.", - "webrtc_not_supported": "Відтворення WebRTC незабаром", - "player_title": "Відеоплеєр", - "loading_video": "Завантаження відео...", - "video_error": "Не вдалося завантажити відео" - } + "url": "URL", + "url_copied": "URL скопійовано до буфера обміну", + "video_error": "Не вдалося завантажити відео", + "watch": "Дивитися", + "webrtc_not_supported": "Відтворення WebRTC незабаром" + }, + "weather_alerts": { + "banner": { + "more_alerts": "+{{count}} більше" + }, + "category": { + "env": "Екологічний", + "fire": "Пожежа", + "health": "Здоров'я", + "met": "Метеорологічний", + "other": "Інше" + }, + "certainty": { + "likely": "Ймовірний", + "observed": "Спостережений", + "possible": "Можливий", + "unknown": "Невідомий", + "unlikely": "Малоймовірний" + }, + "detail": { + "area": "Постраждалий Район", + "certainty": "Впевненість", + "description": "Опис", + "effective": "Чинний з", + "expires": "Закінчується", + "headline": "Заголовок", + "instructions": "Інструкції", + "onset": "Початок", + "sender": "Відправник", + "urgency": "Терміновість" + }, + "feature_disabled": "Погодні Сповіщення Вимкнено", + "feature_disabled_description": "Погодні сповіщення не увімкнено для вашого відділу.", + "filter": { + "all": "Всі", + "nearby": "Поблизу" + }, + "loading": "Завантаження погодних сповіщень...", + "no_alerts": "Немає Погодних Сповіщень", + "no_alerts_description": "Немає активних погодних сповіщень для вашого району.", + "search": "Шукати погодні сповіщення...", + "severity": { + "extreme": "Екстремальний", + "minor": "Незначний", + "moderate": "Помірний", + "severe": "Серйозний", + "unknown": "Невідомий" + }, + "sort": { + "expires": "Скоро закінчується", + "newest": "Найновіші", + "severity": "Серйозність" + }, + "status": { + "active": "Активний", + "cancelled": "Скасований", + "expired": "Закінчився", + "updated": "Оновлений" + }, + "title": "Погодні Сповіщення", + "urgency": { + "expected": "Очікуваний", + "future": "Майбутній", + "immediate": "Негайний", + "past": "Минулий", + "unknown": "Невідомий" + } + }, + "welcome": "Ласкаво просимо до додатку obytes" } From 882b3be411275b66545724cbdb38ab45216eeea0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 27 Apr 2026 16:37:27 -0700 Subject: [PATCH 4/4] RU-T50 Updating runner --- .github/workflows/react-native-cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 87da10f..2076a34 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -125,7 +125,7 @@ jobs: strategy: matrix: platform: [android, ios] - runs-on: ${{ matrix.platform == 'ios' && 'macos-15' || 'ubuntu-latest' }} + runs-on: ${{ matrix.platform == 'ios' && 'macos-26' || 'ubuntu-latest' }} environment: RNBuild steps: - name: 🏗 Checkout repository