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.
This commit is contained in:
Matt DiMeglio 2025-08-12 16:36:26 -04:00
parent 2f01c575db
commit 1b8533d192
8 changed files with 939 additions and 909 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 (
<React.Fragment>
<PageHeader>
<View
style={{
flexDirection: 'column',
height: 80,
alignItems: 'center',
justifyContent: 'flex-end',
paddingBottom: 7
}}
>
<TouchableOpacity
<PageHeader
centerHeader={<TouchableOpacity
style={{
borderRadius: 6,
elevation: 3,
@ -84,9 +79,8 @@ export default function Incidents() {
>
{selectedDepartment?.deptAbv}
</Text>
</TouchableOpacity>
</View>
</PageHeader>
</TouchableOpacity>}
/>
<ScrollView>
<StatusBar style="dark" />
<SafeAreaView />
@ -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() {
</View>
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<View style={{ paddingTop: 5, flexDirection: 'row', justifyContent: 'space-between' }}>
<Text
style={{
fontSize: 12,

View file

@ -99,14 +99,12 @@ export default function Register() {
return (
<View style={{ flex: 1 }}>
<StatusBar style="dark" />
<PageHeader>
<View style={{ flexDirection: 'row', height: 80, alignItems: 'flex-end' }}>
<TouchableOpacity onPress={router.back} style={{ flexDirection: 'row', alignItems: 'center', paddingBottom: 5 }}>
<PageHeader
leftHeader={ <TouchableOpacity onPress={router.back} style={{ flexDirection: 'row', alignItems: 'center', paddingBottom: 5 }}>
<Ionicons name="chevron-back-outline" size={22} color="red" style={{ paddingLeft: 20 }} />
<Text style={{ color: 'red', fontWeight: 600 }}>Back to Login</Text>
</TouchableOpacity>
</View>
</PageHeader>
</TouchableOpacity>}
/>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}

View file

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { View, Text, LayoutAnimation, Image, TextInput, TouchableOpacity, TouchableNativeFeedback, ScrollView } from 'react-native';
import { View, Text, LayoutAnimation, Image, TextInput, TouchableOpacity, TouchableNativeFeedback, ScrollView, Platform, Modal, Pressable } from 'react-native';
import { Picker } from '@react-native-picker/picker';
import { Ionicons } from '@expo/vector-icons';
import { Row } from './Row';
import { Container } from './Container';
export const StyledContainer = styled.View`
@ -44,7 +45,7 @@ export const StyledTextInput = styled.TextInput`
margin-bottom: 10px;
`;
export const StyledInputLabel = styled.Text`
export const StyledInputLabel = styled.Text`
font-size: 13px;
text-align: left;
`;
@ -204,11 +205,33 @@ const providerConversion = {
}
export const PageHeader = ({
children
leftHeader = <View style={{ flex: 1 }} />,
centerHeader = <View style={{ flex: 1 }} />,
rightHeader = <View style={{ flex: 1 }} />
}) => {
return (
<View style={{ position: 'sticky', top: 0, backgroundColor: '#ECEDEE', zIndex: 1, marginBottom: -100 }}>
{children}
<View
style={{
flexDirection: 'column',
height: 80,
alignItems: 'center',
justifyContent: 'flex-end',
paddingBottom: 7
}}
>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
paddingHorizontal: 0
}}>
{leftHeader}
{centerHeader}
{rightHeader}
</View>
</View>
</View>
)
}
@ -228,7 +251,7 @@ export const LoginTextInput = ({
icon,
isPassword = false,
hidePassword = true,
setHidePassword = (boolean) => {},
setHidePassword = (boolean) => { },
...props
}) => {
return (
@ -239,7 +262,7 @@ export const LoginTextInput = ({
<StyledInputLabel>{label}</StyledInputLabel>
<StyledTextInput {...props} />
{isPassword ? (
<RightIcon onPress={() => {setHidePassword(!hidePassword)}}>
<RightIcon onPress={() => { setHidePassword(!hidePassword) }}>
<Ionicons name={hidePassword ? 'eye-off-outline' : 'eye-outline'} size={30} color="gray" />
</RightIcon>
) : null}
@ -249,93 +272,86 @@ export const LoginTextInput = ({
export const RegisterDropdownInput = ({
label,
isOpen,
setOpen,
isOpen: _isOpen,
setOpen: _setOpen,
selectedValue,
onValueChange,
menu
}) => {
const [modalVisible, setModalVisible] = useState(false);
return (
<Container>
<StyledInputLabel>{label}</StyledInputLabel>
<TouchableOpacity activeOpacity={0.8} key={`${menu.menuName}2`}
<TouchableOpacity
activeOpacity={0.8}
style={{
backgroundColor: '#E5E7EB',
marginTop: 3,
marginBottom: 10,
borderRadius: '5px',
borderRadius: 5,
flexDirection: 'row',
alignItems: 'center',
minHeight: 60,
paddingHorizontal: 15,
}}
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
LayoutAnimation.configureNext(LayoutAnimation.create(200, 'easeInEaseOut', 'opacity'));
isOpen ? setOpen(false) : setOpen(true);
onPress={() => setModalVisible(true)}
>
<Ionicons
name={menu.iconName}
size={30}
color={menu.iconColor}
/>
<Text style={{ color: selectedValue ? 'black' : 'grey', fontSize: 16, paddingHorizontal: 16, flex: 1 }}>
{selectedValue ? providerConversion[selectedValue] : menu.placeholder}
</Text>
<DropdownArrow onPress={() => setModalVisible(!modalVisible)}>
<Ionicons name={modalVisible ? "chevron-up-outline" : "chevron-down-outline"} size={30} color="gray" />
</DropdownArrow>
</TouchableOpacity>
<Modal
visible={modalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setModalVisible(false)}
>
<Pressable
style={{ flex: 1 }}
onPress={() => setModalVisible(false)}
/>
<View style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: 32,
paddingTop: 16,
}}>
<Row style={{
paddingHorizontal: 16,
paddingVertical: 16 / 1.2,
}}>
<Ionicons
name={menu.iconName}
size={30}
color={menu.iconColor}
/>
<Text style={{
fontSize: 16,
paddingHorizontal: 16
}}>
{selectedValue ? providerConversion[selectedValue] : menu.placeholder}
</Text>
<DropdownArrow
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
LayoutAnimation.configureNext(LayoutAnimation.create(200, 'easeInEaseOut', 'opacity'));
isOpen ? setOpen(false) : setOpen(true);
<Pressable
style={{ flex: 1, paddingHorizontal: 15, alignItems: 'flex-end' }}
onPress={() => setModalVisible(false)}
>
<Text style={{ color: 'red', fontWeight: 600 }}>
Close
</Text>
</Pressable>
<Picker
selectedValue={selectedValue}
onValueChange={(value) => {
onValueChange(value);
}}
>
<Ionicons
name={isOpen ? "chevron-up-outline" : "chevron-down-outline"}
size={30}
color="gray"
/>
</DropdownArrow>
</Row>
{isOpen && <ScrollView style={{ borderRadius: '5px', backgroundColor: '#E5E7EB' }}>
{menu.dropdownList.map((subMenu, index) => {
return (
<TouchableNativeFeedback
key={index}
onPress={() => {
onValueChange(subMenu.value);
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
LayoutAnimation.configureNext(LayoutAnimation.create(200, 'easeInEaseOut', 'opacity'));
setOpen(false);
}}
>
<View style={{
paddingHorizontal: 16,
paddingVertical: 16 / 1.5,
borderTopColor: 'gray',
borderTopWidth: .5,
marginHorizontal: 10
}}>
<Text>{subMenu.label}</Text>
{selectedValue === subMenu.value &&
<SelectedCheckmark>
<Ionicons
name="checkmark-outline"
size={30}
color="red"
/>
</SelectedCheckmark>
}
</View>
</TouchableNativeFeedback>
)
})}
</ScrollView>}
</TouchableOpacity>
{menu.dropdownList.map((item) => (
<Picker.Item color="black" label={item.label} value={item.value} key={item.value} />
))}
</Picker>
</View>
</Modal>
</Container>
)
);
}
export const formatPhoneNumber = (e) => {

View file

@ -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');

View file

@ -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]);

14
package-lock.json generated
View file

@ -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",

View file

@ -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",