Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
57 changes: 57 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
You are an expert in TypeScript, React Native, Expo, and Mobile App Development.

Code Style and Structure:

- Write concise, type-safe TypeScript code.
- Use functional components and hooks over class components.
- Ensure components are modular, reusable, and maintainable.
- Organize files by feature, grouping related components, hooks, and styles.

Naming Conventions:

- Use camelCase for variable and function names (e.g., `isFetchingData`, `handleUserInput`).
- Use PascalCase for component names (e.g., `UserProfile`, `ChatScreen`).
- Directory and File names should be lowercase and hyphenated (e.g., `user-profile`, `chat-screen`).

TypeScript Usage:

- Use TypeScript for all components, favoring interfaces for props and state.
- Enable strict typing in `tsconfig.json`.
- Avoid using `any`; strive for precise types.
- Utilize `React.FC` for defining functional components with props.

Performance Optimization:

- Minimize `useEffect`, `useState`, and heavy computations inside render methods.
- Use `React.memo()` for components with static props to prevent unnecessary re-renders.
- Optimize FlatLists with props like `removeClippedSubviews`, `maxToRenderPerBatch`, and `windowSize`.
- Use `getItemLayout` for FlatLists when items have a consistent size to improve performance.
- Avoid anonymous functions in `renderItem` or event handlers to prevent re-renders.

UI and Styling:

- 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.
- Ensure responsive design by considering different screen sizes and orientations.
- Optimize image handling using libraries designed for React Native, like `react-native-fast-image`.

Best Practices:

- Follow React Native's threading model to ensure smooth UI performance.
- Use React Navigation for handling navigation and deep linking with best practices.
- Create and use Jest to test to validate all generated components
- Generate tests for all components, services and logic generated. Ensure tests run without errors and fix any issues.

Additional Rules:

- Use `yarn` as the package manager.
- Use Expo's secure store for sensitive data
- Implement proper offline support
- Use `zustand` for state management
- Use `react-hook-form` for form handling
- Use `react-query` for data fetching
- Use `react-i18next` for internationalization
- Use `react-native-mmkv` for local storage
- Use `axios` for API requests
- Use `@rnmapbox/maps` for maps, mapping or vehicle navigation
- Use `lucide-react-native` for icons and use those components directly in the markup and don't use the gluestack-ui icon component
- Use ? : for conditional rendering and not &&
21 changes: 21 additions & 0 deletions src/api/units/unitLocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createApiEndpoint } from '@/api/common/client';
import { type SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput';
import { type SaveUnitLocationResult } from '@/models/v4/unitLocation/saveUnitLocationResult';
import { type UnitLocationResult } from '@/models/v4/unitLocation/unitLocationResult';

const setUnitLocationApi = createApiEndpoint('/UnitLocation/SetUnitLocation');
const getUnitLocationApi = createApiEndpoint('/UnitLocation/GetLatestUnitLocation');

export const setUnitLocation = async (data: SaveUnitLocationInput) => {
const response = await setUnitLocationApi.post<SaveUnitLocationResult>({
...data,
});
return response.data;
};

export const getUnitLocation = async (unitId: string) => {
const response = await getUnitLocationApi.get<UnitLocationResult>({
unitId: unitId,
});
return response.data;
};
29 changes: 22 additions & 7 deletions src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default function Map() {
latitude: state.latitude,
longitude: state.longitude,
heading: state.heading,
isMapLocked: state.isMapLocked,
}));

const _mapOptions = Object.keys(Mapbox.StyleURL)
Expand Down Expand Up @@ -82,18 +83,28 @@ export default function Map() {
latitude: location.latitude,
longitude: location.longitude,
heading: location.heading,
isMapLocked: location.isMapLocked,
},
});

if (!hasUserMovedMap) {
// When map is locked, always follow the location
// When map is unlocked, only follow if user hasn't moved the map
if (location.isMapLocked || !hasUserMovedMap) {
cameraRef.current?.setCamera({
centerCoordinate: [location.longitude, location.latitude],
zoomLevel: 12,
animationDuration: 1000,
animationDuration: location.isMapLocked ? 500 : 1000,
});
}
}
}, [location.latitude, location.longitude, location.heading, hasUserMovedMap]);
}, [location.latitude, location.longitude, location.heading, location.isMapLocked, hasUserMovedMap]);

// Reset hasUserMovedMap when map gets locked
useEffect(() => {
if (location.isMapLocked) {
setHasUserMovedMap(false);
}
}, [location.isMapLocked]);

useEffect(() => {
const fetchMapDataAndMarkers = async () => {
Expand Down Expand Up @@ -125,7 +136,8 @@ export default function Map() {
}, [pulseAnim]);

const onCameraChanged = (event: any) => {
if (event.properties.isUserInteraction) {
// Only register user interaction if map is not locked
if (event.properties.isUserInteraction && !location.isMapLocked) {
setHasUserMovedMap(true);
}
};
Expand Down Expand Up @@ -177,6 +189,9 @@ export default function Map() {
setSelectedPin(null);
};

// Show recenter button only when map is not locked and user has moved the map
const showRecenterButton = !location.isMapLocked && hasUserMovedMap && location.latitude && location.longitude;

return (
<>
<Stack.Screen
Expand All @@ -188,7 +203,7 @@ export default function Map() {
/>
<View className="size-full flex-1" testID="map-container">
<Mapbox.MapView ref={mapRef} styleURL={styleURL.styleURL} style={styles.map} onCameraChanged={onCameraChanged} testID="map-view">
<Mapbox.Camera ref={cameraRef} followZoomLevel={12} followUserLocation followUserMode={Mapbox.UserTrackingMode.Follow} />
<Mapbox.Camera ref={cameraRef} followZoomLevel={12} followUserLocation={location.isMapLocked} followUserMode={location.isMapLocked ? Mapbox.UserTrackingMode.Follow : undefined} />

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

{/* Recenter Button */}
{hasUserMovedMap && location.latitude && location.longitude && (
{/* Recenter Button - only show when map is not locked and user has moved the map */}
{showRecenterButton && (
<TouchableOpacity style={styles.recenterButton} onPress={handleRecenterMap} testID="recenter-button">
<NavigationIcon size={20} color="#ffffff" />
</TouchableOpacity>
Expand Down
153 changes: 152 additions & 1 deletion src/components/sidebar/__tests__/call-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
import { openMapsWithDirections, openMapsWithAddress } from '@/lib/navigation';

// Mock dependencies
jest.mock('react-i18next');
Expand All @@ -20,6 +21,7 @@ jest.mock('expo-router');
jest.mock('react-native/Libraries/Alert/Alert');
jest.mock('@/stores/app/core-store');
jest.mock('@/stores/calls/store');
jest.mock('@/lib/navigation');

// Mock UI components
jest.mock('@/components/ui/bottom-sheet', () => ({
Expand Down Expand Up @@ -184,6 +186,8 @@ const mockRouter = router as jest.Mocked<typeof router>;
const mockAlert = Alert as jest.Mocked<typeof Alert>;
const mockUseCoreStore = useCoreStore as jest.MockedFunction<typeof useCoreStore>;
const mockUseCallsStore = useCallsStore as jest.MockedFunction<typeof useCallsStore>;
const mockOpenMapsWithDirections = openMapsWithDirections as jest.MockedFunction<typeof openMapsWithDirections>;
const mockOpenMapsWithAddress = openMapsWithAddress as jest.MockedFunction<typeof openMapsWithAddress>;

describe('SidebarCallCard', () => {
const mockSetActiveCall = jest.fn();
Expand Down Expand Up @@ -224,6 +228,8 @@ describe('SidebarCallCard', () => {

mockAlert.alert = jest.fn();
mockRouter.push = jest.fn();
mockOpenMapsWithDirections.mockResolvedValue(true);
mockOpenMapsWithAddress.mockResolvedValue(true);
});

describe('Basic Rendering', () => {
Expand Down Expand Up @@ -251,7 +257,7 @@ describe('SidebarCallCard', () => {
expect(screen.getByText('Test Emergency Call')).toBeTruthy();
});

it('should show action buttons when active call exists', () => {
it('should show action buttons when active call exists with coordinates', () => {
mockUseCoreStore.mockReturnValue({
activeCall: mockCall,
activePriority: mockPriority,
Expand All @@ -264,6 +270,69 @@ describe('SidebarCallCard', () => {
expect(screen.getByTestId('map-pin-icon')).toBeTruthy();
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
});

it('should show map button when active call has address only', () => {
const callWithAddressOnly = {
...mockCall,
Latitude: '',
Longitude: '',
Address: '123 Test Street',
};

mockUseCoreStore.mockReturnValue({
activeCall: callWithAddressOnly,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
});

render(<SidebarCallCard />);

expect(screen.getByTestId('eye-icon')).toBeTruthy();
expect(screen.getByTestId('map-pin-icon')).toBeTruthy();
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
});

it('should not show map button when active call has no location data', () => {
const callWithoutLocation = {
...mockCall,
Latitude: '',
Longitude: '',
Address: '',
};

mockUseCoreStore.mockReturnValue({
activeCall: callWithoutLocation,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
});

render(<SidebarCallCard />);

expect(screen.getByTestId('eye-icon')).toBeTruthy();
expect(() => screen.getByTestId('map-pin-icon')).toThrow();
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
});

it('should not show map button when active call has empty address', () => {
const callWithEmptyAddress = {
...mockCall,
Latitude: '',
Longitude: '',
Address: ' ',
};

mockUseCoreStore.mockReturnValue({
activeCall: callWithEmptyAddress,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
});

render(<SidebarCallCard />);

expect(screen.getByTestId('eye-icon')).toBeTruthy();
expect(() => screen.getByTestId('map-pin-icon')).toThrow();
expect(screen.getByTestId('circle-x-icon')).toBeTruthy();
});
});

describe('Bottom Sheet Behavior', () => {
Expand Down Expand Up @@ -356,6 +425,88 @@ describe('SidebarCallCard', () => {
{ cancelable: true }
);
});

it('should open maps with coordinates when map pin button is pressed with valid coordinates', async () => {
render(<SidebarCallCard />);

fireEvent.press(screen.getByTestId('map-pin-icon'));

expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(
mockCall.Latitude,
mockCall.Longitude,
mockCall.Address
);
expect(mockOpenMapsWithAddress).not.toHaveBeenCalled();
});

it('should open maps with address when map pin button is pressed with address only', async () => {
const callWithAddressOnly = {
...mockCall,
Latitude: '',
Longitude: '',
Address: '123 Test Street',
};

mockUseCoreStore.mockReturnValue({
activeCall: callWithAddressOnly,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
});

render(<SidebarCallCard />);

fireEvent.press(screen.getByTestId('map-pin-icon'));

expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('123 Test Street');
expect(mockOpenMapsWithDirections).not.toHaveBeenCalled();
});

it('should show error alert when openMapsWithDirections fails', async () => {
mockOpenMapsWithDirections.mockRejectedValue(new Error('Navigation failed'));

render(<SidebarCallCard />);

fireEvent.press(screen.getByTestId('map-pin-icon'));

// Wait for the async operation to complete
await new Promise(resolve => setTimeout(resolve, 0));

expect(mockAlert.alert).toHaveBeenCalledWith(
'calls.no_location_title',
'calls.no_location_message',
[{ text: 'common.ok' }]
);
});

it('should show error alert when openMapsWithAddress fails', async () => {
const callWithAddressOnly = {
...mockCall,
Latitude: '',
Longitude: '',
Address: '123 Test Street',
};

mockUseCoreStore.mockReturnValue({
activeCall: callWithAddressOnly,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
});

mockOpenMapsWithAddress.mockRejectedValue(new Error('Address navigation failed'));

render(<SidebarCallCard />);

fireEvent.press(screen.getByTestId('map-pin-icon'));

// Wait for the async operation to complete
await new Promise(resolve => setTimeout(resolve, 0));

expect(mockAlert.alert).toHaveBeenCalledWith(
'calls.no_location_title',
'calls.no_location_message',
[{ text: 'common.ok' }]
);
});
});

describe('Accessibility', () => {
Expand Down
Loading
Loading