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 +5 -9
- package/src/components/UICards.js +599 -239
- package/src/hooks/useRealtimeVoice.js +21 -20
- package/src/screens/AIVoiceAssistantScreen.js +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vorqard-ai-sdk",
|
|
3
|
-
"version": "1.0.
|
|
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-
|
|
23
|
-
"
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
{action
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
390
|
+
// Auto-start on mount only — guard against unstable parent props re-triggering startListening
|
|
389
391
|
useEffect(() => {
|
|
390
|
-
|
|
392
|
+
if (!hasStartedRef.current) {
|
|
393
|
+
hasStartedRef.current = true;
|
|
394
|
+
startListening();
|
|
395
|
+
}
|
|
391
396
|
}, [startListening]);
|
|
392
397
|
|
|
393
|
-
// Auto-scroll
|
|
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
|
-
|
|
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';
|