whung99 commited on
Commit
e49190e
Β·
1 Parent(s): 8717018

Add home screen with voice interface and navigation

Browse files

- Voice-first home screen with tap-to-speak MicButton
- Real-time agent reasoning UI showing tool call progression
- Emergency banner for high/emergency urgency detection
- Quick action cards: Doctor, Medicine, Food, Check-in
- Check-in countdown timer with visual progress
- Bottom navigation: Home, Timeline, Doctor, Medicine, About
- App layout with global state provider
- Public assets: icons, placeholders

Built with Mistral Vibe

app/(app)/layout.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BottomNav } from "@/components/sickbuddy/bottom-nav"
2
+ import { AppProvider } from "@/lib/app-context"
3
+
4
+ export default function AppLayout({ children }: { children: React.ReactNode }) {
5
+ return (
6
+ <AppProvider>
7
+ {children}
8
+ <BottomNav />
9
+ </AppProvider>
10
+ )
11
+ }
app/(app)/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { HomeScreen } from "@/components/sickbuddy/home-screen"
2
+
3
+ export default function HomePage() {
4
+ return <HomeScreen />
5
+ }
components/sickbuddy/bottom-nav.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { usePathname } from "next/navigation"
5
+ import { Home, ClipboardList, Stethoscope, Pill, Info } from "lucide-react"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const navItems = [
9
+ { href: "/", icon: Home, label: "Home" },
10
+ { href: "/timeline", icon: ClipboardList, label: "Timeline" },
11
+ { href: "/doctor", icon: Stethoscope, label: "Doctor" },
12
+ { href: "/medication", icon: Pill, label: "Medicine" },
13
+ { href: "/about", icon: Info, label: "About" },
14
+ ]
15
+
16
+ export function BottomNav() {
17
+ const pathname = usePathname()
18
+
19
+ // Don't show bottom nav on check-in or emergency screens
20
+ if (pathname === "/checkin" || pathname === "/emergency") return null
21
+
22
+ return (
23
+ <nav
24
+ className="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[430px] z-50"
25
+ aria-label="Main navigation"
26
+ >
27
+ <div className="flex items-center justify-around border-t border-border bg-background/80 backdrop-blur-xl px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
28
+ {navItems.map((item) => {
29
+ const isActive = pathname === item.href
30
+ return (
31
+ <Link
32
+ key={item.href}
33
+ href={item.href}
34
+ className={cn(
35
+ "flex flex-col items-center gap-1 min-w-[60px] min-h-[48px] justify-center rounded-xl px-3 py-1.5 transition-colors duration-200",
36
+ isActive
37
+ ? "text-primary"
38
+ : "text-muted-foreground hover:text-foreground"
39
+ )}
40
+ aria-current={isActive ? "page" : undefined}
41
+ >
42
+ <item.icon className="h-5 w-5" />
43
+ <span className="text-[10px] font-medium">{item.label}</span>
44
+ </Link>
45
+ )
46
+ })}
47
+ </div>
48
+ </nav>
49
+ )
50
+ }
components/sickbuddy/home-screen.tsx ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useState, useRef, useEffect } from "react"
4
+ import Link from "next/link"
5
+ import { ClipboardList, Volume2, Stethoscope, Pill, UtensilsCrossed, Bot, Zap } from "lucide-react"
6
+ import {
7
+ AppShell,
8
+ SickBuddyLogo,
9
+ GlassCard,
10
+ MicButton,
11
+ WaveBars,
12
+ } from "./design-system"
13
+ import { cn } from "@/lib/utils"
14
+ import { VoxtralRecorder } from "@/lib/voxtral"
15
+ import { speak, stopSpeaking } from "@/lib/elevenlabs"
16
+ import { agentAnalyze, analyzeSymptoms, AGENT_TOOL_DISPLAY } from "@/lib/mistral"
17
+ import type { AgentStep } from "@/lib/mistral"
18
+ import { useApp, formatCountdown, countdownProgress } from "@/lib/app-context"
19
+
20
+ type VoiceState = "idle" | "listening" | "processing" | "responding"
21
+
22
+ const actionCards = [
23
+ { label: "See a Doctor", href: "/doctor", icon: Stethoscope },
24
+ { label: "Get Medicine", href: "/medication", icon: Pill },
25
+ { label: "Order Food", href: "/food", icon: UtensilsCrossed },
26
+ ]
27
+
28
+ export function HomeScreen() {
29
+ const [voiceState, setVoiceState] = useState<VoiceState>("idle")
30
+ const [displayText, setDisplayText] = useState(
31
+ "Hi! I'm SickBuddy. Tap the microphone and tell me how you're feeling."
32
+ )
33
+ const [isReplaying, setIsReplaying] = useState(false)
34
+ const [agentSteps, setAgentSteps] = useState<AgentStep[]>([])
35
+ const [showAgentSteps, setShowAgentSteps] = useState(false)
36
+ const [agentTime, setAgentTime] = useState(0)
37
+ const recorderRef = useRef<VoxtralRecorder | null>(null)
38
+ const { analysis, setAnalysis, addMessage, conversationHistory, nextCheckInMs } = useApp()
39
+
40
+ useEffect(() => {
41
+ if (analysis?.response) setDisplayText(analysis.response)
42
+ }, [analysis])
43
+
44
+ async function handleMicClick() {
45
+ if (voiceState === "listening") {
46
+ await stopRecording()
47
+ } else if (voiceState === "idle" || voiceState === "responding") {
48
+ await startRecording()
49
+ }
50
+ }
51
+
52
+ async function startRecording() {
53
+ try {
54
+ stopSpeaking()
55
+ recorderRef.current = new VoxtralRecorder()
56
+ await recorderRef.current.startRecording()
57
+ setVoiceState("listening")
58
+ setAgentSteps([])
59
+ setShowAgentSteps(false)
60
+ } catch {
61
+ alert("Please allow microphone access to use SickBuddy.")
62
+ }
63
+ }
64
+
65
+ async function stopRecording() {
66
+ if (!recorderRef.current) return
67
+ setVoiceState("processing")
68
+ try {
69
+ // Voxtral now returns BOTH transcription AND audio understanding
70
+ // (voice quality, coughs, breathing, emotional tone)
71
+ const { text: transcript, audioContext } = await recorderRef.current.stopAndTranscribe()
72
+ addMessage("user", transcript)
73
+
74
+ // Log audio observations for debugging
75
+ if (audioContext) {
76
+ console.log("[Audio Understanding]", audioContext)
77
+ }
78
+
79
+ // Use agent-based analysis (with tool calling) as primary
80
+ // Audio context gives the agent "ears" β€” like a real doctor listening
81
+ let result
82
+ try {
83
+ result = await agentAnalyze(transcript, conversationHistory, audioContext)
84
+ if (result.agentSteps) setAgentSteps(result.agentSteps)
85
+ if (result.agentMeta) setAgentTime(result.agentMeta.totalTime)
86
+ } catch {
87
+ console.warn("Agent failed, falling back to simple analysis")
88
+ result = await analyzeSymptoms(transcript, conversationHistory)
89
+ }
90
+
91
+ setAnalysis(result)
92
+ addMessage("assistant", result.response)
93
+ setDisplayText(result.response)
94
+ setVoiceState("responding")
95
+ await speak(result.response)
96
+ } catch {
97
+ setDisplayText("Sorry, I had trouble with that. Please try again.")
98
+ setVoiceState("responding")
99
+ }
100
+ }
101
+
102
+ async function handleReplay() {
103
+ if (isReplaying || !displayText) return
104
+ setIsReplaying(true)
105
+ await speak(displayText)
106
+ setIsReplaying(false)
107
+ }
108
+
109
+ const statusText: Record<VoiceState, string> = {
110
+ idle: "Tap and speak...",
111
+ listening: "I'm listening... (tap to stop)",
112
+ processing: "SickBuddy is thinking...",
113
+ responding: "",
114
+ }
115
+
116
+ return (
117
+ <AppShell className="flex flex-col min-h-screen pb-24">
118
+ {/* Top bar */}
119
+ <header className="flex items-center justify-between px-4 py-3">
120
+ <SickBuddyLogo />
121
+ <Link
122
+ href="/timeline"
123
+ className="flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-200 hover:bg-secondary"
124
+ aria-label="Health Timeline"
125
+ >
126
+ <ClipboardList className="h-5 w-5 text-muted-foreground" />
127
+ </Link>
128
+ </header>
129
+
130
+ {/* Emergency banner */}
131
+ {analysis?.urgency === "emergency" && (
132
+ <Link href="/emergency" className="mx-4 mb-2">
133
+ <div className="flex items-center gap-2 rounded-2xl border border-danger/30 bg-danger/10 px-4 py-3 animate-pulse">
134
+ <span>🚨</span>
135
+ <p className="text-sm font-semibold text-danger">Your symptoms need immediate attention</p>
136
+ </div>
137
+ </Link>
138
+ )}
139
+
140
+ {/* Center hero */}
141
+ <div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
142
+ <MicButton
143
+ state={voiceState === "responding" ? "idle" : voiceState}
144
+ onClick={handleMicClick}
145
+ size={120}
146
+ />
147
+
148
+ {/* Status */}
149
+ {voiceState === "listening" && (
150
+ <div className="flex flex-col items-center gap-3">
151
+ <p className="text-sm font-medium text-foreground">{statusText.listening}</p>
152
+ <WaveBars />
153
+ </div>
154
+ )}
155
+ {voiceState === "processing" && (
156
+ <div className="flex flex-col items-center gap-3">
157
+ <div className="flex items-center gap-2">
158
+ <Bot className="h-4 w-4 text-primary animate-pulse" />
159
+ <p className="text-sm font-medium text-foreground">Agent is analyzing...</p>
160
+ </div>
161
+ <div className="flex gap-3 mt-1">
162
+ {["πŸ”", "πŸ’Š", "πŸ“…", "πŸ₯"].map((emoji, i) => (
163
+ <span
164
+ key={emoji}
165
+ className="text-lg opacity-30 animate-pulse"
166
+ style={{ animationDelay: `${i * 0.4}s` }}
167
+ >
168
+ {emoji}
169
+ </span>
170
+ ))}
171
+ </div>
172
+ </div>
173
+ )}
174
+
175
+ {/* Response / Welcome card */}
176
+ {(voiceState === "idle" || voiceState === "responding") && displayText && (
177
+ <GlassCard className="animate-fade-in-up w-full relative">
178
+ <button
179
+ type="button"
180
+ onClick={handleReplay}
181
+ className={cn(
182
+ "absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-lg hover:bg-secondary transition-colors duration-200",
183
+ isReplaying && "text-primary"
184
+ )}
185
+ aria-label="Replay audio"
186
+ >
187
+ <Volume2 className={cn("h-4 w-4", isReplaying ? "text-primary" : "text-muted-foreground")} />
188
+ </button>
189
+ <p className="text-sm leading-relaxed text-foreground pr-10">{displayText}</p>
190
+ </GlassCard>
191
+ )}
192
+
193
+ {/* Agent Reasoning Steps */}
194
+ {agentSteps.length > 0 && (voiceState === "idle" || voiceState === "responding") && (
195
+ <div className="w-full animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
196
+ <button
197
+ type="button"
198
+ onClick={() => setShowAgentSteps(!showAgentSteps)}
199
+ className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors mb-2 px-1"
200
+ >
201
+ <Zap className="h-3 w-3 text-primary" />
202
+ <span>
203
+ Agent used {agentSteps.length} tools
204
+ {agentTime > 0 && ` in ${(agentTime / 1000).toFixed(1)}s`}
205
+ </span>
206
+ <span className="text-muted-foreground/50">{showAgentSteps ? "β–²" : "β–Ό"}</span>
207
+ </button>
208
+
209
+ {showAgentSteps && (
210
+ <GlassCard className="!p-3 !gap-2 flex flex-col">
211
+ <div className="flex items-center gap-1.5 mb-1">
212
+ <Bot className="h-3.5 w-3.5 text-primary" />
213
+ <span className="text-xs font-semibold text-foreground">Agent Reasoning</span>
214
+ </div>
215
+ {agentSteps.map((step, i) => {
216
+ const display = AGENT_TOOL_DISPLAY[step.tool] || { icon: "πŸ”§", label: step.tool }
217
+ return (
218
+ <div key={i} className="flex items-start gap-2 text-xs">
219
+ <span className="flex-shrink-0 mt-0.5">{display.icon}</span>
220
+ <div className="flex-1 min-w-0">
221
+ <span className="font-medium text-foreground">{display.label}</span>
222
+ <span className="text-muted-foreground ml-1.5">{step.summary}</span>
223
+ </div>
224
+ <span className="text-muted-foreground/40 flex-shrink-0 text-[10px] tabular-nums">
225
+ {step.timestamp > 0 ? `${(step.timestamp / 1000).toFixed(1)}s` : ""}
226
+ </span>
227
+ </div>
228
+ )
229
+ })}
230
+ </GlassCard>
231
+ )}
232
+ </div>
233
+ )}
234
+ </div>
235
+
236
+ {/* Action Cards */}
237
+ <div className="px-4 pb-4">
238
+ <div className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-none">
239
+ {actionCards.map((card) => (
240
+ <Link
241
+ key={card.href}
242
+ href={card.href}
243
+ className="flex-shrink-0 flex flex-col items-center gap-2 rounded-2xl border border-border bg-card p-4 w-[120px] transition-all duration-200 hover:bg-secondary hover:border-primary/20"
244
+ >
245
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
246
+ <card.icon className="h-5 w-5 text-primary" />
247
+ </div>
248
+ <span className="text-xs font-medium text-foreground text-center">{card.label}</span>
249
+ </Link>
250
+ ))}
251
+ </div>
252
+ </div>
253
+
254
+ {/* Check-in countdown */}
255
+ <div className="px-4 pb-4">
256
+ <Link href="/checkin">
257
+ <GlassCard className="flex flex-col gap-2 !p-3 hover:bg-secondary/50 transition-colors">
258
+ <div className="flex items-center justify-between">
259
+ <p className="text-xs text-muted-foreground">
260
+ Next check-in in <span className="text-foreground font-medium">{formatCountdown(nextCheckInMs)}</span>
261
+ </p>
262
+ <span className="text-xs text-primary">Check in now β†’</span>
263
+ </div>
264
+ <div className="h-1 w-full rounded-full bg-secondary overflow-hidden">
265
+ <div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${countdownProgress(nextCheckInMs)}%` }} />
266
+ </div>
267
+ </GlassCard>
268
+ </Link>
269
+ </div>
270
+ </AppShell>
271
+ )
272
+ }
public/apple-icon.png ADDED
public/icon-dark-32x32.png ADDED
public/icon-light-32x32.png ADDED
public/icon.svg ADDED
public/placeholder-logo.png ADDED
public/placeholder-logo.svg ADDED
public/placeholder-user.jpg ADDED
public/placeholder.jpg ADDED
public/placeholder.svg ADDED