diff --git a/AGENTS.md b/AGENTS.md index 7d8dd25a..ac7c451c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,6 @@ src/ │ ├── 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 diff --git a/CLAUDE.md b/CLAUDE.md index 3e57ef19..f2daaf11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,6 @@ src/ │ ├── 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 diff --git a/docs/bluetooth-audio-integration.md b/docs/bluetooth-audio-integration.md index e2227e58..a49a88e5 100644 --- a/docs/bluetooth-audio-integration.md +++ b/docs/bluetooth-audio-integration.md @@ -25,17 +25,16 @@ This system provides comprehensive Bluetooth audio device integration for the Li - Tracks available devices, connection status, and button events - Provides reactive state for UI components -3. **BluetoothAudioModal** (`src/components/bluetooth/bluetooth-audio-modal.tsx`) +3. **BluetoothDeviceSelectionBottomSheet** (`src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`) - User interface for device selection and management - Shows device list, connection status, and controls - - Displays button events and audio routing status ### Data Flow ```mermaid graph TD A[BluetoothAudioService] --> B[BluetoothAudioStore] - B --> C[BluetoothAudioModal] + B --> C[BluetoothDeviceSelectionBottomSheet] A --> D[LiveKit Integration] E[BLE Device] --> A A --> F[Audio Routing] @@ -51,18 +50,18 @@ graph TD ```typescript import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; -import BluetoothAudioModal from '@/components/bluetooth/bluetooth-audio-modal'; +import { BluetoothDeviceSelectionBottomSheet } from '@/components/settings/bluetooth-device-selection-bottom-sheet'; ``` ### 2. Show Device Selection UI ```typescript -const [isBluetoothModalOpen, setIsBluetoothModalOpen] = useState(false); +const [isDeviceSelectionOpen, setIsDeviceSelectionOpen] = useState(false); // In your component render: - setIsBluetoothModalOpen(false)} + setIsDeviceSelectionOpen(false)} /> ``` @@ -308,7 +307,7 @@ Tests are provided for: - BluetoothAudioStore state management - BluetoothAudioService methods -- BluetoothAudioModal UI components +- BluetoothDeviceSelectionBottomSheet UI components ### Mocking @@ -324,7 +323,7 @@ The system includes mocks for: # Run all Bluetooth audio tests yarn test src/stores/app/__tests__/bluetooth-audio-store.test.ts yarn test src/services/__tests__/bluetooth-audio.service.test.ts -yarn test src/components/bluetooth/__tests__/bluetooth-audio-modal.test.tsx +yarn test src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx ``` ## Performance Considerations diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index e5a1fbbe..02345683 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -449,7 +449,8 @@ export default function TabLayout() { zIndex: 100, backgroundColor: 'transparent', borderTopWidth: 0.5, - borderTopColor: 'rgba(0, 0, 0, 0.1)', + // Mid-gray rather than black-alpha so the divider still shows on the dark tab bar. + borderTopColor: 'rgba(127, 127, 127, 0.2)', }, }), [isLandscape, insets.bottom] @@ -462,12 +463,12 @@ export default function TabLayout() { const handleCloseNotifications = useCallback(() => setIsNotificationsOpen(false), []); // Memoize per-screen tab bar icon renderers to prevent new functions every render - const mapIcon = useCallback(({ color }: { color: ColorValue }) => , []); - const callsIcon = useCallback(({ color }: { color: ColorValue }) => , []); - const contactsIcon = useCallback(({ color }: { color: ColorValue }) => , []); + const mapIcon = useCallback(({ color }: { color: ColorValue }) => , []); + const callsIcon = useCallback(({ color }: { color: ColorValue }) => , []); + const contactsIcon = useCallback(({ color }: { color: ColorValue }) => , []); const notesIcon = useCallback(({ color }: { color: ColorValue }) => , []); - const routesIcon = useCallback(({ color }: { color: ColorValue }) => , []); - const weatherAlertsIcon = useCallback(({ color }: { color: ColorValue }) => , []); + const routesIcon = useCallback(({ color }: { color: ColorValue }) => , []); + const weatherAlertsIcon = useCallback(({ color }: { color: ColorValue }) => , []); const protocolsIcon = useCallback(({ color }: { color: ColorValue }) => , []); const settingsIcon = useCallback(({ color }: { color: ColorValue }) => , []); @@ -604,7 +605,7 @@ export default function TabLayout() { {/* Loading overlay during initialization — shown on top of Tabs so the navigator stays mounted */} {!isInitComplete ? ( - + ) : null} @@ -701,7 +702,8 @@ const CreateDrawerMenuButton = ({ setIsOpen, isLandscape }: CreateDrawerMenuButt setIsOpen(true); }} > - + {/* No `color` prop: an explicit one overrides the className colour, and "currentColor" resolves to black against the dark header. */} + ); }; @@ -743,7 +745,6 @@ const styles = StyleSheet.create({ ...(StyleSheet.absoluteFill as object), justifyContent: 'center', alignItems: 'center', - backgroundColor: Platform.OS === 'web' ? '#ffffff' : 'rgba(255,255,255,0.95)', zIndex: 1000, }, }); diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 71f3d154..4215c5bf 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -66,7 +66,7 @@ function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPr ) : null} {unread ? ( - + {channel.UnreadCount > 99 ? '99+' : String(channel.UnreadCount)} ) : null} diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 5f1947c0..0067e440 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -1,5 +1,6 @@ import { Redirect, useFocusEffect } from 'expo-router'; import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useWindowDimensions } from 'react-native'; @@ -30,6 +31,7 @@ import { useToastStore } from '@/stores/toast/store'; export default function ChatbotScreen() { const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); // The assistant is a hidden tab, so the tab bar still sits below it and the keyboard // already covers that strip — pad for the remainder only. const insets = useSafeAreaInsets(); @@ -117,7 +119,7 @@ export default function ChatbotScreen() { useChatStore.getState().newChatbotSession()} accessibilityLabel={t('chatbot.new_session')}> - + {t('chatbot.new_session')} @@ -156,7 +158,7 @@ export default function ChatbotScreen() { - + diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index f44e09ff..a596878c 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -32,7 +32,7 @@ import { StatusBottomSheet } from '@/components/status/status-bottom-sheet'; import { ToastContainer } from '@/components/toast/toast-container'; import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'; import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive'; -import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme'; +import { loadSelectedTheme, useSelectedTheme } from '@/lib/hooks/use-selected-theme'; import { logger } from '@/lib/logging'; import { registerNavigationReadyCheck } from '@/lib/navigation'; import { getDeviceUuid, setDeviceUuid } from '@/lib/storage/app'; @@ -224,10 +224,13 @@ function Providers({ children }: { children: React.ReactNode }) { // nativewind's hook (not react-native's) so manually selected themes are // reflected on web, where Appearance only tracks the system preference. const { colorScheme } = useColorScheme(); + // The provider gets the user's choice, not the resolved scheme: handing it the resolved 'light' or 'dark' + // pinned Appearance to that value, so a 'system' choice stopped following the OS until a restart. + const { selectedTheme } = useSelectedTheme(); const renderContent = () => ( - + {children} diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 99a5152c..d2839df5 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -493,10 +493,10 @@ export default function CallDetail() { {dispatched.Name} - + {t('call_detail.group')}: {dispatched.Group} - + {t('call_detail.type')}: {dispatched.Type} @@ -527,7 +527,7 @@ export default function CallDetail() { {/* Marks a status the sender did not link to this call themselves (auto-linked / inferred). */} - + {event.Name} - {event.Group} {new Date(event.Timestamp).toLocaleString()} diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index c8acca61..57e33825 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -697,7 +697,7 @@ export default function NewCall() { {t('common.cancel')} diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 09ddadeb..489fce30 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -332,9 +332,9 @@ export default function ChannelConversationScreen() { {/* Unit-app identity chip: shows which unit the current user is chatting as. */} {activeUnit ? ( - + - {t('chat.chatting_as', { name: activeUnit.Name })} + {t('chat.chatting_as', { name: activeUnit.Name })} ) : null} diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 8718c9ef..13a8ca4f 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -197,7 +197,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde ) : null} {onSsoPress ? ( ) : null} @@ -206,7 +206,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde {/* Shared vehicle device: every sign-in here starts a shared session that locks between operators */} {onSharedDevicePress ? ( @@ -344,7 +344,7 @@ export default function SsoLogin() { ) : ( )} diff --git a/src/app/routes/active.tsx b/src/app/routes/active.tsx index fc9355f3..acdb5583 100644 --- a/src/app/routes/active.tsx +++ b/src/app/routes/active.tsx @@ -380,7 +380,7 @@ export default function ActiveRouteScreen() { @@ -394,13 +394,23 @@ export default function ActiveRouteScreen() { {t('routes.skip')} — {currentStop?.Name} {t('routes.skip_reason')} - + setSkipModalVisible(false)}> {t('common.cancel')} - {t('routes.skip')} + {t('routes.skip')} @@ -416,11 +426,9 @@ const styles = StyleSheet.create({ }, skipInput: { borderWidth: 1, - borderColor: '#d1d5db', borderRadius: 8, padding: 10, fontSize: 14, - color: '#111827', textAlignVertical: 'top', minHeight: 80, }, diff --git a/src/app/routes/directions.tsx b/src/app/routes/directions.tsx index b9f62e58..f0f05869 100644 --- a/src/app/routes/directions.tsx +++ b/src/app/routes/directions.tsx @@ -709,7 +709,7 @@ export default function RouteDirectionsScreen() { {/* Loading overlay while fetching driving directions */} {isFetchingDirections ? ( - + {t('routes.fetching_directions')} @@ -877,7 +877,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.9)', paddingVertical: 6, marginHorizontal: 60, borderRadius: 20, diff --git a/src/app/routes/poi/[id].tsx b/src/app/routes/poi/[id].tsx index 49e1ce7e..f4ce31d8 100644 --- a/src/app/routes/poi/[id].tsx +++ b/src/app/routes/poi/[id].tsx @@ -111,8 +111,8 @@ export default function PoiDetailScreen() { {destinationEnabled ? ( )} {canSkip && ( - )} {stop.Status === RouteStopStatus.Completed && ( @@ -334,13 +334,23 @@ export default function StopDetailScreen() { {t('routes.skip')} — {stop.Name} {t('routes.skip_reason')} - + setSkipModalVisible(false)}> {t('common.cancel')} - {t('routes.skip')} + {t('routes.skip')} @@ -360,11 +370,9 @@ const styles = StyleSheet.create({ }, skipInput: { borderWidth: 1, - borderColor: '#d1d5db', borderRadius: 8, padding: 10, fontSize: 14, - color: '#111827', textAlignVertical: 'top', minHeight: 80, }, diff --git a/src/app/routes/stop/contact.tsx b/src/app/routes/stop/contact.tsx index 418dde24..a8c7f078 100644 --- a/src/app/routes/stop/contact.tsx +++ b/src/app/routes/stop/contact.tsx @@ -175,7 +175,7 @@ export default function StopContactScreen() { {contact.CompanyName ? : } {displayName} {contact.CompanyName && contact.FirstName && {[contact.FirstName, contact.LastName].filter(Boolean).join(' ')}} - {contact.Email && {contact.Email}} + {contact.Email && {contact.Email}} {/* Phone numbers */} diff --git a/src/components/auth/login-mfa-sheet.tsx b/src/components/auth/login-mfa-sheet.tsx index 1f39102f..c04c8fb4 100644 --- a/src/components/auth/login-mfa-sheet.tsx +++ b/src/components/auth/login-mfa-sheet.tsx @@ -229,7 +229,7 @@ export const LoginMfaSheet: React.FC = ({ isOpen, onLostFact {!approval && showsCodeInput ? ( ) : null} diff --git a/src/components/auth/login-otp-modal.tsx b/src/components/auth/login-otp-modal.tsx index 8181d074..3680b090 100644 --- a/src/components/auth/login-otp-modal.tsx +++ b/src/components/auth/login-otp-modal.tsx @@ -86,7 +86,7 @@ export const LoginOtpModal: React.FC = ({ isOpen, isSubmitti {t('common.cancel', 'Cancel')} diff --git a/src/components/bluetooth/bluetooth-audio-modal.tsx b/src/components/bluetooth/bluetooth-audio-modal.tsx deleted file mode 100644 index 7fc73aed..00000000 --- a/src/components/bluetooth/bluetooth-audio-modal.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { AlertTriangle, Bluetooth, BluetoothConnected, CheckCircle, Mic, MicOff, RefreshCw, Signal, Wifi } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; -import { ScrollView } from 'react-native'; - -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; -import { Badge } from '@/components/ui/badge'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { Spinner } from '@/components/ui/spinner'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; -import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; -import { type BluetoothAudioDevice, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; -import { useLiveKitStore } from '@/stores/app/livekit-store'; - -interface BluetoothAudioModalProps { - isOpen: boolean; - onClose: () => void; -} - -const BluetoothAudioModal: React.FC = ({ isOpen, onClose }) => { - const bluetoothState = useBluetoothAudioStore((s) => s.bluetoothState); - const isScanning = useBluetoothAudioStore((s) => s.isScanning); - const isConnecting = useBluetoothAudioStore((s) => s.isConnecting); - const availableDevices = useBluetoothAudioStore((s) => s.availableDevices); - const connectedDevice = useBluetoothAudioStore((s) => s.connectedDevice); - const connectionError = useBluetoothAudioStore((s) => s.connectionError); - const isAudioRoutingActive = useBluetoothAudioStore((s) => s.isAudioRoutingActive); - const buttonEvents = useBluetoothAudioStore((s) => s.buttonEvents); - const lastButtonAction = useBluetoothAudioStore((s) => s.lastButtonAction); - - const isLiveKitConnected = useLiveKitStore((s) => s.isConnected); - const currentRoom = useLiveKitStore((s) => s.currentRoom); - const [isMicMuted, setIsMicMuted] = useState(false); - - const handleStartScan = React.useCallback(async () => { - try { - await bluetoothAudioService.startScanning(15000); // 15 second scan - } catch (error) { - console.error('Failed to start Bluetooth scan:', error); - } - }, []); - - useEffect(() => { - // Update mic state from LiveKit - if (currentRoom?.localParticipant) { - setIsMicMuted(!currentRoom.localParticipant.isMicrophoneEnabled); - } - }, [currentRoom?.localParticipant, currentRoom?.localParticipant?.isMicrophoneEnabled]); - - useEffect(() => { - // Auto-start scanning when modal opens and Bluetooth is ready - if (isOpen && bluetoothState === State.PoweredOn && !isScanning && !connectedDevice) { - handleStartScan().catch((error) => { - console.error('Failed to start scan:', error); - }); - } - }, [isOpen, bluetoothState, isScanning, connectedDevice, handleStartScan]); - - const handleStopScan = React.useCallback(() => { - bluetoothAudioService.stopScanning(); - }, []); - - const handleConnectDevice = React.useCallback( - async (device: BluetoothAudioDevice) => { - if (isConnecting) return; - - try { - await bluetoothAudioService.connectToDevice(device.id); - } catch (error) { - console.error('Failed to connect to device:', error); - } - }, - [isConnecting] - ); - - const handleDisconnectDevice = React.useCallback(async () => { - try { - await bluetoothAudioService.disconnectDevice(); - } catch (error) { - console.error('Failed to disconnect device:', error); - } - }, []); - - const handleToggleMicrophone = React.useCallback(async () => { - if (!currentRoom?.localParticipant) return; - - try { - const newMuteState = !isMicMuted; - await currentRoom.localParticipant.setMicrophoneEnabled(!newMuteState); - setIsMicMuted(newMuteState); - } catch (error) { - console.error('Failed to toggle microphone:', error); - } - }, [currentRoom?.localParticipant, isMicMuted]); - - const renderBluetoothState = () => { - switch (bluetoothState) { - case State.PoweredOff: - return ( - - - Bluetooth is turned off. Please enable Bluetooth to connect audio devices. - - ); - case State.Unauthorized: - return ( - - - Bluetooth permission denied. Please grant Bluetooth permissions in Settings. - - ); - case State.PoweredOn: - return null; - default: - return ( - - - Checking Bluetooth status... - - ); - } - }; - - const renderConnectionError = () => { - if (!connectionError) return null; - - return ( - - - - - Connection Error - {connectionError} - - - - ); - }; - - const renderConnectedDevice = () => { - if (!connectedDevice) return null; - - return ( - - - - - - {connectedDevice.name || 'Unknown Device'} - - Connected - {isAudioRoutingActive ? ( - - Audio Active - - ) : null} - - {connectedDevice.supportsMicrophoneControl ? Button control available : null} - - - - - {isLiveKitConnected ? ( - - ) : null} - - - - - - ); - }; - - const renderRecentButtonEvents = () => { - if (buttonEvents.length === 0) return null; - - const recentEvents = buttonEvents.slice(0, 3); - - return ( - - - Recent Button Events - - - {recentEvents.map((event, index) => ( - - {new Date(event.timestamp).toLocaleTimeString()} - - {event.type === 'long_press' ? 'Long ' : event.type === 'double_press' ? 'Double ' : ''} - {event.button === 'ptt_start' - ? 'PTT Start' - : event.button === 'ptt_stop' - ? 'PTT Stop' - : event.button === 'mute' - ? 'Mute' - : event.button === 'volume_up' - ? 'Volume +' - : event.button === 'volume_down' - ? 'Volume -' - : 'Unknown'} - - {lastButtonAction && lastButtonAction.timestamp === event.timestamp ? ( - - Applied - - ) : null} - - ))} - - - ); - }; - - const renderDeviceList = () => { - if (availableDevices.length === 0 && !isScanning) { - return ( - - - No audio devices found - - - ); - } - - return ( - - - Available Devices - - - - - - {availableDevices.map((device) => ( - - - - - - {device.name || 'Unknown Device'} - - {device.rssi ? ( - <> - - {device.rssi} dBm - - ) : null} - {device.hasAudioCapability ? ( - - Audio - - ) : null} - {device.supportsMicrophoneControl ? ( - - Mic Control - - ) : null} - - - - - {!device.isConnected ? ( - - ) : ( - - - Connected - - )} - - - ))} - - - - ); - }; - - const bluetoothStateError = renderBluetoothState(); - - return ( - - - - - - - - - - Bluetooth Audio - {connectedDevice && isLiveKitConnected ? ( - - - LiveKit Active - - ) : null} - - - - {bluetoothStateError ? ( - bluetoothStateError - ) : ( - - {renderConnectionError()} - {renderConnectedDevice()} - {renderRecentButtonEvents()} - {renderDeviceList()} - - )} - - - - - ); -}; - -export default BluetoothAudioModal; diff --git a/src/components/call-video-feeds/video-feed-form-sheet.tsx b/src/components/call-video-feeds/video-feed-form-sheet.tsx index 319aae5c..b7f303e6 100644 --- a/src/components/call-video-feeds/video-feed-form-sheet.tsx +++ b/src/components/call-video-feeds/video-feed-form-sheet.tsx @@ -152,14 +152,14 @@ export const VideoFeedFormSheet: React.FC = ({ isOpen, {/* Name */} - {t('video_feeds.name')} * + {t('video_feeds.name')} * ( - + )} /> @@ -167,14 +167,14 @@ export const VideoFeedFormSheet: React.FC = ({ isOpen, {/* URL */} - {t('video_feeds.url')} * + {t('video_feeds.url')} * ( - handleUrlBlur(value)} placeholder="https://" autoCapitalize="none" keyboardType="url" /> + handleUrlBlur(value)} placeholder="https://" autoCapitalize="none" keyboardType="url" /> )} /> @@ -182,7 +182,7 @@ export const VideoFeedFormSheet: React.FC = ({ isOpen, {/* Feed Type */} - {t('video_feeds.feed_type')} + {t('video_feeds.feed_type')} {FEED_TYPES.map((type) => ( diff --git a/src/components/calls/call-site-info-tab-panel.tsx b/src/components/calls/call-site-info-tab-panel.tsx index b1e8c87e..f2eb8919 100644 --- a/src/components/calls/call-site-info-tab-panel.tsx +++ b/src/components/calls/call-site-info-tab-panel.tsx @@ -36,7 +36,7 @@ const SiteContactCard: React.FC = ({ site, callId }) => { return ( - + {nameRedacted ? : site.ContactType === 1 ? : } @@ -55,7 +55,7 @@ const SiteContactCard: React.FC = ({ site, callId }) => { Linking.openURL(`tel:${site.PhoneNumber}`).catch(() => {})} className="mb-2" testID={`site-contact-phone-${site.ContactId}`}> - {site.PhoneNumber} + {site.PhoneNumber} ) : null} diff --git a/src/components/calls/location-history-panel.tsx b/src/components/calls/location-history-panel.tsx index d482f6f5..de2f92ec 100644 --- a/src/components/calls/location-history-panel.tsx +++ b/src/components/calls/location-history-panel.tsx @@ -13,7 +13,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { ProtectedFieldIds } from '@/lib/data-protection/redacted'; -import { formatDateForDisplay, parseApiUtcDate } from '@/lib/utils'; +import { formatDateForDisplay, parseApiUtcDate, readableColors } from '@/lib/utils'; import { type LocationHistoryCallData, type LocationHistoryMatch } from '@/models/v4/calls/locationHistoryResult'; import { locationHistoryKey, type LocationHistorySource, useLocationHistoryStore } from '@/stores/calls/location-history-store'; import { dataProtectionStore } from '@/stores/data-protection/store'; @@ -50,6 +50,7 @@ const HistoryCallCard: React.FC = React.memo(({ call, onOp const { CallId: callId } = call; const openCall = useCallback(() => onOpenCall(callId), [onOpenCall, callId]); const toggleNotes = useCallback(() => setExpanded((value) => !value), []); + const priorityColors = readableColors(call.PriorityColor, '#6b7280'); return ( @@ -57,9 +58,11 @@ const HistoryCallCard: React.FC = React.memo(({ call, onOp - {call.Number} - - {call.PriorityText || t('location_history.unknown_priority')} + {call.Number} + + + {call.PriorityText || t('location_history.unknown_priority')} + {t(`location_history.state.${call.State}`, String(call.State))} @@ -98,7 +101,7 @@ const HistoryCallCard: React.FC = React.memo(({ call, onOp {expanded ? : } - {expanded ? t('location_history.hide_notes') : t('location_history.show_notes', { count: call.Notes.length })} + {expanded ? t('location_history.hide_notes') : t('location_history.show_notes', { count: call.Notes.length })} {expanded ? ( diff --git a/src/components/chat/ack-banner.tsx b/src/components/chat/ack-banner.tsx index 50b999f6..e7106658 100644 --- a/src/components/chat/ack-banner.tsx +++ b/src/components/chat/ack-banner.tsx @@ -1,9 +1,9 @@ -import { AlertTriangle } from 'lucide-react-native'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Button, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; +import { AlertTriangle } from '@/components/ui/lucide-icons'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatAckResultData } from '@/models/v4/chat'; @@ -23,7 +23,7 @@ export function AckBanner({ acks, onAcknowledge }: AckBannerProps) { return ( - + {t('chat.ack_required')} {acks.length > 1 ? t('chat.ack_pending_count', { count: acks.length }) : t('chat.ack_pending_one')} diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index 1ca0ce4a..801a8ae2 100644 --- a/src/components/chat/message-bubble.tsx +++ b/src/components/chat/message-bubble.tsx @@ -1,5 +1,5 @@ import { Image } from 'expo-image'; -import { AlertTriangle, Clock, MapPin, MessageSquare, Pin, RefreshCw } from 'lucide-react-native'; +import { Clock, MapPin, MessageSquare, Pin, RefreshCw } from 'lucide-react-native'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking } from 'react-native'; @@ -8,6 +8,7 @@ import { getChatAttachmentImageSource } from '@/api/chat/chat'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Box } from '@/components/ui/box'; import { HStack } from '@/components/ui/hstack'; +import { AlertTriangle } from '@/components/ui/lucide-icons'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; @@ -67,7 +68,7 @@ function MessageBubbleComponent({ message, isOwn, showSender, currentUserId, onL const isPending = message._localStatus === 'pending'; const isFailed = message._localStatus === 'failed'; - const bubbleTone = isOwn ? 'bg-primary-600' : 'bg-background-100'; + const bubbleTone = isOwn ? 'bg-primary-600 dark:bg-primary-400' : 'bg-background-100'; const textTone = isOwn ? 'text-white' : 'text-typography-900'; const urgentClasses = isUrgent && !isOwn ? 'border-2 border-error-500 bg-error-50' : isUrgent && isOwn ? 'border-2 border-error-300' : ''; @@ -146,7 +147,7 @@ function MessageBubbleComponent({ message, isOwn, showSender, currentUserId, onL {isUrgent && !isDeleted ? ( - + {t('chat.urgent')} ) : null} diff --git a/src/components/chat/message-composer.tsx b/src/components/chat/message-composer.tsx index 51ad4c81..72d6f245 100644 --- a/src/components/chat/message-composer.tsx +++ b/src/components/chat/message-composer.tsx @@ -148,7 +148,7 @@ export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpe ) : null} - + diff --git a/src/components/chat/new-conversation-sheet.tsx b/src/components/chat/new-conversation-sheet.tsx index 69e33622..000a90b0 100644 --- a/src/components/chat/new-conversation-sheet.tsx +++ b/src/components/chat/new-conversation-sheet.tsx @@ -7,7 +7,7 @@ import { getRecipients } from '@/api/messaging/messages'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { HStack } from '@/components/ui/hstack'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; @@ -187,7 +187,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo {mode === 'group' && isSelected ? ( - + ) : null} @@ -200,8 +200,8 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo {mode === 'group' ? ( ) : null} diff --git a/src/components/check-in-timers/check-in-bottom-sheet.tsx b/src/components/check-in-timers/check-in-bottom-sheet.tsx index e50565f8..388be67d 100644 --- a/src/components/check-in-timers/check-in-bottom-sheet.tsx +++ b/src/components/check-in-timers/check-in-bottom-sheet.tsx @@ -86,7 +86,7 @@ export const CheckInBottomSheet: React.FC = ({ isOpen, {/* Type selector */} - {t('check_in.select_type')} + {t('check_in.select_type')} {selectableCheckInTypes.map((type) => ( {offersCode && !approval ? ( ) : null} diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx index 4555d2c0..ba95cc0f 100644 --- a/src/components/livekit/livekit-bottom-sheet.tsx +++ b/src/components/livekit/livekit-bottom-sheet.tsx @@ -197,8 +197,8 @@ export const LiveKitBottomSheet = () => { {isTalking && {t('livekit.speaking')}} {/* Audio Device Info */} - - {t('livekit.audio_devices')} + + {t('livekit.audio_devices')} {t('livekit.microphone')} diff --git a/src/components/mfa/account-verify-modal.tsx b/src/components/mfa/account-verify-modal.tsx index 9bd1cb63..266f44fa 100644 --- a/src/components/mfa/account-verify-modal.tsx +++ b/src/components/mfa/account-verify-modal.tsx @@ -151,7 +151,7 @@ export const AccountVerifyModal: React.FC = ({ proof, o {(reauth && !useProvider) || offersCode ? ( ) : null} diff --git a/src/components/push-notification/push-notification-modal.tsx b/src/components/push-notification/push-notification-modal.tsx index 58f85736..2c2eed2a 100644 --- a/src/components/push-notification/push-notification-modal.tsx +++ b/src/components/push-notification/push-notification-modal.tsx @@ -16,13 +16,13 @@ const NotificationIcon = ({ type }: { type: NotificationType }) => { switch (type) { case 'call': - return ; + return ; case 'message': - return ; + return ; case 'chat': - return ; + return ; case 'group-chat': - return ; + return ; default: return ; } @@ -110,9 +110,9 @@ export const PushNotificationModal: React.FC = () => { ) : null} {notification.type === 'unknown' ? ( - - - {t('push_notifications.unknown_type_warning')} + + + {t('push_notifications.unknown_type_warning')} ) : null} diff --git a/src/components/roles/role-user-selection-modal.tsx b/src/components/roles/role-user-selection-modal.tsx index 1465f307..e65562e7 100644 --- a/src/components/roles/role-user-selection-modal.tsx +++ b/src/components/roles/role-user-selection-modal.tsx @@ -66,7 +66,7 @@ export const RoleUserSelectionModal: React.FC = ({ handleSelect(item.UserId)} - className={`px-4 py-3 ${isSelected ? (isDark ? 'bg-primary-900/30' : 'bg-primary-50') : ''}`} + className={`px-4 py-3 ${isSelected ? (isDark ? 'bg-primary-100/30' : 'bg-primary-50') : ''}`} testID={`user-item-${item.UserId}`} accessibilityRole="button" accessibilityLabel={t('roles.selectUserLabel', { name: fullName, defaultValue: `Select ${fullName}` })} @@ -78,7 +78,7 @@ export const RoleUserSelectionModal: React.FC = ({ - {fullName} + {fullName} {isAssignedElsewhere ? ( {otherAssignment?.roleName ? otherAssignment.roleName : t('roles.assignedElsewhere', 'In another role')} @@ -158,7 +158,7 @@ export const RoleUserSelectionModal: React.FC = ({ {/* Unassigned option */} handleSelect(undefined)} - className={`px-4 py-3 ${!selectedUserId ? (isDark ? 'bg-primary-900/30' : 'bg-primary-50') : ''}`} + className={`px-4 py-3 ${!selectedUserId ? (isDark ? 'bg-primary-100/30' : 'bg-primary-50') : ''}`} testID="unassigned-option" accessibilityRole="button" accessibilityLabel={t('roles.unassigned', 'Unassigned')} @@ -168,7 +168,7 @@ export const RoleUserSelectionModal: React.FC = ({ {!selectedUserId ? : } - {t('roles.unassigned', 'Unassigned')} + {t('roles.unassigned', 'Unassigned')} {t('roles.clearAssignment', 'Clear the current assignment')} {!selectedUserId ? : null} diff --git a/src/components/roles/roles-bottom-sheet.tsx b/src/components/roles/roles-bottom-sheet.tsx index 7d9fac54..74313449 100644 --- a/src/components/roles/roles-bottom-sheet.tsx +++ b/src/components/roles/roles-bottom-sheet.tsx @@ -232,7 +232,7 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl {t('common.cancel', 'Cancel')} diff --git a/src/components/routes/poi-list-content.tsx b/src/components/routes/poi-list-content.tsx index 86af8a10..2df3bd83 100644 --- a/src/components/routes/poi-list-content.tsx +++ b/src/components/routes/poi-list-content.tsx @@ -146,7 +146,7 @@ export const PoiListContent: React.FC = () => { {t('routes.no_search_results_pois')} {t('routes.no_pois_filtered_description')} - {t('routes.clear_filters')} + {t('routes.clear_filters')} diff --git a/src/components/routes/routes-home.tsx b/src/components/routes/routes-home.tsx index 883dae33..fb5a69ab 100644 --- a/src/components/routes/routes-home.tsx +++ b/src/components/routes/routes-home.tsx @@ -51,11 +51,11 @@ const FilterSheet: React.FC = () => { 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' + selectedPoiTypeId === null ? (isDark ? 'border-primary-500 bg-primary-100/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')} + {t('routes.poi_filter_all_types')} {selectedPoiTypeId === null && } @@ -67,14 +67,14 @@ const FilterSheet: React.FC = () => { 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-100/30' : 'border-primary-500 bg-primary-50' : isDark ? 'border-neutral-700 bg-neutral-800' : 'border-neutral-200 bg-white' }`} > - {poiType.Name} + {poiType.Name} {selectedPoiTypeId === poiType.PoiTypeId && } ))} @@ -92,10 +92,10 @@ const FilterSheet: React.FC = () => { key={option.value} onPress={() => 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' + sortBy === option.value ? (isDark ? 'border-primary-500 bg-primary-100/30' : 'border-primary-500 bg-primary-50') : isDark ? 'border-neutral-700 bg-neutral-800' : 'border-neutral-200 bg-white' }`} > - {option.label} + {option.label} {sortBy === option.value && } ))} diff --git a/src/components/routes/stop-card.tsx b/src/components/routes/stop-card.tsx index 030f594f..cf6770da 100644 --- a/src/components/routes/stop-card.tsx +++ b/src/components/routes/stop-card.tsx @@ -81,14 +81,14 @@ export const StopCard: React.FC = ({ stop, isCurrent = false, onC {isCurrent && stop.Status !== RouteStopStatus.Completed && stop.Status !== RouteStopStatus.Skipped ? ( {stop.Status === RouteStopStatus.Pending ? ( - ) : null} {stop.Status === RouteStopStatus.InProgress ? ( - ) : null} diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx index fee17c1b..f55dc65e 100644 --- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx +++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx @@ -168,13 +168,13 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo handleDeviceSelect(item)} disabled={!!connectingDeviceId} - className={`mb-2 rounded-lg border p-4 ${isSelected ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'} ${!!connectingDeviceId ? 'opacity-70' : ''}`} + className={`mb-2 rounded-lg border p-4 ${isSelected ? 'border-primary-500 bg-primary-50' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'} ${!!connectingDeviceId ? 'opacity-70' : ''}`} > - {item.name || t('bluetooth.unknown_device')} + {item.name || t('bluetooth.unknown_device')} {isConnected && } @@ -187,7 +187,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo ) : isSelected ? ( - {t('bluetooth.selected')} + {t('bluetooth.selected')} {isConnected && {t('bluetooth.connected')}} ) : null} @@ -270,19 +270,19 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo } }} disabled={!!connectingDeviceId} - className={`rounded-lg border p-4 ${preferredDevice?.id === 'system-audio' ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'} ${!!connectingDeviceId ? 'opacity-70' : ''}`} + className={`rounded-lg border p-4 ${preferredDevice?.id === 'system-audio' ? 'border-primary-500 bg-primary-50' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'} ${!!connectingDeviceId ? 'opacity-70' : ''}`} > - {t('bluetooth.system_audio')} + {t('bluetooth.system_audio')} {t('bluetooth.system_audio_description')} {preferredDevice?.id === 'system-audio' && ( - {t('bluetooth.selected')} + {t('bluetooth.selected')} )} diff --git a/src/components/settings/item.tsx b/src/components/settings/item.tsx index 06aeeda0..0162b7ce 100644 --- a/src/components/settings/item.tsx +++ b/src/components/settings/item.tsx @@ -1,7 +1,7 @@ -import { ArrowRight } from 'lucide-react-native'; import * as React from 'react'; import { Pressable, View } from '@/components/ui'; +import { ArrowRight } from '@/components/ui/lucide-icons'; import { Text } from '@/components/ui/text'; type ItemProps = { @@ -26,7 +26,7 @@ export const Item = ({ text, value, icon, onPress, textStyle }: ItemProps) => { {truncatedValue} {isPressable && ( - + )} diff --git a/src/components/shared-session/shared-session-bar.tsx b/src/components/shared-session/shared-session-bar.tsx index 57819ca7..d64097e9 100644 --- a/src/components/shared-session/shared-session-bar.tsx +++ b/src/components/shared-session/shared-session-bar.tsx @@ -50,7 +50,7 @@ export const SharedSessionBar: React.FC = () => { }; return ( - + {installationLabel ? t('shared_session.bar_label', { label: installationLabel }) : t('shared_session.bar')} @@ -72,7 +72,7 @@ export const SharedSessionBar: React.FC = () => { ) : ( <> ) : null} diff --git a/src/components/sidebar/__tests__/sidebar-content.test.tsx b/src/components/sidebar/__tests__/sidebar-content.test.tsx index dc65d9ab..a073d710 100644 --- a/src/components/sidebar/__tests__/sidebar-content.test.tsx +++ b/src/components/sidebar/__tests__/sidebar-content.test.tsx @@ -160,4 +160,18 @@ describe('Sidebar status buttons', () => { unmount(); }); + + it.each([false, true])('rings only the current status, without a "Current" badge (hold mode: %s)', (holdMode) => { + setCoreState({ holdMode, currentStateId: 10 }); + + const { unmount } = render(); + fireEvent.press(screen.getByTestId('sidebar-status-show-all')); + + expect(screen.getByTestId('sidebar-status-current-ring-10')).toBeTruthy(); + expect(screen.queryByTestId('sidebar-status-current-ring-11')).toBeNull(); + expect(screen.queryByTestId('sidebar-status-current-ring-12')).toBeNull(); + expect(screen.queryByText('status.current')).toBeNull(); + + unmount(); + }); }); diff --git a/src/components/sidebar/__tests__/status-sidebar.test.tsx b/src/components/sidebar/__tests__/status-sidebar.test.tsx index f88ab145..cb8d80af 100644 --- a/src/components/sidebar/__tests__/status-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/status-sidebar.test.tsx @@ -103,6 +103,32 @@ describe('SidebarStatusCard', () => { }); }); + it('should give the label a fixed contrasting colour on a hex status colour', () => { + const cases = [ + { StateStyle: 'label-warning', color: '#000000' }, + { StateStyle: 'label-default', color: '#FFFFFF' }, + ]; + + cases.forEach(({ StateStyle, color }) => { + const mockStatus = createMockStatus({ State: `Status ${StateStyle}`, StateStyle }); + mockUseCoreStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus })); + + const { getByText, unmount } = render(); + expect(getByText(`Status ${StateStyle}`).props.style).toEqual(expect.objectContaining({ color })); + unmount(); + }); + }); + + it('should leave the themed label colour alone when the status has no hex colour', () => { + const mockStatus = createMockStatus({ State: 'Returning', StateStyle: 'label-returning' }); + mockUseCoreStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus })); + + const { getByText, unmount } = render(); + const style = getByText('Returning').props.style; + expect(style?.color).toBeUndefined(); + unmount(); + }); + it('should handle unknown status styles by keeping original value', () => { const mockStatus = createMockStatus({ State: 'Test Status', diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx index fe30ac49..0086f162 100644 --- a/src/components/sidebar/call-sidebar.tsx +++ b/src/components/sidebar/call-sidebar.tsx @@ -258,7 +258,7 @@ export const SidebarCallCard = () => { }); }} className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${ - activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : '' + activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-100' : 'bg-primary-50') : '' }`} testID={`call-item-${call.CallId}`} > diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index e3332859..a3088ed9 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -4,13 +4,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { CurrentStatusRing } from '@/components/status/current-status-ring'; import { HoldToConfirmButton } from '@/components/status/hold-to-confirm-button'; 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 { getOfferedStatuses, resolveCurrentStatusId } from '@/lib/status-flow'; -import { invertColor } from '@/lib/utils'; +import { readableColors } from '@/lib/utils'; import { useCoreStore } from '@/stores/app/core-store'; import { useIsChatEnabled, useIsChecklistsEnabled, useIsDeploymentsEnabled, useIsRecordsFieldEnabled } from '@/stores/feature-flags/store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; @@ -23,6 +24,10 @@ import { SidebarRolesCard } from './roles-sidebar'; import { SidebarStatusCard } from './status-sidebar'; import { SidebarUnitCard } from './unit-sidebar'; +/** Corner radii of the two status button kinds (gluestack `rounded`, HoldToConfirmButton), for the current-status ring. */ +const BUTTON_RADIUS = 4; +const HOLD_BUTTON_RADIUS = 8; + interface SidebarProps { onClose?: () => void; } @@ -45,7 +50,7 @@ const Sidebar = ({ onClose }: SidebarProps) => { const isActiveStatusesEmpty = !activeStatuses?.Statuses || activeStatuses.Statuses.length === 0; - // Same status flow as the status sheet: the current status is outlined, and a status with next statuses + // Same status flow as the status sheet: the current status is ringed, and a status with next statuses // configured narrows the buttons to those until the crew asks for all of them. const currentStatusId = React.useMemo(() => { if (!activeUnitStatus || (activeUnitId && activeUnitStatus.UnitId && String(activeUnitStatus.UnitId) !== String(activeUnitId))) { @@ -189,42 +194,30 @@ const Sidebar = ({ onClose }: SidebarProps) => { {offeredStatuses.offered.map((status) => { const isCurrent = String(status.Id) === currentStatusId; - // invertColor throws on a non-hex value, so an option without a color falls back to white like the status sheet. - const background = status.BColor || '#ffffff'; - const foreground = invertColor(background, true); + // An option without a color falls back to white like the status sheet. + const { backgroundColor: background, textColor: foreground } = readableColors(status.BColor, '#ffffff'); const label = ( - - - {status.Text} - - {isCurrent ? ( - - {t('status.current')} - - ) : null} - + + {status.Text} + ); + const accessibilityLabel = isCurrent ? `${status.Text}, ${t('status.current')}` : undefined; - if (isHoldMode) { - return ( - setIsOpen(true, status, { holdConfirmed: true })} - onTap={() => showToast('info', t('status.hold_to_set_hint'))} - backgroundColor={background} - foregroundColor={foreground} - style={isCurrent ? styles.currentOutline : null} - contentStyle={styles.holdContent} - accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : status.Text} - accessibilityHint={t('status.hold_to_set_hint')} - > - {label} - - ); - } - - return ( + const button = isHoldMode ? ( + setIsOpen(true, status, { holdConfirmed: true })} + onTap={() => showToast('info', t('status.hold_to_set_hint'))} + backgroundColor={background} + foregroundColor={foreground} + contentStyle={styles.holdContent} + accessibilityLabel={accessibilityLabel ?? status.Text} + accessibilityHint={t('status.hold_to_set_hint')} + > + {label} + + ) : ( ); + + return isCurrent ? ( + + + {button} + + ) : ( + button + ); })} {offeredStatuses.isRestricted ? ( @@ -258,21 +260,9 @@ const Sidebar = ({ onClose }: SidebarProps) => { }; const styles = StyleSheet.create({ - currentOutline: { - borderWidth: 3, - borderColor: '#dc2626', - }, - currentPill: { - backgroundColor: '#dc2626', - borderRadius: 999, - paddingHorizontal: 6, - paddingVertical: 1, - }, - currentPillText: { - color: '#ffffff', - fontSize: 10, - fontWeight: '700', - textTransform: 'uppercase', + labelText: { + flexShrink: 1, + textAlign: 'center', }, holdContent: { paddingHorizontal: 12, diff --git a/src/components/sidebar/status-sidebar.tsx b/src/components/sidebar/status-sidebar.tsx index 755834f2..141d07aa 100644 --- a/src/components/sidebar/status-sidebar.tsx +++ b/src/components/sidebar/status-sidebar.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Text } from '@/components/ui/text'; +import { readableTextColor } from '@/lib/utils'; import { useCoreStore } from '@/stores/app/core-store'; import { Card } from '../ui/card'; @@ -33,9 +34,14 @@ export const SidebarStatusCard = () => { displayColor = '#449d44'; } + // A status colour is fixed in both themes, so the themed text colour can vanish against it; pick black or white instead. + const foreground = readableTextColor(displayColor); + return ( - {displayStatus} + + {displayStatus} + ); }; diff --git a/src/components/status/__tests__/current-status-ring.test.tsx b/src/components/status/__tests__/current-status-ring.test.tsx new file mode 100644 index 00000000..4ef75e47 --- /dev/null +++ b/src/components/status/__tests__/current-status-ring.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import { CURRENT_STATUS_RING_OUTSET, CurrentStatusRing } from '../current-status-ring'; + +describe('CurrentStatusRing', () => { + it('sits just outside the button with corners that follow it', () => { + render(); + + const ring = screen.getByTestId('ring'); + const style = StyleSheet.flatten(ring.props.style); + + expect(style).toMatchObject({ + position: 'absolute', + top: -CURRENT_STATUS_RING_OUTSET, + right: -CURRENT_STATUS_RING_OUTSET, + bottom: -CURRENT_STATUS_RING_OUTSET, + left: -CURRENT_STATUS_RING_OUTSET, + borderRadius: 8 + CURRENT_STATUS_RING_OUTSET, + }); + }); + + it('never takes touches meant for the button', () => { + render(); + + expect(screen.getByTestId('ring').props.pointerEvents).toBe('none'); + }); +}); diff --git a/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx b/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx index c778bebc..6135e41a 100644 --- a/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet-submission.test.tsx @@ -52,7 +52,7 @@ jest.mock('../../ui/textarea', () => ({ })); jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }), styled: (component: any) => component, cssInterop: (component: any) => component })); jest.mock('lucide-react-native', () => new Proxy({}, { get: () => () => null })); -jest.mock('@/lib/utils', () => ({ invertColor: () => '#000000' })); +jest.mock('@/lib/utils', () => ({ readableColors: (color?: string, fallback = '#808080') => ({ backgroundColor: color || fallback, textColor: '#000000' }) })); jest.mock('@/lib/logging', () => ({ logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } })); jest.mock('@/services/location-fix', () => ({ diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index 57c826d6..896b823c 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -124,7 +124,7 @@ jest.mock('nativewind', () => ({ jest.mock('@/lib/utils', () => ({ IS_ANDROID: false, IS_IOS: true, - invertColor: jest.fn(() => '#000000'), + readableColors: jest.fn((color?: string, fallback = '#808080') => ({ backgroundColor: color || fallback, textColor: '#000000' })), createSelectors: jest.fn(), openLinkInBrowser: jest.fn(), DEFAULT_CENTER_COORDINATE: [-77.036086, 38.910233], @@ -3691,7 +3691,7 @@ describe('StatusBottomSheet', () => { }); // New tests for color scheme functionality - it('should use BColor for background and invertColor for text color in status selection', () => { + it('should use BColor for background and a contrasting text color in status selection', () => { const statusWithBColor = { Id: 1, Type: 1, @@ -4237,7 +4237,7 @@ describe('StatusBottomSheet', () => { jest.useRealTimers(); }); - it('offers only the next statuses and keeps the current one visible, outlined', () => { + it('offers only the next statuses and keeps the current one visible, ringed', () => { setCoreStore({}); mountStatefulSheetStore(); @@ -4246,8 +4246,9 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('On Scene')).toBeTruthy(); expect(screen.queryByText('Available')).toBeNull(); expect(screen.getByTestId('status-current-banner')).toBeTruthy(); + expect(screen.getByTestId('status-current-banner-ring')).toBeTruthy(); expect(screen.getByText('Departed')).toBeTruthy(); - expect(screen.getByText('status.current')).toBeTruthy(); + expect(screen.queryByText('status.current')).toBeNull(); expect(screen.getByText('status.show_all_statuses')).toBeTruthy(); }); @@ -4262,6 +4263,9 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('On Scene')).toBeTruthy(); expect(screen.queryByTestId('status-current-banner')).toBeNull(); expect(screen.getByTestId('status-option-12').props.accessibilityLabel).toBe('Departed, status.current'); + expect(screen.getByTestId('status-current-ring-12')).toBeTruthy(); + expect(screen.queryByTestId('status-current-ring-10')).toBeNull(); + expect(screen.queryByText('status.current')).toBeNull(); expect(screen.getByTestId('status-show-next')).toBeTruthy(); }); diff --git a/src/components/status/current-status-ring.tsx b/src/components/status/current-status-ring.tsx new file mode 100644 index 00000000..77c130d7 --- /dev/null +++ b/src/components/status/current-status-ring.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +/** The gap between the button and the ring, then the ring's own line. */ +const RING_GAP = 2; +const RING_WIDTH = 2; + +/** How far the ring reaches outside the button; the container needs this much room so it is not clipped. */ +export const CURRENT_STATUS_RING_OUTSET = RING_GAP + RING_WIDTH; + +interface CurrentStatusRingProps { + /** Corner radius of the button it surrounds, so the ring's corners follow it. */ + radius: number; + testID?: string; +} + +/** + * Marks the unit's current status with a thin ring just outside its button: a small gap, then a neutral line + * that follows the theme. Keeping it off the button means it shows whatever colour the status is. + * + * Render it first inside a plain wrapper around the button; it takes no space of its own. + */ +export const CurrentStatusRing: React.FC = React.memo(({ radius, testID }) => ( + +)); + +CurrentStatusRing.displayName = 'CurrentStatusRing'; + +const styles = StyleSheet.create({ + ring: { + position: 'absolute', + top: -CURRENT_STATUS_RING_OUTSET, + right: -CURRENT_STATUS_RING_OUTSET, + bottom: -CURRENT_STATUS_RING_OUTSET, + left: -CURRENT_STATUS_RING_OUTSET, + borderWidth: RING_WIDTH, + }, +}); diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index dc07c704..cd1ab94f 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -10,7 +10,7 @@ import { logger } from '@/lib/logging'; import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; import { getUnitStatusCallDestinationId, resolveDefaultStatusCall } from '@/lib/status-destination'; import { canSubmitStatusWithoutInput, getOfferedStatuses, resolveCurrentStatusId } from '@/lib/status-flow'; -import { invertColor } from '@/lib/utils'; +import { readableColors } from '@/lib/utils'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes'; import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes'; @@ -34,10 +34,11 @@ import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { Textarea, TextareaInput } from '../ui/textarea'; import { VStack } from '../ui/vstack'; +import { CURRENT_STATUS_RING_OUTSET, CurrentStatusRing } from './current-status-ring'; import { HoldToConfirmButton } from './hold-to-confirm-button'; -/** The red outline that marks the unit's current status (crews asked for it to be unmistakable). */ -const CURRENT_STATUS_BORDER = '#dc2626'; +/** Corner radius of a status option (`rounded-lg`, HoldToConfirmButton) and the current-status banner, for the ring. */ +const OPTION_RADIUS = 8; type DestinationTab = 'call' | 'station' | 'poi'; @@ -923,17 +924,17 @@ export const StatusBottomSheet = () => { return null; } - const summaryBackground = selectedStatus.BColor || '#f3f4f6'; + const summaryColors = readableColors(selectedStatus.BColor, '#f3f4f6'); const destinationText = getSelectedDestinationDisplay(); return ( - + {`${selectedStatus.Text} · ${destinationText}`} @@ -944,7 +945,7 @@ export const StatusBottomSheet = () => { const renderSubmitButtonContent = () => ( <> {isSubmitting ? : null} - {isSubmitting ? t('common.submitting') : t('common.submit')} + {isSubmitting ? t('common.submitting') : t('common.submit')} ); @@ -985,7 +986,7 @@ export const StatusBottomSheet = () => { renderSubmitButtonContent() ) : ( <> - {t('common.next')} + {t('common.next')} )} @@ -993,25 +994,18 @@ export const StatusBottomSheet = () => { ); }; - const renderCurrentPill = () => ( - - {t('status.current')} - - ); - const renderCurrentStatusBanner = (status: StatusesResultData) => { - const background = status.BColor || '#ffffff'; - const foreground = invertColor(background, true); + const { backgroundColor: background, textColor: foreground } = readableColors(status.BColor, '#ffffff'); return ( - - {t('status.current_status')} - - + + + + {t('status.current_status')} + {status.Text} - {renderCurrentPill()} - + ); }; @@ -1019,25 +1013,33 @@ export const StatusBottomSheet = () => { const renderStatusOption = (status: StatusesResultData) => { const statusDetailDescription = getStatusDetailDescription(Number(status.Detail)); const isCurrent = String(status.Id) === currentStatusId; - const background = status.BColor || '#ffffff'; - const foreground = invertColor(background, true); + const { backgroundColor: background, textColor: foreground } = readableColors(status.BColor, '#ffffff'); const details = ( - - - {status.Text} + + {status.Text} + + {/* These sit on the status's own fixed colour, so they follow its foreground rather than the theme. */} + {Number(status.Detail) > 0 ? ( + + {statusDetailDescription} - {isCurrent ? renderCurrentPill() : null} - - {Number(status.Detail) > 0 ? {statusDetailDescription} : null} - {Number(status.Note) > 0 ? {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} : null} + ) : null} + {Number(status.Note) > 0 ? ( + + {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} + + ) : null} ); + const ring = isCurrent ? : null; + if (isHoldMode) { return ( + {ring} handleStatusHold(status.Id.toString())} @@ -1045,7 +1047,7 @@ export const StatusBottomSheet = () => { disabled={isSubmitting || holdConfirmed} backgroundColor={background} foregroundColor={foreground} - style={isCurrent ? styles.currentOutline : styles.optionOutline} + style={styles.optionOutline} contentStyle={styles.holdOptionContent} accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : status.Text} accessibilityHint={t('status.hold_to_set_hint')} @@ -1059,19 +1061,21 @@ export const StatusBottomSheet = () => { 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') }, isCurrent && !isSelected ? styles.currentOutline : null]} - accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : undefined} - > - - - {details} - - + + {ring} + handleStatusSelect(status.Id.toString())} + className={`rounded-lg border-2 p-3 ${isSelected ? 'border-blue-500' : 'border-gray-200 dark:border-gray-700'}`} + style={{ backgroundColor: status.BColor ? background : isSelected ? '#dbeafe' : '#ffffff' }} + accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : undefined} + > + + + {details} + + + ); }; @@ -1122,7 +1126,8 @@ export const StatusBottomSheet = () => { {/* The current status stays visible even when the list only offers what follows it. */} {currentStatus && !offeredStatuses.offered.some((status) => String(status.Id) === currentStatusId) ? renderCurrentStatusBanner(currentStatus) : null} - + {/* Pulled out by the ring's reach and padded back, so the current status's ring is not clipped. */} + {offeredStatuses.offered.length > 0 ? ( offeredStatuses.offered.map((status) => renderStatusOption(status)) @@ -1422,30 +1427,18 @@ const styles = StyleSheet.create({ holdOptionContent: { padding: 12, }, + optionList: { + marginHorizontal: -CURRENT_STATUS_RING_OUTSET, + }, + optionListContent: { + padding: CURRENT_STATUS_RING_OUTSET, + }, optionOutline: { borderWidth: 2, borderColor: 'transparent', }, - currentOutline: { - borderWidth: 3, - borderColor: CURRENT_STATUS_BORDER, - }, - currentPill: { - backgroundColor: CURRENT_STATUS_BORDER, - borderRadius: 999, - paddingHorizontal: 8, - paddingVertical: 2, - }, - currentPillText: { - color: '#ffffff', - fontSize: 11, - fontWeight: '700', - textTransform: 'uppercase', - }, currentBanner: { - borderWidth: 3, - borderColor: CURRENT_STATUS_BORDER, - borderRadius: 8, + borderRadius: OPTION_RADIUS, paddingHorizontal: 12, paddingVertical: 8, }, diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx index 95b88f7e..3ffde54c 100644 --- a/src/components/toast/toast.tsx +++ b/src/components/toast/toast.tsx @@ -16,10 +16,11 @@ export const ToastMessage: React.FC<{ const { t } = useTranslation(); return ( + // typography-0 flips against the toast backgrounds (dark in light mode, pastel in dark mode); a fixed white vanishes in dark mode. - {title && {t(title)}} - {t(message)} + {title && {t(title)}} + {t(message)} ); diff --git a/src/components/ui/button/index.tsx b/src/components/ui/button/index.tsx index c76b1bc0..511cc2c3 100644 --- a/src/components/ui/button/index.tsx +++ b/src/components/ui/button/index.tsx @@ -140,7 +140,7 @@ const buttonTextStyle = tva({ { variant: 'outline', action: 'primary', - class: 'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500', + class: 'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700', }, { variant: 'outline', diff --git a/src/components/ui/focus-aware-status-bar.tsx b/src/components/ui/focus-aware-status-bar.tsx index 07646769..f564d7ff 100644 --- a/src/components/ui/focus-aware-status-bar.tsx +++ b/src/components/ui/focus-aware-status-bar.tsx @@ -61,7 +61,7 @@ function FocusAwareStatusBarInner({ hidden = false }: Props) { if (Platform.OS === 'web') return null; // Only render SystemBars when focused and on supported platforms - return isFocused && (Platform.OS === 'android' || Platform.OS === 'ios') ? {showOverflowIndicators && overflowState.canScrollLeft ? ( - - + + ) : null} {showOverflowIndicators && overflowState.canScrollRight ? ( - - + + ) : null} @@ -271,13 +272,13 @@ export const SharedTabs: React.FC = ({ {tabs.map((tab, index) => ( handleTabPress(index)}> - {tab.icon ? {tab.icon} : null} + {tab.icon ? {renderIcon(tab.icon, index)} : null} {typeof tab.title === 'string' ? ( - + {t(tab.title)} ) : ( - + {tab.title} )} diff --git a/src/components/ui/textarea/index.tsx b/src/components/ui/textarea/index.tsx index 4a6d8d6f..13b274d8 100644 --- a/src/components/ui/textarea/index.tsx +++ b/src/components/ui/textarea/index.tsx @@ -28,7 +28,7 @@ const textareaStyle = tva({ }); const textareaInputStyle = tva({ - base: 'p-2 web:outline-0 web:outline-none flex-1 color-typography-900 align-text-top placeholder:text-typography-500 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', + base: 'p-2 web:outline-0 web:outline-none flex-1 text-typography-900 align-text-top placeholder:text-typography-500 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', parentVariants: { size: { sm: 'text-sm', diff --git a/src/components/weather-alerts/weather-alert-detail-map.tsx b/src/components/weather-alerts/weather-alert-detail-map.tsx index fd2b6c98..2f6cab49 100644 --- a/src/components/weather-alerts/weather-alert-detail-map.tsx +++ b/src/components/weather-alerts/weather-alert-detail-map.tsx @@ -54,7 +54,7 @@ export const WeatherAlertDetailMap: React.FC = ({ al if (!mapCenter) { return ( - + {t('call_detail.no_location')} ); diff --git a/src/features/livekit-call/components/LiveKitCallModal.tsx b/src/features/livekit-call/components/LiveKitCallModal.tsx deleted file mode 100644 index 02fa29f4..00000000 --- a/src/features/livekit-call/components/LiveKitCallModal.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { AlertTriangle, CheckCircle, CircleIcon, Mic, MicOff, PhoneMissed } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; -import { ScrollView } from 'react-native'; - -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; -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 { Radio, RadioGroup, RadioIndicator, RadioLabel } from '@/components/ui/radio'; -import { Spinner } from '@/components/ui/spinner'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; - -import { type RoomInfo, useLiveKitCallStore } from '../store/useLiveKitCallStore'; - -interface LiveKitCallModalProps { - isOpen: boolean; - onClose: () => void; - participantIdentity?: string; // Optional: pass if you have a specific identity -} - -const LiveKitCallModal: React.FC = ({ - isOpen, - onClose, - participantIdentity = `user-${Math.random().toString(36).substring(7)}`, // Default unique enough for example -}) => { - const availableRooms = useLiveKitCallStore((s) => s.availableRooms); - const selectedRoomForJoining = useLiveKitCallStore((s) => s.selectedRoomForJoining); - const currentRoomId = useLiveKitCallStore((s) => s.currentRoomId); - const isConnecting = useLiveKitCallStore((s) => s.isConnecting); - const isConnected = useLiveKitCallStore((s) => s.isConnected); - const error = useLiveKitCallStore((s) => s.error); - const localParticipant = useLiveKitCallStore((s) => s.localParticipant); - const actions = useLiveKitCallStore((s) => s.actions); - - const [isMicrophoneEnabled, setIsMicrophoneEnabled] = useState(true); - - useEffect(() => { - if (localParticipant) { - const micPublication = localParticipant.getTrackPublicationByName('microphone'); - setIsMicrophoneEnabled(micPublication ? micPublication.isMuted === false : true); - } else { - setIsMicrophoneEnabled(true); // Default before connected - } - }, [localParticipant, isConnected]); - - const handleJoinRoom = () => { - if (selectedRoomForJoining && !isConnecting && !isConnected) { - actions.connectToRoom(selectedRoomForJoining, participantIdentity); - // Modal can be closed by user, connection persists via store - } - }; - - const handleLeaveRoom = () => { - actions.disconnectFromRoom(); - onClose(); // Close modal on leaving - }; - - const handleToggleMicrophone = async () => { - await actions.setMicrophoneEnabled(!isMicrophoneEnabled); - setIsMicrophoneEnabled(!isMicrophoneEnabled); // Update local state immediately for UI responsiveness - }; - - const internalOnClose = () => { - if (isConnecting) { - // Optionally prevent closing or ask for confirmation if connecting - // For now, allow close - } - actions._clearError(); // Clear any transient errors when modal is closed - onClose(); - }; - - const currentRoomName = availableRooms.find((r) => r.id === currentRoomId)?.name || currentRoomId; - const selectedRoomName = availableRooms.find((r) => r.id === selectedRoomForJoining)?.name || selectedRoomForJoining; - - return ( - - - - - - - - {isConnecting ? ( - - - Connecting to {selectedRoomName || 'room'}... - - ) : error ? ( - - - - Connection Error - - {error} - - - ) : isConnected && currentRoomId ? ( - - - - - Connected - - - You are in room: {currentRoomName} - - {localParticipant && Your ID: {localParticipant.identity}} - - - - - - - - ) : ( - - - Join a Voice Call - - Select a room to join: - - actions.setSelectedRoomForJoining(nextValue)} accessibilityLabel="Select a room"> - - {availableRooms.map((room: RoomInfo) => ( - - - - - {room.name} - - ))} - - - - - - )} - - - ); -}; - -export default LiveKitCallModal; diff --git a/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts b/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts deleted file mode 100644 index 1f8758f1..00000000 --- a/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { renderHook, act } from '@testing-library/react-native'; - -// Mock Platform before any imports - must be inlined to avoid hoisting issues -jest.mock('react-native', () => ({ - Platform: { - OS: 'ios' as 'ios' | 'android' | 'web' | 'windows' | 'macos', - select: jest.fn((obj: any) => obj.ios || obj.default), - Version: 14, - }, -})); - -// Mock the CallKeep service module -jest.mock('../../../../services/callkeep.service.ios', () => ({ - callKeepService: { - setup: jest.fn(), - startCall: jest.fn(), - endCall: jest.fn(), - isCallActiveNow: jest.fn(), - getCurrentCallUUID: jest.fn(), - cleanup: jest.fn(), - setMuteStateCallback: jest.fn(), - }, -})); - -// Mock logger -jest.mock('../../../../lib/logging', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('../../../../services/bluetooth-audio.service', () => ({ - bluetoothAudioService: { - ensurePttInputMonitoring: jest.fn(), - }, -})); - -// Mock livekit-client -const mockRoom = { - on: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - localParticipant: { - setMicrophoneEnabled: jest.fn(), - setCameraEnabled: jest.fn(), - identity: 'local-participant', - }, - remoteParticipants: new Map(), - name: 'test-room', -} as any; - -jest.mock('livekit-client', () => ({ - Room: jest.fn().mockImplementation(() => mockRoom), - RoomEvent: { - ConnectionStateChanged: 'connectionStateChanged', - ParticipantConnected: 'participantConnected', - ParticipantDisconnected: 'participantDisconnected', - }, - ConnectionState: { - Connected: 'connected', - Disconnected: 'disconnected', - Connecting: 'connecting', - Reconnecting: 'reconnecting', - }, -})); - -import { useLiveKitCallStore } from '../useLiveKitCallStore'; -import { logger } from '../../../../lib/logging'; - -// Get the mocked constructors after the imports -const mockCallKeepService = require('../../../../services/callkeep.service.ios').callKeepService; -const mockLogger = logger as jest.Mocked; -const MockedRoom = require('livekit-client').Room as jest.MockedClass; - -describe('useLiveKitCallStore with CallKeep Integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - require('react-native').Platform.OS = 'ios'; - - // Reset mock implementations - mockCallKeepService.setup.mockResolvedValue(undefined); - mockCallKeepService.startCall.mockResolvedValue('test-uuid'); - mockCallKeepService.endCall.mockResolvedValue(undefined); - mockCallKeepService.isCallActiveNow.mockReturnValue(false); - mockCallKeepService.getCurrentCallUUID.mockReturnValue(null); - mockCallKeepService.setMuteStateCallback.mockReturnValue(undefined); - - // Reset the Room mock to return mockRoom by default - MockedRoom.mockImplementation(() => mockRoom); - - mockRoom.connect.mockResolvedValue(undefined); - mockRoom.disconnect.mockResolvedValue(undefined); - mockRoom.localParticipant.setMicrophoneEnabled.mockResolvedValue(undefined); - mockRoom.localParticipant.setCameraEnabled.mockResolvedValue(undefined); - - // Clear logger mocks - mockLogger.debug.mockClear(); - mockLogger.info.mockClear(); - mockLogger.warn.mockClear(); - mockLogger.error.mockClear(); - - // Reset the store to initial state - useLiveKitCallStore.setState({ - availableRooms: [ - { id: 'emergency-channel', name: 'Emergency Channel' }, - { id: 'tactical-1', name: 'Tactical 1' }, - { id: 'dispatch', name: 'Dispatch' }, - ], - selectedRoomForJoining: null, - currentRoomId: null, - isConnecting: false, - isConnected: false, - roomInstance: null, - participants: [], - error: null, - localParticipant: null, - }); - }); - - describe('CallKeep Mute State Callback', () => { - beforeEach(() => { - require('react-native').Platform.OS = 'ios'; - }); - - it('should register mute state callback when connecting on iOS', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockCallKeepService.setMuteStateCallback).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('should clear mute state callback when disconnecting on iOS', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockCallKeepService.setMuteStateCallback).toHaveBeenCalledWith(null); - }); - - it('should not register callback on non-iOS platforms', async () => { - require('react-native').Platform.OS = 'android'; - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockCallKeepService.setMuteStateCallback).not.toHaveBeenCalled(); - }); - }); - - describe('Room Connection with CallKeep', () => { - beforeEach(() => { - // Mock successful connection flow - mockRoom.on.mockImplementation((event: any, callback: any) => { - if (event === 'connectionStateChanged') { - // Simulate connected state - setTimeout(() => callback('connected'), 0); - } - return mockRoom; - }); - }); - - it('should start CallKeep call on successful room connection (iOS)', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('emergency-channel', 'test-participant'); - // Wait for the connection state change event to fire - await new Promise(resolve => setTimeout(resolve, 100)); - }); - - expect(mockCallKeepService.startCall).toHaveBeenCalledWith('emergency-channel'); - }); - - it('should not start CallKeep call on Android', async () => { - require('react-native').Platform.OS = 'android'; - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('emergency-channel', 'test-participant'); - }); - - expect(mockCallKeepService.startCall).not.toHaveBeenCalled(); - }); - - it('should handle CallKeep start call errors gracefully', async () => { - const error = new Error('Failed to start call'); - mockCallKeepService.startCall.mockRejectedValueOnce(error); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('emergency-channel', 'test-participant'); - // Wait for the connection state change event to fire - await new Promise(resolve => setTimeout(resolve, 100)); - }); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Failed to start CallKeep call (background audio may not work)', - context: { error, roomId: 'emergency-channel' }, - }); - }); - }); - - describe('Room Disconnection with CallKeep', () => { - it('should end CallKeep call on room disconnection (iOS)', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // First set up a connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).toHaveBeenCalled(); - expect(mockCallKeepService.endCall).toHaveBeenCalled(); - }); - - it('should not end CallKeep call on Android', async () => { - require('react-native').Platform.OS = 'android'; - const { result } = renderHook(() => useLiveKitCallStore()); - - // First set up a connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).toHaveBeenCalled(); - expect(mockCallKeepService.endCall).not.toHaveBeenCalled(); - }); - - it('should handle CallKeep end call errors gracefully', async () => { - const error = new Error('Failed to end call'); - mockCallKeepService.endCall.mockRejectedValueOnce(error); - - const { result } = renderHook(() => useLiveKitCallStore()); - - // First set up a connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Failed to end CallKeep call', - context: { error }, - }); - }); - - it('should handle disconnection when no room instance exists', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).not.toHaveBeenCalled(); - expect(mockCallKeepService.endCall).not.toHaveBeenCalled(); - }); - }); - - describe('Connection State Changes with CallKeep', () => { - it('should end CallKeep call on connection lost (iOS)', async () => { - require('react-native').Platform.OS = 'ios'; - - // Mock the room event listener - let connectionStateListener: Function | null = null; - mockRoom.on.mockImplementation((event: any, callback: any) => { - if (event === 'connectionStateChanged') { - connectionStateListener = callback; - } - return mockRoom; - }); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(connectionStateListener).toBeDefined(); - - // Simulate disconnection - if (connectionStateListener) { - act(() => { - connectionStateListener!('disconnected'); - }); - } - - expect(mockCallKeepService.endCall).toHaveBeenCalled(); - }); - - it('should not end CallKeep call on Android disconnection', async () => { - require('react-native').Platform.OS = 'android'; - - // Mock the room event listener - let connectionStateListener: Function | null = null; - mockRoom.on.mockImplementation((event: any, callback: any) => { - if (event === 'connectionStateChanged') { - connectionStateListener = callback; - } - return mockRoom; - }); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(connectionStateListener).toBeDefined(); - - // Simulate disconnection - if (connectionStateListener) { - act(() => { - connectionStateListener!('disconnected'); - }); - } - - expect(mockCallKeepService.endCall).not.toHaveBeenCalled(); - }); - }); - - describe('Store State Management', () => { - it('should initialize with correct default state', () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - expect(result.current.availableRooms).toHaveLength(3); - expect(result.current.selectedRoomForJoining).toBeNull(); - expect(result.current.currentRoomId).toBeNull(); - expect(result.current.isConnecting).toBe(false); - expect(result.current.isConnected).toBe(false); - expect(result.current.roomInstance).toBeNull(); - expect(result.current.participants).toEqual([]); - expect(result.current.error).toBeNull(); - expect(result.current.localParticipant).toBeNull(); - }); - - it('should clear error when setting selected room', () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - act(() => { - result.current.actions.setSelectedRoomForJoining('test-room'); - }); - - expect(result.current.selectedRoomForJoining).toBe('test-room'); - expect(result.current.error).toBeNull(); - }); - - it('should clear error explicitly', () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - act(() => { - result.current.actions._clearError(); - }); - - expect(result.current.error).toBeNull(); - }); - }); - - describe('Microphone Control', () => { - it('should enable microphone when connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(true); - }); - - expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); - }); - - it('should disable microphone when connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(false); - }); - - expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); - }); - - it('should handle microphone errors', async () => { - const error = new Error('Microphone error'); - mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(error); - - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(true); - }); - - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Error setting microphone state', - context: { error, enabled: true }, - }); - expect(result.current.error).toBe('Could not change microphone state.'); - }); - - it('should handle microphone control when not connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(true); - }); - - expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); - }); - }); - - describe('Connection Prevention', () => { - it('should prevent connection when already connecting', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set connecting state - act(() => { - result.current.actions._setIsConnecting(true); - }); - - // First connection attempt should succeed - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - // Second connection attempt should be prevented - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Connection attempt while already connecting or connected', - context: { - roomId: 'test-room', - participantIdentity: 'test-participant', - isConnecting: true, - isConnected: false - }, - }); - }); - - it('should prevent connection when already connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set connected state - act(() => { - result.current.actions._setIsConnected(true); - result.current.actions._setRoomInstance(mockRoom); - }); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Connection attempt while already connecting or connected', - context: { - roomId: 'test-room', - participantIdentity: 'test-participant', - isConnecting: false, - isConnected: true - }, - }); - }); - }); - - describe('Error Handling', () => { - describe('Error Handling', () => { - it('should handle room initialization errors', async () => { - // Make the Room constructor throw an error - MockedRoom.mockImplementationOnce(() => { - throw new Error('Failed to initialize room'); - }); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Failed to connect to LiveKit room', - context: { error: expect.any(Error), roomId: 'test-room', participantIdentity: 'test-participant' }, - }); - expect(result.current.error).toBe('Failed to initialize room'); - expect(result.current.isConnecting).toBe(false); - expect(result.current.isConnected).toBe(false); - }); - - it('should handle basic error state management', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Test basic error clearing functionality since token fetching isn't implemented - act(() => { - // Set an error state and then clear it - result.current.actions._clearError(); - }); - - expect(result.current.error).toBeNull(); - }); - }); - - it('should handle basic error state management', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Test basic error clearing functionality since token fetching isn't implemented - act(() => { - // Set an error state and then clear it - result.current.actions._clearError(); - }); - - expect(result.current.error).toBeNull(); - }); - }); -}); diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts deleted file mode 100644 index 55c8a6a5..00000000 --- a/src/features/livekit-call/store/useLiveKitCallStore.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { ConnectionState, type LocalParticipant, type Participant, type RemoteParticipant, Room, type RoomConnectOptions, RoomEvent, type RoomOptions } from 'livekit-client'; // livekit-react-native re-exports these -import { Platform } from 'react-native'; -import { create } from 'zustand'; - -import { logger } from '../../../lib/logging'; -import { bluetoothAudioService } from '../../../services/bluetooth-audio.service'; -import { callKeepService } from '../../../services/callkeep.service'; - -export interface RoomInfo { - id: string; - name: string; -} - -interface LiveKitCallState { - availableRooms: RoomInfo[]; - selectedRoomForJoining: string | null; - currentRoomId: string | null; - isConnecting: boolean; - isConnected: boolean; - roomInstance: Room | null; - participants: Participant[]; // Includes local participant - error: string | null; - localParticipant: LocalParticipant | null; - - actions: { - setSelectedRoomForJoining: (roomId: string | null) => void; - connectToRoom: (roomId: string, participantIdentity: string) => Promise; - disconnectFromRoom: () => Promise; - setMicrophoneEnabled: (enabled: boolean) => Promise; - // Internal actions - not typically called directly from UI - _setRoomInstance: (room: Room | null) => void; - _setIsConnected: (isConnected: boolean) => void; - _setIsConnecting: (isConnecting: boolean) => void; - _addParticipant: (participant: Participant) => void; - _removeParticipant: (participantId: string) => void; - _updateParticipants: () => void; - _clearError: () => void; - }; -} - -const initialRooms: RoomInfo[] = [ - { id: 'general-chat', name: 'General Chat' }, - { id: 'dev-team-sync', name: 'Dev Team Sync' }, - { id: 'product-updates', name: 'Product Updates' }, -]; - -export const useLiveKitCallStore = create((set, get) => ({ - availableRooms: initialRooms, - selectedRoomForJoining: null, - currentRoomId: null, - isConnecting: false, - isConnected: false, - roomInstance: null, - participants: [], - error: null, - localParticipant: null, - - actions: { - setSelectedRoomForJoining: (roomId) => set({ selectedRoomForJoining: roomId, error: null }), - _clearError: () => set({ error: null }), - - connectToRoom: async (roomId, participantIdentity) => { - bluetoothAudioService.ensurePttInputMonitoring('useLiveKitCallStore connectToRoom start'); - - if (get().isConnecting || get().isConnected) { - logger.warn({ - message: 'Connection attempt while already connecting or connected', - context: { roomId, participantIdentity, isConnecting: get().isConnecting, isConnected: get().isConnected }, - }); - return; - } - - set({ isConnecting: true, error: null, selectedRoomForJoining: roomId }); - - // Register CallKeep mute callback for iOS - if (Platform.OS === 'ios') { - callKeepService.setMuteStateCallback((muted: boolean) => { - const currentState = get(); - if (currentState.isConnected && currentState.roomInstance) { - currentState.actions.setMicrophoneEnabled(!muted); - } - }); - } - - try { - const roomOptions: RoomOptions = { - adaptiveStream: true, - dynacast: true, // Enable dynamic simulcast - }; - const newRoom = new Room(roomOptions); - - newRoom - .on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => { - logger.info({ - message: 'LiveKit Connection State Changed', - context: { state, roomId }, - }); - if (state === ConnectionState.Connected) { - set({ - isConnected: true, - isConnecting: false, - currentRoomId: roomId, - roomInstance: newRoom, - localParticipant: newRoom.localParticipant, - error: null, - }); - get().actions._updateParticipants(); // Initial participant list - newRoom.localParticipant.setMicrophoneEnabled(true).catch((error) => { - logger.warn({ message: 'Failed to enable microphone on connect', context: { error, roomId } }); - }); - newRoom.localParticipant.setCameraEnabled(false).catch((error) => { - logger.warn({ message: 'Failed to disable camera on connect', context: { error, roomId } }); - }); // No video - - bluetoothAudioService.ensurePttInputMonitoring('useLiveKitCallStore connected'); - - // Start CallKeep call for iOS background audio support - if (Platform.OS === 'ios') { - callKeepService - .startCall(roomId) - .then((callUUID) => { - logger.info({ - message: 'CallKeep call started successfully', - context: { callUUID, roomId }, - }); - }) - .catch((error) => { - logger.warn({ - message: 'Failed to start CallKeep call (background audio may not work)', - context: { error, roomId }, - }); - }); - } - } else if (state === ConnectionState.Disconnected) { - set({ - isConnected: false, - isConnecting: false, - currentRoomId: null, - roomInstance: null, - participants: [], - localParticipant: null, - // Keep error if there was one leading to disconnect - }); - - // End CallKeep call for iOS when disconnected - if (Platform.OS === 'ios') { - callKeepService - .endCall() - .then(() => { - logger.info({ - message: 'CallKeep call ended on disconnect', - context: { roomId }, - }); - }) - .catch((error) => { - logger.warn({ - message: 'Failed to end CallKeep call on disconnect', - context: { error, roomId }, - }); - }); - } - } else if (state === ConnectionState.Connecting) { - set({ isConnecting: true }); - } else if (state === ConnectionState.Reconnecting) { - set({ isConnecting: true, error: 'Connection lost, attempting to reconnect...' }); - } - }) - .on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => { - logger.info({ - message: 'Participant connected', - context: { participantIdentity: participant.identity, roomId }, - }); - get().actions._addParticipant(participant); - }) - .on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => { - logger.info({ - message: 'Participant disconnected', - context: { participantIdentity: participant.identity, roomId }, - }); - get().actions._removeParticipant(participant.sid); - }) - .on(RoomEvent.LocalTrackPublished, (trackPublication, participant) => { - logger.debug({ - message: 'Local track published', - context: { trackKind: trackPublication.kind, participantIdentity: participant.identity, roomId }, - }); - get().actions._updateParticipants(); // Ensure local participant updates reflect - }) - .on(RoomEvent.LocalTrackUnpublished, (trackPublication, participant) => { - logger.debug({ - message: 'Local track unpublished', - context: { trackKind: trackPublication.kind, participantIdentity: participant.identity, roomId }, - }); - get().actions._updateParticipants(); - }) - .on(RoomEvent.TrackSubscribed, (track, publication, participant) => { - logger.debug({ - message: 'Subscribed to track', - context: { - trackSid: publication.trackSid, - trackKind: track.kind, - participantIdentity: participant.identity, - roomId, - }, - }); - // Audio tracks are usually auto-played. No specific handling needed here for audio only. - }) - .on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { - logger.debug({ - message: 'Unsubscribed from track', - context: { - trackSid: publication.trackSid, - participantIdentity: participant.identity, - roomId, - }, - }); - }) - .on(RoomEvent.Disconnected, (reason) => { - logger.info({ - message: 'Disconnected from room', - context: { reason: String(reason), roomId }, - }); - // DisconnectReason is an enum of strings like 'CLIENT_INITIATED', etc. - const reasonMsg = reason ? String(reason) : 'Unknown reason'; - set({ error: `Disconnected: ${reasonMsg}` }); - // Full cleanup is also handled by ConnectionStateChanged to Disconnected - }); - - const connectOptions: RoomConnectOptions = { - autoSubscribe: true, // Subscribe to all tracks by default - }; - - //await newRoom.connect(LIVEKIT_URL, token, connectOptions); - // Connection success is handled by the ConnectionStateChanged event listener - } catch (err: any) { - logger.error({ - message: 'Failed to connect to LiveKit room', - context: { error: err, roomId, participantIdentity }, - }); - set({ - error: err.message || 'An unknown error occurred during connection.', - isConnecting: false, - isConnected: false, - roomInstance: null, - currentRoomId: null, - }); - // Clean up any partially initialized room - if (get().roomInstance) { - await get().roomInstance?.disconnect(); - set({ roomInstance: null }); - } - } - }, - - disconnectFromRoom: async () => { - const room = get().roomInstance; - if (room) { - logger.info({ - message: 'Disconnecting from room', - context: { roomName: room.name, currentRoomId: get().currentRoomId }, - }); - await room.disconnect(); - // State updates (isConnected, currentRoomId, etc.) are handled by RoomEvent.Disconnected - // and ConnectionState.Disconnected listeners. - set({ - roomInstance: null, - currentRoomId: null, - isConnected: false, - isConnecting: false, - participants: [], - localParticipant: null, - selectedRoomForJoining: null, // Reset selection - }); - - // End CallKeep call for iOS - if (Platform.OS === 'ios') { - try { - await callKeepService.endCall(); - // Clear the mute state callback - callKeepService.setMuteStateCallback(null); - logger.info({ - message: 'CallKeep call ended successfully', - }); - } catch (error) { - logger.warn({ - message: 'Failed to end CallKeep call', - context: { error }, - }); - } - } - } - }, - - setMicrophoneEnabled: async (enabled: boolean) => { - const room = get().roomInstance; - if (room && room.localParticipant) { - try { - await room.localParticipant.setMicrophoneEnabled(enabled); - get().actions._updateParticipants(); // reflect change in participant state - logger.info({ - message: 'Microphone state changed', - context: { enabled, participantIdentity: room.localParticipant.identity }, - }); - } catch (e) { - logger.error({ - message: 'Error setting microphone state', - context: { error: e, enabled }, - }); - set({ error: 'Could not change microphone state.' }); - } - } - }, - - _setRoomInstance: (room) => set({ roomInstance: room }), - _setIsConnected: (isConnected) => set({ isConnected }), - _setIsConnecting: (isConnecting) => set({ isConnecting }), - - _addParticipant: (participant) => { - set((state) => { - if (!state.participants.find((p) => p.sid === participant.sid)) { - return { participants: [...state.participants, participant] }; - } - return {}; // No change - }); - }, - _removeParticipant: (participantSid) => { - set((state) => ({ - participants: state.participants.filter((p) => p.sid !== participantSid), - })); - }, - _updateParticipants: () => { - const room = get().roomInstance; - if (room) { - // Use room.remoteParticipants which is Map - const remoteParticipantsArray: RemoteParticipant[] = Array.from(room.remoteParticipants.values()); - const allParticipants: Participant[] = [room.localParticipant, ...remoteParticipantsArray]; - set({ - participants: allParticipants, - localParticipant: room.localParticipant, - }); - } - }, - }, -})); - -// Selector for convenience -export const useLiveKit = useLiveKitCallStore; - -// Example on how to listen to participant's microphone status -// This would typically be in a component that renders a participant -/* -const { isMuted } = useParticipantTrack({ - participant: remoteParticipant, - source: Track.Source.Microphone, - publication: remoteParticipant.getTrackPublication(Track.Source.Microphone), -}); -*/ diff --git a/src/lib/__tests__/readable-text-color.test.ts b/src/lib/__tests__/readable-text-color.test.ts new file mode 100644 index 00000000..8db01f7e --- /dev/null +++ b/src/lib/__tests__/readable-text-color.test.ts @@ -0,0 +1,61 @@ +import { readableColors, readableTextColor } from '../utils'; + +describe('readableTextColor', () => { + it('uses white on dark backgrounds', () => { + expect(readableTextColor('#000000')).toBe('#FFFFFF'); + expect(readableTextColor('#262626')).toBe('#FFFFFF'); + expect(readableTextColor('#475569')).toBe('#FFFFFF'); + }); + + it('uses black on light backgrounds', () => { + expect(readableTextColor('#ffffff')).toBe('#000000'); + expect(readableTextColor('#f8ac59')).toBe('#000000'); + }); + + it('uses black on mid-tone status colours where white would fall below 3:1', () => { + expect(readableTextColor('#23c6c8')).toBe('#000000'); + expect(readableTextColor('#449d44')).toBe('#000000'); + expect(readableTextColor('#ED5565')).toBe('#000000'); + }); + + it('accepts 3-digit hex and a missing #', () => { + expect(readableTextColor('#fff')).toBe('#000000'); + expect(readableTextColor('000')).toBe('#FFFFFF'); + }); + + it('reads opaque rgb() and rgba() colours', () => { + expect(readableTextColor('rgb(0,0,0)')).toBe('#FFFFFF'); + expect(readableTextColor('rgb(200, 0, 0)')).toBe('#FFFFFF'); + expect(readableTextColor('RGB(255, 255, 0)')).toBe('#000000'); + expect(readableTextColor('rgba(30, 58, 138, 1)')).toBe('#FFFFFF'); + expect(readableTextColor(' rgba(255,255,255,1.0) ')).toBe('#000000'); + }); + + it('returns undefined for values whose contrast it cannot work out', () => { + expect(readableTextColor('')).toBeUndefined(); + expect(readableTextColor('label-default')).toBeUndefined(); + expect(readableTextColor('red')).toBeUndefined(); + expect(readableTextColor('hsl(0, 100%, 50%)')).toBeUndefined(); + expect(readableTextColor('rgba(0, 0, 0, 0.5)')).toBeUndefined(); + expect(readableTextColor('rgb(256, 0, 0)')).toBeUndefined(); + expect(readableTextColor('#12345')).toBeUndefined(); + }); +}); + +describe('readableColors', () => { + it('keeps a colour it can read and pairs it with the readable text colour', () => { + expect(readableColors('#1e3a8a')).toEqual({ backgroundColor: '#1e3a8a', textColor: '#FFFFFF' }); + expect(readableColors('rgb(255, 255, 0)')).toEqual({ backgroundColor: 'rgb(255, 255, 0)', textColor: '#000000' }); + }); + + it('adds the # React Native needs to draw bare hex', () => { + expect(readableColors(' ff0000 ')).toEqual({ backgroundColor: '#ff0000', textColor: '#000000' }); + }); + + it('swaps a colour it cannot read for the fallback and its text colour', () => { + expect(readableColors('hsl(0, 100%, 25%)')).toEqual({ backgroundColor: '#808080', textColor: '#000000' }); + expect(readableColors('rgba(255, 255, 255, 0.2)', '#6b7280')).toEqual({ backgroundColor: '#6b7280', textColor: '#FFFFFF' }); + expect(readableColors(undefined, '#6b7280')).toEqual({ backgroundColor: '#6b7280', textColor: '#FFFFFF' }); + expect(readableColors(null)).toEqual({ backgroundColor: '#808080', textColor: '#000000' }); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a9b66c39..302c6357 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -63,6 +63,7 @@ export function onSortOptions(a: any, b: any) { return 0; } +/** For black-or-white text on a coloured background prefer `readableTextColor`; the `bw` cutoff here picks white on many mid-tones. */ export function invertColor(hex: string, bw: boolean): string { if (hex.indexOf('#') === 0) { hex = hex.slice(1); @@ -89,6 +90,56 @@ export function invertColor(hex: string, bw: boolean): string { return '#' + padZero(r2, 2) + padZero(g2, 2) + padZero(b2, 2); } +/** The 0-255 [r, g, b] of a 3- or 6-digit hex colour (the # optional) or an opaque rgb()/rgba(). */ +function parseOpaqueRgb(color: string): number[] | undefined { + const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color); + if (hex) { + const digits = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1]; + return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)); + } + // A translucent colour's contrast depends on what is drawn behind it, so only an alpha of 1 counts. + const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d*\.?\d+)\s*)?\)$/i.exec(color); + if (!rgb || (rgb[4] !== undefined && Number(rgb[4]) < 1)) return undefined; + const channels = [rgb[1], rgb[2], rgb[3]].map(Number); + return channels.every((c) => c <= 255) ? channels : undefined; +} + +/** + * Black or white, whichever has the higher WCAG contrast against `color`. Returns undefined for a + * value that isn't a 3- or 6-digit hex colour or an opaque rgb()/rgba(), so callers can fall back; + * `readableColors` does that by swapping in a known background. + * Unlike `invertColor(hex, true)`, this picks black on mid-tones such as orange, teal and green. + */ +export function readableTextColor(color: string): '#000000' | '#FFFFFF' | undefined { + const channels = parseOpaqueRgb(color.trim()); + if (!channels) return undefined; + const [r, g, b] = channels.map((channel) => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + // Contrast with black is (L + 0.05) / 0.05 and with white 1.05 / (L + 0.05); they are equal at L ≈ 0.179. + return luminance > 0.179 ? '#000000' : '#FFFFFF'; +} + +interface ReadableColors { + backgroundColor: string; + textColor: '#000000' | '#FFFFFF'; +} + +/** + * The background to draw for a server-supplied `color` and the black or white text that reads on it. + * A colour `readableTextColor` can't read (named, hsl(), translucent, invalid) is replaced by `fallback`, + * so text never sits in a fixed colour on a background it wasn't picked for. + */ +export function readableColors(color: string | null | undefined, fallback = '#808080'): ReadableColors { + const value = color?.trim() ?? ''; + const textColor = readableTextColor(value); + if (!textColor) return { backgroundColor: fallback, textColor: readableTextColor(fallback) ?? '#000000' }; + // React Native only draws a hex colour that has its leading #. + return { backgroundColor: /^[0-9a-f]+$/i.test(value) ? `#${value}` : value, textColor }; +} + export function padZero(str: string, len: number): string { len = len || 2; const zeros = new Array(len).join('0'); diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 867e7898..9f785258 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -85,7 +85,6 @@ jest.mock('@/stores/app/bluetooth-audio-store', () => ({ isConnecting: false, availableDevices: [], connectionError: null, - isAudioRoutingActive: false, }, useBluetoothAudioStore: { setState: jest.fn(), @@ -544,7 +543,6 @@ describe('app-reset.service', () => { isConnecting: false, availableDevices: [], connectionError: null, - isAudioRoutingActive: false, }); }); diff --git a/src/services/__tests__/bluetooth-audio-b01inrico.test.ts b/src/services/__tests__/bluetooth-audio-b01inrico.test.ts index c21166e2..158f349a 100644 --- a/src/services/__tests__/bluetooth-audio-b01inrico.test.ts +++ b/src/services/__tests__/bluetooth-audio-b01inrico.test.ts @@ -22,12 +22,9 @@ jest.mock('@/stores/app/bluetooth-audio-store', () => ({ setIsConnecting: jest.fn(), setConnectionError: jest.fn(), clearConnectionError: jest.fn(), - addButtonEvent: jest.fn(), - setLastButtonAction: jest.fn(), setAvailableAudioDevices: jest.fn(), setSelectedMicrophone: jest.fn(), setSelectedSpeaker: jest.fn(), - setAudioRoutingActive: jest.fn(), availableDevices: [], connectedDevice: null, preferredDevice: null, @@ -237,8 +234,6 @@ describe('BluetoothAudioService - B01 Inrico Button Parsing', () => { describe('handleB01InricoButtonEvent', () => { it('should process base64 encoded button data', () => { - const mockAddButtonEvent = jest.fn(); - const mockSetLastButtonAction = jest.fn(); const mockProcessButtonEvent = jest.fn(); // Mock the processButtonEvent method diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts index fc491c2d..b30a8b2d 100644 --- a/src/services/__tests__/bluetooth-audio.service.test.ts +++ b/src/services/__tests__/bluetooth-audio.service.test.ts @@ -63,19 +63,6 @@ jest.mock('@/services/audio.service', () => ({ }, })); -jest.mock('@/features/livekit-call/store/useLiveKitCallStore', () => ({ - useLiveKitCallStore: { - getState: jest.fn(() => ({ - isConnected: false, - roomInstance: null, - localParticipant: null, - actions: { - setMicrophoneEnabled: jest.fn(), - }, - })), - }, -})); - jest.mock('@/stores/app/livekit-store', () => { const actions = { toggleMicrophone: jest.fn(), @@ -225,27 +212,6 @@ describe('BluetoothAudioService Refactoring', () => { await service.setMicrophoneEnabled(false); expect(storeMock.setMicrophoneEnabled).toHaveBeenCalledWith(false); }); - - it('should fall back to legacy livekitStore when feature store is connected but has no local participant', async () => { - const service = bluetoothAudioService as any; - const featureStore = require('@/features/livekit-call/store/useLiveKitCallStore').useLiveKitCallStore; - const mockFeatureSetMicrophoneEnabled = jest.fn(); - - featureStore.getState.mockReturnValue({ - isConnected: true, - roomInstance: null, - localParticipant: null, - actions: { - setMicrophoneEnabled: mockFeatureSetMicrophoneEnabled, - }, - }); - - await service.setMicrophoneEnabled(true); - - const legacyStore = require('@/stores/app/livekit-store').useLiveKitStore.getState(); - expect(mockFeatureSetMicrophoneEnabled).not.toHaveBeenCalled(); - expect(legacyStore.setMicrophoneEnabled).toHaveBeenCalledWith(true); - }); }); describe('Capability parsing strictness', () => { diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index a66acc96..4405bc8d 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -7,13 +7,7 @@ import { logger } from '@/lib/logging'; import { audioService } from '@/services/audio.service'; import { callKeepService } from '@/services/callkeep.service'; import { type AudioButtonEvent, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; -// Lazy getters to avoid circular dependencies with livekit-store and useLiveKitCallStore -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const getLiveKitCallStore = (): any => { - // Using import() for lazy loading to avoid circular dependencies - // eslint-disable-next-line @typescript-eslint/no-require-imports - return require('@/features/livekit-call/store/useLiveKitCallStore').useLiveKitCallStore; -}; +// Lazy getter to avoid a circular dependency with livekit-store // eslint-disable-next-line @typescript-eslint/no-explicit-any const getLiveKitStore = (): any => { // Using import() for lazy loading to avoid circular dependencies @@ -2063,8 +2057,6 @@ class BluetoothAudioService { context: { buttonEvent }, }); - useBluetoothAudioStore.getState().addButtonEvent(buttonEvent); - // Handle mute/unmute events if (buttonEvent.button === 'mute') { this.handleMuteToggle(); @@ -2119,11 +2111,6 @@ class BluetoothAudioService { context: { direction }, }); - useBluetoothAudioStore.getState().setLastButtonAction({ - action: direction, - timestamp: Date.now(), - }); - // Add volume control logic here if needed // This would typically involve native audio controls } @@ -2135,30 +2122,6 @@ class BluetoothAudioService { private async handleMuteToggle(): Promise { try { - const featureLiveKitState = getLiveKitCallStore().getState(); - const featureRoom = featureLiveKitState.roomInstance; - const featureLocalParticipant = featureRoom?.localParticipant ?? featureLiveKitState.localParticipant; - - if (featureLiveKitState.isConnected && featureRoom && featureLocalParticipant) { - const nextMicEnabled = !featureLocalParticipant.isMicrophoneEnabled; - await featureLiveKitState.actions.setMicrophoneEnabled(nextMicEnabled); - - const updatedState = getLiveKitCallStore().getState(); - const updatedParticipant = updatedState.roomInstance?.localParticipant ?? updatedState.localParticipant; - - if (updatedParticipant && updatedParticipant.isMicrophoneEnabled === nextMicEnabled) { - return; - } - - logger.warn({ - message: 'Feature store microphone toggle did not apply, falling back to legacy store', - context: { - nextMicEnabled, - hasUpdatedParticipant: Boolean(updatedParticipant), - }, - }); - } - await getLiveKitStore().getState().toggleMicrophone(); } catch (error) { logger.error({ @@ -2251,45 +2214,17 @@ class BluetoothAudioService { private async applyMicrophoneEnabled(enabled: boolean): Promise { try { - const featureLiveKitState = getLiveKitCallStore().getState(); - const featureRoom = featureLiveKitState.roomInstance; - const legacyLiveKitState = getLiveKitStore().getState(); - const hasFeatureRoom = Boolean(featureLiveKitState.isConnected && featureRoom?.localParticipant); - const hasLegacyRoom = Boolean(legacyLiveKitState.currentRoom?.localParticipant); - const stillConnecting = featureLiveKitState.isConnecting || legacyLiveKitState.isConnecting; - - if (!hasFeatureRoom && !hasLegacyRoom && stillConnecting) { + const liveKitState = getLiveKitStore().getState(); + const hasRoom = Boolean(liveKitState.currentRoom?.localParticipant); + + if (!hasRoom && liveKitState.isConnecting) { this.scheduleMicApplyRetry(enabled); return; } this.clearMicApplyRetry(); - if (featureLiveKitState.isConnected && featureRoom?.localParticipant) { - const currentFeatureMicEnabled = featureRoom.localParticipant.isMicrophoneEnabled; - if (currentFeatureMicEnabled === enabled) { - return; - } - - await featureLiveKitState.actions.setMicrophoneEnabled(enabled); - - const updatedState = getLiveKitCallStore().getState(); - const updatedParticipant = updatedState.roomInstance?.localParticipant ?? updatedState.localParticipant; - - if (updatedParticipant && updatedParticipant.isMicrophoneEnabled === enabled) { - return; - } - - logger.warn({ - message: 'Feature store setMicrophoneEnabled did not apply, falling back to legacy store', - context: { - enabled, - hasUpdatedParticipant: Boolean(updatedParticipant), - }, - }); - } - - await getLiveKitStore().getState().setMicrophoneEnabled(enabled); + await liveKitState.setMicrophoneEnabled(enabled); } catch (error) { logger.error({ message: 'Failed to set microphone via Bluetooth PTT button', @@ -2336,8 +2271,6 @@ class BluetoothAudioService { // 2. Configure LiveKit's audio context to use the Bluetooth device as input/output // 3. Set audio session category and options appropriately - bluetoothStore.setAudioRoutingActive(true); - // Notify LiveKit store about audio device change // This would trigger any necessary audio context updates } catch (error) { @@ -2373,8 +2306,6 @@ class BluetoothAudioService { bluetoothStore.setSelectedSpeaker(defaultSpeaker); } - // Revert audio routing to default (phone speaker/microphone) - bluetoothStore.setAudioRoutingActive(false); bluetoothStore.setIsHeadsetButtonMonitoring(false); this.pttPressActive = false; this.clearPttReleaseFallback(); diff --git a/src/stores/app/__tests__/livekit-store-room-switch.test.ts b/src/stores/app/__tests__/livekit-store-room-switch.test.ts index 4a5a984a..08567490 100644 --- a/src/stores/app/__tests__/livekit-store-room-switch.test.ts +++ b/src/stores/app/__tests__/livekit-store-room-switch.test.ts @@ -127,7 +127,6 @@ jest.mock('../bluetooth-audio-store', () => ({ microphone: null, speaker: null, }, - setLastButtonAction: jest.fn(), setSelectedMicrophone: jest.fn(), setSelectedSpeaker: jest.fn(), })), diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts index 50793660..40234c34 100644 --- a/src/stores/app/bluetooth-audio-store.ts +++ b/src/stores/app/bluetooth-audio-store.ts @@ -42,11 +42,6 @@ export interface AudioButtonEvent { timestamp: number; } -export interface ButtonAction { - action: 'mute' | 'unmute' | 'volume_up' | 'volume_down'; - timestamp: number; -} - export interface AudioDeviceInfo { id: string; name: string; @@ -77,11 +72,6 @@ interface BluetoothAudioState { // Connection status connectionError: string | null; - isAudioRoutingActive: boolean; - - // Button events - buttonEvents: AudioButtonEvent[]; - lastButtonAction: ButtonAction | null; // Media button PTT settings (for AirPods/earbuds) mediaButtonPTTSettings: MediaButtonPTTSettings; @@ -110,14 +100,6 @@ interface BluetoothAudioState { setConnectionError: (error: string | null) => void; clearConnectionError: () => void; - // Audio routing - setAudioRoutingActive: (active: boolean) => void; - - // Button events - addButtonEvent: (event: AudioButtonEvent) => void; - clearButtonEvents: () => void; - setLastButtonAction: (action: ButtonAction | null) => void; - // Media button PTT settings (for AirPods/earbuds) setMediaButtonPTTSettings: (settings: Partial) => void; setMediaButtonPTTEnabled: (enabled: boolean) => void; @@ -141,10 +123,6 @@ export const INITIAL_STATE: Omit< | 'updateAudioDeviceAvailability' | 'setConnectionError' | 'clearConnectionError' - | 'setAudioRoutingActive' - | 'addButtonEvent' - | 'clearButtonEvents' - | 'setLastButtonAction' | 'setMediaButtonPTTSettings' | 'setMediaButtonPTTEnabled' | 'setIsHeadsetButtonMonitoring' @@ -165,9 +143,6 @@ export const INITIAL_STATE: Omit< speaker: { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true }, }, connectionError: null, - isAudioRoutingActive: false, - buttonEvents: [], - lastButtonAction: null, mediaButtonPTTSettings: createDefaultPTTSettings(), }; @@ -231,21 +206,6 @@ export const useBluetoothAudioStore = create((set, get) => setConnectionError: (error) => set({ connectionError: error }), clearConnectionError: () => set({ connectionError: null }), - // Audio routing - setAudioRoutingActive: (active) => set({ isAudioRoutingActive: active }), - - // Button events - addButtonEvent: (event) => { - const { buttonEvents } = get(); - const maxEvents = 50; // Keep only the last 50 events - const updatedEvents = [event, ...buttonEvents].slice(0, maxEvents); - set({ buttonEvents: updatedEvents }); - }, - - clearButtonEvents: () => set({ buttonEvents: [] }), - - setLastButtonAction: (action) => set({ lastButtonAction: action }), - // Preferred device management setPreferredDevice: (device) => set({ preferredDevice: device }), diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0f6660d5..ff91d9ea 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -331,11 +331,6 @@ export const useLiveKitStore = create((set, get) => ({ context: { enabled }, }); - useBluetoothAudioStore.getState().setLastButtonAction({ - action: enabled ? 'unmute' : 'mute', - timestamp: Date.now(), - }); - if (enabled) { await audioService.playStartTransmittingSound(); } else { diff --git a/theme-tokens.css b/theme-tokens.css index 8bbcad96..f2066c43 100644 --- a/theme-tokens.css +++ b/theme-tokens.css @@ -145,6 +145,8 @@ @media (prefers-color-scheme: dark) { :root { + /* Primary is the light ramp reversed, like every other scale: 600+ must be light, since + text-primary-600, focus borders and checked controls sit on dark surfaces here. */ --color-primary-0: 15 23 42; --color-primary-50: 30 41 59; --color-primary-100: 39 49 66; @@ -152,11 +154,11 @@ --color-primary-300: 51 65 85; --color-primary-400: 71 85 105; --color-primary-500: 100 116 139; - --color-primary-600: 71 85 105; - --color-primary-700: 51 65 85; - --color-primary-800: 45 55 72; - --color-primary-900: 39 49 66; - --color-primary-950: 30 41 59; + --color-primary-600: 148 163 184; + --color-primary-700: 203 213 225; + --color-primary-800: 226 232 240; + --color-primary-900: 241 245 249; + --color-primary-950: 248 250 252; --color-secondary-0: 11 12 12; --color-secondary-50: 24 23 23; --color-secondary-100: 39 38 38; @@ -284,11 +286,11 @@ --color-primary-300: 51 65 85; --color-primary-400: 71 85 105; --color-primary-500: 100 116 139; - --color-primary-600: 71 85 105; - --color-primary-700: 51 65 85; - --color-primary-800: 45 55 72; - --color-primary-900: 39 49 66; - --color-primary-950: 30 41 59; + --color-primary-600: 148 163 184; + --color-primary-700: 203 213 225; + --color-primary-800: 226 232 240; + --color-primary-900: 241 245 249; + --color-primary-950: 248 250 252; --color-secondary-0: 11 12 12; --color-secondary-50: 24 23 23; --color-secondary-100: 39 38 38;