Skip to content

Commit 1f4dd4a

Browse files
authored
Merge pull request #122 from Resgrid/develop
CU-868cu9311 Location enhancements fixing bugs some tests.
2 parents 0552fef + 6db467d commit 1f4dd4a

File tree

17 files changed

+1715
-116
lines changed

17 files changed

+1715
-116
lines changed

.DS_Store

0 Bytes
Binary file not shown.

.github/copilot-instructions.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
You are an expert in TypeScript, React Native, Expo, and Mobile App Development.
2+
3+
Code Style and Structure:
4+
5+
- Write concise, type-safe TypeScript code.
6+
- Use functional components and hooks over class components.
7+
- Ensure components are modular, reusable, and maintainable.
8+
- Organize files by feature, grouping related components, hooks, and styles.
9+
10+
Naming Conventions:
11+
12+
- Use camelCase for variable and function names (e.g., `isFetchingData`, `handleUserInput`).
13+
- Use PascalCase for component names (e.g., `UserProfile`, `ChatScreen`).
14+
- Directory and File names should be lowercase and hyphenated (e.g., `user-profile`, `chat-screen`).
15+
16+
TypeScript Usage:
17+
18+
- Use TypeScript for all components, favoring interfaces for props and state.
19+
- Enable strict typing in `tsconfig.json`.
20+
- Avoid using `any`; strive for precise types.
21+
- Utilize `React.FC` for defining functional components with props.
22+
23+
Performance Optimization:
24+
25+
- Minimize `useEffect`, `useState`, and heavy computations inside render methods.
26+
- Use `React.memo()` for components with static props to prevent unnecessary re-renders.
27+
- Optimize FlatLists with props like `removeClippedSubviews`, `maxToRenderPerBatch`, and `windowSize`.
28+
- Use `getItemLayout` for FlatLists when items have a consistent size to improve performance.
29+
- Avoid anonymous functions in `renderItem` or event handlers to prevent re-renders.
30+
31+
UI and Styling:
32+
33+
- Use consistent styling leveraging `gluestack-ui`. If there isn't a Gluestack component in the `components/ui` directory for the component you are trying to use consistently style it either through `StyleSheet.create()` or Styled Components.
34+
- Ensure responsive design by considering different screen sizes and orientations.
35+
- Optimize image handling using libraries designed for React Native, like `react-native-fast-image`.
36+
37+
Best Practices:
38+
39+
- Follow React Native's threading model to ensure smooth UI performance.
40+
- Use React Navigation for handling navigation and deep linking with best practices.
41+
- Create and use Jest to test to validate all generated components
42+
- Generate tests for all components, services and logic generated. Ensure tests run without errors and fix any issues.
43+
44+
Additional Rules:
45+
46+
- Use `yarn` as the package manager.
47+
- Use Expo's secure store for sensitive data
48+
- Implement proper offline support
49+
- Use `zustand` for state management
50+
- Use `react-hook-form` for form handling
51+
- Use `react-query` for data fetching
52+
- Use `react-i18next` for internationalization
53+
- Use `react-native-mmkv` for local storage
54+
- Use `axios` for API requests
55+
- Use `@rnmapbox/maps` for maps, mapping or vehicle navigation
56+
- Use `lucide-react-native` for icons and use those components directly in the markup and don't use the gluestack-ui icon component
57+
- Use ? : for conditional rendering and not &&

src/api/units/unitLocation.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createApiEndpoint } from '@/api/common/client';
2+
import { type SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput';
3+
import { type SaveUnitLocationResult } from '@/models/v4/unitLocation/saveUnitLocationResult';
4+
import { type UnitLocationResult } from '@/models/v4/unitLocation/unitLocationResult';
5+
6+
const setUnitLocationApi = createApiEndpoint('/UnitLocation/SetUnitLocation');
7+
const getUnitLocationApi = createApiEndpoint('/UnitLocation/GetLatestUnitLocation');
8+
9+
export const setUnitLocation = async (data: SaveUnitLocationInput) => {
10+
const response = await setUnitLocationApi.post<SaveUnitLocationResult>({
11+
...data,
12+
});
13+
return response.data;
14+
};
15+
16+
export const getUnitLocation = async (unitId: string) => {
17+
const response = await getUnitLocationApi.get<UnitLocationResult>({
18+
unitId: unitId,
19+
});
20+
return response.data;
21+
};

src/app/(app)/index.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default function Map() {
3232
latitude: state.latitude,
3333
longitude: state.longitude,
3434
heading: state.heading,
35+
isMapLocked: state.isMapLocked,
3536
}));
3637

3738
const _mapOptions = Object.keys(Mapbox.StyleURL)
@@ -82,18 +83,28 @@ export default function Map() {
8283
latitude: location.latitude,
8384
longitude: location.longitude,
8485
heading: location.heading,
86+
isMapLocked: location.isMapLocked,
8587
},
8688
});
8789

88-
if (!hasUserMovedMap) {
90+
// When map is locked, always follow the location
91+
// When map is unlocked, only follow if user hasn't moved the map
92+
if (location.isMapLocked || !hasUserMovedMap) {
8993
cameraRef.current?.setCamera({
9094
centerCoordinate: [location.longitude, location.latitude],
9195
zoomLevel: 12,
92-
animationDuration: 1000,
96+
animationDuration: location.isMapLocked ? 500 : 1000,
9397
});
9498
}
9599
}
96-
}, [location.latitude, location.longitude, location.heading, hasUserMovedMap]);
100+
}, [location.latitude, location.longitude, location.heading, location.isMapLocked, hasUserMovedMap]);
101+
102+
// Reset hasUserMovedMap when map gets locked
103+
useEffect(() => {
104+
if (location.isMapLocked) {
105+
setHasUserMovedMap(false);
106+
}
107+
}, [location.isMapLocked]);
97108

98109
useEffect(() => {
99110
const fetchMapDataAndMarkers = async () => {
@@ -125,7 +136,8 @@ export default function Map() {
125136
}, [pulseAnim]);
126137

127138
const onCameraChanged = (event: any) => {
128-
if (event.properties.isUserInteraction) {
139+
// Only register user interaction if map is not locked
140+
if (event.properties.isUserInteraction && !location.isMapLocked) {
129141
setHasUserMovedMap(true);
130142
}
131143
};
@@ -177,6 +189,9 @@ export default function Map() {
177189
setSelectedPin(null);
178190
};
179191

192+
// Show recenter button only when map is not locked and user has moved the map
193+
const showRecenterButton = !location.isMapLocked && hasUserMovedMap && location.latitude && location.longitude;
194+
180195
return (
181196
<>
182197
<Stack.Screen
@@ -188,7 +203,7 @@ export default function Map() {
188203
/>
189204
<View className="size-full flex-1" testID="map-container">
190205
<Mapbox.MapView ref={mapRef} styleURL={styleURL.styleURL} style={styles.map} onCameraChanged={onCameraChanged} testID="map-view">
191-
<Mapbox.Camera ref={cameraRef} followZoomLevel={12} followUserLocation followUserMode={Mapbox.UserTrackingMode.Follow} />
206+
<Mapbox.Camera ref={cameraRef} followZoomLevel={12} followUserLocation={location.isMapLocked} followUserMode={location.isMapLocked ? Mapbox.UserTrackingMode.Follow : undefined} />
192207

193208
{location.latitude && location.longitude && (
194209
<Mapbox.PointAnnotation id="userLocation" coordinate={[location.longitude, location.latitude]} anchor={{ x: 0.5, y: 0.5 }}>
@@ -220,8 +235,8 @@ export default function Map() {
220235
<MapPins pins={mapPins} onPinPress={handlePinPress} />
221236
</Mapbox.MapView>
222237

223-
{/* Recenter Button */}
224-
{hasUserMovedMap && location.latitude && location.longitude && (
238+
{/* Recenter Button - only show when map is not locked and user has moved the map */}
239+
{showRecenterButton && (
225240
<TouchableOpacity style={styles.recenterButton} onPress={handleRecenterMap} testID="recenter-button">
226241
<NavigationIcon size={20} color="#ffffff" />
227242
</TouchableOpacity>

src/components/sidebar/__tests__/call-sidebar.test.tsx

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useCoreStore } from '@/stores/app/core-store';
1111
import { useCallsStore } from '@/stores/calls/store';
1212
import { type CallResultData } from '@/models/v4/calls/callResultData';
1313
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
14+
import { openMapsWithDirections, openMapsWithAddress } from '@/lib/navigation';
1415

1516
// Mock dependencies
1617
jest.mock('react-i18next');
@@ -20,6 +21,7 @@ jest.mock('expo-router');
2021
jest.mock('react-native/Libraries/Alert/Alert');
2122
jest.mock('@/stores/app/core-store');
2223
jest.mock('@/stores/calls/store');
24+
jest.mock('@/lib/navigation');
2325

2426
// Mock UI components
2527
jest.mock('@/components/ui/bottom-sheet', () => ({
@@ -184,6 +186,8 @@ const mockRouter = router as jest.Mocked<typeof router>;
184186
const mockAlert = Alert as jest.Mocked<typeof Alert>;
185187
const mockUseCoreStore = useCoreStore as jest.MockedFunction<typeof useCoreStore>;
186188
const mockUseCallsStore = useCallsStore as jest.MockedFunction<typeof useCallsStore>;
189+
const mockOpenMapsWithDirections = openMapsWithDirections as jest.MockedFunction<typeof openMapsWithDirections>;
190+
const mockOpenMapsWithAddress = openMapsWithAddress as jest.MockedFunction<typeof openMapsWithAddress>;
187191

188192
describe('SidebarCallCard', () => {
189193
const mockSetActiveCall = jest.fn();
@@ -224,6 +228,8 @@ describe('SidebarCallCard', () => {
224228

225229
mockAlert.alert = jest.fn();
226230
mockRouter.push = jest.fn();
231+
mockOpenMapsWithDirections.mockResolvedValue(true);
232+
mockOpenMapsWithAddress.mockResolvedValue(true);
227233
});
228234

229235
describe('Basic Rendering', () => {
@@ -251,7 +257,7 @@ describe('SidebarCallCard', () => {
251257
expect(screen.getByText('Test Emergency Call')).toBeTruthy();
252258
});
253259

254-
it('should show action buttons when active call exists', () => {
260+
it('should show action buttons when active call exists with coordinates', () => {
255261
mockUseCoreStore.mockReturnValue({
256262
activeCall: mockCall,
257263
activePriority: mockPriority,
@@ -264,6 +270,69 @@ describe('SidebarCallCard', () => {
264270
expect(screen.getByTestId('map-pin-icon')).toBeTruthy();
265271
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
266272
});
273+
274+
it('should show map button when active call has address only', () => {
275+
const callWithAddressOnly = {
276+
...mockCall,
277+
Latitude: '',
278+
Longitude: '',
279+
Address: '123 Test Street',
280+
};
281+
282+
mockUseCoreStore.mockReturnValue({
283+
activeCall: callWithAddressOnly,
284+
activePriority: mockPriority,
285+
setActiveCall: mockSetActiveCall,
286+
});
287+
288+
render(<SidebarCallCard />);
289+
290+
expect(screen.getByTestId('eye-icon')).toBeTruthy();
291+
expect(screen.getByTestId('map-pin-icon')).toBeTruthy();
292+
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
293+
});
294+
295+
it('should not show map button when active call has no location data', () => {
296+
const callWithoutLocation = {
297+
...mockCall,
298+
Latitude: '',
299+
Longitude: '',
300+
Address: '',
301+
};
302+
303+
mockUseCoreStore.mockReturnValue({
304+
activeCall: callWithoutLocation,
305+
activePriority: mockPriority,
306+
setActiveCall: mockSetActiveCall,
307+
});
308+
309+
render(<SidebarCallCard />);
310+
311+
expect(screen.getByTestId('eye-icon')).toBeTruthy();
312+
expect(() => screen.getByTestId('map-pin-icon')).toThrow();
313+
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
314+
});
315+
316+
it('should not show map button when active call has empty address', () => {
317+
const callWithEmptyAddress = {
318+
...mockCall,
319+
Latitude: '',
320+
Longitude: '',
321+
Address: ' ',
322+
};
323+
324+
mockUseCoreStore.mockReturnValue({
325+
activeCall: callWithEmptyAddress,
326+
activePriority: mockPriority,
327+
setActiveCall: mockSetActiveCall,
328+
});
329+
330+
render(<SidebarCallCard />);
331+
332+
expect(screen.getByTestId('eye-icon')).toBeTruthy();
333+
expect(() => screen.getByTestId('map-pin-icon')).toThrow();
334+
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
335+
});
267336
});
268337

269338
describe('Bottom Sheet Behavior', () => {
@@ -356,6 +425,88 @@ describe('SidebarCallCard', () => {
356425
{ cancelable: true }
357426
);
358427
});
428+
429+
it('should open maps with coordinates when map pin button is pressed with valid coordinates', async () => {
430+
render(<SidebarCallCard />);
431+
432+
fireEvent.press(screen.getByTestId('map-pin-icon'));
433+
434+
expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(
435+
mockCall.Latitude,
436+
mockCall.Longitude,
437+
mockCall.Address
438+
);
439+
expect(mockOpenMapsWithAddress).not.toHaveBeenCalled();
440+
});
441+
442+
it('should open maps with address when map pin button is pressed with address only', async () => {
443+
const callWithAddressOnly = {
444+
...mockCall,
445+
Latitude: '',
446+
Longitude: '',
447+
Address: '123 Test Street',
448+
};
449+
450+
mockUseCoreStore.mockReturnValue({
451+
activeCall: callWithAddressOnly,
452+
activePriority: mockPriority,
453+
setActiveCall: mockSetActiveCall,
454+
});
455+
456+
render(<SidebarCallCard />);
457+
458+
fireEvent.press(screen.getByTestId('map-pin-icon'));
459+
460+
expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('123 Test Street');
461+
expect(mockOpenMapsWithDirections).not.toHaveBeenCalled();
462+
});
463+
464+
it('should show error alert when openMapsWithDirections fails', async () => {
465+
mockOpenMapsWithDirections.mockRejectedValue(new Error('Navigation failed'));
466+
467+
render(<SidebarCallCard />);
468+
469+
fireEvent.press(screen.getByTestId('map-pin-icon'));
470+
471+
// Wait for the async operation to complete
472+
await new Promise(resolve => setTimeout(resolve, 0));
473+
474+
expect(mockAlert.alert).toHaveBeenCalledWith(
475+
'calls.no_location_title',
476+
'calls.no_location_message',
477+
[{ text: 'common.ok' }]
478+
);
479+
});
480+
481+
it('should show error alert when openMapsWithAddress fails', async () => {
482+
const callWithAddressOnly = {
483+
...mockCall,
484+
Latitude: '',
485+
Longitude: '',
486+
Address: '123 Test Street',
487+
};
488+
489+
mockUseCoreStore.mockReturnValue({
490+
activeCall: callWithAddressOnly,
491+
activePriority: mockPriority,
492+
setActiveCall: mockSetActiveCall,
493+
});
494+
495+
mockOpenMapsWithAddress.mockRejectedValue(new Error('Address navigation failed'));
496+
497+
render(<SidebarCallCard />);
498+
499+
fireEvent.press(screen.getByTestId('map-pin-icon'));
500+
501+
// Wait for the async operation to complete
502+
await new Promise(resolve => setTimeout(resolve, 0));
503+
504+
expect(mockAlert.alert).toHaveBeenCalledWith(
505+
'calls.no_location_title',
506+
'calls.no_location_message',
507+
[{ text: 'common.ok' }]
508+
);
509+
});
359510
});
360511

361512
describe('Accessibility', () => {

0 commit comments

Comments
 (0)