Skip to content

Commit 761e132

Browse files
committed
feat(emergency-contacts): ICE 1-5 multi-contact UI with add/delete, premium dashed plus button
1 parent 642e4c6 commit 761e132

4 files changed

Lines changed: 293 additions & 70 deletions

File tree

‎novadrive-mobile/app/emergency-contacts.tsx‎

Lines changed: 255 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
33
import { Alert, Pressable, ScrollView, StyleSheet, View } from 'react-native';
44
import { useSafeAreaInsets } from 'react-native-safe-area-context';
55
import { MaterialIcons } from '@expo/vector-icons';
6+
import * as Haptics from 'expo-haptics';
67
import { HudCard } from '../src/components/HudCard';
78
import { HudText } from '../src/components/HudText';
89
import { MargiButton } from '../src/components/MargiButton';
@@ -18,32 +19,137 @@ import {
1819
} from '../src/lib/storage';
1920
import { tokens } from '../src/theme/tokens';
2021

21-
/** Stitch emergency contacts card — Primary Contact (ICE 1) with name, relationship, phone. */
22+
const MAX_ICE = 5; // ICE 1 (primary) + ICE 2–5 (extras)
23+
24+
const ICE_LABELS: Record<number, string> = {
25+
1: 'Primary Contact (ICE 1)',
26+
2: 'ICE 2 — Backup Contact',
27+
3: 'ICE 3 — Tertiary Contact',
28+
4: 'ICE 4',
29+
5: 'ICE 5',
30+
};
31+
32+
/** Single ICE contact form card */
33+
function IceContactCard({
34+
index,
35+
contact,
36+
onChange,
37+
onDelete,
38+
canDelete,
39+
}: {
40+
index: number; // 1-based ICE number
41+
contact: EmergencyContact;
42+
onChange: (c: EmergencyContact) => void;
43+
onDelete: () => void;
44+
canDelete: boolean;
45+
}) {
46+
return (
47+
<HudCard style={styles.card}>
48+
<View style={styles.cardHead}>
49+
{/* ICE badge */}
50+
<View style={styles.iceBadge}>
51+
<HudText variant="mono" style={styles.iceBadgeText}>
52+
ICE {index}
53+
</HudText>
54+
</View>
55+
<HudText variant="bodyMd" style={styles.iceTitle}>
56+
{ICE_LABELS[index] ?? `ICE ${index}`}
57+
</HudText>
58+
{canDelete && (
59+
<Pressable
60+
onPress={() => {
61+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
62+
onDelete();
63+
}}
64+
style={styles.deleteBtn}
65+
accessibilityLabel={`Remove ICE ${index}`}
66+
>
67+
<MaterialIcons name="delete-outline" size={22} color={tokens.outline} />
68+
</Pressable>
69+
)}
70+
</View>
71+
72+
<HudText variant="mono" style={styles.label}>Full Name</HudText>
73+
<MargiInput
74+
value={contact.fullName}
75+
onChangeText={(t) => onChange({ ...contact, fullName: t })}
76+
placeholder="e.g. Sarah Jenkins"
77+
/>
78+
79+
<HudText variant="mono" style={styles.label}>Relationship</HudText>
80+
<MargiInput
81+
value={contact.relationship}
82+
onChangeText={(t) => onChange({ ...contact, relationship: t })}
83+
placeholder="e.g. Spouse, Parent, Sibling"
84+
/>
85+
86+
<HudText variant="mono" style={styles.label}>Phone Number</HudText>
87+
<MargiInput
88+
value={contact.phone}
89+
onChangeText={(t) => onChange({ ...contact, phone: t })}
90+
placeholder="+91 98765 43210"
91+
keyboardType="phone-pad"
92+
/>
93+
</HudCard>
94+
);
95+
}
96+
97+
/** Emergency Contacts screen — supports ICE 1 through ICE 5 */
2298
export default function EmergencyContactsScreen() {
2399
const insets = useSafeAreaInsets();
24100
const { profile, updateMedical } = useApp();
25-
const [contact, setContact] = useState<EmergencyContact>(() => {
101+
const userEdited = useRef(false);
102+
103+
// ICE 1 — primary contact
104+
const [primary, setPrimary] = useState<EmergencyContact>(() => {
26105
const m = normalizeMedical({ ...defaultMedical(), ...profile.medical });
27106
return m.primaryContact ?? defaultEmergencyContact();
28107
});
29-
const userEdited = useRef(false);
108+
109+
// ICE 2–5 — extra contacts (stored as array, max 4)
110+
const [extras, setExtras] = useState<EmergencyContact[]>(() => {
111+
const m = normalizeMedical({ ...defaultMedical(), ...profile.medical });
112+
return m.iceContacts ?? [];
113+
});
30114

31115
useEffect(() => {
32116
if (userEdited.current || !profile.medical) return;
33117
const m = normalizeMedical(profile.medical);
34-
setContact(m.primaryContact ?? defaultEmergencyContact());
118+
setPrimary(m.primaryContact ?? defaultEmergencyContact());
119+
setExtras(m.iceContacts ?? []);
35120
}, [profile.medical]);
36121

122+
const totalContacts = 1 + extras.length; // ICE 1 + extras
123+
const canAddMore = totalContacts < MAX_ICE;
124+
125+
const addContact = () => {
126+
if (!canAddMore) return;
127+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => undefined);
128+
userEdited.current = true;
129+
setExtras((prev) => [...prev, defaultEmergencyContact()]);
130+
};
131+
132+
const updateExtra = (i: number, c: EmergencyContact) => {
133+
userEdited.current = true;
134+
setExtras((prev) => prev.map((x, idx) => (idx === i ? c : x)));
135+
};
136+
137+
const deleteExtra = (i: number) => {
138+
userEdited.current = true;
139+
setExtras((prev) => prev.filter((_, idx) => idx !== i));
140+
};
141+
37142
const save = async () => {
38-
if (!contact.fullName.trim() || !contact.phone.trim()) {
39-
Alert.alert('Required fields', 'Enter the full name and phone number for your primary contact.');
143+
if (!primary.fullName.trim() || !primary.phone.trim()) {
144+
Alert.alert('Required fields', 'Enter the full name and phone number for your primary contact (ICE 1).');
40145
return;
41146
}
42147
const m = normalizeMedical({
43148
...defaultMedical(),
44149
...profile.medical,
45-
primaryContact: contact,
46-
emergencyContact: formatIceLine(contact),
150+
primaryContact: primary,
151+
emergencyContact: formatIceLine(primary),
152+
iceContacts: extras,
47153
});
48154
userEdited.current = true;
49155
await updateMedical(m);
@@ -57,73 +163,72 @@ export default function EmergencyContactsScreen() {
57163
contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + 32 }]}
58164
keyboardShouldPersistTaps="handled"
59165
>
166+
{/* Section header */}
60167
<View style={styles.headerRow}>
61168
<MaterialIcons name="contact-emergency" size={24} color={tokens.primary} />
62169
<HudText variant="headlineMd" style={styles.title}>
63170
Emergency Contacts
64171
</HudText>
65-
<Pressable
66-
onPress={() =>
67-
setContact({
68-
fullName: '',
69-
relationship: '',
70-
phone: '',
71-
})
72-
}
73-
style={styles.addBtn}
74-
>
75-
<MaterialIcons name="add" size={18} color={tokens.primary} />
76-
<HudText variant="mono" style={styles.addLabel}>
77-
Add
172+
{/* Contact count badge */}
173+
<View style={styles.countBadge}>
174+
<HudText variant="mono" style={styles.countText}>
175+
{totalContacts}/{MAX_ICE}
78176
</HudText>
79-
</Pressable>
177+
</View>
80178
</View>
81179

82-
<HudCard>
83-
<View style={styles.cardHead}>
84-
<HudText variant="bodyMd" style={styles.iceTitle}>
85-
Primary Contact (ICE 1)
86-
</HudText>
87-
<Pressable onPress={() => setContact(defaultEmergencyContact())}>
88-
<MaterialIcons name="delete-outline" size={22} color={tokens.outline} />
89-
</Pressable>
90-
</View>
91-
<HudText variant="mono" style={styles.label}>
92-
Full Name
93-
</HudText>
94-
<MargiInput
95-
value={contact.fullName}
96-
onChangeText={(t) => {
97-
userEdited.current = true;
98-
setContact((c) => ({ ...c, fullName: t }));
99-
}}
100-
placeholder="Sarah Jenkins"
101-
/>
102-
<HudText variant="mono" style={styles.label}>
103-
Relationship
104-
</HudText>
105-
<MargiInput
106-
value={contact.relationship}
107-
onChangeText={(t) => {
108-
userEdited.current = true;
109-
setContact((c) => ({ ...c, relationship: t }));
110-
}}
111-
placeholder="Spouse"
112-
/>
113-
<HudText variant="mono" style={styles.label}>
114-
Phone Number
115-
</HudText>
116-
<MargiInput
117-
value={contact.phone}
118-
onChangeText={(t) => {
119-
userEdited.current = true;
120-
setContact((c) => ({ ...c, phone: t }));
121-
}}
122-
placeholder="+91 98765 43210"
123-
keyboardType="phone-pad"
180+
<HudText variant="bodySm" style={styles.subtitle}>
181+
In Case of Emergency contacts are notified automatically when SOS is triggered.
182+
</HudText>
183+
184+
{/* ICE 1 — always shown, cannot be deleted */}
185+
<IceContactCard
186+
index={1}
187+
contact={primary}
188+
onChange={(c) => {
189+
userEdited.current = true;
190+
setPrimary(c);
191+
}}
192+
onDelete={() => {/* primary cannot be deleted */}}
193+
canDelete={false}
194+
/>
195+
196+
{/* ICE 2–5 — dynamically added extras */}
197+
{extras.map((c, i) => (
198+
<IceContactCard
199+
key={i}
200+
index={i + 2}
201+
contact={c}
202+
onChange={(updated) => updateExtra(i, updated)}
203+
onDelete={() => deleteExtra(i)}
204+
canDelete
124205
/>
125-
</HudCard>
206+
))}
126207

208+
{/* Add contact button — hidden when at ICE 5 */}
209+
{canAddMore && (
210+
<Pressable
211+
onPress={addContact}
212+
style={({ pressed }) => [styles.addBtn, pressed && styles.addBtnPressed]}
213+
accessibilityRole="button"
214+
accessibilityLabel="Add emergency contact"
215+
>
216+
<View style={styles.addIconCircle}>
217+
<MaterialIcons name="add" size={22} color={tokens.onPrimary} />
218+
</View>
219+
<View style={styles.addTextBlock}>
220+
<HudText variant="mono" style={styles.addLabel}>
221+
Add ICE {totalContacts + 1}
222+
</HudText>
223+
<HudText variant="bodySm" style={styles.addSub}>
224+
{MAX_ICE - totalContacts} slot{MAX_ICE - totalContacts !== 1 ? 's' : ''} remaining
225+
</HudText>
226+
</View>
227+
<MaterialIcons name="chevron-right" size={20} color={tokens.onSurfaceVariant} />
228+
</Pressable>
229+
)}
230+
231+
{/* Actions */}
127232
<View style={styles.actions}>
128233
<MargiButton label="Cancel" variant="ghost" onPress={() => router.back()} style={{ flex: 1 }} />
129234
<MargiButton label="Save & continue" onPress={save} large style={{ flex: 1 }} />
@@ -136,17 +241,97 @@ export default function EmergencyContactsScreen() {
136241
const styles = StyleSheet.create({
137242
root: { flex: 1, backgroundColor: tokens.background },
138243
scroll: { padding: 20, gap: 14 },
244+
139245
headerRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
140246
title: { flex: 1, color: tokens.primary, fontFamily: 'PublicSans_700Bold' },
141-
addBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, padding: 6 },
142-
addLabel: { color: tokens.primary, fontSize: 12 },
247+
248+
countBadge: {
249+
backgroundColor: tokens.primaryFixed,
250+
borderRadius: 99,
251+
paddingHorizontal: 10,
252+
paddingVertical: 3,
253+
borderWidth: 1,
254+
borderColor: tokens.primaryContainer,
255+
},
256+
countText: {
257+
fontSize: 10,
258+
color: tokens.primary,
259+
fontFamily: 'PublicSans_700Bold',
260+
letterSpacing: 0.4,
261+
},
262+
263+
subtitle: {
264+
color: tokens.onSurfaceVariant,
265+
lineHeight: 20,
266+
marginBottom: 4,
267+
},
268+
269+
card: { gap: 0 },
270+
143271
cardHead: {
144272
flexDirection: 'row',
145-
justifyContent: 'space-between',
146273
alignItems: 'center',
147-
marginBottom: 8,
274+
gap: 10,
275+
marginBottom: 10,
276+
},
277+
iceBadge: {
278+
backgroundColor: tokens.primary,
279+
borderRadius: 6,
280+
paddingHorizontal: 8,
281+
paddingVertical: 3,
282+
},
283+
iceBadgeText: {
284+
fontSize: 9,
285+
color: tokens.onPrimary,
286+
fontFamily: 'PublicSans_700Bold',
287+
letterSpacing: 0.8,
148288
},
149-
iceTitle: { color: tokens.primary, fontFamily: 'PublicSans_700Bold' },
289+
iceTitle: {
290+
flex: 1,
291+
color: tokens.primary,
292+
fontFamily: 'PublicSans_700Bold',
293+
},
294+
deleteBtn: {
295+
padding: 4,
296+
},
297+
150298
label: { color: tokens.primary, fontSize: 11, marginTop: 10, marginBottom: 4 },
299+
300+
/* Add contact button */
301+
addBtn: {
302+
flexDirection: 'row',
303+
alignItems: 'center',
304+
gap: 14,
305+
backgroundColor: tokens.surface,
306+
borderRadius: tokens.radius.card,
307+
borderWidth: 1.5,
308+
borderColor: tokens.primary,
309+
borderStyle: 'dashed',
310+
padding: 16,
311+
},
312+
addBtnPressed: {
313+
backgroundColor: tokens.primaryFixed,
314+
},
315+
addIconCircle: {
316+
width: 40,
317+
height: 40,
318+
borderRadius: 20,
319+
backgroundColor: tokens.primary,
320+
alignItems: 'center',
321+
justifyContent: 'center',
322+
},
323+
addTextBlock: { flex: 1 },
324+
addLabel: {
325+
color: tokens.primary,
326+
fontSize: 13,
327+
fontFamily: 'PublicSans_700Bold',
328+
letterSpacing: 0.3,
329+
},
330+
addSub: {
331+
color: tokens.onSurfaceVariant,
332+
fontSize: 11,
333+
marginTop: 2,
334+
},
335+
151336
actions: { flexDirection: 'row', gap: 12, marginTop: 8 },
152337
});

0 commit comments

Comments
 (0)