From 1b8533d1928fab40760a28af3db9e0f419c1bc48 Mon Sep 17 00:00:00 2001 From: Matt DiMeglio Date: Tue, 12 Aug 2025 16:36:26 -0400 Subject: [PATCH] Incident details encoding and UI headers Introduces Unicode-safe base64 encoding/decoding for call details between incidents and call screens. Refactors PageHeader to accept left, center, and right header props for more flexible layouts. Updates call.jsx and register.jsx to use the new header structure, and improves department/unit rendering and modal dropdowns for department selection. Generalizes and modernizes UI code for better maintainability and cross-platform compatibility. --- app/call.jsx | 1594 +++++++++++++++-------------- app/incidents.jsx | 26 +- app/register.jsx | 10 +- components/generalHelpers.jsx | 194 ++-- contexts/WebSocketContext.js | 2 +- hooks/useCallFeed/useCallFeed.jsx | 7 +- package-lock.json | 14 + package.json | 1 + 8 files changed, 939 insertions(+), 909 deletions(-) diff --git a/app/call.jsx b/app/call.jsx index ff695a4..7c98d99 100644 --- a/app/call.jsx +++ b/app/call.jsx @@ -1,5 +1,6 @@ import React, { useRef } from 'react'; import styled from 'styled-components'; +import { router } from 'expo-router'; import { useCallFeed } from '../hooks/useCallFeed'; import { Platform, Linking, View, ScrollView, Text, TouchableOpacity } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; @@ -16,816 +17,823 @@ import ActionSheet from 'react-native-actions-sheet'; const DepartmentActionSheet = styled(ActionSheet)``; +function fromBase64Unicode(str) { + return new TextDecoder().decode(Uint8Array.from(atob(str), c => c.charCodeAt(0))); +} + export default function Call() { - const { callDetails } = useLocalSearchParams(); - const actionSheetRef = useRef(null); - const callFeed = useCallFeed(); + const { callDetails } = useLocalSearchParams(); + const actionSheetRef = useRef(null); + const callFeed = useCallFeed(true); - const { - departments, - callIconMap, - callColorSelector, - formatCallTimePast, - formatCallDateTime - } = callFeed; + const { + departments, + callIconMap, + callColorSelector, + formatCallTimePast, + formatCallDateTime + } = callFeed; - const { - departmentTypeMap, - accountDetails, - selectedDepartment, - setSelectedDepartment, - updateSelectedDepartment, - selectedDepartmentColorPicker, - deptList, - } = departments; + const { + departmentTypeMap, + accountDetails, + selectedDepartment, + setSelectedDepartment, + updateSelectedDepartment, + selectedDepartmentColorPicker, + deptList, + } = departments; - const decoded = atob(callDetails); + const decoded = fromBase64Unicode(callDetails); - const { Incident, Address, Person, Response } = JSON.parse(decoded); - const { CallThemes } = accountDetails; - const { - IncID, - IncNumber, - JurisdictionNumber, - ServiceNumber, - ServiceID, - IncDate, - IncNature, - IncNatureCode, - IncNatureCodeDesc, - Notes, - Status, - Origin, - } = Incident; - const { - StreetAddress, - AddressApartment, - Town, - State, - ZipCode, - Latitude, - Longitude, - County, - Intersection1, - Intersection2, - LocationName, - WeatherCondition, - } = Address; - const { - Name, - Age, - Gender, - Statement, - Conscious, - Breathing, - CallBackNumber, - } = Person; - const { - Units - } = Response; + const { incident, address, person, response } = JSON.parse(decoded); + const { CallThemes } = accountDetails; + const { + incID, + incNumber, + jurisdictionNumber, + serviceNumber, + serviceID, + incDate, + incNature, + incNatureCode, + incNatureCodeDesc, + notes, + status, + origin, + } = incident; + const { + streetAddress, + addressApartment, + town, + state, + zipCode, + latitude, + longitude, + county, + intersection1, + intersection2, + locationName, + weatherCondition, + } = address; + const { + name, + age, + gender, + statement, + conscious, + breathing, + callBackNumber, + } = person; + const { + units + } = response; - const SelectedIcon = callIconMap[IncNature] || AccidentAndEmergency; + const SelectedIcon = callIconMap[incNature] || AccidentAndEmergency; - const ownDepartmentResponse = Units?.map((unit) => { - if (unit?.Department === selectedDepartment?.dept || - selectedDepartment?.addDepts?.includes(unit?.Department)) { - return unit; - } - return null; - })?.filter((filterItem) => { - return filterItem; - }); + const ownDepartmentResponse = units?.map((unit) => { + if (unit?.department === selectedDepartment?.dept || + selectedDepartment?.addDepts?.includes(unit?.department)) { + return unit; + } + return null; + })?.filter((filterItem) => { + return filterItem; + }); - const mutualAidDepartmentResponse = Units?.map((unit) => { - if (unit?.Department !== selectedDepartment?.dept && - !selectedDepartment?.addDepts?.includes(unit?.Department)) { - return unit; - } - return null; - })?.filter((filterItem) => { - return filterItem; - });; + const mutualAidDepartmentResponse = units?.map((unit) => { + if (unit?.department !== selectedDepartment?.dept && + !selectedDepartment?.addDepts?.includes(unit?.department)) { + return unit; + } + return null; + })?.filter((filterItem) => { + return filterItem; + });; - const formatResponseTimes = (time) => { - if (time === null) { - return ''; - } - const initTime = new Date(time); - const hours = initTime.getHours().toString().padStart(2, '0'); - const minutes = initTime.getMinutes().toString().padStart(2, '0'); - return `${hours}:${minutes}`; - }; + const formatResponseTimes = (time) => { + if (time === null || time === undefined || time === '') { + return ''; + } + const initTime = new Date(time); + const hours = initTime.getHours().toString().padStart(2, '0'); + const minutes = initTime.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + }; - const openMaps = (latitude, longitude) => { - const daddr = `${latitude},${longitude}`; - const company = Platform.OS === "ios" ? "apple" : "google"; - Linking.openURL(`http://maps.${company}.com/maps?daddr=${daddr}`); - }; + const openMaps = (latitude, longitude) => { + const daddr = `${latitude},${longitude}`; + const company = Platform.OS === "ios" ? "apple" : "google"; + Linking.openURL(`http://maps.${company}.com/maps?daddr=${daddr}`); + }; - const callNumber = (number) => { - const formattedNumber = number.replace(/[()\-\s]/g, ''); - Linking.openURL(`tel:${formattedNumber}`); - }; - - return ( - - - { + const formattedNumber = number.replace(/[()\-\s]/g, ''); + Linking.openURL(`tel:${formattedNumber}`); + }; + + return ( + + + + + Back to Incidents + + } + centerHeader={ + { + actionSheetRef.current?.show(); + }} + > + + {selectedDepartment?.deptAbv} + + + } + /> + + + + + + {formatCallDateTime(incDate)} + {formatCallTimePast(incDate)} + + + - + + + + + {`${incNature}`} + + + + {`${incNatureCodeDesc}`} + + + + + + {status.toLowerCase() === 'closed' ? ( + + This Incident is Closed. + + ) : } + + {`Incident #: ${serviceNumber}`} + + + + + + + + + + + {locationName ? ( + + {`${locationName}`} + + ) : null} + { + return openMaps(latitude, longitude); + }} + > + + {`${streetAddress}`} + + + {`${town}, ${state}`} + + + {addressApartment ? ( + + + + {`${addressApartment}`} + + + ) : null} + + + + Map + + { - actionSheetRef.current?.show(); + return openMaps(latitude, longitude); }} - > - - {selectedDepartment?.deptAbv} - - + > + Nav + + + + - - - - - - - {formatCallDateTime(IncDate)} - {formatCallTimePast(IncDate)} - - - - - - - - - {`${IncNature}`} - - - - {`${IncNatureCodeDesc}`} - - - - - - {Status === 'CLOSED' ? ( - - This Incident is Closed. - - ) : } - - {`Incident #: ${ServiceNumber}`} - - - - - - - - - - - {LocationName ? ( - - {`${LocationName}`} - - ) : null } - { - return openMaps(Latitude, Longitude); - }} - > - - {`${StreetAddress}`} - - - {`${Town}, ${State}`} - - - {AddressApartment ? ( - - - - {`${AddressApartment}`} - - - ) : null} - - - - Map - - { - return openMaps(Latitude, Longitude); - }} - > - Nav - - - - - - - - - - - {Intersection1} - - - - - - {Intersection2} - - - - - - - {selectedDepartment?.supervisor ? ( - - - - - - {`${Name}`} - - { - return callNumber(CallBackNumber); - }} - > - - {`${CallBackNumber}`} - - - - { - return callNumber(CallBackNumber); - }} - > - - - - - - - ) : null } - - - - {`${Age} `} - - - {`${Gender} - `} - - - {`Conscious: ${Conscious} | `} - - - {`Breathing: ${Breathing}`} - - - - {`${Statement}`} - - - - - - - - - - - {Units?.length > 0 ? ( - - {ownDepartmentResponse?.length > 0 ? ( - - - - Units - Disp - Resp - Arr - {selectedDepartment?.type === 'EMS' || selectedDepartment?.type === 'Rescue' ? ( - Trans - ) : null} - BIS - - - - {ownDepartmentResponse?.map((unit) => { - return ( - - - {unit?.Unit} - - - {formatResponseTimes(unit?.Dispatched)} - - - {formatResponseTimes(unit?.Responding)} - - - {formatResponseTimes(unit?.OnScene)} - - {selectedDepartment?.type === 'EMS' || - selectedDepartment?.type === 'Rescue' ? ( - - {formatResponseTimes(unit?.Transporting)} - - ) : null } - - {formatResponseTimes(unit?.InService)} - - - ) - })} - - - - ) : ( - - - {`No ${selectedDepartment?.dept} Units Responding`} - - - ) } - {mutualAidDepartmentResponse?.length > 0 ? ( - - - - M/A - Disp - Resp - Arr - {selectedDepartment?.type === 'EMS' || selectedDepartment?.type === 'Rescue' ? ( - Trans - ) : null} - BIS - - - {mutualAidDepartmentResponse?.map((unit) => { - return ( - - - {unit?.Unit} - - - {formatResponseTimes(unit?.Dispatched)} - - - {formatResponseTimes(unit?.Responding)} - - - {formatResponseTimes(unit?.OnScene)} - - {selectedDepartment?.type === 'EMS' || - selectedDepartment?.type === 'Rescue' ? ( - - {formatResponseTimes(unit?.Transporting)} - - ) : null } - - {formatResponseTimes(unit?.InService)} - - - ) - })} - - ) : null } - - ) : ( - - No Units Responding - - )} - - - - - - Incident Notes - - - - - {Notes?.split('\n').map((note, index) => ( - - {note} - {index < Notes.split('\n').length - 1 && ( - - )} - - ))} - - - - - - - - + + + - - {deptList?.map((item) => { - return ( - - { - actionSheetRef.current?.hide(); - return updateSelectedDepartment( - selectedDepartment?.deptId, - item?.deptId - ) - }} - > - - - {item?.dept} - - {item?.primary ? : null} - - {`${item?.deptAbv} - ${departmentTypeMap[item?.type]}`} - - - ); - })} + + {intersection1} + + + + + + {intersection2} + + + + + + + {selectedDepartment?.supervisor ? ( + + + + + + {`${name}`} + + { + return callNumber(callBackNumber); + }} + > + + {`${callBackNumber}`} + + + + { + return callNumber(callBackNumber); + }} + > + + + + + - - - ); - } \ No newline at end of file + ) : null} + + + + {`${age} `} + + + {`${gender} - `} + + + {`Conscious: ${conscious} | `} + + + {`Breathing: ${breathing}`} + + + + {`${statement}`} + + + + + + + + + + + {units?.length > 0 ? ( + + {ownDepartmentResponse?.length > 0 ? ( + + + + Units + Disp + Resp + Arr + {selectedDepartment?.type === 'EMS' || selectedDepartment?.type === 'Rescue' ? ( + Trans + ) : null} + BIS + + + + {ownDepartmentResponse?.map((unit) => { + return ( + + + {unit?.unit} + + + {formatResponseTimes(unit?.dispatched)} + + + {formatResponseTimes(unit?.responding)} + + + {formatResponseTimes(unit?.onScene)} + + {selectedDepartment?.type === 'EMS' || + selectedDepartment?.type === 'Rescue' ? ( + + {formatResponseTimes(unit?.transporting)} + + ) : null} + + {formatResponseTimes(unit?.inService)} + + + ) + })} + + + + ) : ( + + + {`No ${selectedDepartment?.dept} Units Responding`} + + + )} + {mutualAidDepartmentResponse?.length > 0 ? ( + + + + M/A + Disp + Resp + Arr + {selectedDepartment?.type === 'EMS' || selectedDepartment?.type === 'Rescue' ? ( + Trans + ) : null} + BIS + + + {mutualAidDepartmentResponse?.map((unit) => { + return ( + + + {unit?.unit} + + + {formatResponseTimes(unit?.dispatched)} + + + {formatResponseTimes(unit?.responding)} + + + {formatResponseTimes(unit?.onScene)} + + {selectedDepartment?.type === 'EMS' || + selectedDepartment?.type === 'Rescue' ? ( + + {formatResponseTimes(unit?.transporting)} + + ) : null} + + {formatResponseTimes(unit?.inService)} + + + ) + })} + + ) : null} + + ) : ( + + No Units Responding + + )} + + + + + + Incident Notes + + + + + {notes?.split('\n').map((note, index) => ( + + {note} + {index < notes.split('\n').length - 1 && ( + + )} + + ))} + + + + + + + + + + {deptList?.map((item) => { + return ( + + { + actionSheetRef.current?.hide(); + return updateSelectedDepartment( + selectedDepartment?.deptId, + item?.deptId + ) + }} + > + + + {item?.dept} + + {item?.primary ? : null} + + {`${item?.deptAbv} - ${departmentTypeMap[item?.type]}`} + + + ); + })} + + + + ); +} \ No newline at end of file diff --git a/app/incidents.jsx b/app/incidents.jsx index ac6737c..a5c0308 100644 --- a/app/incidents.jsx +++ b/app/incidents.jsx @@ -15,6 +15,10 @@ import ActionSheet from 'react-native-actions-sheet'; const DepartmentActionSheet = styled(ActionSheet)``; +function toBase64Unicode(str) { + return btoa(new TextEncoder().encode(str).reduce((data, byte) => data + String.fromCharCode(byte), '')); +} + export default function Incidents() { const actionSheetRef = useRef(null); const callFeed = useCallFeed(); @@ -49,17 +53,8 @@ export default function Incidents() { return ( - - - {selectedDepartment?.deptAbv} - - - + } + /> @@ -121,7 +115,7 @@ export default function Incidents() { router.push({ pathname: '/call', params: { - callDetails: btoa(JSON.stringify(callItem)) + callDetails: toBase64Unicode(JSON.stringify(callItem)) } }) }} @@ -254,7 +248,7 @@ export default function Incidents() { - + - - - + Back to Login - - - + } + /> , + centerHeader = , + rightHeader = }) => { return ( - {children} + + + {leftHeader} + {centerHeader} + {rightHeader} + + ) } export const PageFooter = ({ - children + children }) => { return ( @@ -224,11 +247,11 @@ export const PageFooter = ({ } export const LoginTextInput = ({ - label, - icon, - isPassword = false, - hidePassword = true, - setHidePassword = (boolean) => {}, + label, + icon, + isPassword = false, + hidePassword = true, + setHidePassword = (boolean) => { }, ...props }) => { return ( @@ -239,7 +262,7 @@ export const LoginTextInput = ({ {label} {isPassword ? ( - {setHidePassword(!hidePassword)}}> + { setHidePassword(!hidePassword) }}> ) : null} @@ -249,115 +272,108 @@ export const LoginTextInput = ({ export const RegisterDropdownInput = ({ label, - isOpen, - setOpen, + isOpen: _isOpen, + setOpen: _setOpen, selectedValue, onValueChange, menu }) => { + const [modalVisible, setModalVisible] = useState(false); + return ( {label} - { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - LayoutAnimation.configureNext(LayoutAnimation.create(200, 'easeInEaseOut', 'opacity')); - isOpen ? setOpen(false) : setOpen(true); + onPress={() => setModalVisible(true)} + > + + + {selectedValue ? providerConversion[selectedValue] : menu.placeholder} + + setModalVisible(!modalVisible)}> + + + + setModalVisible(false)} + > + setModalVisible(false)} + /> + - - - - {selectedValue ? providerConversion[selectedValue] : menu.placeholder} - - { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - LayoutAnimation.configureNext(LayoutAnimation.create(200, 'easeInEaseOut', 'opacity')); - isOpen ? setOpen(false) : setOpen(true); + setModalVisible(false)} + > + + Close + + + { + onValueChange(value); }} > - - - - {isOpen && - {menu.dropdownList.map((subMenu, index) => { - return ( - { - onValueChange(subMenu.value); - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - LayoutAnimation.configureNext(LayoutAnimation.create(200, 'easeInEaseOut', 'opacity')); - setOpen(false); - }} - > - - {subMenu.label} - {selectedValue === subMenu.value && - - - - } - - - ) - })} - } - + {menu.dropdownList.map((item) => ( + + ))} + + + - ) + ); } -export const formatPhoneNumber = (e) => { +export const formatPhoneNumber = (e) => { let formattedNumber; const length = e?.length; const regex = () => e.replace(/[^0-9\.]+/g, ""); const areaCode = () => `(${regex().slice(0, 3)})`; const firstSix = () => `${areaCode()} ${regex().slice(3, 6)}`; - const trailer = (start) => `${regex().slice(start, regex().length)}`; + const trailer = (start) => `${regex().slice(start, regex().length)}`; if (length <= 3) { formattedNumber = regex(); } else if (length === 4) { formattedNumber = `${areaCode()} ${trailer(3)}`; } else if (length === 5) { - formattedNumber = `${areaCode().replace(")", "")}`; + formattedNumber = `${areaCode().replace(")", "")}`; } else if (length >= 5 && length <= 9) { - formattedNumber = `${areaCode()} ${trailer(3)}`; + formattedNumber = `${areaCode()} ${trailer(3)}`; } else if (length >= 10) { formattedNumber = `${firstSix()}-${trailer(6)}`; - } + } return formattedNumber; }; \ No newline at end of file diff --git a/contexts/WebSocketContext.js b/contexts/WebSocketContext.js index 641a59e..f9354ca 100644 --- a/contexts/WebSocketContext.js +++ b/contexts/WebSocketContext.js @@ -20,7 +20,7 @@ export const WebSocketProvider = ({ children }) => { } console.log(`🔁 Connecting (Attempt ${reconnectAttempts.current + 1}/${maxReconnectAttempts})`); - ws.current = new WebSocket(process.env.EXPO_PUBLIC_WS_URL); + ws.current = new WebSocket(`${process.env.EXPO_PUBLIC_WS_URL}/callfeed`); ws.current.onopen = () => { console.log('✅ WebSocket connected'); diff --git a/hooks/useCallFeed/useCallFeed.jsx b/hooks/useCallFeed/useCallFeed.jsx index 9437be1..31f413a 100644 --- a/hooks/useCallFeed/useCallFeed.jsx +++ b/hooks/useCallFeed/useCallFeed.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from "react"; import { useDepartments } from "../useDepartments"; import { - C, Cardiology, Cpr, FourByFour, @@ -107,7 +106,7 @@ const getIncidents = async (departments, incidentStatus) => { return response?.json() || []; }; -export const useCallFeed = () => { +export const useCallFeed = (callPage = false) => { const departments = useDepartments(); const { lastMessage } = useWebSocketContext(); const [allCalls, setAllCalls] = useState([]); @@ -123,7 +122,7 @@ export const useCallFeed = () => { setAllCalls(incidents); setInit(false); } - fetchData(); + if (!callPage) fetchData(); }, []); useEffect(() => { @@ -141,7 +140,7 @@ export const useCallFeed = () => { setAllCalls(incidents); } if (!init) { - fetchData(); + if (!callPage) fetchData(); } }, [selectedStatus]); diff --git a/package-lock.json b/package-lock.json index 79022e2..5329613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@emotion/unitless": "^0.10.0", "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "^1.24.0", + "@react-native-picker/picker": "^2.11.1", "@react-navigation/native": "^7.1.17", "expo": "^53.0.20", "expo-constants": "~17.1.7", @@ -3114,6 +3115,19 @@ "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, + "node_modules/@react-native-picker/picker": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", + "integrity": "sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.79.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", diff --git a/package.json b/package.json index 38e0e53..b3739a8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@emotion/unitless": "^0.10.0", "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "^1.24.0", + "@react-native-picker/picker": "^2.11.1", "@react-navigation/native": "^7.1.17", "expo": "^53.0.20", "expo-constants": "~17.1.7",