vorqard-ai-sdk 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vorqard-ai-sdk",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Standalone React Native SDK for VORQARD AI Health Assistant and Report Analyzer",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -19,8 +19,9 @@
19
19
  "react-native-linear-gradient": "*",
20
20
  "react-native-safe-area-context": "*",
21
21
  "react-native-vector-icons": "*",
22
- "react-native-vision-camera": "*",
23
- "react-native-webrtc": "*"
22
+ "react-native-webrtc": "*",
23
+ "expo-av": "*",
24
+ "react-native-live-audio-stream": "*"
24
25
  },
25
26
  "keywords": [
26
27
  "vorqard",
@@ -30,10 +31,5 @@
30
31
  "healthcare"
31
32
  ],
32
33
  "author": "VORQARD Developer",
33
- "license": "Abhivorn Technologies",
34
- "dependencies": {
35
- "expo-av": "^16.0.8",
36
- "expo-file-system": "~18.0.0",
37
- "react-native-live-audio-stream": "^1.1.1"
38
- }
34
+ "license": "Abhivorn Technologies"
39
35
  }
@@ -1,239 +1,599 @@
1
- import React from 'react';
2
- import { View, Text, TouchableOpacity, StyleSheet, Image } from 'react-native';
3
- import Ionicons from 'react-native-vector-icons/Ionicons';
4
-
5
- export const UICardContainer = ({ action, onAction }) => {
6
- if (!action) return null;
7
-
8
- switch (action.type) {
9
- case 'selection':
10
- return <SelectionCard action={action} onAction={onAction} />;
11
- case 'slot_selection':
12
- return <SlotSelectionCard action={action} onAction={onAction} />;
13
- case 'booking_success':
14
- return <BookingSuccessCard action={action} onAction={onAction} />;
15
- case 'appointments_list':
16
- return <AppointmentsListCard action={action} onAction={onAction} />;
17
- default:
18
- return null;
19
- }
20
- };
21
-
22
- const SelectionCard = ({ action, onAction }) => {
23
- return (
24
- <View style={styles.card}>
25
- <Text style={styles.title}>{action.title || 'Please Select'}</Text>
26
- {action.options?.map((opt, i) => (
27
- <TouchableOpacity
28
- key={opt.id || i}
29
- style={styles.doctorItem}
30
- onPress={() => onAction(opt.label || opt.name)}
31
- >
32
- {opt.profile_picture ? (
33
- <Image source={{ uri: opt.profile_picture }} style={styles.docImg} />
34
- ) : (
35
- <View style={styles.docAvatar}>
36
- <Ionicons name="person" size={20} color="#fff" />
37
- </View>
38
- )}
39
- <View style={styles.docInfo}>
40
- <Text style={styles.docName}>{opt.name || opt.label}</Text>
41
- {opt.specialty && <Text style={styles.docSpec}>{opt.specialty}</Text>}
42
- <View style={styles.docMetaRow}>
43
- {opt.experience && <Text style={styles.docMeta}>{opt.experience} yrs exp</Text>}
44
- {opt.consultation_fee && <Text style={styles.docMeta}> • ₹{opt.consultation_fee}</Text>}
45
- </View>
46
- </View>
47
- </TouchableOpacity>
48
- ))}
49
- </View>
50
- );
51
- };
52
-
53
- const SlotSelectionCard = ({ action, onAction }) => {
54
- return (
55
- <View style={styles.card}>
56
- <Text style={styles.title}>{action.title || 'Select Time'}</Text>
57
- <Text style={styles.subtitle}>{action.selected_date}</Text>
58
- <View style={styles.slotGrid}>
59
- {action.options?.map((opt, i) => (
60
- <TouchableOpacity
61
- key={opt.id || i}
62
- style={styles.slotBtn}
63
- onPress={() => onAction(opt.label)}
64
- >
65
- <Text style={styles.slotText}>{opt.label}</Text>
66
- </TouchableOpacity>
67
- ))}
68
- </View>
69
- </View>
70
- );
71
- };
72
-
73
- const BookingSuccessCard = ({ action, onAction }) => {
74
- return (
75
- <View style={styles.successCard}>
76
- <View style={styles.successIcon}>
77
- <Ionicons name="checkmark-circle" size={48} color="#10B981" />
78
- </View>
79
- <Text style={styles.successTitle}>Booking Confirmed!</Text>
80
- <View style={styles.detailRow}>
81
- <Ionicons name="person-outline" size={16} color="rgba(255,255,255,0.7)" />
82
- <Text style={styles.detailText}>{action.doctor}</Text>
83
- </View>
84
- <View style={styles.detailRow}>
85
- <Ionicons name="calendar-outline" size={16} color="rgba(255,255,255,0.7)" />
86
- <Text style={styles.detailText}>{action.date} at {action.slot}</Text>
87
- </View>
88
- </View>
89
- );
90
- };
91
-
92
- const AppointmentsListCard = ({ action, onAction }) => {
93
- if (!action.appointments || action.appointments.length === 0) {
94
- return (
95
- <View style={styles.card}>
96
- <Text style={styles.title}>Your Appointments</Text>
97
- <Text style={styles.detailText}>No upcoming appointments found.</Text>
98
- </View>
99
- );
100
- }
101
- return (
102
- <View style={styles.card}>
103
- <Text style={styles.title}>Your Appointments</Text>
104
- {action.appointments.map((appt, i) => (
105
- <View key={appt.id || i} style={styles.apptItem}>
106
- <Text style={styles.docName}>{appt.doctor}</Text>
107
- <Text style={styles.docSpec}>{appt.specialty}</Text>
108
- <View style={styles.detailRow}>
109
- <Ionicons name="calendar-outline" size={14} color="#818CF8" />
110
- <Text style={styles.detailText}>{appt.date} at {appt.time}</Text>
111
- </View>
112
- </View>
113
- ))}
114
- </View>
115
- );
116
- };
117
-
118
- const styles = StyleSheet.create({
119
- card: {
120
- backgroundColor: 'rgba(255,255,255,0.08)',
121
- borderRadius: 16,
122
- padding: 16,
123
- marginVertical: 10,
124
- borderWidth: 1,
125
- borderColor: 'rgba(255,255,255,0.15)',
126
- },
127
- title: {
128
- fontSize: 16,
129
- fontWeight: 'bold',
130
- color: '#fff',
131
- marginBottom: 4,
132
- },
133
- subtitle: {
134
- fontSize: 13,
135
- color: 'rgba(255,255,255,0.6)',
136
- marginBottom: 12,
137
- },
138
- doctorItem: {
139
- flexDirection: 'row',
140
- backgroundColor: 'rgba(0,0,0,0.2)',
141
- padding: 12,
142
- borderRadius: 12,
143
- marginTop: 8,
144
- alignItems: 'center',
145
- borderWidth: 1,
146
- borderColor: 'rgba(255,255,255,0.05)',
147
- },
148
- docImg: {
149
- width: 44,
150
- height: 44,
151
- borderRadius: 22,
152
- marginRight: 12,
153
- },
154
- docAvatar: {
155
- width: 44,
156
- height: 44,
157
- borderRadius: 22,
158
- backgroundColor: 'rgba(99,102,241,0.5)',
159
- justifyContent: 'center',
160
- alignItems: 'center',
161
- marginRight: 12,
162
- },
163
- docInfo: {
164
- flex: 1,
165
- },
166
- docName: {
167
- fontSize: 15,
168
- fontWeight: 'bold',
169
- color: '#fff',
170
- },
171
- docSpec: {
172
- fontSize: 12,
173
- color: '#818CF8',
174
- marginTop: 2,
175
- },
176
- docMetaRow: {
177
- flexDirection: 'row',
178
- marginTop: 4,
179
- },
180
- docMeta: {
181
- fontSize: 11,
182
- color: 'rgba(255,255,255,0.5)',
183
- },
184
- slotGrid: {
185
- flexDirection: 'row',
186
- flexWrap: 'wrap',
187
- gap: 8,
188
- },
189
- slotBtn: {
190
- backgroundColor: 'rgba(99,102,241,0.2)',
191
- paddingVertical: 8,
192
- paddingHorizontal: 12,
193
- borderRadius: 8,
194
- borderWidth: 1,
195
- borderColor: 'rgba(99,102,241,0.4)',
196
- },
197
- slotText: {
198
- color: '#E0E7FF',
199
- fontSize: 13,
200
- fontWeight: '600',
201
- },
202
- successCard: {
203
- backgroundColor: 'rgba(16,185,129,0.1)',
204
- borderRadius: 16,
205
- padding: 20,
206
- marginVertical: 10,
207
- borderWidth: 1,
208
- borderColor: 'rgba(16,185,129,0.3)',
209
- alignItems: 'center',
210
- },
211
- successIcon: {
212
- marginBottom: 8,
213
- },
214
- successTitle: {
215
- fontSize: 18,
216
- fontWeight: 'bold',
217
- color: '#34D399',
218
- marginBottom: 16,
219
- },
220
- detailRow: {
221
- flexDirection: 'row',
222
- alignItems: 'center',
223
- marginTop: 6,
224
- gap: 6,
225
- width: '100%',
226
- },
227
- detailText: {
228
- fontSize: 14,
229
- color: 'rgba(255,255,255,0.8)',
230
- },
231
- apptItem: {
232
- backgroundColor: 'rgba(0,0,0,0.2)',
233
- padding: 12,
234
- borderRadius: 12,
235
- marginTop: 8,
236
- borderLeftWidth: 3,
237
- borderLeftColor: '#818CF8',
238
- },
239
- });
1
+ import React, { useState } from 'react';
2
+ import { View, Text, TouchableOpacity, StyleSheet, Image, Linking, ScrollView } from 'react-native';
3
+ import Ionicons from 'react-native-vector-icons/Ionicons';
4
+
5
+ // Rich card components (already built for the chat UI, reused here)
6
+ import { SpecialtySelectionCard } from './cards/SpecialtySelectionCard';
7
+ import { DoctorSelectionList } from './cards/DoctorCards';
8
+ import { SlotSelectionCard } from './cards/SlotSelectionCard';
9
+ import { BookingConfirmationCard } from './cards/BookingConfirmationCard';
10
+
11
+ /**
12
+ * UICardContainer renders the backend-driven ui_action payload.
13
+ *
14
+ * Interaction text conventions (sent via sendUIInteraction → DataChannel):
15
+ * specialty_cards → opt.id e.g. "Cardiology"
16
+ * doctor_cards → "doctor:<id>" e.g. "doctor:5"
17
+ * provider_selection → "doctor:<id>" VORQARD tap
18
+ * → "nearby:<id>" Nearby tap
19
+ * nearby_details → navigation via Linking (no DataChannel)
20
+ * slot_cards (slot) → opt.label e.g. "10:30"
21
+ * slot_cards (date) → "date:YYYY-MM-DD" from date picker
22
+ * payment_card → opt.id "cash" | "upi" | "card"
23
+ * booking_success → no interaction
24
+ */
25
+ export const UICardContainer = ({ action, onAction }) => {
26
+ if (!action) return null;
27
+
28
+ switch (action.type) {
29
+
30
+ // ── Phase 3/4: existing cards ─────────────────────────────────────────────
31
+
32
+ case 'specialty_cards':
33
+ return (
34
+ <SpecialtySelectionCard
35
+ options={action.items}
36
+ onOptionPress={(opt) => onAction(opt.id)}
37
+ />
38
+ );
39
+
40
+ case 'doctor_cards':
41
+ return (
42
+ <DoctorSelectionList
43
+ options={action.items}
44
+ onOptionPress={(opt) => onAction(`doctor:${opt.id}`)}
45
+ />
46
+ );
47
+
48
+ case 'slot_cards':
49
+ return (
50
+ <SlotSelectionCard
51
+ doctor={action.doctor}
52
+ selectedDate={action.date}
53
+ options={action.items}
54
+ onOptionPress={(opt) => onAction(opt.label)}
55
+ />
56
+ );
57
+
58
+ case 'confirmation_card':
59
+ return (
60
+ <BookingConfirmationCard
61
+ ui={{
62
+ doctor: action.doctor,
63
+ date: action.date,
64
+ slot: action.slot,
65
+ options: action.items,
66
+ }}
67
+ onOptionPress={(opt) => onAction(opt.id)}
68
+ />
69
+ );
70
+
71
+ case 'booking_success':
72
+ return <BookingSuccessCard action={action} />;
73
+
74
+ // ── Phase 5: two-category provider selection ──────────────────────────────
75
+
76
+ case 'provider_selection':
77
+ return (
78
+ <ProviderSelectionCard
79
+ action={action}
80
+ onVorqardPress={(doc) => onAction(`doctor:${doc.id}`)}
81
+ onNearbyPress={(p) => onAction(`nearby:${p.id}`)}
82
+ />
83
+ );
84
+
85
+ case 'nearby_details':
86
+ return <NearbyDetailsCard action={action} />;
87
+
88
+ // ── Phase 6: payment ──────────────────────────────────────────────────────
89
+
90
+ case 'payment_card':
91
+ return (
92
+ <PaymentCard
93
+ action={action}
94
+ onMethodPress={(opt) => onAction(opt.id)}
95
+ />
96
+ );
97
+
98
+ // ── Legacy / Gemini-path cards (backwards compat) ─────────────────────────
99
+
100
+ case 'selection':
101
+ return <LegacySelectionCard action={action} onAction={onAction} />;
102
+
103
+ case 'slot_selection':
104
+ return <LegacySlotCard action={action} onAction={onAction} />;
105
+
106
+ case 'appointments_list':
107
+ return <AppointmentsListCard action={action} onAction={onAction} />;
108
+
109
+ default:
110
+ return null;
111
+ }
112
+ };
113
+
114
+ // ── Booking success (shared by both paths) ────────────────────────────────────
115
+
116
+ const BookingSuccessCard = ({ action }) => (
117
+ <View style={styles.successCard}>
118
+ <View style={styles.successIcon}>
119
+ <Ionicons name="checkmark-circle" size={48} color="#10B981" />
120
+ </View>
121
+ <Text style={styles.successTitle}>Booking Confirmed!</Text>
122
+ <View style={styles.detailRow}>
123
+ <Ionicons name="person-outline" size={16} color="rgba(255,255,255,0.7)" />
124
+ <Text style={styles.detailText}>{action.doctor}</Text>
125
+ </View>
126
+ <View style={styles.detailRow}>
127
+ <Ionicons name="calendar-outline" size={16} color="rgba(255,255,255,0.7)" />
128
+ <Text style={styles.detailText}>{action.date} at {action.slot}</Text>
129
+ </View>
130
+ </View>
131
+ );
132
+
133
+ // ── Phase 5: Provider Selection (Our Services + Nearby) ──────────────────────
134
+
135
+ const ProviderSelectionCard = ({ action, onVorqardPress, onNearbyPress }) => {
136
+ const [tab, setTab] = useState('our_services');
137
+ const vorqardList = action.our_services || [];
138
+ const nearbyList = action.nearby || [];
139
+
140
+ return (
141
+ <View style={styles.card}>
142
+ <Text style={styles.title}>{action.title || 'Find a Provider'}</Text>
143
+
144
+ {/* Tab strip */}
145
+ <View style={styles.tabRow}>
146
+ <TouchableOpacity
147
+ style={[styles.tab, tab === 'our_services' && styles.tabActive]}
148
+ onPress={() => setTab('our_services')}
149
+ >
150
+ <Ionicons name="shield-checkmark-outline" size={14} color={tab === 'our_services' ? '#818CF8' : 'rgba(255,255,255,0.5)'} />
151
+ <Text style={[styles.tabText, tab === 'our_services' && styles.tabTextActive]}>
152
+ Our Services ({vorqardList.length})
153
+ </Text>
154
+ </TouchableOpacity>
155
+ <TouchableOpacity
156
+ style={[styles.tab, tab === 'nearby' && styles.tabActive]}
157
+ onPress={() => setTab('nearby')}
158
+ >
159
+ <Ionicons name="location-outline" size={14} color={tab === 'nearby' ? '#818CF8' : 'rgba(255,255,255,0.5)'} />
160
+ <Text style={[styles.tabText, tab === 'nearby' && styles.tabTextActive]}>
161
+ Nearby ({nearbyList.length})
162
+ </Text>
163
+ </TouchableOpacity>
164
+ </View>
165
+
166
+ {tab === 'our_services' ? (
167
+ vorqardList.length === 0 ? (
168
+ <Text style={styles.emptyText}>No VORQARD specialists found in this area.</Text>
169
+ ) : (
170
+ vorqardList.map((doc, i) => (
171
+ <TouchableOpacity key={doc.id || i} style={styles.doctorItem} onPress={() => onVorqardPress(doc)}>
172
+ {doc.profile_picture
173
+ ? <Image source={{ uri: doc.profile_picture }} style={styles.docImg} />
174
+ : <View style={styles.docAvatar}><Ionicons name="person" size={20} color="#fff" /></View>
175
+ }
176
+ <View style={styles.docInfo}>
177
+ <Text style={styles.docName}>{doc.name}</Text>
178
+ <Text style={styles.docSpec}>{doc.specialty}</Text>
179
+ <View style={styles.docMetaRow}>
180
+ {doc.consultation_fee > 0 && <Text style={styles.docMeta}>₹{doc.consultation_fee}</Text>}
181
+ {doc.distance != null && <Text style={styles.docMeta}> • {doc.distance} km</Text>}
182
+ {doc.rating > 0 && <Text style={styles.docMeta}> • ⭐ {doc.rating}</Text>}
183
+ </View>
184
+ </View>
185
+ <View style={styles.bookBadge}>
186
+ <Text style={styles.bookBadgeText}>Book</Text>
187
+ </View>
188
+ </TouchableOpacity>
189
+ ))
190
+ )
191
+ ) : (
192
+ nearbyList.length === 0 ? (
193
+ <Text style={styles.emptyText}>No nearby clinics found in this area.</Text>
194
+ ) : (
195
+ nearbyList.map((p, i) => (
196
+ <TouchableOpacity key={p.id || i} style={styles.nearbyItem} onPress={() => onNearbyPress(p)}>
197
+ <View style={styles.nearbyIcon}>
198
+ <Ionicons name="business-outline" size={22} color="#818CF8" />
199
+ </View>
200
+ <View style={styles.docInfo}>
201
+ <Text style={styles.docName}>{p.name}</Text>
202
+ {p.address ? <Text style={styles.docSpec} numberOfLines={1}>{p.address}</Text> : null}
203
+ <View style={styles.docMetaRow}>
204
+ {p.distance != null && <Text style={styles.docMeta}>{p.distance} km away</Text>}
205
+ {p.phone ? <Text style={styles.docMeta}> • {p.phone}</Text> : null}
206
+ </View>
207
+ </View>
208
+ <Ionicons name="chevron-forward" size={16} color="rgba(255,255,255,0.4)" />
209
+ </TouchableOpacity>
210
+ ))
211
+ )
212
+ )}
213
+ </View>
214
+ );
215
+ };
216
+
217
+ // ── Phase 5: Nearby Details (info-only, no booking) ───────────────────────────
218
+
219
+ const NearbyDetailsCard = ({ action }) => {
220
+ const p = action.provider || {};
221
+ const openMaps = () => {
222
+ if (p.maps_url) Linking.openURL(p.maps_url).catch(() => {});
223
+ };
224
+ const callPhone = () => {
225
+ if (p.phone) Linking.openURL(`tel:${p.phone}`).catch(() => {});
226
+ };
227
+
228
+ return (
229
+ <View style={[styles.card, styles.nearbyDetailCard]}>
230
+ <View style={styles.nearbyDetailHeader}>
231
+ <Ionicons name="business" size={28} color="#818CF8" />
232
+ <Text style={[styles.title, { marginLeft: 10 }]}>{p.name || 'Nearby Clinic'}</Text>
233
+ </View>
234
+
235
+ {p.address ? (
236
+ <View style={styles.detailRow}>
237
+ <Ionicons name="location-outline" size={16} color="rgba(255,255,255,0.6)" />
238
+ <Text style={styles.detailText}>{p.address}</Text>
239
+ </View>
240
+ ) : null}
241
+
242
+ {p.distance != null ? (
243
+ <View style={styles.detailRow}>
244
+ <Ionicons name="navigate-outline" size={16} color="rgba(255,255,255,0.6)" />
245
+ <Text style={styles.detailText}>{p.distance} km away</Text>
246
+ </View>
247
+ ) : null}
248
+
249
+ {p.phone ? (
250
+ <View style={styles.detailRow}>
251
+ <Ionicons name="call-outline" size={16} color="rgba(255,255,255,0.6)" />
252
+ <Text style={styles.detailText}>{p.phone}</Text>
253
+ </View>
254
+ ) : null}
255
+
256
+ <View style={styles.nearbyBtnRow}>
257
+ {p.phone ? (
258
+ <TouchableOpacity style={styles.nearbyBtn} onPress={callPhone}>
259
+ <Ionicons name="call" size={16} color="#fff" />
260
+ <Text style={styles.nearbyBtnText}>Call</Text>
261
+ </TouchableOpacity>
262
+ ) : null}
263
+ {p.maps_url ? (
264
+ <TouchableOpacity style={[styles.nearbyBtn, styles.navBtn]} onPress={openMaps}>
265
+ <Ionicons name="navigate" size={16} color="#fff" />
266
+ <Text style={styles.nearbyBtnText}>Navigate</Text>
267
+ </TouchableOpacity>
268
+ ) : null}
269
+ </View>
270
+
271
+ <View style={styles.nearbyNoBookNotice}>
272
+ <Ionicons name="information-circle-outline" size={14} color="rgba(255,255,255,0.4)" />
273
+ <Text style={styles.nearbyNoBookText}>Booking not available — visit or call directly.</Text>
274
+ </View>
275
+ </View>
276
+ );
277
+ };
278
+
279
+ // ── Phase 6: Payment Card ─────────────────────────────────────────────────────
280
+
281
+ const PaymentCard = ({ action, onMethodPress }) => {
282
+ const doc = action.doctor || {};
283
+ return (
284
+ <View style={[styles.card, styles.paymentCard]}>
285
+ <Text style={styles.title}>{action.title || 'Payment'}</Text>
286
+
287
+ {/* Appointment summary */}
288
+ <View style={styles.paymentSummary}>
289
+ <View style={styles.detailRow}>
290
+ <Ionicons name="person-outline" size={15} color="rgba(255,255,255,0.6)" />
291
+ <Text style={styles.detailText}>{doc.name || 'Doctor'}</Text>
292
+ </View>
293
+ <View style={styles.detailRow}>
294
+ <Ionicons name="calendar-outline" size={15} color="rgba(255,255,255,0.6)" />
295
+ <Text style={styles.detailText}>{action.date} at {action.slot}</Text>
296
+ </View>
297
+ <View style={[styles.detailRow, styles.amountRow]}>
298
+ <Ionicons name="cash-outline" size={15} color="#10B981" />
299
+ <Text style={styles.amountText}>₹{action.amount || 0}</Text>
300
+ </View>
301
+ </View>
302
+
303
+ {/* Payment options */}
304
+ <Text style={styles.subtitle}>Choose payment method</Text>
305
+ {(action.items || []).map((opt, i) => (
306
+ <TouchableOpacity key={opt.id || i} style={styles.paymentBtn} onPress={() => onMethodPress(opt)}>
307
+ <Ionicons name={opt.icon || 'card-outline'} size={20} color="#818CF8" />
308
+ <Text style={styles.paymentBtnText}>{opt.label}</Text>
309
+ <Ionicons name="chevron-forward" size={16} color="rgba(255,255,255,0.4)" />
310
+ </TouchableOpacity>
311
+ ))}
312
+ </View>
313
+ );
314
+ };
315
+
316
+ // ── Legacy cards (kept for Gemini-routed queries) ─────────────────────────────
317
+
318
+ const LegacySelectionCard = ({ action, onAction }) => (
319
+ <View style={styles.card}>
320
+ <Text style={styles.title}>{action.title || 'Please Select'}</Text>
321
+ {action.options?.map((opt, i) => (
322
+ <TouchableOpacity
323
+ key={opt.id || i}
324
+ style={styles.doctorItem}
325
+ onPress={() => onAction(opt.label || opt.name)}
326
+ >
327
+ {opt.profile_picture ? (
328
+ <Image source={{ uri: opt.profile_picture }} style={styles.docImg} />
329
+ ) : (
330
+ <View style={styles.docAvatar}>
331
+ <Ionicons name="person" size={20} color="#fff" />
332
+ </View>
333
+ )}
334
+ <View style={styles.docInfo}>
335
+ <Text style={styles.docName}>{opt.name || opt.label}</Text>
336
+ {opt.specialty && <Text style={styles.docSpec}>{opt.specialty}</Text>}
337
+ <View style={styles.docMetaRow}>
338
+ {opt.experience && <Text style={styles.docMeta}>{opt.experience} yrs exp</Text>}
339
+ {opt.consultation_fee && <Text style={styles.docMeta}> • ₹{opt.consultation_fee}</Text>}
340
+ </View>
341
+ </View>
342
+ </TouchableOpacity>
343
+ ))}
344
+ </View>
345
+ );
346
+
347
+ const LegacySlotCard = ({ action, onAction }) => (
348
+ <View style={styles.card}>
349
+ <Text style={styles.title}>{action.title || 'Select Time'}</Text>
350
+ <Text style={styles.subtitle}>{action.selected_date}</Text>
351
+ <View style={styles.slotGrid}>
352
+ {action.options?.map((opt, i) => (
353
+ <TouchableOpacity
354
+ key={opt.id || i}
355
+ style={styles.slotBtn}
356
+ onPress={() => onAction(opt.label)}
357
+ >
358
+ <Text style={styles.slotText}>{opt.label}</Text>
359
+ </TouchableOpacity>
360
+ ))}
361
+ </View>
362
+ </View>
363
+ );
364
+
365
+ const AppointmentsListCard = ({ action, onAction }) => {
366
+ if (!action.appointments || action.appointments.length === 0) {
367
+ return (
368
+ <View style={styles.card}>
369
+ <Text style={styles.title}>Your Appointments</Text>
370
+ <Text style={styles.detailText}>No upcoming appointments found.</Text>
371
+ </View>
372
+ );
373
+ }
374
+ return (
375
+ <View style={styles.card}>
376
+ <Text style={styles.title}>Your Appointments</Text>
377
+ {action.appointments.map((appt, i) => (
378
+ <View key={appt.id || i} style={styles.apptItem}>
379
+ <Text style={styles.docName}>{appt.doctor}</Text>
380
+ <Text style={styles.docSpec}>{appt.specialty}</Text>
381
+ <View style={styles.detailRow}>
382
+ <Ionicons name="calendar-outline" size={14} color="#818CF8" />
383
+ <Text style={styles.detailText}>{appt.date} at {appt.time}</Text>
384
+ </View>
385
+ </View>
386
+ ))}
387
+ </View>
388
+ );
389
+ };
390
+
391
+ // ── Styles ────────────────────────────────────────────────────────────────────
392
+
393
+ const styles = StyleSheet.create({
394
+ card: {
395
+ backgroundColor: 'rgba(255,255,255,0.08)',
396
+ borderRadius: 16,
397
+ padding: 16,
398
+ marginVertical: 10,
399
+ borderWidth: 1,
400
+ borderColor: 'rgba(255,255,255,0.15)',
401
+ },
402
+ title: {
403
+ fontSize: 16,
404
+ fontWeight: 'bold',
405
+ color: '#fff',
406
+ marginBottom: 4,
407
+ },
408
+ subtitle: {
409
+ fontSize: 13,
410
+ color: 'rgba(255,255,255,0.6)',
411
+ marginBottom: 12,
412
+ },
413
+ doctorItem: {
414
+ flexDirection: 'row',
415
+ backgroundColor: 'rgba(0,0,0,0.2)',
416
+ padding: 12,
417
+ borderRadius: 12,
418
+ marginTop: 8,
419
+ alignItems: 'center',
420
+ borderWidth: 1,
421
+ borderColor: 'rgba(255,255,255,0.05)',
422
+ },
423
+ docImg: {
424
+ width: 44,
425
+ height: 44,
426
+ borderRadius: 22,
427
+ marginRight: 12,
428
+ },
429
+ docAvatar: {
430
+ width: 44,
431
+ height: 44,
432
+ borderRadius: 22,
433
+ backgroundColor: 'rgba(99,102,241,0.5)',
434
+ justifyContent: 'center',
435
+ alignItems: 'center',
436
+ marginRight: 12,
437
+ },
438
+ docInfo: { flex: 1 },
439
+ docName: { fontSize: 15, fontWeight: 'bold', color: '#fff' },
440
+ docSpec: { fontSize: 12, color: '#818CF8', marginTop: 2 },
441
+ docMetaRow: { flexDirection: 'row', marginTop: 4 },
442
+ docMeta: { fontSize: 11, color: 'rgba(255,255,255,0.5)' },
443
+ slotGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
444
+ slotBtn: {
445
+ backgroundColor: 'rgba(99,102,241,0.2)',
446
+ paddingVertical: 8,
447
+ paddingHorizontal: 12,
448
+ borderRadius: 8,
449
+ borderWidth: 1,
450
+ borderColor: 'rgba(99,102,241,0.4)',
451
+ },
452
+ slotText: { color: '#E0E7FF', fontSize: 13, fontWeight: '600' },
453
+ successCard: {
454
+ backgroundColor: 'rgba(16,185,129,0.1)',
455
+ borderRadius: 16,
456
+ padding: 20,
457
+ marginVertical: 10,
458
+ borderWidth: 1,
459
+ borderColor: 'rgba(16,185,129,0.3)',
460
+ alignItems: 'center',
461
+ },
462
+ successIcon: { marginBottom: 8 },
463
+ successTitle: { fontSize: 18, fontWeight: 'bold', color: '#34D399', marginBottom: 16 },
464
+ detailRow: {
465
+ flexDirection: 'row',
466
+ alignItems: 'center',
467
+ marginTop: 6,
468
+ gap: 6,
469
+ width: '100%',
470
+ },
471
+ detailText: { fontSize: 14, color: 'rgba(255,255,255,0.8)' },
472
+ apptItem: {
473
+ backgroundColor: 'rgba(0,0,0,0.2)',
474
+ padding: 12,
475
+ borderRadius: 12,
476
+ marginTop: 8,
477
+ borderLeftWidth: 3,
478
+ borderLeftColor: '#818CF8',
479
+ },
480
+
481
+ // ── Provider Selection ────────────────────────────────────────────────────
482
+ tabRow: {
483
+ flexDirection: 'row',
484
+ marginBottom: 12,
485
+ gap: 8,
486
+ },
487
+ tab: {
488
+ flex: 1,
489
+ flexDirection: 'row',
490
+ alignItems: 'center',
491
+ justifyContent: 'center',
492
+ gap: 4,
493
+ paddingVertical: 8,
494
+ borderRadius: 10,
495
+ backgroundColor: 'rgba(255,255,255,0.06)',
496
+ borderWidth: 1,
497
+ borderColor: 'rgba(255,255,255,0.08)',
498
+ },
499
+ tabActive: {
500
+ backgroundColor: 'rgba(99,102,241,0.2)',
501
+ borderColor: 'rgba(99,102,241,0.5)',
502
+ },
503
+ tabText: { fontSize: 12, color: 'rgba(255,255,255,0.5)', fontWeight: '600' },
504
+ tabTextActive: { color: '#818CF8' },
505
+ nearbyItem: {
506
+ flexDirection: 'row',
507
+ backgroundColor: 'rgba(0,0,0,0.2)',
508
+ padding: 12,
509
+ borderRadius: 12,
510
+ marginTop: 8,
511
+ alignItems: 'center',
512
+ gap: 10,
513
+ borderWidth: 1,
514
+ borderColor: 'rgba(255,255,255,0.05)',
515
+ },
516
+ nearbyIcon: {
517
+ width: 44,
518
+ height: 44,
519
+ borderRadius: 22,
520
+ backgroundColor: 'rgba(99,102,241,0.15)',
521
+ justifyContent: 'center',
522
+ alignItems: 'center',
523
+ },
524
+ bookBadge: {
525
+ backgroundColor: 'rgba(99,102,241,0.3)',
526
+ paddingHorizontal: 10,
527
+ paddingVertical: 5,
528
+ borderRadius: 8,
529
+ borderWidth: 1,
530
+ borderColor: 'rgba(99,102,241,0.5)',
531
+ },
532
+ bookBadgeText: { color: '#A5B4FC', fontSize: 12, fontWeight: '700' },
533
+ emptyText: { color: 'rgba(255,255,255,0.4)', fontSize: 13, textAlign: 'center', marginTop: 12 },
534
+
535
+ // ── Nearby Details ────────────────────────────────────────────────────────
536
+ nearbyDetailCard: {
537
+ borderColor: 'rgba(99,102,241,0.3)',
538
+ },
539
+ nearbyDetailHeader: {
540
+ flexDirection: 'row',
541
+ alignItems: 'center',
542
+ marginBottom: 12,
543
+ },
544
+ nearbyBtnRow: {
545
+ flexDirection: 'row',
546
+ gap: 10,
547
+ marginTop: 14,
548
+ },
549
+ nearbyBtn: {
550
+ flex: 1,
551
+ flexDirection: 'row',
552
+ alignItems: 'center',
553
+ justifyContent: 'center',
554
+ gap: 6,
555
+ backgroundColor: 'rgba(99,102,241,0.25)',
556
+ paddingVertical: 10,
557
+ borderRadius: 10,
558
+ borderWidth: 1,
559
+ borderColor: 'rgba(99,102,241,0.4)',
560
+ },
561
+ navBtn: {
562
+ backgroundColor: 'rgba(16,185,129,0.2)',
563
+ borderColor: 'rgba(16,185,129,0.4)',
564
+ },
565
+ nearbyBtnText: { color: '#fff', fontSize: 14, fontWeight: '600' },
566
+ nearbyNoBookNotice: {
567
+ flexDirection: 'row',
568
+ alignItems: 'center',
569
+ gap: 5,
570
+ marginTop: 12,
571
+ },
572
+ nearbyNoBookText: { color: 'rgba(255,255,255,0.35)', fontSize: 11 },
573
+
574
+ // ── Payment Card ──────────────────────────────────────────────────────────
575
+ paymentCard: {
576
+ borderColor: 'rgba(16,185,129,0.25)',
577
+ },
578
+ paymentSummary: {
579
+ backgroundColor: 'rgba(0,0,0,0.2)',
580
+ borderRadius: 10,
581
+ padding: 12,
582
+ marginBottom: 14,
583
+ gap: 6,
584
+ },
585
+ amountRow: { marginTop: 4 },
586
+ amountText: { fontSize: 20, fontWeight: 'bold', color: '#34D399', marginLeft: 6 },
587
+ paymentBtn: {
588
+ flexDirection: 'row',
589
+ alignItems: 'center',
590
+ gap: 12,
591
+ backgroundColor: 'rgba(255,255,255,0.06)',
592
+ padding: 14,
593
+ borderRadius: 12,
594
+ marginTop: 8,
595
+ borderWidth: 1,
596
+ borderColor: 'rgba(255,255,255,0.08)',
597
+ },
598
+ paymentBtnText: { flex: 1, color: '#fff', fontSize: 15, fontWeight: '600' },
599
+ });
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import { Platform, PermissionsAndroid } from 'react-native';
3
3
  import { mediaDevices, RTCPeerConnection, RTCSessionDescription } from 'react-native-webrtc';
4
- import { Audio } from 'expo-av';
4
+
5
5
 
6
6
  export const VOICE_STATE = {
7
7
  IDLE: 'idle',
@@ -30,14 +30,6 @@ export const useRealtimeVoice = ({ onMessageAdded, backendUrl, userToken } = {})
30
30
  setAiResponse('');
31
31
  setUiAction(null);
32
32
 
33
- // 1. Audio Session permissions (mostly needed for iOS routing)
34
- const { status } = await Audio.requestPermissionsAsync();
35
- if (status !== 'granted') {
36
- setError('Microphone permission denied by Expo AV.');
37
- setVoiceState(VOICE_STATE.ERROR);
38
- return;
39
- }
40
-
41
33
  // 1b. Explicitly request Android microphone permission using PermissionsAndroid
42
34
  if (Platform.OS === 'android') {
43
35
  const granted = await PermissionsAndroid.request(
@@ -57,13 +49,22 @@ export const useRealtimeVoice = ({ onMessageAdded, backendUrl, userToken } = {})
57
49
  }
58
50
  }
59
51
 
60
- await Audio.setAudioModeAsync({
61
- allowsRecordingIOS: true,
62
- playsInSilentModeIOS: true,
63
- playThroughEarpieceAndroid: false, // FORCE SPEAKERPHONE ON ANDROID
64
- staysActiveInBackground: true,
65
- });
66
-
52
+ // 1c. Force audio to loudspeaker instead of earpiece using expo-av (if available)
53
+ try {
54
+ const { Audio } = require('expo-av');
55
+ if (Audio) {
56
+ await Audio.setAudioModeAsync({
57
+ allowsRecordingIOS: true,
58
+ playsInSilentModeIOS: true,
59
+ staysActiveInBackground: true,
60
+ shouldRouteThroughEarpieceAndroid: false, // Force Speakerphone
61
+ });
62
+ console.log('[WebRTC] Audio routed to main loudspeaker via expo-av');
63
+ }
64
+ } catch (audioErr) {
65
+ console.log('[WebRTC] expo-av not found or failed, using default routing.');
66
+ }
67
+
67
68
  // 2. Setup WebRTC Peer Connection
68
69
  const pc = new RTCPeerConnection({
69
70
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
@@ -101,13 +102,15 @@ export const useRealtimeVoice = ({ onMessageAdded, backendUrl, userToken } = {})
101
102
  setVoiceState(VOICE_STATE.PROCESSING);
102
103
  } else if (data.type === 'response_partial') {
103
104
  setAiResponse(prev => prev + data.text);
104
- setVoiceState(VOICE_STATE.SPEAKING);
105
+ // Only transition to SPEAKING once — avoid re-rendering on every character
106
+ setVoiceState(prev => prev === VOICE_STATE.SPEAKING ? prev : VOICE_STATE.SPEAKING);
105
107
  } else if (data.type === 'response') {
106
108
  setAiResponse(data.text);
107
109
  setVoiceState(VOICE_STATE.SPEAKING);
108
110
  } else if (data.type === 'interrupted') {
109
111
  setAiResponse('');
110
112
  setTranscript('');
113
+ setUiAction(null);
111
114
  setVoiceState(VOICE_STATE.LISTENING);
112
115
  } else if (data.type === 'turn_complete') {
113
116
  setAiResponse('');
@@ -228,9 +231,7 @@ export const useRealtimeVoice = ({ onMessageAdded, backendUrl, userToken } = {})
228
231
  dcRef.current.close();
229
232
  dcRef.current = null;
230
233
  }
231
- try {
232
- await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
233
- } catch (_) {}
234
+
234
235
 
235
236
  setVoiceState(VOICE_STATE.IDLE);
236
237
  }, []);
@@ -24,7 +24,7 @@
24
24
  * └──────────────────────────────┘
25
25
  */
26
26
 
27
- import React, { useEffect, useRef, useCallback } from 'react';
27
+ import React, { useEffect, useRef, useCallback, useState } from 'react';
28
28
  import {
29
29
  View,
30
30
  Text,
@@ -363,6 +363,8 @@ export const AIVoiceAssistantScreen = ({
363
363
  } = useRealtimeVoice({ onMessageAdded, backendUrl, userToken });
364
364
 
365
365
  const scrollRef = useRef(null);
366
+ const scrollTimerRef = useRef(null);
367
+ const hasStartedRef = useRef(false);
366
368
  const cfg = STATE_CFG[voiceState] || STATE_CFG[VOICE_STATE.IDLE];
367
369
 
368
370
  const isListening = voiceState === VOICE_STATE.LISTENING;
@@ -385,16 +387,26 @@ export const AIVoiceAssistantScreen = ({
385
387
  }
386
388
  }, [voiceState, interrupt, startListening]);
387
389
 
388
- // Auto-start listening when the screen opens
390
+ // Auto-start on mount only guard against unstable parent props re-triggering startListening
389
391
  useEffect(() => {
390
- startListening();
392
+ if (!hasStartedRef.current) {
393
+ hasStartedRef.current = true;
394
+ startListening();
395
+ }
391
396
  }, [startListening]);
392
397
 
393
- // Auto-scroll transcript when new content appears
398
+ // Auto-scroll debounced fires at most once per 150ms to avoid thrashing on every character
394
399
  useEffect(() => {
395
400
  if (transcript || aiResponse) {
396
- setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
401
+ if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
402
+ scrollTimerRef.current = setTimeout(
403
+ () => scrollRef.current?.scrollToEnd({ animated: true }),
404
+ 150
405
+ );
397
406
  }
407
+ return () => {
408
+ if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
409
+ };
398
410
  }, [transcript, aiResponse]);
399
411
 
400
412
  const displayName = patientName || 'there';