Tushar9802 commited on
Commit
b56fe2d
·
1 Parent(s): ba004e6

frontend: APK status bar + reactive connectivity + dev-view tool_calls

Browse files

App.jsx changes that landed across the session 28 APK rebuild round:

- Status bar (Capacitor): overlay + Style.Light (= dark icons) on
Android. Was transparent-on-light = invisible airplane / clock icons.
Light naming in Capacitor is inverted; LIGHT style = dark icons.
Browser fallback wrapped in try/catch.
- Reactive online/offline: on 'online' event re-probe /api/health,
on 'offline' immediately flip apiReachable=false + update banner.
Was: one probe on mount, badge stayed stale through airplane toggles.
- Dev-view _raw: capture tool_calls + form + danger + timing from the
SSE response so the Developer view card on Voice/Text tabs can
render the Ollama tool_calls round-trip on screen — the same Ollama
function-calling proof that appears in the demo video.

Capacitor plugin config: register StatusBar plugin with the same
overlay + LIGHT defaults so the native shell starts in the right
state before JS runs.

@capacitor/status-bar dep added at ^8.0.2 (matches @capacitor/core 8.x).

frontend/capacitor.config.json CHANGED
@@ -5,5 +5,11 @@
5
  "server": {
6
  "androidScheme": "http",
7
  "cleartext": true
 
 
 
 
 
 
8
  }
9
  }
 
5
  "server": {
6
  "androidScheme": "http",
7
  "cleartext": true
8
+ },
9
+ "plugins": {
10
+ "StatusBar": {
11
+ "overlaysWebView": true,
12
+ "style": "LIGHT"
13
+ }
14
  }
15
  }
frontend/package-lock.json CHANGED
@@ -11,6 +11,7 @@
11
  "@capacitor/android": "^8.3.1",
12
  "@capacitor/cli": "^8.3.1",
13
  "@capacitor/core": "^8.3.1",
 
14
  "react": "^19.2.4",
15
  "react-dom": "^19.2.4"
16
  },
@@ -328,6 +329,15 @@
328
  "tslib": "^2.1.0"
329
  }
330
  },
 
 
 
 
 
 
 
 
 
331
  "node_modules/@emnapi/core": {
332
  "version": "1.9.1",
333
  "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
 
11
  "@capacitor/android": "^8.3.1",
12
  "@capacitor/cli": "^8.3.1",
13
  "@capacitor/core": "^8.3.1",
14
+ "@capacitor/status-bar": "^8.0.2",
15
  "react": "^19.2.4",
16
  "react-dom": "^19.2.4"
17
  },
 
329
  "tslib": "^2.1.0"
330
  }
331
  },
332
+ "node_modules/@capacitor/status-bar": {
333
+ "version": "8.0.2",
334
+ "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz",
335
+ "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==",
336
+ "license": "MIT",
337
+ "peerDependencies": {
338
+ "@capacitor/core": ">=8.0.0"
339
+ }
340
+ },
341
  "node_modules/@emnapi/core": {
342
  "version": "1.9.1",
343
  "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
frontend/package.json CHANGED
@@ -14,6 +14,7 @@
14
  "@capacitor/android": "^8.3.1",
15
  "@capacitor/cli": "^8.3.1",
16
  "@capacitor/core": "^8.3.1",
 
17
  "react": "^19.2.4",
18
  "react-dom": "^19.2.4"
19
  },
 
14
  "@capacitor/android": "^8.3.1",
15
  "@capacitor/cli": "^8.3.1",
16
  "@capacitor/core": "^8.3.1",
17
+ "@capacitor/status-bar": "^8.0.2",
18
  "react": "^19.2.4",
19
  "react-dom": "^19.2.4"
20
  },
frontend/src/App.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
  import { saveRecording, getQueue, getRecording, removeRecording, clearQueue, updateRecordingStatus, appendChunk, assembleChunks, listOrphanedSessions, clearChunks } from './offlineQueue'
 
3
  import Cactus from './lib/cactus'
4
  import { runPipeline } from './lib/pipeline'
5
  import './App.css'
@@ -181,6 +182,19 @@ function keyValueRows(data, prefix = '') {
181
  }
182
 
183
  function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  const [activeTab, setActiveTab] = useState('voice')
185
  const [health, setHealth] = useState('Checking backend...')
186
  const [apiReachable, setApiReachable] = useState(null) // null = unknown, true/false after probe
@@ -226,6 +240,7 @@ function App() {
226
  form: null,
227
  danger: null,
228
  timing: null,
 
229
  })
230
 
231
  const [textState, setTextState] = useState({
@@ -235,6 +250,7 @@ function App() {
235
  form: null,
236
  danger: null,
237
  timing: null,
 
238
  })
239
 
240
  const [pipelineStages, setPipelineStages] = useState([])
@@ -364,10 +380,35 @@ function App() {
364
  if (metadata.asha_id) localStorage.setItem('sakhi_asha_id', metadata.asha_id)
365
  }, [metadata.asha_id])
366
 
367
- // Online/offline detection + queue loading
 
 
368
  useEffect(() => {
369
- const goOnline = () => setIsOnline(true)
370
- const goOffline = () => setIsOnline(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  window.addEventListener('online', goOnline)
372
  window.addEventListener('offline', goOffline)
373
  loadQueue()
@@ -779,6 +820,12 @@ function App() {
779
  form: evt.form || {},
780
  danger: evt.danger || {},
781
  timing: evt.timing || {},
 
 
 
 
 
 
782
  })
783
  setPipelineStages((prev) => prev.map((s) => ({ ...s, status: 'done' })))
784
  saveToHistory(source, evt.visit_type, evt.form, evt.danger, evt.transcript || null, evt.timing)
@@ -809,7 +856,7 @@ function App() {
809
  setVoiceState((s) => ({ ...s, error: 'Upload or record audio first.' }))
810
  return
811
  }
812
- setVoiceState({ loading: true, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null })
813
  setPipelineStages([])
814
 
815
  const formData = new FormData()
@@ -849,7 +896,7 @@ function App() {
849
  setTextState((s) => ({ ...s, error: 'Transcript is empty.' }))
850
  return
851
  }
852
- setTextState({ loading: true, error: '', visitType: '', form: null, danger: null, timing: null })
853
  setPipelineStages([])
854
 
855
  fetch(`${API_BASE}/api/process-text-stream`, {
@@ -1089,6 +1136,14 @@ function App() {
1089
  <pre className="transcript">{translation.english}</pre>
1090
  </>
1091
  )}
 
 
 
 
 
 
 
 
1092
  </div>
1093
  </section>
1094
  )}
@@ -1123,6 +1178,14 @@ function App() {
1123
  onChange={(e) => setTextInput(e.target.value)}
1124
  placeholder="Paste Hindi conversation transcript here..."
1125
  />
 
 
 
 
 
 
 
 
1126
  </div>
1127
  </section>
1128
  )}
@@ -1166,8 +1229,12 @@ function App() {
1166
  </div>
1167
  )}
1168
  <div className="card">
1169
- <div className={`connectivity-badge ${isOnline ? 'online' : 'offline'}`}>
1170
- {isOnline ? 'Connected — ready to sync' : 'Offline — recordings saved locally'}
 
 
 
 
1171
  </div>
1172
  <p className="field-desc">
1173
  Record ASHA conversations during home visits. Audio is saved on your device
@@ -1198,6 +1265,21 @@ function App() {
1198
  <option key={opt.value} value={opt.value}>{opt.label}</option>
1199
  ))}
1200
  </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1201
  <button
1202
  className="btn primary"
1203
  onClick={processFieldOnDevice}
@@ -1273,7 +1355,7 @@ function App() {
1273
  <div className="queue-header">
1274
  <h3>Saved Recordings ({offlineQueue.length})</h3>
1275
  <div className="queue-actions">
1276
- {isOnline && (
1277
  <button className="btn primary" onClick={syncAll} disabled={syncingId != null}>
1278
  Sync All
1279
  </button>
@@ -1297,7 +1379,7 @@ function App() {
1297
  <button className="btn secondary" onClick={() => playRecording(entry.id)}>
1298
  {playingId === entry.id ? 'Stop' : 'Play'}
1299
  </button>
1300
- {isOnline && entry.status === 'pending' && (
1301
  <button className="btn secondary" onClick={() => syncRecording(entry.id)} disabled={syncingId != null}>
1302
  Sync
1303
  </button>
@@ -1591,6 +1673,40 @@ function App() {
1591
  })}
1592
  </div>
1593
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1594
  </div>
1595
  )
1596
  }
 
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
  import { saveRecording, getQueue, getRecording, removeRecording, clearQueue, updateRecordingStatus, appendChunk, assembleChunks, listOrphanedSessions, clearChunks } from './offlineQueue'
3
+ import { StatusBar, Style } from '@capacitor/status-bar'
4
  import Cactus from './lib/cactus'
5
  import { runPipeline } from './lib/pipeline'
6
  import './App.css'
 
182
  }
183
 
184
  function App() {
185
+ // Native APK: dark status-bar icons (LinkedIn-style) so the airplane icon
186
+ // and clock render visibly against Sakhi's light content. No-op in browser.
187
+ useEffect(() => {
188
+ (async () => {
189
+ try {
190
+ await StatusBar.setOverlaysWebView({ overlay: true })
191
+ await StatusBar.setStyle({ style: Style.Light })
192
+ } catch {
193
+ /* not running in a native Capacitor context */
194
+ }
195
+ })()
196
+ }, [])
197
+
198
  const [activeTab, setActiveTab] = useState('voice')
199
  const [health, setHealth] = useState('Checking backend...')
200
  const [apiReachable, setApiReachable] = useState(null) // null = unknown, true/false after probe
 
240
  form: null,
241
  danger: null,
242
  timing: null,
243
+ _raw: null,
244
  })
245
 
246
  const [textState, setTextState] = useState({
 
250
  form: null,
251
  danger: null,
252
  timing: null,
253
+ _raw: null,
254
  })
255
 
256
  const [pipelineStages, setPipelineStages] = useState([])
 
380
  if (metadata.asha_id) localStorage.setItem('sakhi_asha_id', metadata.asha_id)
381
  }, [metadata.asha_id])
382
 
383
+ // Online/offline detection + queue loading.
384
+ // On network flip, immediately reflect the new API reachability so the
385
+ // top banner + Field Mode badge stop showing a stale "Connected" state.
386
  useEffect(() => {
387
+ const probeHealth = () => {
388
+ fetch(`${API_BASE}/api/health`)
389
+ .then((r) => r.json())
390
+ .then((d) => {
391
+ setHealth(
392
+ d.whisper
393
+ ? `API: ${d.status} · LLM: ${d.model} · ASR: ${d.whisper}`
394
+ : `API: ${d.status} · Model: ${d.model}`
395
+ )
396
+ setApiReachable(true)
397
+ })
398
+ .catch(() => {
399
+ setHealth(`API not reachable at ${API_BASE || window.location.origin}`)
400
+ setApiReachable(false)
401
+ })
402
+ }
403
+ const goOnline = () => {
404
+ setIsOnline(true)
405
+ probeHealth()
406
+ }
407
+ const goOffline = () => {
408
+ setIsOnline(false)
409
+ setApiReachable(false)
410
+ setHealth('API not reachable — phone is offline')
411
+ }
412
  window.addEventListener('online', goOnline)
413
  window.addEventListener('offline', goOffline)
414
  loadQueue()
 
820
  form: evt.form || {},
821
  danger: evt.danger || {},
822
  timing: evt.timing || {},
823
+ _raw: {
824
+ tool_calls: evt.tool_calls || [],
825
+ form: evt.form || {},
826
+ danger: evt.danger || {},
827
+ metadata: evt.metadata || null,
828
+ },
829
  })
830
  setPipelineStages((prev) => prev.map((s) => ({ ...s, status: 'done' })))
831
  saveToHistory(source, evt.visit_type, evt.form, evt.danger, evt.transcript || null, evt.timing)
 
856
  setVoiceState((s) => ({ ...s, error: 'Upload or record audio first.' }))
857
  return
858
  }
859
+ setVoiceState({ loading: true, error: '', transcript: '', visitType: '', form: null, danger: null, timing: null, _raw: null })
860
  setPipelineStages([])
861
 
862
  const formData = new FormData()
 
896
  setTextState((s) => ({ ...s, error: 'Transcript is empty.' }))
897
  return
898
  }
899
+ setTextState({ loading: true, error: '', visitType: '', form: null, danger: null, timing: null, _raw: null })
900
  setPipelineStages([])
901
 
902
  fetch(`${API_BASE}/api/process-text-stream`, {
 
1136
  <pre className="transcript">{translation.english}</pre>
1137
  </>
1138
  )}
1139
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 13, color: '#555', cursor: 'pointer' }}>
1140
+ <input
1141
+ type="checkbox"
1142
+ checked={devViewEnabled}
1143
+ onChange={(e) => setDevViewEnabled(e.target.checked)}
1144
+ />
1145
+ Developer view — show raw API response (tool_calls, parsed form, parsed danger)
1146
+ </label>
1147
  </div>
1148
  </section>
1149
  )}
 
1178
  onChange={(e) => setTextInput(e.target.value)}
1179
  placeholder="Paste Hindi conversation transcript here..."
1180
  />
1181
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 13, color: '#555', cursor: 'pointer' }}>
1182
+ <input
1183
+ type="checkbox"
1184
+ checked={devViewEnabled}
1185
+ onChange={(e) => setDevViewEnabled(e.target.checked)}
1186
+ />
1187
+ Developer view — show raw API response (tool_calls, parsed form, parsed danger)
1188
+ </label>
1189
  </div>
1190
  </section>
1191
  )}
 
1229
  </div>
1230
  )}
1231
  <div className="card">
1232
+ <div className={`connectivity-badge ${apiReachable === true ? 'online' : 'offline'}`}>
1233
+ {apiReachable === true
1234
+ ? 'Connected — ready to sync'
1235
+ : isOnline
1236
+ ? 'Workstation unreachable — recordings saved locally'
1237
+ : 'Offline — recordings saved locally'}
1238
  </div>
1239
  <p className="field-desc">
1240
  Record ASHA conversations during home visits. Audio is saved on your device
 
1265
  <option key={opt.value} value={opt.value}>{opt.label}</option>
1266
  ))}
1267
  </select>
1268
+ <button
1269
+ className="btn secondary"
1270
+ type="button"
1271
+ onClick={() => {
1272
+ const anc = examples.find((e) => (e.label || '').toLowerCase().includes('preeclampsia'))
1273
+ || examples.find((e) => e.visit_type === 'anc')
1274
+ if (anc && anc.transcript) {
1275
+ setFieldOnDeviceText(anc.transcript)
1276
+ setFieldOnDeviceVisitType('anc')
1277
+ }
1278
+ }}
1279
+ disabled={fieldOnDeviceState.loading || examples.length === 0}
1280
+ >
1281
+ Load ANC example
1282
+ </button>
1283
  <button
1284
  className="btn primary"
1285
  onClick={processFieldOnDevice}
 
1355
  <div className="queue-header">
1356
  <h3>Saved Recordings ({offlineQueue.length})</h3>
1357
  <div className="queue-actions">
1358
+ {apiReachable === true && (
1359
  <button className="btn primary" onClick={syncAll} disabled={syncingId != null}>
1360
  Sync All
1361
  </button>
 
1379
  <button className="btn secondary" onClick={() => playRecording(entry.id)}>
1380
  {playingId === entry.id ? 'Stop' : 'Play'}
1381
  </button>
1382
+ {apiReachable === true && entry.status === 'pending' && (
1383
  <button className="btn secondary" onClick={() => syncRecording(entry.id)} disabled={syncingId != null}>
1384
  Sync
1385
  </button>
 
1673
  })}
1674
  </div>
1675
  )}
1676
+
1677
+ {devViewEnabled && activeTab !== 'field' && activeState._raw && (
1678
+ <div className="card" style={{ background: '#0f172a', color: '#e2e8f0', fontFamily: 'ui-monospace, Menlo, Consolas, monospace' }}>
1679
+ <h3 style={{ marginTop: 0, color: '#93c5fd' }}>Raw API response (workstation pipeline)</h3>
1680
+ {Array.isArray(activeState._raw.tool_calls) && activeState._raw.tool_calls.length > 0 && (
1681
+ <div style={{ marginBottom: 12 }}>
1682
+ <div style={{ color: '#93c5fd', fontSize: 12, marginBottom: 4 }}>$ tool_calls (Ollama function calling) →</div>
1683
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, background: '#020617', padding: 10, borderRadius: 4, maxHeight: 300, overflow: 'auto' }}>
1684
+ {JSON.stringify(activeState._raw.tool_calls, null, 2)}
1685
+ </pre>
1686
+ </div>
1687
+ )}
1688
+ <div style={{ marginBottom: 12 }}>
1689
+ <div style={{ color: '#93c5fd', fontSize: 12, marginBottom: 4 }}>$ form →</div>
1690
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, background: '#020617', padding: 10, borderRadius: 4, maxHeight: 300, overflow: 'auto' }}>
1691
+ {JSON.stringify(activeState._raw.form, null, 2)}
1692
+ </pre>
1693
+ </div>
1694
+ <div style={{ marginBottom: 12 }}>
1695
+ <div style={{ color: '#93c5fd', fontSize: 12, marginBottom: 4 }}>$ danger →</div>
1696
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, background: '#020617', padding: 10, borderRadius: 4, maxHeight: 300, overflow: 'auto' }}>
1697
+ {JSON.stringify(activeState._raw.danger, null, 2)}
1698
+ </pre>
1699
+ </div>
1700
+ {activeState._raw.metadata && (
1701
+ <div>
1702
+ <div style={{ color: '#93c5fd', fontSize: 12, marginBottom: 4 }}>$ metadata (header from ASHA) →</div>
1703
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, background: '#020617', padding: 10, borderRadius: 4, maxHeight: 200, overflow: 'auto' }}>
1704
+ {JSON.stringify(activeState._raw.metadata, null, 2)}
1705
+ </pre>
1706
+ </div>
1707
+ )}
1708
+ </div>
1709
+ )}
1710
  </div>
1711
  )
1712
  }