diff --git a/.forge/skills/react-native-best-practices/POWER.md b/.forge/skills/react-native-best-practices/POWER.md new file mode 100644 index 00000000..bedabb48 --- /dev/null +++ b/.forge/skills/react-native-best-practices/POWER.md @@ -0,0 +1,161 @@ +--- +name: react-native-best-practices +description: Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops. +license: MIT +author: Callstack +keywords: ["react-native", "expo", "performance", "optimization", "profiling"] +--- + +# Onboarding + +## Step 1: Validate React Native Setup + +Before applying performance optimizations, ensure: +- **Expo CLI** or **React Native CLI** is installed + - Verify with: `npx expo --version` and `npx react-native --version` +- Metro bundler is running (**apply only for** bundle analysis) +- React Native DevTools is available (**apply only for** profiling) + - Press 'j' in Metro terminal or shake device → "Open DevTools" + +## Security Guardrails + +- Review shell commands before running them and prefer version-pinned tooling from trusted sources. +- Do not pipe remote install scripts directly into a shell. +- Treat third-party packages as normal supply-chain dependencies that require provenance and version review. +- If using Re.Pack code splitting, only load first-party chunks from trusted HTTPS origins tied to the current release. + +# When to Load Reference Files + +Load specific reference files from `references/` based on the task: + +## JavaScript/React Performance (`js-*`) + +- **Debugging slow/janky UI or animations** → `references/js-measure-fps.md` +- **Investigating re-render issues** → `references/js-profile-react.md` → `references/js-react-compiler.md` +- **Optimizing list scrolling** → `references/js-lists-flatlist-flashlist.md` +- **Reducing re-renders with state management** → `references/js-atomic-state.md` +- **Using Concurrent React features** → `references/js-concurrent-react.md` +- **Enabling automatic memoization** → `references/js-react-compiler.md` +- **Optimizing animations** → `references/js-animations-reanimated.md` +- **Fixing TextInput lag** → `references/js-uncontrolled-components.md` +- **Hunting JavaScript memory leaks** → `references/js-memory-leaks.md` + +## Native Performance (`native-*`) + +- **Measuring startup time (TTI)** → `references/native-measure-tti.md` +- **Building native modules** → `references/native-turbo-modules.md` +- **Understanding native threading** → `references/native-threading-model.md` +- **Profiling native code** → `references/native-profiling.md` +- **Setting up native tooling** → `references/native-platform-setup.md` +- **Debugging view hierarchy** → `references/native-view-flattening.md` +- **Native memory patterns** → `references/native-memory-patterns.md` +- **Hunting native memory leaks** → `references/native-memory-leaks.md` +- **Choosing native SDKs vs polyfills** → `references/native-sdks-over-polyfills.md` +- **Fixing Android 16KB alignment** → `references/native-android-16kb-alignment.md` + +## Bundle & App Size (`bundle-*`) + +- **Analyzing bundle size** → `references/bundle-analyze-js.md` +- **Analyzing app size** → `references/bundle-analyze-app.md` +- **Fixing barrel imports** → `references/bundle-barrel-exports.md` +- **Enabling tree shaking** → `references/bundle-tree-shaking.md` +- **Android code shrinking** → `references/bundle-r8-android.md` +- **Optimizing Hermes bundle loading** → `references/bundle-hermes-mmap.md` +- **Managing native assets** → `references/bundle-native-assets.md` +- **Evaluating library size** → `references/bundle-library-size.md` +- **Code splitting** → `references/bundle-code-splitting.md` + +## Problem → Reference Mapping + +Use this quick lookup when debugging specific issues: + +| Problem | Start With | +|---------|-----------| +| App feels slow/janky | `references/js-measure-fps.md` → `references/js-profile-react.md` | +| Too many re-renders | `references/js-profile-react.md` → `references/js-react-compiler.md` | +| Slow startup (TTI) | `references/native-measure-tti.md` → `references/bundle-analyze-js.md` | +| Large app size | `references/bundle-analyze-app.md` → `references/bundle-r8-android.md` | +| Memory growing | `references/js-memory-leaks.md` or `references/native-memory-leaks.md` | +| Animation drops frames | `references/js-animations-reanimated.md` | +| List scroll jank | `references/js-lists-flatlist-flashlist.md` | +| TextInput lag | `references/js-uncontrolled-components.md` | +| Native module slow | `references/native-turbo-modules.md` → `references/native-threading-model.md` | +| Native library alignment issue | `references/native-android-16kb-alignment.md` | + +## Quick Reference Commands + +### FPS & Re-renders +```bash +# Open React Native DevTools +# Press 'j' in Metro, or shake device → "Open DevTools" +``` + +Baseline runtime metrics should come from the target interaction itself: +- Capture commit timeline, re-render counts, slow components, and heaviest-commit breakdown. +- Treat component tree depth and count as supporting context only. + +**Common fixes:** +- Replace ScrollView with FlatList/FlashList for lists +- Use React Compiler for automatic memoization +- Use atomic state (Jotai/Zustand) to reduce re-renders +- Use `useDeferredValue` for expensive computations + +**Review guardrails:** +- Check library versions before suggesting API-specific fixes. FlashList v2 deprecates `estimatedItemSize`. +- Do not suggest `useMemo` or `useCallback` dependency changes without a reproducible correctness issue or profiling evidence. +- Do not report stale closures unless the stale read path or repro is clear. + +### Analyze Bundle Size +```bash +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false --minify true + +npx source-map-explorer output.js --no-border-checks +``` + +**Common fixes:** +- Avoid barrel imports (import directly from source) +- Remove unnecessary Intl polyfills only after checking Hermes API and method coverage +- Enable tree shaking (Expo SDK 52+ or Re.Pack) +- Enable R8 for Android native code shrinking + +### Measure TTI +- Use `react-native-performance` for markers +- Only measure cold starts (exclude warm/hot/prewarm) + +**Common fixes:** +- Disable JS bundle compression on Android (enables Hermes mmap) +- Use native navigation (react-native-screens) +- Preload commonly-used expensive screens before navigating to them + +### Native Performance + +**Profile native:** +- iOS: Xcode Instruments → Time Profiler +- Android: Android Studio → CPU Profiler + +**Common fixes:** +- Use background threads for heavy native work +- Prefer async over sync Turbo Module methods +- Use C++ for cross-platform performance-critical code + +## Priority Guidelines + +Apply optimizations in this order: + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | FPS & Re-renders | CRITICAL | `js-*` | +| 2 | Bundle Size | CRITICAL | `bundle-*` | +| 3 | TTI Optimization | HIGH | `native-*`, `bundle-*` | +| 4 | Native Performance | HIGH | `native-*` | +| 5 | Memory Management | MEDIUM-HIGH | `js-*`, `native-*` | +| 6 | Animations | MEDIUM | `js-*` | + +## Attribution + +Based on "The Ultimate Guide to React Native Optimization" by Callstack. diff --git a/.forge/skills/react-native-best-practices/SKILL.md b/.forge/skills/react-native-best-practices/SKILL.md new file mode 100644 index 00000000..bde66071 --- /dev/null +++ b/.forge/skills/react-native-best-practices/SKILL.md @@ -0,0 +1,253 @@ +--- +name: react-native-best-practices +description: Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops. +license: MIT +metadata: + author: Callstack + tags: react-native, expo, performance, optimization, profiling +--- + +# React Native Best Practices + +## Overview + +Performance optimization guide for React Native applications, covering JavaScript/React, Native (iOS/Android), and bundling optimizations. Based on Callstack's "Ultimate Guide to React Native Optimization". + +## Skill Format + +Each reference file follows a hybrid format for fast lookup and deep understanding: + +- **Quick Pattern**: Incorrect/Correct code snippets for immediate pattern matching +- **Quick Command**: Shell commands for process/measurement skills +- **Quick Config**: Configuration snippets for setup-focused skills +- **Quick Reference**: Summary tables for conceptual skills +- **Deep Dive**: Full context with When to Use, Prerequisites, Step-by-Step, Common Pitfalls + +**Impact ratings**: CRITICAL (fix immediately), HIGH (significant improvement), MEDIUM (worthwhile optimization) + +## When to Apply + +Reference these guidelines when: +- Debugging slow/janky UI or animations +- Investigating memory leaks (JS or native) +- Optimizing app startup time (TTI) +- Reducing bundle or app size +- Writing native modules (Turbo Modules) +- Profiling React Native performance +- Reviewing React Native code for performance + +## Security Notes + +- Treat shell commands in these references as local developer operations. Review them before running, prefer version-pinned tooling, and avoid piping remote scripts directly to a shell. +- Treat third-party libraries and plugins as dependencies that still require normal supply-chain controls: pin versions, verify provenance, and update through your standard review process. +- Treat Re.Pack code splitting as first-party artifact delivery only. Remote chunks must come from trusted HTTPS origins you control and be pinned to the current app release. + +## Priority-Ordered Guidelines + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | FPS & Re-renders | CRITICAL | `js-*` | +| 2 | Bundle Size | CRITICAL | `bundle-*` | +| 3 | TTI Optimization | HIGH | `native-*`, `bundle-*` | +| 4 | Native Performance | HIGH | `native-*` | +| 5 | Memory Management | MEDIUM-HIGH | `js-*`, `native-*` | +| 6 | Animations | MEDIUM | `js-*` | + +## Quick Reference + +### Optimization Workflow + +Follow this cycle for any performance issue: **Measure → Optimize → Re-measure → Validate** + +1. **Measure**: Capture baseline metrics before changes. For runtime issues, prefer commit timeline, re-render counts, slow components, heaviest-commit breakdown, and startup/TTI when available. Component tree depth or count are optional context, not substitutes. +2. **Optimize**: Apply the targeted fix from the relevant reference +3. **Re-measure**: Run the same measurement to get updated metrics +4. **Validate**: Confirm improvement (e.g., FPS 45→60, TTI 3.2s→1.8s, bundle 2.1MB→1.6MB) + +If metrics did not improve, revert and try the next suggested fix. + +### Review Guardrails + +- Check library versions before suggesting API-specific fixes. Example: FlashList v2 deprecates `estimatedItemSize`, so do not flag it as missing there. +- Do not suggest `useMemo` or `useCallback` dependency changes unless behavior is demonstrably incorrect or profiling shows wasted work tied to that value. +- Do not report stale closures speculatively. Show the stale read path, a repro, or profiler evidence before calling it out. +- When profiling a flow, measure the target interaction itself. Do not treat component tree depth or component count as the main performance evidence. + +### Critical: FPS & Re-renders + +**Profile first:** +```bash +# Open React Native DevTools +# Press 'j' in Metro, or shake device → "Open DevTools" +``` + +**Common fixes:** +- Replace ScrollView with FlatList/FlashList for lists +- Use React Compiler for automatic memoization +- Use atomic state (Jotai/Zustand) to reduce re-renders +- Use `useDeferredValue` for expensive computations + +### Critical: Bundle Size + +**Analyze bundle:** +```bash +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false --minify true + +npx source-map-explorer output.js --no-border-checks +``` + +**Verify improvement after optimization:** +```bash +# Record baseline size before changes +ls -lh output.js # e.g., Before: 2.1 MB + +# After applying fixes, re-bundle and compare +npx react-native bundle --entry-file index.js --bundle-output output.js \ + --platform ios --dev false --minify true +ls -lh output.js # e.g., After: 1.6 MB (24% reduction) +``` + +**Common fixes:** +- Avoid barrel imports (import directly from source) +- Remove unnecessary Intl polyfills only after checking Hermes API and method coverage +- Enable tree shaking (Expo SDK 52+ or Re.Pack) +- Enable R8 for Android native code shrinking + +### High: TTI Optimization + +**Measure TTI:** +- Use `react-native-performance` for markers +- Only measure cold starts (exclude warm/hot/prewarm) + +**Common fixes:** +- Disable JS bundle compression on Android (enables Hermes mmap) +- Use native navigation (react-native-screens) +- Preload commonly-used expensive screens before navigating to them + +### High: Native Performance + +**Profile native:** +- iOS: Xcode Instruments → Time Profiler +- Android: Android Studio → CPU Profiler + +**Common fixes:** +- Use background threads for heavy native work +- Prefer async over sync Turbo Module methods +- Use C++ for cross-platform performance-critical code + +## References + +Full documentation with code examples in [references/][references]: + +### JavaScript/React (`js-*`) + +| File | Impact | Description | +|------|--------|-------------| +| [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] | CRITICAL | Replace ScrollView with virtualized lists | +| [js-profile-react.md][js-profile-react] | MEDIUM | React DevTools profiling | +| [js-measure-fps.md][js-measure-fps] | HIGH | FPS monitoring and measurement | +| [js-memory-leaks.md][js-memory-leaks] | MEDIUM | JS memory leak hunting | +| [js-atomic-state.md][js-atomic-state] | HIGH | Jotai/Zustand patterns | +| [js-concurrent-react.md][js-concurrent-react] | HIGH | useDeferredValue, useTransition | +| [js-react-compiler.md][js-react-compiler] | HIGH | Automatic memoization | +| [js-animations-reanimated.md][js-animations-reanimated] | MEDIUM | Reanimated worklets | +| [js-bottomsheet.md][js-bottomsheet] | HIGH | Bottom sheet optimization | +| [js-uncontrolled-components.md][js-uncontrolled-components] | HIGH | TextInput optimization | + +### Native (`native-*`) + +| File | Impact | Description | +|------|--------|-------------| +| [native-turbo-modules.md][native-turbo-modules] | HIGH | Building fast native modules | +| [native-sdks-over-polyfills.md][native-sdks-over-polyfills] | HIGH | Native vs JS libraries | +| [native-measure-tti.md][native-measure-tti] | HIGH | TTI measurement setup | +| [native-threading-model.md][native-threading-model] | HIGH | Turbo Module threads | +| [native-profiling.md][native-profiling] | MEDIUM | Xcode/Android Studio profiling | +| [native-platform-setup.md][native-platform-setup] | MEDIUM | iOS/Android tooling guide | +| [native-view-flattening.md][native-view-flattening] | MEDIUM | View hierarchy debugging | +| [native-memory-patterns.md][native-memory-patterns] | MEDIUM | C++/Swift/Kotlin memory | +| [native-memory-leaks.md][native-memory-leaks] | MEDIUM | Native memory leak hunting | +| [native-android-16kb-alignment.md][native-android-16kb-alignment] | CRITICAL | Third-party library alignment for Google Play | + +### Bundling (`bundle-*`) + +| File | Impact | Description | +|------|--------|-------------| +| [bundle-barrel-exports.md][bundle-barrel-exports] | CRITICAL | Avoid barrel imports | +| [bundle-analyze-js.md][bundle-analyze-js] | CRITICAL | JS bundle visualization | +| [bundle-tree-shaking.md][bundle-tree-shaking] | HIGH | Dead code elimination | +| [bundle-analyze-app.md][bundle-analyze-app] | HIGH | App size analysis | +| [bundle-r8-android.md][bundle-r8-android] | HIGH | Android code shrinking | +| [bundle-hermes-mmap.md][bundle-hermes-mmap] | HIGH | Disable bundle compression | +| [bundle-native-assets.md][bundle-native-assets] | HIGH | Asset catalog setup | +| [bundle-library-size.md][bundle-library-size] | MEDIUM | Evaluate dependencies | +| [bundle-code-splitting.md][bundle-code-splitting] | MEDIUM | Re.Pack code splitting | + + +## Searching References + +```bash +# Find patterns by keyword +grep -l "reanimated" references/ +grep -l "flatlist" references/ +grep -l "memory" references/ +grep -l "profil" references/ +grep -l "tti" references/ +grep -l "bundle" references/ +``` + +## Problem → Skill Mapping + +| Problem | Start With | +|---------|------------| +| App feels slow/janky | [js-measure-fps.md][js-measure-fps] → [js-profile-react.md][js-profile-react] | +| Too many re-renders | [js-profile-react.md][js-profile-react] → [js-react-compiler.md][js-react-compiler] | +| Slow startup (TTI) | [native-measure-tti.md][native-measure-tti] → [bundle-analyze-js.md][bundle-analyze-js] | +| Large app size | [bundle-analyze-app.md][bundle-analyze-app] → [bundle-r8-android.md][bundle-r8-android] | +| Memory growing | [js-memory-leaks.md][js-memory-leaks] or [native-memory-leaks.md][native-memory-leaks] | +| Animation drops frames | [js-animations-reanimated.md][js-animations-reanimated] | +| Bottom sheet jank/re-renders | [js-bottomsheet.md][js-bottomsheet] → [js-animations-reanimated.md][js-animations-reanimated] | +| List scroll jank | [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] | +| TextInput lag | [js-uncontrolled-components.md][js-uncontrolled-components] | +| Native module slow | [native-turbo-modules.md][native-turbo-modules] → [native-threading-model.md][native-threading-model] | +| Native library alignment issue | [native-android-16kb-alignment.md][native-android-16kb-alignment] | + +[references]: references/ +[js-lists-flatlist-flashlist]: references/js-lists-flatlist-flashlist.md +[js-profile-react]: references/js-profile-react.md +[js-measure-fps]: references/js-measure-fps.md +[js-memory-leaks]: references/js-memory-leaks.md +[js-atomic-state]: references/js-atomic-state.md +[js-concurrent-react]: references/js-concurrent-react.md +[js-react-compiler]: references/js-react-compiler.md +[js-animations-reanimated]: references/js-animations-reanimated.md +[js-bottomsheet]: references/js-bottomsheet.md +[js-uncontrolled-components]: references/js-uncontrolled-components.md +[native-turbo-modules]: references/native-turbo-modules.md +[native-sdks-over-polyfills]: references/native-sdks-over-polyfills.md +[native-measure-tti]: references/native-measure-tti.md +[native-threading-model]: references/native-threading-model.md +[native-profiling]: references/native-profiling.md +[native-platform-setup]: references/native-platform-setup.md +[native-view-flattening]: references/native-view-flattening.md +[native-memory-patterns]: references/native-memory-patterns.md +[native-memory-leaks]: references/native-memory-leaks.md +[native-android-16kb-alignment]: references/native-android-16kb-alignment.md +[bundle-barrel-exports]: references/bundle-barrel-exports.md +[bundle-analyze-js]: references/bundle-analyze-js.md +[bundle-tree-shaking]: references/bundle-tree-shaking.md +[bundle-analyze-app]: references/bundle-analyze-app.md +[bundle-r8-android]: references/bundle-r8-android.md +[bundle-hermes-mmap]: references/bundle-hermes-mmap.md +[bundle-native-assets]: references/bundle-native-assets.md +[bundle-library-size]: references/bundle-library-size.md +[bundle-code-splitting]: references/bundle-code-splitting.md + +## Attribution + +Based on "The Ultimate Guide to React Native Optimization" by Callstack. diff --git a/.forge/skills/react-native-best-practices/agents/openai.yaml b/.forge/skills/react-native-best-practices/agents/openai.yaml new file mode 100644 index 00000000..48c283d0 --- /dev/null +++ b/.forge/skills/react-native-best-practices/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "React Native Best Practices" + short_description: "React Native performance optimization guide" + default_prompt: "Use $react-native-best-practices to diagnose and improve React Native performance." diff --git a/.forge/skills/react-native-best-practices/references/bundle-analyze-app.md b/.forge/skills/react-native-best-practices/references/bundle-analyze-app.md new file mode 100644 index 00000000..71136852 --- /dev/null +++ b/.forge/skills/react-native-best-practices/references/bundle-analyze-app.md @@ -0,0 +1,211 @@ +--- +title: Analyze App Bundle Size +impact: HIGH +tags: app-size, ruler, emerge-tools, thinning +--- + +# Skill: Analyze App Bundle Size + +Measure iOS and Android app download/install sizes using Ruler, App Store Connect, and Emerge Tools. + +## Quick Command + +```bash +# Android (Ruler) +cd android && ./gradlew analyzeReleaseBundle + +# iOS (Xcode export with thinning) +cd ios && xcodebuild -exportArchive \ + -archivePath MyApp.xcarchive \ + -exportPath ./export \ + -exportOptionsPlist ExportOptions.plist +# Check: App Thinning Size Report.txt +``` + +## When to Use + +- App download size is too large +- Users complain about storage usage +- App approaching store limits +- Comparing releases for size regression + +> **Note**: This skill involves interpreting visual size reports (Ruler, Emerge Tools X-Ray). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the reports manually, or await MCP-based visual feedback integration (see roadmap). + +## Key Metrics + +| Metric | Description | User Impact | +|--------|-------------|-------------| +| Download Size | Compressed, transferred over network | Download time, data usage | +| Install Size | Uncompressed, on device storage | Storage space | + +**Google finding**: Every 6 MB increase reduces installs by 1%. + +## Android: Ruler (Spotify) + +### Setup + +Add to `android/build.gradle`: + +```groovy +buildscript { + dependencies { + classpath("com.spotify.ruler:ruler-gradle-plugin:2.0.0-beta-3") + } +} +``` + +Add to `android/app/build.gradle`: + +```groovy +apply plugin: "com.spotify.ruler" + +ruler { + abi.set("arm64-v8a") // Target architecture + locale.set("en") + screenDensity.set(480) + sdkVersion.set(34) +} +``` + +### Analyze + +```bash +cd android +./gradlew analyzeReleaseBundle +``` + +Opens HTML report with: +- Download size +- Install size +- Component breakdown (biggest → smallest) + +### CI Size Validation + +```groovy +ruler { + verification { + downloadSizeThreshold = 20 * 1024 * 1024 // 20 MB + installSizeThreshold = 50 * 1024 * 1024 // 50 MB + } +} +``` + +Build fails if thresholds exceeded. + +## iOS: Xcode App Thinning + +### Via App Store Connect (Most Accurate) + +After uploading to TestFlight: +1. Open App Store Connect +2. Go to your build +3. View size table by device variant + +**Note**: TestFlight builds include debug data, App Store builds slightly larger due to DRM. + +### Via Xcode Export + +1. Archive app: **Product → Archive** +2. In Organizer, click **Distribute App** +3. Select **Custom** +4. Choose **App Thinning: All compatible device variants** + +Or in `ExportOptions.plist`: + +```xml +thinning +<thin-for-all-variants> +``` + +### Output + +Creates folder with: +- **Universal IPA**: All variants combined +- **Thinned IPAs**: One per device variant +- **App Thinning Size Report.txt**: + +``` +Variant: SampleApp-.ipa +App + On Demand Resources size: 3.5 MB compressed, 10.6 MB uncompressed +App size: 3.5 MB compressed, 10.6 MB uncompressed +``` + +- Compressed = Download size +- Uncompressed = Install size + +## Emerge Tools (Cross-Platform) + +Third-party service with visual analysis. + +### Upload + +Upload IPA, APK, or AAB through their web interface or CI integration. + +### Features + +![Emerge Tools X-Ray for iOS](images/emerge-xray-ios.png) + +- **X-Ray**: Treemap visualization (like source-map-explorer for binaries) + - Shows Frameworks (hermes.framework), Mach-O sections (TEXT, DATA), etc. + - Color-coded: Binaries, Localizations, Fonts, Asset Catalogs, Videos, CoreML Models + - Visible components: `main.jsbundle` (JS code), RCT modules, DYLD sections +- **Breakdown**: Component-by-component size +- **Insights**: Automated suggestions (use with caution) + +**Caution**: Some suggestions may not apply to React Native (e.g., "remove Hermes"). + +## Size Comparison + +| Tool | Platform | Accuracy | CI Integration | +|------|----------|----------|----------------| +| Ruler | Android | High | Yes (Gradle) | +| App Store Connect | iOS | Highest | No | +| Xcode Export | iOS | High | Yes (xcodebuild) | +| Emerge Tools | Both | High | Yes (API) | + +## Typical React Native App Sizes + +| Component | Approximate Size | +|-----------|------------------| +| Hermes engine | ~2-3 MB | +| React Native core | ~3-5 MB | +| JavaScript bundle | 1-10 MB | +| Assets (images, etc.) | Varies | + +**Baseline empty app**: ~6-10 MB download + +## Optimization Impact Example + +| Optimization | Size Reduction | +|--------------|----------------| +| Enable R8 (Android) | ~30% | +| Remove unused polyfills | 400+ KB | +| Asset catalog (iOS) | 10-50% of assets | +| Tree shaking | 10-15% | + +## Quick Commands + +```bash +# Android release bundle size +cd android && ./gradlew bundleRelease +# Check: android/app/build/outputs/bundle/release/ + +# iOS archive +cd ios && xcodebuild -workspace ios/MyApp.xcworkspace \ + -scheme MyApp \ + -configuration Release \ + -archivePath MyApp.xcarchive \ + archive + +# Export with thinning report +cd ios && xcodebuild -exportArchive \ + -archivePath MyApp.xcarchive \ + -exportPath ./export \ + -exportOptionsPlist ExportOptions.plist +``` + +## Related Skills + +- [bundle-r8-android.md](./bundle-r8-android.md) - Reduce Android size +- [bundle-native-assets.md](./bundle-native-assets.md) - Optimize asset delivery +- [bundle-analyze-js.md](./bundle-analyze-js.md) - JS bundle analysis diff --git a/.forge/skills/react-native-best-practices/references/bundle-analyze-js.md b/.forge/skills/react-native-best-practices/references/bundle-analyze-js.md new file mode 100644 index 00000000..531a4eab --- /dev/null +++ b/.forge/skills/react-native-best-practices/references/bundle-analyze-js.md @@ -0,0 +1,262 @@ +--- +title: Analyze JS Bundle Size +impact: CRITICAL +tags: bundle, analysis, source-map-explorer, expo-atlas +--- + +# Skill: Analyze JS Bundle Size + +Use source-map-explorer and Expo Atlas to visualize what's in your JavaScript bundle. + +## Quick Command + +```bash +# React Native CLI +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false --minify true && \ +npx source-map-explorer output.js --no-border-checks + +# Expo +EXPO_UNSTABLE_ATLAS=true npx expo export --platform ios && npx expo-atlas +``` + +## When to Use + +- JS bundle seems too large +- Want to identify heavy dependencies +- Investigating startup time issues +- Before/after optimization comparison + +> **Note**: This skill involves interpreting visual treemap output (source-map-explorer, Expo Atlas). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the visualization manually, or await MCP-based visual feedback integration (see roadmap). + +## Understanding Hermes Bytecode + +Modern React Native (0.70+) uses Hermes bytecode, not raw JavaScript: +- Skips parsing at runtime +- Still benefits from smaller bundles +- Heavy imports still execute on startup + +**Impact of bundle size:** +- Larger bytecode = longer download from store +- More imports on init path = slower TTI + +## Method 1: source-map-explorer + +### Generate Bundle with Source Map + +**React Native CLI:** + +```bash +npx react-native bundle \ + --entry-file index.js \ + --bundle-output output.js \ + --platform ios \ + --sourcemap-output output.js.map \ + --dev false \ + --minify true +``` + +**Expo (SDK 51+):** + +```bash +npx expo export --platform ios --source-maps --output-dir dist +# Bundle at: dist/ios/_expo/static/js/ios/*.js +# Source map at: dist/ios/_expo/static/js/ios/*.map +``` + +### Analyze + +```bash +npx source-map-explorer output.js --no-border-checks +``` + +**Note**: `--no-border-checks` needed due to Metro's non-standard source maps. + +Opens browser with treemap visualization: + +![Bundle Treemap from source-map-explorer](images/bundle-treemap-source-map-explorer.png) + +The treemap shows: +- **Hierarchy**: `node_modules/` → `react-native/` → `Libraries/` → individual files +- **Size**: Box area proportional to file size (KB shown in labels) +- **Major components visible**: + - `react-native` (724.18 KB, 80.5%) + - `Renderer` (208.44 KB) - ReactNativeRenderer-prod.js, ReactFabric-prod.js + - `Components` (125.29 KB) - Touchable, ScrollView, etc. + - `Animated` (79.48 KB) - Animation system + - `virtualized-lists` (57.57 KB) - FlatList internals + +Click on any section to drill down into that directory. + +**Limitation**: May lose ~30% info due to mapping issues. + +## Method 2: Expo Atlas + +More accurate for Expo projects (or with workaround for bare RN). + +### For Expo Projects + +```bash +# Start with Atlas enabled +EXPO_UNSTABLE_ATLAS=true npx expo start --no-dev + +# Or export +EXPO_UNSTABLE_ATLAS=true npx expo export +``` + +Then launch UI: + +```bash +npx expo-atlas +``` + +![Expo Atlas Treemap](images/expo-atlas-treemap.png) + +Expo Atlas provides more accurate visualization for Expo projects, with similar treemap interface showing module sizes and dependencies. + +### For Non-Expo Projects + +Use `expo-atlas-without-expo` package. + +## Method 3: Re.Pack Bundle Analysis (Webpack/Rspack) + +If using Re.Pack: + +### webpack-bundle-analyzer + +```bash +rspack build --analyze +``` + +### bundle-stats / statoscope + +```bash +# Generate stats +npx react-native bundle \ + --platform android \ + --entry-file index.js \ + --dev false \ + --minify true \ + --json stats.json + +# Analyze +npx bundle-stats --html --json stats.json +``` + +### Rsdoctor + +```javascript +// rspack.config.js +const { RsdoctorRspackPlugin } = require('@rsdoctor/rspack-plugin'); + +module.exports = { + plugins: [ + process.env.RSDOCTOR && new RsdoctorRspackPlugin(), + ].filter(Boolean), +}; +``` + +Run with: + +```bash +RSDOCTOR=true npx react-native start +``` + +## What to Look For + +### Red Flags + +| Finding | Problem | Solution | +|---------|---------|----------| +| Entire library imported | Barrel exports | Use direct imports | +| Duplicate packages | Multiple versions | Dedupe in package.json | +| Dev dependencies in bundle | Incorrect imports | Check conditional imports | +| Large polyfills | Unnecessary for Hermes | Remove (see native-sdks-over-polyfills.md) | +| Moment.js with locales | Bloated date library | Switch to date-fns or dayjs | + +### Common Offenders + +- **Lodash full import**: Use `lodash-es` or specific imports +- **Moment.js**: Replace with `date-fns` or `dayjs` +- **Intl polyfills**: Check Hermes API and method coverage before removing them +- **AWS SDK**: Import specific services only + +## Code Examples + +### Identify Barrel Import Impact + +```tsx +// BAD: Imports entire library through barrel +import { format } from 'date-fns'; + +// In bundle: All of date-fns loaded + +// GOOD: Direct import +import format from 'date-fns/format'; + +// In bundle: Only format function +``` + +## Comparing Bundles + +### source-map-explorer + +```bash +# Generate baseline +npx react-native bundle ... --bundle-output baseline.js --sourcemap-output baseline.js.map + +# Make changes, generate new bundle +npx react-native bundle ... --bundle-output current.js --sourcemap-output current.js.map + +# Compare manually in browser +``` + +### Re.Pack (automated) + +```bash +npx bundle-stats compare baseline-stats.json current-stats.json +``` + +## Quick Commands + +**React Native CLI:** + +```bash +# iOS bundle analysis +npx react-native bundle \ + --entry-file index.js \ + --bundle-output ios-bundle.js \ + --platform ios \ + --sourcemap-output ios-bundle.js.map \ + --dev false \ + --minify true && \ +npx source-map-explorer ios-bundle.js --no-border-checks + +# Android bundle analysis +npx react-native bundle \ + --entry-file index.js \ + --bundle-output android-bundle.js \ + --platform android \ + --sourcemap-output android-bundle.js.map \ + --dev false \ + --minify true && \ +npx source-map-explorer android-bundle.js --no-border-checks +``` + +**Expo:** + +```bash +# Use Expo Atlas (recommended for Expo projects) +EXPO_UNSTABLE_ATLAS=true npx expo export --platform ios +npx expo-atlas +``` + +## Related Skills + +- [bundle-barrel-exports.md](./bundle-barrel-exports.md) - Fix barrel import issues +- [bundle-tree-shaking.md](./bundle-tree-shaking.md) - Enable dead code elimination +- [bundle-library-size.md](./bundle-library-size.md) - Check library sizes before adding diff --git a/.forge/skills/react-native-best-practices/references/bundle-barrel-exports.md b/.forge/skills/react-native-best-practices/references/bundle-barrel-exports.md new file mode 100644 index 00000000..42b37f28 --- /dev/null +++ b/.forge/skills/react-native-best-practices/references/bundle-barrel-exports.md @@ -0,0 +1,248 @@ +--- +title: Avoid Barrel Exports +impact: CRITICAL +tags: bundle, imports, barrel, tree-shaking +--- + +# Skill: Avoid Barrel Exports + +Refactor barrel imports (index files) to reduce bundle size and improve startup time. + +## Quick Pattern + +**Incorrect:** + +```tsx +import { Button } from './components'; +// Loads ALL exports from components/index.ts +``` + +**Correct:** + +```tsx +import Button from './components/Button'; +// Loads only Button +``` + +## When to Use + +- Bundle contains unused code from libraries +- Circular dependency warnings in Metro +- Hot Module Replacement (HMR) breaks frequently +- TTI is slow due to module evaluation + +## What Are Barrel Exports? + +```tsx +// components/index.ts (barrel file) +export { Button } from './Button'; +export { Card } from './Card'; +export { Modal } from './Modal'; +export { Sidebar } from './Sidebar'; + +// Usage (barrel import) +import { Button } from './components'; +``` + +## Problems with Barrel Imports + +### 1. Bundle Size Overhead + +Metro includes **all exports** even if you use one: + +```tsx +// Only need Button, but entire barrel is bundled +import { Button } from './components'; +// Card, Modal, Sidebar also included! +``` + +### 2. Runtime Overhead + +All modules evaluate before returning your import: + +```tsx +import { Button } from './components'; +// JavaScript must evaluate: +// - Button.tsx +// - Card.tsx +// - Modal.tsx +// - Sidebar.tsx +// Even though you only use Button +``` + +### 3. Circular Dependencies + +Barrel files make cycles easier to create accidentally: + +``` +Warning: Require cycle: + components/index.ts -> Button.tsx -> utils/index.ts -> components/index.ts +``` + +Breaks HMR, causes unpredictable behavior. + +## Solution 1: Direct Imports + +Replace barrel imports with direct paths: + +```tsx +// BEFORE: Barrel import +import { Button, Card } from './components'; + +// AFTER: Direct imports +import Button from './components/Button'; +import Card from './components/Card'; +``` + +### Enforce with ESLint + +```bash +npm install -D eslint-plugin-no-barrel-files +``` + +```javascript +// eslint.config.js +import noBarrelFiles from 'eslint-plugin-no-barrel-files'; + +export default [ + { + plugins: { 'no-barrel-files': noBarrelFiles }, + rules: { + 'no-barrel-files/no-barrel-files': 'error', + }, + }, +]; +``` + +## Solution 2: Tree Shaking (Automatic) + +Enable tree shaking to automatically remove unused barrel exports. + +### Expo SDK 52+ + +```tsx +// metro.config.js +const { getDefaultConfig } = require('expo/metro-config'); +const config = getDefaultConfig(__dirname); + +config.transformer.getTransformOptions = async () => ({ + transform: { + experimentalImportSupport: true, + }, +}); + +module.exports = config; +``` + +```bash +# .env +EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1 +EXPO_UNSTABLE_TREE_SHAKING=1 +``` + +### metro-serializer-esbuild + +```bash +npm install @rnx-kit/metro-serializer-esbuild +``` + +### Re.Pack (Webpack/Rspack) + +Tree shaking built-in. + +## Real-World Example: date-fns + +```tsx +// BAD: Imports entire library +import { format, addDays, isToday } from 'date-fns'; + +// GOOD: Direct imports +import format from 'date-fns/format'; +import addDays from 'date-fns/addDays'; +import isToday from 'date-fns/isToday'; +``` + +## Library-Specific Solutions + +Some libraries provide Babel plugins: + +### React Native Paper + +```javascript +// babel.config.js +module.exports = { + plugins: [ + 'react-native-paper/babel', // Auto-transforms imports + ], +}; +``` + +Transforms: +```tsx +import { Button } from 'react-native-paper'; +// Into: +import Button from 'react-native-paper/lib/module/components/Button'; +``` + +## Refactoring Strategy + +### Step 1: Identify Barrel Files + +Look for `index.ts` files with multiple exports: + +```bash +grep -r "export \* from" src/ +grep -r "export { .* } from" src/ +``` + +### Step 2: Update Imports + +```tsx +// Find all usages +// VS Code: Cmd+Shift+F for "from './components'" + +// Replace each with direct import +import Button from './components/Button'; +``` + +### Step 3: (Optional) Keep Barrel for External API + +If your package is consumed by others: + +```tsx +// Keep index.ts for package API +// components/index.ts +export { Button } from './Button'; + +// Internal code uses direct imports +// src/screens/Home.tsx +import Button from '../components/Button'; +``` + +## Migration Script Example + +```bash +# Use codemod or search-replace +# Find: import { (\w+) } from '\.\/components'; +# Replace: import $1 from './components/$1'; +``` + +## Verification + +After refactoring: + +1. Run bundle analysis (see [bundle-analyze-js.md](./bundle-analyze-js.md)) +2. Compare sizes before/after +3. Check for circular dependency warnings + +## Common Pitfalls + +- **Breaking external consumers**: If publishing a library, keep barrel for public API +- **IDE auto-imports**: Configure IDE to prefer direct imports +- **Inconsistent patterns**: Enforce with ESLint across team + +## Related Skills + +- [bundle-analyze-js.md](./bundle-analyze-js.md) - Verify impact +- [bundle-tree-shaking.md](./bundle-tree-shaking.md) - Automatic solution +- [bundle-library-size.md](./bundle-library-size.md) - Check library patterns diff --git a/.forge/skills/react-native-best-practices/references/bundle-code-splitting.md b/.forge/skills/react-native-best-practices/references/bundle-code-splitting.md new file mode 100644 index 00000000..9eb18f2b --- /dev/null +++ b/.forge/skills/react-native-best-practices/references/bundle-code-splitting.md @@ -0,0 +1,247 @@ +--- +title: Remote Code Loading +impact: MEDIUM +tags: code-splitting, repack, lazy-loading, chunks +--- + +# Skill: Remote Code Loading + +Set up code splitting with Re.Pack for on-demand bundle loading from trusted, first-party assets. + +## Quick Pattern + +**Before (static import):** + +```jsx +import SettingsScreen from './screens/SettingsScreen'; +``` + +**After (lazy loaded chunk):** + +```jsx +const SettingsScreen = React.lazy(() => + import(/* webpackChunkName: "settings" */ './screens/SettingsScreen') +); + +}> + + +``` + +## When to Use + +Consider code splitting when: +- **Not using Hermes** (JSC/V8 benefits more) +- App size exceeds 200 MB (Play Store limit) +- Building micro-frontend architecture +- Loading features based on user permissions +- Other optimizations exhausted + +**Note**: Hermes already uses memory mapping for efficient bundle reading. Benefits of code splitting are minimal with Hermes or even counterproductive in some cases. + +## Security Model + +Remote chunks are executable application code. Only load chunks that you build and publish yourself. + +Keep these guardrails in place: +- Serve chunks only from a first-party, HTTPS-only origin you control +- Resolve `scriptId` through a fixed allowlist or release manifest +- Fail closed if a chunk is missing or unexpected +- Do not load chunks from user-controlled input, query params, or third-party domains + +## Prerequisites + +- Re.Pack installed (replaces Metro) + +```bash +npx @callstack/repack-init +``` + +## Step-by-Step Instructions + +### 1. Initialize Re.Pack + +```bash +npx @callstack/repack-init +``` + +Follow prompts to migrate from Metro. Check [migration guide](https://re-pack.dev/docs/getting-started/quick-start). + +### 2. Create Split Point with React.lazy + +```tsx +// BEFORE: Static import +import SettingsScreen from './screens/SettingsScreen'; + +// AFTER: Dynamic import (creates split point) +const SettingsScreen = React.lazy(() => + import(/* webpackChunkName: "settings" */ './screens/SettingsScreen') +); +``` + +### 3. Wrap with Suspense + +```tsx +import React, { Suspense } from 'react'; + +const App = () => { + return ( + }> + + + ); +}; +``` + +### 4. Configure Chunk Loading + +```jsx +// index.js (before AppRegistry) +import { ScriptManager, Script } from '@callstack/repack/client'; + +const CHUNK_URLS = { + settings: 'https://assets.example.com/app/v42/settings.chunk.bundle', +}; + +ScriptManager.shared.addResolver((scriptId) => ({ + url: __DEV__ ? Script.getDevServerURL(scriptId) : getChunkUrl(scriptId), +})); + +function getChunkUrl(scriptId) { + const url = CHUNK_URLS[scriptId]; + + if (!url) { + throw new Error(`Unknown chunk: ${scriptId}`); + } + + return url; +} + +AppRegistry.registerComponent(appName, () => App); +``` + +### 5. Build and Deploy Chunks + +Build generates: +- `index.bundle` - Main bundle +- `settings.chunk.bundle` - Lazy-loaded chunk + +Deploy chunks to a first-party CDN with versioned paths, and keep the allowlist or manifest in sync with the app release. + +## Complete Example + +```tsx +// App.tsx +import React, { Suspense, useState } from 'react'; +import { Button, View, ActivityIndicator } from 'react-native'; + +// Lazy load heavy feature +const HeavyFeature = React.lazy(() => + import(/* webpackChunkName: "heavy-feature" */ './HeavyFeature') +); + +const App = () => { + const [showFeature, setShowFeature] = useState(false); + + return ( + + )} + + ( + 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 014cf8db..d2d9a291 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 2e6cc00b..7875ebf6 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 b1b374a8..1a26241f 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(); @@ -152,6 +155,7 @@ export default function NewCall() { plusCode: '', latitude: undefined, longitude: undefined, + destinationPoiId: '', priority: '', type: '', contactName: '', @@ -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, @@ -833,6 +837,20 @@ export default function NewCall() { )} + + ( + onChange(poiId != null ? poiId.toString() : '')} + /> + )} + />
diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 5d468558..8796fbb4 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -1,27 +1,23 @@ import { useRouter } from 'expo-router'; import { Bell, ChevronRight, MapPin, Users } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useEffect, useRef, useState } from 'react'; -import { Dimensions, Image } from 'react-native'; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; - -import { FocusAwareStatusBar, SafeAreaView, View } from '@/components/ui'; -import { Button, ButtonText } from '@/components/ui/button'; -import { FlatList } from '@/components/ui/flat-list'; -import { Pressable } from '@/components/ui/pressable'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Image, Pressable, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; + +import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; import { Text } from '@/components/ui/text'; import { useAuthStore } from '@/lib/auth'; import { useIsFirstTime } from '@/lib/storage'; -const { width } = Dimensions.get('window'); - type OnboardingItemProps = { title: string; description: string; icon: React.ReactNode; }; -const onboardingData: OnboardingItemProps[] = [ +const SLIDES = [ { title: 'Resgrid Unit', description: "Track your unit's location and status in real-time with our advanced mapping and AVL system", @@ -39,68 +35,127 @@ const onboardingData: OnboardingItemProps[] = [ }, ]; -const OnboardingItem: React.FC = ({ title, description, icon }) => { - return ( - - {icon} - {title} - - {description} - - - ); -}; - -const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ currentIndex, length }) => { - return ( - - {Array.from({ length }).map((_, index) => ( - - ))} - - ); -}; +const SLIDE_COUNT = SLIDES.length; +const SPRING_CONFIG = { damping: 25, stiffness: 200 }; + +const OnboardingSlide: React.FC = ({ title, description, icon, slideWidth }) => ( + + {icon} + {title} + + {description} + + +); + +const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ currentIndex, length }) => ( + + {Array.from({ length }).map((_, index) => ( + + ))} + +); export default function Onboarding() { const [_, setIsFirstTime] = useIsFirstTime(); const setIsOnboarding = useAuthStore((state) => state.setIsOnboarding); const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(0); - const flatListRef = useRef>(null); - const buttonOpacity = useSharedValue(0); + const [containerWidthState, setContainerWidthState] = useState(0); + const containerWidth = useSharedValue(0); const { colorScheme } = useColorScheme(); + // Shared value tracks slide position as an index offset (e.g. 0 = first slide, 1 = second) + const slideOffset = useSharedValue(0); + const buttonOpacity = useSharedValue(0); + const startOffset = useSharedValue(0); + useEffect(() => { setIsOnboarding(); }, [setIsOnboarding]); - const handleScroll = (event: { nativeEvent: { contentOffset: { x: number } } }) => { - const index = Math.round(event.nativeEvent.contentOffset.x / width); - setCurrentIndex(index); + const updateIndex = useCallback( + (index: number) => { + setCurrentIndex(index); + buttonOpacity.value = index === SLIDE_COUNT - 1 ? withTiming(1, { duration: 500 }) : withTiming(0, { duration: 300 }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); - // Show button with animation when on the last slide - if (index === onboardingData.length - 1) { - buttonOpacity.value = withTiming(1, { duration: 500 }); - } else { - buttonOpacity.value = withTiming(0, { duration: 300 }); + // Sync shared value → JS state whenever the slide settles + useAnimatedReaction( + () => Math.round(slideOffset.value), + (result, previous) => { + if (result !== previous && result >= 0 && result < SLIDE_COUNT) { + runOnJS(updateIndex)(result); + } } - }; - - const nextSlide = () => { - if (currentIndex < onboardingData.length - 1) { - flatListRef.current?.scrollToIndex({ - index: currentIndex + 1, - animated: true, - }); - } - }; + ); - const buttonAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: buttonOpacity.value, - transform: [{ translateY: (1 - buttonOpacity.value) * 20 }], - }; - }); + // Swipe gesture: pan horizontally, snap to nearest slide on release + const panGesture = Gesture.Pan() + .activeOffsetX([-15, 15]) + .onStart(() => { + 'worklet'; + startOffset.value = slideOffset.value; + }) + .onUpdate((event) => { + 'worklet'; + const dragIndex = -event.translationX / containerWidth.value; + const target = startOffset.value + dragIndex; + slideOffset.value = Math.max(-0.3, Math.min(SLIDE_COUNT - 1 + 0.3, target)); + }) + .onEnd(() => { + 'worklet'; + const snapped = Math.round(slideOffset.value); + const clamped = Math.max(0, Math.min(SLIDE_COUNT - 1, snapped)); + slideOffset.value = withSpring(clamped, SPRING_CONFIG); + }); + + const nextSlide = useCallback(() => { + const next = Math.min(currentIndex + 1, SLIDE_COUNT - 1); + slideOffset.value = withSpring(next, SPRING_CONFIG); + }, [currentIndex, slideOffset]); + + const skip = useCallback(() => { + setIsFirstTime(false); + router.replace('/login'); + }, [setIsFirstTime, router]); + + const finish = useCallback(() => { + setIsFirstTime(false); + router.replace('/login'); + }, [setIsFirstTime, router]); + + const handleContainerLayout = useCallback( + (event: { nativeEvent: { layout: { width: number } } }) => { + const w = event.nativeEvent.layout.width; + containerWidth.value = w; + setContainerWidthState(w); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // The entire carousel translates on X + const carouselStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -slideOffset.value * containerWidth.value }], + })); + + const buttonAnimatedStyle = useAnimatedStyle(() => ({ + opacity: buttonOpacity.value, + transform: [{ translateY: (1 - buttonOpacity.value) * 20 }], + })); + + if (containerWidthState === 0) { + // Render only the outer container to measure width before laying out slides + return ( + + + ); + } return ( @@ -110,55 +165,35 @@ export default function Onboarding() { - - } - horizontal - showsHorizontalScrollIndicator={false} - pagingEnabled - bounces={false} - keyExtractor={(item) => item.title} - onScroll={handleScroll} - scrollEventThrottle={16} - className="flex-1" - /> + + + + {SLIDES.map((slide) => ( + + ))} + + - + - {currentIndex < onboardingData.length - 1 ? ( + {currentIndex < SLIDE_COUNT - 1 ? ( - { - setIsFirstTime(false); - router.replace('/login'); - }} - > + Skip - + ) : ( - + + Let's Get Started + )} diff --git a/src/app/routes/_layout.tsx b/src/app/routes/_layout.tsx index 4e1d7544..1a471bb6 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/directions.tsx b/src/app/routes/directions.tsx index f7a7ed54..74d8413b 100644 --- a/src/app/routes/directions.tsx +++ b/src/app/routes/directions.tsx @@ -1,21 +1,72 @@ import { useLocalSearchParams } from 'expo-router'; -import { CheckCircleIcon, ClockIcon, ExternalLinkIcon, MapIcon, MapPinIcon, NavigationIcon } from 'lucide-react-native'; +import { AlertTriangleIcon, CheckCircleIcon, ClockIcon, ExternalLinkIcon, FlagIcon, MapIcon, MapPinIcon, NavigationIcon, PlayIcon, TimerIcon, TrafficConeIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Linking, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Linking, Platform, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; import { Loading } from '@/components/common/loading'; import { Camera, LineLayer, MapView, PointAnnotation, ShapeSource, StyleURL, UserLocation } from '@/components/maps/mapbox'; 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 { Env } from '@/lib/env'; import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; import { useRoutesStore } from '@/stores/routes/store'; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAPBOX_DIRECTIONS_API = 'https://api.mapbox.com/directions/v5/mapbox/driving-traffic'; +const MAPBOX_GEOCODING_API = 'https://api.mapbox.com/geocoding/v5/mapbox.places'; + +const DRIVING_PROFILE = 'driving-traffic'; // uses traffic-aware routing + +const statusColor: Record = { + [RouteStopStatus.Pending]: '#9ca3af', + [RouteStopStatus.InProgress]: '#3b82f6', + [RouteStopStatus.Completed]: '#22c55e', + [RouteStopStatus.Skipped]: '#eab308', +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DirectionsSegment { + /** GeoJSON LineString coordinates for this segment */ + coordinates: [number, number][]; + /** Distance in meters */ + distance: number; + /** Duration in seconds (with traffic) */ + duration: number; + /** Typical duration without traffic */ + durationTypical?: number; +} + +interface RouteDirectionsInfo { + totalDistance: number; + totalDuration: number; + totalDurationTypical: number; + segments: DirectionsSegment[]; + /** Concatenated GeoJSON for rendering */ + routeGeoJSON: GeoJSON.Feature; + /** Congestion segments extracted from Mapbox response */ + congestion: CongestionSegment[]; +} + +interface CongestionSegment { + coordinate: [number, number]; + level: 'low' | 'moderate' | 'heavy' | 'severe'; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + const openInMaps = (lat: number, lon: number, label: string) => { const url = Platform.select({ ios: `maps://app?daddr=${lat},${lon}&q=${encodeURIComponent(label)}`, @@ -40,19 +91,293 @@ const formatDuration = (seconds: number | null | undefined): string => { return `${hours}h ${mins % 60}m`; }; -const statusColor: Record = { - [RouteStopStatus.Pending]: '#9ca3af', - [RouteStopStatus.InProgress]: '#3b82f6', - [RouteStopStatus.Completed]: '#22c55e', - [RouteStopStatus.Skipped]: '#eab308', +/** Map congestion level string from the Mapbox API to a color */ +const congestionColor = (level: string): string => { + switch (level) { + case 'low': + return '#22c55e'; // green + case 'moderate': + return '#eab308'; // yellow + case 'heavy': + return '#f97316'; // orange + case 'severe': + return '#ef4444'; // red + default: + return '#3b82f6'; // blue (unknown / no data) + } }; +/** Derive a human-readable driving condition summary from congestion data */ +const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: string; color: string; icon: typeof TrafficConeIcon } => { + if (congestion.length === 0) { + return { label: 'No traffic data', color: '#9ca3af', icon: TrafficConeIcon }; + } + + const counts = { low: 0, moderate: 0, heavy: 0, severe: 0 }; + for (const seg of congestion) { + counts[seg.level]++; + } + const total = congestion.length; + + if (counts.severe / total > 0.15) { + return { label: 'Severe traffic', color: '#ef4444', icon: AlertTriangleIcon }; + } + if (counts.heavy / total > 0.2) { + return { label: 'Heavy traffic', color: '#f97316', icon: AlertTriangleIcon }; + } + if (counts.moderate / total > 0.3) { + return { label: 'Moderate traffic', color: '#eab308', icon: TrafficConeIcon }; + } + return { label: 'Light traffic', color: '#22c55e', icon: TrafficConeIcon }; +}; + +// --------------------------------------------------------------------------- +// Mapbox Directions API fetcher +// --------------------------------------------------------------------------- + +/** + * Fetches driving directions from the Mapbox Directions API for a set of + * coordinate waypoints (stop locations). Requests traffic-aware durations + * and congestion annotations. + * + * Returns parsed route geometry, durations, and congestion data, or null + * on failure (e.g., missing API key, network error). + */ +async function fetchMapboxDirections(waypoints: [number, number][]): Promise { + if (waypoints.length < 2) return null; + + const token = Env.UNIT_MAPBOX_PUBKEY; + if (!token) return null; + + // Build the coordinate string for Mapbox Directions API + // Format: lng1,lat1;lng2,lat2;... + const coords = waypoints.map(([lng, lat]) => `${lng},${lat}`).join(';'); + + const url = + `${MAPBOX_DIRECTIONS_API}/${coords}` + `?access_token=${token}` + `&geometries=geojson` + `&overview=full` + `&annotations=congestion,duration,distance` + `&steps=true` + `&continue_straight=true` + `&language=en`; + + try { + const response = await fetch(url); + if (!response.ok) return null; + + const data = await response.json(); + if (!data.routes || data.routes.length === 0) return null; + + const route = data.routes[0]; + + // Extract geometry coordinates + const routeCoords: [number, number][] = route.geometry?.coordinates ?? []; + + // Extract leg-level distance/duration + const segments: DirectionsSegment[] = []; + let totalDistance = 0; + let totalDuration = 0; + let totalDurationTypical = 0; + + for (const leg of route.legs ?? []) { + const legDistance: number = leg.distance ?? 0; + const legDuration: number = leg.duration ?? 0; + // Mapbox doesn't always return duration_typical, use duration as fallback + const legDurationTypical: number = leg.duration_typical ?? legDuration; + + totalDistance += legDistance; + totalDuration += legDuration; + totalDurationTypical += legDurationTypical; + + // Collect congestion annotations from the leg's annotation + const annotationCongestion: string[] = leg.annotation?.congestion ?? []; + + // Extract coordinates for this leg's segment from step geometries + let segmentCoords: [number, number][] = []; + for (const step of leg.steps ?? []) { + const stepCoords = step.geometry?.coordinates ?? []; + segmentCoords = segmentCoords.concat(stepCoords); + } + + segments.push({ + coordinates: segmentCoords.length > 0 ? segmentCoords : [], + distance: legDistance, + duration: legDuration, + durationTypical: legDurationTypical, + }); + } + + // Build congestion segments from the annotation + const congestion: CongestionSegment[] = []; + if (route.legs) { + for (const leg of route.legs) { + const annotationCongestion: string[] = leg.annotation?.congestion ?? []; + // Each annotation entry corresponds to a coordinate pair between nodes + // We sample along the leg to build congestion segments + const legCoords: [number, number][] = []; + for (const step of leg.steps ?? []) { + const stepCoords = step.geometry?.coordinates ?? []; + legCoords.push(...stepCoords); + } + // Map congestion annotations to coordinates (one per edge) + const edgesCount = Math.min(annotationCongestion.length, legCoords.length - 1); + for (let i = 0; i < edgesCount; i++) { + const level = annotationCongestion[i]; + if (level && level !== 'unknown') { + congestion.push({ + coordinate: legCoords[i], + level: level as CongestionSegment['level'], + }); + } + } + } + } + + // Build GeoJSON Feature from the full route geometry + const routeGeoJSON: GeoJSON.Feature = { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: routeCoords, + }, + }; + + return { + totalDistance, + totalDuration, + totalDurationTypical, + segments, + routeGeoJSON, + congestion, + }; + } catch { + return null; + } +} + +/** + * Build a fallback straight-line GeoJSON from stops when directions API is unavailable. + */ +function buildFallbackRoute(stops: { Longitude: number; Latitude: number; StopOrder: number }[]): GeoJSON.Feature { + const sorted = [...stops].sort((a, b) => a.StopOrder - b.StopOrder); + return { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: sorted.map((s) => [s.Longitude, s.Latitude] as [number, number]), + }, + }; +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/** Start marker: green with play icon label */ +function StartMarker({ name }: { name: string }) { + return ( + + + + + + + {name} + + + + ); +} + +/** End marker: red with flag icon label */ +function EndMarker({ name }: { name: string }) { + return ( + + + + + + + {name} + + + + ); +} + +/** Intermediate stop marker: numbered circle */ +function IntermediateStopMarker({ order, color }: { order: number; color: string }) { + return ( + + + {order} + + + ); +} + +const markerStyles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + pin: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 3, + borderColor: '#ffffff', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + }, + circle: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2.5, + borderColor: '#ffffff', + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 2, + }, + circleText: { + color: '#ffffff', + fontSize: 12, + fontWeight: 'bold', + }, + labelContainer: { + marginTop: 2, + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + maxWidth: 120, + }, + labelText: { + color: '#ffffff', + fontSize: 10, + fontWeight: '600', + textAlign: 'center', + }, +}); + +// --------------------------------------------------------------------------- +// Main screen +// --------------------------------------------------------------------------- + export default function RouteDirectionsScreen() { const { t } = useTranslation(); const { instanceId } = useLocalSearchParams<{ instanceId: string }>(); const { colorScheme } = useColorScheme(); const cameraRef = useRef(null); const [isMapReady, setIsMapReady] = useState(false); + const [isFetchingDirections, setIsFetchingDirections] = useState(false); + const [mapboxDirections, setMapboxDirections] = useState(null); const directions = useRoutesStore((s) => s.directions); const isLoadingDirections = useRoutesStore((s) => s.isLoadingDirections); @@ -60,20 +385,70 @@ export default function RouteDirectionsScreen() { const fetchDirections = useRoutesStore((s) => s.fetchDirections); const activeInstance = useRoutesStore((s) => s.activeInstance); const instanceStops = useRoutesStore((s) => s.instanceStops); + const fetchStopsForInstance = useRoutesStore((s) => s.fetchStopsForInstance); const resolvedInstanceId = (() => { const id = instanceId && instanceId !== 'undefined' ? instanceId : activeInstance?.RouteInstanceId; return id || undefined; })(); + // Fetch backend directions and stops useEffect(() => { if (resolvedInstanceId) { fetchDirections(resolvedInstanceId); + fetchStopsForInstance(resolvedInstanceId); + } + }, [resolvedInstanceId, fetchDirections, fetchStopsForInstance]); + + // Sorted stops for display + const sortedStops = useMemo(() => [...instanceStops].sort((a, b) => a.StopOrder - b.StopOrder), [instanceStops]); + + // Valid stops with coordinates + const validStops = useMemo(() => sortedStops.filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)), [sortedStops]); + + // First and last stops for distinct markers + const startStop = validStops.length > 0 ? validStops[0] : null; + const endStop = validStops.length > 1 ? validStops[validStops.length - 1] : null; + + // Intermediate stops (everything except first and last) + const intermediateStops = useMemo(() => { + if (validStops.length <= 2) return []; + return validStops.slice(1, -1); + }, [validStops]); + + // Fetch real driving directions from Mapbox API + useEffect(() => { + if (validStops.length < 2) return; + + let cancelled = false; + + const fetchDrivingDirections = async () => { + setIsFetchingDirections(true); + const waypoints: [number, number][] = validStops.map((s) => [s.Longitude, s.Latitude]); + + const result = await fetchMapboxDirections(waypoints); + + if (!cancelled) { + setMapboxDirections(result); + setIsFetchingDirections(false); + } + }; + + fetchDrivingDirections(); + + return () => { + cancelled = true; + }; + }, [validStops]); + + // Build the route GeoJSON: prefer Mapbox directions, fallback to backend, then straight-line + const routeGeoJson = useMemo((): GeoJSON.Feature | null => { + // 1. Best: Mapbox driving directions with real road geometry + if (mapboxDirections?.routeGeoJSON) { + return mapboxDirections.routeGeoJSON; } - }, [resolvedInstanceId, fetchDirections]); - // Build route GeoJSON from Geometry field or stop coordinates - const routeGeoJson = useMemo(() => { + // 2. Server-side directions geometry if (directions?.Geometry) { try { const parsed = JSON.parse(directions.Geometry); @@ -82,22 +457,106 @@ export default function RouteDirectionsScreen() { // fall through } } - const validStops = instanceStops.filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)); - if (validStops.length < 2) return null; - const sorted = [...validStops].sort((a, b) => a.StopOrder - b.StopOrder); + + // 3. Fallback: straight-line connections between stops + if (validStops.length >= 2) { + return buildFallbackRoute(validStops); + } + + return null; + }, [mapboxDirections, directions, validStops]); + + // Congestion overlay GeoJSON: colored line segments by traffic level + const congestionGeoJSON = useMemo((): GeoJSON.FeatureCollection | null => { + if (!mapboxDirections || mapboxDirections.congestion.length === 0) return null; + + // Group consecutive congestion points into features + const features: GeoJSON.Feature[] = []; + let currentLevel: string | null = null; + let currentCoords: [number, number][] = []; + + for (const seg of mapboxDirections.congestion) { + if (seg.level !== currentLevel) { + if (currentCoords.length >= 2 && currentLevel) { + features.push({ + type: 'Feature', + properties: { level: currentLevel, color: congestionColor(currentLevel) }, + geometry: { type: 'LineString', coordinates: currentCoords }, + }); + } + currentLevel = seg.level; + currentCoords = [seg.coordinate]; + } else { + currentCoords.push(seg.coordinate); + } + } + + // Flush last segment + if (currentCoords.length >= 2 && currentLevel) { + features.push({ + type: 'Feature', + properties: { level: currentLevel, color: congestionColor(currentLevel) }, + geometry: { type: 'LineString', coordinates: currentCoords }, + }); + } + + if (features.length === 0) return null; + return { - type: 'Feature' as const, - geometry: { - type: 'LineString' as const, - coordinates: sorted.map((s) => [s.Longitude, s.Latitude]), - }, - properties: {}, + type: 'FeatureCollection', + features, }; - }, [directions, instanceStops]); + }, [mapboxDirections]); - // Sorted stops for display - const sortedStops = useMemo(() => [...instanceStops].sort((a, b) => a.StopOrder - b.StopOrder), [instanceStops]); + // Distance: prefer Mapbox directions (more accurate), fallback to server + const estimatedDistance = useMemo(() => { + if (mapboxDirections?.totalDistance != null && mapboxDirections.totalDistance > 0) { + return mapboxDirections.totalDistance; + } + return directions?.EstimatedDistanceMeters ?? null; + }, [mapboxDirections, directions]); + + // Duration: prefer Mapbox directions (traffic-aware), fallback to server + const estimatedDuration = useMemo(() => { + if (mapboxDirections?.totalDuration != null && mapboxDirections.totalDuration > 0) { + return mapboxDirections.totalDuration; + } + return directions?.EstimatedDurationSeconds ?? null; + }, [mapboxDirections, directions]); + + // Typical duration (without traffic) — for comparison + const typicalDuration = useMemo(() => { + if (mapboxDirections?.totalDurationTypical != null && mapboxDirections.totalDurationTypical > 0) { + return mapboxDirections.totalDurationTypical; + } + return null; + }, [mapboxDirections]); + // Traffic delay + const trafficDelaySeconds = useMemo(() => { + if (estimatedDuration != null && typicalDuration != null && typicalDuration > 0) { + const delay = estimatedDuration - typicalDuration; + return delay > 30 ? delay : 0; // only show meaningful delays (>30s) + } + return null; + }, [estimatedDuration, typicalDuration]); + + // Driving conditions summary + const drivingCondition = useMemo(() => { + if (mapboxDirections?.congestion) { + return deriveDrivingCondition(mapboxDirections.congestion); + } + return null; + }, [mapboxDirections]); + + // ETA (arrival time) + const eta = useMemo(() => { + if (estimatedDuration == null) return null; + const arrivalTime = new Date(Date.now() + estimatedDuration * 1000); + return arrivalTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }, [estimatedDuration]); + + // Destination for "Open in Maps" const destination = useMemo(() => { const last = sortedStops[sortedStops.length - 1]; if (last?.Latitude != null && last?.Longitude != null) { @@ -106,17 +565,33 @@ export default function RouteDirectionsScreen() { return null; }, [sortedStops]); - // Fit map to stops once ready + // --------------------------------------------------------------------------- + // Camera: imperative fitBounds matching the active route screen pattern + // --------------------------------------------------------------------------- + + // Once the map is ready AND stops are loaded, fit bounds to show all stops. + // If Mapbox driving directions are later loaded, re-fit to the full route. useEffect(() => { - if (!isMapReady || sortedStops.length === 0 || !cameraRef.current) return; - const valid = sortedStops.filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)); - if (valid.length === 0) return; - const lngs = valid.map((s) => s.Longitude); - const lats = valid.map((s) => s.Latitude); + if (!isMapReady || validStops.length === 0 || !cameraRef.current) return; + + let lngs = validStops.map((s) => s.Longitude); + let lats = validStops.map((s) => s.Latitude); + + // Prefer fitting to full driving route geometry when available + if (mapboxDirections?.routeGeoJSON) { + const geom = mapboxDirections.routeGeoJSON.geometry as GeoJSON.LineString; + if (geom?.coordinates && geom.coordinates.length > 1) { + lngs = geom.coordinates.map((c: number[]) => c[0]); + lats = geom.coordinates.map((c: number[]) => c[1]); + } + } + const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; - cameraRef.current.fitBounds(ne, sw, [60, 60, 60, 60], 600); - }, [isMapReady, sortedStops]); + + // Padding: [top, right, bottom, left] — extra bottom for the overlay panel + cameraRef.current.fitBounds(ne, sw, [60, 60, 300, 60], 800); + }, [isMapReady, validStops, mapboxDirections]); // eslint-disable-line react-hooks/exhaustive-deps const handleOpenInMaps = useCallback(() => { if (destination) { @@ -124,6 +599,7 @@ export default function RouteDirectionsScreen() { } }, [destination, t]); + // --- Loading states --- if (isLoadingDirections && sortedStops.length === 0) { return ( @@ -147,55 +623,187 @@ export default function RouteDirectionsScreen() { setIsMapReady(true)}> + {/* Bare Camera — positioned imperatively via fitBounds once isMapReady && validStops */} - - {routeGeoJson && ( + {/* Route line (driving geometry) */} + {routeGeoJson ? ( + {/* Shadow/border line underneath */} + + {/* Main route line */} + + ) : null} + + {/* Congestion overlay */} + {congestionGeoJSON ? ( + + - )} - - {sortedStops - .filter((s) => s.Latitude != null && s.Longitude != null && isFinite(s.Latitude) && isFinite(s.Longitude)) - .map((stop) => ( - - - - ))} + ) : null} + + {/* Start marker */} + {startStop ? ( + + + + ) : null} + + {/* End marker (only if different from start) */} + {endStop && endStop.RouteInstanceStopId !== startStop?.RouteInstanceStopId ? ( + + + + ) : null} + + {/* Intermediate stop markers */} + {intermediateStops.map((stop) => ( + + + + ))} + + {/* Loading overlay while fetching driving directions */} + {isFetchingDirections ? ( + + + {t('routes.fetching_directions')} + + ) : null} - {/* Stop list panel */} - - {/* Summary */} - {directions?.EstimatedDistanceMeters || directions?.EstimatedDurationSeconds ? ( - - {directions.EstimatedDistanceMeters ? {formatDistance(directions.EstimatedDistanceMeters)} : null} - {directions.EstimatedDurationSeconds ? {formatDuration(directions.EstimatedDurationSeconds)} : null} + {/* Bottom panel */} + + {/* Summary bar: distance, duration, ETA, driving conditions */} + + + {/* Distance */} + {estimatedDistance != null ? ( + + + + {formatDistance(estimatedDistance)} + + {t('routes.distance')} + + ) : null} + + {/* Duration with traffic */} + {estimatedDuration != null ? ( + + + + {formatDuration(estimatedDuration)} + + {t('routes.duration')} + + ) : null} + + {/* ETA */} + {eta ? ( + + + + {eta} + + {t('routes.eta')} + + ) : null} + + {/* Driving conditions */} + {drivingCondition ? ( + + + + {drivingCondition.label} + + {trafficDelaySeconds != null && trafficDelaySeconds > 0 ? ( + + +{formatDuration(trafficDelaySeconds)} {t('routes.delay')} + + ) : ( + {t('routes.driving_conditions')} + )} + + ) : null} - ) : null} - {/* Stops */} + {/* Traffic delay bar — only when there's a notable delay */} + {trafficDelaySeconds != null && trafficDelaySeconds > 60 ? ( + + + {t('routes.traffic_delay', { time: formatDuration(trafficDelaySeconds) })} + + ) : null} + + + {/* Stops list */} {sortedStops.map((stop, index) => { const color = statusColor[stop.Status] ?? '#9ca3af'; const isLast = index === sortedStops.length - 1; + const isFirst = index === 0; + const isStopLast = isLast && sortedStops.length > 1; + const stopLabel = isFirst ? t('routes.start') : isStopLast ? t('routes.end') : `#${stop.StopOrder}`; + return ( - {/* Order badge */} - - {stop.StopOrder} + {/* Order badge with distinct styling for start/end */} + + {isFirst ? : isStopLast ? : {stop.StopOrder}} + - {stop.Name} + + {stop.Name} + {isFirst ? ( + + {t('routes.start').toUpperCase()} + + ) : null} + {isStopLast ? ( + + {t('routes.end').toUpperCase()} + + ) : null} + {stop.Address ? ( @@ -204,8 +812,18 @@ export default function RouteDirectionsScreen() { ) : null} + {/* Segment distance/duration from Mapbox if available */} + {mapboxDirections?.segments[index] ? ( + + + {formatDistance(mapboxDirections.segments[index].distance)} · {formatDuration(mapboxDirections.segments[index].duration)} + + + ) : null} - {stop.Status === RouteStopStatus.Completed ? : stop.Status === RouteStopStatus.InProgress ? : null} + + {stop.Status === RouteStopStatus.Completed ? : stop.Status === RouteStopStatus.InProgress ? : null} + ); })} @@ -227,13 +845,6 @@ const styles = StyleSheet.create({ container: { flex: 1 }, mapContainer: { flex: 1 }, map: { flex: 1 }, - marker: { - width: 16, - height: 16, - borderRadius: 8, - borderWidth: 2, - borderColor: '#fff', - }, badge: { width: 24, height: 24, @@ -242,4 +853,22 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginTop: 1, }, + fetchingOverlay: { + position: 'absolute', + top: 12, + left: 0, + right: 0, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.9)', + paddingVertical: 6, + marginHorizontal: 60, + borderRadius: 20, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.15, + shadowRadius: 3, + }, }); diff --git a/src/app/routes/history/instance/[id].tsx b/src/app/routes/history/instance/[id].tsx index 5dc8867d..a347c089 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 f8db0642..ca68f02f 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 00000000..25ee61fd --- /dev/null +++ b/src/app/routes/poi/[id].tsx @@ -0,0 +1,157 @@ +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 { 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 { 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'; +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/routes/start.tsx b/src/app/routes/start.tsx index 26942f98..9257ee9a 100644 --- a/src/app/routes/start.tsx +++ b/src/app/routes/start.tsx @@ -1,7 +1,7 @@ import { router, useLocalSearchParams } from 'expo-router'; import { Clock, Info, MapPin, Navigation, Phone, Play, Truck, User } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Pressable, ScrollView, StyleSheet, Text as RNText, View } from 'react-native'; @@ -86,19 +86,23 @@ export default function RouteViewScreen() { const sortedStops = useMemo(() => [...(activePlan?.Stops || [])].sort((a, b) => a.StopOrder - b.StopOrder), [activePlan]); - const mapData = useMemo(() => { - const valid = sortedStops.filter((s) => s.Latitude && s.Longitude); - if (valid.length === 0) return null; + const mapStops = useMemo(() => sortedStops.filter((s) => s.Latitude && s.Longitude), [sortedStops]); - const lats = valid.map((s) => s.Latitude); - const lngs = valid.map((s) => s.Longitude); - const centerLat = (Math.min(...lats) + Math.max(...lats)) / 2; - const centerLng = (Math.min(...lngs) + Math.max(...lngs)) / 2; - const span = Math.max(Math.max(...lats) - Math.min(...lats), Math.max(...lngs) - Math.min(...lngs)); - const zoom = span === 0 ? 14 : Math.max(6, Math.min(14, Math.round(Math.log2(0.5 / span)) + 10)); + const cameraRef = useRef(null); + const [isMapReady, setIsMapReady] = useState(false); - return { center: [centerLng, centerLat] as [number, number], zoom, stops: valid }; - }, [sortedStops]); + // Fit bounds to show all stops once the map is ready + useEffect(() => { + if (!isMapReady || mapStops.length === 0 || !cameraRef.current) return; + + const lngs = mapStops.map((s) => s.Longitude); + const lats = mapStops.map((s) => s.Latitude); + + const ne: [number, number] = [Math.max(...lngs), Math.max(...lats)]; + const sw: [number, number] = [Math.min(...lngs), Math.min(...lats)]; + + cameraRef.current.fitBounds(ne, sw, [60, 60, 60, 60], 800); + }, [isMapReady, mapStops]); const markerColor = activePlan?.RouteColor || '#3b82f6'; @@ -211,11 +215,11 @@ export default function RouteViewScreen() { {/* Interactive stop map */} - {mapData ? ( + {mapStops.length > 0 ? ( - - - {mapData.stops.map((stop) => ( + setIsMapReady(true)}> + + {mapStops.map((stop) => ( diff --git a/src/app/weather-alert/[id].tsx b/src/app/weather-alert/[id].tsx new file mode 100644 index 00000000..f4d5db42 --- /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 { 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'; + +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 00000000..11cade94 --- /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 00000000..70e714f7 --- /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 00000000..ec15f663 --- /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 00000000..bf366cf1 --- /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 00000000..319aae5c --- /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 00000000..b8169f72 --- /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 00000000..bbfbe92c --- /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,12 +117,60 @@ 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 Dispatched: {format(new Date(call.DispatchedOn), 'PPp')} */} + + {/* Dispatch Ticker */} + {dispatches && dispatches.length > 0 ? ( + + {dispatches.map((d, index) => { + let typeLetter = 'P'; + let typeBgColor = '#3B82F6'; // blue - Personnel + const t = d.Type?.toLowerCase() || ''; + if (t.includes('unit')) { + typeLetter = 'U'; + typeBgColor = '#10B981'; // green + } else if (t.includes('group')) { + typeLetter = 'G'; + typeBgColor = '#8B5CF6'; // purple + } else if (t.includes('role')) { + typeLetter = 'R'; + typeBgColor = '#F59E0B'; // amber + } + + return ( + + + + {typeLetter} + + + + {d.Name} + + + ); + })} + + ) : null} {/* Nature of Call */} diff --git a/src/components/calls/call-detail-menu.tsx b/src/components/calls/call-detail-menu.tsx index 2c785f41..566edbf8 100644 --- a/src/components/calls/call-detail-menu.tsx +++ b/src/components/calls/call-detail-menu.tsx @@ -1,11 +1,13 @@ import { EditIcon, MoreVerticalIcon, XIcon } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Modal, Pressable as RNPressable, StyleSheet, View } from 'react-native'; -import { Pressable } from '@/components/ui/'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper, ActionsheetItem, ActionsheetItemText } from '@/components/ui/actionsheet'; import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; import { useAnalytics } from '@/hooks/use-analytics'; +import { isIOS, showNativeActionSheet } from '@/utils/action-sheet'; interface CallDetailMenuProps { onEditCall: () => void; @@ -13,82 +15,100 @@ interface CallDetailMenuProps { canUserCreateCalls?: boolean; } +interface HeaderRightMenuButtonProps { + onPress: () => void; +} + +/** Stable module-level component — React Navigation won't remount it across renders */ +const HeaderRightMenuButton = ({ onPress }: HeaderRightMenuButtonProps) => { + return ( + + + + ); +}; + export const useCallDetailMenu = ({ onEditCall, onCloseCall, canUserCreateCalls = false }: CallDetailMenuProps) => { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); const [isKebabMenuOpen, setIsKebabMenuOpen] = useState(false); - // Track when call detail menu is opened - useEffect(() => { - if (isKebabMenuOpen) { - trackEvent('call_detail_menu_opened', { - hasEditAction: canUserCreateCalls, - hasCloseAction: canUserCreateCalls, + const closeMenu = () => setIsKebabMenuOpen(false); + + const openMenu = useCallback(() => { + if (!canUserCreateCalls) return; + + // Track analytics on both platforms + trackEvent('call_detail_menu_opened', { + hasEditAction: canUserCreateCalls, + hasCloseAction: canUserCreateCalls, + }); + + if (isIOS()) { + const options = [t('call_detail.edit_call'), t('call_detail.close_call'), t('common.cancel')]; + showNativeActionSheet({ options, cancelButtonIndex: options.length - 1 }, (buttonIndex) => { + if (buttonIndex === 0) { + trackEvent('call_detail_menu_item_selected', { action: 'edit' }); + onEditCall(); + } else if (buttonIndex === 1) { + trackEvent('call_detail_menu_item_selected', { action: 'close' }); + onCloseCall(); + } }); + } else { + setIsKebabMenuOpen(true); } - }, [isKebabMenuOpen, trackEvent, canUserCreateCalls]); + }, [canUserCreateCalls, t, onEditCall, onCloseCall, trackEvent]); - const openMenu = () => { - setIsKebabMenuOpen(true); - }; - const closeMenu = () => setIsKebabMenuOpen(false); + const HeaderRightMenu = useCallback(() => { + if (!canUserCreateCalls) return null; + return ; + }, [canUserCreateCalls, openMenu]); - const HeaderRightMenu = () => { - // Don't show menu if user doesn't have create calls permission - if (!canUserCreateCalls) { + const CallDetailActionSheet = useCallback(() => { + // On iOS, the native ActionSheetIOS is used instead — skip rendering + if (isIOS() || !canUserCreateCalls) { return null; } return ( - - - - ); - }; + + + + - const CallDetailActionSheet = () => { - // Don't show action sheet if user doesn't have create calls permission - if (!canUserCreateCalls) { - return null; - } + { + closeMenu(); + onEditCall(); + }} + testID="edit-call-button" + > + + + {t('call_detail.edit_call')} + + - return ( - - - - - - - - { - closeMenu(); - onEditCall(); - }} - testID="edit-call-button" - > - - - {t('call_detail.edit_call')} - - - - { - closeMenu(); - onCloseCall(); - }} - testID="close-call-button" - > - - - {t('call_detail.close_call')} - - - - + { + closeMenu(); + onCloseCall(); + }} + testID="close-call-button" + > + + + {t('call_detail.close_call')} + + + + + ); - }; + }, [isKebabMenuOpen, canUserCreateCalls, t, onEditCall, onCloseCall]); return { HeaderRightMenu, @@ -98,3 +118,32 @@ export const useCallDetailMenu = ({ onEditCall, onCloseCall, canUserCreateCalls closeMenu, }; }; + +const menuStyles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: 'white', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingBottom: 34, + paddingTop: 8, + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#D1D5DB', + alignSelf: 'center', + marginBottom: 8, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 20, + }, +}); diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx index 7dbf7406..67d981a2 100644 --- a/src/components/calls/call-files-modal.tsx +++ b/src/components/calls/call-files-modal.tsx @@ -1,12 +1,12 @@ 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'; import { useTranslation } from 'react-i18next'; import { Alert, Pressable, useColorScheme } from 'react-native'; -import { ScrollView } from 'react-native-gesture-handler'; +import { ScrollView } from 'react-native'; import { getCallAttachmentFile } from '@/api/calls/callFiles'; import { Box } from '@/components/ui/box'; @@ -76,11 +76,13 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, // Handle sheet changes const handleSheetChanges = useCallback( (index: number) => { - if (index === -1) { + // Only close when swiped down AND the modal is actually open + // Prevents spurious close events during device rotation + if (index === -1 && isOpen) { onClose(); } }, - [onClose] + [onClose, isOpen] ); // Render backdrop @@ -118,7 +120,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 +139,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 @@ -246,33 +251,41 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, return ( <> + + {item.Name || ''} + {item.Timestamp || ''} - + ); } - // At this point, imageSource is guaranteed to be non-null return ( - + handleImagePress(imageSource!, item.Name)} testID={`image-${item.Id}-touchable`} activeOpacity={0.7} style={{ width: '100%' }} delayPressIn={0} delayPressOut={0}> = ({ isOpen, onClose, call pointerEvents="none" cachePolicy="memory-disk" recyclingKey={item.Id} - onError={() => { - handleImageError(item.Id, 'expo-image load error'); - }} + onError={() => handleImageError(item.Id, 'expo-image load error')} onLoad={() => { - // Remove from error set if it loads successfully setImageErrors((prev) => { const newSet = new Set(prev); newSet.delete(item.Id); @@ -291,71 +293,47 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call }} /> - {item.Name || ''} + + {item.Name || ''} + {item.Timestamp || ''} - + ); }; - const handleViewableItemsChanged = useRef(({ viewableItems }: any) => { - if (viewableItems.length > 0) { - setActiveIndex(viewableItems[0].index || 0); - } - }).current; - - const handlePrevious = () => { - const newIndex = Math.max(0, activeIndex - 1); - setActiveIndex(newIndex); - try { - flatListRef.current?.scrollToIndex({ - index: newIndex, - animated: true, - }); - } catch (error) { - console.warn('Error scrolling to previous image:', error); - } - }; - - const handleNext = () => { - const newIndex = Math.min(validImages.length - 1, activeIndex + 1); - setActiveIndex(newIndex); - try { - flatListRef.current?.scrollToIndex({ - index: newIndex, - animated: true, - }); - } catch (error) { - console.warn('Error scrolling to next image:', error); - } - }; - const renderPagination = () => { if (!validImages || validImages.length <= 1) return null; return ( - - - + + + - - + + {activeIndex + 1} / {validImages.length} - - + + ); }; + const renderImageGallery = () => { + if (!validImages?.length) return null; + + return ( + + {renderActiveImage()} + {renderPagination()} + + ); + }; + const renderAddImageContent = () => ( <> {/* Scrollable content area */} @@ -413,41 +391,6 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call ); - const renderImageGallery = () => { - if (!validImages?.length) return null; - - return ( - - - item?.Id || `image-${Math.random()}`} - horizontal - pagingEnabled - showsHorizontalScrollIndicator={false} - onViewableItemsChanged={handleViewableItemsChanged} - viewabilityConfig={{ - itemVisiblePercentThreshold: 50, - minimumViewTime: 100, - }} - estimatedItemSize={width} - className="w-full" - contentContainerStyle={{ paddingHorizontal: 0 }} - initialScrollIndex={0} - ListEmptyComponent={() => ( - - {t('callImages.no_images')} - - )} - /> - - {renderPagination()} - - ); - }; - const renderContent = () => { if (isLoadingImages) { return ; diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx index dc00434d..a83b11e6 100644 --- a/src/components/calls/close-call-bottom-sheet.tsx +++ b/src/components/calls/close-call-bottom-sheet.tsx @@ -1,14 +1,12 @@ import { useRouter } from 'expo-router'; +import { ChevronDown } from 'lucide-react-native'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet } from 'react-native'; +import { Modal, Pressable as RNPressable, StyleSheet, View } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Button, ButtonText } from '@/components/ui/button'; -import { FormControl, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; import { HStack } from '@/components/ui/hstack'; -import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; @@ -24,6 +22,16 @@ interface CloseCallBottomSheetProps { isLoading?: boolean; } +const CLOSE_CALL_TYPES = [ + { value: '1', translationKey: 'call_detail.close_call_types.closed' }, + { value: '2', translationKey: 'call_detail.close_call_types.cancelled' }, + { value: '3', translationKey: 'call_detail.close_call_types.unfounded' }, + { value: '4', translationKey: 'call_detail.close_call_types.founded' }, + { value: '5', translationKey: 'call_detail.close_call_types.minor' }, + { value: '6', translationKey: 'call_detail.close_call_types.transferred' }, + { value: '7', translationKey: 'call_detail.close_call_types.false_alarm' }, +]; + export const CloseCallBottomSheet: React.FC = ({ isOpen, onClose, callId, isLoading = false }) => { const { t } = useTranslation(); const router = useRouter(); @@ -34,6 +42,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const [closeCallType, setCloseCallType] = useState(''); const [closeCallNote, setCloseCallNote] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); // Track when close call bottom sheet is opened/rendered React.useEffect(() => { @@ -48,6 +57,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const handleClose = React.useCallback(() => { setCloseCallType(''); setCloseCallNote(''); + setIsTypeDropdownOpen(false); onClose(); }, [onClose]); @@ -84,72 +94,141 @@ export const CloseCallBottomSheet: React.FC = ({ isOp } }, [closeCallType, showToast, t, callId, closeCallNote, handleClose, fetchCalls, router, closeCall]); + const selectedTypeLabel = closeCallType ? t(CLOSE_CALL_TYPES.find((ct) => ct.value === closeCallType)?.translationKey ?? '') : t('call_detail.close_call_type_placeholder'); + const isButtonDisabled = isLoading || isSubmitting; return ( - - - - - - - - - - {t('call_detail.close_call')} - - - - {t('call_detail.close_call_type')} - - - - - - {t('call_detail.close_call_note')} - + + + e.stopPropagation()}> + + + + + {t('call_detail.close_call')} + + {/* Close Call Type selector */} + + {t('call_detail.close_call_type')} + setIsTypeDropdownOpen(!isTypeDropdownOpen)} testID="close-call-type-select"> + {selectedTypeLabel} + + + + {isTypeDropdownOpen && ( + + {CLOSE_CALL_TYPES.map((type) => ( + { + setCloseCallType(type.value); + setIsTypeDropdownOpen(false); + }} + testID={`close-call-type-option-${type.value}`} + > + {t(type.translationKey)} + + ))} + + )} + + + + {t('call_detail.close_call_note')} + + + + + + + - - - - - - - - - + + + + ); }; const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: 'white', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '90%', + paddingBottom: 34, + paddingTop: 8, + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#D1D5DB', + alignSelf: 'center', + marginBottom: 8, + }, scrollView: { width: '100%', }, scrollViewContent: { flexGrow: 1, - paddingBottom: 80, + paddingBottom: 40, + }, + typeTrigger: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + backgroundColor: '#F9FAFB', + }, + typeText: { + fontSize: 16, + color: '#111827', + }, + typePlaceholder: { + fontSize: 16, + color: '#9CA3AF', + }, + typeDropdown: { + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 8, + backgroundColor: 'white', + marginTop: 4, + }, + typeOption: { + paddingHorizontal: 12, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#E5E7EB', + }, + typeOptionSelected: { + backgroundColor: '#EFF6FF', + }, + typeOptionText: { + fontSize: 15, + color: '#374151', + }, + typeOptionTextSelected: { + fontSize: 15, + color: '#2563EB', + fontWeight: '600', }, }); diff --git a/src/components/calls/destination-poi-selector.tsx b/src/components/calls/destination-poi-selector.tsx new file mode 100644 index 00000000..87dc8f6c --- /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 { createPoiTypeMap, getPoiSelectionLabel, groupPoisByType } 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 00000000..f0a36648 --- /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 00000000..1bf37421 --- /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 00000000..fdb5512c --- /dev/null +++ b/src/components/check-in-timers/check-in-bottom-sheet.tsx @@ -0,0 +1,105 @@ +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 type { CheckInResult } from '@/stores/check-in-timers/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 result: CheckInResult = await performCheckInAction(input); + + 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')); + } + }, [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 00000000..2035421c --- /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 00000000..1565532c --- /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 00000000..5dfac1d4 --- /dev/null +++ b/src/components/check-in-timers/check-in-timer-card.tsx @@ -0,0 +1,108 @@ +import { differenceInMinutes } from 'date-fns'; +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 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 minutesSinceLastCheckIn = timer.LastCheckIn ? differenceInMinutes(new Date(), timer.LastCheckIn) : 0; + + return ( + + + + + + + + {timer.TargetName} + {timer.TargetTypeName} + + + + + {t(`check_in.status_${safeStatusLower}`)} + + + + + {/* Progress bar */} + + + + + + + {t('check_in.last_check_in')}: {minutesSinceLastCheckIn} {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 12061f46..c5ec9ae5 100644 --- a/src/components/maps/__tests__/pin-actions.test.tsx +++ b/src/components/maps/__tests__/pin-actions.test.tsx @@ -149,10 +149,11 @@ const mockCallPin = { Latitude: 40.7128, Longitude: -74.0060, ImagePath: 'call', - Type: 1, + Type: 0, InfoWindowContent: 'Medical emergency at Main St', Color: '#ff0000', zIndex: '1', + PoiImage: '', }; const mockUnitPin = { @@ -165,6 +166,7 @@ const mockUnitPin = { InfoWindowContent: 'Engine 1 available', Color: '#00ff00', zIndex: '1', + PoiImage: '', }; describe('Pin Actions Integration Tests', () => { @@ -436,7 +438,7 @@ describe('Pin Actions Integration Tests', () => { const callPinByType = { ...mockCallPin, ImagePath: 'other', - Type: 1, + Type: 0, }; render( @@ -532,6 +534,7 @@ describe('Pin Actions Integration Tests', () => { InfoWindowContent: '', Color: '', zIndex: '1', + PoiImage: '', }; render( @@ -586,4 +589,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/map-pins.tsx b/src/components/maps/map-pins.tsx index 9d295138..39b4fed9 100644 --- a/src/components/maps/map-pins.tsx +++ b/src/components/maps/map-pins.tsx @@ -2,9 +2,11 @@ import React, { useCallback } from 'react'; import Mapbox from '@/components/maps/mapbox'; import { type MAP_ICONS } from '@/constants/map-icons'; +import { isPoiMarker } from '@/lib/poi-marker-utils'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import PinMarker from './pin-marker'; +import PoiMarker from './poi-marker'; type MapIconKey = keyof typeof MAP_ICONS; @@ -13,15 +15,35 @@ interface MapPinsProps { onPinPress?: (pin: MapMakerInfoData) => void; } -// Individual pin wrapper to keep stable onPress callbacks per pin +/** + * Individual pin wrapper that renders the appropriate marker component + * based on whether the marker is a POI or a legacy (call/unit/station/personnel) marker. + * + * POI markers use the SVG shape + icon rendering (per the "POI Map Icon Renderer" + * reference document). Non-POI markers use PNG images from the MAP_ICONS lookup. + */ const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void }) => { const handlePress = useCallback(() => { onPinPress?.(pin); }, [onPinPress, pin]); + const poi = isPoiMarker(pin); + return ( - - + + {poi ? ( + + ) : ( + + )} ); }); diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx index f5f99857..d538292d 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/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index 06183ebc..57ddd837 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -2,22 +2,28 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { Image, StyleSheet, Text, TouchableOpacity } from 'react-native'; -import type Mapbox from '@/components/maps/mapbox'; +import type { PointAnnotation } from '@/components/maps/mapbox'; import { MAP_ICONS } from '@/constants/map-icons'; type MapIconKey = keyof typeof MAP_ICONS; interface PinMarkerProps { imagePath?: MapIconKey; + poiImage?: MapIconKey; title: string; size?: number; + markerRef?: React.ComponentRef | null; onPress?: () => void; } -const PinMarker: React.FC = React.memo(({ imagePath, title, size = 32, onPress }) => { +const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, onPress }) => { const { colorScheme } = useColorScheme(); - const icon = (imagePath && MAP_ICONS[imagePath.toLowerCase() as MapIconKey]) || MAP_ICONS['call']; + // Prefer poiImage (new field) over imagePath (null for POIs after backend fix), + // with final fallback to default 'call' icon + const resolvedPath = poiImage || imagePath; + const iconKey = resolvedPath?.toLowerCase() as MapIconKey; + const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['call']; return ( diff --git a/src/components/maps/poi-marker-icon.tsx b/src/components/maps/poi-marker-icon.tsx new file mode 100644 index 00000000..f694076b --- /dev/null +++ b/src/components/maps/poi-marker-icon.tsx @@ -0,0 +1,222 @@ +import { + AlertTriangle, + Anchor, + Banknote, + BedSingle, + Beer, + Bird, + BookOpen, + Building2, + Bus, + BusFront, + Car, + Church, + Circle, + Cog, + Cross, + Factory, + Flag, + Flame, + Fuel, + Globe, + GraduationCap, + Hammer, + HeartPulse, + Home, + Hotel, + Landmark, + LibraryBig, + type LucideIcon, + MapPin, + Mountain, + ParkingCircle, + Pill, + Plane, + RailSymbol, + Shield, + ShoppingBag, + ShoppingBasket, + ShoppingCart, + Siren, + Square, + Stamp, + Store, + TentTree, + Theater, + TrainFront, + Trees, + TriangleAlert, + Truck, + UtensilsCrossed, + Warehouse, + Waves, + Wrench, + Zap, +} from 'lucide-react-native'; +import React from 'react'; + +import { POI_ICON_LAYOUT } from '@/lib/poi-marker-utils'; + +/** + * Maps a map-icon key name (e.g., "hospital") to a lucide-react-native icon component. + * Covers the most commonly used POI type icons from the map-icons font library. + * Unmapped icons fall back to a simple white circle. + */ +const MAP_ICON_TO_LUCIDE: Record = { + // Medical / Emergency + hospital: HeartPulse, + 'first-aid': Cross, + pharmacy: Pill, + 'medical-store': Pill, + 'medical-shop': Pill, + + // Fire / Police / Emergency Services + 'fire-station': Flame, + police: Siren, + 'police-station': Siren, + 'emergency-phone': TriangleAlert, + ambulance: Truck, + + // Transportation + airport: Plane, + 'bus-station': Bus, + 'train-station': TrainFront, + 'rail-station': RailSymbol, + parking: ParkingCircle, + 'parking-garage': ParkingCircle, + 'gas-station': Fuel, + 'car-rental': Car, + 'ferry-terminal': Anchor, + heliport: Plane, + + // Education + school: GraduationCap, + university: Building2, + library: LibraryBig, + museum: Landmark, + + // Government / Civic + courthouse: Landmark, + 'city-hall': Building2, + embassy: Flag, + 'post-office': Stamp, + + // Religious + church: Church, + mosque: Building2, + synagogue: Building2, + temple: Building2, + 'place-of-worship': Church, + + // Commercial + bank: Banknote, + restaurant: UtensilsCrossed, + cafe: UtensilsCrossed, + bar: Beer, + store: Store, + 'grocery-or-supermarket': ShoppingCart, + 'shopping-mall': ShoppingBag, + 'clothing-store': ShoppingBasket, + 'convenience-store': Store, + 'hardware-store': Wrench, + bakery: UtensilsCrossed, + 'liquor-store': Beer, + + // Recreation / Entertainment + park: Trees, + playground: Trees, + campground: TentTree, + stadium: Flag, + theater: Theater, + cinema: Theater, + zoo: Bird, + aquarium: Waves, + 'golf-course': Flag, + gym: Cog, + spa: HeartPulse, + 'night-club': Beer, + + // Accommodation + lodging: Hotel, + hotel: Hotel, + motel: Hotel, + + // Industrial / Work + 'industrial-building': Factory, + industry: Factory, + factory: Factory, + warehouse: Warehouse, + 'construction-site': Hammer, + workshop: Wrench, + + // Natural Features + mountain: Mountain, + volcano: TriangleAlert, + waterfall: Waves, + beach: Waves, + river: Waves, + lake: Waves, + + // Infrastructure + bridge: Anchor, + dam: Waves, + lighthouse: Globe, + 'power-plant': Zap, + 'water-supply': Waves, + + // Shape-type icons (used as POI type icons themselves) + 'map-pin': MapPin, + 'point-of-interest': MapPin, + shield: Shield, + route: MapPin, + square: Square, + 'square-rounded': Square, + 'square-pin': Square, +}; + +/** + * Props for the PoiMarkerIcon component. + */ +interface PoiMarkerIconProps { + /** The map-icon key (e.g., "hospital" from "map-icon-hospital") */ + iconKey: string; + /** Font size in pixels */ + size: number; +} + +/** + * Renders the inner icon for a POI marker. + * + * Maps map-icon CSS class keys to lucide-react-native vector icons. + * Falls back to a simple white circle when no mapping exists. + * All icons are rendered in white (#ffffff) to match the web app rendering. + */ +const PoiMarkerIcon: React.FC = React.memo(({ iconKey, size }) => { + const IconComponent = iconKey ? MAP_ICON_TO_LUCIDE[iconKey] : undefined; + + if (IconComponent) { + return ; + } + + // Fallback: simple white circle (generic POI indicator) + return ; +}); + +PoiMarkerIcon.displayName = 'PoiMarkerIcon'; + +/** + * Returns whether a given map-icon key has a mapped lucide icon. + * Useful for components that need to know if a specific icon is available. + */ +export function hasLucideIcon(iconKey: string): boolean { + return iconKey in MAP_ICON_TO_LUCIDE; +} + +/** + * Returns the lucide icon component for a map-icon key, or undefined. + */ +export function getLucideIcon(iconKey: string): LucideIcon | undefined { + return MAP_ICON_TO_LUCIDE[iconKey]; +} + +export default PoiMarkerIcon; diff --git a/src/components/maps/poi-marker.tsx b/src/components/maps/poi-marker.tsx new file mode 100644 index 00000000..1533388b --- /dev/null +++ b/src/components/maps/poi-marker.tsx @@ -0,0 +1,146 @@ +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Svg, { Path } from 'react-native-svg'; + +import { getMapIconKey, getPoiMarkerColor, getPoiMarkerIconClass, getPoiMarkerShapePath, POI_ICON_LAYOUT, POI_MARKER_DIMENSIONS } from '@/lib/poi-marker-utils'; + +import PoiMarkerIcon from './poi-marker-icon'; + +/** + * Props for the PoiMarker component. + */ +export interface PoiMarkerProps { + /** The icon CSS class name from the API, e.g. "map-icon-hospital" */ + poiImage?: string; + /** Legacy ImagePath field (null for POIs, but used as fallback) */ + imagePath?: string; + /** The hex color for the SVG shape fill, e.g. "#2563eb" */ + color?: string; + /** The marker shape type, e.g. "MAP_PIN", "SHIELD" */ + marker?: string; + /** Display title for the marker label */ + title: string; + /** Overall marker size in pixels (width). Height scales proportionally. */ + size?: number; + /** Callback when the marker is pressed */ + onPress?: () => void; +} + +/** + * Renders a POI (Point of Interest) map marker as an SVG shape + * filled with the POI type's color, with a white icon overlaid on top. + * + * This matches the web app's rendering as described in the + * "POI Map Icon Renderer — Reference for Mobile Applications" document. + * + * Anatomy: + * - SVG background shape (MAP_PIN, SHIELD, ROUTE, SQUARE, or SQUARE_ROUNDED) + * filled with the Color field, with a drop-shadow + * - White icon centered horizontally, 10px from top + * - Title label below the marker + */ +const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, color, marker, title, size = 36, onPress }) => { + // Resolve rendering properties (with defaults per the reference document) + const shapePath = getPoiMarkerShapePath(marker); + const fillColor = getPoiMarkerColor(color); + const iconClass = getPoiMarkerIconClass({ PoiImage: poiImage, ImagePath: imagePath }); + const iconKey = getMapIconKey(iconClass); + + // Height scales proportionally: width 36 → height 48, so height = width * (48/36) = width * 4/3 + const svgWidth = size; + const svgHeight = size * (POI_MARKER_DIMENSIONS.height / POI_MARKER_DIMENSIONS.width); + + // Icon top position scaled relative to the SVG viewBox (topOffset: 10px in 48px height) + const iconTop = (POI_ICON_LAYOUT.topOffset / POI_MARKER_DIMENSIONS.height) * svgHeight; + const iconFontSize = (POI_ICON_LAYOUT.fontSize / POI_MARKER_DIMENSIONS.width) * svgWidth; + + return ( + + {/* SVG background shape with drop shadow */} + + + + + + {/* White icon overlaid on the shape */} + + + + + + + ); +}); + +PoiMarker.displayName = 'PoiMarker'; + +/** + * Renders the title label below the POI marker shape. + * Theme-aware colors matching PinMarker's title style. + */ +const TitleLabel: React.FC<{ title: string }> = React.memo(({ title }) => { + const { colorScheme } = useColorScheme(); + return ( + + {title} + + ); +}); +TitleLabel.displayName = 'TitleLabel'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + shapeContainer: { + position: 'relative', + alignItems: 'center', + // Drop shadow equivalent: offset(0,1) blur=2 color=rgba(17,24,39,0.35) + ...Platform.select({ + ios: { + shadowColor: '#111827', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 2, + }, + android: { + elevation: 4, + }, + default: { + // Web: CSS filter applied via style + filter: 'drop-shadow(0 1px 2px rgba(17, 24, 39, 0.35))', + }, + }), + }, + iconContainer: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + marginTop: 2, + overflow: 'visible', + fontSize: 10, + fontWeight: '600', + textAlign: 'center', + }, +}); + +export default PoiMarker; diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index 4c86da08..53f55a17 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 new file mode 100644 index 00000000..06d73e80 --- /dev/null +++ b/src/components/routes/active-routes-list.tsx @@ -0,0 +1,215 @@ +import { router } from 'expo-router'; +import { MapPin, Navigation, Route, Search, X } from 'lucide-react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, RefreshControl, ScrollView, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import { RouteCard } from '@/components/routes/route-card'; +import { Badge, BadgeText } from '@/components/ui/badge'; +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 { 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 activeRouteBanner = 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; + + 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 ( + + + + + {t('common.errorOccurred')} + {error} + + ); + } + + return ( + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + {filteredRoutes.length > 0 || activeInstance ? ( + + + testID="routes-list" + data={filteredRoutes} + ListHeaderComponent={activeRouteBanner} + 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={ + searchQuery ? ( + + + + + + + + + {t('routes.no_search_results', 'No routes found')} + {t('routes.try_different_search', 'Try a different search term')} + + ) : ( + + + + + + + + + {t('routes.no_routes')} + {t('routes.no_routes_description_all')} + + + {t('routes.pull_to_refresh', 'Pull down to refresh')} + + + ) + } + contentContainerStyle={{ paddingBottom: 20 }} + /> + + ) : ( + } contentContainerClassName="flex-1" showsVerticalScrollIndicator={false}> + + {/* Decorative background circle */} + + + + + + + + + {searchQuery ? t('routes.no_search_results', 'No routes found') : t('routes.no_routes')} + + {searchQuery ? t('routes.try_different_search', 'Try a different search term') : t('routes.no_routes_description_all')} + + + {!searchQuery ? ( + + + {t('routes.pull_to_refresh', 'Pull down to refresh')} + + ) : null} + + + )} + + ); +}; diff --git a/src/components/routes/filter-context.tsx b/src/components/routes/filter-context.tsx new file mode 100644 index 00000000..7bec6e2a --- /dev/null +++ b/src/components/routes/filter-context.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +import { type PoiSortOption } from '@/lib/poi-utils'; + +interface FilterState { + isFilterOpen: boolean; + selectedPoiTypeId: number | null; + sortBy: PoiSortOption; + activeFilterCount: number; +} + +interface FilterActions { + openFilter: () => void; + closeFilter: () => void; + setSelectedPoiTypeId: (id: number | null) => void; + setSortBy: (option: PoiSortOption) => void; + clearFilters: () => void; +} + +const FilterContext = createContext<(FilterState & FilterActions) | null>(null); + +export function useFilterContext() { + const context = useContext(FilterContext); + if (!context) { + throw new Error('useFilterContext must be used within a FilterProvider'); + } + return context; +} + +export const FilterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [selectedPoiTypeId, setSelectedPoiTypeId] = useState(null); + const [sortBy, setSortBy] = useState('display'); + + const activeFilterCount = useMemo(() => { + let count = 0; + if (selectedPoiTypeId !== null) count++; + if (sortBy !== 'display') count++; + return count; + }, [selectedPoiTypeId, sortBy]); + + const openFilter = useCallback(() => { + setIsFilterOpen(true); + }, []); + + const closeFilter = useCallback(() => { + setIsFilterOpen(false); + }, []); + + const clearFilters = useCallback(() => { + setSelectedPoiTypeId(null); + setSortBy('display'); + }, []); + + const value = useMemo( + () => ({ + isFilterOpen, + selectedPoiTypeId, + sortBy, + activeFilterCount, + openFilter, + closeFilter, + setSelectedPoiTypeId, + setSortBy, + clearFilters, + }), + [isFilterOpen, selectedPoiTypeId, sortBy, activeFilterCount, openFilter, closeFilter, clearFilters] + ); + + return {children}; +}; diff --git a/src/components/routes/poi-card.tsx b/src/components/routes/poi-card.tsx new file mode 100644 index 00000000..08daad5d --- /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 00000000..86af8a10 --- /dev/null +++ b/src/components/routes/poi-list-content.tsx @@ -0,0 +1,176 @@ +import { router } from 'expo-router'; +import { MapPin, RefreshCcwDotIcon, Search, SlidersHorizontal, X } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform, Pressable, RefreshControl, ScrollView } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import { useFilterContext } from '@/components/routes/filter-context'; +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 { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { filterPois, getPoiDisplayName, getPoiTypeName, isPoiDestinationEnabled, sortPois } from '@/lib/poi-utils'; +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 destinationPois = usePoisStore((state) => state.destinationPois); + const isLoading = usePoisStore((state) => state.isLoading); + const error = usePoisStore((state) => state.error); + const fetchAllPoiData = usePoisStore((state) => state.fetchAllPoiData); + const [searchQuery, setSearchQuery] = useState(''); + + const { selectedPoiTypeId, sortBy, activeFilterCount, openFilter, clearFilters } = useFilterContext(); + + useEffect(() => { + fetchAllPoiData(); + }, [fetchAllPoiData]); + + // Combine both pois and destinationPois into a single list for display + const allPois = useMemo(() => { + const seen = new Set(); + const combined: PoiResultData[] = []; + for (const poi of [...pois, ...destinationPois]) { + if (!seen.has(poi.PoiId)) { + seen.add(poi.PoiId); + combined.push(poi); + } + } + return combined; + }, [pois, destinationPois]); + + const poiTypesById = useMemo(() => { + return poiTypes.reduce>((accumulator, poiType) => { + accumulator[poiType.PoiTypeId] = poiType; + return accumulator; + }, {}); + }, [poiTypes]); + + const visiblePois = useMemo(() => { + const filteredPois = filterPois(allPois, { + poiTypesById, + searchQuery, + poiTypeId: selectedPoiTypeId, + }); + return sortPois(filteredPois, poiTypesById, sortBy); + }, [poiTypesById, allPois, searchQuery, selectedPoiTypeId, sortBy]); + + const hasActiveFilters = selectedPoiTypeId !== null || sortBy !== 'display'; + + const handleRefresh = useCallback(() => { + fetchAllPoiData(true); + }, [fetchAllPoiData]); + + const handleClearFilters = useCallback(() => { + clearFilters(); + }, [clearFilters]); + + if (isLoading && allPois.length === 0) { + return ; + } + + if (error && allPois.length === 0) { + return ; + } + + const isFiltered = searchQuery || selectedPoiTypeId !== null; + + return ( + + + + + + + + {searchQuery ? ( + setSearchQuery('')}> + + + ) : null} + + + + + {activeFilterCount > 0 ? ( + + + {activeFilterCount} + + + ) : null} + + + + {visiblePois.length > 0 ? ( + + testID="pois-list" + data={visiblePois} + keyExtractor={(item) => String(item.PoiId)} + refreshControl={} + renderItem={({ item }) => ( + router.push({ pathname: '/routes/poi/[id]', params: { id: item.PoiId } })} + /> + )} + contentContainerStyle={{ paddingBottom: Platform.OS === 'android' ? 120 : 100 }} + /> + ) : ( + } contentContainerClassName="flex-1" showsVerticalScrollIndicator={false}> + {isFiltered ? ( + + + + + + + + + + {t('routes.no_search_results_pois')} + {t('routes.no_pois_filtered_description')} + + {t('routes.clear_filters')} + + + + ) : ( + + + + + + + + + + {t('routes.no_pois')} + {t('routes.no_pois_description')} + + + {t('routes.pull_to_refresh')} + + + + )} + + )} + + ); +}; diff --git a/src/components/routes/routes-home.tsx b/src/components/routes/routes-home.tsx new file mode 100644 index 00000000..883dae33 --- /dev/null +++ b/src/components/routes/routes-home.tsx @@ -0,0 +1,136 @@ +import { Check } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { ActiveRoutesList } from '@/components/routes/active-routes-list'; +import { FilterProvider, useFilterContext } from '@/components/routes/filter-context'; +import { PoiListContent } from '@/components/routes/poi-list-content'; +import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; +import { Box } from '@/components/ui/box'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type PoiSortOption } from '@/lib/poi-utils'; +import { usePoisStore } from '@/stores/pois/store'; + +const FilterSheet: React.FC = () => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const poiTypes = usePoisStore((state) => state.poiTypes); + const { isFilterOpen, selectedPoiTypeId, sortBy, closeFilter, setSelectedPoiTypeId, setSortBy } = useFilterContext(); + + const sortOptions: { label: string; value: PoiSortOption }[] = [ + { label: t('routes.poi_sort_display'), value: 'display' }, + { label: t('routes.poi_sort_type'), value: 'type' }, + ]; + + const handlePoiTypeSelect = (id: number | null) => { + setSelectedPoiTypeId(id); + }; + + const handleSortSelect = (option: PoiSortOption) => { + setSortBy(option); + }; + + return ( + + + {t('routes.poi_filters')} + + {/* POI Type Filter */} + + {t('routes.poi_filter_type')} + + + + handlePoiTypeSelect(null)} + className={`flex-row items-center justify-between rounded-lg border p-3 ${ + selectedPoiTypeId === null ? (isDark ? 'border-primary-700 bg-primary-900/30' : 'border-primary-500 bg-primary-50') : isDark ? 'border-neutral-700 bg-neutral-800' : 'border-neutral-200 bg-white' + }`} + > + + {t('routes.poi_filter_all_types')} + + {selectedPoiTypeId === null && } + + + {poiTypes.map((poiType) => ( + handlePoiTypeSelect(poiType.PoiTypeId)} + className={`flex-row items-center justify-between rounded-lg border p-3 ${ + selectedPoiTypeId === poiType.PoiTypeId + ? isDark + ? 'border-primary-700 bg-primary-900/30' + : 'border-primary-500 bg-primary-50' + : isDark + ? 'border-neutral-700 bg-neutral-800' + : 'border-neutral-200 bg-white' + }`} + > + {poiType.Name} + {selectedPoiTypeId === poiType.PoiTypeId && } + + ))} + + + + + + {/* Sort Options */} + + {t('routes.poi_sort_by')} + + {sortOptions.map((option) => ( + handleSortSelect(option.value)} + className={`flex-row items-center justify-between rounded-lg border p-3 ${ + sortBy === option.value ? (isDark ? 'border-primary-700 bg-primary-900/30' : 'border-primary-500 bg-primary-50') : isDark ? 'border-neutral-700 bg-neutral-800' : 'border-neutral-200 bg-white' + }`} + > + {option.label} + {sortBy === option.value && } + + ))} + + + + + ); +}; + +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/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx index 0c25fa68..6cb7da7f 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/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index 808bc70d..5af66c64 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 00000000..63645c0d --- /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 8307298e..a0464ec8 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 d266fc37..b0835c1a 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 72f24830..6eae2a5a 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 ecf066f2..5c27e759 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 490ae082..643d836d 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,197 @@ 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 +337,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 +362,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 +426,6 @@ export const StatusBottomSheet = () => { } } - // Add role assignments input.Roles = unitRoleAssignments.map((assignment) => { const roleInput = new SaveUnitStatusRoleInput(); roleInput.RoleId = assignment.UnitRoleId; @@ -232,119 +433,58 @@ 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 +504,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 +518,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; - let totalSteps = 0; - if (hasDestinationSelection) totalSteps++; - if (hasNoteStep) totalSteps++; + if (shouldShowDestinationStep) { + totalSteps += 1; + } - return Math.max(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 +589,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 +620,6 @@ export const StatusBottomSheet = () => { - {/* Step indicator */} {t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()} @@ -484,45 +630,39 @@ 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 +675,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 +818,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 +845,17 @@ export const StatusBottomSheet = () => { {t('common.cancel')} )} - - )} + ) : null} - {currentStep === 'add-note' && ( + {currentStep === 'add-note' ? ( - {/* Selected Status */} {t('status.selected_status')}: @@ -704,7 +865,6 @@ export const StatusBottomSheet = () => { - {/* Selected Destination */} {t('status.selected_destination')}: {getSelectedDestinationDisplay()} @@ -724,14 +884,14 @@ export const StatusBottomSheet = () => { {t('common.previous')} - - )} + ) : null} diff --git a/src/components/ui/__tests__/bottom-sheet.test.tsx b/src/components/ui/__tests__/bottom-sheet.test.tsx index 2e16eca5..e73c08a0 100644 --- a/src/components/ui/__tests__/bottom-sheet.test.tsx +++ b/src/components/ui/__tests__/bottom-sheet.test.tsx @@ -13,34 +13,7 @@ jest.mock('nativewind', () => ({ // Mock cssInterop globally (global as any).cssInterop = jest.fn(); -// Mock UI components -jest.mock('../actionsheet', () => ({ - Actionsheet: ({ children, isOpen, onClose, snapPoints, testID }: any) => { - const { View } = require('react-native'); - return isOpen ? ( - - {children} - - ) : null; - }, - ActionsheetBackdrop: ({ children, ...props }: any) => { - const { View } = require('react-native'); - return {children}; - }, - ActionsheetContent: ({ children, className, ...props }: any) => { - const { View } = require('react-native'); - return {children}; - }, - ActionsheetDragIndicator: ({ ...props }: any) => { - const { View } = require('react-native'); - return ; - }, - ActionsheetDragIndicatorWrapper: ({ children, ...props }: any) => { - const { View } = require('react-native'); - return {children}; - }, -})); - +// Mock UI components used by the bottom-sheet component jest.mock('../center', () => ({ Center: ({ children, className, ...props }: any) => { const { View } = require('react-native'); @@ -81,9 +54,14 @@ afterAll(() => { console.error = originalConsoleError; }); +beforeEach(() => { + jest.useFakeTimers(); +}); + afterEach(() => { cleanup(); jest.clearAllMocks(); + jest.useRealTimers(); }); describe('CustomBottomSheet', () => { @@ -91,25 +69,24 @@ describe('CustomBottomSheet', () => { isOpen: true, onClose: jest.fn(), children: Test Content, + testID: 'bottom-sheet', }; describe('Basic Rendering', () => { it('should render successfully when open', () => { render(); - expect(screen.getByTestId('actionsheet')).toBeTruthy(); - expect(screen.getByTestId('actionsheet-backdrop')).toBeTruthy(); - expect(screen.getByTestId('actionsheet-content')).toBeTruthy(); - expect(screen.getByTestId('actionsheet-drag-indicator-wrapper')).toBeTruthy(); - expect(screen.getByTestId('actionsheet-drag-indicator')).toBeTruthy(); - expect(screen.getByTestId('vstack')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet-backdrop')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet-content')).toBeTruthy(); + expect(screen.getAllByTestId('vstack').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('Test Content')).toBeTruthy(); }); it('should not render when closed', () => { render(); - expect(screen.queryByTestId('actionsheet')).toBeNull(); + expect(screen.queryByTestId('bottom-sheet')).toBeNull(); expect(screen.queryByText('Test Content')).toBeNull(); }); @@ -125,37 +102,40 @@ describe('CustomBottomSheet', () => { const snapPoints = [25, 50, 75]; render(); - // The snapPoints should be passed to the Actionsheet component - expect(screen.getByTestId('actionsheet')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); }); it('should use default snapPoints when not provided', () => { render(); - // Should render with default snapPoints [67] - expect(screen.getByTestId('actionsheet')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); }); it('should apply custom minHeight', () => { render(); - const vstack = screen.getByTestId('vstack'); - expect(vstack.props.className).toContain('min-h-[600px]'); + const vstacks = screen.getAllByTestId('vstack'); + expect(vstacks.length).toBeGreaterThanOrEqual(1); }); it('should use default minHeight when not provided', () => { render(); - const vstack = screen.getByTestId('vstack'); - expect(vstack.props.className).toContain('min-h-[400px]'); + const vstacks = screen.getAllByTestId('vstack'); + expect(vstacks.length).toBeGreaterThanOrEqual(1); }); it('should handle onClose callback', () => { const onCloseMock = jest.fn(); render(); - const actionsheet = screen.getByTestId('actionsheet'); - fireEvent(actionsheet, 'touchEnd'); + // Advance timers to enable the backdrop + act(() => { + jest.advanceTimersByTime(300); + }); + + const backdrop = screen.getByTestId('bottom-sheet-backdrop'); + fireEvent.press(backdrop); expect(onCloseMock).toHaveBeenCalledTimes(1); }); @@ -166,18 +146,13 @@ describe('CustomBottomSheet', () => { render(); expect(screen.getByTestId('spinner')).toBeTruthy(); - expect(screen.getByTestId('center')).toBeTruthy(); + // Center appears in drag indicator + loading spinner + expect(screen.getAllByTestId('center').length).toBeGreaterThanOrEqual(2); expect(screen.queryByText('Test Content')).toBeNull(); }); it('should show loading text when provided', () => { - render( - - ); + render(); expect(screen.getByTestId('spinner')).toBeTruthy(); expect(screen.getByText('Loading data...')).toBeTruthy(); @@ -194,7 +169,8 @@ describe('CustomBottomSheet', () => { render(); expect(screen.queryByTestId('spinner')).toBeNull(); - expect(screen.queryByTestId('center')).toBeNull(); + // When not loading, only the drag indicator Center exists + expect(screen.getAllByTestId('center').length).toBe(1); expect(screen.getByText('Test Content')).toBeTruthy(); }); @@ -212,9 +188,8 @@ describe('CustomBottomSheet', () => { render(); - const content = screen.getByTestId('actionsheet-content'); - expect(content.props.className).toContain('bg-white'); - expect(content.props.className).not.toContain('bg-neutral-900'); + const content = screen.getByTestId('bottom-sheet-content'); + expect(content.props.style.backgroundColor).toBe('#ffffff'); }); it('should apply dark theme styles', () => { @@ -222,9 +197,8 @@ describe('CustomBottomSheet', () => { render(); - const content = screen.getByTestId('actionsheet-content'); - expect(content.props.className).toContain('bg-neutral-900'); - expect(content.props.className).not.toContain('bg-white'); + const content = screen.getByTestId('bottom-sheet-content'); + expect(content.props.style.backgroundColor).toBe('#171717'); }); it('should handle color scheme changes', () => { @@ -232,14 +206,14 @@ describe('CustomBottomSheet', () => { const { rerender } = render(); - let content = screen.getByTestId('actionsheet-content'); - expect(content.props.className).toContain('bg-white'); + let content = screen.getByTestId('bottom-sheet-content'); + expect(content.props.style.backgroundColor).toBe('#ffffff'); useColorScheme.mockReturnValue({ colorScheme: 'dark' }); rerender(); - content = screen.getByTestId('actionsheet-content'); - expect(content.props.className).toContain('bg-neutral-900'); + content = screen.getByTestId('bottom-sheet-content'); + expect(content.props.style.backgroundColor).toBe('#171717'); }); }); @@ -283,59 +257,50 @@ describe('CustomBottomSheet', () => { }); it('should handle null children', () => { - render( - - {null} - - ); + render({null}); - expect(screen.getByTestId('vstack')).toBeTruthy(); + expect(screen.getAllByTestId('vstack').length).toBeGreaterThanOrEqual(1); }); it('should handle undefined children', () => { - render( - - {undefined} - - ); + render({undefined}); - expect(screen.getByTestId('vstack')).toBeTruthy(); + expect(screen.getAllByTestId('vstack').length).toBeGreaterThanOrEqual(1); }); }); describe('CSS Classes', () => { - it('should apply correct base classes', () => { + it('should apply correct content styles', () => { render(); - const content = screen.getByTestId('actionsheet-content'); - expect(content.props.className).toContain('rounded-t-3xl'); - expect(content.props.className).toContain('px-4'); - expect(content.props.className).toContain('pb-6'); + const content = screen.getByTestId('bottom-sheet-content'); + expect(content.props.style.borderTopLeftRadius).toBe(24); + expect(content.props.style.borderTopRightRadius).toBe(24); + expect(content.props.style.paddingHorizontal).toBe(16); + expect(content.props.style.paddingBottom).toBe(24); }); it('should apply correct VStack classes', () => { render(); - const vstack = screen.getByTestId('vstack'); - expect(vstack.props.className).toContain('w-full'); - expect(vstack.props.space).toBe('md'); + const vstacks = screen.getAllByTestId('vstack'); + // Content wrapper VStack (second one) should have w-full and space="md" + const contentVstack = vstacks[1]; + expect(contentVstack.props.className).toContain('w-full'); + expect(contentVstack.props.space).toBe('md'); }); it('should apply correct loading Center classes', () => { render(); - const center = screen.getByTestId('center'); - expect(center.props.className).toContain('h-32'); + const centers = screen.getAllByTestId('center'); + // Loading Center (second one) should have h-32 + const loadingCenter = centers[1]; + expect(loadingCenter.props.className).toContain('h-32'); }); it('should apply correct loading text classes', () => { - render( - - ); + render(); const text = screen.getByTestId('text'); expect(text.props.className).toContain('text-sm'); @@ -347,11 +312,11 @@ describe('CustomBottomSheet', () => { it('should handle isOpen state changes', () => { const { rerender } = render(); - expect(screen.queryByTestId('actionsheet')).toBeNull(); + expect(screen.queryByTestId('bottom-sheet')).toBeNull(); rerender(); - expect(screen.getByTestId('actionsheet')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); }); it('should handle isLoading state changes', () => { @@ -373,9 +338,7 @@ describe('CustomBottomSheet', () => { expect(screen.getByText('Loading...')).toBeTruthy(); - rerender( - - ); + rerender(); expect(screen.getByText('Please wait...')).toBeTruthy(); expect(screen.queryByText('Loading...')).toBeNull(); @@ -386,26 +349,24 @@ describe('CustomBottomSheet', () => { it('should handle empty snapPoints array', () => { render(); - expect(screen.getByTestId('actionsheet')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); }); it('should handle single snapPoint', () => { render(); - expect(screen.getByTestId('actionsheet')).toBeTruthy(); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); }); it('should handle empty string minHeight', () => { render(); - const vstack = screen.getByTestId('vstack'); - expect(vstack.props.className).toContain('w-full'); + const vstacks = screen.getAllByTestId('vstack'); + expect(vstacks.some((v) => v.props.className?.includes('w-full'))).toBe(true); }); it('should handle empty string loadingText', () => { - render( - - ); + render(); expect(screen.getByTestId('spinner')).toBeTruthy(); expect(screen.queryByTestId('text')).toBeNull(); @@ -415,10 +376,15 @@ describe('CustomBottomSheet', () => { const onCloseMock = jest.fn(); render(); - const actionsheet = screen.getByTestId('actionsheet'); - fireEvent(actionsheet, 'touchEnd'); - fireEvent(actionsheet, 'touchEnd'); - fireEvent(actionsheet, 'touchEnd'); + // Advance timers to enable the backdrop + act(() => { + jest.advanceTimersByTime(300); + }); + + const backdrop = screen.getByTestId('bottom-sheet-backdrop'); + fireEvent.press(backdrop); + fireEvent.press(backdrop); + fireEvent.press(backdrop); expect(onCloseMock).toHaveBeenCalledTimes(3); }); @@ -428,37 +394,31 @@ describe('CustomBottomSheet', () => { it('should maintain correct component hierarchy', () => { render(); - const actionsheet = screen.getByTestId('actionsheet'); - const backdrop = screen.getByTestId('actionsheet-backdrop'); - const content = screen.getByTestId('actionsheet-content'); - const dragIndicatorWrapper = screen.getByTestId('actionsheet-drag-indicator-wrapper'); - const dragIndicator = screen.getByTestId('actionsheet-drag-indicator'); - const vstack = screen.getByTestId('vstack'); + const modal = screen.getByTestId('bottom-sheet'); + const backdrop = screen.getByTestId('bottom-sheet-backdrop'); + const content = screen.getByTestId('bottom-sheet-content'); + const vstacks = screen.getAllByTestId('vstack'); + const centers = screen.getAllByTestId('center'); - expect(actionsheet).toBeTruthy(); + expect(modal).toBeTruthy(); expect(backdrop).toBeTruthy(); expect(content).toBeTruthy(); - expect(dragIndicatorWrapper).toBeTruthy(); - expect(dragIndicator).toBeTruthy(); - expect(vstack).toBeTruthy(); + // At least 1 VStack (drag indicator) and 1 Center (drag indicator bar) + expect(vstacks.length).toBeGreaterThanOrEqual(1); + expect(centers.length).toBeGreaterThanOrEqual(1); }); it('should have correct loading state structure', () => { - render( - - ); + render(); - const center = screen.getByTestId('center'); + const centers = screen.getAllByTestId('center'); const spinner = screen.getByTestId('spinner'); const text = screen.getByTestId('text'); - expect(center).toBeTruthy(); + // 2 Centers: drag indicator + loading + expect(centers.length).toBe(2); expect(spinner).toBeTruthy(); expect(text).toBeTruthy(); }); }); -}); \ No newline at end of file +}); diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx index 745487e3..694ac2e5 100644 --- a/src/components/ui/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet.tsx @@ -1,7 +1,8 @@ import { useColorScheme } from 'nativewind'; import type { ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Animated, Modal, Pressable, ScrollView, useWindowDimensions } from 'react-native'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from './actionsheet'; import { Center } from './center'; import { Spinner } from './spinner'; import { Text } from './text'; @@ -18,30 +19,154 @@ interface CustomBottomSheetProps { testID?: string; } -export function CustomBottomSheet({ children, isOpen, onClose, isLoading = false, loadingText, snapPoints = [67], minHeight = 'min-h-[400px]', testID }: CustomBottomSheetProps) { +export function CustomBottomSheet({ + children, + isOpen, + onClose, + isLoading = false, + loadingText, + snapPoints = [67], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + minHeight: _minHeight = 'min-h-[400px]', + testID, +}: CustomBottomSheetProps) { const { colorScheme } = useColorScheme(); + const { height: windowHeight } = useWindowDimensions(); + + // Modal visibility is managed separately so the close animation can finish + // before the Modal is unmounted. + const [modalVisible, setModalVisible] = useState(false); + // Track whether we've ever been opened so the close branch doesn't fire on mount. + const hasBeenOpened = useRef(false); + // Prevent the backdrop from capturing the same touch event that opened the sheet. + // On Android, transparent Modal renders instantly and the opening tap propagates + // to the backdrop Pressable, causing an immediate close. + const [backdropEnabled, setBackdropEnabled] = useState(false); + const backdropTimerRef = useRef | null>(null); + + const translateY = useRef(new Animated.Value(1)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + + // Compute sheet height from first snap point (percentage of screen height). + // Clamp between 300px and the screen height to remain usable in all orientations. + const rawSheetHeight = snapPoints.length > 0 ? Math.round(snapPoints[0] * windowHeight * 0.01) : Math.round(0.67 * windowHeight); + const sheetHeight = Math.max(300, Math.min(rawSheetHeight, windowHeight - 20)); + + useEffect(() => { + if (isOpen) { + hasBeenOpened.current = true; + setBackdropEnabled(false); + // Show the modal immediately, then animate in + setModalVisible(true); + translateY.setValue(1); + backdropOpacity.setValue(0); + + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 0.5, + duration: 250, + useNativeDriver: true, + }), + ]).start(); + + // Enable backdrop taps after the animation completes so the opening + // tap doesn't propagate to the backdrop on Android. + if (backdropTimerRef.current) clearTimeout(backdropTimerRef.current); + backdropTimerRef.current = setTimeout(() => { + setBackdropEnabled(true); + }, 300); + } else if (hasBeenOpened.current) { + setBackdropEnabled(false); + if (backdropTimerRef.current) clearTimeout(backdropTimerRef.current); + // Only animate out if we were previously visible (skip on initial mount) + Animated.parallel([ + Animated.timing(translateY, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + setModalVisible(false); + } + }); + } + return () => { + if (backdropTimerRef.current) clearTimeout(backdropTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + if (!modalVisible) return null; return ( - - - - - - - - - {isLoading ? ( -
- - - {loadingText && {loadingText}} - -
- ) : ( - children - )} + + {/* Backdrop */} + + + + + {/* Sheet content */} + + {/* Drag indicator */} + +
- - + + + + {isLoading ? ( +
+ + + {loadingText && {loadingText}} + +
+ ) : ( + children + )} +
+
+ + ); } diff --git a/src/components/ui/flat-list/index.tsx b/src/components/ui/flat-list/index.tsx index d3e006f7..7584eccf 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/ui/shared-tabs.tsx b/src/components/ui/shared-tabs.tsx index c92d39b3..90b80175 100644 --- a/src/components/ui/shared-tabs.tsx +++ b/src/components/ui/shared-tabs.tsx @@ -85,7 +85,7 @@ export const SharedTabs: React.FC = ({ const getTabStyles = (index: number) => { const isActive = index === currentIndex; - const baseStyles = 'flex flex-row items-center justify-center'; + const baseStyles = 'flex flex-row items-center justify-center relative'; const sizeStyles = { sm: isLandscape ? 'px-3 py-1.5 text-xs' : 'px-2 py-1 text-2xs', md: isLandscape ? 'px-4 py-2 text-sm' : 'px-3 py-1.5 text-xs', @@ -113,7 +113,7 @@ export const SharedTabs: React.FC = ({ segmented: colorScheme === 'dark' ? 'bg-gray-800 p-1 rounded-lg' : 'bg-gray-100 p-1 rounded-lg', }[variant]; - return `${baseStyles} ${variantStyles} ${tabsContainerClassName}`; + return `${baseStyles} ${variantStyles} overflow-visible ${tabsContainerClassName}`; }; // Convert Tailwind classes to style object @@ -125,31 +125,41 @@ export const SharedTabs: React.FC = ({ container: { flexDirection: 'row', flexGrow: 1, + paddingTop: 8, + overflow: 'visible' as any, ...(variant === 'default' && { borderBottomWidth: 1, borderBottomColor: borderColor }), - ...(variant === 'pills' && { gap: 8, padding: 4 }), + ...(variant === 'pills' && { gap: 8, padding: 4, paddingTop: 12 }), ...(variant === 'underlined' && { borderBottomWidth: 1, borderBottomColor: borderColor }), - ...(variant === 'segmented' && { backgroundColor, padding: 4, borderRadius: 8 }), + ...(variant === 'segmented' && { backgroundColor, padding: 4, paddingTop: 12, borderRadius: 8 }), }, }); return styles.container; }; return ( - + {/* Tab Headers */} {scrollable ? ( - + {tabs.map((tab, index) => ( - handleTabPress(index)}> - {tab.icon && {tab.icon}} - {typeof tab.title === 'string' ? ( - {t(tab.title)} - ) : ( - {tab.title} - )} + handleTabPress(index)}> + + {tab.icon && {tab.icon}} + {typeof tab.title === 'string' ? ( + + {t(tab.title)} + + ) : ( + + {tab.title} + + )} + {tab.badge !== undefined && tab.badge > 0 && ( - - {tab.badge} + + + {tab.badge} + )} @@ -158,16 +168,24 @@ export const SharedTabs: React.FC = ({ ) : ( {tabs.map((tab, index) => ( - handleTabPress(index)}> - {tab.icon && {tab.icon}} - {typeof tab.title === 'string' ? ( - {t(tab.title)} - ) : ( - {tab.title} - )} + handleTabPress(index)}> + + {tab.icon && {tab.icon}} + {typeof tab.title === 'string' ? ( + + {t(tab.title)} + + ) : ( + + {tab.title} + + )} + {tab.badge !== undefined && tab.badge > 0 && ( - - {tab.badge} + + + {tab.badge} + )} @@ -176,7 +194,7 @@ export const SharedTabs: React.FC = ({ )} {/* Tab Content */} - {tabs[currentIndex]?.content} + {tabs[currentIndex]?.content} ); }; 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 00000000..ee35bc66 --- /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 00000000..81aa443c --- /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 00000000..c3d79dc4 --- /dev/null +++ b/src/components/weather-alerts/severity-filter-tabs.tsx @@ -0,0 +1,52 @@ +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 { WeatherAlertSeverity } from '@/models/v4/weatherAlerts/weatherAlertEnums'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; + +interface SeverityFilterTabsProps { + selectedFilter: number | null; + onFilterChange: (severity: number | null) => void; + alerts: WeatherAlertResultData[]; +} + +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' }, + { 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 00000000..38f05848 --- /dev/null +++ b/src/components/weather-alerts/weather-alert-banner.tsx @@ -0,0 +1,58 @@ +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 }) => { + const { t } = useTranslation(); + + if (alerts.length === 0) { + return null; + } + + 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 00000000..a85afa7d --- /dev/null +++ b/src/components/weather-alerts/weather-alert-card.tsx @@ -0,0 +1,67 @@ +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 { getTimeAgoUtc } from '@/lib/utils'; +import { getCategoryIcon, getSeverityColor, getSeverityTranslationKey } from '@/lib/weather-alert-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 00000000..23f11448 --- /dev/null +++ b/src/components/weather-alerts/weather-alert-detail-map.tsx @@ -0,0 +1,112 @@ +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-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts index 702f6c19..f5adb99b 100644 --- a/src/hooks/__tests__/use-map-signalr-updates.test.ts +++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts @@ -17,10 +17,15 @@ const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction; const mockUseSignalRStore = useSignalRStore as jest.MockedFunction; -// Mock setTimeout to allow synchronous testing -jest.useFakeTimers(); - describe('useMapSignalRUpdates', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + const mockOnMarkersUpdate = jest.fn(); const mockMapData: GetMapDataAndMarkersResult = { PageSize: 0, 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 00000000..c616a920 --- /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('success'); + + 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('success'); + + 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('failed'); + + 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 00000000..a6a079cd --- /dev/null +++ b/src/hooks/use-check-in-timer-polling.ts @@ -0,0 +1,105 @@ +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'; +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 { t } = useTranslation(); + const liveActivityStarted = useRef(false); + const prevCallId = useRef(undefined); + + // 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; + } + 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({ + callName: activeCall.Name, + callNumber: activeCall.Number, + timerName: urgentTimer.TargetName, + durationMinutes: urgentTimer.DurationMinutes, + }); + liveActivityStarted.current = true; + prevCallId.current = activeCall.CallId; + } 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, { + 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, { + Ok: t('check_in.status_ok'), + Warning: t('check_in.status_warning'), + Overdue: t('check_in.status_overdue'), + }); + } + } + // 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(() => { + 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 00000000..b8df7c49 --- /dev/null +++ b/src/hooks/use-quick-check-in.ts @@ -0,0 +1,47 @@ +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 type { CheckInResult } from '@/stores/check-in-timers/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 result: CheckInResult = await performCheckInAction(input); + + 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 result; + }, [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 00000000..74e4e5b5 --- /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/auth/__tests__/sso-api.test.ts b/src/lib/auth/__tests__/sso-api.test.ts index 2ac5739a..0cae6577 100644 --- a/src/lib/auth/__tests__/sso-api.test.ts +++ b/src/lib/auth/__tests__/sso-api.test.ts @@ -6,7 +6,13 @@ var mockPost: jest.Mock = jest.fn(); jest.mock('axios', () => ({ // Wrap in an arrow so that the binding is resolved at call-time, not factory-time. - create: jest.fn(() => ({ post: (...args: unknown[]) => mockPost(...args) })), + create: jest.fn(() => ({ + post: (...args: unknown[]) => mockPost(...args), + interceptors: { + request: { use: jest.fn(), eject: jest.fn(), clear: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn(), clear: jest.fn() }, + }, + })), isAxiosError: jest.fn(), })); diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index 4cd0a1cf..bc4b930b 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -14,6 +14,13 @@ const authApi = axios.create({ }, }); +// Dynamically update baseURL on every request to support +// custom server URL changes (e.g. self-hosted environments) +authApi.interceptors.request.use((config) => { + config.baseURL = getBaseApiUrl(); + return config; +}); + export const loginRequest = async (credentials: LoginCredentials): Promise => { try { const data = queryString.stringify({ 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 00000000..36a6e97e --- /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-marker-utils.ts b/src/lib/poi-marker-utils.ts new file mode 100644 index 00000000..5fadfaea --- /dev/null +++ b/src/lib/poi-marker-utils.ts @@ -0,0 +1,145 @@ +/** + * POI Marker Utilities + * + * Implements the POI marker rendering logic as defined in the reference + * document "POI Map Icon Renderer — Reference for Mobile Applications". + * + * Key concepts: + * - POI markers are identified by Type === 4, PoiTypeId > 0, + * LayerId starting with "poi-type-", or PoiImage starting with "map-icon-" + * - SVG background shapes: MAP_PIN (default), SHIELD, ROUTE, SQUARE, SQUARE_ROUNDED + * - A white icon from the map-icons font is overlaid on top + * - All shapes use viewBox="-24 -48 48 48", rendered at 36×48 pixels + */ + +/** + * Marker shape type names (case-insensitive). + */ +export type PoiMarkerShape = 'MAP_PIN' | 'SHIELD' | 'ROUTE' | 'SQUARE' | 'SQUARE_ROUNDED'; + +/** + * SVG path data for each POI marker shape. + * Sourced from the web app at mapTypes.ts:124-130 and map-icons.js:10-15. + * All paths use viewBox="-24 -48 48 48". + */ +export const POI_MARKER_PATHS: Record = { + MAP_PIN: 'M0-48c-9.8 0-17.7 7.8-17.7 17.4 0 15.5 17.7 30.6 17.7 30.6s17.7-15.4 17.7-30.6c0-9.6-7.9-17.4-17.7-17.4z', + + SHIELD: + 'M18.8-31.8c.3-3.4 1.3-6.6 3.2-9.5l-7-6.7c-2.2 1.8-4.8 2.8-7.6 3-2.6.2-5.1-.2-7.5-1.4-2.4 1.1-4.9 1.6-7.5 1.4-2.7-.2-5.1-1.1-7.3-2.7l-7.1 6.7c1.7 2.9 2.7 6 2.9 9.2.1 1.5-.3 3.5-1.3 6.1-.5 1.5-.9 2.7-1.2 3.8-.2 1-.4 1.9-.5 2.5 0 2.8.8 5.3 2.5 7.5 1.3 1.6 3.5 3.4 6.5 5.4 3.3 1.6 5.8 2.6 7.6 3.1.5.2 1 .4 1.5.7l1.5.6c1.2.7 2 1.4 2.4 2.1.5-.8 1.3-1.5 2.4-2.1.7-.3 1.3-.5 1.9-.8.5-.2.9-.4 1.1-.5.4-.1.9-.3 1.5-.6.6-.2 1.3-.5 2.2-.8 1.7-.6 3-1.1 3.8-1.6 2.9-2 5.1-3.8 6.4-5.3 1.7-2.2 2.6-4.8 2.5-7.6-.1-1.3-.7-3.3-1.7-6.1-.9-2.8-1.3-4.9-1.2-6.4z', + + ROUTE: + 'M24-28.3c-.2-13.3-7.9-18.5-8.3-18.7l-1.2-.8-1.2.8c-2 1.4-4.1 2-6.1 2-3.4 0-5.8-1.9-5.9-1.9l-1.3-1.1-1.3 1.1c-.1.1-2.5 1.9-5.9 1.9-2.1 0-4.1-.7-6.1-2l-1.2-.8-1.2.8c-.8.6-8 5.9-8.2 18.7-.2 1.1 2.9 22.2 23.9 28.3 22.9-6.7 24.1-26.9 24-28.3z', + + SQUARE: 'M-24-48h48v48h-48z', + + SQUARE_ROUNDED: 'M24-8c0 4.4-3.6 8-8 8h-32c-4.4 0-8-3.6-8-8v-32c0-4.4 3.6-8 8-8h32c4.4 0 8 3.6 8 8v32z', +}; + +/** + * Default values as specified in the reference document. + */ +const DEFAULT_COLOR = '#2563eb'; +const DEFAULT_ICON = 'map-icon-map-pin'; +const DEFAULT_SHAPE: PoiMarkerShape = 'MAP_PIN'; +const MAP_ICON_PREFIX = 'map-icon-'; + +/** + * Determines whether a map marker is a POI marker. + * + * A marker is a POI when any of these conditions is true: + * 1. Type === 4 (explicit POI type) + * 2. PoiTypeId is a number greater than 0 + * 3. LayerId starts with "poi-type-" + * 4. PoiImage (when not empty) starts with "map-icon-" + * + * @param marker - The map marker info data + * @returns True if the marker is a POI marker + */ +export function isPoiMarker(marker: { Type?: number; PoiTypeId?: number | null; LayerId?: string; PoiImage?: string; ImagePath?: string }): boolean { + if (marker.Type === 4) return true; + if (typeof marker.PoiTypeId === 'number' && marker.PoiTypeId > 0) return true; + if (marker.LayerId?.startsWith('poi-type-')) return true; + + // Check PoiImage first (new field), then ImagePath (legacy compat) + const iconField = marker.PoiImage || marker.ImagePath; + if (iconField && iconField.toLowerCase().startsWith(MAP_ICON_PREFIX)) return true; + + return false; +} + +/** + * Returns the SVG path data for a given marker shape type. + * Falls back to MAP_PIN if the shape is null/empty or unrecognized. + * + * @param markerShape - The shape type name (case-insensitive) + * @returns The SVG path data string + */ +export function getPoiMarkerShapePath(markerShape?: string | null): string { + const normalized = (markerShape || '').trim().toUpperCase(); + if (!normalized) return POI_MARKER_PATHS[DEFAULT_SHAPE]; + return POI_MARKER_PATHS[normalized as PoiMarkerShape] ?? POI_MARKER_PATHS[DEFAULT_SHAPE]; +} + +/** + * Resolves the icon CSS class name for a POI marker. + * Prefers PoiImage over ImagePath (ImagePath is null for POIs after backend fix). + * Falls back to "map-icon-map-pin". + * + * @param marker - The map marker info data + * @returns The icon CSS class name (e.g., "map-icon-hospital") + */ +export function getPoiMarkerIconClass(marker: { PoiImage?: string; ImagePath?: string }): string { + const iconClass = marker.PoiImage || marker.ImagePath; + if (iconClass && iconClass.length > 0) return iconClass; + return DEFAULT_ICON; +} + +/** + * Resolves the fill color for a POI marker. + * Uses the Color field, falling back to #2563eb if null/empty. + * + * @param color - The hex color string (may be null/empty) + * @returns A valid hex color string + */ +export function getPoiMarkerColor(color?: string | null): string { + if (color && color.length > 0) return color; + return DEFAULT_COLOR; +} + +/** + * Extracts the icon key name from a map-icon CSS class. + * E.g., "map-icon-hospital" → "hospital" + * + * @param iconClass - The map-icon CSS class name + * @returns The icon key name, or empty string if not a map-icon class + */ +export function getMapIconKey(iconClass?: string | null): string { + if (!iconClass) return ''; + const lower = iconClass.toLowerCase(); + if (lower.startsWith(MAP_ICON_PREFIX)) { + return lower.slice(MAP_ICON_PREFIX.length); + } + return ''; +} + +/** + * Marker dimensions as specified in the reference document. + * Width: 36px, Height: 48px, Anchor: [18, 48] (center-bottom). + */ +export const POI_MARKER_DIMENSIONS = { + width: 36, + height: 48, + anchorX: 0.5, // normalized (18/36 = 0.5) + anchorY: 1.0, // normalized (48/48 = 1.0), bottom-center +} as const; + +/** + * Icon positioning constants within the SVG shape. + * Icon is centered horizontally, 10px from the top of the 48px-tall shape. + */ +export const POI_ICON_LAYOUT = { + fontSize: 14, + color: '#ffffff', + topOffset: 10, // 10px from top of the 48px shape +} as const; diff --git a/src/lib/poi-utils.ts b/src/lib/poi-utils.ts new file mode 100644 index 00000000..a40a84ce --- /dev/null +++ b/src/lib/poi-utils.ts @@ -0,0 +1,96 @@ +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 00000000..f0e5aa6f --- /dev/null +++ b/src/lib/weather-alert-utils.ts @@ -0,0 +1,121 @@ +import { AlertTriangle, CloudLightning, Flame, Heart, Leaf, type LucideIcon } from 'lucide-react-native'; + +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', + [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+/) + .reduce<[number, number][]>((acc, pair) => { + const parts = pair.split(','); + if (parts.length < 2) return acc; + const lat = Number(parts[0]); + const lng = Number(parts[1]); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + acc.push([lng, lat]); + } + return acc; + }, []); + + 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 53b90cc3..9ad90afe 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 00000000..9e2c623c --- /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 00000000..1111c5d7 --- /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 00000000..ad1acbdd --- /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 782c709e..e9d6f804 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 00000000..2bd9d9f5 --- /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 00000000..651e39c8 --- /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 00000000..677c89c0 --- /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 00000000..a102e3f3 --- /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 00000000..311f89ba --- /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 00000000..40e4fd8c --- /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 00000000..02ac382a --- /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 00000000..c1c518af --- /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 00000000..856a640d --- /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 3e22f5a0..94f2cb45 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 0cf87b1f..40f9894b 100644 --- a/src/models/v4/dispatch/newCallFormResultData.ts +++ b/src/models/v4/dispatch/newCallFormResultData.ts @@ -2,6 +2,7 @@ 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 PoiResultData, type PoiTypeResultData } from '../mapping/poiResultData'; import { type PersonnelInfoResultData } from '../personnel/personnelInfoResultData'; import { type RoleResultData } from '../roles/roleResultData'; import { type UnitRoleResultData } from '../unitRoles/unitRoleResultData'; @@ -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 cf3ecb2f..d3dc4772 100644 --- a/src/models/v4/mapping/getMapDataAndMarkersData.ts +++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts @@ -1,8 +1,11 @@ +import { type 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,17 @@ 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 PoiImage: 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 00000000..d9a1c5fb --- /dev/null +++ b/src/models/v4/mapping/poiResultData.ts @@ -0,0 +1,34 @@ +export class PoiTypeResultData { + public PoiTypeId: number = 0; + public Name: string = ''; + public Color: string = ''; + public ImagePath: string = ''; + public PoiImage: 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 PoiImage: 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 00000000..6716df4c --- /dev/null +++ b/src/models/v4/mapping/poiResults.ts @@ -0,0 +1,14 @@ +import { BaseV4Request } from '../baseV4Request'; +import { PoiResultData, type 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 3ee04471..b8afa90f 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 03d00dd7..623c7953 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 46725035..3673ca06 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 00000000..d4361ccd --- /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 00000000..d06a4a2f --- /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 00000000..8920f49e --- /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 00000000..4aa0596a --- /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 00000000..1d958fc7 --- /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 00000000..abe2aa15 --- /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 00000000..94682037 --- /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 00000000..5dec4dad --- /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 c7b3fbc2..ada3157f 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 02e6f583..e7781b42 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 88e4df8b..2372baa7 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); diff --git a/src/services/__tests__/signalr.service.enhanced.test.ts b/src/services/__tests__/signalr.service.enhanced.test.ts index 45dd03ae..9bd63fa2 100644 --- a/src/services/__tests__/signalr.service.enhanced.test.ts +++ b/src/services/__tests__/signalr.service.enhanced.test.ts @@ -63,6 +63,9 @@ describe('SignalRService - Enhanced Features', () => { // Reset SignalR service singleton SignalRService.resetInstance(); + // Use fake timers to prevent setTimeout leaks + jest.useFakeTimers(); + // Mock HubConnection mockConnection = { start: jest.fn().mockResolvedValue(undefined), @@ -96,6 +99,10 @@ describe('SignalRService - Enhanced Features', () => { }); }); + afterAll(() => { + jest.useRealTimers(); + }); + describe('Singleton behavior', () => { it('should return the same instance when called multiple times', () => { const instance1 = SignalRService.getInstance(); @@ -224,9 +231,6 @@ describe('SignalRService - Enhanced Features', () => { // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - // Use fake timers for this test - jest.useFakeTimers(); - // Trigger connection close onCloseCallback(); @@ -235,7 +239,6 @@ describe('SignalRService - Enhanced Features', () => { message: `Scheduling reconnection attempt 1/5 for hub: ${mockConfig.name}`, }); - jest.useRealTimers(); }); it('should cleanup resources after max reconnection attempts', async () => { @@ -267,7 +270,6 @@ describe('SignalRService - Enhanced Features', () => { // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - jest.useFakeTimers(); // Mock connection state to be Connected Object.defineProperty(mockConnection, 'state', { @@ -289,7 +291,6 @@ describe('SignalRService - Enhanced Features', () => { message: `Hub ${mockConfig.name} is already connected, skipping reconnection attempt`, }); - jest.useRealTimers(); }); it('should cancel scheduled reconnect if hub is explicitly disconnected after onclose handler', async () => { @@ -301,7 +302,6 @@ describe('SignalRService - Enhanced Features', () => { // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - jest.useFakeTimers(); // Store original connections map state const connectionsMap = (service as any).connections; @@ -343,7 +343,6 @@ describe('SignalRService - Enhanced Features', () => { connectionsMap.set(key, value); }); - jest.useRealTimers(); }); }); }); diff --git a/src/services/check-in-notification.service.ts b/src/services/check-in-notification.service.ts new file mode 100644 index 00000000..36b10e0e --- /dev/null +++ b/src/services/check-in-notification.service.ts @@ -0,0 +1,118 @@ +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'; + +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 { + if (!CheckInNotificationService.instance) { + CheckInNotificationService.instance = new CheckInNotificationService(); + } + return CheckInNotificationService.instance; + } + + private async ensureChannel(channelName: string, channelDescription: string): Promise { + if (this.channelCreated || Platform.OS !== 'android') return; + + await notifee.createChannel({ + id: CHANNEL_ID, + name: channelName, + description: channelDescription, + importance: AndroidImportance.LOW, + }); + this.channelCreated = true; + } + + async startNotification(callName: string, callNumber: string, timerName: string, secondsRemaining: number, status: string, labels: NotificationLabels): Promise { + if (Platform.OS !== 'android') return; + + this.currentLabels = labels; + await this.ensureChannel(labels.channelName, labels.channelDescription); + 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, statusLabels: Record): Promise { + this.currentSeconds = secondsRemaining; + this.currentStatus = status; + if (this.currentLabels) { + this.currentLabels = { ...this.currentLabels, statusLabels }; + } + } + + 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 = this.currentLabels?.statusLabels[this.currentStatus] ?? this.currentStatus; + const actionText = this.currentLabels?.actionText ?? 'Check In'; + + 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: actionText, + 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 54cb9d47..55a7a440 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 07d4c68b..07aaf5c0 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; @@ -188,6 +195,32 @@ 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' }); + const activeCall = useCoreStore.getState().activeCall; + const activeUnit = useCoreStore.getState().activeUnit; + if (activeCall) { + 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(), + }); + } + } + } + } + // Handle notification press if (type === EventType.PRESS && detail.notification) { const eventCode = detail.notification.data?.eventCode as string | undefined; diff --git a/src/services/push-notification.web.ts b/src/services/push-notification.web.ts index caab70c8..13b0a591 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({ @@ -296,17 +296,20 @@ class WebPushNotificationService { } /** - * Convert VAPID key from base64 to Uint8Array + * Convert VAPID key from base64url to ArrayBuffer. + * + * @returns An ArrayBuffer containing the decoded key bytes. */ - 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/call-video-feeds/__tests__/store.test.ts b/src/stores/call-video-feeds/__tests__/store.test.ts new file mode 100644 index 00000000..faecb0fd --- /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 00000000..cdd0bfe9 --- /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 82290a2e..b2f747db 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -1,23 +1,32 @@ import { create } from 'zustand'; import { getCallPriorities } from '@/api/calls/callPriorities'; -import { getCalls } from '@/api/calls/calls'; +import { getCallExtraData, 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 DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; 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[]; + callDispatches: Record; isLoading: boolean; isInitialized: boolean; + isCallFormDataLoaded: boolean; error: string | null; lastFetchedAt: number; fetchCalls: () => Promise; fetchCallPriorities: () => Promise; fetchCallTypes: () => Promise; + fetchCallFormData: () => Promise; + fetchCallDispatches: (callIds: string[]) => Promise; init: () => Promise; } @@ -25,8 +34,12 @@ export const useCallsStore = create((set, get) => ({ calls: [], callPriorities: [], callTypes: [], + destinationPois: [], + poiTypes: [], + callDispatches: {}, isLoading: false, isInitialized: false, + isCallFormDataLoaded: false, error: null, lastFetchedAt: 0, init: async () => { @@ -80,4 +93,53 @@ export const useCallsStore = create((set, get) => ({ set({ error: 'Failed to fetch call types', isLoading: false }); } }, + fetchCallFormData: async () => { + if (get().isCallFormDataLoaded) { + 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 : [], + isCallFormDataLoaded: true, + isLoading: false, + }); + } catch (error) { + set({ error: 'Failed to fetch call form data', isLoading: false }); + } + }, + fetchCallDispatches: async (callIds: string[]) => { + const existing = get().callDispatches; + // Only fetch for call IDs that aren't already cached + const uncachedIds = callIds.filter((id) => !(id in existing)); + if (uncachedIds.length === 0) return; + + try { + const results = await Promise.all( + uncachedIds.map(async (callId) => { + try { + const result = await getCallExtraData(callId); + const dispatches = result?.Data?.Dispatches ?? []; + return { callId, dispatches: dispatches as DispatchedEventResultData[] }; + } catch { + return { callId, dispatches: [] as DispatchedEventResultData[] }; + } + }) + ); + + const newDispatches: Record = {}; + for (const { callId, dispatches } of results) { + newDispatches[callId] = dispatches; + } + set({ callDispatches: { ...get().callDispatches, ...newDispatches } }); + } catch (error) { + console.warn('Failed to fetch call dispatches:', error); + } + }, })); 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 00000000..218226ac --- /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 checkInResult: string = ''; + await act(async () => { + checkInResult = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); + }); + + expect(checkInResult).toBe('success'); + 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 checkInResult: string = ''; + await act(async () => { + checkInResult = await result.current.performCheckIn({ CallId: 1, CheckInType: 0 }); + }); + + expect(checkInResult).toBe('failed'); + 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 00000000..cadf5b23 --- /dev/null +++ b/src/stores/check-in-timers/store.ts @@ -0,0 +1,142 @@ +import { isAxiosError } from 'axios'; +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'; + +export type CheckInResult = 'success' | 'queued' | 'failed'; + +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 data = Array.isArray(result.Data) ? result.Data : []; + const sorted = [...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: Array.isArray(result.Data) ? 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: Array.isArray(result.Data) ? 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 'success' as CheckInResult; + } catch (error) { + 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 for offline sync', context: { input } }); + set({ isCheckingIn: false }); + 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 'failed' as CheckInResult; + } + }, + + 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 00000000..75d790e1 --- /dev/null +++ b/src/stores/pois/store.ts @@ -0,0 +1,147 @@ +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 98fb15b8..f82ecc8c 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -7,6 +7,23 @@ import { signalRService } from '@/services/signalr.service'; 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; @@ -53,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', '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 +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', '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 +130,36 @@ 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() }); + 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() }); + 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() }); + 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', () => { 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 bb357499..607f3bea 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 cdf268d6..dc31bebe 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,14 @@ 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 +282,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 00000000..c5705e82 --- /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 00000000..8c5f59ae --- /dev/null +++ b/src/stores/weather-alerts/store.ts @@ -0,0 +1,148 @@ +import { create } from 'zustand'; + +import { getActiveAlerts, getWeatherAlert, getWeatherAlertSettings } from '@/api/weather-alerts/weather-alerts'; +import { logger } from '@/lib/logging'; +import { sortAlertsBySeverity } from '@/lib/weather-alert-utils'; +import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData'; +import { WeatherAlertSettingsData } from '@/models/v4/weatherAlerts/weatherAlertSettingsData'; + +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 && typeof settingsResponse.Data === 'object' ? settingsResponse.Data : new WeatherAlertSettingsData(); + 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 && typeof response.Data === 'object' ? response.Data : new WeatherAlertSettingsData() }); + } 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; + if (!newAlert) { + logger.warn({ message: 'handleAlertReceived: missing alert data', context: { alertId } }); + return; + } + 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 + } + }, + handleAlertUpdated: async (alertId: string) => { + try { + const response = await getWeatherAlert(alertId); + const updatedAlert = response.Data; + if (!updatedAlert) { + logger.warn({ message: 'handleAlertUpdated: missing alert data', context: { alertId } }); + return; + } + 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 ef026ff6..e937807b 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -144,6 +144,7 @@ "contact_info": "معلومات الاتصال", "contact_name": "اسم جهة الاتصال", "contact_phone": "الهاتف", + "destination": "Destination", "edit_call": "تعديل المكالمة", "external_id": "المعرف الخارجي", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", @@ -189,6 +190,7 @@ "setting_active": "جاري التعيين كنشط...", "status": "الحالة", "tabs": { + "check_in": "تسجيل الحضور", "contact": "جهة الاتصال", "dispatched": "المرسلة", "info": "المعلومات", @@ -235,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": "إرسال إلى جميع الموظفين المتاحين", @@ -252,6 +257,7 @@ "invalid_type": "نوع غير صحيح محدد. يرجى اختيار نوع مكالمة صحيح.", "loading": "جاري تحميل المكالمات...", "loading_calls": "جاري تحميل المكالمات...", + "loading_destination_pois": "Loading destination POIs...", "name": "الاسم", "name_placeholder": "أدخل اسم المكالمة", "nature": "الطبيعة", @@ -263,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": "لا توجد مكالمات مفتوحة متاحة", @@ -282,6 +289,7 @@ "select_address": "اختر العنوان", "select_address_placeholder": "اختر عنوان المكالمة", "select_description": "اختر الوصف", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "اختيار مستقبلي الإرسال", "select_location": "اختر الموقع على الخريطة", "select_name": "اختر الاسم", @@ -306,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": "رجوع", @@ -498,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": "الطبقات النشطة", @@ -605,10 +648,12 @@ "cancel_route_confirm": "هل أنت متأكد من إلغاء هذا المسار؟", "check_in": "تسجيل الوصول", "check_out": "تسجيل المغادرة", + "clear_filters": "Clear filters", "completed": "مكتمل", "contact": "جهة الاتصال", "contact_details": "تفاصيل جهة الاتصال", "current_step": "الخطوة الحالية", + "delay": "تأخير", "description": "الوصف", "destination": "وجهة المسار", "deviation": "انحراف المسار", @@ -620,8 +665,10 @@ "deviations": "الانحرافات", "directions": "الاتجاهات", "distance": "المسافة", + "driving_conditions": "ظروف القيادة", "duration": "المدة", "dwell_time": "وقت التوقف", + "end": "النهاية", "end_route": "إنهاء المسار", "end_route_confirm": "هل أنت متأكد من إنهاء هذا المسار؟", "entrance": "مدخل", @@ -630,12 +677,16 @@ "eta": "الوقت المتوقع للوصول", "eta_to_next": "الوقت المتوقع للمحطة التالية", "exit": "مخرج", + "failed_to_open_poi_maps": "Failed to open maps for this POI", + "fetching_directions": "جارٍ جلب اتجاهات القيادة...", "geofence_radius": "نطاق السياج الجغرافي", "history": "سجل المسار", "in_progress": "قيد التنفيذ", "instance_detail": "تفاصيل المسار", "loading": "جارٍ تحميل المسارات...", "loading_directions": "جارٍ تحميل الاتجاهات...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "جارٍ تحميل المحطات...", "location": "الموقع", "min": "دق", @@ -646,9 +697,14 @@ "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": "لا توجد خطط مسار متاحة.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "لا توجد محطات", "notes": "ملاحظات", "notes_placeholder": "أدخل ملاحظات لهذه المحطة...", @@ -658,6 +714,25 @@ "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_detail": "تفاصيل نقطة الاهتمام", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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": "عالٍ", @@ -665,17 +740,23 @@ "priority_medium": "متوسط", "priority_normal": "عادي", "progress": "{{percent}}% مكتمل", + "pull_to_refresh": "Pull down to refresh", "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": "أدخل سبب تخطي هذه المحطة", "skipped": "تم التخطي", "skipped_by_driver": "تم التخطي من قبل السائق", + "start": "البداية", "start_route": "بدء المسار", "step_of": "الخطوة {{current}} من {{total}}", "stop_contact": "جهة اتصال المحطة", @@ -694,11 +775,14 @@ "total": "الإجمالي", "total_distance": "المسافة الإجمالية", "total_duration": "المدة الإجمالية", + "traffic_delay": "{{time}} تأخير بسبب حركة المرور", + "try_different_search": "Try a different search term", "type": "النوع", "unassigned": "غير معين", "unit": "الوحدة", "unit_required": "يجب اختيار وحدة لبدء المسار", "view_contact": "عرض جهة الاتصال", + "view_on_map": "View on map", "view_route": "عرض المسار" }, "settings": { @@ -794,16 +878,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": "اختر الحالة", @@ -812,6 +902,7 @@ "selected_status": "الحالة المختارة", "set_status": "تعيين الحالة", "station_destination_enabled": "يمكن الاستجابة للمحطات", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "المحطات", "status_saved_successfully": "تم حفظ الحالة بنجاح!" }, @@ -822,7 +913,124 @@ "notes": "الملاحظات", "protocols": "البروتوكولات", "routes": "المسارات", - "settings": "الإعدادات" + "settings": "الإعدادات", + "weather_alerts": "الطقس" + }, + "video_feeds": { + "add_feed": "إضافة بث", + "added_by": "أضافه", + "added_on": "أُضيف في", + "copy_url": "نسخ الرابط", + "delete_confirm_message": "هل أنت متأكد أنك تريد حذف هذا البث؟", + "delete_confirm_title": "حذف بث الفيديو", + "delete_error": "فشل حذف بث الفيديو", + "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": "خط الطول", + "name": "الاسم", + "no_feeds": "لا توجد بثوث فيديو متاحة", + "player_title": "مشغل الفيديو", + "rtsp_not_supported": "لا يمكن تشغيل بثوث RTSP مباشرة. انسخ الرابط لاستخدامه في مشغل مخصص.", + "save_error": "فشل حفظ بث الفيديو", + "save_success": "تم حفظ بث الفيديو بنجاح", + "status_active": "نشط", + "status_error": "خطأ", + "status_inactive": "غير نشط", + "tab_title": "فيديو", + "type_body_cam": "كاميرا جسدية", + "type_drone": "طائرة بدون طيار", + "type_fixed_camera": "كاميرا ثابتة", + "type_other": "أخرى", + "type_satellite_feed": "بث فضائي", + "type_traffic_cam": "كاميرا مرور", + "type_weather_cam": "كاميرا طقس", + "type_web_cam": "كاميرا ويب", + "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 cd32d76c..56025b10 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -144,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", @@ -189,6 +190,7 @@ "setting_active": "Wird als aktiv gesetzt...", "status": "Status", "tabs": { + "check_in": "Check-In", "contact": "Kontakt", "dispatched": "Entsendet", "info": "Info", @@ -235,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", @@ -252,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", @@ -263,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", @@ -282,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", @@ -306,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", @@ -498,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Sind Sie sicher, dass Sie diese Route abbrechen möchten?", "check_in": "Einchecken", "check_out": "Auschecken", + "clear_filters": "Clear filters", "completed": "Abgeschlossen", "contact": "Kontakt", "contact_details": "Kontaktdetails", "current_step": "Aktueller Schritt", + "delay": "Verzögerung", "description": "Beschreibung", "destination": "Routenziel", "deviation": "Routenabweichung", @@ -620,8 +665,10 @@ "deviations": "Abweichungen", "directions": "Wegbeschreibung", "distance": "Entfernung", + "driving_conditions": "Fahrbedingungen", "duration": "Dauer", "dwell_time": "Aufenthaltszeit", + "end": "Ende", "end_route": "Route beenden", "end_route_confirm": "Sind Sie sicher, dass Sie diese Route beenden möchten?", "entrance": "Eingang", @@ -630,12 +677,16 @@ "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", + "fetching_directions": "Fahrrouten werden abgerufen...", "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", @@ -646,9 +697,14 @@ "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.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "Keine Stopps verfügbar", "notes": "Notizen", "notes_placeholder": "Notizen für diesen Stopp eingeben...", @@ -658,6 +714,25 @@ "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_detail": "POI-Details", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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", @@ -665,17 +740,23 @@ "priority_medium": "Mittel", "priority_normal": "Normal", "progress": "{{percent}}% abgeschlossen", + "pull_to_refresh": "Pull down to refresh", "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", "skipped": "Übersprungen", "skipped_by_driver": "Vom Fahrer übersprungen", + "start": "Start", "start_route": "Route starten", "step_of": "Schritt {{current}} von {{total}}", "stop_contact": "Stoppkontakt", @@ -694,11 +775,14 @@ "total": "Gesamt", "total_distance": "Gesamtentfernung", "total_duration": "Gesamtdauer", + "traffic_delay": "{{time}} Verzögerung durch Verkehr", + "try_different_search": "Try a different search term", "type": "Typ", "unassigned": "Nicht zugewiesen", "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": { @@ -794,16 +878,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", @@ -812,6 +902,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!" }, @@ -822,7 +913,124 @@ "notes": "Notizen", "protocols": "Protokolle", "routes": "Routen", - "settings": "Einstellungen" + "settings": "Einstellungen", + "weather_alerts": "Wetter" + }, + "video_feeds": { + "add_feed": "Feed hinzufügen", + "added_by": "Hinzugefügt von", + "added_on": "Hinzugefügt am", + "copy_url": "URL kopieren", + "delete_confirm_message": "Möchten Sie diesen Video-Feed wirklich löschen?", + "delete_confirm_title": "Video-Feed löschen", + "delete_error": "Video-Feed konnte nicht gelöscht werden", + "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", + "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_error": "Fehler", + "status_inactive": "Inaktiv", + "tab_title": "Video", + "type_body_cam": "Bodycam", + "type_drone": "Drohne", + "type_fixed_camera": "Feste Kamera", + "type_other": "Sonstige", + "type_satellite_feed": "Satelliten-Feed", + "type_traffic_cam": "Verkehrskamera", + "type_weather_cam": "Wetterkamera", + "type_web_cam": "Webcam", + "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 c16527fc..bf448c37 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -144,6 +144,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", @@ -189,6 +190,7 @@ "setting_active": "Setting Active...", "status": "Status", "tabs": { + "check_in": "Check-In", "contact": "Contact", "dispatched": "Dispatched", "info": "Info", @@ -235,6 +237,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 +257,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 +269,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", @@ -282,6 +289,7 @@ "select_address": "Select Address", "select_address_placeholder": "Select the address of the call", "select_description": "Select Description", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Select Dispatch Recipients", "select_location": "Select Location on Map", "select_name": "Select Name", @@ -306,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", @@ -498,10 +537,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Are you sure you want to cancel this route?", "check_in": "Check In", "check_out": "Check Out", + "clear_filters": "Clear filters", "completed": "Completed", "contact": "Contact", "contact_details": "Contact Details", "current_step": "Current Step", + "delay": "delay", "description": "Description", "destination": "Route Destination", "deviation": "Route Deviation", @@ -620,8 +665,10 @@ "deviations": "Deviations", "directions": "Directions", "distance": "Distance", + "driving_conditions": "Driving Conditions", "duration": "Duration", "dwell_time": "Dwell Time", + "end": "End", "end_route": "End Route", "end_route_confirm": "Are you sure you want to end this route?", "entrance": "Entrance", @@ -630,12 +677,16 @@ "eta": "ETA", "eta_to_next": "ETA to next stop", "exit": "Exit", + "failed_to_open_poi_maps": "Failed to open maps for this POI", + "fetching_directions": "Fetching driving directions...", "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_stops": "Loading stops...", "location": "Location", "min": "min", @@ -646,9 +697,14 @@ "no_directions": "No directions available", "no_history": "No route history available", "no_history_description": "Completed routes will appear here.", + "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_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "No stops available", "notes": "Notes", "notes_placeholder": "Enter notes for this stop...", @@ -658,6 +714,25 @@ "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_detail": "POI Detail", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort By", + "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", @@ -665,17 +740,23 @@ "priority_medium": "Medium", "priority_normal": "Normal", "progress": "{{percent}}% complete", + "pull_to_refresh": "Pull down to refresh", "remaining_steps": "Remaining Steps", "resume_route": "Resume Route", "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", "skipped": "Skipped", "skipped_by_driver": "Skipped by driver", + "start": "Start", "start_route": "Start Route", "step_of": "Step {{current}} of {{total}}", "stop_contact": "Stop Contact", @@ -694,11 +775,14 @@ "total": "Total", "total_distance": "Total Distance", "total_duration": "Total Duration", + "traffic_delay": "{{time}} delay due to traffic", + "try_different_search": "Try a different search term", "type": "Type", "unassigned": "Unassigned", "unit": "Unit", "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" }, "settings": { @@ -794,16 +878,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 +902,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 +913,124 @@ "notes": "Notes", "protocols": "Protocols", "routes": "Routes", - "settings": "Settings" + "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": { + "banner": { + "more_alerts": "+{{count}} more" + }, + "category": { + "env": "Environmental", + "fire": "Fire", + "health": "Health", + "met": "Meteorological", + "other": "Other" + }, + "certainty": { + "likely": "Likely", + "observed": "Observed", + "possible": "Possible", + "unknown": "Unknown", + "unlikely": "Unlikely" + }, + "detail": { + "area": "Affected Area", + "certainty": "Certainty", + "description": "Description", + "effective": "Effective", + "expires": "Expires", + "headline": "Headline", + "instructions": "Instructions", + "onset": "Onset", + "sender": "Sender", + "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": { + "expires": "Expiring Soon", + "newest": "Newest", + "severity": "Severity" + }, + "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" } diff --git a/src/translations/es.json b/src/translations/es.json index 60973596..7f180228 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -144,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", @@ -189,6 +190,7 @@ "setting_active": "Estableciendo como activa...", "status": "Estado", "tabs": { + "check_in": "Registro", "contact": "Contacto", "dispatched": "Despachadas", "info": "Información", @@ -235,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", @@ -252,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", @@ -263,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", @@ -282,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", @@ -306,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", @@ -498,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "¿Está seguro de que desea cancelar esta ruta?", "check_in": "Registrar Entrada", "check_out": "Registrar Salida", + "clear_filters": "Clear filters", "completed": "Completado", "contact": "Contacto", "contact_details": "Detalles del Contacto", "current_step": "Paso Actual", + "delay": "retraso", "description": "Descripción", "destination": "Destino de Ruta", "deviation": "Desviación de Ruta", @@ -620,8 +665,10 @@ "deviations": "Desviaciones", "directions": "Direcciones", "distance": "Distancia", + "driving_conditions": "Condiciones de conducción", "duration": "Duración", "dwell_time": "Tiempo de Permanencia", + "end": "Fin", "end_route": "Finalizar Ruta", "end_route_confirm": "¿Está seguro de que desea finalizar esta ruta?", "entrance": "Entrada", @@ -630,12 +677,16 @@ "eta": "ETA", "eta_to_next": "ETA a la siguiente parada", "exit": "Salida", + "failed_to_open_poi_maps": "Failed to open maps for this POI", + "fetching_directions": "Obteniendo indicaciones de conducción...", "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", @@ -646,9 +697,14 @@ "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.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "No hay paradas disponibles", "notes": "Notas", "notes_placeholder": "Ingrese notas para esta parada...", @@ -658,6 +714,25 @@ "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_detail": "Detalle de POI", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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", @@ -665,17 +740,23 @@ "priority_medium": "Media", "priority_normal": "Normal", "progress": "{{percent}}% completado", + "pull_to_refresh": "Pull down to refresh", "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", "skipped": "Omitida", "skipped_by_driver": "Omitido por el conductor", + "start": "Inicio", "start_route": "Iniciar Ruta", "step_of": "Paso {{current}} de {{total}}", "stop_contact": "Contacto de Parada", @@ -694,11 +775,14 @@ "total": "Total", "total_distance": "Distancia Total", "total_duration": "Duración Total", + "traffic_delay": "{{time}} de retraso por el tráfico", + "try_different_search": "Try a different search term", "type": "Tipo", "unassigned": "Sin asignar", "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": { @@ -794,16 +878,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", @@ -812,6 +902,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!" }, @@ -822,7 +913,124 @@ "notes": "Notas", "protocols": "Protocolos", "routes": "Rutas", - "settings": "Configuración" + "settings": "Configuración", + "weather_alerts": "Clima" + }, + "video_feeds": { + "add_feed": "Agregar transmisión", + "added_by": "Agregado por", + "added_on": "Agregado el", + "copy_url": "Copiar URL", + "delete_confirm_message": "¿Está seguro de que desea eliminar esta transmisión de video?", + "delete_confirm_title": "Eliminar transmisión de video", + "delete_error": "Error al eliminar la transmisión de video", + "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", + "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_error": "Error", + "status_inactive": "Inactivo", + "tab_title": "Video", + "type_body_cam": "Cámara corporal", + "type_drone": "Dron", + "type_fixed_camera": "Cámara fija", + "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_web_cam": "Cámara web", + "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 a61feb5d..9f45e00e 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -144,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", @@ -189,6 +190,7 @@ "setting_active": "Définition comme actif...", "status": "Statut", "tabs": { + "check_in": "Pointage", "contact": "Contact", "dispatched": "Envoyé", "info": "Info", @@ -235,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", @@ -252,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", @@ -263,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", @@ -282,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", @@ -306,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", @@ -498,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Êtes-vous sûr de vouloir annuler cet itinéraire ?", "check_in": "Arrivée", "check_out": "Départ", + "clear_filters": "Clear filters", "completed": "Terminé", "contact": "Contact", "contact_details": "Détails du contact", "current_step": "Étape actuelle", + "delay": "retard", "description": "Description", "destination": "Destination de l'itinéraire", "deviation": "Déviation d'itinéraire", @@ -620,8 +665,10 @@ "deviations": "Déviations", "directions": "Itinéraire", "distance": "Distance", + "driving_conditions": "Conditions de conduite", "duration": "Durée", "dwell_time": "Temps de séjour", + "end": "Fin", "end_route": "Terminer l'itinéraire", "end_route_confirm": "Êtes-vous sûr de vouloir terminer cet itinéraire ?", "entrance": "Entrée", @@ -630,12 +677,16 @@ "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", + "fetching_directions": "Récupération des directions de conduite...", "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", @@ -646,9 +697,14 @@ "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.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "Aucun arrêt disponible", "notes": "Notes", "notes_placeholder": "Saisir des notes pour cet arrêt...", @@ -658,6 +714,25 @@ "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_detail": "Détail du POI", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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é", @@ -665,17 +740,23 @@ "priority_medium": "Moyen", "priority_normal": "Normal", "progress": "{{percent}}% terminé", + "pull_to_refresh": "Pull down to refresh", "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", "skipped": "Ignoré", "skipped_by_driver": "Ignoré par le conducteur", + "start": "Début", "start_route": "Démarrer l'itinéraire", "step_of": "Étape {{current}} sur {{total}}", "stop_contact": "Contact de l'arrêt", @@ -694,11 +775,14 @@ "total": "Total", "total_distance": "Distance totale", "total_duration": "Durée totale", + "traffic_delay": "{{time}} de retard dû au trafic", + "try_different_search": "Try a different search term", "type": "Type", "unassigned": "Non assigné", "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": { @@ -794,16 +878,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", @@ -812,6 +902,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 !" }, @@ -822,7 +913,124 @@ "notes": "Notes", "protocols": "Protocoles", "routes": "Itinéraires", - "settings": "Paramètres" + "settings": "Paramètres", + "weather_alerts": "Météo" + }, + "video_feeds": { + "add_feed": "Ajouter un flux", + "added_by": "Ajouté par", + "added_on": "Ajouté le", + "copy_url": "Copier l'URL", + "delete_confirm_message": "Êtes-vous sûr de vouloir supprimer ce flux vidéo ?", + "delete_confirm_title": "Supprimer le flux vidéo", + "delete_error": "Échec de la suppression du flux vidéo", + "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", + "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_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_other": "Autre", + "type_satellite_feed": "Flux satellite", + "type_traffic_cam": "Caméra de circulation", + "type_weather_cam": "Caméra météo", + "type_web_cam": "Webcam", + "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 65c69cc4..1d061bc6 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -144,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", @@ -189,6 +190,7 @@ "setting_active": "Impostazione attiva...", "status": "Stato", "tabs": { + "check_in": "Check-In", "contact": "Contatto", "dispatched": "Inviato", "info": "Info", @@ -235,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", @@ -252,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", @@ -263,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", @@ -282,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", @@ -306,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", @@ -498,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Sei sicuro di voler annullare questo percorso?", "check_in": "Check-in", "check_out": "Check-out", + "clear_filters": "Clear filters", "completed": "Completato", "contact": "Contatto", "contact_details": "Dettagli contatto", "current_step": "Passo corrente", + "delay": "ritardo", "description": "Descrizione", "destination": "Destinazione percorso", "deviation": "Deviazione percorso", @@ -620,8 +665,10 @@ "deviations": "Deviazioni", "directions": "Indicazioni", "distance": "Distanza", + "driving_conditions": "Condizioni di guida", "duration": "Durata", "dwell_time": "Tempo di sosta", + "end": "Fine", "end_route": "Termina percorso", "end_route_confirm": "Sei sicuro di voler terminare questo percorso?", "entrance": "Ingresso", @@ -630,12 +677,16 @@ "eta": "ETA", "eta_to_next": "ETA prossima fermata", "exit": "Uscita", + "failed_to_open_poi_maps": "Failed to open maps for this POI", + "fetching_directions": "Recupero indicazioni stradali...", "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", @@ -646,9 +697,14 @@ "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.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "Nessuna fermata disponibile", "notes": "Note", "notes_placeholder": "Inserisci note per questa fermata...", @@ -658,6 +714,25 @@ "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_detail": "Dettaglio POI", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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", @@ -665,17 +740,23 @@ "priority_medium": "Medio", "priority_normal": "Normale", "progress": "{{percent}}% completato", + "pull_to_refresh": "Pull down to refresh", "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", "skipped": "Saltato", "skipped_by_driver": "Saltato dal conducente", + "start": "Inizio", "start_route": "Avvia percorso", "step_of": "Passo {{current}} di {{total}}", "stop_contact": "Contatto fermata", @@ -694,11 +775,14 @@ "total": "Totale", "total_distance": "Distanza totale", "total_duration": "Durata totale", + "traffic_delay": "{{time}} di ritardo a causa del traffico", + "try_different_search": "Try a different search term", "type": "Tipo", "unassigned": "Non assegnato", "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": { @@ -794,16 +878,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", @@ -812,6 +902,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!" }, @@ -822,7 +913,124 @@ "notes": "Note", "protocols": "Protocolli", "routes": "Percorsi", - "settings": "Impostazioni" + "settings": "Impostazioni", + "weather_alerts": "Meteo" + }, + "video_feeds": { + "add_feed": "Aggiungi feed", + "added_by": "Aggiunto da", + "added_on": "Aggiunto il", + "copy_url": "Copia URL", + "delete_confirm_message": "Sei sicuro di voler eliminare questo feed video?", + "delete_confirm_title": "Elimina feed video", + "delete_error": "Impossibile eliminare il feed video", + "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", + "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_error": "Errore", + "status_inactive": "Inattivo", + "tab_title": "Video", + "type_body_cam": "Bodycam", + "type_drone": "Drone", + "type_fixed_camera": "Telecamera fissa", + "type_other": "Altro", + "type_satellite_feed": "Feed satellitare", + "type_traffic_cam": "Telecamera traffico", + "type_weather_cam": "Telecamera meteo", + "type_web_cam": "Webcam", + "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 0e8f510d..c2767aed 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -144,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", @@ -189,6 +190,7 @@ "setting_active": "Ustawianie jako aktywne...", "status": "Status", "tabs": { + "check_in": "Meldunek", "contact": "Kontakt", "dispatched": "Wysłane", "info": "Info", @@ -235,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", @@ -252,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", @@ -263,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ń", @@ -282,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ę", @@ -306,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", @@ -498,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Czy na pewno chcesz anulować tę trasę?", "check_in": "Zamelduj się", "check_out": "Wymelduj się", + "clear_filters": "Clear filters", "completed": "Ukończono", "contact": "Kontakt", "contact_details": "Szczegóły kontaktu", "current_step": "Bieżący krok", + "delay": "opóźnienie", "description": "Opis", "destination": "Cel trasy", "deviation": "Odchylenie trasy", @@ -620,8 +665,10 @@ "deviations": "Odchylenia", "directions": "Wskazówki", "distance": "Odległość", + "driving_conditions": "Warunki jazdy", "duration": "Czas trwania", "dwell_time": "Czas postoju", + "end": "Koniec", "end_route": "Zakończ trasę", "end_route_confirm": "Czy na pewno chcesz zakończyć tę trasę?", "entrance": "Wejście", @@ -630,12 +677,16 @@ "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", + "fetching_directions": "Pobieranie wskazówek dojazdu...", "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", @@ -646,9 +697,14 @@ "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.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "Brak dostępnych postojów", "notes": "Notatki", "notes_placeholder": "Wpisz notatki dla tego postoju...", @@ -658,6 +714,25 @@ "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_detail": "Szczegóły POI", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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", @@ -665,17 +740,23 @@ "priority_medium": "Średni", "priority_normal": "Normalny", "progress": "{{percent}}% ukończone", + "pull_to_refresh": "Pull down to refresh", "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", "skipped": "Pominięto", "skipped_by_driver": "Pominięty przez kierowcę", + "start": "Początek", "start_route": "Rozpocznij trasę", "step_of": "Krok {{current}} z {{total}}", "stop_contact": "Kontakt postoju", @@ -694,11 +775,14 @@ "total": "Razem", "total_distance": "Całkowita odległość", "total_duration": "Całkowity czas", + "traffic_delay": "{{time}} opóźnienia z powodu ruchu drogowego", + "try_different_search": "Try a different search term", "type": "Typ", "unassigned": "Nieprzypisany", "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": { @@ -794,16 +878,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", @@ -812,6 +902,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!" }, @@ -822,7 +913,124 @@ "notes": "Notatki", "protocols": "Protokoły", "routes": "Trasy", - "settings": "Ustawienia" + "settings": "Ustawienia", + "weather_alerts": "Pogoda" + }, + "video_feeds": { + "add_feed": "Dodaj transmisję", + "added_by": "Dodane przez", + "added_on": "Dodano", + "copy_url": "Kopiuj URL", + "delete_confirm_message": "Czy na pewno chcesz usunąć tę transmisję wideo?", + "delete_confirm_title": "Usuń transmisję wideo", + "delete_error": "Nie udało się usunąć transmisji wideo", + "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", + "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_error": "Błąd", + "status_inactive": "Nieaktywny", + "tab_title": "Wideo", + "type_body_cam": "Kamera nasobna", + "type_drone": "Dron", + "type_fixed_camera": "Kamera stała", + "type_other": "Inne", + "type_satellite_feed": "Transmisja satelitarna", + "type_traffic_cam": "Kamera drogowa", + "type_weather_cam": "Kamera pogodowa", + "type_web_cam": "Kamera internetowa", + "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 198a852d..773a0b0d 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -144,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", @@ -189,6 +190,7 @@ "setting_active": "Anger aktiv...", "status": "Status", "tabs": { + "check_in": "Incheckning", "contact": "Kontakt", "dispatched": "Utskickad", "info": "Info", @@ -235,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", @@ -252,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", @@ -263,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", @@ -282,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", @@ -306,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", @@ -498,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", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Är du säker på att du vill avbryta denna rutt?", "check_in": "Checka in", "check_out": "Checka ut", + "clear_filters": "Clear filters", "completed": "Slutförd", "contact": "Kontakt", "contact_details": "Kontaktdetaljer", "current_step": "Aktuellt steg", + "delay": "försening", "description": "Beskrivning", "destination": "Ruttmål", "deviation": "Ruttavvikelse", @@ -620,8 +665,10 @@ "deviations": "Avvikelser", "directions": "Vägbeskrivning", "distance": "Avstånd", + "driving_conditions": "Körförhållanden", "duration": "Varaktighet", "dwell_time": "Uppehållstid", + "end": "Slut", "end_route": "Avsluta rutt", "end_route_confirm": "Är du säker på att du vill avsluta denna rutt?", "entrance": "Ingång", @@ -630,12 +677,16 @@ "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", + "fetching_directions": "Hämtar körvägledning...", "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", @@ -646,9 +697,14 @@ "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.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "Inga stopp tillgängliga", "notes": "Anteckningar", "notes_placeholder": "Ange anteckningar för detta stopp...", @@ -658,6 +714,25 @@ "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_detail": "POI-detalj", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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", @@ -665,17 +740,23 @@ "priority_medium": "Medel", "priority_normal": "Normal", "progress": "{{percent}}% slutfört", + "pull_to_refresh": "Pull down to refresh", "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", "skipped": "Överhoppad", "skipped_by_driver": "Hoppades över av föraren", + "start": "Start", "start_route": "Starta rutt", "step_of": "Steg {{current}} av {{total}}", "stop_contact": "Stoppkontakt", @@ -694,11 +775,14 @@ "total": "Totalt", "total_distance": "Totalt avstånd", "total_duration": "Total varaktighet", + "traffic_delay": "{{time}} försening på grund av trafik", + "try_different_search": "Try a different search term", "type": "Typ", "unassigned": "Ej tilldelad", "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": { @@ -794,16 +878,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", @@ -812,6 +902,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!" }, @@ -822,7 +913,124 @@ "notes": "Anteckningar", "protocols": "Protokoll", "routes": "Rutter", - "settings": "Inställningar" + "settings": "Inställningar", + "weather_alerts": "Väder" + }, + "video_feeds": { + "add_feed": "Lägg till flöde", + "added_by": "Tillagd av", + "added_on": "Tillagd", + "copy_url": "Kopiera URL", + "delete_confirm_message": "Är du säker på att du vill ta bort detta videoflöde?", + "delete_confirm_title": "Ta bort videoflöde", + "delete_error": "Kunde inte ta bort videoflödet", + "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", + "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_error": "Fel", + "status_inactive": "Inaktiv", + "tab_title": "Video", + "type_body_cam": "Kroppskamera", + "type_drone": "Drönare", + "type_fixed_camera": "Fast kamera", + "type_other": "Övrigt", + "type_satellite_feed": "Satellitflöde", + "type_traffic_cam": "Trafikkamera", + "type_weather_cam": "Väderkamera", + "type_web_cam": "Webbkamera", + "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 14a7fe53..2c9ca185 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -144,6 +144,7 @@ "contact_info": "Контактна інформація", "contact_name": "Ім'я контакту", "contact_phone": "Телефон", + "destination": "Destination", "edit_call": "Редагувати виклик", "external_id": "Зовнішній ID", "failed_to_open_maps": "Не вдалося відкрити додаток карт", @@ -189,6 +190,7 @@ "setting_active": "Встановлення активним...", "status": "Статус", "tabs": { + "check_in": "Реєстрація", "contact": "Контакт", "dispatched": "Відправлено", "info": "Інфо", @@ -235,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": "Відправити до всього доступного персоналу", @@ -252,6 +257,7 @@ "invalid_type": "Вибрано неправильний тип. Виберіть правильний тип виклику.", "loading": "Завантаження викликів...", "loading_calls": "Завантаження викликів...", + "loading_destination_pois": "Loading destination POIs...", "name": "Назва", "name_placeholder": "Введіть назву виклику", "nature": "Характер", @@ -263,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": "Немає відкритих викликів", @@ -282,6 +289,7 @@ "select_address": "Вибрати адресу", "select_address_placeholder": "Виберіть адресу виклику", "select_description": "Вибрати опис", + "select_destination_poi": "Select Destination POI", "select_dispatch_recipients": "Вибрати одержувачів відправки", "select_location": "Вибрати місцезнаходження на карті", "select_name": "Вибрати назву", @@ -306,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": "Назад", @@ -498,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": "Активні шари", @@ -605,10 +648,12 @@ "cancel_route_confirm": "Ви впевнені, що хочете скасувати цей маршрут?", "check_in": "Зареєструватися", "check_out": "Від'їхати", + "clear_filters": "Clear filters", "completed": "Завершено", "contact": "Контакт", "contact_details": "Деталі контакту", "current_step": "Поточний крок", + "delay": "затримка", "description": "Опис", "destination": "Пункт призначення маршруту", "deviation": "Відхилення маршруту", @@ -620,8 +665,10 @@ "deviations": "Відхилення", "directions": "Маршрут", "distance": "Відстань", + "driving_conditions": "Умови водіння", "duration": "Тривалість", "dwell_time": "Час стоянки", + "end": "Кінець", "end_route": "Завершити маршрут", "end_route_confirm": "Ви впевнені, що хочете завершити цей маршрут?", "entrance": "Вхід", @@ -630,12 +677,16 @@ "eta": "ОЧП", "eta_to_next": "Орієнтовний час до наступної зупинки", "exit": "Вихід", + "failed_to_open_poi_maps": "Failed to open maps for this POI", + "fetching_directions": "Отримання маршрутних вказівок...", "geofence_radius": "Радіус геозони", "history": "Історія маршрутів", "in_progress": "В процесі", "instance_detail": "Екземпляр маршруту", "loading": "Завантаження маршрутів...", "loading_directions": "Завантаження маршруту...", + "loading_poi": "Loading POI...", + "loading_pois": "Loading POIs...", "loading_stops": "Завантаження зупинок...", "location": "Розташування", "min": "хв", @@ -646,9 +697,14 @@ "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": "Немає доступних планів маршрутів.", + "no_search_results": "No routes found", + "no_search_results_pois": "No POIs found", "no_stops": "Зупинок немає", "notes": "Примітки", "notes_placeholder": "Введіть примітки для цієї зупинки...", @@ -658,6 +714,25 @@ "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_detail": "Деталі POI", + "poi_filter_all_types": "All POI types", + "poi_filter_placeholder": "Filter by type", + "poi_filter_type": "POI Type", + "poi_filters": "POI Filters", + "poi_not_found": "POI not found", + "poi_not_found_description": "This POI could not be found.", + "poi_note": "Note", + "poi_sort_by": "Sort by", + "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": "Високий", @@ -665,17 +740,23 @@ "priority_medium": "Середній", "priority_normal": "Нормальний", "progress": "{{percent}}% завершено", + "pull_to_refresh": "Pull down to refresh", "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": "Введіть причину пропуску цієї зупинки", "skipped": "Пропущено", "skipped_by_driver": "Пропущено водієм", + "start": "Початок", "start_route": "Почати маршрут", "step_of": "Крок {{current}} з {{total}}", "stop_contact": "Контакт зупинки", @@ -694,11 +775,14 @@ "total": "Всього", "total_distance": "Загальна відстань", "total_duration": "Загальний час", + "traffic_delay": "{{time}} затримки через трафік", + "try_different_search": "Try a different search term", "type": "Тип", "unassigned": "Не призначено", "unit": "Підрозділ", "unit_required": "Для початку маршруту необхідно вибрати підрозділ", "view_contact": "Переглянути контакт", + "view_on_map": "View on map", "view_route": "Переглянути маршрут" }, "settings": { @@ -794,16 +878,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": "Вибрати статус", @@ -812,6 +902,7 @@ "selected_status": "Вибраний статус", "set_status": "Встановити статус", "station_destination_enabled": "Може реагувати на станції", + "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Станції", "status_saved_successfully": "Статус успішно збережено!" }, @@ -822,7 +913,124 @@ "notes": "Примітки", "protocols": "Протоколи", "routes": "Маршрути", - "settings": "Налаштування" + "settings": "Налаштування", + "weather_alerts": "Погода" + }, + "video_feeds": { + "add_feed": "Додати потік", + "added_by": "Додав", + "added_on": "Додано", + "copy_url": "Копіювати URL", + "delete_confirm_message": "Ви впевнені, що хочете видалити цей відеопотік?", + "delete_confirm_title": "Видалити відеопотік", + "delete_error": "Не вдалося видалити відеопотік", + "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": "Довгота", + "name": "Назва", + "no_feeds": "Немає доступних відеопотоків", + "player_title": "Відеоплеєр", + "rtsp_not_supported": "Потоки RTSP не можна відтворювати безпосередньо. Скопіюйте URL для використання у спеціалізованому плеєрі.", + "save_error": "Не вдалося зберегти відеопотік", + "save_success": "Відеопотік успішно збережено", + "status_active": "Активний", + "status_error": "Помилка", + "status_inactive": "Неактивний", + "tab_title": "Відео", + "type_body_cam": "Натільна камера", + "type_drone": "Дрон", + "type_fixed_camera": "Стаціонарна камера", + "type_other": "Інше", + "type_satellite_feed": "Супутниковий потік", + "type_traffic_cam": "Камера руху", + "type_weather_cam": "Камера погоди", + "type_web_cam": "Вебкамера", + "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" } diff --git a/src/utils/action-sheet.ts b/src/utils/action-sheet.ts new file mode 100644 index 00000000..10097c9e --- /dev/null +++ b/src/utils/action-sheet.ts @@ -0,0 +1,18 @@ +import { ActionSheetIOS, Platform } from 'react-native'; + +/** + * Check if the current platform is iOS. + * Extracted so it can be easily mocked in tests. + */ +export function isIOS(): boolean { + return Platform.OS === 'ios'; +} + +/** + * Thin wrapper around ActionSheetIOS.showActionSheetWithOptions. + * Extracted so it can be easily mocked in tests without requiring + * the native ActionSheetManager module. + */ +export function showNativeActionSheet(options: { options: string[]; cancelButtonIndex?: number }, callback: (buttonIndex: number) => void): void { + ActionSheetIOS.showActionSheetWithOptions(options, callback); +} diff --git a/yarn.lock b/yarn.lock index 3e75924a..2ed976e8 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" @@ -279,7 +341,7 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.4", "@babel/parser@^7.25.3", "@babel/parser@^7.26.10", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4": +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.4", "@babel/parser@^7.25.3", "@babel/parser@^7.26.10", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== @@ -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,7 +985,20 @@ "@babel/types" "^7.28.4" 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": +"@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.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" integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== @@ -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" @@ -1367,19 +1522,19 @@ dependencies: node-forge "^1.3.3" -"@expo/config-plugins@~10.1.2": - version "10.1.2" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-10.1.2.tgz#6efa256a3fa2fca116eeb5bef8b22b089e287282" - integrity sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw== +"@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" "^53.0.5" - "@expo/json-file" "~9.1.5" - "@expo/plist" "^0.3.5" + "@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 "^10.4.2" + glob "^13.0.0" resolve-from "^5.0.0" semver "^7.5.4" slash "^3.0.0" @@ -1387,43 +1542,49 @@ 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-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.0", "@expo/config@~11.0.12", "@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== +"@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" "~10.1.2" - "@expo/config-types" "^53.0.5" - "@expo/json-file" "^9.1.5" + "@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 "^10.4.2" + 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.0" + sucrase "~3.35.1" -"@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/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/env@~1.0.7": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@expo/env/-/env-1.0.7.tgz#6ee604e158d0f140fc2be711b9a7cb3adc341889" - integrity sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow== +"@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@~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" @@ -1431,18 +1592,17 @@ 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/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,132 +1614,147 @@ 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== - dependencies: - "@babel/code-frame" "~7.10.4" - json5 "^2.2.3" -"@expo/json-file@^9.1.5", "@expo/json-file@~9.1.5": - version "9.1.5" - resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-9.1.5.tgz#7d7b2dc4990dc2c2de69a571191aba984b7fb7ed" - integrity sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA== +"@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/metro-config@0.20.18", "@expo/metro-config@~0.20.18": - version "0.20.18" - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.20.18.tgz#8006a505978a014597d9d55f33bb09cfcb81bed1" - integrity sha512-qPYq3Cq61KQO1CppqtmxA1NGKpzFOmdiL7WxwLhEVnz73LPSgneW7dV/3RZwVFkjThzjA41qB4a9pxDqtpepPg== +"@expo/metro-config@54.0.14", "@expo/metro-config@~54.0.14": + version "54.0.15" + resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.15.tgz#aafdd2c2627fa60927e2d307f4d8cd303b6c5169" + integrity sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw== dependencies: + "@babel/code-frame" "^7.20.0" "@babel/core" "^7.20.0" "@babel/generator" "^7.20.5" - "@babel/parser" "^7.20.0" - "@babel/types" "^7.20.0" - "@expo/config" "~11.0.13" - "@expo/env" "~1.0.7" - "@expo/json-file" "~9.1.5" + "@expo/config" "~12.0.13" + "@expo/env" "~2.0.8" + "@expo/json-file" "~10.0.8" + "@expo/metro" "~54.2.0" "@expo/spawn-async" "^1.7.2" + browserslist "^4.25.0" chalk "^4.1.0" debug "^4.3.2" dotenv "~16.4.5" dotenv-expand "~11.0.6" getenv "^2.0.0" - glob "^10.4.2" + glob "^13.0.0" + hermes-parser "^0.29.1" jsc-safe-url "^0.2.4" - lightningcss "~1.27.0" - minimatch "^9.0.0" + lightningcss "^1.30.1" + picomatch "^4.0.3" 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" ora "^3.4.0" resolve-workspace-root "^2.0.0" -"@expo/plist@^0.3.5": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.3.5.tgz#11913c64951936101529cb26d7260ef16970fc31" - integrity sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g== +"@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: "@xmldom/xmldom" "^0.8.8" 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== - 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" +"@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 +1767,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" @@ -2401,10 +2576,10 @@ dependencies: "@react-native-aria/focus" "^0.2.9" -"@gorhom/bottom-sheet@~5.0.5": - version "5.0.6" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.0.6.tgz#f20736502399c7bcf8c73ea09e6b571dc07fe0eb" - integrity sha512-SI/AhPvgRfnCWN6/+wbE6TXwRE4X8F2fLyE4L/0bRwgE34Zenq585qLT139uEcfCIyovC2swC3ICqQpkmWEcFA== +"@gorhom/bottom-sheet@~5.2.13": + version "5.2.13" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.13.tgz#67764e2300b224aff332a818b6deba2c899badd6" + integrity sha512-cMxyd9kIowMME9kw2wwXAuWrXUQnPkJQz7rDbOSBBomZ+PpV/C/tlO1UozBrAe2zs3tp9th3JMW21FI/y0VeuQ== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" @@ -3572,11 +3747,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 +3874,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 +4262,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 +4321,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,159 +4943,156 @@ 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.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz#03f36703f13a9af7b593e24d0e9ab92aa6a0b601" + integrity sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA== dependencies: - "@sentry/core" "8.54.0" + "@sentry/core" "10.51.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.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.51.0.tgz#2119ee63d06a1f1efe0017e60c5718f995192c61" + integrity sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA== dependencies: - "@sentry/core" "8.54.0" + "@sentry/core" "10.51.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.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.51.0.tgz#f20fc2703e155778562e42fcbfd1bce43e9adba2" + integrity sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA== dependencies: - "@sentry-internal/replay" "8.54.0" - "@sentry/core" "8.54.0" + "@sentry-internal/replay" "10.51.0" + "@sentry/core" "10.51.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.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.51.0.tgz#2b2153104066de466b55a682b900ea31e60b11a0" + integrity sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw== dependencies: - "@sentry-internal/browser-utils" "8.54.0" - "@sentry/core" "8.54.0" + "@sentry-internal/browser-utils" "10.51.0" + "@sentry/core" "10.51.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== - dependencies: - https-proxy-agent "^5.0.0" - node-fetch "^2.6.7" +"@sentry/babel-plugin-component-annotate@5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.2.1.tgz#99ab8cc4e5482d9b1ad3d09671ce999c59f40bae" + integrity sha512-QQ9AL5EXIbSK26ObLVtiU6l3tCUdpGSJ/6VwDkPhC3qvtoksSlcoU9Yzm7XC0NBcvu1N2abL5R7gckKGZ4JewQ== + +"@sentry/browser@10.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.51.0.tgz#d5ba0215b48651ad4f07c9865c8df04e517c584e" + integrity sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA== + dependencies: + "@sentry-internal/browser-utils" "10.51.0" + "@sentry-internal/feedback" "10.51.0" + "@sentry-internal/replay" "10.51.0" + "@sentry-internal/replay-canvas" "10.51.0" + "@sentry/core" "10.51.0" + +"@sentry/cli-darwin@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-3.4.1.tgz#71d7daed5d87f86ba479c3fbdafeb280bb22b909" + integrity sha512-44foor4g/nfFaOaEZYQnxBrAW7TOMO4LatYsRjPI8dAoqXNVsl+P77FIk0gGAFnTwbt5gREXeeOn6j8DA9NyZg== + +"@sentry/cli-linux-arm64@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.4.1.tgz#bd75c2b4f50faa3faa65e4de996c982b1c01af95" + integrity sha512-rYWeBxVEiYMZ5hUe16qkpCmJQBc+lxT50sls/CqO5WTN3VlrSRlJsd+jMTKUNesM0j4PMEi82Xy7rovD8a+2tA== + +"@sentry/cli-linux-arm@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-3.4.1.tgz#c1911c632d82ee3bd7a4ffae32497a087d6407e3" + integrity sha512-XIT4ICA86vwrZWfbvKRiY9HgMg1aJNv1VJgNuiWu/3ysk0H4U7U4rJl6SQNbthgNGpcxvFdXmHbujKv7VZdv5w== + +"@sentry/cli-linux-i686@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-3.4.1.tgz#56532d100037ab6806aa658a59fcb79061dddf50" + integrity sha512-yirtGGummlarcrPmIm4cg5vEA5gYnL/GJ6FLV6yfq1zAeMzEWnl7kaWIVsoTZ8qBi7vmHpy0APlH5LXxK4QXNw== + +"@sentry/cli-linux-x64@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-3.4.1.tgz#8df916f85d6def62ba2b42bdf5cb86621dedca05" + integrity sha512-r2wJq9Bt1eRFtnAo3vWACfAN3IdV5gRfLnH8rbtDsg7pqdM0MP7tgAjzJDLptLGP02ndPhJfeJ1mXjcDY1MqqQ== + +"@sentry/cli-win32-arm64@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.4.1.tgz#793b7ee9f4ab12d6ba575199b3fa56341279b419" + integrity sha512-IWg2FeB9OzxDky3q885MqepN2QEujyGdbcCB0VbHB3zRpT2D2fbk87FIy64JGoZkPVmoI4d7Mbxa+XKFS4jlrw== + +"@sentry/cli-win32-i686@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-3.4.1.tgz#8049f96619a33b645242ef73cc792ce04e57ac95" + integrity sha512-RlKYU1Cdyk0uqRa9llKA6yVxg2QK0CXX0bogv89lIfABgZ4o1o45zcwVmEQLrD5rrIQmUcIJHWysQVnSyydJkA== + +"@sentry/cli-win32-x64@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-3.4.1.tgz#b17eeddb4dd92015c89400dace0a657e143966a1" + integrity sha512-MgKSglGeD+zOIEdbZrt+P9v9ExkMrHKwlIK8hnJA8qNVjmPuOW9yR4khzdVIYd3cdujAQQhLcV3gEB9ceR4PxQ== + +"@sentry/cli@3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-3.4.1.tgz#a1336010e8af55a613ed80dc5030f38bb012de11" + integrity sha512-xY9WcIg+/B/bJxY1KbP0XrDZGPQaFNjIOVJbXNbwVtRujiG6DEwVHqGJhADjPa8rilLQVDOUumVMwLbAUCmh6A== + dependencies: progress "^2.0.3" proxy-from-env "^1.1.0" + undici "^6.22.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/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== - dependencies: - "@sentry/core" "8.54.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== - dependencies: - "@sentry/core" "8.54.0" - -"@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== - dependencies: - recyclerlistview "4.2.3" + "@sentry/cli-darwin" "3.4.1" + "@sentry/cli-linux-arm" "3.4.1" + "@sentry/cli-linux-arm64" "3.4.1" + "@sentry/cli-linux-i686" "3.4.1" + "@sentry/cli-linux-x64" "3.4.1" + "@sentry/cli-win32-arm64" "3.4.1" + "@sentry/cli-win32-i686" "3.4.1" + "@sentry/cli-win32-x64" "3.4.1" + +"@sentry/core@10.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.51.0.tgz#bf103c6f03882311b93258f5424b777b24d4fb4f" + integrity sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w== + +"@sentry/expo-upload-sourcemaps@8.10.0": + version "8.10.0" + resolved "https://registry.yarnpkg.com/@sentry/expo-upload-sourcemaps/-/expo-upload-sourcemaps-8.10.0.tgz#d83bfa749e14c356b352232b111b7cf819059a6a" + integrity sha512-9nca3zuzeohl77Hspkox0CcpCQz11gvplgJMktD0fVLrHKBLW9/KTtAOBSez7FfXe2e8FbF7cd5/Cb5EHyJjpw== + dependencies: + "@sentry/cli" "3.4.1" + +"@sentry/react-native@^8.10.0": + version "8.10.0" + resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-8.10.0.tgz#eeaf79b53758aeee5e993276c616372cfe19a206" + integrity sha512-Pfr7h1unqMsE87UMwaUIZ26VjX7SSsitBLpK4gHeIwYmuXn+qfdYUmme6RnoLlL5IPzu8pCLoRNCvdAJy6eTgw== + dependencies: + "@sentry/babel-plugin-component-annotate" "5.2.1" + "@sentry/browser" "10.51.0" + "@sentry/cli" "3.4.1" + "@sentry/core" "10.51.0" + "@sentry/expo-upload-sourcemaps" "8.10.0" + "@sentry/react" "10.51.0" + "@sentry/types" "10.51.0" + +"@sentry/react@10.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.51.0.tgz#2c65becc65c4fd0a77470fda00af778dfd94977c" + integrity sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw== + dependencies: + "@sentry/browser" "10.51.0" + "@sentry/core" "10.51.0" + +"@sentry/types@10.51.0": + version "10.51.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-10.51.0.tgz#1408ccac89fdbffaefeabac9ad9e7b1f0494458b" + integrity sha512-/0nTcXk82RKtGGv0mxmY56o+BE85lBuSWG9chtSEfeypvxHFyWn3D7td9rPmjboDMtytC24cYbUzx55jb2OjQA== + dependencies: + "@sentry/core" "10.51.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: tslib "2.8.1" "@sinclair/typebox@^0.27.8": @@ -5418,10 +5761,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 +6136,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 +6202,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 +6419,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 +6741,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-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-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.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 +6796,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 +6816,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,11 +6845,21 @@ 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" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@^2.10.12: + version "2.10.27" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3" + integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA== + baseline-browser-mapping@^2.8.3: version "2.8.9" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz#fd0b8543c4f172595131e94965335536b3101b75" @@ -6590,6 +6956,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" @@ -6608,6 +6981,17 @@ browserslist@^4.24.0, browserslist@^4.25.3: node-releases "^2.0.21" update-browserslist-db "^1.1.3" +browserslist@^4.25.0: + version "4.28.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" + integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== + dependencies: + baseline-browser-mapping "^2.10.12" + caniuse-lite "^1.0.30001782" + electron-to-chromium "^1.5.328" + node-releases "^2.0.36" + update-browserslist-db "^1.2.3" + bs-logger@0.x: version "0.2.6" resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" @@ -6762,25 +7146,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" @@ -6811,6 +7176,11 @@ caniuse-lite@^1.0.30001741: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz#ab2a36e3b6ed5bfb268adc002c476aab6513f859" integrity sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ== +caniuse-lite@^1.0.30001782: + version "1.0.30001792" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5" + integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw== + centra@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/centra/-/centra-2.7.0.tgz#4c8312a58436e8a718302011561db7e6a2b0ec18" @@ -7337,16 +7707,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" @@ -7367,10 +7727,10 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" -countly-sdk-react-native-bridge@25.4.1: - version "25.4.1" - resolved "https://registry.yarnpkg.com/countly-sdk-react-native-bridge/-/countly-sdk-react-native-bridge-25.4.1.tgz#068485670d6d0920e3993171d1c2f5550d5128c2" - integrity sha512-6rwQ2TIfh+F1zKsTpat5XtW8v/GQb5SV4Q1Ly0SDpyfsvLfFLh72DaEHzdjRnP5qOMWnG38AICPrz9Fm3DzY/w== +countly-sdk-react-native-bridge@26.1.0: + version "26.1.0" + resolved "https://registry.yarnpkg.com/countly-sdk-react-native-bridge/-/countly-sdk-react-native-bridge-26.1.0.tgz#6341c9b9118d443e45be2541b7c8f3c73e41ba37" + integrity sha512-Rqp6u9YgkoKE8o6/XBELt5cxWj/J8yhYctiJoLNY9hlUQOQcrTP8y0ccBf30HrBQzf+oIpY/+Jf1YdQHU9/NMw== crc@^3.8.0: version "3.8.0" @@ -7420,11 +7780,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" @@ -7731,11 +8086,6 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== - detect-libc@^2.0.1: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" @@ -7751,6 +8101,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" @@ -8004,6 +8359,11 @@ electron-to-chromium@^1.5.218: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz#c81b6af045b0d6098faed261f0bd611dc282d3a7" integrity sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA== +electron-to-chromium@^1.5.328: + version "1.5.351" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz#7314fbb5b4835a1869feaec09665541b6a84cd37" + integrity sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA== + electron@40.0.0: version "40.0.0" resolved "https://registry.yarnpkg.com/electron/-/electron-40.0.0.tgz#01b1589ce2619dc7e792b1ca78d3ff1ae2ce05e7" @@ -8762,7 +9122,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 +9142,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 +9198,327 @@ 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== - dependencies: - "@expo/config" "~11.0.12" - "@expo/env" "~1.0.7" +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@~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-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.13" - "@expo/env" "~1.0.7" + "@expo/config" "~12.0.13" + "@expo/env" "~2.0.8" -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-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: base64-js "^1.3.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-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: + 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-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-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-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@~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 +10042,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" @@ -9781,7 +10151,7 @@ glob@^10.2.2: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^10.3.10, glob@^10.4.2: +glob@^10.3.10: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -9793,6 +10163,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,30 +10371,42 @@ 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-parser@0.29.1: +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, hermes-parser@^0.29.1: version "0.29.1" resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.29.1.tgz#436b24bcd7bb1e71f92a04c396ccc0716c288d56" integrity sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA== 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" @@ -10028,7 +10419,7 @@ hey-listen@^1.0.8: resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== -hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -10119,7 +10510,7 @@ http2-wrapper@^1.0.0-beta.5.2: quick-lru "^5.1.1" resolve-alpn "^1.0.0" -https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: +https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -10227,14 +10618,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 +10905,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 +11481,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 +11949,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" @@ -11733,106 +12105,111 @@ lightningcss-android-arm64@1.30.2: resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307" integrity sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A== -lightningcss-darwin-arm64@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.27.0.tgz#565bd610533941cba648a70e105987578d82f996" - integrity sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ== +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== lightningcss-darwin-arm64@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz#a5fa946d27c029e48c7ff929e6e724a7de46eb2c" integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA== -lightningcss-darwin-x64@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.27.0.tgz#c906a267237b1c7fe08bff6c5ac032c099bc9482" - integrity sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg== +lightningcss-darwin-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5" + integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== lightningcss-darwin-x64@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz#5ce87e9cd7c4f2dcc1b713f5e8ee185c88d9b7cd" integrity sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ== -lightningcss-freebsd-x64@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.27.0.tgz#a7c3c4d6ee18dffeb8fa69f14f8f9267f7dc0c34" - integrity sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA== +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== lightningcss-freebsd-x64@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz#6ae1d5e773c97961df5cff57b851807ef33692a5" integrity sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA== -lightningcss-linux-arm-gnueabihf@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.27.0.tgz#c7c16432a571ec877bf734fe500e4a43d48c2814" - integrity sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA== +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== lightningcss-linux-arm-gnueabihf@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz#62c489610c0424151a6121fa99d77731536cdaeb" integrity sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA== -lightningcss-linux-arm64-gnu@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.27.0.tgz#cfd9e18df1cd65131da286ddacfa3aee6862a752" - integrity sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A== +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== lightningcss-linux-arm64-gnu@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz#2a3661b56fe95a0cafae90be026fe0590d089298" integrity sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A== -lightningcss-linux-arm64-musl@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.27.0.tgz#6682ff6b9165acef9a6796bd9127a8e1247bb0ed" - integrity sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg== +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== lightningcss-linux-arm64-musl@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz#d7ddd6b26959245e026bc1ad9eb6aa983aa90e6b" integrity sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA== -lightningcss-linux-x64-gnu@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.27.0.tgz#714221212ad184ddfe974bbb7dbe9300dfde4bc0" - integrity sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A== +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== lightningcss-linux-x64-gnu@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz#5a89814c8e63213a5965c3d166dff83c36152b1a" integrity sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w== -lightningcss-linux-x64-musl@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.27.0.tgz#247958daf622a030a6dc2285afa16b7184bdf21e" - integrity sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA== +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== lightningcss-linux-x64-musl@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz#808c2e91ce0bf5d0af0e867c6152e5378c049728" integrity sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA== -lightningcss-win32-arm64-msvc@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.27.0.tgz#64cfe473c264ef5dc275a4d57a516d77fcac6bc9" - integrity sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ== +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== lightningcss-win32-arm64-msvc@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz#ab4a8a8a2e6a82a4531e8bbb6bf0ff161ee6625a" integrity sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ== -lightningcss-win32-x64-msvc@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.27.0.tgz#237d0dc87d9cdc9cf82536bcbc07426fa9f3f422" - integrity sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw== +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== lightningcss-win32-x64-msvc@1.30.2: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz#f01f382c8e0a27e1c018b0bee316d210eac43b6e" integrity sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw== +lightningcss-win32-x64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" + integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + lightningcss@^1.27.0: version "1.30.2" resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.30.2.tgz#4ade295f25d140f487d37256f4cd40dc607696d0" @@ -11852,23 +12229,24 @@ lightningcss@^1.27.0: lightningcss-win32-arm64-msvc "1.30.2" lightningcss-win32-x64-msvc "1.30.2" -lightningcss@~1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.27.0.tgz#d4608e63044343836dd9769f6c8b5d607867649a" - integrity sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ== +lightningcss@^1.30.1: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9" + integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== dependencies: - detect-libc "^1.0.3" + detect-libc "^2.0.3" optionalDependencies: - lightningcss-darwin-arm64 "1.27.0" - lightningcss-darwin-x64 "1.27.0" - lightningcss-freebsd-x64 "1.27.0" - lightningcss-linux-arm-gnueabihf "1.27.0" - lightningcss-linux-arm64-gnu "1.27.0" - lightningcss-linux-arm64-musl "1.27.0" - lightningcss-linux-x64-gnu "1.27.0" - lightningcss-linux-x64-musl "1.27.0" - lightningcss-win32-arm64-msvc "1.27.0" - lightningcss-win32-x64-msvc "1.27.0" + lightningcss-android-arm64 "1.32.0" + lightningcss-darwin-arm64 "1.32.0" + lightningcss-darwin-x64 "1.32.0" + lightningcss-freebsd-x64 "1.32.0" + lightningcss-linux-arm-gnueabihf "1.32.0" + lightningcss-linux-arm64-gnu "1.32.0" + lightningcss-linux-arm64-musl "1.32.0" + lightningcss-linux-x64-gnu "1.32.0" + lightningcss-linux-x64-musl "1.32.0" + lightningcss-win32-arm64-msvc "1.32.0" + lightningcss-win32-x64-msvc "1.32.0" lilconfig@^2.1.0: version "2.1.0" @@ -12030,7 +12408,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 +12567,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 +12730,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@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-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.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-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-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.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.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.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.82.5" + metro-resolver "0.83.3" + +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.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 +12860,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-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-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.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.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-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.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.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-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.82.5" + ob1 "0.83.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.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.82.5" + metro-source-map "0.83.3" 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-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.83.5" + nullthrows "^1.1.1" + source-map "^0.5.6" + vlq "^1.0.0" + +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 +12973,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 +13043,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 +13069,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 +13128,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 +13140,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 +13206,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 +13307,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 +13388,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== @@ -12910,6 +13504,11 @@ node-releases@^2.0.21: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c" integrity sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw== +node-releases@^2.0.36: + version "2.0.38" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.38.tgz#791569b9e4424a044e12c3abfad418ed83ce9947" + integrity sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw== + nopt@^8.0.0: version "8.1.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-8.1.0.tgz#b11d38caf0f8643ce885818518064127f602eae3" @@ -13049,10 +13648,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 +14026,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 +14160,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 +14222,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 +14482,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 +14625,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 +14633,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 +14680,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 +14724,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 +14738,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 +14767,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 +14804,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 +14820,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 +14834,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 +14909,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 +14960,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 +15068,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 +15217,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 +15461,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 +15491,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 +15637,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" @@ -15681,7 +16310,7 @@ styleq@^0.1.3: resolved "https://registry.yarnpkg.com/styleq/-/styleq-0.1.3.tgz#8efb2892debd51ce7b31dc09c227ad920decab71" integrity sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA== -sucrase@3.35.0, sucrase@^3.32.0: +sucrase@^3.32.0: version "3.35.0" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== @@ -15694,6 +16323,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 +16509,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 +16619,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 +16766,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 +16786,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 +16917,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,10 +16962,10 @@ 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== +undici@^6.22.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.25.0.tgz#8c4efb8c998dc187fc1cfb5dde1ef19a211849fb" + integrity sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" @@ -16344,10 +16995,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 +17014,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" @@ -16425,6 +17069,14 @@ update-browserslist-db@^1.1.3: escalade "^3.2.0" picocolors "^1.1.1" +update-browserslist-db@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + update-notifier@^7.0.0: version "7.3.1" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-7.3.1.tgz#49af1ad6acfa0ea01c0d0f3c04047c154ead7096" @@ -16456,11 +17108,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 +17209,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 +17664,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"