Skip to content

Commit 5068532

Browse files
authored
Merge pull request #42 from Resgrid/develop
Develop
2 parents 70192db + 2086a1f commit 5068532

42 files changed

Lines changed: 2971 additions & 141 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/react-native-cicd.yml

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,17 @@ jobs:
9393
node-version: '24'
9494
cache: 'yarn'
9595

96-
- name: 📦 Setup yarn cache
96+
# node_modules is post-patch-package state, so patches/ must be part of the key:
97+
# restoring an already-patched tree built from a different patches/ revision makes
98+
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
99+
# back node_modules patched by some other revision. The yarn tarball cache is handled
100+
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
101+
# re-download.
102+
- name: 📦 Setup node_modules cache
97103
uses: actions/cache@v4
98104
with:
99-
path: |
100-
~/.cache/yarn
101-
node_modules
102-
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
103-
restore-keys: |
104-
${{ runner.os }}-yarn-
105+
path: node_modules
106+
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}
105107

106108
- name: 📦 Install dependencies
107109
run: yarn install --frozen-lockfile
@@ -164,15 +166,17 @@ jobs:
164166
eas-version: latest
165167
token: ${{ secrets.EXPO_TOKEN }}
166168

167-
- name: 📦 Setup yarn cache
169+
# node_modules is post-patch-package state, so patches/ must be part of the key:
170+
# restoring an already-patched tree built from a different patches/ revision makes
171+
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
172+
# back node_modules patched by some other revision. The yarn tarball cache is handled
173+
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
174+
# re-download.
175+
- name: 📦 Setup node_modules cache
168176
uses: actions/cache@v4
169177
with:
170-
path: |
171-
~/.cache/yarn
172-
node_modules
173-
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
174-
restore-keys: |
175-
${{ runner.os }}-yarn-
178+
path: node_modules
179+
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}
176180

177181
- name: 📦 Install dependencies
178182
run: |
@@ -399,15 +403,17 @@ jobs:
399403
node-version: '24'
400404
cache: 'yarn'
401405

402-
- name: 📦 Setup yarn cache
406+
# node_modules is post-patch-package state, so patches/ must be part of the key:
407+
# restoring an already-patched tree built from a different patches/ revision makes
408+
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
409+
# back node_modules patched by some other revision. The yarn tarball cache is handled
410+
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
411+
# re-download.
412+
- name: 📦 Setup node_modules cache
403413
uses: actions/cache@v4
404414
with:
405-
path: |
406-
~/.cache/yarn
407-
node_modules
408-
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
409-
restore-keys: |
410-
${{ runner.os }}-yarn-
415+
path: node_modules
416+
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}
411417

412418
- name: 📦 Install dependencies
413419
run: yarn install --frozen-lockfile
@@ -523,15 +529,17 @@ jobs:
523529
node-version: '24'
524530
cache: 'yarn'
525531

526-
- name: 📦 Setup yarn cache
532+
# node_modules is post-patch-package state, so patches/ must be part of the key:
533+
# restoring an already-patched tree built from a different patches/ revision makes
534+
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
535+
# back node_modules patched by some other revision. The yarn tarball cache is handled
536+
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
537+
# re-download.
538+
- name: 📦 Setup node_modules cache
527539
uses: actions/cache@v4
528540
with:
529-
path: |
530-
~/.cache/yarn
531-
node_modules
532-
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
533-
restore-keys: |
534-
${{ runner.os }}-yarn-
541+
path: node_modules
542+
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}
535543

536544
- name: 📦 Install dependencies
537545
run: yarn install --frozen-lockfile

app.config.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
7272
foregroundImage: './assets/adaptive-icon.png',
7373
backgroundColor: '#2484c4',
7474
},
75-
softwareKeyboardLayoutMode: 'pan',
75+
// 'pan' makes Android scroll the window under the IME on its own, which fights
76+
// react-native-keyboard-controller. Its hooks flip the activity to adjustResize on
77+
// mount and call setDefaultMode() on unmount, restoring whatever this value is — so
78+
// with 'pan' any closing sheet or modal drops the app back into pan mode and inputs
79+
// end up under the keyboard. Edge-to-edge means the OS no longer resizes for us
80+
// either, so 'resize' leaves keyboard avoidance entirely to the library.
81+
softwareKeyboardLayoutMode: 'resize',
7682
package: Env.PACKAGE,
7783
googleServicesFile: 'google-services.json',
7884
// Register the ResgridIC:// deep-link scheme so OIDC / SAML callbacks are routed back here
@@ -138,7 +144,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
138144
[
139145
'@rnmapbox/maps',
140146
{
141-
RNMapboxMapsVersion: '11.16.2',
147+
// Keep in step with the `mapbox` field of the installed @rnmapbox/maps — the JS
148+
// bindings are generated against a specific native SDK, and pinning an older one
149+
// makes style props the bindings emit (symbolZOffset and friends) trap natively.
150+
RNMapboxMapsVersion: '11.23.1',
142151
},
143152
],
144153
[

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"@notifee/react-native": "9.1.8",
7272
"@novu/react-native": "3.11.0",
7373
"@react-native-community/netinfo": "12.0.1",
74-
"@rnmapbox/maps": "10.2.10",
74+
"@rnmapbox/maps": "10.3.5",
7575
"@semantic-release/git": "10.0.1",
7676
"@sentry/react-native": "~8.20.0",
7777
"@shopify/flash-list": "2.0.2",
@@ -161,6 +161,7 @@
161161
"@commitlint/config-conventional": "19.2.2",
162162
"@expo/config": "^56.0.0",
163163
"@react-native/jest-preset": "~0.85.3",
164+
"@tailwindcss/postcss": "^4.2.0",
164165
"@testing-library/jest-dom": "6.5.0",
165166
"@testing-library/react-native": "12.9.0",
166167
"@types/geojson": "7946.0.16",
@@ -202,7 +203,6 @@
202203
"postinstall-postinstall": "2.1.0",
203204
"prettier": "3.3.3",
204205
"react-native-svg-transformer": "1.5.1",
205-
"@tailwindcss/postcss": "^4.2.0",
206206
"tailwindcss": "^4.2.0",
207207
"ts-node": "10.9.2",
208208
"typescript": "~6.0.3",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
diff --git a/node_modules/@rnmapbox/maps/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt b/node_modules/@rnmapbox/maps/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt
2+
index 8cda49e..87f4b2d 100644
3+
--- a/node_modules/@rnmapbox/maps/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt
4+
+++ b/node_modules/@rnmapbox/maps/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt
5+
@@ -50,8 +50,15 @@ class LocationObserverAdapter(val callback: LocationEngineCallback): LocationObs
6+
callback.onSuccess(LocationEngineResult(locations.last()))
7+
}
8+
}
9+
+// Each registration remembers the provider the observer was actually registered on.
10+
+// `LocationEngine.locationProvider` is swapped whenever the request changes, so unregistering
11+
+// through the *current* provider would leave the observer attached to the previous one — a
12+
+// callback the caller already tore down, still being fed location updates.
13+
+// Plain class, not a data class: identity equality is what `contains`/`removeAll` want here.
14+
+class LocationObserverRegistration(val observer: LocationObserverAdapter, val provider: DeviceLocationProvider)
15+
+
16+
class LocationEngine(var locationProvider: DeviceLocationProvider, var request: LocationProviderRequest) {
17+
- var observers: MutableList<LocationObserverAdapter> = mutableListOf()
18+
+ var observers: MutableList<LocationObserverRegistration> = mutableListOf()
19+
}
20+
21+
22+
@@ -73,17 +80,53 @@ fun LocationEngine.requestLocationUpdatesV11(callback: LocationEngineCallback, l
23+
}
24+
}
25+
val observer = LocationObserverAdapter(callback)
26+
+ // Publish into `observers` *before* registering with the provider, and pin the provider we
27+
+ // register on. A concurrent `removeLocationUpdates` (same two threads described below) that
28+
+ // lands between the two steps would otherwise never see this observer and would leave it
29+
+ // registered — feeding location updates to a callback the caller already tore down, on a
30+
+ // provider instance the request refresh above may have replaced. Registration itself stays
31+
+ // outside the monitor: no Mapbox SDK call is ever made while holding it.
32+
+ val provider = locationProvider
33+
+ val registration = LocationObserverRegistration(observer, provider)
34+
+ synchronized(observers) {
35+
+ observers.add(registration)
36+
+ }
37+
if (looper != null) {
38+
- locationProvider.addLocationObserver(observer, looper)
39+
+ provider.addLocationObserver(observer, looper)
40+
} else {
41+
- locationProvider.addLocationObserver(observer)
42+
+ provider.addLocationObserver(observer)
43+
+ }
44+
+ // Gone from the list means a removal ran while we were registering, and its
45+
+ // `removeLocationObserver` hit a provider that did not know this observer yet. Undo here.
46+
+ val canceled = synchronized(observers) { !observers.contains(registration) }
47+
+ if (canceled) {
48+
+ provider.removeLocationObserver(observer)
49+
}
50+
- observers.add(observer)
51+
}
52+
53+
+// `observers` is reached from more than one thread: LocationManager.enable() runs on the
54+
+// main thread when the activity resumes, and again on the React native-modules thread when
55+
+// RNMBXLocationModule.start()/setMinDisplacement() land. Unsynchronized, the two overlap
56+
+// inside Kotlin's `removeAll { }` (filterInPlace), whose trailing `removeAt(readIndex)`
57+
+// walks indices captured before the other thread shrank the list — IndexOutOfBoundsException
58+
+// "Index 0 out of bounds for length 0", crashing the app on foreground.
59+
+//
60+
+// Guard both mutations, and match on identity via a plain collection so `filterInPlace`
61+
+// is not involved at all. `removeLocationObserver` runs outside the lock: it calls into the
62+
+// Mapbox SDK and must not be holding our monitor while it does — and it goes through each
63+
+// registration's own provider, not `locationProvider`, which a later request refresh may
64+
+// already have replaced.
65+
+//
66+
+// Dropping an observer from the list is also the cancel signal for a registration still
67+
+// in flight on the other thread — see `requestLocationUpdatesV11`, which re-checks
68+
+// membership after registering and unregisters itself if it was pulled out meanwhile.
69+
fun LocationEngine.removeLocationUpdates(callback: LocationEngineCallback) {
70+
- observers.filter { it.callback == callback }.forEach { locationProvider.removeLocationObserver(it) }
71+
- observers.removeAll { it.callback == callback }
72+
+ val stale = synchronized(observers) {
73+
+ val matched = observers.filter { it.observer.callback == callback }
74+
+ observers.removeAll(matched.toSet())
75+
+ matched
76+
+ }
77+
+ stale.forEach { it.provider.removeLocationObserver(it.observer) }
78+
}
79+
80+
fun LocationEngine.getLastLocation(callback: LocationEngineCallback) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
diff --git a/node_modules/react-native-webview/apple/RNCWebViewDecisionManager.m b/node_modules/react-native-webview/apple/RNCWebViewDecisionManager.m
2+
index aa90548..0c99c73 100644
3+
--- a/node_modules/react-native-webview/apple/RNCWebViewDecisionManager.m
4+
+++ b/node_modules/react-native-webview/apple/RNCWebViewDecisionManager.m
5+
@@ -1,7 +1,15 @@
6+
#import "RNCWebViewDecisionManager.h"
7+
8+
-
9+
-
10+
+/**
11+
+ * Thread-safe singleton that manages navigation decision handlers for WKWebView.
12+
+ *
13+
+ * This class bridges async navigation decisions between:
14+
+ * - WKWebView delegate (main thread) - stores decision handlers
15+
+ * - React Native bridge (background thread) - resolves decisions from JS
16+
+ *
17+
+ * All public methods use @synchronized for thread safety since they access
18+
+ * shared state (nextLockIdentifier and decisionHandlers) from different threads.
19+
+ */
20+
@implementation RNCWebViewDecisionManager
21+
22+
@synthesize nextLockIdentifier;
23+
@@ -16,22 +24,39 @@
24+
return lockManager;
25+
}
26+
27+
+/**
28+
+ * Stores a decision handler and returns a unique identifier.
29+
+ * Called from the main thread (WKNavigationDelegate).
30+
+ * @synchronized ensures atomic increment + insertion.
31+
+ */
32+
- (int)setDecisionHandler:(DecisionBlock)decisionHandler {
33+
- int lockIdentifier = self.nextLockIdentifier++;
34+
-
35+
- [self.decisionHandlers setObject:decisionHandler forKey:@(lockIdentifier)];
36+
- return lockIdentifier;
37+
+ @synchronized (self) {
38+
+ int lockIdentifier = self.nextLockIdentifier++;
39+
+ [self.decisionHandlers setObject:decisionHandler forKey:@(lockIdentifier)];
40+
+ return lockIdentifier;
41+
+ }
42+
}
43+
44+
+/**
45+
+ * Resolves a pending navigation decision.
46+
+ * Called from the RN bridge thread (background) when JS responds.
47+
+ *
48+
+ * The handler is invoked OUTSIDE the @synchronized block to prevent deadlocks,
49+
+ * since the handler dispatches to the main queue and could potentially
50+
+ * trigger another navigation that re-enters this class.
51+
+ */
52+
- (void) setResult:(BOOL)shouldStart
53+
forLockIdentifier:(int)lockIdentifier {
54+
- DecisionBlock handler = [self.decisionHandlers objectForKey:@(lockIdentifier)];
55+
- if (handler == nil) {
56+
- RCTLogWarn(@"Lock not found");
57+
- return;
58+
+ DecisionBlock handler;
59+
+ @synchronized (self) {
60+
+ handler = [self.decisionHandlers objectForKey:@(lockIdentifier)];
61+
+ if (handler == nil) {
62+
+ RCTLogWarn(@"Lock not found");
63+
+ return;
64+
+ }
65+
+ [self.decisionHandlers removeObjectForKey:@(lockIdentifier)];
66+
}
67+
handler(shouldStart);
68+
- [self.decisionHandlers removeObjectForKey:@(lockIdentifier)];
69+
}
70+
71+
- (id)init {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { type NewCallFieldPolicyResultData } from '@/models/v4/calls/newCallFieldPolicyResultData';
2+
3+
import { createApiEndpoint } from '../common/client';
4+
5+
const getNewCallFieldPolicyApi = createApiEndpoint('/Calls/GetNewCallFieldPolicy');
6+
7+
interface NewCallFieldPolicyResult {
8+
Data: NewCallFieldPolicyResultData | null;
9+
}
10+
11+
/**
12+
* Fetches the department's new-call field policy.
13+
*
14+
* An empty rule list means the stock form — every field visible, nothing extra required — which is
15+
* also what a failure degrades to, since hiding fields a dispatcher needs is far worse than showing
16+
* one they were told to hide. The server enforces the same policy on save regardless.
17+
*/
18+
export const getNewCallFieldPolicy = async (signal?: AbortSignal): Promise<NewCallFieldPolicyResultData> => {
19+
const response = await getNewCallFieldPolicyApi.get<NewCallFieldPolicyResult>(undefined, signal);
20+
21+
return response.data?.Data ?? { Rules: [] };
22+
};

src/app/(app)/index.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useMapGeolocationUpdates } from '@/hooks/use-map-geolocation-updates';
2121
import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
2222
import { Env } from '@/lib/env';
2323
import { logger } from '@/lib/logging';
24+
import { useDepartmentMapCenter } from '@/lib/map-center';
2425
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
2526
import { locationService } from '@/services/location';
2627
import { useCoreStore } from '@/stores/app/core-store';
@@ -60,6 +61,9 @@ function MapContent() {
6061
const locationLongitude = useLocationStore((state) => state.longitude);
6162
const locationHeading = useLocationStore((state) => state.heading);
6263
const isMapLocked = useLocationStore((state) => state.isMapLocked);
64+
// Reactive: department config can land after this screen mounts.
65+
const departmentCenter = useDepartmentMapCenter();
66+
const appliedDepartmentCenterRef = useRef<string | null>(null);
6367

6468
// Weather alert banner state
6569
const weatherAlerts = useWeatherAlertsStore((state) => state.alerts);
@@ -108,14 +112,36 @@ function MapContent() {
108112
};
109113
}
110114

111-
// Fallback: default US center when location hasn't arrived yet
115+
// Fallback: the department's configured center when location hasn't arrived yet
112116
return {
113-
centerCoordinate: [-98.5795, 39.8283] as [number, number],
117+
centerCoordinate: [departmentCenter.longitude, departmentCenter.latitude] as [number, number],
114118
zoomLevel: 4,
115119
heading: 0,
116120
pitch: 0,
117121
};
118-
}, [locationLatitude, locationLongitude, isMapLocked]);
122+
}, [locationLatitude, locationLongitude, isMapLocked, departmentCenter.latitude, departmentCenter.longitude]);
123+
124+
// defaultSettings only applies at mount, and department config can land after it. Move the camera
125+
// to the department center when it changes, unless a device fix or the user got there first.
126+
useEffect(() => {
127+
if (!isMapReady || hasUserMovedMap || (locationLatitude != null && locationLongitude != null)) {
128+
return;
129+
}
130+
131+
const center = `${departmentCenter.longitude},${departmentCenter.latitude}`;
132+
if (appliedDepartmentCenterRef.current === center) {
133+
return;
134+
}
135+
appliedDepartmentCenterRef.current = center;
136+
137+
cameraRef.current?.setCamera({
138+
centerCoordinate: [departmentCenter.longitude, departmentCenter.latitude],
139+
zoomLevel: 4,
140+
heading: 0,
141+
pitch: 0,
142+
animationDuration: 500,
143+
});
144+
}, [isMapReady, hasUserMovedMap, locationLatitude, locationLongitude, departmentCenter.latitude, departmentCenter.longitude]);
119145

120146
// Fetch map layers (department-level, no unit context needed)
121147
useEffect(() => {

0 commit comments

Comments
 (0)