+
{isLoaded && {children}}
);
@@ -169,14 +338,20 @@ interface CameraProps {
}
// Camera component
-export const Camera = forwardRef
(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, followUserLocation, followZoomLevel }, ref) => {
+export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel }, ref) => {
const map = React.useContext(MapContext);
const geolocateControl = useRef(null);
+ const hasInitialized = useRef(false);
useImperativeHandle(ref, () => ({
setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => {
if (!map) return;
+ // Validate coordinates before passing to mapbox
+ if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) {
+ return;
+ }
+
map.easeTo(
{
center: options.centerCoordinate,
@@ -190,6 +365,12 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve
},
flyTo: (options: any) => {
if (!map) return;
+
+ // Validate center if provided
+ if (options.center && Array.isArray(options.center) && (!isFinite(options.center[0]) || !isFinite(options.center[1]))) {
+ return;
+ }
+
map.flyTo(options, { _programmatic: true });
},
}));
@@ -197,19 +378,34 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve
useEffect(() => {
if (!map) return;
- if (centerCoordinate) {
- map.easeTo(
- {
- center: centerCoordinate,
- zoom: zoomLevel,
- bearing: heading,
- pitch: pitch,
- duration: animationDuration,
- },
- { _programmatic: true }
- );
+ if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) {
+ // Skip the first render — the MapView already initialized at the correct
+ // position via initialCenter/initialZoom, so no programmatic move needed.
+ if (!hasInitialized.current) {
+ hasInitialized.current = true;
+ return;
+ }
+
+ // For subsequent coordinate/zoom changes, animate to the new position.
+ const cameraOptions = {
+ center: centerCoordinate as [number, number],
+ zoom: zoomLevel,
+ bearing: heading,
+ pitch: pitch,
+ duration: animationDuration,
+ };
+
+ try {
+ if (animationMode === 'flyTo') {
+ map.flyTo(cameraOptions, { _programmatic: true });
+ } else {
+ map.easeTo(cameraOptions, { _programmatic: true });
+ }
+ } catch {
+ // Suppress projection-matrix errors during resize/transition
+ }
}
- }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration]);
+ }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]);
useEffect(() => {
if (!map || !followUserLocation) return;
@@ -238,7 +434,11 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve
clearTimeout(triggerTimeoutId);
}
if (geolocateControl.current) {
- map.removeControl(geolocateControl.current);
+ try {
+ map.removeControl(geolocateControl.current);
+ } catch {
+ // map may already be destroyed during route transitions
+ }
geolocateControl.current = null;
}
};
@@ -321,7 +521,7 @@ export const PointAnnotation: React.FC = ({ id, coordinate
// Update coordinate when values actually change (by value, not reference)
useEffect(() => {
- if (markerRef.current && coordinate) {
+ if (markerRef.current && coordinate && coordinate.length === 2 && isFinite(coordinate[0]) && isFinite(coordinate[1])) {
markerRef.current.setLngLat(coordinate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -382,13 +582,21 @@ export const UserLocation: React.FC = ({ visible = true, show
map.on('load', onMapLoad);
return () => {
- map.off('load', onMapLoad);
- map.removeControl(geolocate);
+ try {
+ map.off('load', onMapLoad);
+ map.removeControl(geolocate);
+ } catch {
+ // map may already be destroyed during route transitions
+ }
};
}
return () => {
- map.removeControl(geolocate);
+ try {
+ map.removeControl(geolocate);
+ } catch {
+ // map may already be destroyed during route transitions
+ }
};
}, [map, visible, showsUserHeadingIndicator]);
diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx
index 13a90374..c6b48620 100644
--- a/src/components/maps/static-map.tsx
+++ b/src/components/maps/static-map.tsx
@@ -1,7 +1,7 @@
import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { StyleSheet } from 'react-native';
+import { Platform, StyleSheet, View } from 'react-native';
import Mapbox from '@/components/maps/mapbox';
import { Box } from '@/components/ui/box';
@@ -29,19 +29,22 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo
if (!latitude || !longitude) {
return (
-
+
{t('call_detail.no_location')}
);
}
return (
-
-
+
+
- {/* Marker for the location */}
+ {/* Marker pin for the location */}
-
+
+
+
+
{/* Show user location if requested */}
@@ -67,6 +70,42 @@ const styles = StyleSheet.create({
map: {
flex: 1,
},
+ markerContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 30,
+ height: 40,
+ },
+ markerPin: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: '#E53E3E',
+ borderWidth: 3,
+ borderColor: '#FFFFFF',
+ ...Platform.select({
+ ios: {
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.3,
+ shadowRadius: 3,
+ },
+ android: {
+ elevation: 4,
+ },
+ }),
+ },
+ markerDot: {
+ width: 0,
+ height: 0,
+ borderLeftWidth: 6,
+ borderRightWidth: 6,
+ borderTopWidth: 8,
+ borderLeftColor: 'transparent',
+ borderRightColor: 'transparent',
+ borderTopColor: '#E53E3E',
+ marginTop: -2,
+ },
addressContainer: {
position: 'absolute',
bottom: 0,
diff --git a/src/components/notes/__tests__/note-details-sheet.test.tsx b/src/components/notes/__tests__/note-details-sheet.test.tsx
index 347aae4f..0683c807 100644
--- a/src/components/notes/__tests__/note-details-sheet.test.tsx
+++ b/src/components/notes/__tests__/note-details-sheet.test.tsx
@@ -3,12 +3,12 @@ import React from 'react';
import { NoteDetailsSheet } from '../note-details-sheet';
-// Mock react-native-webview
-jest.mock('react-native-webview', () => {
+// Mock HtmlRenderer
+jest.mock('@/components/ui/html-renderer', () => {
const { View } = require('react-native');
return {
__esModule: true,
- default: View,
+ HtmlRenderer: View,
};
});
diff --git a/src/components/notes/note-details-sheet.tsx b/src/components/notes/note-details-sheet.tsx
index 0c11144b..4d5a34de 100644
--- a/src/components/notes/note-details-sheet.tsx
+++ b/src/components/notes/note-details-sheet.tsx
@@ -1,10 +1,9 @@
import { Calendar, Tag, X } from 'lucide-react-native';
-import { useColorScheme } from 'nativewind';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native';
-import WebView from 'react-native-webview';
+import { HtmlRenderer } from '@/components/ui/html-renderer';
import { useAnalytics } from '@/hooks/use-analytics';
import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
import { useNotesStore } from '@/stores/notes/store';
@@ -20,7 +19,6 @@ import { VStack } from '../ui/vstack';
export const NoteDetailsSheet: React.FC = () => {
const { t } = useTranslation();
- const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
const notes = useNotesStore((s) => s.notes);
const selectedNoteId = useNotesStore((s) => s.selectedNoteId);
@@ -45,8 +43,6 @@ export const NoteDetailsSheet: React.FC = () => {
if (!selectedNote) return null;
- const textColor = colorScheme === 'dark' ? '#E5E7EB' : '#1F2937'; // gray-200 : gray-800
-
return (
@@ -66,40 +62,9 @@ export const NoteDetailsSheet: React.FC = () => {
- {/* Note content in WebView */}
+ {/* Note content */}
-
-
-
-
-
-
- ${selectedNote.Body}
-
- `,
- }}
- androidLayerType="software"
- />
+
diff --git a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
index fac68c20..792de21f 100644
--- a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
+++ b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
@@ -25,13 +25,38 @@ jest.mock('nativewind', () => ({
cssInterop: jest.fn(),
}));
-jest.mock('react-native-webview', () => ({
+jest.mock('@/components/ui/html-renderer', () => ({
__esModule: true,
- default: ({ source }: { source: any }) => {
+ HtmlRenderer: ({ html, ...rest }: { html: string;[key: string]: any }) => {
const { View, Text } = require('react-native');
+ // Mirror the real component's theme-aware defaults (light mode in test)
+ const textColor = rest.textColor || '#1F2937';
+ const bgColor = rest.backgroundColor || '#F9FAFB';
return (
- {source.html}
+ {`
+
+
+
+
+
+
+ ${html}
+
+ `}
);
},
diff --git a/src/components/protocols/protocol-details-sheet.tsx b/src/components/protocols/protocol-details-sheet.tsx
index d99978c3..0b34cacf 100644
--- a/src/components/protocols/protocol-details-sheet.tsx
+++ b/src/components/protocols/protocol-details-sheet.tsx
@@ -1,9 +1,8 @@
import { Calendar, Tag, X } from 'lucide-react-native';
-import { useColorScheme } from 'nativewind';
import React, { useEffect } from 'react';
import { StyleSheet } from 'react-native';
-import WebView from 'react-native-webview';
+import { HtmlRenderer } from '@/components/ui/html-renderer';
import { useAnalytics } from '@/hooks/use-analytics';
import { formatDateForDisplay, parseDateISOString, stripHtmlTags } from '@/lib/utils';
import { useProtocolsStore } from '@/stores/protocols/store';
@@ -18,7 +17,6 @@ import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';
export const ProtocolDetailsSheet: React.FC = () => {
- const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
const protocols = useProtocolsStore((s) => s.protocols);
const selectedProtocolId = useProtocolsStore((s) => s.selectedProtocolId);
@@ -50,8 +48,6 @@ export const ProtocolDetailsSheet: React.FC = () => {
);
}
- const textColor = colorScheme === 'dark' ? '#E5E7EB' : '#1F2937'; // gray-200 : gray-800
-
return (
@@ -86,42 +82,12 @@ export const ProtocolDetailsSheet: React.FC = () => {
)}
- {/* Protocol content in WebView */}
-
-
-
-
-
-
-
- ${selectedProtocol.ProtocolText}
-
- `,
- }}
- androidLayerType="software"
- />
-
+ {/* Protocol content */}
+ {selectedProtocol.ProtocolText && (
+
+
+
+ )}
diff --git a/src/components/ui/box/index.web.tsx b/src/components/ui/box/index.web.tsx
index aa0fa86c..f20566fc 100644
--- a/src/components/ui/box/index.web.tsx
+++ b/src/components/ui/box/index.web.tsx
@@ -1,12 +1,14 @@
import type { VariantProps } from '@gluestack-ui/nativewind-utils';
import React from 'react';
+import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
import { boxStyle } from './styles';
-type IBoxProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { className?: string };
+type IBoxProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { className?: string; style?: StyleProp };
-const Box = React.forwardRef(({ className, ...props }, ref) => {
- return ;
+const Box = React.forwardRef(({ className, style, ...props }, ref) => {
+ const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style;
+ return ;
});
Box.displayName = 'Box';
diff --git a/src/components/ui/card/index.web.tsx b/src/components/ui/card/index.web.tsx
index 3ffa137a..948ba6f2 100644
--- a/src/components/ui/card/index.web.tsx
+++ b/src/components/ui/card/index.web.tsx
@@ -1,12 +1,14 @@
import type { VariantProps } from '@gluestack-ui/nativewind-utils';
import React from 'react';
+import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
import { cardStyle } from './styles';
-type ICardProps = React.ComponentPropsWithoutRef<'div'> & VariantProps;
+type ICardProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { style?: StyleProp };
-const Card = React.forwardRef(({ className, size = 'md', variant = 'elevated', ...props }, ref) => {
- return ;
+const Card = React.forwardRef(({ className, size = 'md', variant = 'elevated', style, ...props }, ref) => {
+ const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style;
+ return ;
});
Card.displayName = 'Card';
diff --git a/src/components/ui/center/index.web.tsx b/src/components/ui/center/index.web.tsx
index 0a010efb..b8c8786b 100644
--- a/src/components/ui/center/index.web.tsx
+++ b/src/components/ui/center/index.web.tsx
@@ -1,12 +1,14 @@
import type { VariantProps } from '@gluestack-ui/nativewind-utils';
import React from 'react';
+import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
import { centerStyle } from './styles';
-type ICenterProps = React.ComponentPropsWithoutRef<'div'> & VariantProps;
+type ICenterProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { style?: StyleProp };
-const Center = React.forwardRef(({ className, ...props }, ref) => {
- return ;
+const Center = React.forwardRef(({ className, style, ...props }, ref) => {
+ const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style;
+ return ;
});
Center.displayName = 'Center';
diff --git a/src/components/ui/hstack/index.web.tsx b/src/components/ui/hstack/index.web.tsx
index 2f457f59..843f4526 100644
--- a/src/components/ui/hstack/index.web.tsx
+++ b/src/components/ui/hstack/index.web.tsx
@@ -1,12 +1,14 @@
import type { VariantProps } from '@gluestack-ui/nativewind-utils';
import React from 'react';
+import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
import { hstackStyle } from './styles';
-type IHStackProps = React.ComponentPropsWithoutRef<'div'> & VariantProps;
+type IHStackProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { style?: StyleProp };
-const HStack = React.forwardRef, IHStackProps>(({ className, space, reversed, ...props }, ref) => {
- return ;
+const HStack = React.forwardRef, IHStackProps>(({ className, space, reversed, style, ...props }, ref) => {
+ const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style;
+ return ;
});
HStack.displayName = 'HStack';
diff --git a/src/components/ui/html-renderer/__tests__/html-renderer.test.tsx b/src/components/ui/html-renderer/__tests__/html-renderer.test.tsx
new file mode 100644
index 00000000..8ba04fbe
--- /dev/null
+++ b/src/components/ui/html-renderer/__tests__/html-renderer.test.tsx
@@ -0,0 +1,114 @@
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+// Mock nativewind useColorScheme — default to light mode
+let mockColorScheme = 'light';
+jest.mock('nativewind', () => ({
+ useColorScheme: () => ({ colorScheme: mockColorScheme }),
+ cssInterop: jest.fn(),
+}));
+
+// Mock react-native-webview before importing HtmlRenderer
+jest.mock('react-native-webview', () => {
+ const { View, Text } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({ source, ...rest }: { source: { html: string };[key: string]: any }) => (
+
+ {source.html}
+
+ ),
+ };
+});
+
+import { HtmlRenderer } from '../index';
+
+describe('HtmlRenderer (native)', () => {
+ beforeEach(() => {
+ mockColorScheme = 'light';
+ });
+
+ it('should render WebView with the provided HTML content', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('Hello World
');
+ expect(htmlContent.props.children).toContain('');
+ });
+
+ it('should apply custom textColor override', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('color: #FF0000');
+ });
+
+ it('should apply custom backgroundColor override', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('background-color: #333333');
+ });
+
+ describe('light mode defaults', () => {
+ it('should use light theme text color when not specified', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('color: #1F2937');
+ });
+
+ it('should use light theme background color when not specified', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('background-color: transparent');
+ });
+ });
+
+ describe('dark mode defaults', () => {
+ beforeEach(() => {
+ mockColorScheme = 'dark';
+ });
+
+ it('should use dark theme text color when not specified', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('color: #E5E7EB');
+ });
+
+ it('should use dark theme background color when not specified', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('background-color: transparent');
+ });
+
+ it('should allow overriding colors in dark mode', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('color: #FFFFFF');
+ expect(htmlContent.props.children).toContain('background-color: #000000');
+ });
+ });
+
+ it('should render the full HTML structure including viewport meta', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('');
+ expect(htmlContent.props.children).toContain('');
+ expect(htmlContent.props.children).toContain('Test
');
+ });
+
+ it('should include responsive CSS styles', () => {
+ render();
+
+ const htmlContent = screen.getByTestId('webview-html');
+ expect(htmlContent.props.children).toContain('max-width: 100%');
+ expect(htmlContent.props.children).toContain('font-family: system-ui, -apple-system, sans-serif');
+ });
+});
diff --git a/src/components/ui/html-renderer/index.tsx b/src/components/ui/html-renderer/index.tsx
new file mode 100644
index 00000000..62cccfe3
--- /dev/null
+++ b/src/components/ui/html-renderer/index.tsx
@@ -0,0 +1,128 @@
+import { useColorScheme } from 'nativewind';
+import React from 'react';
+import { Linking, type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
+import WebView from 'react-native-webview';
+
+/** Light / dark theme color tokens used when no explicit override is provided */
+const THEME_COLORS = {
+ light: { text: '#1F2937', background: 'transparent' }, // gray-800
+ dark: { text: '#E5E7EB', background: 'transparent' }, // gray-200
+} as const;
+
+export interface HtmlRendererProps {
+ /** The raw HTML string to render inside the body tag */
+ html: string;
+ /** Optional inline style applied to the container */
+ style?: StyleProp;
+ /** Whether scrolling is enabled (default: false) */
+ scrollEnabled?: boolean;
+ /** Whether to show the vertical scroll indicator (default: false) */
+ showsVerticalScrollIndicator?: boolean;
+ /** Text color applied to the body – defaults to a theme-aware gray */
+ textColor?: string;
+ /** Background color applied to the body – defaults to a theme-aware gray */
+ backgroundColor?: string;
+ /** Optional React key for forcing re-renders */
+ rendererKey?: string;
+ /** Additional CSS injected inside the
+
+ ${html}
+