From 9caf034d4f27e7e380f60b12efe5c3be9354e31b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 27 Jan 2026 08:37:18 -0800 Subject: [PATCH 1/3] RR-T40 Bug fixes --- app.config.ts | 7 + src/components/personnel/personnel-card.tsx | 187 +++++++++++------- .../personnel/personnel-details-sheet.tsx | 55 +++++- src/components/shifts/shift-details-sheet.tsx | 2 +- 4 files changed, 173 insertions(+), 78 deletions(-) diff --git a/app.config.ts b/app.config.ts index 0f9ad83..e2d8ca5 100644 --- a/app.config.ts +++ b/app.config.ts @@ -264,6 +264,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ microphonePermission: 'Allow Resgrid Responder to access the microphone for audio input used in PTT and calls.', }, ], + [ + 'expo-image-picker', + { + cameraPermission: 'Allow Resgrid Responder to access the camera to take photos for call documentation.', + photosPermission: 'Allow Resgrid Responder to access your photos library to attach images to calls.', + }, + ], 'react-native-ble-manager', 'expo-secure-store', '@livekit/react-native-expo-plugin', diff --git a/src/components/personnel/personnel-card.tsx b/src/components/personnel/personnel-card.tsx index 63dde46..58a8d0b 100644 --- a/src/components/personnel/personnel-card.tsx +++ b/src/components/personnel/personnel-card.tsx @@ -1,16 +1,42 @@ import { Mail, Phone, Users } from 'lucide-react-native'; +import * as React from 'react'; import { Pressable } from 'react-native'; -import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, getAvatarUrl, parseDateISOString } from '@/lib/utils'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; import { useSecurityStore } from '@/stores/security/store'; +import { Avatar, AvatarFallbackText, AvatarImage } from '../ui/avatar'; import { Badge } from '../ui/badge'; import { Box } from '../ui/box'; import { HStack } from '../ui/hstack'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; +/** + * Generates a deterministic color from a string (user ID or name) + * Returns a hex color string + */ +function getColorFromString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + // Generate HSL color with good saturation and lightness for visibility + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 65%, 45%)`; +} + +/** + * Gets initials from first and last name + */ +function getInitials(firstName?: string, lastName?: string): string { + const first = firstName?.trim()?.[0]?.toUpperCase() || ''; + const last = lastName?.trim()?.[0]?.toUpperCase() || ''; + return first + last || '?'; +} + interface PersonnelCardProps { personnel: PersonnelInfoResultData; onPress: (id: string) => void; @@ -19,90 +45,107 @@ interface PersonnelCardProps { export const PersonnelCard: React.FC = ({ personnel, onPress }) => { const fullName = `${personnel.FirstName} ${personnel.LastName}`.trim(); const { canUserViewPII } = useSecurityStore(); + const [imageError, setImageError] = React.useState(false); + + const avatarUrl = getAvatarUrl(personnel.UserId); + const initials = getInitials(personnel.FirstName, personnel.LastName); + const fallbackColor = getColorFromString(personnel.UserId || fullName); return ( onPress(personnel.UserId)} testID={`personnel-card-${personnel.UserId}`}> - - {fullName} - - {/* Contact Information */} - {canUserViewPII ? ( - - {personnel.EmailAddress ? ( - - - - {personnel.EmailAddress} - - - ) : null} + + {/* Profile Avatar */} + + {!imageError && ( + setImageError(true)} + /> + )} + {initials} + - {personnel.MobilePhone ? ( - - - - {personnel.MobilePhone} - - - ) : null} + + {fullName} + {/* Contact Information */} + {canUserViewPII ? ( + + {personnel.EmailAddress ? ( + + + + {personnel.EmailAddress} + + + ) : null} - {personnel.GroupName ? ( - - - - {personnel.GroupName} - - - ) : null} - - ) : personnel.GroupName ? ( - - {personnel.GroupName ? ( - - - - {personnel.GroupName} - - - ) : null} - - ) : null} - - {/* Status and Staffing Badges */} - - {personnel.Status ? ( - - {personnel.Status} - - ) : null} + {personnel.MobilePhone ? ( + + + + {personnel.MobilePhone} + + + ) : null} - {personnel.Staffing ? ( - - {personnel.Staffing} - + {personnel.GroupName ? ( + + + + {personnel.GroupName} + + + ) : null} + + ) : personnel.GroupName ? ( + + {personnel.GroupName ? ( + + + + {personnel.GroupName} + + + ) : null} + ) : null} - - - {/* Roles */} - {personnel.Roles && personnel.Roles.length > 0 ? ( - - {personnel.Roles.slice(0, 3).map((role, index) => ( - - {role} + + {/* Status and Staffing Badges */} + + {personnel.Status ? ( + + {personnel.Status} - ))} - {personnel.Roles.length > 3 ? ( - - +{personnel.Roles.length - 3} + ) : null} + + {personnel.Staffing ? ( + + {personnel.Staffing} ) : null} - ) : null} - {/* Last Status Update */} - {personnel.StatusTimestamp ? Status: {formatDateForDisplay(parseDateISOString(personnel.StatusTimestamp), 'yyyy-MM-dd HH:mm Z')} : null} - + {/* Roles */} + {personnel.Roles && personnel.Roles.length > 0 ? ( + + {personnel.Roles.slice(0, 3).map((role, index) => ( + + {role} + + ))} + {personnel.Roles.length > 3 ? ( + + +{personnel.Roles.length - 3} + + ) : null} + + ) : null} + + {/* Last Status Update */} + {personnel.StatusTimestamp ? Status: {formatDateForDisplay(parseDateISOString(personnel.StatusTimestamp), 'yyyy-MM-dd HH:mm Z')} : null} + + ); diff --git a/src/components/personnel/personnel-details-sheet.tsx b/src/components/personnel/personnel-details-sheet.tsx index 8cfc361..17fe831 100644 --- a/src/components/personnel/personnel-details-sheet.tsx +++ b/src/components/personnel/personnel-details-sheet.tsx @@ -1,14 +1,15 @@ import { Calendar, IdCard, Mail, Phone, Tag, Users, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; import { useAnalytics } from '@/hooks/use-analytics'; -import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, getAvatarUrl, parseDateISOString } from '@/lib/utils'; import { usePersonnelStore } from '@/stores/personnel/store'; import { useSecurityStore } from '@/stores/security/store'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; +import { Avatar, AvatarFallbackText, AvatarImage } from '../ui/avatar'; import { Badge } from '../ui/badge'; import { Box } from '../ui/box'; import { Button } from '../ui/button'; @@ -18,6 +19,29 @@ import { HStack } from '../ui/hstack'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; +/** + * Generates a deterministic color from a string (user ID or name) + * Returns an HSL color string + */ +function getColorFromString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 65%, 45%)`; +} + +/** + * Gets initials from first and last name + */ +function getInitials(firstName?: string, lastName?: string): string { + const first = firstName?.trim()?.[0]?.toUpperCase() || ''; + const last = lastName?.trim()?.[0]?.toUpperCase() || ''; + return first + last || '?'; +} + /** * Safely formats a timestamp string with error handling. * Returns formatted date string on success, empty string on failure. @@ -39,9 +63,15 @@ export const PersonnelDetailsSheet: React.FC = () => { const { personnel, selectedPersonnelId, isDetailsOpen, closeDetails } = usePersonnelStore(); const { canUserViewPII } = useSecurityStore(); const { trackEvent } = useAnalytics(); + const [imageError, setImageError] = useState(false); const selectedPersonnel = personnel?.find((person) => person.UserId === selectedPersonnelId); + // Reset image error state when selected personnel changes + useEffect(() => { + setImageError(false); + }, [selectedPersonnelId]); + // Cache formatted timestamps to avoid double parsing const formattedStatusTimestamp = useMemo(() => safeFormatTimestamp(selectedPersonnel?.StatusTimestamp, 'yyyy-MM-dd HH:mm Z'), [selectedPersonnel?.StatusTimestamp]); @@ -86,6 +116,9 @@ export const PersonnelDetailsSheet: React.FC = () => { if (!selectedPersonnel || !isDetailsOpen) return null; const fullName = `${selectedPersonnel.FirstName} ${selectedPersonnel.LastName}`.trim(); + const avatarUrl = getAvatarUrl(selectedPersonnel.UserId); + const initials = getInitials(selectedPersonnel.FirstName, selectedPersonnel.LastName); + const fallbackColor = getColorFromString(selectedPersonnel.UserId || fullName); return ( @@ -97,9 +130,21 @@ export const PersonnelDetailsSheet: React.FC = () => { - - {fullName} - + + {/* Profile Avatar */} + + {!imageError && ( + setImageError(true)} + /> + )} + {initials} + + + {fullName} + + diff --git a/src/components/shifts/shift-details-sheet.tsx b/src/components/shifts/shift-details-sheet.tsx index d19b9b9..ccc541e 100644 --- a/src/components/shifts/shift-details-sheet.tsx +++ b/src/components/shifts/shift-details-sheet.tsx @@ -244,7 +244,7 @@ const ShiftDetailsSheetComponent: React.FC = ({ isOpen, {/* Type Badges */} - {t('shifts.shift_type')} + {t('shifts.shift_type.label')} From 36c607b61ab6513f56756563d96c10bc5f6338a8 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 27 Jan 2026 08:54:10 -0800 Subject: [PATCH 2/3] RU-T47 PTT android voice join crash fix, profile image fix. --- src/components/personnel/personnel-card.tsx | 4 +++- .../personnel/personnel-details-sheet.tsx | 4 +++- src/stores/app/livekit-store.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/personnel/personnel-card.tsx b/src/components/personnel/personnel-card.tsx index 58a8d0b..5738ca5 100644 --- a/src/components/personnel/personnel-card.tsx +++ b/src/components/personnel/personnel-card.tsx @@ -63,7 +63,9 @@ export const PersonnelCard: React.FC = ({ personnel, onPress onError={() => setImageError(true)} /> )} - {initials} + {imageError && ( + {initials} + )} diff --git a/src/components/personnel/personnel-details-sheet.tsx b/src/components/personnel/personnel-details-sheet.tsx index 17fe831..f4eb48e 100644 --- a/src/components/personnel/personnel-details-sheet.tsx +++ b/src/components/personnel/personnel-details-sheet.tsx @@ -139,7 +139,9 @@ export const PersonnelDetailsSheet: React.FC = () => { onError={() => setImageError(true)} /> )} - {initials} + {imageError && ( + {initials} + )} {fullName} diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index dd56e33..33e9254 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -166,6 +166,26 @@ export const useLiveKitStore = create((set, get) => ({ try { const { currentRoom, voipServerWebsocketSslAddress } = get(); + // On Android 14+ (SDK 34+), we MUST have RECORD_AUDIO permission granted + // BEFORE starting a foreground service with microphone type. + // This is a security requirement - the app must be "eligible" to use the microphone. + if (Platform.OS === 'android') { + const micPermission = await getRecordingPermissionsAsync(); + if (!micPermission.granted) { + const result = await requestRecordingPermissionsAsync(); + if (!result.granted) { + logger.error({ + message: 'Cannot connect to room - microphone permission denied', + context: { platform: Platform.OS }, + }); + throw new Error('Microphone permission is required to join a voice channel'); + } + } + logger.info({ + message: 'Microphone permission verified before starting foreground service', + }); + } + // Disconnect from current room if connected if (currentRoom) { currentRoom.disconnect(); From 7c9b2f6c253c4d645999d45f557375bd37ba82eb Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 27 Jan 2026 10:02:37 -0800 Subject: [PATCH 3/3] RR-T40 PR#100 fixes --- .../personnel-details-sheet.test.tsx | 4 ++ src/components/personnel/personnel-card.tsx | 44 +++++-------------- .../personnel/personnel-details-sheet.tsx | 37 +--------------- src/lib/__tests__/utils.test.ts | 43 +++++++++++++++++- src/lib/utils.ts | 39 ++++++++++++++++ 5 files changed, 98 insertions(+), 69 deletions(-) diff --git a/src/components/personnel/__tests__/personnel-details-sheet.test.tsx b/src/components/personnel/__tests__/personnel-details-sheet.test.tsx index 72ebcc3..e16b02c 100644 --- a/src/components/personnel/__tests__/personnel-details-sheet.test.tsx +++ b/src/components/personnel/__tests__/personnel-details-sheet.test.tsx @@ -128,6 +128,10 @@ jest.mock('react-i18next', () => ({ jest.mock('@/lib/utils', () => ({ formatDateForDisplay: (date: any) => 'Formatted Date', parseDateISOString: (date: string) => new Date(date), + safeFormatTimestamp: (timestamp: string, format: string) => 'Formatted Date', + getAvatarUrl: (userId: string) => `https://example.com/avatar/${userId}`, + getInitials: (first: string, last: string) => 'JD', + getColorFromString: (str: string) => '#000000', })); describe('PersonnelDetailsSheet', () => { diff --git a/src/components/personnel/personnel-card.tsx b/src/components/personnel/personnel-card.tsx index 5738ca5..7e86568 100644 --- a/src/components/personnel/personnel-card.tsx +++ b/src/components/personnel/personnel-card.tsx @@ -2,7 +2,7 @@ import { Mail, Phone, Users } from 'lucide-react-native'; import * as React from 'react'; import { Pressable } from 'react-native'; -import { formatDateForDisplay, getAvatarUrl, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, getAvatarUrl, getColorFromString, getInitials, parseDateISOString, safeFormatTimestamp } from '@/lib/utils'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; import { useSecurityStore } from '@/stores/security/store'; @@ -13,29 +13,7 @@ import { HStack } from '../ui/hstack'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; -/** - * Generates a deterministic color from a string (user ID or name) - * Returns a hex color string - */ -function getColorFromString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - - // Generate HSL color with good saturation and lightness for visibility - const hue = Math.abs(hash % 360); - return `hsl(${hue}, 65%, 45%)`; -} -/** - * Gets initials from first and last name - */ -function getInitials(firstName?: string, lastName?: string): string { - const first = firstName?.trim()?.[0]?.toUpperCase() || ''; - const last = lastName?.trim()?.[0]?.toUpperCase() || ''; - return first + last || '?'; -} interface PersonnelCardProps { personnel: PersonnelInfoResultData; @@ -47,6 +25,10 @@ export const PersonnelCard: React.FC = ({ personnel, onPress const { canUserViewPII } = useSecurityStore(); const [imageError, setImageError] = React.useState(false); + React.useEffect(() => { + setImageError(false); + }, [personnel.UserId]); + const avatarUrl = getAvatarUrl(personnel.UserId); const initials = getInitials(personnel.FirstName, personnel.LastName); const fallbackColor = getColorFromString(personnel.UserId || fullName); @@ -102,14 +84,12 @@ export const PersonnelCard: React.FC = ({ personnel, onPress ) : personnel.GroupName ? ( - {personnel.GroupName ? ( - - - - {personnel.GroupName} - - - ) : null} + + + + {personnel.GroupName} + + ) : null} @@ -145,7 +125,7 @@ export const PersonnelCard: React.FC = ({ personnel, onPress ) : null} {/* Last Status Update */} - {personnel.StatusTimestamp ? Status: {formatDateForDisplay(parseDateISOString(personnel.StatusTimestamp), 'yyyy-MM-dd HH:mm Z')} : null} + {personnel.StatusTimestamp ? Status: {safeFormatTimestamp(personnel.StatusTimestamp, 'yyyy-MM-dd HH:mm Z')} : null} diff --git a/src/components/personnel/personnel-details-sheet.tsx b/src/components/personnel/personnel-details-sheet.tsx index f4eb48e..41c617a 100644 --- a/src/components/personnel/personnel-details-sheet.tsx +++ b/src/components/personnel/personnel-details-sheet.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; import { useAnalytics } from '@/hooks/use-analytics'; -import { formatDateForDisplay, getAvatarUrl, parseDateISOString } from '@/lib/utils'; +import { formatDateForDisplay, getAvatarUrl, getColorFromString, getInitials, parseDateISOString, safeFormatTimestamp } from '@/lib/utils'; import { usePersonnelStore } from '@/stores/personnel/store'; import { useSecurityStore } from '@/stores/security/store'; @@ -19,44 +19,9 @@ import { HStack } from '../ui/hstack'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; -/** - * Generates a deterministic color from a string (user ID or name) - * Returns an HSL color string - */ -function getColorFromString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - const hue = Math.abs(hash % 360); - return `hsl(${hue}, 65%, 45%)`; -} -/** - * Gets initials from first and last name - */ -function getInitials(firstName?: string, lastName?: string): string { - const first = firstName?.trim()?.[0]?.toUpperCase() || ''; - const last = lastName?.trim()?.[0]?.toUpperCase() || ''; - return first + last || '?'; -} -/** - * Safely formats a timestamp string with error handling. - * Returns formatted date string on success, empty string on failure. - */ -const safeFormatTimestamp = (timestamp: string | undefined | null, format: string): string => { - if (!timestamp) return ''; - - try { - const parsed = parseDateISOString(timestamp); - return formatDateForDisplay(parsed, format); - } catch (error) { - console.warn('Failed to parse timestamp:', timestamp, error); - return ''; - } -}; export const PersonnelDetailsSheet: React.FC = () => { const { t } = useTranslation(); diff --git a/src/lib/__tests__/utils.test.ts b/src/lib/__tests__/utils.test.ts index 78267d3..3f5e405 100644 --- a/src/lib/__tests__/utils.test.ts +++ b/src/lib/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { formatLocalDateString, isSameDate, isToday, getTodayLocalString } from '../utils'; +import { formatLocalDateString, isSameDate, isToday, getTodayLocalString, safeFormatTimestamp, getColorFromString, getInitials } from '../utils'; describe('Date Utility Functions', () => { describe('formatLocalDateString', () => { @@ -205,4 +205,45 @@ describe('Date Utility Functions', () => { expect(() => formatLocalDateString(validDate)).not.toThrow(); }); }); + + describe('getColorFromString', () => { + it('generates deterministic color from string', () => { + const color1 = getColorFromString('test-string'); + const color2 = getColorFromString('test-string'); + expect(color1).toBe(color2); + expect(color1).toMatch(/^hsl\(\d+, 65%, 45%\)$/); + }); + + it('generates different colors for different strings', () => { + const color1 = getColorFromString('string-1'); + const color2 = getColorFromString('string-2'); + expect(color1).not.toBe(color2); + }); + }); + + describe('getInitials', () => { + it('returns initials from first and last name', () => { + expect(getInitials('John', 'Doe')).toBe('JD'); + }); + + it('handles missing first name', () => { + expect(getInitials(undefined, 'Doe')).toBe('D'); + }); + + it('handles missing last name', () => { + expect(getInitials('John')).toBe('J'); + }); + + it('handles empty strings', () => { + expect(getInitials('', '')).toBe('?'); + }); + + it('handles extra whitespace', () => { + expect(getInitials(' John ', ' Doe ')).toBe('JD'); + }); + + it('returns ? for no input', () => { + expect(getInitials()).toBe('?'); + }); + }); }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e2395bd..40c2779 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -20,6 +20,22 @@ export const createSelectors = >>(_stor return store; }; +/** + * Safely formats a timestamp string with error handling. + * Returns formatted date string on success, empty string on failure. + */ +export const safeFormatTimestamp = (timestamp: string | undefined | null, format: string): string => { + if (!timestamp) return ''; + + try { + const parsed = parseDateISOString(timestamp); + return formatDateForDisplay(parsed, format); + } catch (error) { + console.warn('Failed to parse timestamp:', timestamp, error); + return ''; + } +}; + export const IS_ANDROID = Platform.OS === 'android'; export const IS_IOS = Platform.OS === 'ios'; export const DEFAULT_CENTER_COORDINATE = [-77.036086, 38.910233]; @@ -496,3 +512,26 @@ export function getTimeAgoUtc(time: any): string { } return time; } + +/** + * Generates a deterministic color from a string (user ID or name) + * Returns an HSL color string + */ +export function getColorFromString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 65%, 45%)`; +} + +/** + * Gets initials from first and last name + */ +export function getInitials(firstName?: string, lastName?: string): string { + const first = firstName?.trim()?.[0]?.toUpperCase() || ''; + const last = lastName?.trim()?.[0]?.toUpperCase() || ''; + return first + last || '?'; +}