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
+
+
+
+- **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:
+
+
+
+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 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 (
+
+
+ );
+};
+```
+
+## Module Federation (Advanced)
+
+For micro-frontend architecture:
+
+```tsx
+// Host app loads remote module
+const RemoteModule = React.lazy(() =>
+ import('remote-app/Module')
+);
+```
+
+Enables:
+- Independent team deployments
+- Shared dependencies
+- Runtime composition
+
+**Complexity warning**: Only use when organizational benefits outweigh overhead. Federation increases the trust boundary, so keep the same first-party origin and allowlist rules as above.
+
+### Version Management
+
+Consider [Zephyr Cloud](https://zephyr-cloud.io/) for:
+- Sub-second deployments
+- Version management
+- Re.Pack integration
+
+## Caching Strategy
+
+```tsx
+ScriptManager.shared.addResolver((scriptId) => ({
+ url: getChunkUrl(scriptId),
+ cache: {
+ // Enable caching
+ enabled: true,
+ // Cache location
+ path: `${FileSystem.cacheDirectory}/chunks/`,
+ },
+}));
+```
+
+## When NOT to Use
+
+| Scenario | Why Not |
+|----------|---------|
+| Using Hermes | mmap already efficient |
+| Small app | Overhead not worth it |
+| Simple navigation | Native navigation better |
+| Quick iteration needed | Added complexity |
+
+## Hermes Memory Mapping
+
+Hermes reads bytecode lazily via mmap:
+- Only loads executed code into memory
+- No parse step needed
+- Code splitting provides marginal benefit
+
+## Verification
+
+```tsx
+// Check if chunk loaded correctly
+ScriptManager.shared.on('loading', (scriptId) => {
+ console.log(`Loading: ${scriptId}`);
+});
+
+ScriptManager.shared.on('loaded', (scriptId) => {
+ console.log(`Loaded: ${scriptId}`);
+});
+
+ScriptManager.shared.on('error', (scriptId, error) => {
+ console.error(`Failed: ${scriptId}`, error);
+});
+```
+
+## Common Pitfalls
+
+- **Forgetting Suspense**: Lazy components need fallback
+- **Wrong CDN path**: Chunks 404 in production
+- **No caching**: Re-downloads on every load
+- **Too many chunks**: Network overhead exceeds savings
+- **Untrusted chunk source**: Remote JS from third-party or user-controlled origins is equivalent to remote code execution
+
+## Related Skills
+
+- [bundle-tree-shaking.md](./bundle-tree-shaking.md) - Re.Pack tree shaking
+- [bundle-analyze-js.md](./bundle-analyze-js.md) - Measure chunk sizes
+- [native-measure-tti.md](./native-measure-tti.md) - Verify TTI impact
diff --git a/.forge/skills/react-native-best-practices/references/bundle-hermes-mmap.md b/.forge/skills/react-native-best-practices/references/bundle-hermes-mmap.md
new file mode 100644
index 00000000..3de25c31
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/bundle-hermes-mmap.md
@@ -0,0 +1,167 @@
+---
+title: Disable JS Bundle Compression
+impact: HIGH
+tags: android, hermes, mmap, tti, startup
+---
+
+# Skill: Disable JS Bundle Compression
+
+Disable Android JS bundle compression to enable Hermes memory mapping for faster startup.
+
+## Quick Config
+
+```groovy
+// android/app/build.gradle
+android {
+ androidResources {
+ noCompress += ["bundle"]
+ }
+}
+```
+
+**Note**: Default in React Native 0.79+. Only needed for 0.78 and earlier.
+
+## When to Use
+
+- Android app using Hermes
+- Want faster TTI (Time to Interactive)
+- Willing to trade install size for startup speed
+- React Native version is 0.78 or earlier, skip otherwise (see applicability)
+
+## Background
+
+Android compresses most files in APK/AAB by default, including `index.android.bundle`.
+
+**Problem**: Compressed files can't be memory-mapped (mmap).
+
+**Impact**: Hermes must decompress before reading, losing one of its key optimizations.
+
+## How Hermes Memory Mapping Works
+
+Without compression:
+1. Hermes opens bytecode file
+2. OS memory-maps directly to disk
+3. Only pages actually accessed are loaded
+4. **Result**: Fast startup, low memory
+
+With compression:
+1. Android decompresses entire bundle
+2. Loaded into memory
+3. Then Hermes processes
+4. **Result**: Slower startup, higher memory
+
+## Step-by-Step Implementation
+
+### Edit build.gradle
+
+In `android/app/build.gradle`:
+
+```groovy
+android {
+ androidResources {
+ noCompress += ["bundle"]
+ }
+}
+```
+
+### Full Context
+
+```groovy
+android {
+ namespace "com.myapp"
+ defaultConfig {
+ applicationId "com.myapp"
+ // ...
+ }
+
+ androidResources {
+ noCompress += ["bundle"]
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ // ...
+ }
+ }
+}
+```
+
+### Rebuild
+
+```bash
+cd android
+./gradlew clean
+./gradlew bundleRelease
+# or
+./gradlew assembleRelease
+```
+
+## Trade-offs
+
+| Metric | Without Change | With Change |
+|--------|----------------|-------------|
+| Download size | Same | Same |
+| Install size | Smaller | **+8% larger** |
+| TTI | Slower | **-16% faster** |
+
+**Real example**: 75.9 MB install → 82 MB install, but 450ms faster startup.
+
+## Applicability
+
+**React Native 0.78 and earlier**: Apply this optimization manually.
+
+**React Native 0.79+**: Skip this—bundle compression is disabled by default.
+
+## Verification
+
+### Check APK Contents
+
+```bash
+# Unzip APK
+unzip app-release.apk -d apk-contents
+
+# Check if bundle is compressed
+file apk-contents/assets/index.android.bundle
+# Should show: "data" (not "gzip compressed")
+```
+
+### Measure TTI Impact
+
+Use performance markers (see [native-measure-tti.md](./native-measure-tti.md)) to compare before/after.
+
+## Multiple File Types
+
+If you have other files that benefit from mmap:
+
+```groovy
+androidResources {
+ noCompress += ["bundle", "hbc", "data"]
+}
+```
+
+## Common Pitfalls
+
+- **Not rebuilding**: Change requires clean build
+- **Wrong config location**: Must be in `android` block
+- **Ignoring size increase**: Monitor user feedback on install size
+- **Already default**: Check if React Native version includes this
+
+## Expo Notes
+
+For Expo projects, run `npx expo prebuild` first to generate `android/` folder, then apply the `build.gradle` changes. Add `android/` to version control or use a [config plugin](https://docs.expo.dev/config-plugins/introduction/) for persistent changes.
+
+## Should You Enable This?
+
+| Scenario | Recommendation |
+|----------|---------------|
+| Startup-critical app | ✅ Enable |
+| Storage-sensitive users | ⚠️ Test impact |
+| Already fast TTI | Maybe not worth it |
+| Large JS bundle | ✅ Bigger benefit |
+
+## Related Skills
+
+- [native-measure-tti.md](./native-measure-tti.md) - Measure TTI improvement
+- [bundle-analyze-app.md](./bundle-analyze-app.md) - Check size impact
+- [bundle-r8-android.md](./bundle-r8-android.md) - Offset size increase
diff --git a/.forge/skills/react-native-best-practices/references/bundle-library-size.md b/.forge/skills/react-native-best-practices/references/bundle-library-size.md
new file mode 100644
index 00000000..678afe63
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/bundle-library-size.md
@@ -0,0 +1,177 @@
+---
+title: Determine Library Size
+impact: MEDIUM
+tags: dependencies, bundlephobia, library-size
+---
+
+# Skill: Determine Library Size
+
+Evaluate third-party library size impact before adding to your project.
+
+## Quick Command
+
+```bash
+# Check size before installing
+# Visit: https://bundlephobia.com/package/[package-name]
+
+# Or use CLI
+npx bundle-phobia-cli
+```
+
+## When to Use
+
+- Evaluating new dependencies
+- Comparing alternative libraries
+- Auditing existing dependencies
+- Investigating bundle bloat
+
+## Tools Overview
+
+| Tool | Type | Best For |
+|------|------|----------|
+| bundlephobia.com | Web | Quick size check |
+| pkg-size.dev | Web | Backup/alternative |
+| Import Cost (VS Code) | IDE extension | Real-time feedback |
+
+## bundlephobia.com
+
+### Usage
+
+Visit [bundlephobia.com](https://bundlephobia.com) and enter package name.
+
+### Shows
+
+- **Minified size**: Raw JS size
+- **Minified + Gzipped**: Network transfer size
+- **Download time**: Estimated on various connections
+- **Dependencies**: What else gets pulled in
+- **Composition**: Breakdown by dependency
+
+### Example Analysis
+
+```
+react-native-paper
+├── Minified: 312 kB
+├── Gzipped: 78 kB
+└── Dependencies: 12 packages
+ ├── @callstack/react-theme-provider
+ ├── color
+ └── ...
+```
+
+## pkg-size.dev
+
+Backup when bundlephobia fails.
+
+Visit [pkg-size.dev](https://pkg-size.dev) with package name.
+
+**Difference**: Actually installs package in web container, may be more accurate for edge cases.
+
+## Import Cost (VS Code Extension)
+
+### Install
+
+Search "Import Cost" in VS Code extensions or:
+
+```bash
+code --install-extension wix.vscode-import-cost
+```
+
+### Usage
+
+Shows inline size next to imports:
+
+```tsx
+import React from 'react'; // 6.5K (gzipped)
+import { View, Text } from 'react-native'; // 0B (native)
+import lodash from 'lodash'; // 71.5K (gzipped: 24.7K)
+import get from 'lodash/get'; // 8K (gzipped: 2.9K)
+```
+
+### Limitations
+
+- Uses Webpack internally (not Metro)
+- May fail on React Native-specific packages
+- Doesn't account for tree shaking
+
+## Comparison Workflow
+
+### Before Adding Dependency
+
+1. Check on bundlephobia:
+ ```
+ https://bundlephobia.com/package/[package-name]
+ ```
+
+2. Compare alternatives:
+ ```
+ moment (289 kB) vs date-fns (75 kB) vs dayjs (6 kB)
+ ```
+
+3. Check what you actually need:
+ - Full library import vs specific functions
+ - Native alternative available?
+
+### After Adding
+
+1. Analyze bundle (see [bundle-analyze-js.md](./bundle-analyze-js.md))
+2. Verify actual impact matches expected
+3. Check for duplicate dependencies
+
+## Size Guidelines
+
+| Size (gzipped) | Assessment | Action |
+|----------------|------------|--------|
+| < 5 KB | Small | Generally fine |
+| 5-20 KB | Medium | Evaluate necessity |
+| 20-50 KB | Large | Look for alternatives |
+| > 50 KB | Very large | Strong justification needed |
+
+## Common Large Dependencies
+
+| Library | Size (gzipped) | Alternative |
+|---------|----------------|-------------|
+| moment | ~70 KB | dayjs (~3 KB) |
+| lodash (full) | ~25 KB | lodash-es + direct imports |
+| aws-sdk (full) | 200+ KB | @aws-sdk/client-* |
+| crypto-js | ~15 KB | react-native-quick-crypto |
+
+## Quick Size Check Script
+
+```bash
+# Check size before installing
+npx bundle-phobia-cli
+
+# Or use npm directly (less accurate)
+npm pack --dry-run 2>&1 | grep "total files"
+```
+
+## Decision Matrix
+
+| Factor | Keep JS Library | Use Native Alternative |
+|--------|-----------------|------------------------|
+| Size | > 50 KB | < 50 KB |
+| Platform coverage | Both platforms | Single platform OK |
+| Performance | Not critical | Critical path |
+| Functionality | Simple | Complex computation |
+
+## Code Example: Optimizing Imports
+
+```tsx
+// BAD: Full library (71.5 KB)
+import _ from 'lodash';
+_.get(obj, 'path.to.value');
+
+// BETTER: Specific import (8 KB)
+import get from 'lodash/get';
+get(obj, 'path.to.value');
+
+// BEST: Native JS (0 KB)
+obj?.path?.to?.value;
+```
+
+## Related Skills
+
+- [bundle-analyze-js.md](./bundle-analyze-js.md) - Verify actual bundle impact
+- [bundle-barrel-exports.md](./bundle-barrel-exports.md) - Optimize how you import
+- [native-sdks-over-polyfills.md](./native-sdks-over-polyfills.md) - Native alternatives to JS libs
diff --git a/.forge/skills/react-native-best-practices/references/bundle-native-assets.md b/.forge/skills/react-native-best-practices/references/bundle-native-assets.md
new file mode 100644
index 00000000..2ca55d21
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/bundle-native-assets.md
@@ -0,0 +1,214 @@
+---
+title: Native Assets
+impact: HIGH
+tags: assets, images, asset-catalog, app-thinning
+---
+
+# Skill: Native Assets
+
+Configure platform-specific asset delivery to reduce app download size.
+
+## Quick Config
+
+**iOS Asset Catalog (Build Phase):**
+
+```bash
+# Add to "Bundle React Native code and images" build phase
+export EXTRA_PACKAGER_ARGS="--asset-catalog-dest ./"
+```
+
+**Android**: Automatic via AAB — Play Store delivers correct density per device.
+
+## When to Use
+
+- Images bloating app size
+- Different device densities need different assets
+- Want to leverage App Store/Play Store optimization
+- Using high-resolution images
+
+## Concept: Size Suffixes
+
+React Native convention for multiple resolutions:
+
+```
+assets/
+├── image.jpg # 1x resolution (base)
+├── image@2x.jpg # 2x resolution
+└── image@3x.jpg # 3x resolution
+```
+
+```tsx
+// React Native selects best one for device
+
+```
+
+## Android: Automatic Optimization
+
+Android handles this automatically.
+
+### How It Works
+
+1. Build AAB:
+ ```bash
+ cd android && ./gradlew bundleRelease
+ ```
+
+2. Metro places images in density folders:
+ ```
+ android/app/build/outputs/bundle/release/
+ └── base/
+ └── res/
+ ├── drawable-mdpi-v4/ # 1x
+ ├── drawable-hdpi-v4/ # 1.5x
+ ├── drawable-xhdpi-v4/ # 2x
+ ├── drawable-xxhdpi-v4/ # 3x
+ └── drawable-xxxhdpi-v4/ # 4x
+ ```
+
+3. Play Store delivers only needed density per device.
+
+**No configuration required** for Android.
+
+## iOS: Asset Catalog Setup
+
+iOS requires explicit configuration.
+
+### Step 1: Create Asset Catalog
+
+Create folder in `ios/`:
+
+```
+ios/RNAssets.xcassets/
+```
+
+**Important**: Must be named exactly `RNAssets.xcassets` (hardcoded in React Native).
+
+### Step 2: Configure Build Phase
+
+In Xcode:
+1. Open project settings
+2. Go to **Build Phases**
+3. Find **"Bundle React Native code and images"**
+4. Add before line 8:
+
+```bash
+export EXTRA_PACKAGER_ARGS="--asset-catalog-dest ./"
+```
+
+### Step 3: Build
+
+Run build to populate asset catalog:
+
+```bash
+npx react-native run-ios --mode Release
+```
+
+Or manually:
+
+```bash
+npx react-native bundle \
+ --entry-file index.js \
+ --bundle-output ios-bundle.js \
+ --platform ios \
+ --dev false \
+ --asset-catalog-dest ios \
+ --assets-dest ios/assets
+```
+
+### Step 4: Verify
+
+After build, `RNAssets.xcassets` contains:
+
+```
+ios/RNAssets.xcassets/
+└── assets_image_image.imageset/
+ ├── Contents.json
+ ├── image.jpg
+ ├── image@2x.jpg
+ └── image@3x.jpg
+```
+
+App Store then delivers only needed resolution.
+
+## Before/After Comparison
+
+### Without Asset Catalog (All Variants)
+
+```
+App bundle contains:
+├── image.jpg (100 KB)
+├── image@2x.jpg (300 KB)
+└── image@3x.jpg (600 KB)
+Total: 1 MB
+```
+
+### With Asset Catalog (Device-Specific)
+
+```
+iPhone 15 Pro receives:
+└── image@3x.jpg (600 KB)
+Total: 600 KB (40% smaller)
+```
+
+## Asset Optimization Tips
+
+### 1. Compress Images
+
+Use tools before adding to project:
+
+```bash
+# ImageOptim (macOS)
+# TinyPNG (web)
+# sharp (programmatic)
+
+npx sharp-cli input.jpg -o output.jpg --quality 80
+```
+
+### 2. Use Appropriate Formats
+
+| Format | Best For |
+|--------|----------|
+| JPEG | Photos |
+| PNG | Icons, transparency |
+| WebP | Both (smaller) |
+| SVG | Vector icons |
+
+### 3. Consider react-native-fast-image
+
+Caching and better image handling:
+
+```bash
+npm install react-native-fast-image
+```
+
+## Verification
+
+### iOS App Thinning Report
+
+After export, check `App Thinning Size Report.txt`:
+
+```
+Variant: MyApp-.ipa
+Supported variant descriptors: iPhone15,2 ...
+App size: 3.5 MB compressed, 10.6 MB uncompressed
+```
+
+### Use Emerge Tools
+
+Upload IPA to see asset breakdown.
+
+## Common Pitfalls
+
+- **Wrong folder name**: Must be `RNAssets.xcassets` exactly
+- **Missing build phase config**: Assets not processed
+- **Not using size suffixes**: All variants included anyway
+- **Forgetting to rebuild**: Changes need fresh build
+
+## Future Note
+
+As of January 2025, Asset Catalog is not default. May become default in future React Native versions.
+
+## Related Skills
+
+- [bundle-analyze-app.md](./bundle-analyze-app.md) - Verify asset impact
+- [bundle-r8-android.md](./bundle-r8-android.md) - Android code optimization
diff --git a/.forge/skills/react-native-best-practices/references/bundle-r8-android.md b/.forge/skills/react-native-best-practices/references/bundle-r8-android.md
new file mode 100644
index 00000000..cf079506
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/bundle-r8-android.md
@@ -0,0 +1,225 @@
+---
+title: R8 Code Shrinking
+impact: HIGH
+tags: android, r8, proguard, minify, shrink
+---
+
+# Skill: R8 Code Shrinking
+
+Enable R8 for Android to shrink, optimize, and obfuscate native code.
+
+## Quick Config
+
+```groovy
+// android/app/build.gradle
+def enableProguardInReleaseBuilds = true
+
+android {
+ buildTypes {
+ release {
+ minifyEnabled true
+ shrinkResources true
+ }
+ }
+}
+```
+
+## When to Use
+
+- Android app size too large
+- Want to obfuscate code for security
+- Building release APK/AAB
+
+## What is R8?
+
+R8 replaces ProGuard in Android:
+- **Shrinks**: Removes unused code
+- **Optimizes**: Improves bytecode
+- **Obfuscates**: Renames classes/methods
+
+**Compatibility**: Uses ProGuard configuration format.
+
+## Step-by-Step Instructions
+
+### 1. Enable R8
+
+Edit `android/app/build.gradle`:
+
+```groovy
+def enableProguardInReleaseBuilds = true
+```
+
+This sets `minifyEnabled = true` for release builds.
+
+### 2. Enable Resource Shrinking (Optional)
+
+Further reduces size by removing unused resources:
+
+```groovy
+android {
+ buildTypes {
+ release {
+ minifyEnabled true
+ shrinkResources true // Requires minifyEnabled
+
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+```
+
+### 3. Configure ProGuard Rules (If Needed)
+
+Edit `android/app/proguard-rules.pro`. React Native defaults are usually sufficient—only add rules when specific libraries break after enabling R8.
+
+**Only add if using Firebase (`@react-native-firebase/*`):**
+
+```proguard
+-keep class io.invertase.firebase.** { *; }
+-dontwarn io.invertase.firebase.**
+```
+
+**Only add if using Retrofit:**
+
+```proguard
+-keepattributes Signature
+-keepattributes *Annotation*
+-keep class retrofit2.** { *; }
+-dontwarn retrofit2.**
+```
+
+See [Common Library Rules](#common-library-rules) and [Troubleshooting](#troubleshooting) for more examples.
+
+### 4. Build and Test
+
+```bash
+cd android
+./gradlew assembleRelease
+# or
+./gradlew bundleRelease
+```
+
+**Critical**: Test thoroughly! R8 can remove code it thinks is unused.
+
+## ProGuard Rules Reference
+
+| Rule | Effect |
+|------|--------|
+| `-keep class X` | Don't remove class X |
+| `-keepclassmembers` | Keep members but allow rename |
+| `-keepnames` | Keep names but allow removal if unused |
+| `-dontwarn X` | Suppress warnings for X |
+| `-dontobfuscate` | Disable obfuscation |
+
+### Keep Entire Package
+
+```proguard
+-keep class com.mypackage.** { *; }
+```
+
+### Keep Classes with Annotation
+
+```proguard
+-keep @interface com.facebook.proguard.annotations.DoNotStrip
+-keep @com.facebook.proguard.annotations.DoNotStrip class *
+-keepclassmembers class * {
+ @com.facebook.proguard.annotations.DoNotStrip *;
+}
+```
+
+## Disable Obfuscation (If Needed)
+
+```proguard
+# proguard-rules.pro
+-dontobfuscate
+```
+
+Use when:
+- Debugging crashes (stack traces more readable)
+- Library requires class names
+
+## Size Impact
+
+Example from guide:
+- **Without R8**: 9.5 MB
+- **With R8**: 6.3 MB
+- **Savings**: 33%
+
+Larger apps may see 20-30% reduction.
+
+## Troubleshooting
+
+### App Crashes After R8
+
+Usually means needed class was removed.
+
+**Debug steps**:
+
+1. Check crash log for class name
+2. Add keep rule:
+ ```proguard
+ -keep class com.example.CrashedClass { *; }
+ ```
+3. Rebuild and test
+
+### Library Specific Rules
+
+Many libraries provide ProGuard rules. Check:
+- Library README
+- Library's `consumer-proguard-rules.pro`
+- Stack Overflow for library + proguard
+
+### Common Library Rules
+
+```proguard
+# Hermes (usually auto-included)
+-keep class com.facebook.hermes.unicode.** { *; }
+
+# React Native
+-keep class com.facebook.react.** { *; }
+
+# Gson
+-keepattributes Signature
+-keep class com.google.gson.** { *; }
+
+# OkHttp
+-dontwarn okhttp3.**
+-dontwarn okio.**
+```
+
+## Verification
+
+### Check APK Size
+
+```bash
+# Build
+./gradlew assembleRelease
+
+# Check size
+ls -la android/app/build/outputs/apk/release/
+```
+
+### Use Ruler for Detailed Analysis
+
+See [bundle-analyze-app.md](./bundle-analyze-app.md).
+
+### Verify Obfuscation
+
+Decompile APK to check class names are obfuscated:
+
+```bash
+# Using jadx or similar
+jadx android/app/build/outputs/apk/release/app-release.apk
+```
+
+## Common Pitfalls
+
+- **Not testing release build**: Always QA with R8 enabled
+- **Missing library rules**: Check library docs
+- **Over-keeping**: Too many keep rules negates benefits
+- **Reflection**: Code using reflection may break
+
+## Related Skills
+
+- [bundle-analyze-app.md](./bundle-analyze-app.md) - Measure size impact
+- [bundle-native-assets.md](./bundle-native-assets.md) - Further size reduction
diff --git a/.forge/skills/react-native-best-practices/references/bundle-tree-shaking.md b/.forge/skills/react-native-best-practices/references/bundle-tree-shaking.md
new file mode 100644
index 00000000..a0b06005
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/bundle-tree-shaking.md
@@ -0,0 +1,214 @@
+---
+title: Tree Shaking
+impact: HIGH
+tags: bundle, tree-shaking, dead-code, metro, repack
+---
+
+# Skill: Tree Shaking
+
+Enable dead code elimination to remove unused exports from your JavaScript bundle.
+
+## Quick Config
+
+```bash
+# .env (Expo SDK 52+)
+EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1
+EXPO_UNSTABLE_TREE_SHAKING=1
+```
+
+```javascript
+// metro.config.js
+config.transformer.getTransformOptions = async () => ({
+ transform: { experimentalImportSupport: true },
+});
+```
+
+```javascript
+// babel.config.js (non-Expo projects must set `disableImportExportTransform`)
+module.exports = {
+ presets: [
+ [
+ 'module:@react-native/babel-preset',
+ { disableImportExportTransform: true },
+ ],
+ ],
+};
+```
+
+## When to Use
+
+- Bundle includes unused library code
+- Want automatic barrel export optimization
+- Using Expo SDK 52+ (recommended approach)
+- Project already uses Re.Pack (`@callstack/repack`)
+
+## Platform Support
+
+| Bundler | Tree Shaking | Notes |
+| -------------- | --------------- | ---------------------------- |
+| Metro | ❌ No | Use metro-serializer-esbuild |
+| Expo (SDK 52+) | ✅ Experimental | Requires config |
+| Re.Pack | ✅ Yes | Built-in via Webpack/Rspack |
+
+## Setup: Expo SDK 52+
+
+### 1. Enable Import Support
+
+```javascript
+// metro.config.js
+const { getDefaultConfig } = require('expo/metro-config');
+
+const config = getDefaultConfig(__dirname);
+
+config.transformer.getTransformOptions = async () => ({
+ transform: {
+ experimentalImportSupport: true,
+ },
+});
+
+module.exports = config;
+```
+
+### 2. Enable Tree Shaking
+
+Create/edit `.env`:
+
+```bash
+EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1
+EXPO_UNSTABLE_TREE_SHAKING=1
+```
+
+**Note**: Only applies in production builds.
+
+## Setup: Metro with ESBuild
+
+```bash
+npm install @rnx-kit/metro-serializer-esbuild
+```
+
+Configure in metro.config.js.
+
+## Setup: Re.Pack (Only If Already Installed)
+
+> **Important**: Re.Pack (`@callstack/repack`) is a custom bundler using Rspack/Webpack. Only apply these instructions if the project already has Re.Pack configured. Do not suggest migrating a codebase to Re.Pack—it's rarely necessary and requires significant setup.
+
+**If project has `@callstack/repack` in dependencies:**
+
+Tree shaking is enabled by default with Rspack. Verify in config:
+
+```javascript
+// rspack.config.js or webpack.config.js
+module.exports = {
+ optimization: {
+ usedExports: true, // Mark unused exports
+ minimize: true, // Remove during minification
+ },
+};
+```
+
+## Platform Shaking
+
+Code inside `Platform.OS` and `Platform.select` checks is removed for other platforms:
+
+```tsx
+// IMPORTANT: import Platform directly from 'react-native'
+import { Platform } from 'react-native';
+
+if (Platform.OS === 'ios') {
+ // Removed from Android bundle
+}
+
+if (Platform.select({ ios: true, android: false }) === 'ios') {
+ // Removed from Android bundle
+}
+```
+
+**Critical**: Must use direct import. This does NOT work:
+
+```tsx
+import * as RN from 'react-native';
+if (RN.Platform.OS === 'ios') {
+ // NOT removed - optimization fails
+}
+```
+
+For non-Expo projects, requires both `experimentalImportSupport: true` in Metro config and `disableImportExportTransform: true` in Babel config.
+
+Impact: Savings from enabling platform shaking on a bare React Native Community CLI project are:
+- 5% smaller Hermes bytecode (2.79 MB → 2.64 MB)
+- 15% smaller minified JS bundle (1 MB → 0.85 MB)
+
+## Requirements for Tree Shaking
+
+### ESM Imports Required
+
+```tsx
+// ✅ ESM - Tree shakeable
+import { foo } from './module';
+
+// ❌ CommonJS - Not tree shakeable
+const { foo } = require('./module');
+```
+
+### Side Effects Declaration
+
+Libraries must declare side-effect-free in `package.json`:
+
+```json
+{
+ "sideEffects": false
+}
+```
+
+Or specify files with side effects:
+
+```json
+{
+ "sideEffects": ["*.css", "./src/polyfills.js"]
+}
+```
+
+## Size Impact
+
+| Bundle Type | Metro (MB) | Re.Pack (MB) | Change |
+| ----------------- | ---------- | ------------ | -------- |
+| Production | 35.63 | 38.48 | +8% |
+| Prod Minified | 15.54 | 13.36 | **-14%** |
+| Prod HBC | 21.79 | 19.35 | **-11%** |
+| Prod Minified HBC | 21.62 | 19.05 | **-12%** |
+
+**Expected improvement**: 10-15% bundle size reduction.
+
+## Verification
+
+1. Build production bundle (see [bundle-analyze-js.md](./bundle-analyze-js.md))
+2. Analyze with source-map-explorer (see [bundle-analyze-js.md](./bundle-analyze-js.md))
+3. Search for functions you know are unused
+4. If found → tree shaking not working
+
+### Test Example
+
+```tsx
+// test-treeshake.js
+export const usedFunction = () => 'used';
+export const unusedFunction = () => 'unused'; // Should be removed
+
+// app.js
+import { usedFunction } from './test-treeshake';
+```
+
+After building, search bundle for `unusedFunction`. Should not exist.
+
+## Common Pitfalls
+
+- **Not using production build**: Tree shaking only in prod
+- **CommonJS modules**: Need ESM for full effectiveness
+- **Side effects not declared**: Library may not be shakeable
+- **Dynamic imports**: `require(variable)` prevents analysis
+- **Babel/Metro config mismatch**: `disableImportExportTransform` must match `experimentalImportSupport`
+
+## Related Skills
+
+- [bundle-analyze-js.md](./bundle-analyze-js.md) - Verify tree shaking effect
+- [bundle-barrel-exports.md](./bundle-barrel-exports.md) - Manual alternative
+- [bundle-code-splitting.md](./bundle-code-splitting.md) - Re.Pack code splitting
diff --git a/.forge/skills/react-native-best-practices/references/images/bundle-treemap-source-map-explorer.png b/.forge/skills/react-native-best-practices/references/images/bundle-treemap-source-map-explorer.png
new file mode 100644
index 00000000..b8c93e15
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/bundle-treemap-source-map-explorer.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/controlled-textinput-pingpong.png b/.forge/skills/react-native-best-practices/references/images/controlled-textinput-pingpong.png
new file mode 100644
index 00000000..0de7141e
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/controlled-textinput-pingpong.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/devtools-flamegraph.png b/.forge/skills/react-native-best-practices/references/images/devtools-flamegraph.png
new file mode 100644
index 00000000..10b5afee
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/devtools-flamegraph.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/emerge-xray-ios.png b/.forge/skills/react-native-best-practices/references/images/emerge-xray-ios.png
new file mode 100644
index 00000000..3f737a0d
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/emerge-xray-ios.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/expo-atlas-treemap.png b/.forge/skills/react-native-best-practices/references/images/expo-atlas-treemap.png
new file mode 100644
index 00000000..bfa214e2
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/expo-atlas-treemap.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/flashlight-flatlist-vs-flashlist.png b/.forge/skills/react-native-best-practices/references/images/flashlight-flatlist-vs-flashlist.png
new file mode 100644
index 00000000..2ef15f67
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/flashlight-flatlist-vs-flashlist.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/fps-drop-graph.png b/.forge/skills/react-native-best-practices/references/images/fps-drop-graph.png
new file mode 100644
index 00000000..64843da6
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/fps-drop-graph.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/memory-heap-snapshot.png b/.forge/skills/react-native-best-practices/references/images/memory-heap-snapshot.png
new file mode 100644
index 00000000..5c8a5cec
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/memory-heap-snapshot.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/tti-warm-start-diagram.png b/.forge/skills/react-native-best-practices/references/images/tti-warm-start-diagram.png
new file mode 100644
index 00000000..dcb5256f
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/tti-warm-start-diagram.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/view-hierarchy-flattening.png b/.forge/skills/react-native-best-practices/references/images/view-hierarchy-flattening.png
new file mode 100644
index 00000000..d3dbc26b
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/view-hierarchy-flattening.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/xcode-instruments-templates.png b/.forge/skills/react-native-best-practices/references/images/xcode-instruments-templates.png
new file mode 100644
index 00000000..00a7dcda
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/xcode-instruments-templates.png differ
diff --git a/.forge/skills/react-native-best-practices/references/images/xcode-thread-view.png b/.forge/skills/react-native-best-practices/references/images/xcode-thread-view.png
new file mode 100644
index 00000000..ce2c180c
Binary files /dev/null and b/.forge/skills/react-native-best-practices/references/images/xcode-thread-view.png differ
diff --git a/.forge/skills/react-native-best-practices/references/js-animations-reanimated.md b/.forge/skills/react-native-best-practices/references/js-animations-reanimated.md
new file mode 100644
index 00000000..f116790a
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-animations-reanimated.md
@@ -0,0 +1,255 @@
+---
+title: High-Performance Animations
+impact: MEDIUM
+tags: reanimated, animations, worklets, ui-thread
+---
+
+# Skill: High-Performance Animations
+
+Use React Native Reanimated for smooth 60+ FPS animations.
+
+## Quick Pattern
+
+**Incorrect (JS thread - blocks on heavy work):**
+
+```jsx
+const opacity = useRef(new Animated.Value(0)).current;
+Animated.timing(opacity, { toValue: 1 }).start();
+```
+
+**Correct (UI thread - smooth even during JS work):**
+
+```jsx
+const opacity = useSharedValue(0);
+const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
+opacity.value = withTiming(1);
+```
+
+## When to Use
+
+- Animations drop frames or feel janky
+- UI freezes during animations
+- Need gesture-driven animations
+- Want animations to run during heavy JS work
+
+## Prerequisites
+
+- `react-native-reanimated` (v4+) and `react-native-worklets` installed
+
+```bash
+npm install react-native-reanimated react-native-worklets
+```
+
+Add to `babel.config.js`:
+
+```javascript
+module.exports = {
+ plugins: ['react-native-worklets/plugin'], // Must be last
+};
+```
+
+> **Note**: Reanimated 4 requires React Native's **New Architecture** (Fabric + TurboModules). The Legacy Architecture is no longer supported. If upgrading from v3, see the migration notes at the end of this document.
+
+## Key Concepts
+
+### Main Thread vs JS Thread
+
+- **Main/UI Thread**: Handles native rendering (60+ FPS target)
+- **JS Thread**: Runs React and your JavaScript
+
+**Problem**: Heavy JS work blocks animations running on JS thread.
+
+**Solution**: Run animations on UI thread with Reanimated worklets.
+
+## Step-by-Step Instructions
+
+### 1. Basic Animated Style (UI Thread)
+
+```jsx
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming
+} from 'react-native-reanimated';
+
+const FadeInView = () => {
+ const opacity = useSharedValue(0);
+
+ // This runs on UI thread - won't be blocked by JS
+ const animatedStyle = useAnimatedStyle(() => {
+ return { opacity: opacity.value };
+ });
+
+ useEffect(() => {
+ opacity.value = withTiming(1, { duration: 500 });
+ }, []);
+
+ return ;
+};
+```
+
+### 2. Run Code on UI Thread with `scheduleOnUI`
+
+```jsx
+import { scheduleOnUI } from 'react-native-worklets';
+
+const triggerAnimation = () => {
+ scheduleOnUI(() => {
+ 'worklet';
+ console.log('Running on UI thread');
+ // Direct UI manipulations here
+ });
+};
+```
+
+### 3. Call JS from UI Thread with `scheduleOnRN`
+
+```jsx
+import { scheduleOnRN } from 'react-native-worklets';
+
+// Regular JS function
+const trackAnalytics = (value) => {
+ analytics.track('animation_complete', { value });
+};
+
+const AnimatedComponent = () => {
+ const progress = useSharedValue(0);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ // When animation completes, call JS function
+ if (progress.value === 1) {
+ scheduleOnRN(trackAnalytics, progress.value);
+ }
+ return { opacity: progress.value };
+ });
+
+ return ;
+};
+```
+
+### 4. Animation with Callback
+
+```jsx
+import { scheduleOnRN } from 'react-native-worklets';
+
+const AnimatedButton = () => {
+ const scale = useSharedValue(1);
+
+ const onComplete = () => {
+ console.log('Animation finished!');
+ };
+
+ const handlePress = () => {
+ scale.value = withTiming(
+ 1.2,
+ { duration: 200 },
+ (finished) => {
+ if (finished) {
+ scheduleOnRN(onComplete);
+ }
+ }
+ );
+ };
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: scale.value }],
+ }));
+
+ return (
+
+
+ Press Me
+
+
+ );
+};
+```
+
+## When to Use What
+
+| Thread | Best For |
+|--------|----------|
+| **UI Thread** (worklets) | Visual animations, transforms, gestures |
+| **JS Thread** | State updates, data processing, API calls |
+
+| Hook/API | Use Case |
+|----------|----------|
+| `useAnimatedStyle` | Animated styles (auto UI thread) |
+| `scheduleOnUI` | Manual UI thread execution (from `react-native-worklets`) |
+| `scheduleOnRN` | Call JS functions from worklets (from `react-native-worklets`) |
+| `useTransition` | Alternative for React state-driven delays |
+
+## Common Pitfalls
+
+- **Accessing React state in worklets**: Use `useSharedValue` instead of `useState` for animated values
+- **Not using Animated components**: Must use `Animated.View`, `Animated.Text`, etc.
+- **Heavy computation in useAnimatedStyle**: Keep worklets fast
+- **Forgetting 'worklet' directive**: Required for inline worklet functions
+
+```jsx
+// BAD: Regular function in useAnimatedStyle
+const style = useAnimatedStyle(() => {
+ heavyComputation(); // Blocks UI thread!
+ return { opacity: 1 };
+});
+
+// GOOD: Keep worklets fast
+const style = useAnimatedStyle(() => {
+ return { opacity: opacity.value }; // Just read value
+});
+```
+
+## Migrating from Reanimated 3.x to 4.x
+
+If you're upgrading from Reanimated 3.x, here are the key changes.
+
+> **Can't upgrade to v4?** If your project is blocked from migrating to New Architecture (e.g., incompatible native libraries, complex native code, or timeline constraints), keep using existing APIs and leverage native drivers where applicable. Avoid introducing legacy Reanimated 3.x or older to reduce future migration complexity.
+
+### Breaking Changes
+
+| Old API (v3) | New API (v4) | Package |
+|--------------|--------------|---------|
+| `runOnUI(() => {...})()` | `scheduleOnUI(() => {...})` | `react-native-worklets` |
+| `runOnJS(fn)(args)` | `scheduleOnRN(fn, args)` | `react-native-worklets` |
+| `executeOnUIRuntimeSync` | `runOnUISync` | `react-native-worklets` |
+| `runOnRuntime` | `scheduleOnRuntime` | `react-native-worklets` |
+| `useScrollViewOffset` | `useScrollOffset` | `react-native-reanimated` |
+| `useWorkletCallback` | Use `useCallback` with `'worklet';` directive | React |
+
+### Removed APIs
+
+- `useAnimatedGestureHandler` - Migrate to the Gesture API from `react-native-gesture-handler` v2+
+- `addWhitelistedNativeProps` / `addWhitelistedUIProps` - No longer needed
+- `combineTransition` - Use `EntryExitTransition.entering(...).exiting(...)` instead
+
+### withSpring Changes
+
+```jsx
+// Before (v3)
+withSpring(value, {
+ restDisplacementThreshold: 0.01,
+ restSpeedThreshold: 0.01,
+ duration: 300,
+});
+
+// After (v4)
+withSpring(value, {
+ energyThreshold: 0.01, // Replaces both threshold parameters
+ duration: 200, // Duration is now "perceptual" (~1.5x actual time)
+});
+```
+
+### Migration Checklist
+
+1. **Enable New Architecture** - Reanimated 4 only supports Fabric + TurboModules
+2. **Install `react-native-worklets`** - Required new dependency
+3. **Update Babel plugin** - Change `'react-native-reanimated/plugin'` to `'react-native-worklets/plugin'`
+4. **Update imports** - Move worklet functions to `react-native-worklets`
+5. **Update API calls** - New functions take callback + args directly (not curried)
+6. **Rebuild native apps** - Required after adding `react-native-worklets`
+
+## Related Skills
+
+- [js-measure-fps.md](./js-measure-fps.md) - Verify animation frame rate
+- [js-bottomsheet.md](./js-bottomsheet.md) - Keep bottom sheet visual state on the UI thread
+- [js-concurrent-react.md](./js-concurrent-react.md) - React-level deferral with useTransition
diff --git a/.forge/skills/react-native-best-practices/references/js-atomic-state.md b/.forge/skills/react-native-best-practices/references/js-atomic-state.md
new file mode 100644
index 00000000..f243c34a
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-atomic-state.md
@@ -0,0 +1,246 @@
+---
+title: Atomic State Management
+impact: HIGH
+tags: state, jotai, zustand, re-renders, context
+---
+
+# Skill: Atomic State Management
+
+Use atomic state libraries (Jotai, Zustand) to reduce unnecessary re-renders without manual memoization.
+
+## Quick Pattern
+
+**Before (Context - all consumers re-render):**
+
+```jsx
+const { filter, todos } = useContext(TodoContext);
+// Re-renders when ANY state changes
+```
+
+**After (Zustand - only subscribed state):**
+
+```jsx
+const filter = useTodoStore((s) => s.filter);
+// Only re-renders when filter changes
+```
+
+## When to Use
+
+- Global state changes cause widespread re-renders
+- Using React Context for app state
+- Components re-render even when their data hasn't changed
+- Want to avoid manual `useMemo`/`useCallback` everywhere
+- Not ready to adopt React Compiler
+
+## Prerequisites
+
+- State management library: `jotai` or `zustand`
+
+```bash
+npm install jotai
+# or
+npm install zustand
+```
+
+## Problem Description
+
+With traditional React state or Context:
+
+```jsx
+// When filter OR todos change, EVERYTHING re-renders
+const App = () => {
+ const [filter, setFilter] = useState('all');
+ const [todos, setTodos] = useState([]);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+```
+
+Changing a todo re-renders FilterMenu even though it doesn't use todos.
+
+## Step-by-Step Instructions
+
+### Using Jotai
+
+#### 1. Define Atoms
+
+```jsx
+import { atom } from 'jotai';
+
+// Each atom is an independent piece of state
+const filterAtom = atom('all');
+const todosAtom = atom([]);
+
+// Derived atom (computed value)
+const filteredTodosAtom = atom((get) => {
+ const filter = get(filterAtom);
+ const todos = get(todosAtom);
+
+ if (filter === 'active') return todos.filter(t => !t.completed);
+ if (filter === 'completed') return todos.filter(t => t.completed);
+ return todos;
+});
+```
+
+#### 2. Use Atoms in Components
+
+```jsx
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+
+// Only re-renders when filterAtom changes
+const FilterMenu = () => {
+ const [filter, setFilter] = useAtom(filterAtom);
+
+ return (
+
+ {['all', 'active', 'completed'].map((f) => (
+ setFilter(f)}>
+ {f}
+
+ ))}
+
+ );
+};
+
+// Only re-renders when todosAtom changes
+const TodoItem = ({ id }) => {
+ const setTodos = useSetAtom(todosAtom); // Only setter, no re-render on read
+
+ const toggleTodo = () => {
+ setTodos((prev) =>
+ prev.map((t) => t.id === id ? { ...t, completed: !t.completed } : t)
+ );
+ };
+
+ return ...;
+};
+```
+
+### Using Zustand
+
+#### 1. Create Store
+
+```jsx
+import { create } from 'zustand';
+
+const useTodoStore = create((set, get) => ({
+ filter: 'all',
+ todos: [],
+
+ setFilter: (filter) => set({ filter }),
+
+ toggleTodo: (id) => set((state) => ({
+ todos: state.todos.map((t) =>
+ t.id === id ? { ...t, completed: !t.completed } : t
+ ),
+ })),
+
+ // Selector for derived state
+ getFilteredTodos: () => {
+ const { filter, todos } = get();
+ if (filter === 'active') return todos.filter(t => !t.completed);
+ if (filter === 'completed') return todos.filter(t => t.completed);
+ return todos;
+ },
+}));
+```
+
+#### 2. Use Selectors
+
+```jsx
+// Only re-renders when filter changes
+const FilterMenu = () => {
+ const filter = useTodoStore((state) => state.filter);
+ const setFilter = useTodoStore((state) => state.setFilter);
+
+ return (
+
+ {['all', 'active', 'completed'].map((f) => (
+ setFilter(f)}>
+ {f}
+
+ ))}
+
+ );
+};
+
+// Only re-renders when todos change
+const TodoList = () => {
+ const todos = useTodoStore((state) => state.todos);
+ return todos.map((todo) => );
+};
+```
+
+## Code Examples
+
+### Before: Context-Based (Many Re-renders)
+
+```jsx
+const TodoContext = createContext();
+
+const TodoProvider = ({ children }) => {
+ const [state, setState] = useState({ filter: 'all', todos: [] });
+ return (
+
+ {children}
+
+ );
+};
+
+// Every component using this context re-renders on ANY state change
+const FilterMenu = () => {
+ const { state, setState } = useContext(TodoContext);
+ // Re-renders when todos change too!
+};
+```
+
+### After: Atomic (Targeted Re-renders)
+
+```jsx
+// Jotai version - only affected components re-render
+const filterAtom = atom('all');
+const todosAtom = atom([]);
+
+const FilterMenu = () => {
+ const [filter, setFilter] = useAtom(filterAtom);
+ // Only re-renders when filter changes
+};
+
+const TodoList = () => {
+ const todos = useAtomValue(todosAtom);
+ // Only re-renders when todos change
+};
+```
+
+## Comparison
+
+| Feature | Context | Jotai | Zustand |
+|---------|---------|-------|---------|
+| Re-render scope | All consumers | Atom subscribers | Selector subscribers |
+| Derived state | Manual | Built-in atoms | Selectors |
+| DevTools | React DevTools | Jotai DevTools | Zustand DevTools |
+| Bundle size | 0 KB | ~3 KB | ~2 KB |
+| Learning curve | Low | Medium | Low |
+
+## When to Use Which
+
+- **Jotai**: Fine-grained state, many small atoms, derived/async atoms
+- **Zustand**: Simpler mental model, single store, familiar Redux-like pattern
+- **React Compiler**: If available, may eliminate need for these libraries
+
+## Common Pitfalls
+
+- **Over-atomizing**: Don't create an atom for every variable. Group related state.
+- **Missing selectors in Zustand**: Always use selectors to prevent unnecessary re-renders.
+- **Derived state without memoization**: Use derived atoms (Jotai) or memoized selectors.
+
+## Related Skills
+
+- [js-bottomsheet.md](./js-bottomsheet.md) - Avoid context-driven bottom sheet subtree re-renders
+- [js-react-compiler.md](./js-react-compiler.md) - Automatic memoization alternative
+- [js-profile-react.md](./js-profile-react.md) - Verify re-render reduction
diff --git a/.forge/skills/react-native-best-practices/references/js-bottomsheet.md b/.forge/skills/react-native-best-practices/references/js-bottomsheet.md
new file mode 100644
index 00000000..05e7587e
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-bottomsheet.md
@@ -0,0 +1,325 @@
+---
+title: Bottom Sheet
+impact: HIGH
+tags: bottom-sheet, gorhom, re-renders, shared-values, gestures, context, scrollable, modal, keyboard
+---
+
+# Skill: Bottom Sheet Best Practices
+
+Optimize `@gorhom/bottom-sheet` for smooth 60 FPS by keeping gesture/scroll-driven state on the UI thread.
+
+## Quick Pattern
+
+**Incorrect (can re-enter JS repeatedly during interaction — full subtree re-render):**
+
+```jsx
+const handleAnimate = useCallback((fromIndex, toIndex) => {
+ setIsExpanded(toIndex > 0); // re-renders entire tree
+}, []);
+
+
+
+
+```
+
+**Correct (stays on UI thread — zero re-renders):**
+
+```jsx
+const animatedIndex = useSharedValue(0);
+
+const overlayStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(animatedIndex.value > 0 ? 0.5 : 0),
+}));
+
+
+
+
+
+```
+
+## When to Use
+
+- Implementing or optimizing a bottom sheet with `@gorhom/bottom-sheet`
+- Bottom sheet gestures cause jank or dropped frames
+- Scroll inside bottom sheet triggers excessive re-renders
+- Context provider wrapping bottom sheet re-renders the entire subtree
+- Visual-only state (shadow, opacity, footer visibility) managed with `useState`
+- Need to choose between `BottomSheet` and `BottomSheetModal`
+- Scrollable content inside bottom sheet doesn't coordinate with gestures
+- Keyboard doesn't interact properly with the sheet
+
+## Prerequisites
+
+- Check the official [`@gorhom/bottom-sheet` versioning / compatibility table](https://github.com/gorhom/react-native-bottom-sheet#versioning) first.
+- If your app is on `@gorhom/bottom-sheet` below v5, upgrade to v5 before applying the patterns in this skill.
+- `@gorhom/bottom-sheet` v5 is the current maintained line and is built for `react-native-reanimated` v3.
+- `react-native-reanimated` v4 may work in some apps, but the bottom-sheet docs do not officially guarantee it. Decide explicitly whether to stay on v3 or try v4 and validate thoroughly on device.
+- `react-native-gesture-handler` v2+
+
+```bash
+npm install @gorhom/bottom-sheet@^5 react-native-reanimated@^3 react-native-gesture-handler
+```
+
+> **Note**: In v5, `enableDynamicSizing` defaults to `true`. If you need fixed snap-point indexing or do not want the library to insert a dynamic snap point based on content height, set `enableDynamicSizing={false}` explicitly.
+
+## Problem Description
+
+Bottom-sheet gesture, animation, and scroll callbacks that update React state can re-render the sheet subtree during interaction. In practice, callbacks like `onAnimate` may run repeatedly as the sheet retargets animations, which can cause visible jank if they drive expensive React updates.
+
+## Step-by-Step Instructions
+
+### 1. Convert Gesture-Driven State to SharedValue
+
+Avoid React state for gesture-driven visual state. Update a shared value and consume it via `useAnimatedStyle`.
+
+**Before:**
+
+```jsx
+const [shadowOpacity, setShadowOpacity] = useState(0);
+
+const handleAnimate = useCallback((fromIndex, toIndex) => {
+ setShadowOpacity(toIndex > 0 ? 0.3 : 0);
+}, []);
+
+
+
+
+
+
+```
+
+**After:**
+
+```jsx
+const animatedIndex = useSharedValue(0);
+
+const shadowStyle = useAnimatedStyle(() => ({
+ shadowOpacity: withTiming(animatedIndex.value > 0 ? 0.3 : 0),
+}));
+
+
+
+
+
+
+```
+
+### 2. Drive Sheet-Index Visibility via `useAnimatedReaction`
+
+Toggling content based on sheet index via `{showFooter && }` causes mount/unmount cycles on every snap. Instead, always mount, animate visibility from `animatedIndex`, and bridge only the minimal boolean needed for `pointerEvents`/accessibility — scoped to a wrapper so the full tree doesn't re-render.
+
+**Before:**
+
+```jsx
+const [showFooter, setShowFooter] = useState(false);
+
+// re-mounts footer on every toggle
+{showFooter && }
+```
+
+**After:**
+
+```jsx
+const SheetVisibilityWrapper = ({ animatedIndex, threshold = 1, children }) => {
+ const [isInteractive, setIsInteractive] = useState(false);
+
+ const style = useAnimatedStyle(() => ({
+ opacity: withTiming(animatedIndex.value >= threshold ? 1 : 0),
+ transform: [{ translateY: withTiming(animatedIndex.value >= threshold ? 0 : 50) }],
+ }));
+
+ useAnimatedReaction(
+ () => animatedIndex.value >= threshold,
+ (visible, prev) => {
+ if (visible !== prev) runOnJS(setIsInteractive)(visible);
+ }
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Usage:
+
+
+
+```
+
+### 3. Keep Scroll-Driven Logic off the JS Thread
+
+`BottomSheetScrollView` ignores `scrollEventThrottle`, so setting it is not an optimization. Keep JS `onScroll` work minimal, or move scroll-driven logic to `useAnimatedScrollHandler` (see [js-animations-reanimated.md](./js-animations-reanimated.md)) so it stays on the UI thread:
+
+```jsx
+const scrollHandler = useAnimatedScrollHandler((event) => {
+ scrollY.value = event.contentOffset.y;
+});
+
+
+
+
+```
+
+### 4. Use Library-Provided Components and Props
+
+**Scrollables** — always use these instead of React Native built-ins inside a bottom sheet:
+
+```jsx
+import {
+ BottomSheetScrollView,
+ BottomSheetFlatList,
+ BottomSheetSectionList,
+} from '@gorhom/bottom-sheet';
+
+// FlashList v2: BottomSheetFlashList is deprecated.
+// Create the scroll component, then pass it to FlashList.
+import { useBottomSheetScrollableCreator } from '@gorhom/bottom-sheet';
+import { FlashList } from '@shopify/flash-list';
+
+const BottomSheetFlashListScrollComponent = useBottomSheetScrollableCreator();
+
+
+ item.id}
+ renderItem={renderItem}
+ renderScrollComponent={BottomSheetFlashListScrollComponent}
+ />
+
+```
+
+**Key props:**
+
+| Prop | Purpose |
+|------|---------|
+| `containerHeight` | Provide to skip extra measurement re-render on mount |
+| `enableDynamicSizing={false}` | Use when you want fixed snap-point indexing and do not want a dynamic content-height snap point inserted |
+| `animatedIndex` | SharedValue for continuous index tracking on UI thread |
+| `animatedPosition` | SharedValue for continuous position tracking on UI thread |
+| `onChange` | Fires on snap **completion** only (discrete) — use for analytics/side effects |
+| `onAnimate` | Fires before each animation start/retarget — use sparingly, because it can run repeatedly during interaction |
+
+### 5. BottomSheetModal Setup
+
+```jsx
+import {
+ BottomSheetModal,
+ BottomSheetModalProvider,
+} from '@gorhom/bottom-sheet';
+
+const App = () => (
+
+
+
+
+
+);
+```
+
+**iOS layering fix** — use `FullWindowOverlay` to render above native navigation:
+
+```jsx
+import { FullWindowOverlay } from 'react-native-screens';
+
+ {props.children}}
+>
+```
+
+### 6. Keyboard Handling
+
+```jsx
+
+
+
+```
+
+| `keyboardBehavior` | Effect |
+|--------------------|--------|
+| `extend` | Sheet grows to accommodate keyboard |
+| `fillParent` | Sheet fills parent when keyboard appears |
+| `interactive` | Sheet follows keyboard position interactively |
+
+> Prefer `BottomSheetTextInput` inside a bottom sheet. If you need a custom input, copy the focus/blur handlers from the library's `BottomSheetTextInput` implementation so keyboard handling still works correctly.
+
+## Derived Animations with `animatedPosition`
+
+Use the `animatedPosition` shared value for smooth derived UI that stays on the UI thread:
+
+```jsx
+const animatedPosition = useSharedValue(0);
+
+const backdropStyle = useAnimatedStyle(() => ({
+ opacity: interpolate(
+ animatedPosition.value,
+ [0, 300],
+ [0.5, 0],
+ Extrapolation.CLAMP
+ ),
+}));
+
+
+
+
+
+```
+
+## Native Alternative: react-native-true-sheet
+
+If your app already runs on **New Architecture (Fabric)**, consider `@lodev09/react-native-true-sheet` — a fully native bottom sheet that sidesteps JS re-render problems entirely.
+
+| Scenario | Recommendation |
+|----------|---------------|
+| Need deep JS customization (custom gestures, animated derived UI) | `@gorhom/bottom-sheet` |
+| Standard sheet with native feel + accessibility | `react-native-true-sheet` |
+| Legacy Architecture (no Fabric) | `@gorhom/bottom-sheet` (true-sheet v3+ requires Fabric) |
+| Web support needed | Either (true-sheet uses `@gorhom/bottom-sheet` on web internally) |
+
+**Advantages**: zero JS overhead (sheet lives in native land — no SharedValue plumbing needed), built-in keyboard handling, native screen reader support, side sheet on tablets, iOS 26+ Liquid Glass support, React Navigation sheet navigator integration.
+
+**Requirements**: New Architecture (Fabric) for v3+, use v2.x for Legacy Architecture.
+
+```bash
+npm install @lodev09/react-native-true-sheet
+```
+
+> If requirements are met and you don't need the fine-grained Reanimated-driven customization described in this skill, `react-native-true-sheet` is the simpler and more performant choice.
+
+## Common Pitfalls
+
+- **Using `onChange` for continuous position tracking** — it fires on snap completion only (discrete). Use `animatedPosition` or `animatedIndex` shared values instead.
+- **Forgetting `pointerEvents='none'` on always-mounted hidden elements** — invisible elements still capture touches.
+- **Missing accessibility attributes on hidden elements** — add `accessibilityElementsHidden` and `importantForAccessibility='no-hide-descendants'`.
+- **Bundling independent state values in one context** — see [js-atomic-state.md](./js-atomic-state.md) for splitting patterns.
+- **Assuming `enableDynamicSizing` must be disabled whenever you pass `snapPoints`** — it does not have to be, but leaving it enabled can insert an additional snap point and change indexing.
+- **Using React Native `ScrollView`/`FlatList` inside bottom sheet** — gestures won't coordinate. Use `BottomSheetScrollView`, `BottomSheetFlatList`, etc.
+- **Using React Native touchables on Android** — import `TouchableOpacity`, `TouchableHighlight`, or `TouchableWithoutFeedback` from `@gorhom/bottom-sheet`.
+- **Not providing `containerHeight`** — causes an extra re-render on mount for measurement.
+- **Using a custom `TextInput` without porting the library's focus/blur handlers** — keyboard handling will be incomplete. Prefer `BottomSheetTextInput` unless you need a custom input.
+
+## Related Skills
+
+- [js-animations-reanimated.md](./js-animations-reanimated.md) — SharedValue and useAnimatedStyle fundamentals
+- [js-atomic-state.md](./js-atomic-state.md) — Context splitting and atomic state patterns
+- [js-profile-react.md](./js-profile-react.md) — Profiling to measure re-render reduction
+- [js-measure-fps.md](./js-measure-fps.md) — Verify FPS improvement after optimization
diff --git a/.forge/skills/react-native-best-practices/references/js-concurrent-react.md b/.forge/skills/react-native-best-practices/references/js-concurrent-react.md
new file mode 100644
index 00000000..87b7dc17
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-concurrent-react.md
@@ -0,0 +1,236 @@
+---
+title: Concurrent React
+impact: HIGH
+tags: useDeferredValue, useTransition, suspense, concurrent
+---
+
+# Skill: Concurrent React
+
+Use `useDeferredValue` and `useTransition` to improve perceived performance by prioritizing critical updates.
+
+## Quick Pattern
+
+**Incorrect (blocks input on every keystroke):**
+
+```jsx
+const [query, setQuery] = useState('');
+
+ // Blocks typing
+```
+
+**Correct (input stays responsive):**
+
+```jsx
+const [query, setQuery] = useState('');
+const deferredQuery = useDeferredValue(query);
+
+ // Deferred update
+```
+
+## When to Use
+
+- Search/filter inputs feel laggy with large result sets
+- Expensive computations block UI interactions
+- Loading states appear too frequently
+- Want to show stale content while loading new content
+- Need to prioritize user input over background updates
+
+## Prerequisites
+
+- React Native with New Architecture enabled (default in RN 0.76+)
+- React 18+ features (`useDeferredValue`, `useTransition`, `Suspense`)
+
+## Concept Overview
+
+**Concurrent React** allows updates to be:
+- **Paused**: Low-priority work can wait
+- **Interrupted**: User input takes priority
+- **Abandoned**: Outdated updates can be skipped
+
+## Step-by-Step Instructions
+
+### Pattern 1: Defer Expensive Rendering with `useDeferredValue`
+
+Use when a value drives expensive computation but you want input to stay responsive.
+
+```jsx
+import { useState, useDeferredValue } from 'react';
+
+const SearchScreen = () => {
+ const [query, setQuery] = useState('');
+ const deferredQuery = useDeferredValue(query);
+
+ // query updates immediately (input stays responsive)
+ // deferredQuery updates when React has time
+
+ return (
+
+
+ {/* ExpensiveList receives deferred value */}
+
+
+ );
+};
+```
+
+### Pattern 2: Show Stale Content While Loading
+
+```jsx
+const SearchWithStaleIndicator = () => {
+ const [query, setQuery] = useState('');
+ const deferredQuery = useDeferredValue(query);
+ const isStale = query !== deferredQuery;
+
+ return (
+
+
+
+
+
+ {isStale && }
+
+ );
+};
+```
+
+### Pattern 3: Transition Non-Critical Updates with `useTransition`
+
+Use when you have multiple state updates and want to mark some as low-priority.
+
+```jsx
+import { useState, useTransition } from 'react';
+
+const TransitionExample = () => {
+ const [count, setCount] = useState(0);
+ const [heavyData, setHeavyData] = useState(null);
+ const [isPending, startTransition] = useTransition();
+
+ const handleIncrement = () => {
+ // High priority - updates immediately
+ setCount(c => c + 1);
+
+ // Low priority - can be interrupted
+ startTransition(() => {
+ setHeavyData(computeExpensiveData());
+ });
+ };
+
+ return (
+
+ Count: {count}
+ {isPending ? : }
+
+
+ );
+};
+```
+
+### Pattern 4: Suspense for Data Fetching
+
+```jsx
+import { Suspense, useDeferredValue } from 'react';
+
+const DataScreen = () => {
+ const [query, setQuery] = useState('');
+ const deferredQuery = useDeferredValue(query);
+
+ return (
+
+
+ }>
+
+
+
+ );
+};
+```
+
+## Code Examples
+
+### Slow Component Optimization
+
+```jsx
+// Without Concurrent React - UI freezes
+const SlowSearch = () => {
+ const [query, setQuery] = useState('');
+
+ return (
+ <>
+
+ {/* Blocks every keystroke */}
+ >
+ );
+};
+
+// With Concurrent React - UI stays responsive
+const FastSearch = () => {
+ const [query, setQuery] = useState('');
+ const deferredQuery = useDeferredValue(query);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+// Important: Wrap SlowComponent in memo to prevent re-renders from parent
+const SlowComponent = memo(({ query }) => {
+ // Expensive computation here
+});
+```
+
+### Automatic Batching (React 18+)
+
+React 18 automatically batches state updates:
+
+```jsx
+// Before React 18 - 2 re-renders
+setTimeout(() => {
+ setCount(c => c + 1);
+ setFlag(f => !f);
+ // Rendered twice
+}, 1000);
+
+// React 18+ - 1 re-render (automatic batching)
+setTimeout(() => {
+ setCount(c => c + 1);
+ setFlag(f => !f);
+ // Rendered once!
+}, 1000);
+```
+
+## When to Use Which
+
+| Scenario | Solution |
+|----------|----------|
+| Single value drives expensive render | `useDeferredValue` |
+| Multiple state updates, some non-critical | `useTransition` |
+| Need loading indicator for transition | `useTransition` (has `isPending`) |
+| Data fetching with loading states | `Suspense` + `useDeferredValue` |
+| Simple parent-to-child value deferral | `useDeferredValue` |
+
+## Important Considerations
+
+1. **Wrap expensive components in `memo()`**: Without memoization, the component re-renders from parent anyway.
+
+2. **Use with New Architecture**: Concurrent features require New Architecture in React Native.
+
+3. **Don't overuse**: Only defer truly expensive work. Adding complexity for fast components is counterproductive.
+
+## Common Pitfalls
+
+- **Forgetting memo()**: `useDeferredValue` is useless if child re-renders from parent
+- **Using for simple state**: Overhead isn't worth it for cheap updates
+- **Expecting faster computation**: These hooks don't make code faster, they prioritize what runs when
+
+## Related Skills
+
+- [js-profile-react.md](./js-profile-react.md) - Identify slow components
+- [js-react-compiler.md](./js-react-compiler.md) - Automatic memoization
+- [js-lists-flatlist-flashlist.md](./js-lists-flatlist-flashlist.md) - For list-specific optimizations
diff --git a/.forge/skills/react-native-best-practices/references/js-lists-flatlist-flashlist.md b/.forge/skills/react-native-best-practices/references/js-lists-flatlist-flashlist.md
new file mode 100644
index 00000000..8dd5267b
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-lists-flatlist-flashlist.md
@@ -0,0 +1,244 @@
+---
+title: Higher-Order Lists
+impact: CRITICAL
+tags: lists, flatlist, flashlist, scrollview, virtualization
+---
+
+# Skill: Higher-Order Lists
+
+Replace ScrollView with FlatList or FlashList for performant large list rendering.
+
+## Quick Pattern
+
+**Incorrect:**
+
+```jsx
+
+ {items.map((item) => )}
+
+```
+
+**Correct:**
+
+```jsx
+ item.id}
+ renderItem={({ item }) => }
+/>
+```
+
+## When to Use
+
+- Rendering more than 10-20 items in a list
+- List scrolling is choppy or laggy
+- App freezes when loading list data
+- Memory usage spikes with long lists
+
+## Prerequisites
+
+- `@shopify/flash-list` for FlashList (recommended)
+- Understanding of list virtualization
+
+## Version Guardrail
+
+- FlashList v1: `estimatedItemSize` is part of the optimization guidance.
+- FlashList v2 and newer: `estimatedItemSize`, `estimatedListSize`, and `estimatedFirstItemOffset` are deprecated and no longer used. Do not flag them as missing.
+- Before suggesting a FlashList fix, confirm the installed major version and tailor the advice. See [FlashList v2 changes](https://shopify.github.io/flash-list/docs/v2-changes/).
+
+## Step-by-Step Instructions
+
+### 1. Identify the Problem
+
+
+
+The FPS graph shows a severe performance problem during list rendering:
+- FPS starts at ~60 (smooth)
+- Drops to ~3 FPS during heavy list operation
+- Recovers after rendering completes
+
+```jsx
+// BAD: ScrollView renders ALL items at once
+const BadList = ({ items }) => (
+
+ {items.map((item, index) => (
+
+ {item}
+
+ ))}
+
+);
+```
+
+With 5000 items, this creates 5000 views immediately, causing:
+- Multi-second freeze
+- FPS drop to 0
+- High memory usage
+
+### 2. Replace with FlatList
+
+```jsx
+import { FlatList } from 'react-native';
+
+const BetterList = ({ items }) => {
+ const renderItem = ({ item }) => (
+
+ {item}
+
+ );
+
+ return (
+ index.toString()}
+ />
+ );
+};
+```
+
+FlatList only renders visible items + buffer (windowing).
+
+### 3. Optimize FlatList with getItemLayout
+
+For fixed-height items, skip layout measurement:
+
+```jsx
+const ITEM_HEIGHT = 50;
+
+const OptimizedList = ({ items }) => {
+ const renderItem = ({ item }) => (
+
+ {item}
+
+ );
+
+ const getItemLayout = (_, index) => ({
+ length: ITEM_HEIGHT,
+ offset: ITEM_HEIGHT * index,
+ index,
+ });
+
+ return (
+ index.toString()}
+ getItemLayout={getItemLayout}
+ />
+ );
+};
+```
+
+### 4. Upgrade to FlashList (Best Performance)
+
+```bash
+npm install @shopify/flash-list
+```
+
+```jsx
+import { FlashList } from '@shopify/flash-list';
+
+const BestList = ({ items }) => {
+ const renderItem = ({ item }) => (
+
+ {item}
+
+ );
+
+ return (
+ item.id}
+ />
+ );
+};
+```
+
+For FlashList v1, add `estimatedItemSize` with a realistic average item height. For FlashList v2+, skip that prop and focus on stable keys, lightweight item components, and `getItemType` when item shapes differ.
+
+**FlashList advantages:**
+- Recycles views instead of creating new ones
+- 78/100 vs 25/100 performance score in benchmarks
+- Smoother scrolling at ~54 FPS vs lower for FlatList
+
+## Code Examples
+
+### Variable Height Items (FlashList v1)
+
+```jsx
+// Calculate average for estimatedItemSize
+// Items are 50px, 100px, 150px
+// Average: (50 + 100 + 150) / 3 = 100px
+
+
+```
+
+### Mixed Item Types
+
+```jsx
+ {
+ if (item.type === 'header') return ;
+ if (item.type === 'product') return ;
+ return ;
+ }}
+ getItemType={(item) => item.type} // Helps recycling
+/>
+```
+
+If the project is still on FlashList v1, keep `estimatedItemSize` alongside `getItemType`.
+
+### FlatList Optimizations (if not using FlashList)
+
+```jsx
+ item.id}
+ extraData={selectedId} // Only when selection changes
+/>
+```
+
+## Performance Comparison
+
+| Component | 5000 Items Load | Scroll FPS | Memory |
+|-----------|-----------------|------------|--------|
+| ScrollView | 1-3 seconds freeze | < 30 | High |
+| FlatList | ~100ms | ~45 | Medium |
+| FlashList | ~50ms | ~54 | Low |
+
+## Decision Matrix
+
+| Scenario | Recommendation |
+|----------|---------------|
+| < 20 static items | ScrollView OK |
+| 20-100 items | FlatList minimum |
+| > 100 items | FlashList |
+| Complex item layouts | FlashList with `getItemType` |
+| Fixed height items | FlatList: `getItemLayout`; FlashList v1: `estimatedItemSize`; FlashList v2+: stable item structure |
+
+## Common Pitfalls
+
+- **Inline renderItem functions**: Causes re-renders. Define outside or use `useCallback`.
+- **Missing keyExtractor**: Use unique IDs, not array index when possible.
+- **Assuming all FlashList versions need `estimatedItemSize`**: FlashList v2 ignores it. Check the installed version before suggesting it.
+- **Heavy item components**: Keep list items light. Move side effects out.
+
+## Related Skills
+
+- [js-profile-react.md](./js-profile-react.md) - Profile list rendering
+- [js-measure-fps.md](./js-measure-fps.md) - Measure scroll performance
diff --git a/.forge/skills/react-native-best-practices/references/js-measure-fps.md b/.forge/skills/react-native-best-practices/references/js-measure-fps.md
new file mode 100644
index 00000000..dacf2acb
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-measure-fps.md
@@ -0,0 +1,178 @@
+---
+title: Measure JS FPS
+impact: HIGH
+tags: fps, performance, monitoring, flashlight
+---
+
+# Skill: Measure JS FPS
+
+Monitor and measure JavaScript frame rate to quantify app smoothness and identify performance regressions.
+
+## Quick Command
+
+```bash
+# Method 1: Built-in Perf Monitor
+# Shake device → Dev Menu → "Perf Monitor"
+
+# Method 2: Flashlight (Android, detailed reports)
+# Install Flashlight from an official, verified release channel first.
+flashlight measure
+```
+
+## When to Use
+
+- Animations feel choppy or janky
+- Scrolling is not smooth
+- Need baseline FPS metrics before/after optimization
+- Want to compare performance across builds
+
+## Prerequisites
+
+- React Native app running on device/simulator
+- For Flashlight: Android device (iOS not supported)
+
+> **Note**: This skill involves interpreting visual output (FPS graphs, performance overlays). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing metrics manually, or await MCP-based visual feedback integration (see roadmap).
+
+## Step-by-Step Instructions
+
+### Method 1: React Perf Monitor (Quick Check)
+
+1. Open Dev Menu:
+ - iOS Simulator: `Ctrl + Cmd + Z` or Device > Shake
+ - Android Emulator: `Cmd + M` (Mac) / `Ctrl + M` (Windows)
+
+2. Select **"Perf Monitor"**
+
+3. Observe the overlay showing:
+ - **UI (Main) thread FPS** - Native rendering
+ - **JS thread FPS** - JavaScript execution
+ - **RAM usage**
+
+4. Hide with "Hide Perf Monitor" from Dev Menu
+
+**Interpretation:**
+- **60 FPS** = Smooth (16.6ms per frame)
+- **< 60 FPS** = Dropping frames
+- **120 FPS** target for high refresh rate devices (8.3ms per frame)
+
+### Method 2: Flashlight (Automated Benchmarking)
+
+> Android only. Provides detailed reports and JSON export.
+
+
+
+Flashlight shows comparative performance data:
+- **Score** (0-100): Overall performance rating (higher is better)
+- **Average FPS**: Target 60 FPS for smooth scrolling
+- **FPS Graph**: Real-time frame rate over test duration
+- **CPU/RAM metrics**: Resource consumption
+
+The image shows FlatList (score: 3) vs FlashList (score: 67) - a dramatic difference visible in both the score and FPS graph.
+
+**Installation:**
+
+Install Flashlight from the vendor's official release channel before using it. Prefer a package manager or a version-pinned binary with checksum/signature verification. Do not pipe a remote install script directly into a shell.
+
+**Usage:**
+
+```bash
+# Start measuring (app must be running on Android)
+flashlight measure
+```
+
+**Features:**
+- Real-time FPS graph
+- Average FPS calculation
+- CPU and RAM metrics
+- Overall performance score
+- JSON export for CI comparison
+
+### Important: Disable Dev Mode
+
+**Always disable development mode for accurate measurements:**
+
+**Android:**
+1. Open Dev Menu
+2. Settings > JS Dev Mode → **OFF**
+
+**iOS (React Native CLI):**
+```bash
+# Run Metro in production mode
+npx react-native start --reset-cache
+# Then build release variant
+```
+
+**Expo:**
+```bash
+# Start Metro without dev mode
+npx expo start --no-dev --minify
+# For accurate measurements, use EAS Build for release testing
+```
+
+## Code Examples
+
+### Identify FPS Drop Source
+
+If **UI FPS drops but JS FPS is fine:**
+- Native rendering issue
+- Too many views/complex layouts
+- Heavy native animations
+
+If **JS FPS drops but UI FPS is fine:**
+- JavaScript computation blocking
+- Expensive React re-renders
+- Look for `longRunningFunction` patterns
+
+If **Both drop:**
+- Mixed issue, start with JS profiling
+
+### Target Frame Budgets
+
+```javascript
+// 60 FPS = 16.6ms per frame
+const FRAME_BUDGET_60 = 16.6;
+
+// 120 FPS = 8.3ms per frame
+const FRAME_BUDGET_120 = 8.3;
+
+// If your function takes longer, it will drop frames
+const longRunningFunction = () => {
+ let i = 0;
+ while (i < 1000000000) { // This blocks for seconds!
+ i++;
+ }
+};
+```
+
+## Interpreting Results
+
+| FPS Range | User Perception | Action |
+|-----------|-----------------|--------|
+| 55-60 | Smooth | Acceptable |
+| 45-55 | Slight stutter | Investigate |
+| 30-45 | Noticeable jank | Optimize required |
+| < 30 | Very choppy | Critical fix needed |
+
+## Flashlight CI Integration
+
+```bash
+# Export measurements to JSON
+flashlight measure --output results.json
+
+# Compare builds
+flashlight compare baseline.json current.json
+```
+
+## Common Pitfalls
+
+- **Measuring in dev mode**: Results will be artificially slow
+- **Not using real device**: Simulators don't reflect real performance
+- **Ignoring UI thread**: React Native has two threads - JS issues don't always show on UI thread
+- **Single measurement**: Run multiple times, FPS varies
+
+## Related Skills
+
+- [js-profile-react.md](./js-profile-react.md) - Find what's causing FPS drops
+- [js-animations-reanimated.md](./js-animations-reanimated.md) - Fix animation-related drops
+- [js-bottomsheet.md](./js-bottomsheet.md) - Measure bottom sheet gesture and snap performance
+- [js-lists-flatlist-flashlist.md](./js-lists-flatlist-flashlist.md) - Fix scroll-related drops
diff --git a/.forge/skills/react-native-best-practices/references/js-memory-leaks.md b/.forge/skills/react-native-best-practices/references/js-memory-leaks.md
new file mode 100644
index 00000000..d086e073
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-memory-leaks.md
@@ -0,0 +1,205 @@
+---
+title: Hunt JS Memory Leaks
+impact: MEDIUM
+tags: memory, leaks, profiling, cleanup
+---
+
+# Skill: Hunt JS Memory Leaks
+
+Find and fix JavaScript memory leaks using React Native DevTools memory profiling.
+
+## Quick Pattern
+
+**Incorrect (listener not cleaned up):**
+
+```jsx
+useEffect(() => {
+ const sub = EventEmitter.addListener('event', handler);
+ // Missing cleanup!
+}, []);
+```
+
+**Correct (proper cleanup):**
+
+```jsx
+useEffect(() => {
+ const sub = EventEmitter.addListener('event', handler);
+ return () => sub.remove();
+}, []);
+```
+
+## When to Use
+
+- App memory usage grows over time
+- App crashes after extended use
+- Navigating between screens increases memory
+- Suspecting event listeners or timers not cleaned up
+
+## Prerequisites
+
+- React Native DevTools accessible
+- App running in development mode
+
+## Step-by-Step Instructions
+
+### 1. Open Memory Profiler
+
+1. Launch React Native DevTools (press `j` in Metro)
+2. Go to **Memory** tab
+3. Select **"Allocation instrumentation on timeline"**
+
+### 2. Record Memory Allocations
+
+1. Click **"Start"** at the bottom
+2. Perform actions that might leak (navigate, trigger events, etc.)
+3. Wait 10-30 seconds
+4. Click **"Stop"**
+
+### 3. Analyze the Timeline
+
+**Key indicators:**
+- **Blue bars** = Memory allocated
+- **Gray bars** = Memory freed (garbage collected)
+- **Blue bars that stay blue** = Potential leak!
+
+### 4. Investigate Leaking Objects
+
+
+
+The Memory tab shows:
+- **Timeline** (top): Blue bars = allocations, select time range to filter
+- **Summary view** (bottom): Lists constructors with allocation counts
+
+**Key columns:**
+- **Constructor**: Object type (e.g., `JSObject`, `Function`, `(string)`)
+- **Count**: Number of instances (×85000 = 85,000 objects)
+- **Shallow Size**: Memory of the object itself
+- **Retained Size**: Memory freed if object is deleted (including references)
+
+**Red flag**: Large retained size % with small shallow size % = closures or references holding large objects.
+
+**To investigate:**
+1. Click on a blue spike in the timeline
+2. Look at the Constructor list below
+3. Check **Shallow size** vs **Retained size**
+4. Expand constructors to see individual allocations
+5. Click to see the exact source location
+
+### 5. Verify the Fix
+
+After fixing, re-profile. All bars should turn gray (except the most recent).
+
+## Code Examples
+
+### Common Leak Patterns
+
+**1. Listeners Not Cleaned Up:**
+
+```jsx
+// BAD: Memory leak
+const BadEventComponent = () => {
+ useEffect(() => {
+ const subscription = EventEmitter.addListener('myEvent', handleEvent);
+ // Missing cleanup!
+ }, []);
+
+ return Listening...;
+};
+
+// GOOD: Proper cleanup
+const GoodEventComponent = () => {
+ useEffect(() => {
+ const subscription = EventEmitter.addListener('myEvent', handleEvent);
+ return () => subscription.remove(); // Cleanup!
+ }, []);
+
+ return Listening...;
+};
+```
+
+**2. Timers Not Cleared:**
+
+```jsx
+// BAD: Memory leak
+const BadTimerComponent = () => {
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCount(prev => prev + 1);
+ }, 1000);
+ // Missing cleanup!
+ }, []);
+};
+
+// GOOD: Proper cleanup
+const GoodTimerComponent = () => {
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCount(prev => prev + 1);
+ }, 1000);
+ return () => clearInterval(timer); // Cleanup!
+ }, []);
+};
+```
+
+**3. Closures Capturing Large Objects:**
+
+```jsx
+// BAD: Closure captures entire array
+class BadClosureExample {
+ private largeData = new Array(1000000).fill('data');
+
+ createLeakyFunction() {
+ return () => this.largeData.length; // Captures this.largeData
+ }
+}
+
+// GOOD: Only capture what's needed
+class GoodClosureExample {
+ private largeData = new Array(1000000).fill('data');
+
+ createEfficientFunction() {
+ const length = this.largeData.length; // Extract value
+ return () => length; // Only captures primitive
+ }
+}
+```
+
+**4. Global Arrays Growing:**
+
+```jsx
+// BAD: Global array never cleared
+let leakyClosures = [];
+
+const createLeak = () => {
+ const data = generateLargeData();
+ leakyClosures.push(() => data); // Keeps growing!
+};
+
+// GOOD: Clear when done or use WeakRef
+const createNoLeak = () => {
+ const data = generateLargeData();
+ const closure = () => data;
+ // Use it and let it be garbage collected
+ return closure;
+};
+```
+
+## Memory Profiler Metrics
+
+| Metric | Meaning |
+|--------|---------|
+| **Shallow size** | Memory held by the object itself |
+| **Retained size** | Memory freed if object is deleted (includes references) |
+
+**Large retained size with small shallow size** = Object holding references to other large objects (common in closures).
+
+## Common Pitfalls
+
+- **Not forcing GC**: GC runs periodically. Allocate something else to trigger collection before concluding there's a leak.
+- **Ignoring gray bars**: Gray = properly collected. Only blue bars that persist are leaks.
+- **Missing useEffect cleanup**: Most common React Native leak source.
+
+## Related Skills
+
+- [native-memory-leaks.md](./native-memory-leaks.md) - Native-side memory leaks
+- [js-profile-react.md](./js-profile-react.md) - General profiling
diff --git a/.forge/skills/react-native-best-practices/references/js-profile-react.md b/.forge/skills/react-native-best-practices/references/js-profile-react.md
new file mode 100644
index 00000000..ec4edc50
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-profile-react.md
@@ -0,0 +1,174 @@
+---
+title: Profile React Performance
+impact: MEDIUM
+tags: profiling, devtools, re-renders, flamegraph
+---
+
+# Skill: Profile React Performance
+
+Identify unnecessary re-renders and performance bottlenecks in React Native apps using React Native DevTools.
+
+## Quick Command
+
+```bash
+# Open React Native DevTools (press 'j' in Metro terminal)
+# Or shake device → "Open DevTools"
+# Go to Profiler tab → Start profiling → Perform actions → Stop
+```
+
+For targeted audits, profile the exact flow under review. Baseline output should include commit timeline, re-render counts, slow components, and a breakdown of the heaviest commit.
+
+## When to Use
+
+- App feels sluggish or janky during interactions
+- Need to identify which components re-render unnecessarily
+- Investigating slow list scrolling or form inputs
+- Before applying memoization or state management changes
+
+## Prerequisites
+
+- React Native DevTools accessible (press `j` in Metro or use Dev Menu)
+- App running in development mode
+- React DevTools version 6.0.1+ for React Compiler support
+
+> **Note**: This skill involves interpreting visual profiler output (flame graphs, component highlighting). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the profiler UI manually, or await MCP-based visual feedback integration (see roadmap).
+
+## Step-by-Step Instructions
+
+### 1. Open React Native DevTools
+
+```bash
+# Option A: Press 'j' in Metro terminal (works with both RN CLI and Expo)
+# Option B: Shake device / Cmd+D (iOS) / Cmd+M (Android) → "Open DevTools"
+# Expo: Also accessible via Expo DevTools in browser
+```
+
+### 2. Configure Profiler Settings
+
+1. Go to **Profiler** tab
+2. Click gear icon (⚙️) for settings
+3. Enable:
+ - "Highlight updates when components render"
+ - "Record why each component rendered while profiling"
+
+### 3. Record a Profiling Session
+
+```
+1. Click "Start profiling" (blue circle) or "Reload and start profiling"
+2. Perform the exact interaction or navigation flow you want to analyze
+3. Click "Stop profiling"
+```
+
+**Use "Reload and start profiling"** for startup performance analysis.
+
+For AI-agent workflows, treat this as a required sequence:
+
+1. Start profiling.
+2. Drive the audited flow, not just app startup or idle state.
+3. Stop profiling.
+4. Inspect commit timeline, re-renders, slow components, and the heaviest commit before proposing fixes.
+
+### 4. Analyze the Flame Graph
+
+
+
+The flame graph shows component render hierarchy with timing:
+
+**Color indicators:**
+- **Yellow components**: Most time spent rendering (focus here)
+- **Green components**: Fast/memoized
+- **Gray components**: Did not render
+
+**Right panel shows "Why did this render?":**
+- Props changed (shows which prop, e.g., `children`, `onPress`)
+- Rendered at timestamps with duration (e.g., "3.7s for 0.9ms")
+
+**Click on a component to see:**
+- Why it rendered (hook change, props change, parent re-render)
+- Render duration
+- Child components affected
+
+### 5. Use Ranked View for Bottom-Up Analysis
+
+Click "Ranked" tab to see components sorted by render time (slowest first).
+
+### 6. Profile JavaScript CPU
+
+For non-React performance issues:
+
+1. Go to **JavaScript Profiler** tab (enable in settings if hidden)
+2. Click "Start" to record
+3. Perform actions
+4. Click "Stop"
+5. Use **Heavy (Bottom Up)** view to find slowest functions
+
+## Code Examples
+
+### Before: Unnecessary Re-renders
+
+```jsx
+const App = () => {
+ const [count, setCount] = useState(0);
+
+ return (
+
+ {count}
+ {/* Button re-renders on every count change */}
+
+ );
+};
+
+const Button = ({onPress, title}) => (
+
+ {title}
+
+);
+```
+
+### After: Memoized
+
+```jsx
+const App = () => {
+ const [count, setCount] = useState(0);
+ const onPressHandler = useCallback(() => setCount(c => c + 1), []);
+
+ return (
+
+ {count}
+
+
+ );
+};
+
+const Button = memo(({onPress, title}) => (
+
+ {title}
+
+));
+```
+
+## Interpreting Results
+
+| Symptom | Likely Cause | Solution |
+|---------|--------------|----------|
+| Many yellow components | Cascading re-renders | Add memoization or use React Compiler |
+| "Props changed" on callbacks | Inline functions recreated | Use `useCallback` |
+| "Parent component rendered" | State too high in tree | Move state down or use atomic state |
+| Long JS thread block | Heavy computation | Move to background or use `useDeferredValue` |
+
+Only propose callback or dependency-array changes when the profiler or a reproducible bug shows they matter. Do not infer stale closures from a snippet alone.
+
+## Common Pitfalls
+
+- **Profiling in dev mode**: Always disable JS Dev Mode for accurate measurements (Settings > JS Dev Mode on Android)
+- **Not using production builds**: Some issues only appear with minified code
+- **Ignoring "Why did this render?"**: This tells you exactly what to fix
+- **Using component tree depth or count as the main baseline**: These are secondary context, not the core performance signal
+
+## Related Skills
+
+- [js-react-compiler.md](./js-react-compiler.md) - Automatic memoization
+- [js-atomic-state.md](./js-atomic-state.md) - Reduce re-renders with Jotai/Zustand
+- [js-bottomsheet.md](./js-bottomsheet.md) - Profile bottom sheet callback-driven re-renders
+- [js-measure-fps.md](./js-measure-fps.md) - Quantify frame rate impact
diff --git a/.forge/skills/react-native-best-practices/references/js-react-compiler.md b/.forge/skills/react-native-best-practices/references/js-react-compiler.md
new file mode 100644
index 00000000..f7ccda0b
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-react-compiler.md
@@ -0,0 +1,368 @@
+---
+title: React Compiler
+impact: HIGH
+tags: memoization, react-compiler, memo, useMemo, useCallback
+---
+
+# Skill: React Compiler
+
+Set up React Compiler to automatically memoize components and eliminate unnecessary re-renders.
+
+## Quick Pattern
+
+**Before (manual memoization):**
+
+```jsx
+const MemoizedButton = memo(({ onPress }) => );
+const handler = useCallback(() => doSomething(), []);
+```
+
+**After (automatic with React Compiler):**
+
+```jsx
+// No memo/useCallback needed - compiler handles it
+const Button = ({ onPress }) => ;
+const handler = () => doSomething();
+```
+
+## When to Use
+
+- Want automatic performance optimization without manual `memo`/`useMemo`/`useCallback`
+- Codebase follows Rules of React
+- React Native 0.76+ or Expo SDK 52+
+- Ready to remove boilerplate memoization code
+
+## Prerequisites
+
+- React 17+ (React 19 recommended for best compatibility)
+- Babel-based build system
+- Code follows [Rules of React](https://react.dev/reference/rules)
+
+## Step-by-Step Instructions
+
+### Step 1: Check Compatibility
+
+Before enabling the compiler, verify your project is compatible:
+
+```bash
+npx react-compiler-healthcheck@latest
+```
+
+This checks if your app follows the Rules of React and identifies potential issues.
+
+### Step 2: Install React Compiler
+
+#### Expo Projects
+
+**SDK 54 and later** (simplified setup):
+
+```bash
+npx expo install babel-plugin-react-compiler
+```
+
+**SDK 52-53**:
+
+```bash
+npx expo install babel-plugin-react-compiler@beta react-compiler-runtime@beta
+```
+
+Then enable in your app config:
+
+```json
+// app.json
+{
+ "expo": {
+ "experiments": {
+ "reactCompiler": true
+ }
+ }
+}
+```
+
+#### React Native (without Expo)
+
+```bash
+npm install -D babel-plugin-react-compiler@latest
+```
+
+For React Native < 0.78 (React < 19), also install the runtime:
+
+```bash
+npm install react-compiler-runtime@beta
+```
+
+### Step 3: Configure Babel (React Native without Expo)
+
+For non-Expo React Native projects, configure Babel manually:
+
+```javascript
+// babel.config.js
+const ReactCompilerConfig = {
+ target: '19', // Use '18' for React Native < 0.78
+};
+
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['module:@react-native/babel-preset'],
+ plugins: [
+ ['babel-plugin-react-compiler', ReactCompilerConfig], // Must run first!
+ // ... other plugins
+ ],
+ };
+};
+```
+
+> **Important**: React Compiler must run **first** in your Babel plugin pipeline. The compiler needs the original source information for proper analysis.
+
+### Step 4: Set Up ESLint (Recommended)
+
+The ESLint plugin helps identify code that can't be optimized and enforces the Rules of React.
+
+#### Expo Projects
+
+```bash
+npx expo lint # Ensures ESLint is set up
+npx expo install eslint-plugin-react-compiler -- -D
+```
+
+Configure ESLint:
+
+```javascript
+// .eslintrc.js
+const { defineConfig } = require('eslint/config');
+const expoConfig = require('eslint-config-expo/flat');
+const reactCompiler = require('eslint-plugin-react-compiler');
+
+module.exports = defineConfig([
+ expoConfig,
+ reactCompiler.configs.recommended,
+ {
+ ignores: ['dist/*'],
+ },
+]);
+```
+
+#### React Native (without Expo)
+
+```bash
+npm install -D eslint-plugin-react-hooks@latest
+```
+
+The compiler rules are available in the `recommended-latest` preset. Follow the [eslint-plugin-react-hooks installation instructions](https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks).
+
+### Step 5: Verify Optimizations
+
+Open React DevTools. Optimized components show a `Memo ✨` badge.
+
+You can also verify by checking build output—compiled code includes automatic memoization:
+
+```javascript
+import { c as _c } from 'react/compiler-runtime';
+
+export default function MyApp() {
+ const $ = _c(1);
+ let t0;
+ if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
+ t0 = Hello World
;
+ $[0] = t0;
+ } else {
+ t0 = $[0];
+ }
+ return t0;
+}
+```
+
+**Note**: React Native 0.76+ includes DevTools with Memo badge support by default. For older versions or third-party debuggers with version mismatches, you may need to override `react-devtools-core` in `package.json`.
+
+## Incremental Adoption
+
+You can incrementally adopt React Compiler using two strategies:
+
+### Strategy 1: Limit to Specific Directories
+
+Configure the Babel plugin to only run on specific files, e.g. `src/path/to/dir` in the following examples:
+
+**Expo** (create `babel.config.js` with `npx expo customize babel.config.js`):
+
+```javascript
+// babel.config.js
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: [
+ [
+ 'babel-preset-expo',
+ {
+ 'react-compiler': {
+ sources: (filename) => {
+ return filename.includes('src/path/to/dir');
+ },
+ },
+ },
+ ],
+ ],
+ };
+};
+```
+
+**React Native (without Expo)**:
+
+```javascript
+// babel.config.js
+const ReactCompilerConfig = {
+ target: '19',
+ sources: (filename) => {
+ return filename.includes('src/path/to/dir');
+ },
+};
+
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['module:@react-native/babel-preset'],
+ plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
+ };
+};
+```
+
+After changing `babel.config.js`, restart Metro with cache cleared:
+
+```bash
+# Expo
+npx expo start --clear
+
+# React Native CLI
+npx react-native start --reset-cache
+```
+
+### Strategy 2: Opt Out Specific Components
+
+Use the `"use no memo"` directive to skip optimization for specific components or files:
+
+```jsx
+function ProblematicComponent() {
+ 'use no memo';
+
+ return Will not be optimized;
+}
+```
+
+This is useful for temporarily opting out components that cause issues. Fix the underlying problem and remove the directive once resolved.
+
+## How It Works
+
+The compiler transforms your code to automatically cache values:
+
+**Before (your code):**
+
+```jsx
+export default function MyApp() {
+ const [value, setValue] = useState('');
+ return (
+ setValue(value)}>Hello World
+ );
+}
+```
+
+**After (compiled output):**
+
+```jsx
+import { c as _c } from 'react/compiler-runtime';
+
+export default function MyApp() {
+ const $ = _c(2); // Cache with 2 slots
+ const [value, setValue] = useState('');
+
+ let t0;
+ if ($[0] !== value) {
+ t0 = (
+ setValue(value)}>Hello World
+ );
+ $[0] = value;
+ $[1] = t0;
+ } else {
+ t0 = $[1]; // Return cached JSX
+ }
+ return t0;
+}
+```
+
+## Code Examples
+
+### React Compiler Playground
+
+Test transformations at [React Playground](https://playground.react.dev/).
+
+### What Gets Optimized
+
+```jsx
+// Components - auto-memoized
+const Button = ({ onPress, label }) => (
+
+ {label}
+
+);
+
+// Callbacks - auto-cached (no useCallback needed)
+const handlePress = () => {
+ console.log('pressed');
+};
+
+// Expensive computations - auto-cached (no useMemo needed)
+const filtered = items.filter((item) => item.active);
+```
+
+### What Breaks Compilation
+
+```jsx
+// BAD: Mutating props
+const BadComponent = ({ items }) => {
+ items.push('new item'); // Mutation!
+ return
;
+};
+
+// BAD: Mutating during render
+const BadMutation = () => {
+ const [items, setItems] = useState([]);
+ items.push('new'); // Mutation during render!
+ return
;
+};
+
+// BAD: Non-idempotent render
+let counter = 0;
+const BadRender = () => {
+ counter++; // Side effect during render!
+ return {counter};
+};
+```
+
+## Should You Remove Manual Memoization?
+
+Improvements are primarily automatic. You can remove instances of `useCallback`, `useMemo`, and `React.memo` in favor of automatic memoization once the compiler is working correctly in your project.
+
+**Note**: Class components will not be optimized. Migrate to function components for full benefits.
+
+Expo's implementation only runs on application code (not node_modules), and only when bundling for the client (disabled in server rendering).
+
+## Expected Performance Improvements
+
+Testing on Expensify app showed:
+
+- **4.3% improvement** in Chat Finder TTI
+- Significant reduction in cascading re-renders
+- Most impact on apps without existing manual optimization
+
+Already heavily optimized apps may see marginal gains.
+
+## Common Pitfalls
+
+- **Not fixing ESLint errors first**: When ESLint reports an error, the compiler skips that component—this is safe but means you miss optimization
+- **Expecting it to fix bad patterns**: Compiler optimizes good code, doesn't fix bad code
+- **Forgetting shallow comparison**: Like `memo`, compiler uses shallow comparison for objects/arrays
+- **Not running healthcheck**: Always run `npx react-compiler-healthcheck@latest` before enabling
+
+## Related Skills
+
+- [js-profile-react.md](./js-profile-react.md) - Verify optimization impact
+- [js-atomic-state.md](./js-atomic-state.md) - Alternative for state-related re-renders
diff --git a/.forge/skills/react-native-best-practices/references/js-uncontrolled-components.md b/.forge/skills/react-native-best-practices/references/js-uncontrolled-components.md
new file mode 100644
index 00000000..d2a2f81e
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/js-uncontrolled-components.md
@@ -0,0 +1,216 @@
+---
+title: Uncontrolled Components
+impact: HIGH
+tags: textinput, forms, controlled, uncontrolled
+---
+
+# Skill: Uncontrolled Components
+
+Fix TextInput synchronization and flickering issues by using uncontrolled component pattern.
+
+## Quick Pattern
+
+**Before (controlled - may flicker on legacy arch):**
+
+```jsx
+
+```
+
+**After (uncontrolled - native owns state):**
+
+```jsx
+
+```
+
+## When to Use
+
+- TextInput flickers or shows wrong characters during fast typing
+- Text input lags behind user input on low-end devices
+- Using legacy (non-New Architecture) React Native
+- Need maximum input responsiveness
+
+## Prerequisites
+
+- Understanding of React controlled vs uncontrolled components
+- TextInput component in use
+
+## Problem Description
+
+
+
+The diagram shows what happens when typing "TEST" with a controlled `TextInput`:
+
+1. User types "T" → `onChangeText('T')` fires
+2. React calls `setValue('T')` → native updates to "T"
+3. User types "E" → `onChangeText('TE')` fires
+4. React calls `setValue('TE')` → native updates to "TE"
+5. ...continues for each character
+
+**The problem**: Each character requires a round-trip between native and JavaScript. On legacy architecture, if React state update is slow, native may show intermediate states (flicker).
+
+**New Architecture note:** This issue is largely resolved in New Architecture, but uncontrolled pattern still provides best performance.
+
+## Step-by-Step Instructions
+
+### 1. Identify Controlled TextInput
+
+```jsx
+// Controlled - value prop syncs state to native
+const ControlledInput = () => {
+ const [value, setValue] = useState('');
+
+ return (
+
+ );
+};
+```
+
+### 2. Convert to Uncontrolled
+
+Remove the `value` prop to make it uncontrolled:
+
+```jsx
+// Uncontrolled - native owns the state
+const UncontrolledInput = () => {
+ const [value, setValue] = useState('');
+
+ return (
+
+ );
+};
+```
+
+### 3. Use Ref for Programmatic Control
+
+If you need to read/set value programmatically:
+
+```jsx
+const UncontrolledWithRef = () => {
+ const inputRef = useRef(null);
+
+ const clearInput = () => {
+ inputRef.current?.clear();
+ };
+
+ const getValue = () => {
+ // Use onChangeText to track value, or native methods
+ };
+
+ return (
+ console.log('Current:', text)}
+ />
+ );
+};
+```
+
+## Code Examples
+
+### Full Migration Example
+
+**Before (Controlled):**
+
+```jsx
+const SearchInput = () => {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+
+ const handleChange = (text) => {
+ setQuery(text);
+ fetchResults(text).then(setResults);
+ };
+
+ return (
+
+
+
+
+ );
+};
+```
+
+**After (Uncontrolled):**
+
+```jsx
+const SearchInput = () => {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+
+ const handleChange = (text) => {
+ setQuery(text);
+ fetchResults(text).then(setResults);
+ };
+
+ return (
+
+
+
+
+ );
+};
+```
+
+### When You Need Value Control
+
+For input masking or validation that modifies input:
+
+```jsx
+// Option 1: Accept the controlled behavior (may flicker)
+const MaskedInput = () => {
+ const [value, setValue] = useState('');
+
+ const handleChange = (text) => {
+ // Phone mask: (123) 456-7890
+ const masked = maskPhone(text);
+ setValue(masked);
+ };
+
+ return (
+
+ );
+};
+
+// Option 2: Use a native masked input library
+// react-native-masked-text handles this natively
+```
+
+## Decision Matrix
+
+| Scenario | Recommendation |
+|----------|---------------|
+| Simple text input | Uncontrolled |
+| Search/filter input | Uncontrolled |
+| Form with validation on submit | Uncontrolled |
+| Input masking (phone, credit card) | Controlled or native library |
+| Character-by-character validation | Controlled |
+| New Architecture app | Either works well |
+
+## Common Pitfalls
+
+- **Forgetting `defaultValue`**: Without it, input starts empty
+- **Trying to clear with state**: Use `ref.current.clear()` instead
+- **Mixing patterns**: Don't use both `value` and `defaultValue`
+
+## Related Skills
+
+- [js-profile-react.md](./js-profile-react.md) - Profile input performance
+- [js-concurrent-react.md](./js-concurrent-react.md) - Defer expensive search operations
diff --git a/.forge/skills/react-native-best-practices/references/native-android-16kb-alignment.md b/.forge/skills/react-native-best-practices/references/native-android-16kb-alignment.md
new file mode 100644
index 00000000..58ca1b26
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-android-16kb-alignment.md
@@ -0,0 +1,113 @@
+---
+title: Android 16 KB Page Size Alignment
+impact: CRITICAL
+tags: android, native, 16kb, alignment, page-size, google-play, third-party
+---
+
+# Android 16 KB page size alignment
+
+---
+
+## Quick Reference
+
+| Item | Details |
+| ---------------------- | ---------------------------------------------------- |
+| Google Play deadline | November 1, 2025 for apps targeting Android 15+ |
+| React Native support | Built-in since React Native 0.79 |
+| What to check | Third-party native libraries (`.so` files) |
+| Official documentation | [developer.android.com/guide/practices/page-sizes][] |
+
+[developer.android.com/guide/practices/page-sizes]: https://developer.android.com/guide/practices/page-sizes
+
+---
+
+## Quick Command
+
+Verify APK alignment using Android's official `zipalign` tool:
+
+```bash
+zipalign -c -P 16 -v 4 app-release.apk
+```
+
+If any 64-bit libraries (`arm64-v8a`, `x86_64`) show misalignment, they need updating.
+
+For deeper ELF-level inspection, use Android's [check_elf_alignment.sh][] script.
+
+[check_elf_alignment.sh]: https://cs.android.com/android/platform/superproject/main/+/main:system/extras/tools/check_elf_alignment.sh
+
+---
+
+## When to Check
+
+React Native 0.79+ builds core binaries with correct alignment. However, **third-party
+native libraries** may still be misaligned. Check alignment when:
+
+* Adding or updating SDKs with native code
+* Preparing a release for Google Play
+* Investigating crashes on Android 15+ devices with 16 KB page size
+
+---
+
+## CI Integration
+
+Add alignment check to your release pipeline to catch issues before submission, example:
+
+```bash
+zipalign -c -P 16 -v 4 app-release.apk 2>&1 | tee alignment.log
+if grep -q "Verification FAILED" alignment.log; then exit 1; fi
+```
+
+## Step-by-Step
+
+1. Build your release APK or AAB
+2. Run `zipalign` verification (see Quick Command)
+3. If misaligned libraries are found, trace them to source packages (see below)
+4. Update, replace, or remove the affected dependencies
+
+For runtime testing, use the [16KB Android Emulator image][] or enable
+"Boot with 16KB page size" on Pixel 8/8a/9 devices.
+
+[16KB Android Emulator image]: https://developer.android.com/guide/practices/page-sizes#set-up-the-android-emulator-with-a-16-kb-based-system-image
+
+---
+
+## Tracing Misaligned Libraries
+
+When `zipalign` reports a misaligned library like `libfoo.so`, find its source package:
+
+```bash
+# Find the .so file in node_modules
+find node_modules -name "libfoo.so" 2>/dev/null
+
+# Or search gradle files for references
+grep -r "foo" node_modules/*/android --include="*.gradle" 2>/dev/null
+```
+
+Once identified, update the dependency or contact the vendor for a 16KB-compatible build.
+
+---
+
+## Common Pitfalls
+
+* Waiting for Play Store rejection instead of checking in CI
+* Assuming a React Native upgrade rebuilds third-party native binaries
+* Only checking 32-bit ABIs (`armeabi-v7a`, `x86`) — these are not affected
+* Using `zipalign` without the `-P 16` flag (checks 4 KB, not 16 KB)
+* Validating only debug builds
+
+---
+
+## Fixing Alignment Issues
+
+Alignment issues require **rebuilding** the native library with a compatible toolchain.
+Repackaging alone does not fix them.
+
+See [official remediation steps][] for detailed guidance.
+
+[official remediation steps]: https://developer.android.com/guide/practices/page-sizes#build-app-16kb
+
+---
+
+## Related Skills
+
+* [native-profiling.md](./native-profiling.md) — Native debugging tools
diff --git a/.forge/skills/react-native-best-practices/references/native-measure-tti.md b/.forge/skills/react-native-best-practices/references/native-measure-tti.md
new file mode 100644
index 00000000..a3981c20
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-measure-tti.md
@@ -0,0 +1,262 @@
+---
+title: Measure TTI (Time to Interactive)
+impact: HIGH
+tags: tti, startup, performance, markers
+---
+
+# Skill: Measure TTI (Time to Interactive)
+
+Set up performance markers to measure app startup time and track TTI improvements.
+
+## Quick Command
+
+```bash
+npm install react-native-performance
+```
+
+```tsx
+// Mark when screen is interactive
+import performance from 'react-native-performance';
+
+useEffect(() => {
+ performance.mark('screenInteractive');
+}, []);
+```
+
+## When to Use
+
+- App startup feels slow
+- Need baseline metrics for optimization
+- Setting up performance monitoring
+- Comparing TTI across releases
+
+## Prerequisites
+
+- `react-native-performance` library (recommended)
+
+```bash
+npm install react-native-performance
+```
+
+> **Note**: This skill involves interpreting visual timeline diagrams and profiler output. AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing metrics manually, or await MCP-based visual feedback integration (see roadmap).
+
+## Understanding TTI
+
+**Time to Interactive**: Time from app icon tap to displaying usable content.
+
+### Startup Types
+
+| Type | Description | Measure? |
+|------|-------------|----------|
+| Cold | App not in memory, full init | ✅ Yes |
+| Warm | Process exists, activity recreated | ❌ Skip |
+| Hot | App in background, resumed | ❌ Skip |
+| Prewarmed (iOS) | iOS pre-initialized app | ❌ Filter out |
+
+**Only measure cold starts** for consistent metrics.
+
+## React Native Startup Pipeline
+
+
+
+The diagram shows a warm start (app was in memory):
+
+**UI Thread:**
+1. `init native process` → `init native app`
+2. Gap while user is away (e.g., "5h break from using the app")
+3. `JS bundle load` → `RootView render`
+
+**JS Thread (runs in parallel):**
+- `init entrypoint` → `registerComponent`
+
+**Pipeline markers:**
+```
+1. Native Process Init (nativeLaunchStart → nativeLaunchEnd)
+2. Native App Init (appCreationStart → appCreationEnd)
+3. JS Bundle Load (runJSBundleStart → runJSBundleEnd)
+4. RN Root View Render (contentAppeared)
+5. React App Interactive (screenInteractive) ← This is TTI
+```
+
+## Step-by-Step Implementation
+
+### 1. Detect Cold Start
+
+**iOS (Swift):**
+
+```swift
+let isColdStart = ProcessInfo.processInfo.environment["ActivePrewarm"] != "1"
+```
+
+**Android (Kotlin):**
+
+```kotlin
+class MainApplication : Application() {
+ var isColdStart = false
+
+ override fun onCreate() {
+ super.onCreate()
+
+ var firstPostEnqueued = true
+ Handler().post { firstPostEnqueued = false }
+
+ registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ unregisterActivityLifecycleCallbacks(this)
+ if (firstPostEnqueued && savedInstanceState == null) {
+ isColdStart = true
+ }
+ }
+ // ... other callbacks
+ })
+ }
+}
+```
+
+### 2. Check Foreground State
+
+Only measure when app starts in foreground.
+
+**iOS:**
+
+```swift
+var isForegroundProcess = false
+
+override func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ if application.applicationState == .active {
+ isForegroundProcess = true
+ }
+ return true
+}
+```
+
+**Android:**
+
+```kotlin
+private fun isForegroundProcess(): Boolean {
+ val processInfo = ActivityManager.RunningAppProcessInfo()
+ ActivityManager.getMyMemoryState(processInfo)
+ return processInfo.importance == IMPORTANCE_FOREGROUND
+}
+```
+
+### 3. Set Up Performance Markers
+
+Using `react-native-performance`:
+
+**Native (iOS):**
+
+```swift
+import ReactNativePerformance
+
+RNPerformance.sharedInstance().mark("appCreationStart")
+// ... app init ...
+RNPerformance.sharedInstance().mark("appCreationEnd")
+```
+
+**Native (Android):**
+
+```kotlin
+import com.oblador.performance.RNPerformance
+
+RNPerformance.getInstance().mark("appCreationStart")
+// ... app init ...
+RNPerformance.getInstance().mark("appCreationEnd")
+```
+
+### 4. Mark Screen Interactive (JavaScript)
+
+```tsx
+import performance from 'react-native-performance';
+
+export default function HomeScreen() {
+ useEffect(() => {
+ // Mark when meaningful content is displayed
+ performance.mark('screenInteractive');
+ }, []);
+
+ return ;
+}
+```
+
+### 5. Collect and Report Metrics
+
+```tsx
+import performance from 'react-native-performance';
+
+const collectTTIMetrics = () => {
+ const entries = performance.getEntriesByType('mark');
+
+ // Calculate durations
+ const metrics = {
+ nativeInit: getMarkDuration('nativeLaunchStart', 'nativeLaunchEnd'),
+ appCreation: getMarkDuration('appCreationStart', 'appCreationEnd'),
+ jsBundleLoad: getMarkDuration('runJSBundleStart', 'runJSBundleEnd'),
+ tti: getMarkDuration('nativeLaunchStart', 'screenInteractive'),
+ };
+
+ // Send to analytics
+ analytics.track('app_performance', metrics);
+};
+```
+
+## Built-in Markers
+
+`react-native-performance` provides automatic markers:
+
+| Marker | Description |
+|--------|-------------|
+| `nativeLaunchStart` | Process start (pre-main) |
+| `nativeLaunchEnd` | Native init complete |
+| `runJSBundleStart` | JS bundle loading starts |
+| `runJSBundleEnd` | JS bundle loaded |
+| `contentAppeared` | RN root view rendered |
+
+## Listening to Native Events
+
+**iOS (JS Bundle Load):**
+
+```swift
+NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(onJSLoad),
+ name: NSNotification.Name("RCTJavaScriptDidLoadNotification"),
+ object: nil
+)
+```
+
+**Android (JS Bundle Load):**
+
+```kotlin
+ReactMarker.addListener { name ->
+ when (name) {
+ RUN_JS_BUNDLE_START -> { /* mark start */ }
+ RUN_JS_BUNDLE_END -> { /* mark end */ }
+ CONTENT_APPEARED -> { /* mark content */ }
+ }
+}
+```
+
+## Target Metrics
+
+| Metric | Good | Acceptable | Needs Work |
+|--------|------|------------|------------|
+| TTI | < 2s | 2-4s | > 4s |
+| JS Bundle Load | < 500ms | 500ms-1s | > 1s |
+| Native Init | < 500ms | 500ms-1s | > 1s |
+
+**Note**: Targets vary by app complexity and device tier.
+
+## Common Pitfalls
+
+- **Including prewarmed starts**: iOS prewarming skews metrics
+- **Measuring warm/hot starts**: Only cold starts are meaningful
+- **Wrong screenInteractive placement**: Mark when truly interactive, not just mounted
+- **Not filtering background launches**: Push notifications can start app in background
+
+## Related Skills
+
+- [bundle-analyze-js.md](./bundle-analyze-js.md) - Reduce JS bundle load time
+- [native-profiling.md](./native-profiling.md) - Profile native init
+- [bundle-hermes-mmap.md](./bundle-hermes-mmap.md) - Improve Android TTI
diff --git a/.forge/skills/react-native-best-practices/references/native-memory-leaks.md b/.forge/skills/react-native-best-practices/references/native-memory-leaks.md
new file mode 100644
index 00000000..a946afb9
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-memory-leaks.md
@@ -0,0 +1,240 @@
+---
+title: Hunt Native Memory Leaks
+impact: MEDIUM
+tags: memory, leaks, xcode, instruments, profiler
+---
+
+# Skill: Hunt Native Memory Leaks
+
+Find native memory leaks using Xcode Leaks and Android Studio Memory Profiler.
+
+## Quick Command
+
+```bash
+# iOS: Profile with Leaks instrument
+# Xcode → Product → Profile (Cmd+I) → Leaks template
+
+# Android: Memory Profiler
+# Android Studio → Run → Profile → Track Memory Consumption
+```
+
+## When to Use
+
+- App memory grows despite JS profiler showing no leaks
+- Native modules suspected of leaking
+- Activity recreation causes memory growth (Android)
+- C++/Swift/Kotlin code under investigation
+
+## iOS: Xcode Leaks
+
+### Quick Check: Memory Report
+
+1. Run app via Xcode
+2. Open **Debug Navigator** (side panel)
+3. Click **Memory**
+4. Watch graph for continuous growth
+
+### Deep Analysis: Instruments Leaks
+
+
+
+1. **Xcode → Product → Profile** (or Cmd+I)
+2. Select **Leaks** template (highlighted with orange triangle icon in the grid)
+3. Click **Choose**
+4. Click **Record** (red circle)
+5. Use the app, perform suspect actions
+6. Stop recording
+
+The template picker shows all available Instruments:
+- **Leaks**: Memory leak detection (what we need)
+- **Allocations**: All memory allocations over time
+- **Time Profiler**: CPU usage profiling
+- **Zombies**: Detect messages to deallocated objects
+
+### Analyzing Results
+
+**Red markers** = Leaked memory detected
+
+Click on leak to see:
+- **Leaked Object**: Type and size
+- **Responsible Library**: Which code leaked
+- **Responsible Frame**: Exact function
+- **Stack Trace**: Full call path (right panel)
+
+**Double-click function** to see source code.
+
+### Common iOS Leak: Missing delete
+
+```cpp
+// BAD: Memory leak
+void createNewStrings() {
+ std::string* str = new std::string("Hello");
+ // Forgot delete str;
+}
+
+// GOOD: Fixed
+void createNewStrings() {
+ std::string* str = new std::string("Hello");
+ // ... use str ...
+ delete str;
+}
+
+// BETTER: Use smart pointers
+void createNewStrings() {
+ auto str = std::make_unique("Hello");
+ // Automatically deleted
+}
+```
+
+## Android: Memory Profiler
+
+### Launch Profiler
+
+1. **Run → Profile** (or click Profile in toolbar)
+2. Or: **View → Tool Windows → Profiler**
+3. Select **"Track Memory Consumption"**
+
+### Recording
+
+1. Start the app
+2. Perform actions that might leak
+3. Watch memory graph for growth patterns
+
+### Analyzing Allocations
+
+Memory profiler shows:
+- **Allocations count**: Objects created
+- **Deallocations count**: Objects freed
+- **Live objects**: Still in memory
+
+**If allocations >> deallocations**, you have a leak.
+
+### Common Android Leak: Listener Not Removed
+
+```kotlin
+// BAD: Leaks MainActivity on config change
+class MainActivity : AppCompatActivity(), Callback {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ EventManager.addListener(this)
+ // Never removed!
+ }
+}
+
+// GOOD: Remove listener
+class MainActivity : AppCompatActivity(), Callback {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ EventManager.addListener(this)
+ }
+
+ override fun onDestroy() {
+ EventManager.removeListener(this)
+ super.onDestroy()
+ }
+}
+```
+
+### Activity Recreation Test
+
+Android recreates activities on:
+- Screen rotation
+- Dark mode change
+- Locale change
+
+**Test**: Rotate device multiple times, check if old activities are freed.
+
+React Native note: RN opts out via `android:configChanges` in manifest, but native code might not.
+
+## Debugging Workflow
+
+### iOS
+
+1. Profile with Instruments Leaks
+2. Trigger suspect actions repeatedly
+3. Wait for red leak markers
+4. Click to identify responsible frame
+5. Fix and re-test
+
+### Android
+
+1. Profile memory consumption
+2. Trigger suspect actions (rotate, navigate)
+3. Check allocation/deallocation counts
+4. Look for classes with 0 deallocations
+5. Fix and re-test
+
+## Code Fixes by Pattern
+
+### Reference Cycle (Swift)
+
+```swift
+// BAD
+class Parent {
+ var child: Child?
+}
+class Child {
+ var parent: Parent? // Strong reference cycle
+}
+
+// GOOD
+class Parent {
+ var child: Child?
+}
+class Child {
+ weak var parent: Parent? // Weak breaks cycle
+}
+```
+
+### Missing Cleanup (C++)
+
+```cpp
+// BAD
+void process() {
+ auto* data = new LargeData();
+ if (error) return; // Leak!
+ delete data;
+}
+
+// GOOD: RAII with unique_ptr
+void process() {
+ auto data = std::make_unique();
+ if (error) return; // Automatically cleaned up
+}
+```
+
+### Global Singleton Holding References (Kotlin)
+
+```kotlin
+// BAD: Holds strong references
+object Cache {
+ private val items = mutableMapOf()
+}
+
+// GOOD: Use weak references
+object Cache {
+ private val items = mutableMapOf>()
+}
+```
+
+## Verification
+
+After fixing:
+1. Re-run profiler
+2. Perform same actions
+3. Verify:
+ - iOS: No red leak markers
+ - Android: Allocations ≈ Deallocations
+
+## Common Pitfalls
+
+- **Testing in debug mode**: Some leaks only appear in release
+- **Not waiting for GC**: Force GC before concluding no leak
+- **Ignoring small leaks**: They add up over time
+- **Missing cleanup in invalidate()**: Turbo Modules need proper cleanup
+
+## Related Skills
+
+- [native-memory-patterns.md](./native-memory-patterns.md) - Understanding memory patterns
+- [js-memory-leaks.md](./js-memory-leaks.md) - JS-side leaks
+- [native-threading-model.md](./native-threading-model.md) - Module invalidation
diff --git a/.forge/skills/react-native-best-practices/references/native-memory-patterns.md b/.forge/skills/react-native-best-practices/references/native-memory-patterns.md
new file mode 100644
index 00000000..2505b8fa
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-memory-patterns.md
@@ -0,0 +1,274 @@
+---
+title: Native Memory Management
+impact: MEDIUM
+tags: memory, c++, swift, kotlin, arc, smart-pointers
+---
+
+# Skill: Native Memory Management
+
+Understand memory management patterns in C++, Swift, and Kotlin for React Native native modules.
+
+## Quick Reference
+
+| Pattern | Languages | Mechanism |
+|---------|-----------|-----------|
+| Reference Counting | Swift, Obj-C, C++ (smart ptrs) | Count refs, free at zero |
+| Garbage Collection | Kotlin/Java, JavaScript | GC scans and frees unreachable |
+| Manual | C, C++ (raw pointers) | Explicit new/delete |
+
+**Key rule**: Use `std::unique_ptr`/`std::shared_ptr` in C++, `weak` for delegates in Swift.
+
+## When to Use
+
+- Writing native modules with manual memory management
+- Debugging native memory leaks
+- Interfacing C++ with Swift/Kotlin
+- Understanding reference counting vs garbage collection
+
+## Memory Management Patterns
+
+| Pattern | Languages | Mechanism |
+|---------|-----------|-----------|
+| Reference Counting | Swift, Obj-C, C++ (smart pointers) | Count refs, free at zero |
+| Garbage Collection | Kotlin/Java, JavaScript | GC scans and frees unreachable |
+| Manual | C, C++ (raw pointers) | Explicit new/delete |
+
+## C++ Smart Pointers
+
+### `std::unique_ptr` - Single Owner
+
+```cpp
+#include
+
+void takeOwnership(std::unique_ptr s) {
+ std::cout << *s;
+ // Automatically deleted when function ends
+}
+
+int main() {
+ auto str = std::make_unique("Hello");
+
+ // Can only be moved, not copied
+ takeOwnership(std::move(str));
+ // str is now empty
+
+ return 0;
+}
+```
+
+### `std::shared_ptr` - Multiple Owners
+
+```cpp
+void useShared(std::shared_ptr s) {
+ std::cout << *s; // Reference count temporarily +1
+}
+
+void useReference(const std::shared_ptr& s) {
+ std::cout << *s; // No ref count change (passed by reference)
+}
+
+int main() {
+ auto str = std::make_shared("Hello");
+
+ useShared(str); // Copies pointer, ref count +1
+ useReference(str); // No copy, ref count unchanged
+
+ std::cout << *str; // Still valid
+ return 0;
+}
+```
+
+### `std::weak_ptr` - Non-Owning Reference
+
+```cpp
+void useWeak(std::weak_ptr weak) {
+ if (auto shared = weak.lock()) { // Check if still exists
+ std::cout << *shared;
+ } else {
+ std::cout << "Object destroyed";
+ }
+}
+
+int main() {
+ auto str = std::make_shared("Hello");
+ std::weak_ptr weak = str; // No ref count increase
+
+ useWeak(weak); // Works
+ str.reset(); // Destroys object
+ useWeak(weak); // "Object destroyed"
+
+ return 0;
+}
+```
+
+## Swift ARC (Automatic Reference Counting)
+
+```swift
+class Person {
+ let name: String
+ init(name: String) { self.name = name }
+ deinit { print("Deallocated") }
+}
+
+do {
+ let person1 = Person(name: "John") // Ref count: 1
+
+ do {
+ let person2 = person1 // Ref count: 2
+ } // person2 out of scope, ref count: 1
+
+} // person1 out of scope, ref count: 0, "Deallocated"
+```
+
+### Breaking Reference Cycles with `weak`
+
+```swift
+// BAD: Reference cycle (memory leak)
+class A {
+ var b: B?
+}
+class B {
+ var a: A? // Strong reference creates cycle
+}
+
+// GOOD: Use weak to break cycle
+class A {
+ var b: B?
+}
+class B {
+ weak var a: A? // Weak reference, doesn't prevent deallocation
+}
+```
+
+## Kotlin/Android GC
+
+### WeakHashMap for Caches
+
+```kotlin
+val weakMap = WeakHashMap()
+
+run {
+ weakMap[String("temp")] = "value"
+ println(weakMap.size) // 1
+}
+
+System.gc() // Force garbage collection
+Thread.sleep(100)
+
+println(weakMap.size) // 0 (key was collected)
+```
+
+### WeakReference for Callbacks
+
+```kotlin
+class DataManager {
+ // Weak references to listeners prevent memory leaks
+ private val listeners = mutableListOf>()
+
+ fun addListener(listener: DataListener) {
+ listeners.add(WeakReference(listener))
+ }
+
+ fun notifyListeners(data: String) {
+ listeners.forEach { ref ->
+ ref.get()?.onDataChanged(data)
+ }
+ }
+}
+```
+
+## Common Memory Leak Sources
+
+### 1. Forgetting to Delete (C++)
+
+```cpp
+// BAD: Memory leak
+int main() {
+ std::string* str = new std::string("Hello");
+ // Forgot to delete!
+ return 0;
+}
+
+// GOOD: Use smart pointers or stack allocation
+int main() {
+ auto str = std::make_unique("Hello");
+ // Automatically deleted
+ return 0;
+}
+```
+
+### 2. Reference Cycles (Swift/C++)
+
+```cpp
+// BAD: Cycle
+class A { std::shared_ptr b; };
+class B { std::shared_ptr a; };
+
+// GOOD: Break with weak_ptr
+class A { std::shared_ptr b; };
+class B { std::weak_ptr a; };
+```
+
+### 3. Unremoved Listeners (Kotlin)
+
+```kotlin
+// BAD: Listener never removed
+class MyClass {
+ private val listener = object : Callback {
+ override fun onEvent() { /* ... */ }
+ }
+
+ init {
+ EventManager.addListener(listener)
+ // Never removed!
+ }
+}
+
+// GOOD: Implement cleanup
+class MyClass : AutoCloseable {
+ private val listener = object : Callback {
+ override fun onEvent() { /* ... */ }
+ }
+
+ init {
+ EventManager.addListener(listener)
+ }
+
+ override fun close() {
+ EventManager.removeListener(listener)
+ }
+}
+```
+
+## Swift `Unmanaged` (Advanced)
+
+For C interop, manually manage reference counts:
+
+```swift
+let obj = MyObject() // Ref count: 1
+
+// Increment manually
+let unmanaged = Unmanaged.passRetained(obj) // Ref count: 2
+
+// Decrement and get object
+let retrieved = unmanaged.takeRetainedValue() // Ref count: 1
+
+// Get raw pointer for C
+let pointer = unmanaged.toOpaque()
+```
+
+**Rule**: Match `passRetained` with `takeRetainedValue`, `passUnretained` with `takeUnretainedValue`.
+
+## Best Practices Summary
+
+| Language | Best Practice |
+|----------|---------------|
+| C++ | Use smart pointers (`shared_ptr`, `unique_ptr`) |
+| Swift | Use `weak` for delegates, breaking cycles |
+| Kotlin | Implement `AutoCloseable`, use `WeakReference` |
+| All | Prefer stack over heap when possible |
+
+## Related Skills
+
+- [native-memory-leaks.md](./native-memory-leaks.md) - Find leaks with profilers
+- [native-turbo-modules.md](./native-turbo-modules.md) - Build memory-safe modules
diff --git a/.forge/skills/react-native-best-practices/references/native-platform-setup.md b/.forge/skills/react-native-best-practices/references/native-platform-setup.md
new file mode 100644
index 00000000..2235e492
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-platform-setup.md
@@ -0,0 +1,110 @@
+---
+title: Platform Differences
+impact: MEDIUM
+tags: ios, android, xcode, gradle, cocoapods
+---
+
+# Skill: Platform Differences
+
+Navigate iOS and Android tooling, dependency management, and build systems in React Native.
+
+## Quick Reference
+
+| Platform | IDE | Package Manager | Build System |
+|----------|-----|-----------------|--------------|
+| JavaScript | VS Code | npm/yarn/pnpm/bun | Metro |
+| iOS | Xcode | CocoaPods | xcodebuild |
+| Android | Android Studio | Gradle | Gradle |
+
+```bash
+# Common commands
+bundle install # Install ruby bundler
+cd ios && bundle exec pod install # Install CocoaPods deps
+cd android && ./gradlew clean # Clean Android build
+xed ios/ # Open Xcode
+```
+
+## When to Use
+
+- Setting up native development environment
+- Adding native dependencies
+- Debugging platform-specific issues
+- Understanding build processes
+
+## Dependency Management
+
+### JavaScript (npm/yarn/pnpm/bun)
+
+Infer package manager from lockfile: `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lockb`.
+
+### iOS (CocoaPods)
+
+```bash
+# Install pods after npm install
+bundle install
+cd ios && bundle exec pod install
+
+# Key files
+ios/Podfile # Pod dependencies
+ios/Pods/ # Installed pods (gitignored)
+ios/*.xcworkspace # Open this in Xcode (not .xcodeproj)
+Gemfile # Ruby/CocoaPods version
+```
+
+
+### Android (Gradle)
+
+```bash
+# Sync after adding dependencies
+cd android && ./gradlew clean
+
+# Key files
+android/build.gradle # Project-level config
+android/app/build.gradle # App dependencies
+android/gradle.properties # Build flags
+android/gradlew # Gradle wrapper
+```
+
+## Common Commands
+
+```bash
+# iOS
+bundle install # Install ruby bundler
+cd ios && bundle exec pod install # Install pods
+xcrun simctl list # List simulators
+
+# Android
+cd android && ./gradlew clean # Clean build
+./gradlew tasks # List available tasks
+./gradlew assembleRelease # Build release APK
+
+# React Native CLI
+npx react-native start # Start Metro
+npx react-native run-ios # Run on iOS
+npx react-native run-android # Run on Android
+npx react-native build-ios # Build for iOS
+npx react-native build-android # Build for Android
+
+# Expo
+npx expo start # Start Metro (Expo)
+npx expo run:ios # Run on iOS (dev client)
+npx expo run:android # Run on Android (dev client)
+npx expo prebuild # Generate native projects
+```
+
+## Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| Pod install fails | `cd ios && bundle exec pod install --repo-update` |
+| Xcode build fails | `cd ios && xcodebuild clean` |
+| Android Gradle sync fails | `./gradlew clean` then sync |
+| Can't find simulator | `xcrun simctl list` to verify name |
+| Metro cache issues | `npx react-native start --reset-cache` |
+| React Native cache issues | `npx react-native clean` |
+
+## Related Skills
+
+- [native-profiling.md](./native-profiling.md) - Use IDE profilers
+- [native-turbo-modules.md](./native-turbo-modules.md) - Build native modules
+- [upgrading-react-native.md](../../upgrading-react-native/references/upgrading-react-native.md) - Upgrade React Native safely
diff --git a/.forge/skills/react-native-best-practices/references/native-profiling.md b/.forge/skills/react-native-best-practices/references/native-profiling.md
new file mode 100644
index 00000000..05609c37
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-profiling.md
@@ -0,0 +1,176 @@
+---
+title: Profile Native Code
+impact: MEDIUM
+tags: xcode, instruments, android-studio, profiler
+---
+
+# Skill: Profile Native Code
+
+Use Xcode Instruments and Android Studio Profiler to identify native performance bottlenecks.
+
+## Quick Command
+
+```bash
+# iOS: Open Instruments
+# Xcode → Open Developer Tool → Instruments → Time Profiler
+
+# Android: Open Profiler
+# Android Studio → View → Tool Windows → Profiler
+```
+
+## When to Use
+
+- App is slow but JS profiler shows no issues
+- Investigating native module performance
+- Startup feels slow (native init)
+- Battery drain concerns
+- Need CPU/memory breakdown by thread
+
+> **Note**: This skill involves interpreting visual profiler output (Xcode Instruments, Android Studio Profiler). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the profiler UI manually, or await MCP-based visual feedback integration (see roadmap).
+
+## iOS Profiling with Xcode
+
+### Quick Check: Debug Navigator
+
+1. Run app via Xcode
+2. Open Debug Navigator (side panel)
+3. View real-time: CPU, Memory, Disk, Network
+
+**CPU percentage can exceed 100%** (multi-core usage).
+
+### Deep Profiling: Instruments
+
+1. Open: **Xcode → Open Developer Tool → Instruments**
+2. Select **Time Profiler**
+3. Choose target device and app
+4. Click record (red circle)
+5. Perform actions in app
+6. Stop recording
+
+### Analyzing Time Profiler Results
+
+**Key views:**
+- **Flame Graph**: Visual call stack over time
+- **Call Tree**: Hierarchical function breakdown
+- **Ranked**: Functions sorted by time (Bottom-Up)
+
+**Useful filters:**
+- Hide System Libraries
+- Invert Call Tree (bottom-up view)
+- Filter by thread (main, JS, etc.)
+
+**Identifying problems:**
+- **Microhang**: Brief UI unresponsiveness
+- **Hang**: Full UI thread block (critical)
+- Yellow = most time spent
+
+### Thread Breakdown
+
+Pin threads to compare:
+- **Main thread** (SampleApp): UI rendering
+- **JavaScript thread**: React/JS execution
+- **Background threads**: Native modules
+
+**Pro tip**: JS thread blocking ≠ UI block (React Native design benefit).
+
+## Android Profiling with Android Studio
+
+### Launch Profiler
+
+1. **View → Tool Windows → Profiler**
+2. Or: Click "Profile" in toolbar
+
+### CPU Profiling
+
+1. Select **"Find CPU Hotspots"**
+2. Click **"Start profiler task"**
+3. Interact with app
+4. Stop to analyze
+
+### Analyzing Results
+
+**Flame Graph:**
+- Zoom with scroll/pinch
+- Click to expand call stacks
+- Filter by keyword (e.g., "hermes")
+
+**Views:**
+- **Top Down**: From entry points down
+- **Bottom Up**: From slowest functions up
+- **Flame Chart**: Timeline visualization
+
+### Reading the Call Stack
+
+Example analysis:
+```
+JS Thread activity after button press:
+- Event handler on main thread
+- Triggers JS work via sync JSI calls
+- Hermes processes React reconciliation
+- ~30% time in "commit" phase (Yoga layout)
+```
+
+## Code Example: What to Look For
+
+### 5000 Views in ScrollView (Bad)
+
+Profiler shows:
+- 240ms+ JS thread work
+- Many 1ms Hermes spikes
+- Exceeds 16.6ms frame budget
+- Result: Dropped frames, UI jank
+
+### Using FlatList (Better)
+
+Profiler shows:
+- Minimal JS work (windowed rendering)
+- Smooth main thread
+- Stays within frame budget
+
+## Platform Tools Summary
+
+| Tool | Platform | Use Case |
+|------|----------|----------|
+| Time Profiler | iOS | CPU hotspots |
+| Leaks | iOS | Memory leaks |
+| Hangs | iOS | UI thread blocks |
+| CPU Profiler | Android | CPU hotspots |
+| Memory Profiler | Android | Memory tracking |
+| Perfetto | Android | Advanced trace analysis |
+
+## Perfetto (Advanced Android)
+
+Export traces from Android Studio and analyze at [ui.perfetto.dev](https://ui.perfetto.dev/):
+
+- Cross-process analysis
+- Custom trace events
+- Additional visualizations
+
+## Pro Tips
+
+1. **Profile on low-end devices**: Issues appear more clearly
+2. **Use release builds**: Debug builds have overhead
+3. **Compare before/after**: Export traces for comparison
+4. **Filter by thread**: Focus on relevant work
+5. **Look for patterns**: Spikes correlating with interactions
+
+## Expo Notes
+
+- **Expo Go**: Cannot profile native code directly; JS profiling only
+- **Dev Client / Prebuild**: Full native profiling supported via Xcode/Android Studio
+- Run `npx expo prebuild` to generate native projects, then profile as bare React Native
+
+## Common Findings
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| Main thread hangs | Heavy UI work, blocked operations |
+| JS thread spikes | React re-renders, heavy computation |
+| Background thread busy | Native module work |
+| Memory climbing | Leak (see memory profiling skills) |
+
+## Related Skills
+
+- [native-measure-tti.md](./native-measure-tti.md) - Profile startup specifically
+- [native-memory-leaks.md](./native-memory-leaks.md) - Memory profiling
+- [js-profile-react.md](./js-profile-react.md) - JS/React profiling
diff --git a/.forge/skills/react-native-best-practices/references/native-sdks-over-polyfills.md b/.forge/skills/react-native-best-practices/references/native-sdks-over-polyfills.md
new file mode 100644
index 00000000..3bac263c
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-sdks-over-polyfills.md
@@ -0,0 +1,192 @@
+---
+title: Native SDKs
+impact: HIGH
+tags: polyfills, intl, crypto, navigation, native
+---
+
+# Skill: Native SDKs
+
+Replace web polyfills and JS navigators with native React Native implementations for better performance.
+
+## Quick Pattern
+
+**Before (JS polyfills - 430+ KB):**
+
+```tsx
+import '@formatjs/intl-datetimeformat/polyfill';
+import CryptoJS from 'crypto-js';
+import { createStackNavigator } from '@react-navigation/stack';
+```
+
+**After (native implementations):**
+
+```tsx
+// Hermes has native Intl.DateTimeFormat support, so this polyfill is often unnecessary
+import { createHash } from 'react-native-quick-crypto'; // 58x faster
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+```
+
+## When to Use
+
+- Large JS bundle from polyfills
+- Navigation feels non-native
+- Crypto operations are slow
+- Internationalization bloating bundle
+
+## Step-by-Step Instructions
+
+### 1. Remove Unnecessary Intl Polyfills
+
+Hermes supports many `Intl` APIs natively, but not every constructor and method combination across platforms. Audit the exact APIs and methods you use before removing polyfills:
+
+```tsx
+// BEFORE: All these polyfills (430+ KB)
+import '@formatjs/intl-getcanonicallocales/polyfill';
+import '@formatjs/intl-locale/polyfill';
+import '@formatjs/intl-numberformat/polyfill';
+import '@formatjs/intl-numberformat/locale-data/en';
+import '@formatjs/intl-datetimeformat/polyfill';
+import '@formatjs/intl-datetimeformat/locale-data/en';
+import '@formatjs/intl-pluralrules/polyfill';
+import '@formatjs/intl-pluralrules/locale-data/en';
+import '@formatjs/intl-relativetimeformat/polyfill';
+import '@formatjs/intl-relativetimeformat/locale-data/en';
+import '@formatjs/intl-displaynames/polyfill';
+```
+
+**Hermes Support (as of March 2026):**
+
+| API | Hermes | Keep Polyfill? |
+|-----|--------|----------------|
+| `Intl.Collator` | ✅ | No |
+| `Intl.DateTimeFormat` | ✅ | No |
+| `Intl.NumberFormat` | ⚠️ Partial | Maybe |
+| `Intl.getCanonicalLocales()` | ✅ | No |
+| `Intl.supportedValuesOf()` | ✅ | No |
+| `Intl.Locale` | ❌ | Yes |
+| `Intl.PluralRules` | ❌ | Yes |
+| `Intl.RelativeTimeFormat` | ❌ | Yes |
+| `Intl.DisplayNames` | ❌ | Yes |
+| `Intl.ListFormat` | ❌ | Yes |
+| `Intl.Segmenter` | ❌ | Yes |
+
+`Intl.NumberFormat` is not fully covered on Hermes across platforms. In particular, `Intl.NumberFormat.prototype.formatToParts()` still has an iOS gap, so keep `@formatjs/intl-numberformat` if your app relies on that method.
+
+```tsx
+// AFTER: Keep only the polyfills your app still needs
+import '@formatjs/intl-locale/polyfill';
+import '@formatjs/intl-pluralrules/polyfill';
+import '@formatjs/intl-pluralrules/locale-data/en';
+import '@formatjs/intl-relativetimeformat/polyfill';
+import '@formatjs/intl-relativetimeformat/locale-data/en';
+import '@formatjs/intl-displaynames/polyfill';
+```
+
+If you use `Intl.NumberFormat.prototype.formatToParts()` on Hermes/iOS, also keep:
+
+```tsx
+import '@formatjs/intl-numberformat/polyfill';
+import '@formatjs/intl-numberformat/locale-data/en';
+```
+
+### 2. Use Native Crypto
+
+Replace JS crypto with native C++ implementation:
+
+```bash
+npm install react-native-quick-crypto
+```
+
+**Performance**: Up to 58x faster than `crypto-js`.
+
+```tsx
+// BEFORE: Slow JS implementation
+import CryptoJS from 'crypto-js';
+
+// AFTER: Native C++ implementation
+import { createHash } from 'react-native-quick-crypto';
+```
+
+Essential for:
+- Web3 wallet seed generation
+- CSPRNG (Cryptographically Secure Random Numbers)
+- Any heavy cryptographic operations
+
+### 3. Use Native Stack Navigator
+
+```bash
+npm install @react-navigation/native-stack react-native-screens
+```
+
+```tsx
+// BEFORE: JS-based stack (more flexible, less native)
+import { createStackNavigator } from '@react-navigation/stack';
+const Stack = createStackNavigator();
+
+// AFTER: Native stack (native feel, better performance)
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+const Stack = createNativeStackNavigator();
+
+// Usage is nearly identical
+
+
+
+
+```
+
+**Benefits:**
+- Native navigation animations
+- Platform-specific headers (large titles on iOS)
+- Lower memory usage
+- Offloads work from JS thread
+
+### 4. Use Native Bottom Tabs
+
+```bash
+npm install @bottom-tabs/react-navigation react-native-bottom-tabs
+```
+
+```tsx
+// BEFORE: JS tabs
+import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
+const Tabs = createBottomTabNavigator();
+
+// AFTER: Native tabs
+import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
+const Tabs = createNativeBottomTabNavigator();
+
+
+
+
+
+```
+
+## Recommended Native Libraries
+
+| Category | Library | Description |
+|----------|---------|-------------|
+| Navigation | `react-native-screens` | Native screen containers |
+| Menus | `zeego` | Native menus (Radix-like API) |
+| Slider | `@react-native-community/slider` | Native slider |
+| Date Picker | `react-native-date-picker` | Native date/time picker |
+| Image | `react-native-fast-image` | Native image caching |
+
+## Decision Matrix
+
+| Scenario | Use Native? | Tradeoff |
+|----------|-------------|----------|
+| Standard navigation | ✅ Yes | Slight API differences |
+| Custom transition animations | ⚠️ Maybe | Native is more limited |
+| Platform-consistent UI | ✅ Yes | Less customization |
+| Unique/branded design | ⚠️ Consider JS | Native may not support |
+
+## Common Pitfalls
+
+- **Assuming constructor support means full method coverage**: Check the specific Hermes API and methods you call
+- **Ignoring migration effort**: Native navigators have slightly different APIs
+- **Over-customizing native components**: If design requires heavy customization, JS might be better
+
+## Related Skills
+
+- [bundle-analyze-js.md](./bundle-analyze-js.md) - Measure polyfill impact
+- [bundle-library-size.md](./bundle-library-size.md) - Compare library sizes
diff --git a/.forge/skills/react-native-best-practices/references/native-threading-model.md b/.forge/skills/react-native-best-practices/references/native-threading-model.md
new file mode 100644
index 00000000..0c358154
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-threading-model.md
@@ -0,0 +1,234 @@
+---
+title: Threading Model
+impact: HIGH
+tags: threads, turbo-modules, fabric, async, sync
+---
+
+# Skill: Threading Model
+
+Understand which threads Turbo Modules and Fabric use for initialization, method calls, and view updates.
+
+## Quick Reference
+
+| Action | iOS Thread | Android Thread |
+|--------|------------|----------------|
+| Module init | Main | JS (lazy) / Native (eager) |
+| Sync method | JS | JS |
+| Async method | Native modules | Native modules |
+| View init/props | Main | Main |
+| Yoga layout | JS | JS |
+
+**Key rule**: Sync methods block JS thread. Keep under 16ms or make async.
+
+## When to Use
+
+- Building native modules
+- Debugging threading issues
+- Accessing UI from native code
+- Understanding async vs sync method behavior
+
+## Available Threads
+
+| Thread | Name in Debugger | Purpose |
+|--------|------------------|---------|
+| Main/UI | Main thread | UI rendering, UIKit/Android Views |
+| JavaScript | `mqt_v_js` | JS execution, React |
+| Native Modules | `mqt_v_native` | Async Turbo Module calls |
+| Custom | Various | Your background threads |
+
+## Turbo Modules Threading
+
+### Initialization
+
+| Platform | Thread | Notes |
+|----------|--------|-------|
+| iOS | Main thread | Assumes UIKit access needed |
+| Android (lazy) | JS thread | Default behavior |
+| Android (eager) | Native modules thread | When `needsEagerInit = true` |
+
+**iOS**: React Native runs `init` on main thread assuming UIKit access.
+
+**Android Eager Loading:**
+
+```kotlin
+// ReactModuleInfo constructor params:
+// canOverrideExistingModule, needsEagerInit, isCxxModule, isTurboModule
+ReactModuleInfo(
+ AwesomeModule.NAME,
+ AwesomeModule.NAME,
+ false,
+ true, // needsEagerInit = true → runs on native modules thread
+ false,
+ true
+)
+```
+
+### Synchronous Method Calls
+
+**Always run on JS thread** - blocks until return.
+
+```swift
+// iOS - runs on JS thread
+@objc func multiply(_ a: Double, b: Double) -> NSNumber {
+ // This blocks JS for entire duration!
+ return a * b as NSNumber
+}
+```
+
+**Danger**: Long sync operations freeze the app:
+
+```swift
+// BAD: Blocks JS for 20 seconds
+@objc func multiply(_ a: Double, b: Double) -> NSNumber {
+ Thread.sleep(forTimeInterval: 20) // App frozen!
+ return a * b as NSNumber
+}
+```
+
+### Asynchronous Method Calls
+
+**Run on Native Modules thread** - doesn't block JS.
+
+```swift
+// iOS - runs on mqt_v_native thread
+@objc func asyncOperation(
+ _ a: Double,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: RCTPromiseRejectBlock
+) {
+ // Already on background thread
+ resolve(a * 2)
+}
+```
+
+```kotlin
+// Android - runs on native modules thread
+override fun asyncOperation(a: Double, promise: Promise?) {
+ // Already on background thread
+ promise?.resolve(a * 2)
+}
+```
+
+### Module Invalidation
+
+Called when React Native instance is torn down (e.g., Metro reload):
+
+| Platform | Thread |
+|----------|--------|
+| iOS | Native modules thread |
+| Android | ReactHost thread pool |
+
+**iOS**: Implement `RCTInvalidating` protocol.
+
+## Fabric (Native Views) Threading
+
+### View Lifecycle
+
+| Operation | Thread |
+|-----------|--------|
+| View init | Main thread |
+| Prop updates | Main thread |
+| Layout (Yoga) | JS thread |
+
+Views always manipulate UI on main thread (UIKit/Android requirement).
+
+### Yoga Layout
+
+Layout calculations happen on JS thread:
+
+```
+JS Thread: Calculate Yoga tree → Shadow tree
+Main Thread: Apply layout to native views
+```
+
+## Moving Work to Background
+
+### iOS: DispatchQueue
+
+```swift
+@objc func heavyWork(
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: RCTPromiseRejectBlock
+) {
+ DispatchQueue.global().async {
+ // Heavy computation here
+ let result = self.compute()
+ resolve(result)
+ }
+}
+```
+
+### Android: Coroutines
+
+```kotlin
+class MyModule(reactContext: ReactApplicationContext) :
+ NativeMyModuleSpec(reactContext) {
+
+ private val moduleScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+
+ override fun heavyWork(promise: Promise?) {
+ moduleScope.launch {
+ // Heavy computation here
+ val result = compute()
+ promise?.resolve(result)
+ }
+ }
+
+ override fun invalidate() {
+ super.invalidate()
+ moduleScope.cancel() // Important: cancel to prevent leaks
+ }
+}
+```
+
+## Thread Safety Checklist
+
+| Scenario | Safe? | Solution |
+|----------|-------|----------|
+| Sync method accessing shared state | ⚠️ | Use locks/synchronized |
+| Async method accessing UI | ❌ | Dispatch to main thread |
+| Multiple async calls to same resource | ⚠️ | Queue or mutex |
+| Accessing JS from background | ❌ | Use CallInvoker |
+
+### Accessing UI from Background (iOS)
+
+```swift
+DispatchQueue.global().async {
+ let result = self.heavyComputation()
+
+ DispatchQueue.main.async {
+ // Safe to update UI here
+ self.updateUI(with: result)
+ }
+}
+```
+
+### Accessing UI from Background (Android)
+
+```kotlin
+moduleScope.launch(Dispatchers.Default) {
+ val result = heavyComputation()
+
+ withContext(Dispatchers.Main) {
+ // Safe to update UI here
+ updateUI(result)
+ }
+}
+```
+
+## Summary Table
+
+| Action | iOS Thread | Android Thread |
+|--------|------------|----------------|
+| Module init | Main | JS (lazy) / Native (eager) |
+| Sync method | JS | JS |
+| Async method | Native modules | Native modules |
+| View init | Main | Main |
+| Prop update | Main | Main |
+| Yoga layout | JS | JS |
+| Invalidate | Native modules | ReactHost pool |
+
+## Related Skills
+
+- [native-turbo-modules.md](./native-turbo-modules.md) - Implement background threads
+- [native-profiling.md](./native-profiling.md) - Debug thread issues
diff --git a/.forge/skills/react-native-best-practices/references/native-turbo-modules.md b/.forge/skills/react-native-best-practices/references/native-turbo-modules.md
new file mode 100644
index 00000000..96e735c5
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-turbo-modules.md
@@ -0,0 +1,292 @@
+---
+title: Fast Native Modules
+impact: HIGH
+tags: turbo-modules, native, swift, kotlin, c++
+---
+
+# Skill: Fast Native Modules
+
+Build performant Turbo Modules using modern languages and background threading.
+
+## Quick Pattern
+
+**Incorrect (sync method blocks JS thread):**
+
+```swift
+@objc func heavyWork() -> NSNumber {
+ Thread.sleep(forTimeInterval: 2) // Blocks JS for 2s!
+ return 42
+}
+```
+
+**Correct (async on background thread):**
+
+```swift
+@objc func heavyWork(
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: RCTPromiseRejectBlock
+) {
+ DispatchQueue.global().async {
+ let result = self.compute()
+ resolve(result)
+ }
+}
+```
+
+## When to Use
+
+- Creating new native modules
+- Optimizing existing module performance
+- Heavy computation needs to run off JS thread
+- Cross-platform C++ code needed
+
+## Prerequisites
+
+- React Native Builder Bob for scaffolding
+
+```bash
+npx create-react-native-library@latest my-library
+```
+
+## Step-by-Step Instructions
+
+### 1. Scaffold with Builder Bob
+
+```bash
+npx create-react-native-library@latest awesome-library
+# Follow prompts: choose Turbo Module, select languages
+```
+
+Creates ready-to-publish library with:
+- iOS (Obj-C/Swift) support
+- Android (Kotlin) support
+- TypeScript definitions
+- Codegen setup
+
+For local modules:
+
+```bash
+npx create-react-native-library@latest awesome-library --local
+```
+
+### 2. Enable Swift in iOS Module
+
+Update `awesome-library.podspec`:
+
+```diff
+- s.source_files = "ios/**/*.{h,m,mm,cpp}"
++ s.source_files = "ios/**/*.{h,m,mm,cpp,swift}"
+```
+
+Create Swift file in Xcode (accept bridging header prompt).
+
+Update header file for Swift compatibility:
+
+```objc
+// AwesomeLibrary.h
+#import
+
+#if __cplusplus
+#import "ReactCodegen/RNAwesomeLibrarySpec/RNAwesomeLibrarySpec.h"
+#endif
+
+@interface AwesomeLibrary : NSObject
+#if __cplusplus
+
+#endif
+@end
+```
+
+Import header in bridging header:
+
+```objc
+// AwesomeLibrary-Bridging-Header.h
+#import "AwesomeLibrary.h"
+```
+
+Implement in Swift:
+
+```swift
+// AwesomeLibrary.swift
+import Foundation
+
+extension AwesomeLibrary {
+ @objc func multiply(_ a: Double, b: Double) -> NSNumber {
+ return (a * b) as NSNumber
+ }
+}
+```
+
+Bridge in Obj-C++:
+
+```objc
+// AwesomeLibrary.mm
+#import "AwesomeLibrary.h"
+
+#if __has_include("awesome_library/awesome_library-Swift.h")
+#import "awesome_library/awesome_library-Swift.h"
+#else
+#import "awesome_library-Swift.h"
+#endif
+
+@implementation AwesomeLibrary
+RCT_EXPORT_MODULE()
+RCT_EXTERN_METHOD(multiply:(double)a b:(double)b);
+@end
+```
+
+### 3. Run on Background Thread (iOS)
+
+```swift
+@objc func heavyOperation(
+ _ input: Double,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: RCTPromiseRejectBlock
+) {
+ DispatchQueue.global().async {
+ // Heavy work on background thread
+ let result = self.expensiveComputation(input)
+ resolve(result)
+ }
+}
+```
+
+### 4. Run on Background Thread (Android)
+
+```kotlin
+class AwesomeLibraryModule(reactContext: ReactApplicationContext) :
+ NativeAwesomeLibrarySpec(reactContext) {
+
+ private val moduleScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+
+ override fun heavyOperation(input: Double, promise: Promise?) {
+ moduleScope.launch {
+ // Heavy work on coroutine
+ val result = expensiveComputation(input)
+ promise?.resolve(result)
+ }
+ }
+
+ override fun invalidate() {
+ super.invalidate()
+ moduleScope.cancel() // Prevent memory leaks!
+ }
+}
+```
+
+### 5. Use C++ for Cross-Platform Code
+
+Create C++ Turbo Module for shared logic:
+
+```cpp
+// MyCppModule.h
+#pragma once
+
+#include
+
+namespace facebook::react {
+
+class MyCppModule : public TurboModule {
+public:
+ MyCppModule(std::shared_ptr jsInvoker);
+
+ double multiply(double a, double b);
+};
+
+} // namespace facebook::react
+```
+
+Register for iOS auto-linking:
+
+```objc
+// MyCppModuleRegistration.mm
+#include
+
+@implementation MyCppModuleRegistration
+
++ (void)load {
+ facebook::react::registerCxxModuleToGlobalModuleMap(
+ std::string(facebook::react::MyCppModule::kModuleName),
+ [&](std::shared_ptr jsInvoker) {
+ return std::make_shared(jsInvoker);
+ }
+ );
+}
+
+@end
+```
+
+## Threading Summary
+
+| Method Type | Default Thread | Best Practice |
+|-------------|----------------|---------------|
+| Sync | JS thread | Keep fast (<16ms) |
+| Async | Native modules thread | OK for moderate work |
+| Heavy async | Custom background | Use DispatchQueue/Coroutines |
+
+## Language Interop Costs
+
+| Interface | Overhead | Notes |
+|-----------|----------|-------|
+| Obj-C ↔ C++ | ~0 | Compile-time |
+| Swift ↔ C++ | ~0 | Swift 5.9+ interop |
+| Kotlin ↔ C++ (JNI) | Medium | Per-call lookup |
+| C++ Turbo Module | Low | JSI direct access |
+
+**Tip**: C++ Turbo Modules skip JNI at runtime since JS holds direct C++ function references via JSI.
+
+## Code Example: Complete Async Operation
+
+```typescript
+// TypeScript interface
+export interface Spec extends TurboModule {
+ multiply(a: number, b: number): number; // Sync
+ heavyOperation(input: number): Promise; // Async
+}
+```
+
+```kotlin
+// Android implementation
+override fun heavyOperation(input: Double, promise: Promise?) {
+ moduleScope.launch {
+ try {
+ val result = withContext(Dispatchers.Default) {
+ // Simulate heavy work
+ delay(1000)
+ input * 2
+ }
+ promise?.resolve(result)
+ } catch (e: Exception) {
+ promise?.reject("ERROR", e.message)
+ }
+ }
+}
+```
+
+```swift
+// iOS implementation
+@objc func heavyOperation(
+ _ input: Double,
+ resolve: @escaping RCTPromiseResolveBlock,
+ reject: @escaping RCTPromiseRejectBlock
+) {
+ DispatchQueue.global(qos: .userInitiated).async {
+ // Simulate heavy work
+ Thread.sleep(forTimeInterval: 1.0)
+ let result = input * 2
+ resolve(result)
+ }
+}
+```
+
+## Common Pitfalls
+
+- **Sync methods that block**: Keep under 16ms or make async
+- **Forgetting to cancel coroutine scope**: Causes memory leaks
+- **Not handling errors in async**: Always try/catch with reject
+- **Accessing UI from background**: Dispatch to main thread
+
+## Related Skills
+
+- [native-threading-model.md](./native-threading-model.md) - Thread details
+- [native-memory-patterns.md](./native-memory-patterns.md) - Memory in native code
diff --git a/.forge/skills/react-native-best-practices/references/native-view-flattening.md b/.forge/skills/react-native-best-practices/references/native-view-flattening.md
new file mode 100644
index 00000000..92530413
--- /dev/null
+++ b/.forge/skills/react-native-best-practices/references/native-view-flattening.md
@@ -0,0 +1,201 @@
+---
+title: View Flattening
+impact: MEDIUM
+tags: views, flattening, collapsable, hierarchy
+---
+
+# Skill: View Flattening
+
+Understand and debug React Native's view flattening optimization.
+
+## Quick Pattern
+
+**Problem (children get flattened unexpectedly):**
+
+```jsx
+
+ // May be flattened, breaking native component
+
+
+```
+
+**Solution (prevent flattening):**
+
+```jsx
+
+
+
+
+```
+
+## When to Use
+
+- Native component receives unexpected number of children
+- Layout debugging with native components
+- Building native components that accept children
+- Understanding React Native rendering
+
+> **Note**: This skill involves interpreting visual view hierarchy tools (Xcode Debug View Hierarchy, Android Layout Inspector). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the hierarchy manually, or await MCP-based visual feedback integration (see roadmap).
+
+## What is View Flattening?
+
+React Native's renderer automatically removes "layout-only" views that:
+- Only affect layout (no visual rendering)
+- Don't need to exist in native view hierarchy
+
+**Benefits**: Reduced memory, faster rendering, shallower view tree.
+
+## The Problem with Native Components
+
+```tsx
+// You expect 3 children
+
+
+
+
+
+```
+
+If `Child1` is flattened, its internal views become direct children:
+
+```tsx
+// Native side receives 5 views instead of 3!
+
+ // Was inside Child1
+ // Was inside Child1
+ // Was inside Child1
+
+
+
+```
+
+## Preventing Flattening with `collapsable`
+
+```tsx
+
+
+
+
+
+```
+
+Now native side always receives exactly 3 children.
+
+## Debugging View Hierarchy
+
+
+
+Use native debugging tools to see the actual view hierarchy:
+
+### Xcode (iOS)
+
+1. Run app via Xcode
+2. Click **"Debug View Hierarchy"** in debug toolbar (shown in image)
+3. Inspect 3D view of native hierarchy
+
+**React Native components map to:**
+- `` → `RCTViewComponentView`
+- `` → `RCTTextView`
+
+### Android Studio
+
+1. Run app via Android Studio
+2. **View → Tool Windows → Layout Inspector**
+3. Select running process
+
+**React Native components map to:**
+- `` → `ReactViewGroup`
+- `` → `ReactTextView`
+
+## Code Examples
+
+### When Flattening Breaks Your Component
+
+```tsx
+// Your native component expects exactly 2 tabs
+const NativeTabBar = requireNativeComponent('RCTTabBar');
+
+// BAD: TabContent might get flattened
+const MyTabs = () => (
+
+
+ Home content
+
+
+ Profile content
+
+
+);
+
+// GOOD: Prevent flattening
+const MyTabs = () => (
+
+
+ Home content
+
+
+ Profile content
+
+
+);
+```
+
+### Wrapper Component with collapsable
+
+```tsx
+// Wrapper that prevents flattening
+const NativeChildWrapper = ({ children, ...props }) => (
+
+ {children}
+
+);
+
+// Usage
+
+
+
+
+
+```
+
+## When Views Get Flattened
+
+Views are considered "layout-only" when they:
+- Have no `backgroundColor`
+- Have no `borderWidth`, `borderColor`
+- Have no `shadowColor`, `elevation`
+- Don't handle events (no `onPress`, etc.)
+- Don't use `opacity` < 1
+- Don't have `overflow: 'hidden'`
+
+## Forcing a View to Stay
+
+Besides `collapsable={false}`, these also prevent flattening:
+
+```tsx
+// Any of these prevent flattening
+
+
+
+ {}} />
+```
+
+But `collapsable={false}` is the cleanest solution.
+
+## Debugging Checklist
+
+1. **Check native child count**: Log received children in native code
+2. **Use Layout Inspector**: Visual hierarchy debugging
+3. **Add collapsable={false}**: Test if flattening is the issue
+4. **Check wrapper components**: Intermediate views may be flattened
+
+## Common Pitfalls
+
+- **Assuming JS children = native children**: Flattening changes this
+- **Not documenting native component requirements**: If your native component expects specific child count, document it
+- **Over-using collapsable={false}**: Only use when necessary (loses optimization benefits)
+
+## Related Skills
+
+- [native-platform-setup.md](./native-platform-setup.md) - IDE setup for debugging
+- [native-profiling.md](./native-profiling.md) - Performance impact analysis
diff --git a/.forge/skills/upgrading-react-native/SKILL.md b/.forge/skills/upgrading-react-native/SKILL.md
new file mode 100644
index 00000000..524d4d17
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/SKILL.md
@@ -0,0 +1,82 @@
+---
+name: upgrading-react-native
+description: Upgrades React Native apps to newer versions by applying rn-diff-purge template diffs, updating package.json dependencies, migrating native iOS and Android configuration, resolving CocoaPods and Gradle changes, and handling breaking API updates. Use when upgrading React Native, bumping RN version, updating from RN 0.x to 0.y, or migrating Expo SDK alongside a React Native upgrade.
+license: MIT
+metadata:
+ author: Callstack
+ tags: react-native, upgrade, upgrade-helper, npm, changelog, cocoapods, ios, android
+---
+
+# Upgrading React Native
+
+## Overview
+
+Covers the full React Native upgrade workflow: template diffs via Upgrade Helper, dependency updates, Expo SDK steps, and common pitfalls.
+
+## Typical Upgrade Sequence
+
+1. **Route**: Choose the right upgrade path via [upgrading-react-native.md][upgrading-react-native]
+2. **Diff**: Fetch the canonical template diff using Upgrade Helper via [upgrade-helper-core.md][upgrade-helper-core]
+3. **Dependencies**: Assess and update third-party packages via [upgrading-dependencies.md][upgrading-dependencies]
+4. **React**: Align React version if upgraded via [react.md][react]
+5. **Expo** (if applicable): Apply Expo SDK layer via [expo-sdk-upgrade.md][expo-sdk-upgrade]
+6. **Verify**: Run post-upgrade checks via [upgrade-verification.md][upgrade-verification]
+
+```bash
+# Quick start: detect current version and fetch diff
+npm pkg get dependencies.react-native --prefix "$APP_DIR"
+npm view react-native dist-tags.latest
+
+# Example: upgrading from 0.76.9 to 0.78.2
+# 1. Fetch the template diff
+curl -L -f -o /tmp/rn-diff.diff \
+ "https://raw.githubusercontent.com/react-native-community/rn-diff-purge/diffs/diffs/0.76.9..0.78.2.diff" \
+ && echo "Diff downloaded OK" || echo "ERROR: diff not found, check versions"
+# 2. Review changed files
+grep -n "^diff --git" /tmp/rn-diff.diff
+# 3. Update package.json, apply native changes, then install + rebuild
+npm install --prefix "$APP_DIR"
+cd "$APP_DIR/ios" && pod install
+# 4. Validate: both platforms must build successfully
+npx react-native build-android --mode debug --no-packager
+xcodebuild -workspace "$APP_DIR/ios/App.xcworkspace" -scheme App -sdk iphonesimulator build
+```
+
+## When to Apply
+
+Reference these guidelines when:
+- Moving a React Native app to a newer version
+- Reconciling native config changes from Upgrade Helper
+- Validating release notes for breaking changes
+
+## Quick Reference
+
+| File | Description |
+|------|-------------|
+| [upgrading-react-native.md][upgrading-react-native] | Router: choose the right upgrade path |
+| [upgrade-helper-core.md][upgrade-helper-core] | Core Upgrade Helper workflow and reliability gates |
+| [upgrading-dependencies.md][upgrading-dependencies] | Dependency compatibility checks and migration planning |
+| [react.md][react] | React and React 19 upgrade alignment rules |
+| [expo-sdk-upgrade.md][expo-sdk-upgrade] | Expo SDK-specific upgrade layer (conditional) |
+| [upgrade-verification.md][upgrade-verification] | Manual post-upgrade verification checklist |
+| [monorepo-singlerepo-targeting.md][monorepo-singlerepo-targeting] | Monorepo and single-repo app targeting and command scoping |
+
+## Problem → Skill Mapping
+
+| Problem | Start With |
+|---------|------------|
+| Need to upgrade React Native | [upgrade-helper-core.md][upgrade-helper-core] |
+| Need dependency risk triage and migration options | [upgrading-dependencies.md][upgrading-dependencies] |
+| Need React/React 19 package alignment | [react.md][react] |
+| Need workflow routing first | [upgrading-react-native.md][upgrading-react-native] |
+| Need Expo SDK-specific steps | [expo-sdk-upgrade.md][expo-sdk-upgrade] |
+| Need manual regression validation | [upgrade-verification.md][upgrade-verification] |
+| Need repo/app command scoping | [monorepo-singlerepo-targeting.md][monorepo-singlerepo-targeting] |
+
+[upgrading-react-native]: references/upgrading-react-native.md
+[upgrade-helper-core]: references/upgrade-helper-core.md
+[upgrading-dependencies]: references/upgrading-dependencies.md
+[react]: references/react.md
+[expo-sdk-upgrade]: references/expo-sdk-upgrade.md
+[upgrade-verification]: references/upgrade-verification.md
+[monorepo-singlerepo-targeting]: references/monorepo-singlerepo-targeting.md
diff --git a/.forge/skills/upgrading-react-native/agents/openai.yaml b/.forge/skills/upgrading-react-native/agents/openai.yaml
new file mode 100644
index 00000000..c5152f68
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Upgrade React Native"
+ short_description: "React Native upgrade workflow updated templates, dependencies, and common pitfalls"
+ default_prompt: "Use $upgrading-react-native to plan and execute a React Native upgrade."
diff --git a/.forge/skills/upgrading-react-native/references/expo-sdk-upgrade.md b/.forge/skills/upgrading-react-native/references/expo-sdk-upgrade.md
new file mode 100644
index 00000000..4753bd43
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/expo-sdk-upgrade.md
@@ -0,0 +1,65 @@
+---
+title: Expo SDK Upgrade Layer
+impact: HIGH
+tags: expo, sdk, react-native, dependencies
+---
+
+# Skill: Expo SDK Upgrade Layer
+
+Expo-specific add-on to the core Upgrade Helper workflow. Use only when `expo` exists in app `package.json`.
+
+## Quick Commands
+
+```bash
+npm pkg get dependencies.expo devDependencies.expo --prefix "$APP_DIR"
+cd "$APP_DIR" && npx expo install --fix
+cd "$APP_DIR" && npx expo-doctor
+```
+
+## When to Apply
+
+- `expo` or `expo-updates` is present in the target app package
+- RN upgrade is paired with Expo SDK upgrade
+
+## Official Expo Reference
+
+- Follow Expo's official upgrade skill as a primary guide:
+ - [Expo Upgrading Expo Skill][expo-upgrading-expo-skill]
+- Important for this workflow: skip `app.json` changes, because this is not an Expo Managed project.
+
+## Pre-Upgrade Audit (Required)
+
+- Confirm SDK version and target path.
+- Inventory dependencies and native modules.
+- Review config plugins and prebuild behavior.
+- Review native build setup (Gradle, iOS settings, CI/EAS config).
+- Identify critical app flows for regression testing before changes.
+
+## Workflow Additions
+
+1. Run Expo compatibility alignment.
+ - `npx expo install --fix` (source of truth for SDK-compatible versions).
+ - Treat `expo-modules` package versions as SDK-coupled; align them with Expo recommendations.
+2. Run health checks.
+ - `npx expo-doctor`; resolve blocking issues first.
+3. If native folders are part of workflow, reconcile prebuild output.
+ - `npx expo prebuild --clean` (when applicable).
+4. Handle React 19 pairing.
+ - Run [react.md](react.md).
+5. Run [upgrade-verification.md](upgrade-verification.md) for manual regression checks and release gates.
+
+## Notes
+
+- Use `npx expo ...`; do not require global `expo-cli`.
+- Some package bumps may be optional if not SDK-bound; validate before deferring.
+- Read Expo and React Native release notes deeply before editing code, then map each breaking change to a concrete code/task item.
+
+## Related Skills
+
+- [upgrading-react-native.md](upgrading-react-native.md) - Routing and mode selection
+- [upgrade-helper-core.md](upgrade-helper-core.md) - Base upgrade workflow
+- [react.md](react.md) - React and React 19 alignment
+- [upgrade-verification.md](upgrade-verification.md) - Manual post-upgrade validation
+- [monorepo-singlerepo-targeting.md](monorepo-singlerepo-targeting.md) - Repo/app selection and command scoping
+
+[expo-upgrading-expo-skill]: https://github.com/expo/skills/blob/main/plugins/upgrading-expo/skills/upgrading-expo/SKILL.md
diff --git a/.forge/skills/upgrading-react-native/references/monorepo-singlerepo-targeting.md b/.forge/skills/upgrading-react-native/references/monorepo-singlerepo-targeting.md
new file mode 100644
index 00000000..8c26c841
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/monorepo-singlerepo-targeting.md
@@ -0,0 +1,34 @@
+---
+title: Monorepo vs Single-App Targeting
+impact: HIGH
+tags: monorepo, workspace, react-native, app-selection
+---
+
+# Skill: Monorepo vs Single-App Targeting
+
+Small instruction set for selecting the correct app package and running upgrade commands in the right scope.
+
+## Quick Commands
+
+```bash
+APP_DIR=apps/mobile
+npm pkg get dependencies.react-native devDependencies.react-native --prefix "$APP_DIR"
+```
+
+## Rules
+
+1. Pick one target app package before any upgrade command.
+2. Run all app-specific commands with `--prefix "$APP_DIR"` or from `cd "$APP_DIR"`.
+3. Use `APP_DIR=.` for single-package repos.
+4. Never apply diffs to workspace root when RN app lives in a subpackage.
+
+## Verification
+
+- `react-native` is present in `APP_DIR/package.json`.
+- `ios/` and `android/` paths used for build/pods are under `APP_DIR`.
+
+## Related Skills
+
+- [upgrading-react-native.md](upgrading-react-native.md) - Routing and mode selection
+- [upgrade-helper-core.md](upgrade-helper-core.md) - Base upgrade workflow
+- [expo-sdk-upgrade.md](expo-sdk-upgrade.md) - Expo-specific steps
diff --git a/.forge/skills/upgrading-react-native/references/react.md b/.forge/skills/upgrading-react-native/references/react.md
new file mode 100644
index 00000000..33e9144e
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/react.md
@@ -0,0 +1,42 @@
+---
+title: React Upgrade Layer
+impact: HIGH
+tags: react, react-19, rntl, types
+---
+
+# Skill: React Upgrade Layer
+
+React-specific upgrade rules to run when `react` changes during a React Native or Expo upgrade.
+
+## When to Apply
+
+- `react` version changes (major, minor, or patch).
+- React Native target implies a newer React pairing.
+- Tests/types break after React upgrade.
+
+## React Pairing Rules
+
+1. Keep companion packages aligned with installed React version:
+ - `react-test-renderer`
+ - `@types/react`
+ - `react-dom` (if used by the app)
+2. Prefer matching React major and minor exactly; patch should also match when available.
+3. Do not leave these packages on older `x.y` after upgrading `react`.
+
+## React 19 Rules
+
+1. Upgrade `@testing-library/react-native` to `v13+`.
+2. Follow:
+ - [Expo React 19 Reference][expo-react-19-reference]
+ - [RNTL LLM Docs][rntl-llm-docs]
+3. Expect type-level breakages from deprecated API removals; fix code and mocks accordingly.
+
+## Related Skills
+
+- [upgrade-helper-core.md](upgrade-helper-core.md) - Core RN upgrade workflow
+- [upgrading-dependencies.md](upgrading-dependencies.md) - Dependency compatibility triage
+- [expo-sdk-upgrade.md](expo-sdk-upgrade.md) - Expo-specific upgrade layer
+- [upgrade-verification.md](upgrade-verification.md) - Post-upgrade manual validation
+
+[expo-react-19-reference]: https://github.com/expo/skills/blob/main/plugins/upgrading-expo/skills/upgrading-expo/references/react-19.md
+[rntl-llm-docs]: https://oss.callstack.com/react-native-testing-library/llms.txt
diff --git a/.forge/skills/upgrading-react-native/references/upgrade-helper-core.md b/.forge/skills/upgrading-react-native/references/upgrade-helper-core.md
new file mode 100644
index 00000000..3bbcb7da
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/upgrade-helper-core.md
@@ -0,0 +1,122 @@
+---
+title: Upgrade Helper Core Workflow
+impact: HIGH
+tags: react-native, upgrade-helper, rn-diff-purge, ios, android
+---
+
+# Skill: Upgrade Helper Core Workflow
+
+Reliable, framework-agnostic workflow for React Native upgrades using Upgrade Helper and rn-diff-purge.
+
+Run shared environment checks first in [upgrading-react-native.md](upgrading-react-native.md) under `Prerequisites (All Upgrade Paths)`.
+
+## Quick Commands
+
+```bash
+npm pkg get dependencies.react-native devDependencies.react-native --prefix "$APP_DIR"
+npm view react-native dist-tags.latest
+curl -L "https://raw.githubusercontent.com/react-native-community/rn-diff-purge/master/RELEASES"
+curl -L -o /tmp/rn-diff-...diff "https://raw.githubusercontent.com/react-native-community/rn-diff-purge/diffs/diffs/...diff"
+grep -n "^diff --git" /tmp/rn-diff-...diff
+```
+
+## Upgrade Helper API (Inline Reference)
+
+- List supported versions:
+ - `https://raw.githubusercontent.com/react-native-community/rn-diff-purge/master/RELEASES`
+- Fetch raw unified diff:
+ - `https://raw.githubusercontent.com/react-native-community/rn-diff-purge/diffs/diffs/...diff`
+- GitHub compare view:
+ - `https://github.com/react-native-community/rn-diff-purge/compare/release/..release/`
+- Upgrade Helper UI:
+ - `https://react-native-community.github.io/upgrade-helper/?from=&to=`
+- Path mapping note:
+ - Diff paths are prefixed with `RnDiffApp/`; remap to your app paths and package names.
+
+## Inputs
+
+- `APP_DIR`: app package path (`.` for single-package repos)
+- `current_version`: current React Native version
+- `target_version`: target React Native version (latest by default)
+
+## Reliable Workflow
+
+1. Detect app and versions.
+ - Read `react-native` from `APP_DIR/package.json`.
+ - Resolve target via `npm view react-native dist-tags.latest` unless user provides one.
+2. Validate `target_version` exists.
+ - Check `RELEASES` from rn-diff-purge and confirm `target_version` is listed.
+ - If missing, stop and ask user to choose one of available versions.
+3. Collect canonical sources.
+ - Upgrade Helper URL.
+ - rn-diff-purge raw diff.
+4. Fetch diff with fallback.
+ - Try exact raw diff: `..`.
+ - If 404, try nearest available patch versions and report what was attempted.
+ - If no available pair works, stop and ask user for target adjustment.
+5. Build dependency baseline from rn-diff-purge first.
+ - Start with the `RnDiffApp/package.json` diff for the exact version pair.
+ - Do not manually install RN packages one-by-one before this baseline is captured.
+6. Publish a short execution plan before edits.
+ - Include ordered phases: dependency baseline, one-pass install, native/tooling merges, verification.
+ - If dependency migrations are ambiguous, ask for user confirmation before modifying package choices.
+7. Run dependency risk planning.
+ - Use [upgrading-dependencies.md](upgrading-dependencies.md).
+ - Fold approved migrations into the same dependency update pass.
+8. Apply dependency updates in one pass.
+ - Update `APP_DIR/package.json` (and lockfile) from the baseline plus approved migrations.
+ - Run exactly one install command with the repo's package manager (`npm install`, `yarn install`, `pnpm install`, or `bun install`).
+ - Avoid piecemeal installs such as repeated `npm install ` unless explicitly requested.
+9. Build a change checklist from diff.
+ - Group by JS/TS, iOS, Android, tooling.
+ - Skip template-only UI (`App.tsx`) unless explicitly requested.
+ - Skip template-only dependencies (`@react-native/new-app-screen`) unless they exist in the app.
+10. Apply diff safely.
+ - Treat `RnDiffApp` as placeholder; remap app/package names.
+ - Merge, do not overwrite project-specific customizations.
+11. Sync native deps.
+ - Run iOS pods in `APP_DIR/ios`.
+12. Validate and gate completion.
+ - iOS build passes.
+ - Android build passes.
+ - tests/typecheck/lint pass or failures are documented with next actions.
+ - If `react` was upgraded, run [react.md](react.md).
+ - If `target_version >= 0.81` and tests fail due to missing modules, add proper mocks.
+ - Example (`BackHandler` mock removal): https://github.com/facebook/react-native/issues/52667#issuecomment-3094788618
+ - Run [upgrade-verification.md](upgrade-verification.md) before closing the upgrade.
+
+## Stop Conditions
+
+- Missing `react-native` dependency in target package.
+- Diff source unavailable and no fallback available.
+- Unresolved native merge conflicts in iOS/Android entry files.
+
+## Reliability Rules
+
+- Keep operations version-pair scoped (`current_version -> target_version`).
+- Prefer official sources over ad-hoc guides.
+- Record every manual deviation from Upgrade Helper.
+- Do not run Expo-specific commands here.
+
+## Common Pitfalls
+
+- Upgrading an Expo project with only RN CLI steps: apply the Expo layer ([expo-sdk-upgrade.md](expo-sdk-upgrade.md)).
+- Skipping the Upgrade Helper: leads to missed native config changes.
+- Treating `RnDiffApp` paths as literal project paths.
+- Copying the entire template wholesale: use the diff as a guide and merge only needed changes.
+- Using the wrong changelog: `0.7x` changes live in `CHANGELOG-0.7x.md`.
+- Running the wrong package manager: always match the repo lockfile.
+- Forgetting CocoaPods: iOS builds will fail without `pod install`.
+- Not updating Android Gradle wrapper binary: update `android/gradle/wrapper/gradle-wrapper.jar` for the target RN version. Source template:
+ - `https://raw.githubusercontent.com/react-native-community/rn-diff-purge/release//RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar`
+- Flipper artifacts lingering after removal in v0.74: remove `ReactNativeFlipper.kt` and `FLIPPER_VERSION` when the target RN version drops Flipper.
+- Skipping platform rebuilds after Pod/Gradle changes.
+
+## Related Skills
+
+- [upgrading-react-native.md](upgrading-react-native.md) - Routing and mode selection
+- [upgrading-dependencies.md](upgrading-dependencies.md) - Dependency compatibility and migration plan
+- [expo-sdk-upgrade.md](expo-sdk-upgrade.md) - Expo-only layer on top of core workflow
+- [react.md](react.md) - React and React 19 alignment
+- [upgrade-verification.md](upgrade-verification.md) - Manual post-upgrade validation
+- [monorepo-singlerepo-targeting.md](monorepo-singlerepo-targeting.md) - Repo/app selection and command scoping
diff --git a/.forge/skills/upgrading-react-native/references/upgrade-verification.md b/.forge/skills/upgrading-react-native/references/upgrade-verification.md
new file mode 100644
index 00000000..58909ffb
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/upgrade-verification.md
@@ -0,0 +1,43 @@
+---
+title: Upgrade Verification
+impact: HIGH
+tags: verification, regression, android, ios, navigation
+---
+
+# Skill: Upgrade Verification
+
+Manual validation checklist for human developers after React Native and/or Expo upgrades.
+
+## Scope
+
+- Focus on behavior and UX regressions that static diffs cannot prove.
+- Keep checks small, repeatable, and tied to critical user flows.
+
+## Manual Checks (Required)
+
+1. App launch and core journeys work on both iOS and Android.
+2. Navigation behavior is correct (forward/back stack, params, deep links, modal flows).
+3. Android edge-to-edge is visually correct (status bar, nav bar, safe area insets, keyboard overlays).
+4. Permissions and device APIs work (camera, location, notifications, file/media access).
+5. Background/restore paths work (app resume, push open, interrupted flows).
+
+## Build and Test Gates
+
+1. Run unit/integration tests and fix all upgrade-related failures.
+2. If `target_version >= 0.81` and tests fail due to missing modules, add proper mocks.
+ - Example (`BackHandler` mock removal): https://github.com/facebook/react-native/issues/52667#issuecomment-3094788618
+3. Build installable artifacts for both platforms.
+4. For Expo apps, run `npx expo-doctor` from [expo-sdk-upgrade.md](expo-sdk-upgrade.md).
+
+## Evidence to Capture
+
+- Screen recordings/screenshots for changed flows.
+- List of verified scenarios and pass/fail status.
+- Follow-up fixes for any observed regressions.
+
+## Related Skills
+
+- [upgrading-react-native.md](upgrading-react-native.md) - Upgrade workflow router
+- [upgrade-helper-core.md](upgrade-helper-core.md) - Core RN diff/merge workflow
+- [expo-sdk-upgrade.md](expo-sdk-upgrade.md) - Expo-specific checks and commands
+- [react.md](react.md) - React-specific upgrade rules
diff --git a/.forge/skills/upgrading-react-native/references/upgrading-dependencies.md b/.forge/skills/upgrading-react-native/references/upgrading-dependencies.md
new file mode 100644
index 00000000..e62e8345
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/upgrading-dependencies.md
@@ -0,0 +1,41 @@
+---
+title: Upgrading Dependencies
+impact: HIGH
+tags: react-native, dependencies, compatibility, migration
+---
+
+# Skill: Upgrading Dependencies
+
+Common dependency issues and mitigations when upgrading React Native.
+
+## Quick Checks
+
+```bash
+npm ls --depth=0
+```
+
+## Dependency Risk and Migration Plan
+
+1. Review compatibility signals:
+ - [RN nightly tests](https://react-native-community.github.io/nightly-tests/)
+ - [React Native Directory](https://reactnative.directory/packages?newArchitecture=false)
+2. If `react` is upgraded, run [react.md](react.md) for companion package alignment and React 19 rules.
+3. Handle known risky packages:
+ - `react-native-fast-image` -> prefer `@d11/react-native-fast-image` or `expo-image` (confirm with user)
+ - `@react-native-cookies/cookies` -> prefer `@preeternal/react-native-cookie-manager` (confirm with user)
+ - `react-native-code-push` -> treat as incompatible; disable for upgrade and consider `@appzung/react-native-code-push`, `@bravemobile/react-native-code-push`, or `expo-updates`
+ - `react-native-image-crop-picker` -> upgrade to `>=0.51.1`; if unstable, plan migration to `expo-image-picker` (confirm with user)
+ - `react-native-network-logger` - lists `react` and `react-native` in peer deps as `*` which can be misleading. Upgrade to v2 if `target_version >= 0.79`.
+ - `react-native-permissions` - upgrade to v5 if possible (requires RN 0.74+)
+4. Apply additional cleanup rules:
+ - If `@rnx-kit/metro-resolver-symlinks` is present, remove it from deps and `metro.config.js` (Metro supports symlinks since 0.72)
+ - If app uses `react-native-localize` timezone APIs and `@callstack/timezone-hermes-fix` is missing, ask whether to add it
+5. If no safe alternative is found for a critical dependency, ask for explicit user confirmation before continuing.
+6. Read only breaking/manual steps from RN blog posts between `current_version` and `target_version`.
+
+## Related Skills
+
+- [upgrade-helper-core.md](upgrade-helper-core.md) - Core upgrade workflow
+- [react.md](react.md) - React and React 19 alignment
+- [expo-sdk-upgrade.md](expo-sdk-upgrade.md) - Expo-specific dependency alignment
+- [upgrading-react-native.md](upgrading-react-native.md) - Routing and mode selection
diff --git a/.forge/skills/upgrading-react-native/references/upgrading-react-native.md b/.forge/skills/upgrading-react-native/references/upgrading-react-native.md
new file mode 100644
index 00000000..c2940a1b
--- /dev/null
+++ b/.forge/skills/upgrading-react-native/references/upgrading-react-native.md
@@ -0,0 +1,52 @@
+---
+title: Upgrading React Native
+impact: HIGH
+tags: react-native, upgrade, routing
+---
+
+# Skill: Upgrading React Native
+
+Router for React Native upgrade workflows. Start with core Upgrade Helper instructions, then apply focused add-ons by project shape.
+
+## Prerequisites (All Upgrade Paths)
+
+- Ensure the repo is clean or on a dedicated upgrade branch.
+- Know which package manager the repo uses (`npm`, `yarn`, `pnpm`, `bun`).
+- Use Node.js `20.19.4+`, Java `17`, and Xcode `16.4+` (with Command Line Tools), following https://reactnative.dev/docs/set-up-your-environment.
+ - Optional: use [Xcodes](https://github.com/XcodesOrg/XcodesApp) to manage Xcode versions.
+- Verify active versions before upgrading: `node -v`, `java -version`.
+- Verify Android Studio is installed.
+- For iOS, verify Xcode CLI toolchain is in sync (common pitfall after Xcode upgrades):
+ - Check:
+ - `xcode-select --print-path`
+ - `xcodebuild -version`
+ - `xcrun --sdk iphoneos --show-sdk-version`
+ - If mismatch is suspected, re-point and initialize:
+ - `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`
+ - `sudo xcodebuild -runFirstLaunch`
+
+## Quick Start
+
+0. Run the [Prerequisites (All Upgrade Paths)](#prerequisites-all-upgrade-paths) checklist.
+1. Set `APP_DIR` to the app folder (`.` for single-app repos).
+2. Use [monorepo-singlerepo-targeting.md](monorepo-singlerepo-targeting.md) if you need help choosing `APP_DIR`.
+3. Run [upgrade-helper-core.md](upgrade-helper-core.md) first to anchor changes to rn-diff-purge.
+4. Publish a short plan (ordered phases) before making versioned edits.
+5. Run [upgrading-dependencies.md](upgrading-dependencies.md) to assess risky packages and migrations.
+6. Apply dependency updates in one pass and run a single install with the repo package manager.
+7. Run [react.md](react.md) when `react` is upgraded.
+8. Add [expo-sdk-upgrade.md](expo-sdk-upgrade.md) only if `expo` is present in `APP_DIR/package.json`.
+9. Finish with [upgrade-verification.md](upgrade-verification.md).
+
+## Decision Map
+
+- Need canonical RN diff/merge workflow: [upgrade-helper-core.md](upgrade-helper-core.md)
+- Need to ensure dependencies are compatible: [upgrading-dependencies.md](upgrading-dependencies.md)
+- Need React and React 19 alignment: [react.md](react.md)
+- Project contains Expo SDK deps: [expo-sdk-upgrade.md](expo-sdk-upgrade.md)
+- Need manual post-upgrade validation: [upgrade-verification.md](upgrade-verification.md)
+
+## Related Skills
+
+- [native-platform-setup.md](../../react-native-best-practices/references/native-platform-setup.md) - Tooling and native dependency basics
+- [native-android-16kb-alignment.md](../../react-native-best-practices/references/native-android-16kb-alignment.md) - Third-party library alignment for Google Play
diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml
index 87da10f5..2076a344 100644
--- a/.github/workflows/react-native-cicd.yml
+++ b/.github/workflows/react-native-cicd.yml
@@ -125,7 +125,7 @@ jobs:
strategy:
matrix:
platform: [android, ios]
- runs-on: ${{ matrix.platform == 'ios' && 'macos-15' || 'ubuntu-latest' }}
+ runs-on: ${{ matrix.platform == 'ios' && 'macos-26' || 'ubuntu-latest' }}
environment: RNBuild
steps:
- name: 🏗 Checkout repository
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..0346f30f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,427 @@
+# Resgrid Unit — AI Coding Guidelines
+
+> **Resgrid Unit** is a multi-platform emergency response mobile application built with TypeScript, React Native, Expo (managed + prebuild), targeting iOS, Android, Web, and Electron.
+
+---
+
+## Tech Stack
+
+| Layer | Technology |
+|---|---|
+| Framework | React Native 0.81 + Expo SDK 54 (managed, prebuild) |
+| Language | TypeScript (strict mode) |
+| Routing | Expo Router (file-based, typed routes) |
+| State | Zustand (with MMKV persistence) |
+| Data Fetching | @tanstack/react-query (v5) + Axios |
+| Forms | react-hook-form + zod validation |
+| i18n | react-i18next (9 languages) |
+| UI Components | gluestack-ui (`src/components/ui/`) |
+| Styling | NativeWind / Tailwind CSS |
+| Icons | lucide-react-native (use directly, NOT via gluestack Icon wrapper) |
+| Maps | @rnmapbox/maps |
+| Realtime | @microsoft/signalr |
+| Voice/Video | LiveKit (@livekit/react-native) |
+| Storage | react-native-mmkv (local), expo-secure-store (sensitive) |
+| Logging | Custom singleton → react-native-logs + @sentry/react-native |
+| Push | @notifee/react-native, @react-native-firebase/messaging |
+| Package Manager | yarn (v1 classic) |
+
+---
+
+## Project Structure
+
+```
+src/
+├── api/ # API layer (organized by domain)
+│ ├── common/ # Shared: axios client, cached client, API provider
+│ ├── calls/ # Call endpoints (calls.ts, callNotes.ts, etc.)
+│ └── ... # Other domain API modules
+├── app/ # Expo Router file-based routes
+│ ├── _layout.tsx # Root layout (providers, Sentry wrapper)
+│ ├── (app)/ # Authenticated tab group
+│ ├── call/ # Call screens ([id].tsx, new/, edit)
+│ ├── login/ # Login & SSO screens
+│ └── maps/ # Map screens (custom, indoor, search)
+├── components/ # Shared components
+│ ├── ui/ # gluestack-ui component library
+│ ├── common/ # Cross-feature shared components
+│ └── [domain]/ # Domain-specific components (maps, calls, etc.)
+├── constants/ # App constants (colors, map icons)
+├── features/ # Feature-specific modules (livekit-call)
+├── hooks/ # Custom React hooks
+├── lib/ # Core utilities & services
+│ ├── auth/ # Auth API, types, and utilities
+│ ├── cache/ # Cache manager for API responses
+│ ├── i18n/ # Internationalization setup
+│ ├── logging/ # Logging singleton (→ Sentry)
+│ ├── storage/ # MMKV storage + Zustand adapter
+│ └── native-modules/ # Platform-specific native module wrappers
+├── models/ # TypeScript types for API responses (v4/)
+├── providers/ # React context providers
+├── services/ # Singleton services (signalr, push, audio, location, etc.)
+├── stores/ # Zustand stores (organized by domain)
+│ ├── auth/ # Auth store (login, tokens, profile)
+│ ├── app/ # Core app state, lifecycle, location, audio, bluetooth
+│ ├── calls/ # Calls state
+│ └── ... # Other domain stores
+├── translations/ # i18n JSON files (en, es, fr, de, it, pl, sv, uk, ar)
+├── types/ # Shared TypeScript type definitions
+└── utils/ # Pure utility functions
+```
+
+---
+
+## Path Aliases
+
+Configured in `tsconfig.json` — **always use these** instead of relative paths:
+
+| Alias | Maps To |
+|---|---|
+| `@/*` | `./src/*` |
+| `@env` | `./src/lib/env.js` |
+| `@assets/*` | `./assets/*` |
+
+```typescript
+// ✅ Correct
+import { logger } from '@/lib/logging';
+import { Env } from '@env';
+
+// ❌ Wrong
+import { logger } from '../../lib/logging';
+```
+
+---
+
+## Code Style & Conventions
+
+### TypeScript
+
+- **Strict mode** is enabled. Never use `any`; prefer precise types and interfaces.
+- Use `interface` for props and state definitions.
+- Use `type` imports for type-only imports (enforced by ESLint):
+ ```typescript
+ import type { CallResult } from '@/models/v4/calls/callResult';
+ ```
+
+### Components
+
+- **Functional components only** — never class components.
+- Use `React.FC` for typed components.
+- **All components must be mobile-friendly and responsive** across iOS and Android.
+- This is an Expo managed project using prebuild — **do NOT make native code changes** outside Expo prebuild capabilities.
+
+### Naming
+
+| Kind | Convention | Example |
+|---|---|---|
+| Variables / functions | `camelCase` | `isFetchingData`, `handleUserInput` |
+| Components | `PascalCase` | `UserProfile`, `ChatScreen` |
+| Files / directories | `lowercase-hyphenated` | `user-profile.tsx`, `chat-screen/` |
+| Zustand stores | `use[Domain]Store` | `useAuthStore`, `useCoreStore`, `useCallsStore` |
+| API modules | `camelCase` exports | `getCalls()`, `createCall()`, `getCall()` |
+
+### Conditional Rendering
+
+**Always use ternary `? :` — never use `&&`** for conditional rendering:
+
+```tsx
+// ✅ Correct
+{isLoading ? : }
+
+// ❌ Wrong — can render "false" or "0" as text
+{isLoading && }
+```
+
+### Imports
+
+ESLint enforces `simple-import-sort` with this grouping order:
+1. Side-effect imports (e.g., `import '../../global.css'`)
+2. External packages (react, expo, third-party)
+3. Internal aliases (`@/`, `@env`, `@assets/`)
+4. Relative imports
+5. Type imports
+
+---
+
+## API Layer Pattern
+
+All API modules follow a consistent pattern using `createApiEndpoint` and `createCachedApiEndpoint`:
+
+```typescript
+// src/api/calls/calls.ts
+import { createCachedApiEndpoint } from '../common/cached-client';
+import { createApiEndpoint } from '../common/client';
+
+// Cached endpoint (auto-invalidates via cacheManager)
+const callsApi = createCachedApiEndpoint('/Calls/GetActiveCalls', {
+ ttl: 30 * 1000, // 30 seconds
+ enabled: true,
+});
+
+// Simple endpoint
+const getCallApi = createApiEndpoint('/Calls/GetCall');
+
+export const getCalls = async () => {
+ const response = await callsApi.get();
+ return response.data;
+};
+```
+
+**Key rules:**
+- The Axios client (`src/api/common/client.tsx`) handles auth token injection and automatic 401 refresh via interceptors.
+- Always use the typed generic `` on `.get()`, `.post()`, etc.
+- After mutations (create/update/delete), invalidate relevant caches via `cacheManager.remove()`.
+- API response models live in `src/models/v4/` organized by domain.
+
+---
+
+## State Management (Zustand)
+
+Stores use Zustand with MMKV persistence:
+
+```typescript
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+import { zustandStorage } from '@/lib/storage';
+
+interface MyState {
+ data: SomeType | null;
+ isLoading: boolean;
+ fetchData: () => Promise;
+}
+
+export const useMyStore = create()(
+ persist(
+ (set, get) => ({
+ data: null,
+ isLoading: false,
+ fetchData: async () => {
+ set({ isLoading: true });
+ try {
+ const result = await someApiCall();
+ set({ data: result, isLoading: false });
+ } catch (error) {
+ set({ isLoading: false });
+ logger.error({ message: 'Failed to fetch', context: { error } });
+ }
+ },
+ }),
+ {
+ name: 'my-storage',
+ storage: createJSONStorage(() => zustandStorage),
+ partialize: (state) => ({
+ // Only persist what's needed; exclude transient flags like isLoading
+ data: state.data,
+ }),
+ }
+ )
+);
+```
+
+**Key rules:**
+- Use `partialize` to exclude transient state (loading, error flags) from persistence.
+- Access store outside React via `useMyStore.getState()` (e.g., in services or interceptors).
+- Cross-store interactions use `useOtherStore.getState()` — avoid circular dependencies.
+
+---
+
+## Services (Singleton Pattern)
+
+Services like `SignalRService` and `LogService` use the singleton pattern:
+
+```typescript
+class MyService {
+ private static instance: MyService | null = null;
+ private constructor() {}
+
+ public static getInstance(): MyService {
+ if (!MyService.instance) {
+ MyService.instance = new MyService();
+ }
+ return MyService.instance;
+ }
+
+ // ... methods
+}
+
+export const myService = MyService.getInstance();
+```
+
+---
+
+## Styling
+
+- **Primary**: Use `gluestack-ui` components from `src/components/ui/` when available.
+- **Tailwind/NativeWind**: For utility-first styling via `className` props.
+- **Fallback**: Use `StyleSheet.create()` for styles without a gluestack equivalent.
+- **Dark mode + light mode** must be supported — the app responds to system color scheme via `useColorScheme()`.
+- **Colors**: Use semantic color tokens from Tailwind config (`primary`, `secondary`, `background`, `typography`, etc.), not hardcoded hex values.
+
+---
+
+## Internationalization (i18n)
+
+**All user-visible text MUST be wrapped in `t()`** from `react-i18next`:
+
+```tsx
+import { useTranslation } from 'react-i18next';
+
+const { t } = useTranslation();
+return {t('calls.noActiveCalls')};
+```
+
+- Translation files: `src/translations/*.json` (en.json is the source of truth)
+- Supported: en, es, fr, de, it, pl, sv, uk, ar
+- Translation keys are enforced to be sorted alphabetically and identical across files (ESLint plugin)
+- Use interpolation for dynamic values: `t('greeting', { name: user.name })`
+
+---
+
+## Environment Variables
+
+Managed via `env.js` with Zod validation. Four environments: `development`, `staging`, `internal`, `production`.
+
+- **Client vars** (used in `src/`): Import via `import { Env } from '@env'`
+- **Build-time vars** (used in `app.config.ts`): Accessed directly in `env.js`
+- Each environment has a `.env.{APP_ENV}` file at project root.
+- New variables must be added to both the Zod schema AND the corresponding object in `env.js`.
+
+---
+
+## Testing
+
+- **Framework**: Jest with `jest-expo` preset
+- **Utilities**: `@testing-library/react-native`
+- **Test location**: `__tests__/` directories co-located with source files
+- **Test naming**: `*.test.tsx` or `*.test.ts`
+
+```bash
+yarn test # Run all tests
+yarn test:watch # Watch mode
+yarn test:ci # CI mode with coverage
+```
+
+**Test conventions:**
+- Mock native modules at the top of test files (before imports).
+- Mock stores, services, and hooks using `jest.mock()`.
+- Use `TestWrapper` components for providers.
+- Always call `unmount()` in tests to clean up.
+- Use `jest.useFakeTimers()` / `jest.useRealTimers()` for time-dependent tests.
+- Generate tests for all new components, services, and logic.
+
+---
+
+## Linting & Formatting
+
+```bash
+yarn lint # ESLint (src/**/*.ts,tsx)
+yarn type-check # tsc --noemit
+yarn lint:translations # Validate i18n JSON files
+yarn check-all # Run all three
+```
+
+**Prettier config:**
+- Single quotes
+- Trailing commas (ES5)
+- Print width: 220
+- Auto line endings
+
+**ESLint highlights:**
+- `@typescript-eslint/consistent-type-imports`: Enforces `import type` for type-only imports
+- `simple-import-sort`: Enforces import ordering
+- `react-compiler`: React Compiler plugin enabled
+- Max function length: 1500 lines
+- Max function params: 10 (use an object parameter instead)
+
+---
+
+## Git & Commits
+
+**Conventional Commits** (enforced by commitlint):
+
+```
+feat: add call priority filtering
+fix: prevent token refresh race condition
+refactor: extract location service into singleton
+chore: update Expo SDK to 54
+```
+
+---
+
+## Platforms
+
+The app runs on **four platforms**. Use platform utilities from `src/lib/platform.ts`:
+
+```typescript
+import { isWeb, isNative, isIOS, isAndroid, isElectron, isDesktop } from '@/lib/platform';
+```
+
+Platform-specific files use the extension pattern:
+- `callkeep.service.ios.ts` / `callkeep.service.android.ts` / `callkeep.service.web.ts`
+- React Native / Metro resolves these automatically based on platform.
+
+---
+
+## Key Libraries — Use These, Not Alternatives
+
+| Purpose | Library | Notes |
+|---|---|---|
+| HTTP | `axios` | Via `createApiEndpoint` / `createCachedApiEndpoint` |
+| State | `zustand` | With MMKV persistence |
+| Data fetching | `@tanstack/react-query` | Wraps API calls for caching/invalidation |
+| Forms | `react-hook-form` | With `@hookform/resolvers` + `zod` |
+| i18n | `react-i18next` | All text in `t()` |
+| Local storage | `react-native-mmkv` | Via `@/lib/storage` |
+| Secure storage | `expo-secure-store` | For tokens/credentials |
+| Maps | `@rnmapbox/maps` | Mapbox GL for all mapping |
+| Icons | `lucide-react-native` | Use directly, NOT via gluestack Icon |
+| Animations | `react-native-reanimated` + `@legendapp/motion` | |
+| Bottom sheets | `@gorhom/bottom-sheet` | |
+| Lists | `@shopify/flash-list` | For performant lists |
+| Dates | `date-fns` | |
+| Error tracking | `@sentry/react-native` | Errors auto-reported via logger |
+
+---
+
+## Logging
+
+Use the shared logger singleton — **never use `console.log`** for production code:
+
+```typescript
+import { logger } from '@/lib/logging';
+
+logger.info({ message: 'User logged in', context: { userId } });
+logger.warn({ message: 'Slow response', context: { duration } });
+logger.error({ message: 'API call failed', context: { error } });
+```
+
+- `logger.error()` automatically reports to Sentry.
+- Sensitive keys (tokens, passwords, emails) are automatically redacted from context.
+- In tests, logging is automatically disabled.
+
+---
+
+## Performance Guidelines
+
+- Minimize `useEffect`, `useState`, and heavy computation inside render methods.
+- Use `React.memo()` for components with static props.
+- Optimize `FlatList` / `FlashList` with `removeClippedSubviews`, `maxToRenderPerBatch`, `windowSize`, and `getItemLayout` when items have consistent size.
+- Avoid anonymous functions in `renderItem` or event handlers.
+- Optimize for low-end devices.
+
+---
+
+## Accessibility
+
+- Follow WCAG guidelines for mobile.
+- Use semantic components and accessible labels.
+- Ensure sufficient color contrast in both light and dark mode.
+
+---
+
+## Error Handling
+
+- Handle errors gracefully and provide user feedback via toast notifications (`useToastStore`).
+- API errors are handled by Axios interceptors (auto-logout on auth failure).
+- Services implement retry logic with exponential backoff where appropriate.
+- All async operations should have proper try/catch with logging.
diff --git a/CLAUDE.md b/CLAUDE.md
index f545094b..0346f30f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,155 +1,427 @@
-
-# Dual-Graph Context Policy
+# Resgrid Unit — AI Coding Guidelines
-This project uses a local dual-graph MCP server for efficient context retrieval.
+> **Resgrid Unit** is a multi-platform emergency response mobile application built with TypeScript, React Native, Expo (managed + prebuild), targeting iOS, Android, Web, and Electron.
-## MANDATORY: Always follow this order
+---
+
+## Tech Stack
+
+| Layer | Technology |
+|---|---|
+| Framework | React Native 0.81 + Expo SDK 54 (managed, prebuild) |
+| Language | TypeScript (strict mode) |
+| Routing | Expo Router (file-based, typed routes) |
+| State | Zustand (with MMKV persistence) |
+| Data Fetching | @tanstack/react-query (v5) + Axios |
+| Forms | react-hook-form + zod validation |
+| i18n | react-i18next (9 languages) |
+| UI Components | gluestack-ui (`src/components/ui/`) |
+| Styling | NativeWind / Tailwind CSS |
+| Icons | lucide-react-native (use directly, NOT via gluestack Icon wrapper) |
+| Maps | @rnmapbox/maps |
+| Realtime | @microsoft/signalr |
+| Voice/Video | LiveKit (@livekit/react-native) |
+| Storage | react-native-mmkv (local), expo-secure-store (sensitive) |
+| Logging | Custom singleton → react-native-logs + @sentry/react-native |
+| Push | @notifee/react-native, @react-native-firebase/messaging |
+| Package Manager | yarn (v1 classic) |
+
+---
+
+## Project Structure
+
+```
+src/
+├── api/ # API layer (organized by domain)
+│ ├── common/ # Shared: axios client, cached client, API provider
+│ ├── calls/ # Call endpoints (calls.ts, callNotes.ts, etc.)
+│ └── ... # Other domain API modules
+├── app/ # Expo Router file-based routes
+│ ├── _layout.tsx # Root layout (providers, Sentry wrapper)
+│ ├── (app)/ # Authenticated tab group
+│ ├── call/ # Call screens ([id].tsx, new/, edit)
+│ ├── login/ # Login & SSO screens
+│ └── maps/ # Map screens (custom, indoor, search)
+├── components/ # Shared components
+│ ├── ui/ # gluestack-ui component library
+│ ├── common/ # Cross-feature shared components
+│ └── [domain]/ # Domain-specific components (maps, calls, etc.)
+├── constants/ # App constants (colors, map icons)
+├── features/ # Feature-specific modules (livekit-call)
+├── hooks/ # Custom React hooks
+├── lib/ # Core utilities & services
+│ ├── auth/ # Auth API, types, and utilities
+│ ├── cache/ # Cache manager for API responses
+│ ├── i18n/ # Internationalization setup
+│ ├── logging/ # Logging singleton (→ Sentry)
+│ ├── storage/ # MMKV storage + Zustand adapter
+│ └── native-modules/ # Platform-specific native module wrappers
+├── models/ # TypeScript types for API responses (v4/)
+├── providers/ # React context providers
+├── services/ # Singleton services (signalr, push, audio, location, etc.)
+├── stores/ # Zustand stores (organized by domain)
+│ ├── auth/ # Auth store (login, tokens, profile)
+│ ├── app/ # Core app state, lifecycle, location, audio, bluetooth
+│ ├── calls/ # Calls state
+│ └── ... # Other domain stores
+├── translations/ # i18n JSON files (en, es, fr, de, it, pl, sv, uk, ar)
+├── types/ # Shared TypeScript type definitions
+└── utils/ # Pure utility functions
+```
+
+---
+
+## Path Aliases
+
+Configured in `tsconfig.json` — **always use these** instead of relative paths:
-1. **Call `graph_continue` first** — before any file exploration, grep, or code reading.
+| Alias | Maps To |
+|---|---|
+| `@/*` | `./src/*` |
+| `@env` | `./src/lib/env.js` |
+| `@assets/*` | `./assets/*` |
+
+```typescript
+// ✅ Correct
+import { logger } from '@/lib/logging';
+import { Env } from '@env';
-2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the
- current project directory (`pwd`). Do NOT ask the user.
+// ❌ Wrong
+import { logger } from '../../lib/logging';
+```
-3. **If `graph_continue` returns `skip=true`**: project has fewer than 5 files.
- Do NOT do broad or recursive exploration. Read only specific files if their names
- are mentioned, or ask the user what to work on.
+---
-4. **Read `recommended_files`** using `graph_read` — **one call per file**.
- - `graph_read` accepts a single `file` parameter (string). Call it separately for each
- recommended file. Do NOT pass an array or batch multiple files into one call.
- - `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`).
- Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` — it reads only
- that symbol's lines, not the full file.
- - Example: if `recommended_files` is `["src/auth.ts::handleLogin", "src/db.ts"]`,
- call `graph_read(file: "src/auth.ts::handleLogin")` and `graph_read(file: "src/db.ts")`
- as two separate calls (they can be parallel).
+## Code Style & Conventions
-5. **Check `confidence` and obey the caps strictly:**
- - `confidence=high` -> Stop. Do NOT grep or explore further.
- - `confidence=medium` -> If recommended files are insufficient, call `fallback_rg`
- at most `max_supplementary_greps` time(s) with specific terms, then `graph_read`
- at most `max_supplementary_files` additional file(s). Then stop.
- - `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s),
- then `graph_read` at most `max_supplementary_files` file(s). Then stop.
+### TypeScript
-## Token Usage
+- **Strict mode** is enabled. Never use `any`; prefer precise types and interfaces.
+- Use `interface` for props and state definitions.
+- Use `type` imports for type-only imports (enforced by ESLint):
+ ```typescript
+ import type { CallResult } from '@/models/v4/calls/callResult';
+ ```
-A `token-counter` MCP is available for tracking live token usage.
+### Components
-- To check how many tokens a large file or text will cost **before** reading it:
- `count_tokens({text: ""})`
-- To log actual usage after a task completes (if the user asks):
- `log_usage({input_tokens: , output_tokens: , description: ""})`
-- To show the user their running session cost:
- `get_session_stats()`
+- **Functional components only** — never class components.
+- Use `React.FC` for typed components.
+- **All components must be mobile-friendly and responsive** across iOS and Android.
+- This is an Expo managed project using prebuild — **do NOT make native code changes** outside Expo prebuild capabilities.
-Live dashboard URL is printed at startup next to "Token usage".
+### Naming
-## Rules
+| Kind | Convention | Example |
+|---|---|---|
+| Variables / functions | `camelCase` | `isFetchingData`, `handleUserInput` |
+| Components | `PascalCase` | `UserProfile`, `ChatScreen` |
+| Files / directories | `lowercase-hyphenated` | `user-profile.tsx`, `chat-screen/` |
+| Zustand stores | `use[Domain]Store` | `useAuthStore`, `useCoreStore`, `useCallsStore` |
+| API modules | `camelCase` exports | `getCalls()`, `createCall()`, `getCall()` |
-- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`.
-- Do NOT do broad/recursive exploration at any confidence level.
-- `max_supplementary_greps` and `max_supplementary_files` are hard caps - never exceed them.
-- Do NOT dump full chat history.
-- Do NOT call `graph_retrieve` more than once per turn.
-- After edits, call `graph_register_edit` with the changed files. Use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) when the edit targets a specific function, class, or hook.
+### Conditional Rendering
-## Context Store
+**Always use ternary `? :` — never use `&&`** for conditional rendering:
-Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`.
+```tsx
+// ✅ Correct
+{isLoading ? : }
-**Entry format:**
-```json
-{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"}
+// ❌ Wrong — can render "false" or "0" as text
+{isLoading && }
```
-**To append:** Read the file → add the new entry to the array → Write it back → call `graph_register_edit` on `.dual-graph/context-store.json`.
+### Imports
+
+ESLint enforces `simple-import-sort` with this grouping order:
+1. Side-effect imports (e.g., `import '../../global.css'`)
+2. External packages (react, expo, third-party)
+3. Internal aliases (`@/`, `@env`, `@assets/`)
+4. Relative imports
+5. Type imports
+
+---
+
+## API Layer Pattern
-**Rules:**
-- Only log things worth remembering across sessions (not every minor detail)
-- `content` must be under 15 words
-- `files` lists the files this decision/task relates to (can be empty)
-- Log immediately when the item arises — not at session end
+All API modules follow a consistent pattern using `createApiEndpoint` and `createCachedApiEndpoint`:
-## Session End
+```typescript
+// src/api/calls/calls.ts
+import { createCachedApiEndpoint } from '../common/cached-client';
+import { createApiEndpoint } from '../common/client';
-When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with:
-- **Current Task**: one sentence on what was being worked on
-- **Key Decisions**: bullet list, max 3 items
-- **Next Steps**: bullet list, max 3 items
+// Cached endpoint (auto-invalidates via cacheManager)
+const callsApi = createCachedApiEndpoint('/Calls/GetActiveCalls', {
+ ttl: 30 * 1000, // 30 seconds
+ enabled: true,
+});
-Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation — only what's needed to resume next session.
+// Simple endpoint
+const getCallApi = createApiEndpoint('/Calls/GetCall');
+
+export const getCalls = async () => {
+ const response = await callsApi.get();
+ return response.data;
+};
+```
+
+**Key rules:**
+- The Axios client (`src/api/common/client.tsx`) handles auth token injection and automatic 401 refresh via interceptors.
+- Always use the typed generic `` on `.get()`, `.post()`, etc.
+- After mutations (create/update/delete), invalidate relevant caches via `cacheManager.remove()`.
+- API response models live in `src/models/v4/` organized by domain.
---
-# Project: Resgrid Unit (React Native / Expo)
+## State Management (Zustand)
+
+Stores use Zustand with MMKV persistence:
+
+```typescript
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+import { zustandStorage } from '@/lib/storage';
+
+interface MyState {
+ data: SomeType | null;
+ isLoading: boolean;
+ fetchData: () => Promise;
+}
+
+export const useMyStore = create()(
+ persist(
+ (set, get) => ({
+ data: null,
+ isLoading: false,
+ fetchData: async () => {
+ set({ isLoading: true });
+ try {
+ const result = await someApiCall();
+ set({ data: result, isLoading: false });
+ } catch (error) {
+ set({ isLoading: false });
+ logger.error({ message: 'Failed to fetch', context: { error } });
+ }
+ },
+ }),
+ {
+ name: 'my-storage',
+ storage: createJSONStorage(() => zustandStorage),
+ partialize: (state) => ({
+ // Only persist what's needed; exclude transient flags like isLoading
+ data: state.data,
+ }),
+ }
+ )
+);
+```
+
+**Key rules:**
+- Use `partialize` to exclude transient state (loading, error flags) from persistence.
+- Access store outside React via `useMyStore.getState()` (e.g., in services or interceptors).
+- Cross-store interactions use `useOtherStore.getState()` — avoid circular dependencies.
-## Tech Stack
+---
-TypeScript · React Native · Expo (managed, prebuild) · Zustand · React Query · React Hook Form · react-i18next · react-native-mmkv · Axios · @rnmapbox/maps · gluestack-ui · lucide-react-native
+## Services (Singleton Pattern)
-## Code Style
+Services like `SignalRService` and `LogService` use the singleton pattern:
-- Write concise, type-safe TypeScript. Avoid `any`; use precise types and interfaces for props/state.
-- Use functional components and hooks; never class components. Use `React.FC` for typed components.
-- Enable strict mode in `tsconfig.json`.
-- Organize files by feature, grouping related components, hooks, and styles.
-- All components must be mobile-friendly and responsive, supporting both iOS and Android.
-- This is an Expo managed project using prebuild — **do not make native code changes** outside Expo prebuild capabilities.
+```typescript
+class MyService {
+ private static instance: MyService | null = null;
+ private constructor() {}
-## Naming Conventions
+ public static getInstance(): MyService {
+ if (!MyService.instance) {
+ MyService.instance = new MyService();
+ }
+ return MyService.instance;
+ }
-- Variables and functions: `camelCase` (e.g., `isFetchingData`, `handleUserInput`)
-- Components: `PascalCase` (e.g., `UserProfile`, `ChatScreen`)
-- Files and directories: `lowercase-hyphenated` (e.g., `user-profile.tsx`, `chat-screen/`)
+ // ... methods
+}
+
+export const myService = MyService.getInstance();
+```
+
+---
## Styling
-- Use `gluestack-ui` components from `components/ui` when available.
-- For anything without a Gluestack component, use `StyleSheet.create()` or Styled Components.
-- Support both **dark mode and light mode**.
-- Follow WCAG accessibility guidelines for mobile.
+- **Primary**: Use `gluestack-ui` components from `src/components/ui/` when available.
+- **Tailwind/NativeWind**: For utility-first styling via `className` props.
+- **Fallback**: Use `StyleSheet.create()` for styles without a gluestack equivalent.
+- **Dark mode + light mode** must be supported — the app responds to system color scheme via `useColorScheme()`.
+- **Colors**: Use semantic color tokens from Tailwind config (`primary`, `secondary`, `background`, `typography`, etc.), not hardcoded hex values.
-## Performance
+---
-- Minimize `useEffect`, `useState`, and heavy computation inside render methods.
-- Use `React.memo()` for components with static props.
-- Optimize `FlatList` with `removeClippedSubviews`, `maxToRenderPerBatch`, `windowSize`, and `getItemLayout` when items have a consistent size.
-- Avoid anonymous functions in `renderItem` or event handlers.
+## Internationalization (i18n)
+
+**All user-visible text MUST be wrapped in `t()`** from `react-i18next`:
-## Internationalization
+```tsx
+import { useTranslation } from 'react-i18next';
-- All user-visible text **must** be wrapped in `t()` from `react-i18next`.
-- Translation dictionary files live in `src/translations/`.
+const { t } = useTranslation();
+return {t('calls.noActiveCalls')};
+```
-## Libraries — use these, not alternatives
+- Translation files: `src/translations/*.json` (en.json is the source of truth)
+- Supported: en, es, fr, de, it, pl, sv, uk, ar
+- Translation keys are enforced to be sorted alphabetically and identical across files (ESLint plugin)
+- Use interpolation for dynamic values: `t('greeting', { name: user.name })`
-| Purpose | Library |
-|---|---|
-| Package manager | `yarn` |
-| State management | `zustand` |
-| Data fetching | `react-query` |
-| Forms | `react-hook-form` |
-| i18n | `react-i18next` |
-| Local storage | `react-native-mmkv` |
-| Secure storage | Expo SecureStore |
-| HTTP | `axios` |
-| Maps / navigation | `@rnmapbox/maps` |
-| Icons | `lucide-react-native` (use directly in markup, not via gluestack Icon wrapper) |
+---
-## Conditional Rendering
+## Environment Variables
-Use ternary `? :` for conditional rendering — **never `&&`**.
+Managed via `env.js` with Zod validation. Four environments: `development`, `staging`, `internal`, `production`.
+
+- **Client vars** (used in `src/`): Import via `import { Env } from '@env'`
+- **Build-time vars** (used in `app.config.ts`): Accessed directly in `env.js`
+- Each environment has a `.env.{APP_ENV}` file at project root.
+- New variables must be added to both the Zod schema AND the corresponding object in `env.js`.
+
+---
## Testing
-- Use Jest. Generate tests for all new components, services, and logic.
-- Ensure tests run without errors before considering a task done.
+- **Framework**: Jest with `jest-expo` preset
+- **Utilities**: `@testing-library/react-native`
+- **Test location**: `__tests__/` directories co-located with source files
+- **Test naming**: `*.test.tsx` or `*.test.ts`
+
+```bash
+yarn test # Run all tests
+yarn test:watch # Watch mode
+yarn test:ci # CI mode with coverage
+```
-## Best Practices
+**Test conventions:**
+- Mock native modules at the top of test files (before imports).
+- Mock stores, services, and hooks using `jest.mock()`.
+- Use `TestWrapper` components for providers.
+- Always call `unmount()` in tests to clean up.
+- Use `jest.useFakeTimers()` / `jest.useRealTimers()` for time-dependent tests.
+- Generate tests for all new components, services, and logic.
-- Follow React Native's threading model for smooth UI performance.
-- Use React Navigation for navigation and deep linking.
-- Handle errors gracefully and provide user feedback.
-- Implement proper offline support.
+---
+
+## Linting & Formatting
+
+```bash
+yarn lint # ESLint (src/**/*.ts,tsx)
+yarn type-check # tsc --noemit
+yarn lint:translations # Validate i18n JSON files
+yarn check-all # Run all three
+```
+
+**Prettier config:**
+- Single quotes
+- Trailing commas (ES5)
+- Print width: 220
+- Auto line endings
+
+**ESLint highlights:**
+- `@typescript-eslint/consistent-type-imports`: Enforces `import type` for type-only imports
+- `simple-import-sort`: Enforces import ordering
+- `react-compiler`: React Compiler plugin enabled
+- Max function length: 1500 lines
+- Max function params: 10 (use an object parameter instead)
+
+---
+
+## Git & Commits
+
+**Conventional Commits** (enforced by commitlint):
+
+```
+feat: add call priority filtering
+fix: prevent token refresh race condition
+refactor: extract location service into singleton
+chore: update Expo SDK to 54
+```
+
+---
+
+## Platforms
+
+The app runs on **four platforms**. Use platform utilities from `src/lib/platform.ts`:
+
+```typescript
+import { isWeb, isNative, isIOS, isAndroid, isElectron, isDesktop } from '@/lib/platform';
+```
+
+Platform-specific files use the extension pattern:
+- `callkeep.service.ios.ts` / `callkeep.service.android.ts` / `callkeep.service.web.ts`
+- React Native / Metro resolves these automatically based on platform.
+
+---
+
+## Key Libraries — Use These, Not Alternatives
+
+| Purpose | Library | Notes |
+|---|---|---|
+| HTTP | `axios` | Via `createApiEndpoint` / `createCachedApiEndpoint` |
+| State | `zustand` | With MMKV persistence |
+| Data fetching | `@tanstack/react-query` | Wraps API calls for caching/invalidation |
+| Forms | `react-hook-form` | With `@hookform/resolvers` + `zod` |
+| i18n | `react-i18next` | All text in `t()` |
+| Local storage | `react-native-mmkv` | Via `@/lib/storage` |
+| Secure storage | `expo-secure-store` | For tokens/credentials |
+| Maps | `@rnmapbox/maps` | Mapbox GL for all mapping |
+| Icons | `lucide-react-native` | Use directly, NOT via gluestack Icon |
+| Animations | `react-native-reanimated` + `@legendapp/motion` | |
+| Bottom sheets | `@gorhom/bottom-sheet` | |
+| Lists | `@shopify/flash-list` | For performant lists |
+| Dates | `date-fns` | |
+| Error tracking | `@sentry/react-native` | Errors auto-reported via logger |
+
+---
+
+## Logging
+
+Use the shared logger singleton — **never use `console.log`** for production code:
+
+```typescript
+import { logger } from '@/lib/logging';
+
+logger.info({ message: 'User logged in', context: { userId } });
+logger.warn({ message: 'Slow response', context: { duration } });
+logger.error({ message: 'API call failed', context: { error } });
+```
+
+- `logger.error()` automatically reports to Sentry.
+- Sensitive keys (tokens, passwords, emails) are automatically redacted from context.
+- In tests, logging is automatically disabled.
+
+---
+
+## Performance Guidelines
+
+- Minimize `useEffect`, `useState`, and heavy computation inside render methods.
+- Use `React.memo()` for components with static props.
+- Optimize `FlatList` / `FlashList` with `removeClippedSubviews`, `maxToRenderPerBatch`, `windowSize`, and `getItemLayout` when items have consistent size.
+- Avoid anonymous functions in `renderItem` or event handlers.
- Optimize for low-end devices.
+
+---
+
+## Accessibility
+
+- Follow WCAG guidelines for mobile.
+- Use semantic components and accessible labels.
+- Ensure sufficient color contrast in both light and dark mode.
+
+---
+
+## Error Handling
+
+- Handle errors gracefully and provide user feedback via toast notifications (`useToastStore`).
+- API errors are handled by Axios interceptors (auto-logout on auth failure).
+- Services implement retry logic with exponential backoff where appropriate.
+- All async operations should have proper try/catch with logging.
diff --git a/app.config.ts b/app.config.ts
index cccf848b..2f061998 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -224,6 +224,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'./plugins/withForegroundNotifications.js',
'./plugins/withNotificationSounds.js',
'./plugins/withMediaButtonModule.js',
+ './plugins/withCheckInLiveActivity.js',
'./plugins/withInCallAudioModule.js',
['app-icon-badge', appIconBadgeConfig],
],
diff --git a/babel.config.js b/babel.config.js
index 42f8cb37..12fc0e5c 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -26,7 +26,7 @@ module.exports = function (api) {
},
],
'babel-plugin-transform-import-meta',
- 'react-native-reanimated/plugin',
+ 'react-native-worklets/plugin',
],
};
};
diff --git a/package.json b/package.json
index ff1f45f0..4660dd39 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,7 @@
"@config-plugins/react-native-webrtc": "~12.0.0",
"@dev-plugins/react-query": "~0.2.0",
"@expo/html-elements": "~0.10.1",
- "@expo/metro-runtime": "~5.0.5",
+ "@expo/metro-runtime": "~6.1.2",
"@gluestack-ui/accordion": "~1.0.6",
"@gluestack-ui/actionsheet": "~0.2.44",
"@gluestack-ui/alert": "~0.1.15",
@@ -88,7 +88,7 @@
"@gluestack-ui/textarea": "~0.1.23",
"@gluestack-ui/toast": "~1.0.8",
"@gluestack-ui/tooltip": "~0.1.32",
- "@gorhom/bottom-sheet": "~5.0.5",
+ "@gorhom/bottom-sheet": "~5.2.13",
"@hookform/resolvers": "~3.9.0",
"@legendapp/motion": "~2.4.0",
"@livekit/react-native": "^2.9.1",
@@ -102,47 +102,48 @@
"@react-native-firebase/messaging": "^23.5.0",
"@rnmapbox/maps": "10.2.10",
"@semantic-release/git": "^10.0.1",
- "@sentry/react-native": "~6.14.0",
- "@shopify/flash-list": "1.7.6",
+ "@sentry/react-native": "^8.10.0",
+ "@shopify/flash-list": "2.0.2",
"@tanstack/react-query": "~5.52.1",
"app-icon-badge": "^0.1.2",
"axios": "~1.12.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-transform-import-meta": "^2.3.3",
"buffer": "^6.0.3",
- "countly-sdk-react-native-bridge": "25.4.1",
+ "countly-sdk-react-native-bridge": "26.1.0",
"date-fns": "^4.1.0",
- "expo": "~53.0.27",
- "expo-application": "~6.1.5",
- "expo-asset": "~11.1.7",
- "expo-audio": "~0.4.9",
- "expo-auth-session": "~6.2.1",
- "expo-av": "~15.1.7",
- "expo-build-properties": "~0.14.8",
- "expo-constants": "~17.1.8",
- "expo-crypto": "~14.1.5",
- "expo-dev-client": "~5.2.4",
- "expo-device": "~7.1.4",
- "expo-document-picker": "~13.1.6",
- "expo-file-system": "~18.1.11",
- "expo-font": "~13.3.2",
- "expo-image": "~2.4.1",
- "expo-image-manipulator": "~13.1.7",
- "expo-image-picker": "~16.1.4",
- "expo-keep-awake": "~14.1.4",
- "expo-linking": "~7.1.7",
- "expo-localization": "~16.1.6",
- "expo-location": "~18.1.6",
- "expo-navigation-bar": "~4.2.8",
- "expo-router": "~5.1.11",
- "expo-screen-orientation": "~8.1.7",
- "expo-secure-store": "~14.2.4",
- "expo-sharing": "~13.1.5",
- "expo-splash-screen": "~0.30.10",
- "expo-status-bar": "~2.2.3",
- "expo-system-ui": "~5.0.11",
- "expo-task-manager": "~13.1.6",
- "expo-web-browser": "~14.2.0",
+ "expo": "^54.0.33",
+ "expo-application": "~7.0.8",
+ "expo-asset": "~12.0.12",
+ "expo-audio": "~1.1.1",
+ "expo-auth-session": "~7.0.10",
+ "expo-av": "~16.0.8",
+ "expo-build-properties": "~1.0.10",
+ "expo-clipboard": "~8.0.8",
+ "expo-constants": "~18.0.13",
+ "expo-crypto": "~15.0.8",
+ "expo-dev-client": "~6.0.20",
+ "expo-device": "~8.0.10",
+ "expo-document-picker": "~14.0.8",
+ "expo-file-system": "~19.0.21",
+ "expo-font": "~14.0.11",
+ "expo-image": "~3.0.11",
+ "expo-image-manipulator": "~14.0.8",
+ "expo-image-picker": "~17.0.10",
+ "expo-keep-awake": "~15.0.8",
+ "expo-linking": "~8.0.11",
+ "expo-localization": "~17.0.8",
+ "expo-location": "~19.0.8",
+ "expo-navigation-bar": "~5.0.10",
+ "expo-router": "~6.0.23",
+ "expo-screen-orientation": "~9.0.8",
+ "expo-secure-store": "~15.0.8",
+ "expo-sharing": "~14.0.8",
+ "expo-splash-screen": "~31.0.13",
+ "expo-status-bar": "~3.0.9",
+ "expo-system-ui": "~6.0.9",
+ "expo-task-manager": "~14.0.9",
+ "expo-web-browser": "~15.0.10",
"geojson": "~0.5.0",
"i18next": "~23.14.0",
"livekit-client": "^2.15.7",
@@ -153,28 +154,29 @@
"moti": "~0.29.0",
"nativewind": "~4.1.21",
"promise": "8.3.0",
- "react": "19.0.0",
- "react-dom": "19.0.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
"react-error-boundary": "~4.0.13",
"react-hook-form": "~7.53.0",
"react-i18next": "~15.0.1",
- "react-native": "0.79.6",
+ "react-native": "0.81.5",
"react-native-base64": "~0.2.1",
"react-native-ble-manager": "^12.1.5",
"react-native-callkeep": "github:Irfanwani/react-native-callkeep#957193d0716f1c2dfdc18e627cbff0f8a0800971",
"react-native-edge-to-edge": "1.6.0",
"react-native-flash-message": "~0.4.2",
- "react-native-gesture-handler": "~2.24.0",
- "react-native-keyboard-controller": "^1.18.6",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-keyboard-controller": "1.18.5",
"react-native-logs": "~5.3.0",
"react-native-mmkv": "~3.1.0",
- "react-native-reanimated": "~3.17.4",
+ "react-native-reanimated": "~4.1.1",
"react-native-restart": "0.0.27",
- "react-native-safe-area-context": "5.4.0",
- "react-native-screens": "~4.11.1",
- "react-native-svg": "15.11.2",
- "react-native-web": "^0.20.0",
- "react-native-webview": "~13.13.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "react-native-svg": "15.12.1",
+ "react-native-web": "^0.21.0",
+ "react-native-webview": "13.15.0",
+ "react-native-worklets": "0.5.1",
"react-query-kit": "~3.3.0",
"tailwind-variants": "~0.2.1",
"zod": "~3.23.8",
@@ -184,7 +186,7 @@
"@babel/core": "~7.26.0",
"@commitlint/cli": "~19.2.2",
"@commitlint/config-conventional": "~19.2.2",
- "@expo/config": "^11.0.0",
+ "@expo/config": "~12.0.12",
"@testing-library/jest-dom": "~6.5.0",
"@testing-library/react-native": "~12.9.0",
"@types/geojson": "~7946.0.16",
@@ -192,7 +194,7 @@
"@types/jest": "~29.5.14",
"@types/lodash.memoize": "~4.1.9",
"@types/mapbox-gl": "3.4.1",
- "@types/react": "~19.0.10",
+ "@types/react": "~19.1.10",
"@types/react-native-base64": "~0.2.2",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
@@ -218,7 +220,7 @@
"eslint-plugin-unused-imports": "~2.0.0",
"jest": "~29.7.0",
"jest-environment-jsdom": "~29.7.0",
- "jest-expo": "~53.0.14",
+ "jest-expo": "~54.0.17",
"jest-junit": "~16.0.0",
"lint-staged": "~15.2.9",
"np": "~10.0.7",
@@ -229,7 +231,7 @@
"tailwindcss": "3.4.4",
"ts-jest": "~29.1.2",
"ts-node": "~10.9.2",
- "typescript": "5.8.x",
+ "typescript": "~5.9.2",
"wait-on": "9.0.3"
},
"repository": {
@@ -265,7 +267,7 @@
"initVersion": "7.0.4"
},
"resolutions": {
- "@expo/metro-config": "~0.20.18",
+ "@expo/metro-config": "~54.0.14",
"form-data": "4.0.4",
"promise": "8.3.0"
}
diff --git a/patches/@gluestack-ui+actionsheet+0.2.53.patch b/patches/@gluestack-ui+actionsheet+0.2.53.patch
new file mode 100644
index 00000000..19c2505b
--- /dev/null
+++ b/patches/@gluestack-ui+actionsheet+0.2.53.patch
@@ -0,0 +1,66 @@
+diff --git a/node_modules/@gluestack-ui/actionsheet/lib/ActionsheetContent.jsx b/node_modules/@gluestack-ui/actionsheet/lib/ActionsheetContent.jsx
+index 5055fe3..b7b0eca 100644
+--- a/node_modules/@gluestack-ui/actionsheet/lib/ActionsheetContent.jsx
++++ b/node_modules/@gluestack-ui/actionsheet/lib/ActionsheetContent.jsx
+@@ -1,6 +1,6 @@
+ /* eslint-disable react-native/no-inline-styles */
+ import React, { forwardRef } from 'react';
+-import { Animated, Dimensions, AccessibilityInfo, Platform, Keyboard, } from 'react-native';
++import { Animated, Dimensions, AccessibilityInfo, Platform, Keyboard, useWindowDimensions, } from 'react-native';
+ import { ActionsheetContext } from './context';
+ import { ActionsheetContentProvider } from './ActionsheetContentContext';
+ import { OverlayAnimatePresence } from './OverlayAnimatePresence';
+@@ -8,15 +8,11 @@ import { FocusScope } from '@react-native-aria/focus';
+ import { mergeRefs, findNodeHandle } from '@gluestack-ui/utils';
+ import { useDialog } from '@react-native-aria/dialog';
+ import { usePreventScroll } from '@react-native-aria/overlays';
+-//dimentions not giving proper window height on web
+-const windowHeight = Platform.OS === 'web'
+- ? typeof window !== 'undefined'
+- ? window.innerHeight
+- : Dimensions.get('screen').height
+- : Dimensions.get('screen').height;
++// Patched: use useWindowDimensions for reactive height (fixes landscape)
+ function ActionsheetContent(StyledActionsheetContent, AnimatePresence) {
+ return forwardRef(({ children, _experimentalContent = false, focusScope = true, ...props }, ref) => {
+ const { visible, handleClose, trapFocus, initialFocusRef, handleCloseBackdrop, finalFocusRef, snapPoints, preventScroll, } = React.useContext(ActionsheetContext);
++ const { height: windowHeight } = useWindowDimensions();
+ usePreventScroll({ isDisabled: preventScroll });
+ const pan = React.useRef(new Animated.ValueXY()).current;
+ const contentSheetHeight = React.useRef(0);
+diff --git a/node_modules/@gluestack-ui/actionsheet/src/ActionsheetContent.tsx b/node_modules/@gluestack-ui/actionsheet/src/ActionsheetContent.tsx
+index 425a859..f0ec760 100644
+--- a/node_modules/@gluestack-ui/actionsheet/src/ActionsheetContent.tsx
++++ b/node_modules/@gluestack-ui/actionsheet/src/ActionsheetContent.tsx
+@@ -6,6 +6,7 @@ import {
+ AccessibilityInfo,
+ Platform,
+ Keyboard,
++ useWindowDimensions,
+ } from 'react-native';
+ import { ActionsheetContext } from './context';
+ import { ActionsheetContentProvider } from './ActionsheetContentContext';
+@@ -15,13 +16,7 @@ import { mergeRefs, findNodeHandle } from '@gluestack-ui/utils';
+ import { useDialog } from '@react-native-aria/dialog';
+ import { usePreventScroll } from '@react-native-aria/overlays';
+
+-//dimentions not giving proper window height on web
+-const windowHeight =
+- Platform.OS === 'web'
+- ? typeof window !== 'undefined'
+- ? window.innerHeight
+- : Dimensions.get('screen').height
+- : Dimensions.get('screen').height;
++// Patched: use useWindowDimensions for reactive height (fixes landscape)
+
+ function ActionsheetContent(
+ StyledActionsheetContent: any,
+@@ -48,6 +43,8 @@ function ActionsheetContent(
+ preventScroll,
+ } = React.useContext(ActionsheetContext);
+
++ const { height: windowHeight } = useWindowDimensions();
++
+ usePreventScroll({ isDisabled: preventScroll });
+
+ const pan = React.useRef(new Animated.ValueXY()).current;
diff --git a/patches/react-native-mmkv+3.1.0.patch b/patches/react-native-mmkv+3.1.0.patch
new file mode 100644
index 00000000..1a9e1851
--- /dev/null
+++ b/patches/react-native-mmkv+3.1.0.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/react-native-mmkv/android/CMakeLists.txt b/node_modules/react-native-mmkv/android/CMakeLists.txt
+index 9b7794c..945c10c 100644
+--- a/node_modules/react-native-mmkv/android/CMakeLists.txt
++++ b/node_modules/react-native-mmkv/android/CMakeLists.txt
+@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.9.0)
+ project(ReactNativeMmkv)
+
+ set(CMAKE_VERBOSE_MAKEFILE ON)
+-set(CMAKE_CXX_STANDARD 17)
++set(CMAKE_CXX_STANDARD 20)
+
+ # Compile sources
+ add_library(
diff --git a/plugins/withCheckInLiveActivity.js b/plugins/withCheckInLiveActivity.js
new file mode 100644
index 00000000..27ff258d
--- /dev/null
+++ b/plugins/withCheckInLiveActivity.js
@@ -0,0 +1,507 @@
+const { withDangerousMod, withInfoPlist, withEntitlementsPlist, withXcodeProject } = require('expo/config-plugins');
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Resolves the iOS app target name so bridge files land in the correct folder.
+ *
+ * Resolution order:
+ * 1. config.modRequest.projectName (set by Expo during prebuild — preferred)
+ * 2. Parse ios/.xcodeproj/project.pbxproj and find the PBXNativeTarget
+ * whose productType is "com.apple.product-type.application"
+ *
+ * Throws an explicit error if neither source yields a name, so the developer is
+ * informed immediately instead of files being silently written to the wrong path.
+ *
+ * @param {object} config - Expo config object inside withDangerousMod callback
+ * @param {string} projectRoot - absolute path to the project root
+ * @returns {string} iOS app target / folder name
+ */
+function resolveIosAppName(config, projectRoot) {
+ // 1. Trust Expo's own projectName first (present during `expo prebuild`)
+ if (config.modRequest.projectName) {
+ return config.modRequest.projectName;
+ }
+
+ // 2. Derive the name by parsing project.pbxproj
+ const iosDir = path.join(projectRoot, 'ios');
+ if (fs.existsSync(iosDir)) {
+ let pbxprojPath = null;
+ try {
+ const entries = fs.readdirSync(iosDir);
+ const xcodeprojDir = entries.find((e) => e.endsWith('.xcodeproj'));
+ if (xcodeprojDir) {
+ pbxprojPath = path.join(iosDir, xcodeprojDir, 'project.pbxproj');
+ }
+ } catch (_) {
+ // iosDir not readable — fall through to throw below
+ }
+
+ if (pbxprojPath && fs.existsSync(pbxprojPath)) {
+ const pbxContent = fs.readFileSync(pbxprojPath, 'utf8');
+ // Within a PBXNativeTarget block the fields appear in this order:
+ // name = TargetName;
+ // productName = TargetName;
+ // productReference = /* TargetName.app */;
+ // productType = "com.apple.product-type.application";
+ // The `s` (dotAll) flag lets the pattern span newlines.
+ const match = pbxContent.match(
+ /name\s*=\s*([^\s;]+)\s*;\s*productName\s*=\s*[^;]+;\s*productReference\s*=\s*[^;]+;\s*productType\s*=\s*"com\.apple\.product-type\.application"/s
+ );
+ if (match) {
+ return match[1].trim();
+ }
+ }
+ }
+
+ throw new Error(
+ '[withCheckInLiveActivity] Cannot determine the iOS app target name.\n' +
+ ' • config.modRequest.projectName is not set\n' +
+ ' • No PBXNativeTarget with productType=com.apple.product-type.application\n' +
+ ' was found in ios/*.xcodeproj/project.pbxproj\n' +
+ 'Ensure the iOS project has been initialised via `npx expo prebuild` before\n' +
+ 'running this plugin, or set the `name` field in your app.config.'
+ );
+}
+
+/**
+ * CheckInTimerAttributes.swift — ActivityKit attributes for the check-in timer Live Activity
+ */
+const ATTRIBUTES_SWIFT = `import ActivityKit
+import Foundation
+
+struct CheckInTimerAttributes: ActivityAttributes {
+ public struct ContentState: Codable, Hashable {
+ var elapsedMinutes: Int
+ var status: String
+ var lastCheckIn: String
+ }
+
+ var callName: String
+ var callNumber: String
+ var timerName: String
+ var durationMinutes: Int
+}
+`;
+
+/**
+ * CheckInTimerLiveActivity.swift — SwiftUI views for lock screen and Dynamic Island
+ */
+const LIVE_ACTIVITY_SWIFT = `import ActivityKit
+import SwiftUI
+import WidgetKit
+
+struct CheckInTimerLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: CheckInTimerAttributes.self) { context in
+ // Lock screen / banner UI
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\\(context.attributes.callName) #\\(context.attributes.callNumber)")
+ .font(.headline)
+ .foregroundColor(.white)
+ Text(context.attributes.timerName)
+ .font(.subheadline)
+ .foregroundColor(.white.opacity(0.8))
+ }
+ Spacer()
+ VStack(alignment: .trailing, spacing: 4) {
+ Text("\\(context.state.elapsedMinutes)/\\(context.attributes.durationMinutes) min")
+ .font(.title3)
+ .bold()
+ .foregroundColor(statusColor(context.state.status))
+ Text(context.state.status)
+ .font(.caption)
+ .foregroundColor(statusColor(context.state.status))
+ }
+ }
+ .padding()
+ .background(Color.black)
+ } dynamicIsland: { context in
+ DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Text(context.attributes.timerName)
+ .font(.caption)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ Text("\\(context.state.elapsedMinutes)m")
+ .font(.title3)
+ .foregroundColor(statusColor(context.state.status))
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ ProgressView(value: Double(context.state.elapsedMinutes), total: Double(context.attributes.durationMinutes))
+ .tint(statusColor(context.state.status))
+ }
+ } compactLeading: {
+ Image(systemName: "timer")
+ .foregroundColor(statusColor(context.state.status))
+ } compactTrailing: {
+ Text("\\(context.state.elapsedMinutes)m")
+ .foregroundColor(statusColor(context.state.status))
+ } minimal: {
+ Image(systemName: "timer")
+ .foregroundColor(statusColor(context.state.status))
+ }
+ }
+ }
+
+ private func statusColor(_ status: String) -> Color {
+ switch status {
+ case "Ok": return .green
+ case "Warning": return .yellow
+ case "Overdue": return .red
+ default: return .gray
+ }
+ }
+}
+`;
+
+/**
+ * CheckInTimerWidgetBundle.swift — Widget extension entry point
+ */
+const WIDGET_BUNDLE_SWIFT = `import SwiftUI
+import WidgetKit
+
+@main
+struct CheckInTimerWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ CheckInTimerLiveActivity()
+ }
+}
+`;
+
+/**
+ * CheckInTimerActivityManager.swift — Native bridge for managing Live Activities from RN
+ */
+const ACTIVITY_MANAGER_SWIFT = `import ActivityKit
+import Foundation
+import React
+
+@objc(CheckInTimerActivityManager)
+class CheckInTimerActivityManager: NSObject {
+
+ @objc static func requiresMainQueueSetup() -> Bool { return false }
+
+ @objc
+ func startActivity(_ callName: String, callNumber: String, timerName: String, durationMinutes: Int,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ if #available(iOS 16.1, *) {
+ let attributes = CheckInTimerAttributes(
+ callName: callName, callNumber: callNumber,
+ timerName: timerName, durationMinutes: durationMinutes)
+ let state = CheckInTimerAttributes.ContentState(
+ elapsedMinutes: 0, status: "Ok", lastCheckIn: "")
+ do {
+ let _ = try Activity.request(attributes: attributes, contentState: state)
+ resolve(true)
+ } catch {
+ reject("LIVE_ACTIVITY_ERROR", error.localizedDescription, error)
+ }
+ } else {
+ resolve(false)
+ }
+ }
+
+ @objc
+ func updateActivity(_ elapsedMinutes: Int, status: String,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ if #available(iOS 16.1, *) {
+ Task {
+ let state = CheckInTimerAttributes.ContentState(
+ elapsedMinutes: elapsedMinutes, status: status, lastCheckIn: "")
+ for activity in Activity.activities {
+ await activity.update(using: state)
+ }
+ resolve(true)
+ }
+ } else {
+ resolve(false)
+ }
+ }
+
+ @objc
+ func endActivity(_ resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
+ if #available(iOS 16.1, *) {
+ Task {
+ for activity in Activity.activities {
+ await activity.end(dismissalPolicy: .immediate)
+ }
+ resolve(true)
+ }
+ } else {
+ resolve(false)
+ }
+ }
+}
+`;
+
+/**
+ * CheckInTimerActivityBridge.m — ObjC bridge
+ */
+const BRIDGE_OBJC = `#import
+
+@interface RCT_EXTERN_MODULE(CheckInTimerActivityManager, NSObject)
+
+RCT_EXTERN_METHOD(startActivity:(NSString *)callName
+ callNumber:(NSString *)callNumber
+ timerName:(NSString *)timerName
+ durationMinutes:(int)durationMinutes
+ resolver:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(updateActivity:(int)elapsedMinutes
+ status:(NSString *)status
+ resolver:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+
+RCT_EXTERN_METHOD(endActivity:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+
+@end
+`;
+
+/**
+ * Info.plist for the CheckInTimerWidget extension target.
+ * Required by Xcode; bundle metadata is resolved at build time via build settings.
+ */
+const WIDGET_INFO_PLIST = `
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ CheckInTimerWidget
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
+`;
+
+const withCheckInLiveActivity = (config) => {
+ // Step 1: Add NSSupportsLiveActivities to Info.plist
+ config = withInfoPlist(config, (config) => {
+ config.modResults.NSSupportsLiveActivities = true;
+ return config;
+ });
+
+ // Step 2: Add live activity entitlement
+ config = withEntitlementsPlist(config, (config) => {
+ config.modResults['com.apple.developer.live-activity'] = true;
+ return config;
+ });
+
+ // Step 3: Write Swift Widget Extension files and native bridge
+ config = withDangerousMod(config, [
+ 'ios',
+ async (config) => {
+ const projectRoot = config.modRequest.projectRoot;
+
+ // Write Widget Extension files
+ const widgetDir = path.join(projectRoot, 'ios', 'CheckInTimerWidget');
+ if (!fs.existsSync(widgetDir)) {
+ fs.mkdirSync(widgetDir, { recursive: true });
+ }
+
+ fs.writeFileSync(path.join(widgetDir, 'CheckInTimerAttributes.swift'), ATTRIBUTES_SWIFT);
+ fs.writeFileSync(path.join(widgetDir, 'CheckInTimerLiveActivity.swift'), LIVE_ACTIVITY_SWIFT);
+ fs.writeFileSync(path.join(widgetDir, 'CheckInTimerWidgetBundle.swift'), WIDGET_BUNDLE_SWIFT);
+ fs.writeFileSync(path.join(widgetDir, 'Info.plist'), WIDGET_INFO_PLIST);
+
+ // Write native bridge files to the main app directory.
+ // resolveIosAppName throws explicitly if the target cannot be determined,
+ // preventing files from being written to a wrong/hardcoded path.
+ const appName = resolveIosAppName(config, projectRoot);
+ const appDir = path.join(projectRoot, 'ios', appName);
+ if (!fs.existsSync(appDir)) {
+ fs.mkdirSync(appDir, { recursive: true });
+ }
+
+ fs.writeFileSync(path.join(appDir, 'CheckInTimerActivityManager.swift'), ACTIVITY_MANAGER_SWIFT);
+ fs.writeFileSync(path.join(appDir, 'CheckInTimerActivityBridge.m'), BRIDGE_OBJC);
+
+ return config;
+ },
+ ]);
+
+ // Step 4: Add Widget Extension target to Xcode project and wire all required
+ // build phases, source files, and frameworks so Live Activities actually build.
+ config = withXcodeProject(config, (config) => {
+ const project = config.modResults;
+ const projectRoot = config.modRequest.projectRoot;
+ const appBundleId = config.ios?.bundleIdentifier;
+
+ if (!appBundleId) {
+ throw new Error(
+ '[withCheckInLiveActivity] config.ios.bundleIdentifier is required ' +
+ 'to derive the widget extension bundle identifier.'
+ );
+ }
+
+ const WIDGET_NAME = 'CheckInTimerWidget';
+ const widgetBundleId = `${appBundleId}.${WIDGET_NAME}`;
+
+ // Idempotent: skip if the target was already added in a previous prebuild run.
+ // addTarget stores names with surrounding quotes in the comment key, so check both forms.
+ if (project.pbxTargetByName(WIDGET_NAME) || project.pbxTargetByName(`"${WIDGET_NAME}"`)) {
+ return config;
+ }
+
+ // 1. Create the PBXNativeTarget.
+ // addTarget('app_extension') also:
+ // - adds an "Embed App Extensions" CopyFiles phase to the main target
+ // - adds a PBXTargetDependency from main app → widget
+ // - creates Debug/Release XCBuildConfigurations with basic defaults
+ const widgetTarget = project.addTarget(WIDGET_NAME, 'app_extension', WIDGET_NAME, widgetBundleId);
+
+ // 2. Add the three build phases the widget target needs.
+ // These must be added before files/frameworks are wired, because the
+ // addSourceFile / addFramework helpers find phases by scanning the
+ // target's buildPhases array.
+ project.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', widgetTarget.uuid);
+ project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', widgetTarget.uuid);
+ project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', widgetTarget.uuid);
+
+ // 3. Create a PBX group for the widget folder and attach it to the project's
+ // main group so the files appear in the Xcode file navigator.
+ const { uuid: widgetGroupUuid } = project.addPbxGroup([], WIDGET_NAME, WIDGET_NAME);
+ const { firstProject } = project.getFirstProject();
+ const mainGroup = project.getPBXGroupByKey(firstProject.mainGroup);
+ if (mainGroup && !mainGroup.children.find((c) => c.comment === WIDGET_NAME)) {
+ mainGroup.children.push({ value: widgetGroupUuid, comment: WIDGET_NAME });
+ }
+
+ // 4. Add Swift source files to the widget group and to the widget's Sources phase.
+ // Passing the group key as the third argument to addSourceFile ensures the
+ // file reference lands in the right PBX group; opt.target routes the build
+ // file to the widget's PBXSourcesBuildPhase rather than the main app's.
+ const SWIFT_SOURCES = [
+ 'CheckInTimerAttributes.swift',
+ 'CheckInTimerLiveActivity.swift',
+ 'CheckInTimerWidgetBundle.swift',
+ ];
+ for (const filename of SWIFT_SOURCES) {
+ project.addSourceFile(
+ `${WIDGET_NAME}/${filename}`,
+ { target: widgetTarget.uuid },
+ widgetGroupUuid
+ );
+ }
+
+ // 5. Link WidgetKit and ActivityKit into the widget's Frameworks phase.
+ // opt.target directs addToPbxFrameworksBuildPhase to use the widget's
+ // PBXFrameworksBuildPhase (added above) instead of the main app's.
+ project.addFramework('WidgetKit.framework', { target: widgetTarget.uuid });
+ project.addFramework('ActivityKit.framework', { target: widgetTarget.uuid });
+
+ // 6. Patch build settings on both Debug and Release configurations so the
+ // widget compiles as a Swift 5 app-extension targeting iOS 16.1+.
+ const targetSection = project.pbxNativeTargetSection();
+
+ // Resolve host app version numbers so the widget extension stays in sync.
+ // Priority: main target build settings → Expo config → hardcoded fallback.
+ const hostTarget = project.getFirstTarget();
+ let hostMarketingVersion = null;
+ let hostCurrentProjectVersion = null;
+ const hostConfigListId = targetSection[hostTarget.uuid].buildConfigurationList;
+ const hostConfigList = project.pbxXCConfigurationList()[hostConfigListId];
+ if (hostConfigList) {
+ const firstHostConfigUuid = hostConfigList.buildConfigurations[0]?.value;
+ if (firstHostConfigUuid) {
+ const firstHostConfig = project.pbxXCBuildConfigurationSection()[firstHostConfigUuid];
+ if (firstHostConfig?.buildSettings) {
+ hostMarketingVersion = firstHostConfig.buildSettings.MARKETING_VERSION || null;
+ hostCurrentProjectVersion = firstHostConfig.buildSettings.CURRENT_PROJECT_VERSION || null;
+ }
+ }
+ }
+ // MARKETING_VERSION in pbxproj is already quoted (e.g., '"1.2.3"');
+ // config.version is raw (e.g., '1.2.3'), so wrap it in quotes.
+ const resolvedMarketingVersion =
+ hostMarketingVersion || (config.version ? `"${config.version}"` : '"1.0"');
+ // CURRENT_PROJECT_VERSION in pbxproj is an unquoted number string (e.g., '42');
+ // config.ios?.buildNumber is raw, pass through as-is.
+ const resolvedCurrentProjectVersion =
+ hostCurrentProjectVersion || (config.ios?.buildNumber ?? '1');
+
+ const buildConfigListId = targetSection[widgetTarget.uuid].buildConfigurationList;
+ const buildConfigList = project.pbxXCConfigurationList()[buildConfigListId];
+ if (buildConfigList) {
+ for (const { value: configUuid } of buildConfigList.buildConfigurations) {
+ const buildConfig = project.pbxXCBuildConfigurationSection()[configUuid];
+ if (buildConfig) {
+ Object.assign(buildConfig.buildSettings, {
+ // Override the default addTarget placeholder (TargetName-Info.plist)
+ INFOPLIST_FILE: `"${WIDGET_NAME}/Info.plist"`,
+ SWIFT_VERSION: '"5.0"',
+ TARGETED_DEVICE_FAMILY: '"1,2"',
+ // ActivityKit requires iOS 16.1 or later
+ IPHONEOS_DEPLOYMENT_TARGET: '16.1',
+ SKIP_INSTALL: 'YES',
+ CODE_SIGN_STYLE: 'Automatic',
+ MARKETING_VERSION: resolvedMarketingVersion,
+ CURRENT_PROJECT_VERSION: resolvedCurrentProjectVersion,
+ });
+ }
+ }
+ }
+
+ // 7. Ensure the bridge files are compiled as part of the main app target
+ // so the native module is linked at runtime.
+ const mainGroupKey = project.findPBXGroupKey({ name: appName });
+ const BRIDGE_FILES = [
+ `${appName}/CheckInTimerActivityManager.swift`,
+ `${appName}/CheckInTimerActivityBridge.m`,
+ ];
+ for (const filePath of BRIDGE_FILES) {
+ if (!project.hasFile(filePath)) {
+ project.addSourceFile(filePath, { target: hostTarget.uuid }, mainGroupKey);
+ }
+ }
+
+ // 8. Ensure the main target has a bridging header configured so the ObjC
+ // bridge module is visible to Swift.
+ const BRIDGING_HEADER_FILE = `${appName}/${appName}-Bridging-Header.h`;
+ const bridgingHeaderPath = path.join(projectRoot, 'ios', BRIDGING_HEADER_FILE);
+ if (!fs.existsSync(bridgingHeaderPath)) {
+ fs.writeFileSync(bridgingHeaderPath, `// Auto-generated bridging header for Live Activity native bridge.\n#import \n`);
+ }
+
+ const mainBuildConfigListId = targetSection[hostTarget.uuid].buildConfigurationList;
+ const mainBuildConfigList = project.pbxXCConfigurationList()[mainBuildConfigListId];
+ if (mainBuildConfigList) {
+ for (const { value: mainConfigUuid } of mainBuildConfigList.buildConfigurations) {
+ const mainBuildConfig = project.pbxXCBuildConfigurationSection()[mainConfigUuid];
+ if (mainBuildConfig && !mainBuildConfig.buildSettings.SWIFT_OBJC_BRIDGING_HEADER) {
+ mainBuildConfig.buildSettings.SWIFT_OBJC_BRIDGING_HEADER = `"${BRIDGING_HEADER_FILE}"`;
+ }
+ }
+ }
+
+ return config;
+ });
+
+ return config;
+};
+
+module.exports = withCheckInLiveActivity;
diff --git a/src/api/call-video-feeds/call-video-feeds.ts b/src/api/call-video-feeds/call-video-feeds.ts
new file mode 100644
index 00000000..770b2125
--- /dev/null
+++ b/src/api/call-video-feeds/call-video-feeds.ts
@@ -0,0 +1,70 @@
+import { type CallVideoFeedResult } from '@/models/v4/callVideoFeeds/callVideoFeedResult';
+import { type SaveCallVideoFeedResult } from '@/models/v4/callVideoFeeds/saveCallVideoFeedResult';
+
+import { createApiEndpoint } from '../common/client';
+
+const getCallVideoFeedsApi = createApiEndpoint('/CallVideoFeeds/GetCallVideoFeeds');
+const saveCallVideoFeedApi = createApiEndpoint('/CallVideoFeeds/SaveCallVideoFeed');
+const editCallVideoFeedApi = createApiEndpoint('/CallVideoFeeds/EditCallVideoFeed');
+const deleteCallVideoFeedApi = createApiEndpoint('/CallVideoFeeds/DeleteCallVideoFeed');
+
+export interface SaveCallVideoFeedInput {
+ CallId: number;
+ Name: string;
+ Url: string;
+ FeedType?: number;
+ FeedFormat?: number;
+ Description?: string;
+ Latitude?: string;
+ Longitude?: string;
+ SortOrder?: number;
+}
+
+export interface EditCallVideoFeedInput extends SaveCallVideoFeedInput {
+ CallVideoFeedId: string;
+}
+
+export const getCallVideoFeeds = async (callId: number) => {
+ const response = await getCallVideoFeedsApi.get({
+ callId: encodeURIComponent(callId),
+ });
+ return response.data;
+};
+
+export const saveCallVideoFeed = async (input: SaveCallVideoFeedInput) => {
+ const response = await saveCallVideoFeedApi.post({
+ CallId: input.CallId,
+ Name: input.Name,
+ Url: input.Url,
+ FeedType: input.FeedType,
+ FeedFormat: input.FeedFormat,
+ Description: input.Description,
+ Latitude: input.Latitude,
+ Longitude: input.Longitude,
+ SortOrder: input.SortOrder,
+ });
+ return response.data;
+};
+
+export const editCallVideoFeed = async (input: EditCallVideoFeedInput) => {
+ const response = await editCallVideoFeedApi.put({
+ CallVideoFeedId: input.CallVideoFeedId,
+ CallId: input.CallId,
+ Name: input.Name,
+ Url: input.Url,
+ FeedType: input.FeedType,
+ FeedFormat: input.FeedFormat,
+ Description: input.Description,
+ Latitude: input.Latitude,
+ Longitude: input.Longitude,
+ SortOrder: input.SortOrder,
+ });
+ return response.data;
+};
+
+export const deleteCallVideoFeed = async (feedId: string) => {
+ const response = await deleteCallVideoFeedApi.delete({
+ feedId: encodeURIComponent(feedId),
+ });
+ return response.data;
+};
diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts
index ed4809b1..65b195d6 100644
--- a/src/api/calls/calls.ts
+++ b/src/api/calls/calls.ts
@@ -41,6 +41,7 @@ export interface CreateCallRequest {
nature: string;
note?: string;
address?: string;
+ destinationPoiId?: number | null;
latitude?: number;
longitude?: number;
priority: number;
@@ -62,6 +63,7 @@ export interface UpdateCallRequest {
nature: string;
note?: string;
address?: string;
+ destinationPoiId?: number | null;
latitude?: number;
longitude?: number;
priority: number;
@@ -117,6 +119,7 @@ export const createCall = async (callData: CreateCallRequest) => {
Nature: callData.nature,
Note: callData.note || '',
Address: callData.address || '',
+ DestinationPoiId: callData.destinationPoiId ?? null,
Geolocation: `${callData.latitude?.toString() || ''},${callData.longitude?.toString() || ''}`,
Priority: callData.priority,
Type: callData.type || '',
@@ -149,6 +152,7 @@ export const updateCall = async (callData: UpdateCallRequest) => {
Nature: callData.nature,
Note: callData.note || '',
Address: callData.address || '',
+ DestinationPoiId: callData.destinationPoiId ?? null,
Geolocation: `${callData.latitude?.toString() || ''},${callData.longitude?.toString() || ''}`,
Priority: callData.priority,
Type: callData.type || '',
diff --git a/src/api/check-in-timers/check-in-timers.ts b/src/api/check-in-timers/check-in-timers.ts
new file mode 100644
index 00000000..6d95c2e9
--- /dev/null
+++ b/src/api/check-in-timers/check-in-timers.ts
@@ -0,0 +1,62 @@
+import { type CheckInRecordResult } from '@/models/v4/checkIn/checkInRecordResult';
+import { type CheckInTimerStatusResult } from '@/models/v4/checkIn/checkInTimerStatusResult';
+import { type PerformCheckInResult } from '@/models/v4/checkIn/performCheckInResult';
+import { type ResolvedCheckInTimerResult } from '@/models/v4/checkIn/resolvedCheckInTimerResult';
+
+import { createApiEndpoint } from '../common/client';
+
+const getTimerStatusesApi = createApiEndpoint('/CheckInTimers/GetTimerStatuses');
+const getTimersForCallApi = createApiEndpoint('/CheckInTimers/GetTimersForCall');
+const performCheckInApi = createApiEndpoint('/CheckInTimers/PerformCheckIn');
+const getCheckInHistoryApi = createApiEndpoint('/CheckInTimers/GetCheckInHistory');
+const toggleCallTimersApi = createApiEndpoint('/CheckInTimers/ToggleCallTimers');
+
+export interface PerformCheckInInput {
+ CallId: number;
+ CheckInType: number;
+ UnitId?: number;
+ Latitude?: string;
+ Longitude?: string;
+ Note?: string;
+}
+
+export const getTimerStatuses = async (callId: number) => {
+ const response = await getTimerStatusesApi.get({
+ callId: encodeURIComponent(callId),
+ });
+ return response.data;
+};
+
+export const getTimersForCall = async (callId: number) => {
+ const response = await getTimersForCallApi.get({
+ callId: encodeURIComponent(callId),
+ });
+ return response.data;
+};
+
+export const performCheckIn = async (input: PerformCheckInInput) => {
+ const response = await performCheckInApi.post({
+ CallId: input.CallId,
+ CheckInType: input.CheckInType,
+ UnitId: input.UnitId,
+ Latitude: input.Latitude,
+ Longitude: input.Longitude,
+ Note: input.Note,
+ });
+ return response.data;
+};
+
+export const getCheckInHistory = async (callId: number) => {
+ const response = await getCheckInHistoryApi.get({
+ callId: encodeURIComponent(callId),
+ });
+ return response.data;
+};
+
+export const toggleCallTimers = async (callId: number, enabled: boolean) => {
+ const response = await toggleCallTimersApi.put({
+ CallId: callId,
+ Enabled: enabled,
+ });
+ return response.data;
+};
diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx
index e54c26a5..f56c327b 100644
--- a/src/api/common/client.tsx
+++ b/src/api/common/client.tsx
@@ -35,6 +35,10 @@ const processQueue = (error: Error | null) => {
// Request interceptor for API calls
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
+ // Dynamically update baseURL on every request to support
+ // custom server URL changes (e.g. self-hosted environments)
+ config.baseURL = getBaseApiUrl();
+
const accessToken = useAuthStore.getState().accessToken;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
diff --git a/src/api/dispatch/dispatch.ts b/src/api/dispatch/dispatch.ts
index b17bbef7..1e6f1df8 100644
--- a/src/api/dispatch/dispatch.ts
+++ b/src/api/dispatch/dispatch.ts
@@ -1,11 +1,22 @@
import { createApiEndpoint } from '@/api/common/client';
import { type GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult';
+import { type NewCallFormResult } from '@/models/v4/dispatch/newCallFormResult';
-const getSetUnitStateApi = createApiEndpoint('/Dispatch/GetSetUnitState');
+const getNewCallDataApi = createApiEndpoint('/Dispatch/GetNewCallData');
+const getSetUnitStatusDataApi = createApiEndpoint('/Dispatch/GetSetUnitStatusData');
-export const getSetUnitState = async (unitId: string) => {
- const response = await getSetUnitStateApi.get({
+export const getNewCallData = async () => {
+ const response = await getNewCallDataApi.get();
+ return response.data;
+};
+
+export const getSetUnitStatusData = async (unitId: string) => {
+ const response = await getSetUnitStatusDataApi.get({
unitId: unitId,
});
return response.data;
};
+
+export const getSetUnitState = async (unitId: string) => {
+ return getSetUnitStatusData(unitId);
+};
diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts
index 6c45c38f..06548eb4 100644
--- a/src/api/mapping/mapping.ts
+++ b/src/api/mapping/mapping.ts
@@ -16,12 +16,25 @@ import {
type SearchCustomMapRegionsResult,
type SearchIndoorLocationsResult,
} from '@/models/v4/mapping/mappingResults';
+import { type PoiResult, type PoisResult, type PoiTypesResult } from '@/models/v4/mapping/poiResults';
import { createCachedApiEndpoint } from '../common/cached-client';
import { createApiEndpoint } from '../common/client';
const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers');
const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers');
+const getPoiApi = createCachedApiEndpoint('/Mapping/GetPoi', {
+ ttl: 5 * 60 * 1000,
+ enabled: true,
+});
+const getPoisApi = createCachedApiEndpoint('/Mapping/GetPois', {
+ ttl: 5 * 60 * 1000,
+ enabled: true,
+});
+const getPoiTypesApi = createCachedApiEndpoint('/Mapping/GetPoiTypes', {
+ ttl: 5 * 60 * 1000,
+ enabled: true,
+});
// Indoor map endpoints
const getIndoorMapsApi = createCachedApiEndpoint('/Mapping/GetIndoorMaps', {
@@ -59,6 +72,31 @@ export const getMapDataAndMarkers = async (signal?: AbortSignal) => {
return response.data;
};
+export const getPoiTypes = async () => {
+ const response = await getPoiTypesApi.get();
+ return response.data;
+};
+
+export const getPois = async (poiTypeId?: number, destinationOnly?: boolean) => {
+ const params: Record = {};
+ if (poiTypeId !== undefined) {
+ params.poiTypeId = poiTypeId;
+ }
+ if (destinationOnly !== undefined) {
+ params.destinationOnly = destinationOnly;
+ }
+
+ const response = await getPoisApi.get(params);
+ return response.data;
+};
+
+export const getPoi = async (poiId: number | string) => {
+ const response = await getPoiApi.get({
+ poiId: encodeURIComponent(String(poiId)),
+ });
+ return response.data;
+};
+
export const getMayLayers = async (type: number, signal?: AbortSignal) => {
const response = await getMayLayersApi.get(
{
diff --git a/src/api/weather-alerts/weather-alerts.ts b/src/api/weather-alerts/weather-alerts.ts
new file mode 100644
index 00000000..f6d14fb3
--- /dev/null
+++ b/src/api/weather-alerts/weather-alerts.ts
@@ -0,0 +1,53 @@
+import { type ActiveWeatherAlertsResult } from '@/models/v4/weatherAlerts/activeWeatherAlertsResult';
+import { type WeatherAlertResult } from '@/models/v4/weatherAlerts/weatherAlertResult';
+import { type WeatherAlertSettingsResult } from '@/models/v4/weatherAlerts/weatherAlertSettingsResult';
+import { type WeatherAlertZonesResult } from '@/models/v4/weatherAlerts/weatherAlertZonesResult';
+
+import { createCachedApiEndpoint } from '../common/cached-client';
+import { createApiEndpoint } from '../common/client';
+
+const getActiveAlertsApi = createCachedApiEndpoint('/WeatherAlerts/GetActiveAlerts', { ttl: 60 * 1000, enabled: true });
+const getWeatherAlertApi = createApiEndpoint('/WeatherAlerts/GetWeatherAlert');
+const getAlertsNearLocationApi = createApiEndpoint('/WeatherAlerts/GetAlertsNearLocation');
+const getAlertHistoryApi = createApiEndpoint('/WeatherAlerts/GetAlertHistory');
+const getSettingsApi = createCachedApiEndpoint('/WeatherAlerts/GetSettings', { ttl: 5 * 60 * 1000, enabled: true });
+const getZonesApi = createCachedApiEndpoint('/WeatherAlerts/GetZones', { ttl: 5 * 60 * 1000, enabled: true });
+
+export const getActiveAlerts = async () => {
+ const response = await getActiveAlertsApi.get();
+ return response.data;
+};
+
+export const getWeatherAlert = async (alertId: string) => {
+ const response = await getWeatherAlertApi.get({
+ alertId: encodeURIComponent(alertId),
+ });
+ return response.data;
+};
+
+export const getAlertsNearLocation = async (lat: number, lng: number, radiusMiles: number) => {
+ const response = await getAlertsNearLocationApi.get({
+ lat,
+ lng,
+ radiusMiles,
+ });
+ return response.data;
+};
+
+export const getAlertHistory = async (startDate: string, endDate: string) => {
+ const response = await getAlertHistoryApi.get({
+ startDate,
+ endDate,
+ });
+ return response.data;
+};
+
+export const getWeatherAlertSettings = async () => {
+ const response = await getSettingsApi.get();
+ return response.data;
+};
+
+export const getWeatherAlertZones = async () => {
+ const response = await getZonesApi.get();
+ return response.data;
+};
diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/app/(app)/__tests__/calls.test.tsx
index 1f1daf35..a8d5f3da 100644
--- a/src/app/(app)/__tests__/calls.test.tsx
+++ b/src/app/(app)/__tests__/calls.test.tsx
@@ -52,7 +52,9 @@ const mockCallsStore = {
error: null as string | null,
fetchCalls: jest.fn(),
fetchCallPriorities: jest.fn(),
+ fetchCallDispatches: jest.fn(),
callPriorities: [] as any[],
+ callDispatches: {} as Record,
};
const mockSecurityStore = {
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index d2627aed..519e7224 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -4,7 +4,7 @@ import { NovuProvider } from '@novu/react-native';
import Countly from 'countly-sdk-react-native-bridge';
import * as NavigationBar from 'expo-navigation-bar';
import { Redirect, SplashScreen, Tabs } from 'expo-router';
-import { Contact, ListTree, Map, Megaphone, Menu, Navigation, Notebook, Settings } from 'lucide-react-native';
+import { CloudAlert, Contact, ListTree, Map, Megaphone, Menu, Navigation, Notebook, Settings } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Platform, StyleSheet, useWindowDimensions } from 'react-native';
@@ -34,6 +34,7 @@ import { useCallsStore } from '@/stores/calls/store';
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
+import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';
export default function TabLayout() {
const { t } = useTranslation();
@@ -159,6 +160,7 @@ export default function TabLayout() {
await useCoreStore.getState().init();
await useRolesStore.getState().init();
await useCallsStore.getState().init();
+ await useWeatherAlertsStore.getState().init();
await securityStore.getState().getRights();
await useSignalRStore.getState().connectUpdateHub();
@@ -197,7 +199,7 @@ export default function TabLayout() {
try {
// Refresh data
- await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles()]);
+ await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles(), useWeatherAlertsStore.getState().fetchActiveAlerts()]);
} catch (error) {
logger.error({
message: 'Failed to refresh data on app resume',
@@ -331,6 +333,7 @@ export default function TabLayout() {
const contactsIcon = useCallback(({ color }: { color: string }) => , []);
const notesIcon = useCallback(({ color }: { color: string }) => , []);
const routesIcon = useCallback(({ color }: { color: string }) => , []);
+ const weatherAlertsIcon = useCallback(({ color }: { color: string }) => , []);
const protocolsIcon = useCallback(({ color }: { color: string }) => , []);
const settingsIcon = useCallback(({ color }: { color: string }) => , []);
@@ -397,6 +400,17 @@ export default function TabLayout() {
[t, notesIcon, headerRightNotification]
);
+ const weatherAlertsOptions = useMemo(
+ () => ({
+ title: t('tabs.weather_alerts'),
+ headerShown: true as const,
+ tabBarIcon: weatherAlertsIcon,
+ tabBarButtonTestID: 'weather-alerts-tab' as const,
+ headerRight: headerRightNotification,
+ }),
+ [t, weatherAlertsIcon, headerRightNotification]
+ );
+
const protocolsOptions = useMemo(
() => ({
title: t('tabs.protocols'),
@@ -470,6 +484,8 @@ export default function TabLayout() {
+
+
diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx
index dc0791bc..d9acaa30 100644
--- a/src/app/(app)/calls.tsx
+++ b/src/app/(app)/calls.tsx
@@ -24,6 +24,8 @@ export default function Calls() {
const error = useCallsStore((state) => state.error);
const fetchCalls = useCallsStore((state) => state.fetchCalls);
const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities);
+ const fetchCallDispatches = useCallsStore((state) => state.fetchCallDispatches);
+ const callDispatches = useCallsStore((state) => state.callDispatches);
const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls);
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
@@ -41,6 +43,14 @@ export default function Calls() {
}, [fetchCalls, fetchCallPriorities])
);
+ // Fetch dispatch data for all active calls after calls load (cached per callId)
+ useEffect(() => {
+ if (calls.length > 0) {
+ const callIds = calls.map((c) => c.CallId);
+ fetchCallDispatches(callIds);
+ }
+ }, [calls, fetchCallDispatches]);
+
// Track when calls view is rendered
useEffect(() => {
trackEvent('calls_view_rendered', {
@@ -52,6 +62,7 @@ export default function Calls() {
const handleRefresh = () => {
fetchCalls();
fetchCallPriorities();
+ // Dispatches will auto-fetch via useEffect when calls update
};
const handleNewCall = () => {
@@ -77,7 +88,7 @@ export default function Calls() {
data={filteredCalls}
renderItem={({ item }: { item: CallResultData }) => (
router.push(`/call/${item.CallId}`)}>
- p.Id === item.Priority)} />
+ p.Id === item.Priority)} dispatches={callDispatches[item.CallId]} />
)}
keyExtractor={(item: CallResultData) => item.CallId}
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index ef9d582c..e587fe99 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -1,4 +1,4 @@
-import { Stack, useFocusEffect } from 'expo-router';
+import { router, Stack, useFocusEffect } from 'expo-router';
import { NavigationIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -13,6 +13,7 @@ import Mapbox from '@/components/maps/mapbox';
import PinDetailModal from '@/components/maps/pin-detail-modal';
import { StopMarker } from '@/components/routes/stop-marker';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
+import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
@@ -25,6 +26,7 @@ import { useLocationStore } from '@/stores/app/location-store';
import { useMapsStore } from '@/stores/maps/store';
import { useRoutesStore } from '@/stores/routes/store';
import { useToastStore } from '@/stores/toast/store';
+import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';
Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
@@ -58,6 +60,17 @@ function MapContent() {
const locationHeading = useLocationStore((state) => state.heading);
const isMapLocked = useLocationStore((state) => state.isMapLocked);
+ // Weather alert banner state
+ const weatherAlerts = useWeatherAlertsStore((state) => state.alerts);
+ const weatherSettings = useWeatherAlertsStore((state) => state.settings);
+ const [isBannerDismissed, setIsBannerDismissed] = useState(false);
+ const extremeAlerts = useMemo(() => weatherAlerts.filter((a) => a.Severity <= 1 && a.Status === 0), [weatherAlerts]);
+
+ // Reset dismissed state when alert count changes
+ useEffect(() => {
+ setIsBannerDismissed(false);
+ }, [extremeAlerts.length]);
+
// Route overlay state
const activeUnitId = useCoreStore((state) => state.activeUnitId);
const activeInstance = useRoutesStore((state) => state.activeInstance);
@@ -83,6 +96,27 @@ function MapContent() {
const pulseAnim = useRef(new Animated.Value(1)).current;
useMapSignalRUpdates(setMapPins);
+ // Stable initial camera settings so the native Camera renders at the
+ // correct position from the very first frame (fixes Android/iOS centering).
+ const initialCameraSettings = useMemo(() => {
+ if (locationLatitude != null && locationLongitude != null) {
+ return {
+ centerCoordinate: [locationLongitude, locationLatitude] as [number, number],
+ zoomLevel: isMapLocked ? 16 : 12,
+ heading: 0,
+ pitch: 0,
+ };
+ }
+
+ // Fallback: default US center when location hasn't arrived yet
+ return {
+ centerCoordinate: [-98.5795, 39.8283] as [number, number],
+ zoomLevel: 4,
+ heading: 0,
+ pitch: 0,
+ };
+ }, [locationLatitude, locationLongitude, isMapLocked]);
+
// Fetch active route overlay data
useEffect(() => {
if (activeUnitId) {
@@ -478,6 +512,7 @@ function MapContent() {
>
+ {/* Weather Alert Banner */}
+ {weatherSettings?.WeatherAlertsEnabled && extremeAlerts.length > 0 && !isBannerDismissed ? (
+
+ router.push('/(app)/weather-alerts')} onDismiss={() => setIsBannerDismissed(true)} />
+
+ ) : null}
+
{/* Recenter Button - only show when map is not locked and user has moved the map */}
{showRecenterButton ? (
diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx
index 00429519..c9d5150d 100644
--- a/src/app/(app)/protocols.tsx
+++ b/src/app/(app)/protocols.tsx
@@ -87,7 +87,6 @@ export default function Protocols() {
contentContainerStyle={{ paddingBottom: 100 }}
refreshControl={}
extraData={handleProtocolPress}
- estimatedItemSize={120}
/>
) : (
diff --git a/src/app/(app)/routes.tsx b/src/app/(app)/routes.tsx
index 96ec34e1..31d8e3d0 100644
--- a/src/app/(app)/routes.tsx
+++ b/src/app/(app)/routes.tsx
@@ -1,157 +1,7 @@
-import { router } from 'expo-router';
-import { Navigation, PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native';
-import React, { useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Pressable, RefreshControl, View } from 'react-native';
+import React from 'react';
-import { Loading } from '@/components/common/loading';
-import ZeroState from '@/components/common/zero-state';
-import { RouteCard } from '@/components/routes/route-card';
-import { Badge, BadgeText } from '@/components/ui/badge';
-import { Box } from '@/components/ui/box';
-import { Fab, FabIcon } from '@/components/ui/fab';
-import { FlatList } from '@/components/ui/flat-list';
-import { HStack } from '@/components/ui/hstack';
-import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
-import { Text } from '@/components/ui/text';
-import { type RoutePlanResultData } from '@/models/v4/routes/routePlanResultData';
-import { useCoreStore } from '@/stores/app/core-store';
-import { useRoutesStore } from '@/stores/routes/store';
-import { useUnitsStore } from '@/stores/units/store';
+import { RoutesHome } from '@/components/routes/routes-home';
export default function Routes() {
- const { t } = useTranslation();
- const routePlans = useRoutesStore((state) => state.routePlans);
- const activeInstance = useRoutesStore((state) => state.activeInstance);
- const isLoading = useRoutesStore((state) => state.isLoading);
- const error = useRoutesStore((state) => state.error);
- const fetchAllRoutePlans = useRoutesStore((state) => state.fetchAllRoutePlans);
- const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute);
- const activeUnitId = useCoreStore((state) => state.activeUnitId);
- const activeUnit = useCoreStore((state) => state.activeUnit);
- const units = useUnitsStore((state) => state.units);
- const fetchUnits = useUnitsStore((state) => state.fetchUnits);
- const [searchQuery, setSearchQuery] = useState('');
-
- const unitMap = useMemo(() => Object.fromEntries(units.map((u) => [u.UnitId, u.Name])), [units]);
-
- useEffect(() => {
- fetchAllRoutePlans();
- if (activeUnitId) {
- fetchActiveRoute(activeUnitId);
- }
- if (units.length === 0) {
- fetchUnits();
- }
- }, [activeUnitId, fetchAllRoutePlans, fetchActiveRoute, fetchUnits, units.length]);
-
- const handleRefresh = () => {
- fetchAllRoutePlans();
- if (activeUnitId) {
- fetchActiveRoute(activeUnitId);
- }
- };
-
- const handleRoutePress = (route: RoutePlanResultData) => {
- if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) {
- const iid = activeInstance.RouteInstanceId;
- const url = iid && iid !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${iid}` : `/routes/active?planId=${route.RoutePlanId}`;
- router.push(url as any);
- } else {
- router.push(`/routes/start?planId=${route.RoutePlanId}` as any);
- }
- };
-
- const filteredRoutes = useMemo(() => {
- const active = routePlans.filter((route) => route.RouteStatus === 1);
- if (!searchQuery) return active;
- const q = searchQuery.toLowerCase();
- return active.filter((route) => {
- const isRouteMyUnit = route.UnitId != null && String(route.UnitId) === String(activeUnitId);
- const unitName = route.UnitId != null ? unitMap[route.UnitId] || (isRouteMyUnit ? (activeUnit?.Name ?? '') : '') : '';
- return route.Name.toLowerCase().includes(q) || (route.Description?.toLowerCase() || '').includes(q) || unitName.toLowerCase().includes(q);
- });
- }, [routePlans, searchQuery, unitMap, activeUnitId, activeUnit]);
-
- const renderContent = () => {
- if (isLoading) {
- return ;
- }
-
- if (error) {
- return ;
- }
-
- return (
-
- testID="routes-list"
- data={filteredRoutes}
- ListHeaderComponent={
- activeInstance ? (
- {
- const iid = activeInstance.RouteInstanceId;
- const url = iid && iid !== 'undefined' ? `/routes/active?planId=${activeInstance.RoutePlanId}&instanceId=${iid}` : `/routes/active?planId=${activeInstance.RoutePlanId}`;
- router.push(url as any);
- }}
- >
-
-
-
-
- {activeInstance.RoutePlanName || t('routes.active_route')}
-
-
- {t('routes.active')}
-
-
-
- {t('routes.progress', {
- percent: activeInstance.StopsTotal ? Math.round(((activeInstance.StopsCompleted ?? 0) / activeInstance.StopsTotal) * 100) : 0,
- })}
-
-
-
- ) : null
- }
- renderItem={({ item }: { item: RoutePlanResultData }) => {
- const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId);
- const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : '';
- return (
- handleRoutePress(item)}>
-
-
- );
- }}
- keyExtractor={(item: RoutePlanResultData) => item.RoutePlanId}
- refreshControl={}
- ListEmptyComponent={}
- contentContainerStyle={{ paddingBottom: 20 }}
- />
- );
- };
-
- return (
-
-
-
-
-
-
-
- {searchQuery ? (
- setSearchQuery('')}>
-
-
- ) : null}
-
-
- {renderContent()}
-
- router.push('/routes/start' as any)} testID="new-route-fab">
-
-
-
-
- );
+ return ;
}
diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index bbafc21f..e13655ac 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -29,6 +29,7 @@ import { getBaseApiUrl } from '@/lib/storage/app';
import { openLinkInBrowser } from '@/lib/utils';
import { clearAllAppData } from '@/services/app-reset.service';
import { useCoreStore } from '@/stores/app/core-store';
+import { useServerUrlStore } from '@/stores/app/server-url-store';
import { useUnitsStore } from '@/stores/units/store';
export default function Settings() {
@@ -43,6 +44,7 @@ export default function Settings() {
const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false);
const activeUnit = useCoreStore((state) => state.activeUnit);
const units = useUnitsStore((state) => state.units);
+ const serverUrl = useServerUrlStore((s) => s.url);
const activeUnitName = React.useMemo(() => {
if (!activeUnit) return t('settings.none_selected');
@@ -115,7 +117,7 @@ export default function Settings() {
{t('settings.account')}
- - setShowServerUrl(true)} textStyle="text-info-600" />
+
- setShowServerUrl(true)} textStyle="text-info-600" />
- setShowLoginInfo(true)} textStyle="text-info-600" />
- setShowUnitSelection(true)} textStyle="text-info-600" />
- setShowLogoutConfirm(true)} textStyle="text-error-600" />
diff --git a/src/app/(app)/weather-alerts.tsx b/src/app/(app)/weather-alerts.tsx
new file mode 100644
index 00000000..5d910741
--- /dev/null
+++ b/src/app/(app)/weather-alerts.tsx
@@ -0,0 +1,116 @@
+import { useFocusEffect } from '@react-navigation/native';
+import { router } from 'expo-router';
+import { CloudOff, RefreshCcwDotIcon, Search, X } from 'lucide-react-native';
+import React, { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pressable, RefreshControl, View } from 'react-native';
+
+import { Loading } from '@/components/common/loading';
+import ZeroState from '@/components/common/zero-state';
+import { Box } from '@/components/ui/box';
+import { FlatList } from '@/components/ui/flat-list';
+import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
+import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
+import { SeverityFilterTabs } from '@/components/weather-alerts/severity-filter-tabs';
+import { WeatherAlertCard } from '@/components/weather-alerts/weather-alert-card';
+import { type WeatherAlertResultData } from '@/models/v4/weatherAlerts/weatherAlertResultData';
+import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';
+
+export default function WeatherAlerts() {
+ const alerts = useWeatherAlertsStore((state) => state.alerts);
+ const isLoading = useWeatherAlertsStore((state) => state.isLoading);
+ const error = useWeatherAlertsStore((state) => state.error);
+ const settings = useWeatherAlertsStore((state) => state.settings);
+ const severityFilter = useWeatherAlertsStore((state) => state.severityFilter);
+ const setSeverityFilter = useWeatherAlertsStore((state) => state.setSeverityFilter);
+ const fetchActiveAlerts = useWeatherAlertsStore((state) => state.fetchActiveAlerts);
+ const { t } = useTranslation();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [refreshing, setRefreshing] = useState(false);
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchActiveAlerts();
+ }, [fetchActiveAlerts])
+ );
+
+ const handleRefresh = async () => {
+ setRefreshing(true);
+ await fetchActiveAlerts();
+ setRefreshing(false);
+ };
+
+ // Filter alerts
+ const filteredAlerts = alerts.filter((alert) => {
+ if (severityFilter !== null && alert.Severity !== severityFilter) return false;
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ return (alert.Event ?? '').toLowerCase().includes(query) || (alert.Headline ?? '').toLowerCase().includes(query) || (alert.AreaDescription ?? '').toLowerCase().includes(query);
+ }
+ return true;
+ });
+
+ const renderItem = useCallback(
+ ({ item }: { item: WeatherAlertResultData }) => (
+ router.push(`/weather-alert/${item.WeatherAlertId}`)}>
+
+
+ ),
+ []
+ );
+
+ const keyExtractor = useCallback((item: WeatherAlertResultData) => item.WeatherAlertId, []);
+
+ const renderContent = () => {
+ if (settings?.WeatherAlertsEnabled === false) {
+ return ;
+ }
+
+ if (isLoading && alerts.length === 0) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ testID="weather-alerts-list"
+ data={filteredAlerts}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
+ refreshControl={}
+ ListEmptyComponent={}
+ contentContainerStyle={{ paddingBottom: 20 }}
+ removeClippedSubviews
+ />
+ >
+ );
+ };
+
+ return (
+
+
+
+ {/* Search input */}
+
+
+
+
+
+ {searchQuery ? (
+ setSearchQuery('')}>
+
+
+ ) : null}
+
+
+ {/* Main content */}
+ {renderContent()}
+
+
+ );
+}
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index bc2d7563..86aa06b8 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -1,11 +1,13 @@
import { format } from 'date-fns';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
-import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, UserIcon, UsersIcon } from 'lucide-react-native';
+import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
+import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content';
+import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
// Import a static map component instead of react-native-maps
@@ -25,6 +27,7 @@ import { openMapsWithDirections } from '@/lib/navigation';
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
+import { useCheckInTimerStore } from '@/stores/check-in-timers/store';
import { securityStore } from '@/stores/security/store';
import { useStatusBottomSheetStore } from '@/stores/status/store';
import { useToastStore } from '@/stores/toast/store';
@@ -70,6 +73,10 @@ export default function CallDetail() {
const [isCloseCallModalOpen, setIsCloseCallModalOpen] = useState(false);
const [isSettingActive, setIsSettingActive] = useState(false);
const showToast = useToastStore((state) => state.showToast);
+ const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses);
+ const startPolling = useCheckInTimerStore((state) => state.startPolling);
+ const stopPolling = useCheckInTimerStore((state) => state.stopPolling);
+ const resetTimers = useCheckInTimerStore((state) => state.reset);
const { colorScheme } = useColorScheme();
const textColor = colorScheme === 'dark' ? '#FFFFFF' : '#000000';
@@ -95,13 +102,13 @@ export default function CallDetail() {
setIsFilesModalOpen(true);
};
- const handleEditCall = () => {
+ const handleEditCall = useCallback(() => {
router.push(`/call/${callId}/edit`);
- };
+ }, [router, callId]);
- const handleCloseCall = () => {
+ const handleCloseCall = useCallback(() => {
setIsCloseCallModalOpen(true);
- };
+ }, []);
const handleSetActive = async () => {
if (!call) return;
@@ -191,6 +198,17 @@ export default function CallDetail() {
}
}, [trackEvent, call, callExtraData]);
+ // Check-in timer polling lifecycle
+ useEffect(() => {
+ if (call?.CheckInTimersEnabled) {
+ startPolling(parseInt(call.CallId, 10), 30000);
+ }
+ return () => {
+ stopPolling();
+ resetTimers();
+ };
+ }, [call?.CheckInTimersEnabled, call?.CallId, startPolling, stopPolling, resetTimers]);
+
/**
* Opens the device's native maps application with directions to the call location
*/
@@ -223,7 +241,7 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
- headerRight: () => ,
+ headerRight: HeaderRightMenu,
headerBackTitle: '',
}}
/>
@@ -242,7 +260,7 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
- headerRight: () => ,
+ headerRight: HeaderRightMenu,
headerBackTitle: '',
}}
/>
@@ -280,6 +298,7 @@ export default function CallDetail() {
}
const renderTabs = () => {
+ const destinationLabel = call.DestinationName || call.DestinationAddress || '';
const tabs: TabItem[] = [
{
key: 'info',
@@ -306,6 +325,13 @@ export default function CallDetail() {
{t('call_detail.address')}
{call.Address}
+ {destinationLabel ? (
+
+ {t('call_detail.destination')}
+ {destinationLabel}
+ {call.DestinationTypeName || call.DestinationAddress ? {[call.DestinationTypeName, call.DestinationAddress].filter(Boolean).join(' - ')} : null}
+
+ ) : null}
{t('call_detail.note')}
@@ -425,6 +451,26 @@ export default function CallDetail() {
},
];
+ // Video feeds tab
+ tabs.push({
+ key: 'video',
+ title: t('video_feeds.tab_title'),
+ icon: ,
+ content: ,
+ });
+
+ // Conditionally add check-in tab
+ if (call?.CheckInTimersEnabled) {
+ const overdueCount = timerStatuses.filter((t) => t.Status === 'Overdue').length;
+ tabs.push({
+ key: 'checkin',
+ title: t('check_in.tab_title'),
+ icon: ,
+ badge: overdueCount > 0 ? overdueCount : undefined,
+ content: ,
+ });
+ }
+
return tabs;
};
@@ -434,7 +480,7 @@ export default function CallDetail() {
options={{
title: t('call_detail.title'),
headerShown: true,
- headerRight: () => ,
+ headerRight: HeaderRightMenu,
headerBackTitle: '',
}}
/>
@@ -454,9 +500,9 @@ export default function CallDetail() {
)}
-
-
-
+
+
+
@@ -511,7 +557,7 @@ export default function CallDetail() {
{/* Tabs */}
-
+
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index d830c47b..8a79ef52 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { ScrollView, View } from 'react-native';
import * as z from 'zod';
+import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector';
import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal';
import { Loading } from '@/components/common/loading';
import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker';
@@ -40,6 +41,7 @@ const formSchema = z.object({
plusCode: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
+ destinationPoiId: z.string().optional(),
priority: z.string().min(1, 'Priority is required'),
type: z.string().min(1, 'Type is required'),
contactName: z.string().optional(),
@@ -79,10 +81,11 @@ export default function EditCall() {
const callId = Array.isArray(id) ? id[0] : id;
const callPriorities = useCallsStore((state) => state.callPriorities);
const callTypes = useCallsStore((state) => state.callTypes);
+ const destinationPois = useCallsStore((state) => state.destinationPois);
+ const poiTypes = useCallsStore((state) => state.poiTypes);
const callDataLoading = useCallsStore((state) => state.isLoading);
const callDataError = useCallsStore((state) => state.error);
- const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities);
- const fetchCallTypes = useCallsStore((state) => state.fetchCallTypes);
+ const fetchCallFormData = useCallsStore((state) => state.fetchCallFormData);
const call = useCallDetailStore((state) => state.call);
const callDetailLoading = useCallDetailStore((state) => state.isLoading);
const callDetailError = useCallDetailStore((state) => state.error);
@@ -128,6 +131,7 @@ export default function EditCall() {
plusCode: '',
latitude: undefined,
longitude: undefined,
+ destinationPoiId: '',
priority: '',
type: '',
contactName: '',
@@ -143,12 +147,11 @@ export default function EditCall() {
});
useEffect(() => {
- fetchCallPriorities();
- fetchCallTypes();
+ fetchCallFormData();
if (callId) {
fetchCallDetail(callId);
}
- }, [fetchCallPriorities, fetchCallTypes, fetchCallDetail, callId]);
+ }, [fetchCallDetail, fetchCallFormData, callId]);
// Pre-populate form when call data is loaded
useEffect(() => {
@@ -166,6 +169,7 @@ export default function EditCall() {
plusCode: '',
latitude: call.Latitude ? parseFloat(call.Latitude) : undefined,
longitude: call.Longitude ? parseFloat(call.Longitude) : undefined,
+ destinationPoiId: call.DestinationPoiId != null ? String(call.DestinationPoiId) : '',
priority: priority?.Name || '',
type: type?.Name || '',
contactName: call.ContactName || '',
@@ -228,6 +232,7 @@ export default function EditCall() {
address: data.address,
latitude: data.latitude,
longitude: data.longitude,
+ destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null,
what3words: data.what3words,
plusCode: data.plusCode,
contactName: data.contactName,
@@ -616,6 +621,20 @@ export default function EditCall() {
)}
+
+ (
+ onChange(poiId != null ? poiId.toString() : '')}
+ />
+ )}
+ />
diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx
index 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 ;
+
+ case FeedFormat.YouTubeLive:
+ case FeedFormat.Embed:
+ case FeedFormat.MJPEG:
+ case FeedFormat.Other:
+ return ;
+
+ case FeedFormat.RTSP:
+ return (
+
+ {t('video_feeds.rtsp_not_supported')}
+
+
+ );
+
+ case FeedFormat.WebRTC:
+ return (
+
+ {t('video_feeds.webrtc_not_supported')}
+
+ );
+
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ {feed.Name}
+
+
+
+
+ {/* Player */}
+ {renderPlayer()}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ video: {
+ width: '100%',
+ height: '100%',
+ },
+});
diff --git a/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx b/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx
index e76dbd05..6b10d8b7 100644
--- a/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx
+++ b/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx
@@ -20,6 +20,14 @@ jest.mock('react-i18next', () => ({
}),
}));
+// Mock ActionSheetIOS wrapper so tests can run without native module
+jest.mock('@/utils/action-sheet', () => ({
+ isIOS: () => true,
+ showNativeActionSheet: jest.fn((options, callback) => {
+ // By default do nothing — tests that need specific button indices can mock this further
+ }),
+}));
+
// Mock UI components that are used internally
jest.mock('@/components/ui/', () => ({
Pressable: ({ children, ...props }: any) => {
@@ -117,7 +125,8 @@ describe('useCallDetailMenu Analytics', () => {
hasEditAction: true,
hasCloseAction: true,
});
- expect(result.current.isMenuOpen).toBe(true);
+ // On iOS, isMenuOpen stays false — native ActionSheetIOS handles its own UI
+ expect(result.current.isMenuOpen).toBe(false);
});
it('should not track analytics event when menu is closed', () => {
@@ -200,7 +209,8 @@ describe('useCallDetailMenu Analytics', () => {
act(() => {
result.current.openMenu();
});
- expect(result.current.isMenuOpen).toBe(true);
+ // On iOS, isMenuOpen stays false — native ActionSheetIOS handles its own UI
+ expect(result.current.isMenuOpen).toBe(false);
// Close menu
act(() => {
diff --git a/src/components/calls/__tests__/call-detail-menu-integration.test.tsx b/src/components/calls/__tests__/call-detail-menu-integration.test.tsx
index 0adbf937..7f6d0137 100644
--- a/src/components/calls/__tests__/call-detail-menu-integration.test.tsx
+++ b/src/components/calls/__tests__/call-detail-menu-integration.test.tsx
@@ -10,6 +10,12 @@ jest.mock('react-i18next', () => ({
}),
}));
+// Mock ActionSheetIOS wrapper so tests can run without native module
+jest.mock('@/utils/action-sheet', () => ({
+ isIOS: () => false,
+ showNativeActionSheet: jest.fn(),
+}));
+
// Mock the UI components
jest.mock('@/components/ui/actionsheet', () => ({
Actionsheet: ({ children, isOpen, testID }: { children: React.ReactNode; isOpen: boolean; testID?: string }) => {
@@ -58,6 +64,13 @@ jest.mock('lucide-react-native', () => ({
},
}));
+jest.mock('@/components/ui/pressable', () => ({
+ Pressable: ({ children, onPress, testID }: { children: React.ReactNode; onPress?: () => void; testID?: string }) => {
+ const { TouchableOpacity } = require('react-native');
+ return {children};
+ },
+}));
+
jest.mock('@/components/ui/', () => ({
Pressable: ({ children, onPress, onPressIn, testID }: { children: React.ReactNode; onPress?: () => void; onPressIn?: () => void; testID?: string }) => {
const { TouchableOpacity } = require('react-native');
diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx
index 56dba836..01f551b3 100644
--- a/src/components/calls/__tests__/call-files-modal.test.tsx
+++ b/src/components/calls/__tests__/call-files-modal.test.tsx
@@ -78,7 +78,7 @@ jest.mock('@/hooks/use-analytics', () => ({
}));
// Mock expo modules
-jest.mock('expo-file-system', () => ({
+jest.mock('expo-file-system/legacy', () => ({
documentDirectory: '/mock/documents/',
writeAsStringAsync: jest.fn(),
EncodingType: {
@@ -325,9 +325,10 @@ describe('CallFilesModal', () => {
});
it('renders correctly when closed', () => {
- const { getByTestId } = render();
+ const { queryByTestId, getByTestId } = render();
- expect(getByTestId('bottom-sheet')).toBeTruthy();
+ // BottomSheet is not mounted when closed (prevents rotation bugs)
+ expect(queryByTestId('bottom-sheet')).toBeNull();
expect(getByTestId('focus-aware-status-bar')).toBeTruthy();
});
@@ -506,7 +507,7 @@ describe('CallFilesModal', () => {
describe('File Download', () => {
const mockGetCallAttachmentFile = require('@/api/calls/callFiles').getCallAttachmentFile;
- const mockWriteAsStringAsync = require('expo-file-system').writeAsStringAsync;
+ const mockWriteAsStringAsync = require('expo-file-system/legacy').writeAsStringAsync;
const mockShareAsync = require('expo-sharing').shareAsync;
beforeEach(() => {
diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx
index 3a6a214a..d1cc8edc 100644
--- a/src/components/calls/__tests__/call-images-modal.test.tsx
+++ b/src/components/calls/__tests__/call-images-modal.test.tsx
@@ -33,7 +33,7 @@ jest.mock('expo-image-picker', () => ({
},
}));
-jest.mock('expo-file-system', () => ({
+jest.mock('expo-file-system/legacy', () => ({
readAsStringAsync: jest.fn(),
EncodingType: {
Base64: 'base64',
@@ -269,7 +269,7 @@ const mockTrackEvent = jest.fn();
const mockReadAsStringAsync = jest.fn();
const mockManipulateAsync = jest.fn();
-jest.mock('expo-file-system', () => ({
+jest.mock('expo-file-system/legacy', () => ({
readAsStringAsync: mockReadAsStringAsync,
EncodingType: {
Base64: 'base64',
@@ -1211,4 +1211,4 @@ describe('CallImagesModal', () => {
expect(contentContainerStyle.flexGrow).toBe(1);
});
});
-});
\ No newline at end of file
+});
diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
index 75a70eca..df6d023d 100644
--- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
+++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react-native';
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react-native';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
@@ -40,24 +40,6 @@ jest.mock('nativewind', () => ({
// Mock cssInterop globally
(global as any).cssInterop = jest.fn();
-// Mock actionsheet components
-jest.mock('@/components/ui/actionsheet', () => ({
- Actionsheet: ({ children, isOpen }: any) => {
- const { View } = require('react-native');
- return isOpen ? {children} : null;
- },
- ActionsheetBackdrop: () => null,
- ActionsheetContent: ({ children }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- ActionsheetDragIndicator: () => null,
- ActionsheetDragIndicatorWrapper: ({ children }: any) => {
- const { View } = require('react-native');
- return {children};
- },
-}));
-
// Mock keyboard aware scroll view
jest.mock('react-native-keyboard-controller', () => ({
KeyboardAwareScrollView: ({ children }: any) => {
@@ -66,14 +48,12 @@ jest.mock('react-native-keyboard-controller', () => ({
},
}));
-// Mock UI components
-jest.mock('@/components/ui/bottom-sheet', () => ({
- CustomBottomSheet: ({ children, isOpen }: any) => {
- const { View } = require('react-native');
- return isOpen ? {children} : null;
- },
+// Mock lucide icons
+jest.mock('lucide-react-native', () => ({
+ ChevronDown: () => null,
}));
+// Mock UI components
jest.mock('@/components/ui/button', () => ({
Button: ({ children, onPress, testID, disabled, ...props }: any) => {
const { TouchableOpacity } = require('react-native');
@@ -106,77 +86,6 @@ jest.mock('@/components/ui/hstack', () => ({
},
}));
-jest.mock('@/components/ui/form-control', () => ({
- FormControl: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- FormControlLabel: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- FormControlLabelText: ({ children, ...props }: any) => {
- const { Text } = require('react-native');
- return {children};
- },
-}));
-
-jest.mock('@/components/ui/select', () => {
- // Store the callback for each select
- const selectCallbacks: Record void> = {};
-
- return {
- Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => {
- const React = require('react');
- const { View, TouchableOpacity, Text } = require('react-native');
-
- // Store the callback for external access
- React.useEffect(() => {
- if (testID && onValueChange) {
- selectCallbacks[testID] = onValueChange;
- }
- return () => {
- if (testID) {
- delete selectCallbacks[testID];
- }
- };
- }, [testID, onValueChange]);
-
- return (
-
- {children}
-
- );
- },
- SelectTrigger: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- SelectInput: ({ placeholder, ...props }: any) => {
- const { Text } = require('react-native');
- return {placeholder};
- },
- SelectIcon: () => null,
- SelectPortal: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- SelectBackdrop: () => null,
- SelectContent: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- SelectItem: ({ label, value, ...props }: any) => {
- const { View, Text } = require('react-native');
- return {label};
- },
- };
-});
-
jest.mock('@/components/ui/textarea', () => ({
Textarea: ({ children, ...props }: any) => {
const { View } = require('react-native');
@@ -207,6 +116,14 @@ const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction;
const mockUseToastStore = useToastStore as jest.MockedFunction;
+/** Helper: select a close call type via the inline dropdown */
+function selectCloseCallType(type: string) {
+ const typeSelect = screen.getByTestId('close-call-type-select');
+ fireEvent.press(typeSelect);
+ const option = screen.getByTestId(`close-call-type-option-${type}`);
+ fireEvent.press(option);
+}
+
describe('CloseCallBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -273,9 +190,8 @@ describe('CloseCallBottomSheet', () => {
const mockOnClose = jest.fn();
render();
- // Select close type
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', '1');
+ // Select close type via inline dropdown
+ selectCloseCallType('1');
// Add note
const noteInput = screen.getByPlaceholderText('call_detail.close_call_note_placeholder');
@@ -306,8 +222,7 @@ describe('CloseCallBottomSheet', () => {
render();
// Select close type but leave note empty
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', '2');
+ selectCloseCallType('2');
// Submit
const submitButton = screen.getAllByText('call_detail.close_call')[1];
@@ -333,8 +248,7 @@ describe('CloseCallBottomSheet', () => {
render();
// Select close type
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', '1');
+ selectCloseCallType('1');
// Submit
const submitButton = screen.getAllByText('call_detail.close_call')[1];
@@ -362,9 +276,8 @@ describe('CloseCallBottomSheet', () => {
render();
- // Select close type
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', type);
+ // Select close type via inline dropdown
+ selectCloseCallType(type);
// Submit
const submitButton = screen.getAllByText('call_detail.close_call')[1];
@@ -393,8 +306,7 @@ describe('CloseCallBottomSheet', () => {
render();
// Select close type
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', '1');
+ selectCloseCallType('1');
// Submit
const submitButton = screen.getAllByText('call_detail.close_call')[1];
@@ -418,8 +330,7 @@ describe('CloseCallBottomSheet', () => {
render();
// Select close type and add note
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', '1');
+ selectCloseCallType('1');
const noteInput = screen.getByPlaceholderText('call_detail.close_call_note_placeholder');
fireEvent.changeText(noteInput, 'Some note');
@@ -440,8 +351,7 @@ describe('CloseCallBottomSheet', () => {
render();
// Select close type
- const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent(typeSelect, 'onValueChange', '1');
+ selectCloseCallType('1');
// Submit
const submitButton = screen.getAllByText('call_detail.close_call')[1];
@@ -517,4 +427,4 @@ describe('CloseCallBottomSheet', () => {
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
});
-});
\ No newline at end of file
+});
diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx
index d1d4dce0..e8209e3d 100644
--- a/src/components/calls/call-card.tsx
+++ b/src/components/calls/call-card.tsx
@@ -1,6 +1,7 @@
-import { AlertTriangle, MapPin, Phone } from 'lucide-react-native';
-import React from 'react';
-import { StyleSheet } from 'react-native';
+import { AlertTriangle, MapPin, Phone, Timer } from 'lucide-react-native';
+import React, { useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Animated, ScrollView, StyleSheet } from 'react-native';
import { Box } from '@/components/ui/box';
import { HStack } from '@/components/ui/hstack';
@@ -11,6 +12,7 @@ import { VStack } from '@/components/ui/vstack';
import { getTimeAgoUtc, invertColor } from '@/lib/utils';
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
import type { CallResultData } from '@/models/v4/calls/callResultData';
+import type { DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
function getColor(call: CallResultData, priority: CallPriorityResultData | undefined) {
if (!call) {
@@ -27,10 +29,28 @@ function getColor(call: CallResultData, priority: CallPriorityResultData | undef
interface CallCardProps {
call: CallResultData;
priority: CallPriorityResultData | undefined;
+ showTimerIcon?: boolean;
+ isTimerOverdue?: boolean;
+ dispatches?: DispatchedEventResultData[];
}
-export const CallCard: React.FC = ({ call, priority }) => {
+export const CallCard: React.FC = ({ call, priority, showTimerIcon = false, isTimerOverdue = false, dispatches }) => {
+ const { t } = useTranslation();
const textColor = invertColor(getColor(call, priority), true);
+ const pulseAnim = useRef(new Animated.Value(1)).current;
+ const destinationLabel = call.DestinationName || call.DestinationAddress || '';
+
+ useEffect(() => {
+ if (isTimerOverdue) {
+ const animation = Animated.loop(
+ Animated.sequence([Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }), Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true })])
+ );
+ animation.start();
+ return () => animation.stop();
+ } else {
+ pulseAnim.setValue(1);
+ }
+ }, [isTimerOverdue, pulseAnim]);
return (
= ({ call, priority }) => {
#{call.Number}
-
- {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 (
<>
-
-
- {/* Fixed Header */}
-
-
- {t('calls.files.title')}
-
-
-
+ {isOpen && (
+
+
+ {/* Fixed Header */}
+
+
+ {t('calls.files.title')}
+
+
+
- {/* Scrollable Files List */}
-
- {renderFilesContent()}
-
-
-
+ {/* Scrollable Files List */}
+
+ {renderFilesContent()}
+
+
+
+ )}
>
);
};
diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx
index 07acf0c6..7484dd8c 100644
--- a/src/components/calls/call-images-modal.tsx
+++ b/src/components/calls/call-images-modal.tsx
@@ -1,17 +1,16 @@
-import * as FileSystem from 'expo-file-system';
+import { EncodingType, readAsStringAsync } from 'expo-file-system/legacy';
import { Image } from 'expo-image';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import { CameraIcon, ChevronLeftIcon, ChevronRightIcon, ImageIcon, PlusIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
-import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Dimensions, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native';
+import { Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { KeyboardStickyView } from 'react-native-keyboard-controller';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
-import { FlatList } from '@/components/ui/flat-list';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAuthStore } from '@/lib';
import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData';
@@ -33,8 +32,6 @@ interface CallImagesModalProps {
callId: string;
}
-const { width } = Dimensions.get('window');
-
const CallImagesModal: React.FC = ({ isOpen, onClose, callId }) => {
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
@@ -51,7 +48,6 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
const [isAddingImage, setIsAddingImage] = useState(false);
const [imageErrors, setImageErrors] = useState>(new Set());
const [fullScreenImage, setFullScreenImage] = useState<{ source: any; name?: string } | null>(null);
- const flatListRef = useRef>(null);
const callImages = useCallDetailStore((state) => state.callImages);
const isLoadingImages = useCallDetailStore((state) => state.isLoadingImages);
@@ -176,8 +172,8 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
);
// Read the manipulated image as base64
- const base64Image = await FileSystem.readAsStringAsync(manipulatedImage.uri, {
- encoding: FileSystem.EncodingType.Base64,
+ const base64Image = await readAsStringAsync(manipulatedImage.uri, {
+ encoding: EncodingType.Base64,
});
// Get current location if available
@@ -228,29 +224,37 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
setFullScreenImage({ source, name });
}, []);
- // Reset active index when valid images change
+ const getActiveImage = () => {
+ if (!validImages || validImages.length === 0 || activeIndex < 0 || activeIndex >= validImages.length) return null;
+ return validImages[activeIndex];
+ };
- const renderImageItem = ({ item, index }: { item: CallFileResultData; index: number }) => {
+ const handlePrevious = () => {
+ setActiveIndex((prev) => Math.max(0, prev - 1));
+ };
+
+ const handleNext = () => {
+ setActiveIndex((prev) => Math.min(validImages.length - 1, prev + 1));
+ };
+
+ const renderActiveImage = () => {
+ const item = getActiveImage();
if (!item) return null;
const hasError = imageErrors.has(item.Id);
let imageSource: { uri: string } | null = null;
if (item.Data && item.Data.trim() !== '') {
- // Use Data as base64 image
- const mimeType = item.Mime || 'image/png'; // Default to png if no mime type
+ const mimeType = item.Mime || 'image/png';
imageSource = { uri: `data:${mimeType};base64,${item.Data}` };
} else if (item.Url && item.Url.trim() !== '') {
- // Use URL directly since it's unauthenticated
- const url = item.Url.trim();
- imageSource = { uri: url };
+ imageSource = { uri: item.Url.trim() };
}
- // Show error state if there's an error or no valid image source
if (!imageSource || hasError) {
return (
-
-
+
+
{t('callImages.failed_to_load')}
{item.Url && (
@@ -258,19 +262,20 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
URL: {item.Url}
)}
-
- {item.Name || ''}
+
+
+ {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')}
-
- {isSubmitting && }
+ void handleSubmit()} isDisabled={!canProceedFromCurrentStep() || isSubmitting} className="bg-blue-600 px-3">
+ {isSubmitting ? : null}
{isSubmitting ? t('common.submitting') : t('common.submit')}
- )}
+ ) : 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