diff options
| author | hc <haocheng.xie@respiree.com> | 2026-04-13 16:37:52 +0800 |
|---|---|---|
| committer | hc <haocheng.xie@respiree.com> | 2026-04-13 16:37:52 +0800 |
| commit | f22bb303a081d6779592b90a68feee2203d7962a (patch) | |
| tree | 6e20af1d4b3c9ccd9ce3e1f88fdcf189801300be | |
| parent | da5bb92c8ee7c4cf44fb0787e63abaab1ced8236 (diff) | |
Dashboard with continuous scan, unified device list, AsyncStorage persistence
| -rw-r--r-- | App.tsx | 836 | ||||
| -rw-r--r-- | package-lock.json | 34 | ||||
| -rw-r--r-- | package.json | 1 |
3 files changed, 585 insertions, 286 deletions
| @@ -1,4 +1,4 @@ | |||
| 1 | import React, {useState, useEffect, useRef} from 'react'; | 1 | import React, {useState, useEffect, useRef, useCallback} from 'react'; |
| 2 | import { | 2 | import { |
| 3 | StyleSheet, | 3 | StyleSheet, |
| 4 | View, | 4 | View, |
| @@ -11,9 +11,10 @@ import { | |||
| 11 | DeviceEventEmitter, | 11 | DeviceEventEmitter, |
| 12 | NativeEventEmitter, | 12 | NativeEventEmitter, |
| 13 | NativeModules, | 13 | NativeModules, |
| 14 | Alert, | 14 | ActivityIndicator, |
| 15 | } from 'react-native'; | 15 | } from 'react-native'; |
| 16 | import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; | 16 | import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; |
| 17 | import AsyncStorage from '@react-native-async-storage/async-storage'; | ||
| 17 | 18 | ||
| 18 | // ── Native module types ────────────────────────────────────────────── | 19 | // ── Native module types ────────────────────────────────────────────── |
| 19 | 20 | ||
| @@ -59,34 +60,44 @@ interface IBP550BTModule { | |||
| 59 | getAllConnectedDevices(): void; | 60 | getAllConnectedDevices(): void; |
| 60 | } | 61 | } |
| 61 | 62 | ||
| 62 | const iHealthDeviceManagerModule = | 63 | const mgr = NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; |
| 63 | NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; | 64 | const bp550 = NativeModules.BP550BTModule as IBP550BTModule; |
| 64 | const BP550BTModule = | ||
| 65 | NativeModules.BP550BTModule as IBP550BTModule; | ||
| 66 | 65 | ||
| 67 | type Device = { | 66 | // ── Types ──────────────────────────────────────────────────────────── |
| 67 | |||
| 68 | type Device = {mac: string; type: string; rssi?: number; timestamp: number}; | ||
| 69 | |||
| 70 | type SavedDevice = {mac: string; type: string; addedAt: string}; | ||
| 71 | |||
| 72 | type Reading = { | ||
| 68 | mac: string; | 73 | mac: string; |
| 69 | type: string; | 74 | sys?: number; |
| 70 | rssi?: number; | 75 | dia?: number; |
| 71 | timestamp: number; | 76 | pulse?: number; |
| 77 | date?: string; | ||
| 78 | battery?: number; | ||
| 79 | fetchedAt: string; | ||
| 72 | }; | 80 | }; |
| 73 | 81 | ||
| 82 | type Screen = 'home' | 'dashboard' | 'debug' | 'debug-device'; | ||
| 83 | |||
| 84 | const STORAGE_KEY_DEVICES = '@ihealth/saved_devices'; | ||
| 85 | const STORAGE_KEY_READINGS = '@ihealth/readings'; | ||
| 86 | |||
| 74 | // ── Helpers ────────────────────────────────────────────────────────── | 87 | // ── Helpers ────────────────────────────────────────────────────────── |
| 75 | 88 | ||
| 76 | async function requestAndroidPermissions(): Promise<boolean> { | 89 | async function requestAndroidPermissions(): Promise<boolean> { |
| 77 | if (Platform.OS !== 'android') return true; | 90 | if (Platform.OS !== 'android') return true; |
| 78 | const permissions: string[] = []; | 91 | const perms: string[] = []; |
| 79 | if (Platform.Version >= 31) { | 92 | if (Platform.Version >= 31) { |
| 80 | permissions.push( | 93 | perms.push( |
| 81 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, | 94 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, |
| 82 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, | 95 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, |
| 83 | ); | 96 | ); |
| 84 | } | 97 | } |
| 85 | permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); | 98 | perms.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); |
| 86 | const results = await PermissionsAndroid.requestMultiple(permissions as any); | 99 | const r = await PermissionsAndroid.requestMultiple(perms as any); |
| 87 | return Object.values(results).every( | 100 | return Object.values(r).every(v => v === PermissionsAndroid.RESULTS.GRANTED); |
| 88 | r => r === PermissionsAndroid.RESULTS.GRANTED, | ||
| 89 | ); | ||
| 90 | } | 101 | } |
| 91 | 102 | ||
| 92 | function getEmitter() { | 103 | function getEmitter() { |
| @@ -101,329 +112,450 @@ function getBPEmitter() { | |||
| 101 | : DeviceEventEmitter; | 112 | : DeviceEventEmitter; |
| 102 | } | 113 | } |
| 103 | 114 | ||
| 104 | // ── Device Detail Screen ───────────────────────────────────────────── | 115 | // ── Home Screen ────────────────────────────────────────────────────── |
| 105 | 116 | ||
| 106 | function DeviceScreen({ | 117 | function HomeScreen({onNav}: {onNav: (s: Screen) => void}) { |
| 107 | device, | 118 | return ( |
| 108 | onBack, | 119 | <SafeAreaView style={s.container}> |
| 109 | }: { | 120 | <View style={s.homeCenter}> |
| 110 | device: Device; | 121 | <Text style={s.homeTitle}>iHealth</Text> |
| 111 | onBack: () => void; | 122 | <Text style={s.homeSubtitle}>Blood Pressure Monitor</Text> |
| 112 | }) { | ||
| 113 | const [connected, setConnected] = useState(false); | ||
| 114 | const [connecting, setConnecting] = useState(false); | ||
| 115 | const [log, setLog] = useState<string[]>([]); | ||
| 116 | 123 | ||
| 117 | const addLog = (msg: string) => | 124 | <TouchableOpacity |
| 118 | setLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]); | 125 | style={s.homeButton} |
| 126 | onPress={() => onNav('dashboard')}> | ||
| 127 | <Text style={s.homeButtonText}>Dashboard</Text> | ||
| 128 | <Text style={s.homeButtonSub}>View your devices and readings</Text> | ||
| 129 | </TouchableOpacity> | ||
| 130 | |||
| 131 | <TouchableOpacity | ||
| 132 | style={[s.homeButton, s.homeButtonSecondary]} | ||
| 133 | onPress={() => onNav('debug')}> | ||
| 134 | <Text style={[s.homeButtonText, s.homeButtonTextSecondary]}> | ||
| 135 | Debug Scanner | ||
| 136 | </Text> | ||
| 137 | <Text style={[s.homeButtonSub, s.homeButtonTextSecondary]}> | ||
| 138 | Scan all devices, raw logs | ||
| 139 | </Text> | ||
| 140 | </TouchableOpacity> | ||
| 141 | </View> | ||
| 142 | </SafeAreaView> | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | |||
| 146 | // ── Dashboard Screen ───────────────────────────────────────────────── | ||
| 119 | 147 | ||
| 148 | function DashboardScreen({onBack}: {onBack: () => void}) { | ||
| 149 | const [savedDevices, setSavedDevices] = useState<SavedDevice[]>([]); | ||
| 150 | const [readings, setReadings] = useState<Reading[]>([]); | ||
| 151 | const [foundDevices, setFoundDevices] = useState<Device[]>([]); | ||
| 152 | const [status, setStatus] = useState('Starting...'); | ||
| 153 | const foundRef = useRef<Device[]>([]); | ||
| 154 | const savedRef = useRef<SavedDevice[]>([]); | ||
| 155 | const readingsRef = useRef<Reading[]>([]); | ||
| 156 | const syncingMac = useRef<string | null>(null); | ||
| 157 | |||
| 158 | // Load saved data on mount | ||
| 159 | useEffect(() => { | ||
| 160 | (async () => { | ||
| 161 | const [devJson, readJson] = await Promise.all([ | ||
| 162 | AsyncStorage.getItem(STORAGE_KEY_DEVICES), | ||
| 163 | AsyncStorage.getItem(STORAGE_KEY_READINGS), | ||
| 164 | ]); | ||
| 165 | const devs: SavedDevice[] = devJson ? JSON.parse(devJson) : []; | ||
| 166 | const reads: Reading[] = readJson ? JSON.parse(readJson) : []; | ||
| 167 | setSavedDevices(devs); | ||
| 168 | savedRef.current = devs; | ||
| 169 | setReadings(reads); | ||
| 170 | readingsRef.current = reads; | ||
| 171 | await requestAndroidPermissions(); | ||
| 172 | })(); | ||
| 173 | }, []); | ||
| 174 | |||
| 175 | // Always-on scan loop: restart scan every time it finishes | ||
| 120 | useEffect(() => { | 176 | useEffect(() => { |
| 121 | const emitter = getEmitter(); | 177 | const emitter = getEmitter(); |
| 178 | const bpEmitter = getBPEmitter(); | ||
| 122 | 179 | ||
| 123 | const connSub = emitter.addListener( | 180 | const startScan = () => { |
| 124 | iHealthDeviceManagerModule.Event_Device_Connected ?? 'event_device_connected', | 181 | try { mgr.startDiscovery('ALL'); } |
| 125 | (e: {mac: string; type: string}) => { | 182 | catch (_) {} |
| 126 | if (e.mac === device.mac) { | 183 | setStatus('Scanning...'); |
| 127 | setConnected(true); | 184 | }; |
| 128 | setConnecting(false); | 185 | |
| 129 | addLog(`Connected to ${e.type}`); | 186 | // When a device is found |
| 187 | const scanSub = emitter.addListener( | ||
| 188 | mgr.Event_Scan_Device ?? 'event_scan_device', | ||
| 189 | (e: {mac: string; type: string; rssi?: number}) => { | ||
| 190 | if (e.type === 'AM3') return; | ||
| 191 | |||
| 192 | // Track all found devices | ||
| 193 | const exists = foundRef.current.find(d => d.mac === e.mac); | ||
| 194 | if (!exists) { | ||
| 195 | const updated = [...foundRef.current, {mac: e.mac, type: e.type, rssi: e.rssi, timestamp: Date.now()}]; | ||
| 196 | foundRef.current = updated; | ||
| 197 | setFoundDevices(updated); | ||
| 198 | } | ||
| 199 | |||
| 200 | // Auto-connect if it's a saved device we haven't synced yet | ||
| 201 | const isSaved = savedRef.current.some(d => d.mac === e.mac); | ||
| 202 | if (isSaved && !syncingMac.current) { | ||
| 203 | syncingMac.current = e.mac; | ||
| 204 | setStatus(`Found ${e.mac.slice(-4)}, connecting...`); | ||
| 205 | try { mgr.stopDiscovery(); } catch (_) {} | ||
| 206 | mgr.connectDevice(e.mac, e.type); | ||
| 130 | } | 207 | } |
| 131 | }, | 208 | }, |
| 132 | ); | 209 | ); |
| 133 | 210 | ||
| 134 | const failSub = emitter.addListener( | 211 | // When scan finishes, restart after a short delay |
| 135 | iHealthDeviceManagerModule.Event_Device_Connect_Failed ?? 'event_device_connect_failed', | 212 | const finSub = emitter.addListener( |
| 136 | (e: {mac: string; errorid?: number}) => { | 213 | mgr.Event_Scan_Finish ?? 'event_scan_finish', |
| 137 | if (e.mac === device.mac) { | 214 | () => { |
| 138 | setConnecting(false); | 215 | if (!syncingMac.current) { |
| 139 | addLog(`Connection failed (error: ${e.errorid ?? 'unknown'})`); | 216 | setTimeout(startScan, 3000); |
| 140 | } | 217 | } |
| 141 | }, | 218 | }, |
| 142 | ); | 219 | ); |
| 143 | 220 | ||
| 144 | const dcSub = emitter.addListener( | 221 | // When connected, pull data |
| 145 | iHealthDeviceManagerModule.Event_Device_Disconnect ?? 'event_device_disconnect', | 222 | const connSub = emitter.addListener( |
| 146 | (e: {mac: string}) => { | 223 | mgr.Event_Device_Connected ?? 'event_device_connected', |
| 147 | if (e.mac === device.mac) { | 224 | async (e: {mac: string; type: string}) => { |
| 148 | setConnected(false); | 225 | if (e.mac !== syncingMac.current) return; |
| 149 | addLog('Disconnected'); | 226 | setStatus(`Connected to ${e.mac.slice(-4)}, reading...`); |
| 227 | |||
| 228 | // Get battery | ||
| 229 | const battery = await new Promise<number | undefined>(resolve => { | ||
| 230 | const sub = bpEmitter.addListener( | ||
| 231 | bp550?.Event_Notify ?? 'event_notify', | ||
| 232 | (ev: Record<string, unknown>) => { | ||
| 233 | if (ev.action === 'battery_bp' && ev.battery != null) { | ||
| 234 | sub.remove(); resolve(ev.battery as number); | ||
| 235 | } | ||
| 236 | }, | ||
| 237 | ); | ||
| 238 | bp550.getBattery(e.mac); | ||
| 239 | setTimeout(() => { sub.remove(); resolve(undefined); }, 5000); | ||
| 240 | }); | ||
| 241 | |||
| 242 | // Get offline data | ||
| 243 | const offlineData = await new Promise<Record<string, unknown>[]>(resolve => { | ||
| 244 | const results: Record<string, unknown>[] = []; | ||
| 245 | const sub = bpEmitter.addListener( | ||
| 246 | bp550?.Event_Notify ?? 'event_notify', | ||
| 247 | (ev: Record<string, unknown>) => { | ||
| 248 | if (ev.action === 'historicaldata_bp' && ev.data) { | ||
| 249 | results.push(...(ev.data as Record<string, unknown>[])); | ||
| 250 | } | ||
| 251 | if (ev.action === 'get_historical_over_bp') { | ||
| 252 | sub.remove(); resolve(results); | ||
| 253 | } | ||
| 254 | if (ev.action === 'offlinenum' && ev.offlinenum === 0) { | ||
| 255 | sub.remove(); resolve(results); | ||
| 256 | } | ||
| 257 | }, | ||
| 258 | ); | ||
| 259 | bp550.getOffLineData(e.mac); | ||
| 260 | setTimeout(() => { sub.remove(); resolve(results); }, 10000); | ||
| 261 | }); | ||
| 262 | |||
| 263 | // Save new readings | ||
| 264 | const newReadings = [...readingsRef.current]; | ||
| 265 | for (const d of offlineData) { | ||
| 266 | newReadings.push({ | ||
| 267 | mac: e.mac, sys: d.sys as number | undefined, | ||
| 268 | dia: d.dia as number | undefined, | ||
| 269 | pulse: d.heartRate as number | undefined, | ||
| 270 | date: d.date as string | undefined, | ||
| 271 | battery, fetchedAt: new Date().toISOString(), | ||
| 272 | }); | ||
| 150 | } | 273 | } |
| 274 | if (offlineData.length === 0 && battery != null) { | ||
| 275 | newReadings.push({mac: e.mac, battery, fetchedAt: new Date().toISOString()}); | ||
| 276 | } | ||
| 277 | readingsRef.current = newReadings; | ||
| 278 | setReadings(newReadings); | ||
| 279 | await AsyncStorage.setItem(STORAGE_KEY_READINGS, JSON.stringify(newReadings)); | ||
| 280 | |||
| 281 | // Disconnect and resume scanning | ||
| 282 | try { bp550.disconnect(e.mac); } catch (_) {} | ||
| 283 | setStatus(`Synced ${e.mac.slice(-4)} (${offlineData.length} readings)`); | ||
| 284 | syncingMac.current = null; | ||
| 285 | setTimeout(startScan, 3000); | ||
| 151 | }, | 286 | }, |
| 152 | ); | 287 | ); |
| 153 | 288 | ||
| 154 | // BP550BT event listener | 289 | const failSub = emitter.addListener( |
| 155 | const bpEmitter = getBPEmitter(); | 290 | mgr.Event_Device_Connect_Failed ?? 'event_device_connect_failed', |
| 156 | const notifySub = bpEmitter.addListener( | 291 | () => { |
| 157 | BP550BTModule?.Event_Notify ?? 'event_notify', | 292 | setStatus('Connect failed, resuming scan...'); |
| 158 | (e: Record<string, unknown>) => { | 293 | syncingMac.current = null; |
| 159 | addLog(JSON.stringify(e, null, 2)); | 294 | setTimeout(startScan, 3000); |
| 160 | }, | 295 | }, |
| 161 | ); | 296 | ); |
| 162 | 297 | ||
| 298 | const dcSub = emitter.addListener( | ||
| 299 | mgr.Event_Device_Disconnect ?? 'event_device_disconnect', | ||
| 300 | () => { | ||
| 301 | if (syncingMac.current) { | ||
| 302 | syncingMac.current = null; | ||
| 303 | setTimeout(startScan, 3000); | ||
| 304 | } | ||
| 305 | }, | ||
| 306 | ); | ||
| 307 | |||
| 308 | // Start first scan | ||
| 309 | startScan(); | ||
| 310 | |||
| 163 | return () => { | 311 | return () => { |
| 164 | connSub.remove(); | 312 | scanSub.remove(); finSub.remove(); connSub.remove(); |
| 165 | failSub.remove(); | 313 | failSub.remove(); dcSub.remove(); |
| 166 | dcSub.remove(); | 314 | try { mgr.stopDiscovery(); } catch (_) {} |
| 167 | notifySub.remove(); | ||
| 168 | }; | 315 | }; |
| 169 | }, [device.mac]); | 316 | }, []); |
| 170 | 317 | ||
| 171 | const connect = () => { | 318 | const addDevice = async (dev: Device) => { |
| 172 | setConnecting(true); | 319 | const newDev: SavedDevice = {mac: dev.mac, type: dev.type, addedAt: new Date().toISOString()}; |
| 173 | addLog(`Connecting to ${device.mac}...`); | 320 | const updated = [...savedRef.current, newDev]; |
| 174 | iHealthDeviceManagerModule.connectDevice(device.mac, device.type); | 321 | savedRef.current = updated; |
| 322 | setSavedDevices(updated); | ||
| 323 | await AsyncStorage.setItem(STORAGE_KEY_DEVICES, JSON.stringify(updated)); | ||
| 324 | const f = foundRef.current.filter(d => d.mac !== dev.mac); | ||
| 325 | foundRef.current = f; | ||
| 326 | setFoundDevices(f); | ||
| 175 | }; | 327 | }; |
| 176 | 328 | ||
| 177 | const disconnect = () => { | 329 | const removeDevice = async (mac: string) => { |
| 178 | addLog('Disconnecting...'); | 330 | const updated = savedRef.current.filter(d => d.mac !== mac); |
| 179 | if (BP550BTModule) { | 331 | savedRef.current = updated; |
| 180 | BP550BTModule.disconnect(device.mac); | 332 | setSavedDevices(updated); |
| 181 | } else { | 333 | await AsyncStorage.setItem(STORAGE_KEY_DEVICES, JSON.stringify(updated)); |
| 182 | iHealthDeviceManagerModule.disconnectDevice(device.mac, device.type); | ||
| 183 | } | ||
| 184 | }; | 334 | }; |
| 185 | 335 | ||
| 186 | const actions: {label: string; fn: () => void; needsConnect: boolean}[] = [ | 336 | const isDeviceSaved = (mac: string) => savedRef.current.some(d => d.mac === mac); |
| 187 | { | 337 | |
| 188 | label: 'Get Battery', | 338 | const recentReadings = readings |
| 189 | needsConnect: true, | 339 | .filter(r => r.sys != null) |
| 190 | fn: () => { | 340 | .slice(-20) |
| 191 | addLog('Requesting battery...'); | 341 | .reverse(); |
| 192 | BP550BTModule.getBattery(device.mac); | ||
| 193 | }, | ||
| 194 | }, | ||
| 195 | { | ||
| 196 | label: 'Get Function Info', | ||
| 197 | needsConnect: true, | ||
| 198 | fn: () => { | ||
| 199 | addLog('Requesting function info...'); | ||
| 200 | BP550BTModule.getFunctionInfo(device.mac); | ||
| 201 | }, | ||
| 202 | }, | ||
| 203 | { | ||
| 204 | label: 'Get Offline Count', | ||
| 205 | needsConnect: true, | ||
| 206 | fn: () => { | ||
| 207 | addLog('Requesting offline data count...'); | ||
| 208 | BP550BTModule.getOffLineNum(device.mac); | ||
| 209 | }, | ||
| 210 | }, | ||
| 211 | { | ||
| 212 | label: 'Get Offline Data', | ||
| 213 | needsConnect: true, | ||
| 214 | fn: () => { | ||
| 215 | addLog('Requesting offline data...'); | ||
| 216 | BP550BTModule.getOffLineData(device.mac); | ||
| 217 | }, | ||
| 218 | }, | ||
| 219 | { | ||
| 220 | label: 'Get IDPS', | ||
| 221 | needsConnect: false, | ||
| 222 | fn: () => { | ||
| 223 | addLog('Requesting IDPS...'); | ||
| 224 | iHealthDeviceManagerModule.getDevicesIDPS(device.mac, (idps) => { | ||
| 225 | addLog(`IDPS: ${JSON.stringify(idps, null, 2)}`); | ||
| 226 | }); | ||
| 227 | }, | ||
| 228 | }, | ||
| 229 | ]; | ||
| 230 | 342 | ||
| 231 | return ( | 343 | return ( |
| 232 | <SafeAreaView style={styles.container}> | 344 | <SafeAreaView style={s.container}> |
| 233 | <TouchableOpacity style={styles.backButton} onPress={onBack}> | 345 | <TouchableOpacity style={s.backButton} onPress={onBack}> |
| 234 | <Text style={styles.backText}>Back</Text> | 346 | <Text style={s.backText}>Home</Text> |
| 235 | </TouchableOpacity> | 347 | </TouchableOpacity> |
| 236 | 348 | ||
| 237 | <Text style={styles.title}>{device.type}</Text> | 349 | <Text style={s.title}>Dashboard 🦄✨</Text> |
| 238 | <Text style={styles.deviceMac}>{device.mac}</Text> | ||
| 239 | <Text style={[styles.statusBadge, connected && styles.statusConnected]}> | ||
| 240 | {connecting ? 'Connecting...' : connected ? 'Connected' : 'Disconnected'} | ||
| 241 | </Text> | ||
| 242 | 350 | ||
| 243 | {!connected && !connecting && ( | 351 | {/* Show status only when actively syncing a device */} |
| 244 | <TouchableOpacity style={styles.button} onPress={connect}> | 352 | {status.includes('connecting') || status.includes('reading') || status.includes('Synced') ? ( |
| 245 | <Text style={styles.buttonText}>Connect</Text> | 353 | <View style={s.syncBar}> |
| 246 | </TouchableOpacity> | 354 | <ActivityIndicator size="small" color="#2196F3" /> |
| 247 | )} | 355 | <Text style={s.syncText}>{status}</Text> |
| 248 | {connected && ( | 356 | </View> |
| 249 | <TouchableOpacity | 357 | ) : null} |
| 250 | style={[styles.button, styles.buttonStop]} | ||
| 251 | onPress={disconnect}> | ||
| 252 | <Text style={styles.buttonText}>Disconnect</Text> | ||
| 253 | </TouchableOpacity> | ||
| 254 | )} | ||
| 255 | 358 | ||
| 256 | <Text style={styles.sectionTitle}>Actions</Text> | 359 | {/* All devices — unified list, saved ones marked */} |
| 257 | <ScrollView | 360 | <Text style={s.sectionTitle}> |
| 258 | horizontal | 361 | Devices ({foundDevices.length} nearby, {savedDevices.length} saved) |
| 259 | showsHorizontalScrollIndicator={false} | 362 | </Text> |
| 260 | style={styles.actionsRow}> | 363 | |
| 261 | {actions.map(a => ( | 364 | {/* Merge: all found devices + saved devices not currently found */} |
| 262 | <TouchableOpacity | 365 | {(() => { |
| 263 | key={a.label} | 366 | const allMacs = new Set([ |
| 264 | style={[ | 367 | ...foundDevices.map(d => d.mac), |
| 265 | styles.actionButton, | 368 | ...savedDevices.map(d => d.mac), |
| 266 | a.needsConnect && !connected && styles.actionDisabled, | 369 | ]); |
| 267 | ]} | 370 | const merged = Array.from(allMacs).map(mac => { |
| 268 | disabled={a.needsConnect && !connected} | 371 | const found = foundDevices.find(d => d.mac === mac); |
| 269 | onPress={a.fn}> | 372 | const saved = savedDevices.find(d => d.mac === mac); |
| 270 | <Text | 373 | return { |
| 271 | style={[ | 374 | mac, |
| 272 | styles.actionText, | 375 | type: found?.type ?? saved?.type ?? 'KN550', |
| 273 | a.needsConnect && !connected && styles.actionTextDisabled, | 376 | rssi: found?.rssi, |
| 274 | ]}> | 377 | nearby: !!found, |
| 275 | {a.label} | 378 | saved: !!saved, |
| 276 | </Text> | 379 | }; |
| 277 | </TouchableOpacity> | 380 | }); |
| 278 | ))} | 381 | // Sort: saved first, then nearby, then rest |
| 279 | </ScrollView> | 382 | merged.sort((a, b) => { |
| 383 | if (a.saved !== b.saved) return a.saved ? -1 : 1; | ||
| 384 | if (a.nearby !== b.nearby) return a.nearby ? -1 : 1; | ||
| 385 | return 0; | ||
| 386 | }); | ||
| 387 | return merged.map(d => ( | ||
| 388 | <View key={d.mac} style={s.savedRow}> | ||
| 389 | <View style={[s.deviceIcon, d.saved && s.deviceIconSaved, !d.nearby && s.deviceOffline]}> | ||
| 390 | <Text style={s.deviceIconText}>BP</Text> | ||
| 391 | </View> | ||
| 392 | <View style={[s.deviceInfo, !d.nearby && s.deviceOffline]}> | ||
| 393 | <Text style={s.deviceType}> | ||
| 394 | {d.type} | ||
| 395 | {d.saved ? ' ' : ''} | ||
| 396 | {d.saved && <Text style={s.savedBadge}>SAVED</Text>} | ||
| 397 | </Text> | ||
| 398 | <Text style={s.deviceMac}> | ||
| 399 | {d.mac} | ||
| 400 | {!d.nearby ? ' (offline)' : d.rssi != null ? ` ${d.rssi} dBm` : ''} | ||
| 401 | </Text> | ||
| 402 | </View> | ||
| 403 | {d.saved ? ( | ||
| 404 | <TouchableOpacity onPress={() => removeDevice(d.mac)}> | ||
| 405 | <Text style={s.removeText}>Remove</Text> | ||
| 406 | </TouchableOpacity> | ||
| 407 | ) : ( | ||
| 408 | <TouchableOpacity style={s.addButton} onPress={() => addDevice({mac: d.mac, type: d.type, timestamp: Date.now()})}> | ||
| 409 | <Text style={s.addButtonText}>Save</Text> | ||
| 410 | </TouchableOpacity> | ||
| 411 | )} | ||
| 412 | </View> | ||
| 413 | )); | ||
| 414 | })()} | ||
| 280 | 415 | ||
| 281 | <Text style={styles.sectionTitle}>Log</Text> | 416 | {/* Readings */} |
| 417 | <Text style={[s.sectionTitle, {marginTop: 16}]}> | ||
| 418 | Readings ({recentReadings.length}) | ||
| 419 | </Text> | ||
| 282 | <FlatList | 420 | <FlatList |
| 283 | data={log} | 421 | data={recentReadings} |
| 284 | keyExtractor={(_, i) => String(i)} | 422 | keyExtractor={(_, i) => String(i)} |
| 285 | renderItem={({item}) => <Text style={styles.logLine}>{item}</Text>} | 423 | style={s.list} |
| 286 | style={styles.logList} | 424 | renderItem={({item}) => ( |
| 425 | <View style={s.readingRow}> | ||
| 426 | <View style={s.readingValues}> | ||
| 427 | <Text style={s.readingSys}>{item.sys ?? '--'}</Text> | ||
| 428 | <Text style={s.readingSlash}>/</Text> | ||
| 429 | <Text style={s.readingDia}>{item.dia ?? '--'}</Text> | ||
| 430 | <Text style={s.readingUnit}>mmHg</Text> | ||
| 431 | <Text style={s.readingPulse}>{item.pulse ?? '--'} bpm</Text> | ||
| 432 | </View> | ||
| 433 | <View style={s.readingMeta}> | ||
| 434 | <Text style={s.readingDate}> | ||
| 435 | {item.date ?? item.fetchedAt.split('T')[0]} | ||
| 436 | </Text> | ||
| 437 | <Text style={s.readingMac}>{item.mac}</Text> | ||
| 438 | </View> | ||
| 439 | </View> | ||
| 440 | )} | ||
| 441 | ListEmptyComponent={ | ||
| 442 | <Text style={s.emptyText}> | ||
| 443 | {savedDevices.length === 0 | ||
| 444 | ? 'Add a device to start tracking' | ||
| 445 | : 'No readings yet. Sync to pull data.'} | ||
| 446 | </Text> | ||
| 447 | } | ||
| 287 | /> | 448 | /> |
| 288 | </SafeAreaView> | 449 | </SafeAreaView> |
| 289 | ); | 450 | ); |
| 290 | } | 451 | } |
| 291 | 452 | ||
| 292 | // ── Scanner Screen ─────────────────────────────────────────────────── | 453 | // ── Debug Scanner Screen ───────────────────────────────────────────── |
| 293 | 454 | ||
| 294 | function ScannerScreen({onSelectDevice}: {onSelectDevice: (d: Device) => void}) { | 455 | function DebugScannerScreen({ |
| 456 | onBack, | ||
| 457 | onSelectDevice, | ||
| 458 | }: { | ||
| 459 | onBack: () => void; | ||
| 460 | onSelectDevice: (d: Device) => void; | ||
| 461 | }) { | ||
| 295 | const [devices, setDevices] = useState<Device[]>([]); | 462 | const [devices, setDevices] = useState<Device[]>([]); |
| 296 | const [scanning, setScanning] = useState(false); | 463 | const [scanning, setScanning] = useState(false); |
| 297 | const [error, setError] = useState<string | null>(null); | 464 | const [error, setError] = useState<string | null>(null); |
| 298 | const devicesRef = useRef<Device[]>([]); | 465 | const devicesRef = useRef<Device[]>([]); |
| 299 | 466 | ||
| 300 | useEffect(() => { | 467 | useEffect(() => { |
| 301 | if (!iHealthDeviceManagerModule) { | 468 | if (!mgr) { setError('iHealth native module not found.'); return; } |
| 302 | setError('iHealth native module not found.'); | ||
| 303 | return; | ||
| 304 | } | ||
| 305 | const emitter = getEmitter(); | 469 | const emitter = getEmitter(); |
| 306 | 470 | const sub = emitter.addListener( | |
| 307 | const scanSub = emitter.addListener( | 471 | mgr.Event_Scan_Device ?? 'event_scan_device', |
| 308 | iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device', | 472 | (e: {mac: string; type: string; rssi?: number}) => { |
| 309 | (event: {mac: string; type: string; rssi?: number}) => { | 473 | if (e.type === 'AM3') return; |
| 310 | const {mac = '', type = 'Unknown', rssi} = event; | 474 | const {mac = '', type = 'Unknown', rssi} = e; |
| 311 | if (type === 'AM3') return; | 475 | const idx = devicesRef.current.findIndex(d => d.mac === mac); |
| 312 | |||
| 313 | const existing = devicesRef.current.findIndex(d => d.mac === mac); | ||
| 314 | let updated: Device[]; | 476 | let updated: Device[]; |
| 315 | if (existing >= 0) { | 477 | if (idx >= 0) { |
| 316 | updated = [...devicesRef.current]; | 478 | updated = [...devicesRef.current]; |
| 317 | updated[existing] = {mac, type, rssi, timestamp: Date.now()}; | 479 | updated[idx] = {mac, type, rssi, timestamp: Date.now()}; |
| 318 | } else { | 480 | } else { |
| 319 | updated = [ | 481 | updated = [...devicesRef.current, {mac, type, rssi, timestamp: Date.now()}]; |
| 320 | ...devicesRef.current, | ||
| 321 | {mac, type, rssi, timestamp: Date.now()}, | ||
| 322 | ]; | ||
| 323 | } | 482 | } |
| 324 | devicesRef.current = updated; | 483 | devicesRef.current = updated; |
| 325 | setDevices(updated); | 484 | setDevices(updated); |
| 326 | }, | 485 | }, |
| 327 | ); | 486 | ); |
| 328 | 487 | const finSub = emitter.addListener( | |
| 329 | const finishSub = emitter.addListener( | 488 | mgr.Event_Scan_Finish ?? 'event_scan_finish', |
| 330 | iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish', | ||
| 331 | () => setScanning(false), | 489 | () => setScanning(false), |
| 332 | ); | 490 | ); |
| 333 | 491 | return () => { sub.remove(); finSub.remove(); }; | |
| 334 | return () => { | ||
| 335 | scanSub.remove(); | ||
| 336 | finishSub.remove(); | ||
| 337 | }; | ||
| 338 | }, []); | 492 | }, []); |
| 339 | 493 | ||
| 340 | const startScan = async () => { | 494 | const startScan = async () => { |
| 341 | setError(null); | 495 | setError(null); |
| 342 | const granted = await requestAndroidPermissions(); | 496 | const ok = await requestAndroidPermissions(); |
| 343 | if (!granted) { | 497 | if (!ok) { setError('Permissions denied'); return; } |
| 344 | setError('Bluetooth permissions denied'); | ||
| 345 | return; | ||
| 346 | } | ||
| 347 | devicesRef.current = []; | 498 | devicesRef.current = []; |
| 348 | setDevices([]); | 499 | setDevices([]); |
| 349 | setScanning(true); | 500 | setScanning(true); |
| 350 | |||
| 351 | if (Platform.OS === 'android') { | 501 | if (Platform.OS === 'android') { |
| 352 | try { iHealthDeviceManagerModule.startDiscovery('ALL'); } | 502 | try { mgr.startDiscovery('ALL'); } catch (e) { console.log(e); } |
| 353 | catch (e) { console.log('Discovery failed:', e); } | ||
| 354 | } else { | 503 | } else { |
| 355 | const iosTypes = ['KN550', 'BP3L', 'BP5S', 'BP7S', 'AM3S', 'AM4', | 504 | const types = ['KN550', 'BP3L', 'BP5S', 'BP7S', 'AM3S', 'AM4', |
| 356 | 'PO3', 'HS2', 'HS2S', 'HS4S', 'BG5S', 'BG1S', 'PO1', 'ECG3']; | 505 | 'PO3', 'HS2', 'HS2S', 'HS4S', 'BG5S', 'BG1S', 'PO1', 'ECG3']; |
| 357 | let i = 0; | 506 | let i = 0; |
| 358 | const scanNext = () => { | 507 | const next = () => { |
| 359 | if (i < iosTypes.length) { | 508 | if (i < types.length) { |
| 360 | try { iHealthDeviceManagerModule.startDiscovery(iosTypes[i]); } | 509 | try { mgr.startDiscovery(types[i]); } catch (_) {} |
| 361 | catch (e) { console.log(`Failed: ${iosTypes[i]}`, e); } | ||
| 362 | i++; | 510 | i++; |
| 363 | setTimeout(scanNext, 2000); | 511 | setTimeout(next, 2000); |
| 364 | } | 512 | } |
| 365 | }; | 513 | }; |
| 366 | setTimeout(scanNext, 2000); | 514 | setTimeout(next, 2000); |
| 367 | } | 515 | } |
| 368 | }; | 516 | }; |
| 369 | 517 | ||
| 370 | const stopScan = () => { | 518 | const stopScan = () => { |
| 371 | try { iHealthDeviceManagerModule.stopDiscovery(); } | 519 | try { mgr.stopDiscovery(); } catch (_) {} |
| 372 | catch (e) { console.log('Stop failed:', e); } | ||
| 373 | setScanning(false); | 520 | setScanning(false); |
| 374 | }; | 521 | }; |
| 375 | 522 | ||
| 376 | return ( | 523 | return ( |
| 377 | <SafeAreaView style={styles.container}> | 524 | <SafeAreaView style={s.container}> |
| 378 | <Text style={styles.title}>iHealth Scanner</Text> | 525 | <TouchableOpacity style={s.backButton} onPress={onBack}> |
| 379 | <Text style={styles.subtitle}> | 526 | <Text style={s.backText}>Home</Text> |
| 380 | {scanning | 527 | </TouchableOpacity> |
| 381 | ? `Scanning... (${devices.length} found)` | 528 | <Text style={s.title}>Debug Scanner</Text> |
| 382 | : `${devices.length} device(s) found`} | 529 | <Text style={s.subtitle}> |
| 530 | {scanning ? `Scanning... (${devices.length})` : `${devices.length} device(s)`} | ||
| 383 | </Text> | 531 | </Text> |
| 384 | 532 | {error && <Text style={s.error}>{error}</Text>} | |
| 385 | {error && <Text style={styles.error}>{error}</Text>} | ||
| 386 | |||
| 387 | <TouchableOpacity | 533 | <TouchableOpacity |
| 388 | style={[styles.button, scanning && styles.buttonStop]} | 534 | style={[s.button, scanning && s.buttonStop]} |
| 389 | onPress={scanning ? stopScan : startScan}> | 535 | onPress={scanning ? stopScan : startScan}> |
| 390 | <Text style={styles.buttonText}> | 536 | <Text style={s.buttonText}>{scanning ? 'Stop' : 'Start Scan'}</Text> |
| 391 | {scanning ? 'Stop Scan' : 'Start Scan'} | ||
| 392 | </Text> | ||
| 393 | </TouchableOpacity> | 537 | </TouchableOpacity> |
| 394 | |||
| 395 | <FlatList | 538 | <FlatList |
| 396 | data={devices} | 539 | data={devices} |
| 397 | keyExtractor={item => item.mac} | 540 | keyExtractor={item => item.mac} |
| 398 | renderItem={({item}) => ( | 541 | renderItem={({item}) => ( |
| 399 | <TouchableOpacity | 542 | <TouchableOpacity style={s.deviceRow} onPress={() => { stopScan(); onSelectDevice(item); }}> |
| 400 | style={styles.deviceRow} | 543 | <View style={s.deviceIcon}> |
| 401 | onPress={() => { | 544 | <Text style={s.deviceIconText}>{item.type.substring(0, 3)}</Text> |
| 402 | stopScan(); | ||
| 403 | onSelectDevice(item); | ||
| 404 | }}> | ||
| 405 | <View style={styles.deviceIcon}> | ||
| 406 | <Text style={styles.deviceIconText}> | ||
| 407 | {item.type.substring(0, 3)} | ||
| 408 | </Text> | ||
| 409 | </View> | 545 | </View> |
| 410 | <View style={styles.deviceInfo}> | 546 | <View style={s.deviceInfo}> |
| 411 | <Text style={styles.deviceType}>{item.type}</Text> | 547 | <Text style={s.deviceType}>{item.type}</Text> |
| 412 | <Text style={styles.deviceMac}>{item.mac}</Text> | 548 | <Text style={s.deviceMac}>{item.mac}</Text> |
| 413 | </View> | 549 | </View> |
| 414 | {item.rssi != null && ( | 550 | {item.rssi != null && <Text style={s.deviceRssi}>{item.rssi} dBm</Text>} |
| 415 | <Text style={styles.deviceRssi}>{item.rssi} dBm</Text> | 551 | <Text style={s.chevron}>{'>'}</Text> |
| 416 | )} | ||
| 417 | <Text style={styles.chevron}>{'>'}</Text> | ||
| 418 | </TouchableOpacity> | 552 | </TouchableOpacity> |
| 419 | )} | 553 | )} |
| 420 | style={styles.list} | 554 | style={s.list} |
| 421 | contentContainerStyle={devices.length === 0 && styles.emptyList} | 555 | contentContainerStyle={devices.length === 0 && s.emptyList} |
| 422 | ListEmptyComponent={ | 556 | ListEmptyComponent={ |
| 423 | <Text style={styles.emptyText}> | 557 | <Text style={s.emptyText}> |
| 424 | {scanning | 558 | {scanning ? 'Scanning...' : 'Tap Start Scan'} |
| 425 | ? 'Looking for iHealth devices...' | ||
| 426 | : 'Tap "Start Scan" to find nearby iHealth devices'} | ||
| 427 | </Text> | 559 | </Text> |
| 428 | } | 560 | } |
| 429 | /> | 561 | /> |
| @@ -431,20 +563,125 @@ function ScannerScreen({onSelectDevice}: {onSelectDevice: (d: Device) => void}) | |||
| 431 | ); | 563 | ); |
| 432 | } | 564 | } |
| 433 | 565 | ||
| 566 | // ── Debug Device Screen ────────────────────────────────────────────── | ||
| 567 | |||
| 568 | function DebugDeviceScreen({device, onBack}: {device: Device; onBack: () => void}) { | ||
| 569 | const [connected, setConnected] = useState(false); | ||
| 570 | const [connecting, setConnecting] = useState(false); | ||
| 571 | const [log, setLog] = useState<string[]>([]); | ||
| 572 | |||
| 573 | const addLog = (msg: string) => | ||
| 574 | setLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]); | ||
| 575 | |||
| 576 | useEffect(() => { | ||
| 577 | const emitter = getEmitter(); | ||
| 578 | const bpEmitter = getBPEmitter(); | ||
| 579 | const connSub = emitter.addListener( | ||
| 580 | mgr.Event_Device_Connected ?? 'event_device_connected', | ||
| 581 | (e: {mac: string}) => { | ||
| 582 | if (e.mac === device.mac) { setConnected(true); setConnecting(false); addLog('Connected'); } | ||
| 583 | }, | ||
| 584 | ); | ||
| 585 | const failSub = emitter.addListener( | ||
| 586 | mgr.Event_Device_Connect_Failed ?? 'event_device_connect_failed', | ||
| 587 | (e: {mac: string; errorid?: number}) => { | ||
| 588 | if (e.mac === device.mac) { setConnecting(false); addLog(`Connect failed: ${e.errorid}`); } | ||
| 589 | }, | ||
| 590 | ); | ||
| 591 | const dcSub = emitter.addListener( | ||
| 592 | mgr.Event_Device_Disconnect ?? 'event_device_disconnect', | ||
| 593 | (e: {mac: string}) => { | ||
| 594 | if (e.mac === device.mac) { setConnected(false); addLog('Disconnected'); } | ||
| 595 | }, | ||
| 596 | ); | ||
| 597 | const notSub = bpEmitter.addListener( | ||
| 598 | bp550?.Event_Notify ?? 'event_notify', | ||
| 599 | (e: Record<string, unknown>) => addLog(JSON.stringify(e, null, 2)), | ||
| 600 | ); | ||
| 601 | return () => { connSub.remove(); failSub.remove(); dcSub.remove(); notSub.remove(); }; | ||
| 602 | }, [device.mac]); | ||
| 603 | |||
| 604 | const actions = [ | ||
| 605 | {label: 'Get Battery', fn: () => { addLog('Battery...'); bp550.getBattery(device.mac); }}, | ||
| 606 | {label: 'Function Info', fn: () => { addLog('FuncInfo...'); bp550.getFunctionInfo(device.mac); }}, | ||
| 607 | {label: 'Offline Count', fn: () => { addLog('OfflineNum...'); bp550.getOffLineNum(device.mac); }}, | ||
| 608 | {label: 'Offline Data', fn: () => { addLog('OfflineData...'); bp550.getOffLineData(device.mac); }}, | ||
| 609 | {label: 'IDPS', fn: () => { | ||
| 610 | addLog('IDPS...'); | ||
| 611 | mgr.getDevicesIDPS(device.mac, idps => addLog(JSON.stringify(idps, null, 2))); | ||
| 612 | }}, | ||
| 613 | ]; | ||
| 614 | |||
| 615 | return ( | ||
| 616 | <SafeAreaView style={s.container}> | ||
| 617 | <TouchableOpacity style={s.backButton} onPress={onBack}> | ||
| 618 | <Text style={s.backText}>Back</Text> | ||
| 619 | </TouchableOpacity> | ||
| 620 | <Text style={s.title}>{device.type}</Text> | ||
| 621 | <Text style={s.deviceMac}>{device.mac}</Text> | ||
| 622 | <Text style={[s.statusBadge, connected && s.statusConnected]}> | ||
| 623 | {connecting ? 'Connecting...' : connected ? 'Connected' : 'Disconnected'} | ||
| 624 | </Text> | ||
| 625 | {!connected && !connecting && ( | ||
| 626 | <TouchableOpacity style={s.button} onPress={() => { | ||
| 627 | setConnecting(true); addLog('Connecting...'); | ||
| 628 | mgr.connectDevice(device.mac, device.type); | ||
| 629 | }}> | ||
| 630 | <Text style={s.buttonText}>Connect</Text> | ||
| 631 | </TouchableOpacity> | ||
| 632 | )} | ||
| 633 | {connected && ( | ||
| 634 | <TouchableOpacity style={[s.button, s.buttonStop]} onPress={() => { | ||
| 635 | addLog('Disconnecting...'); bp550.disconnect(device.mac); | ||
| 636 | }}> | ||
| 637 | <Text style={s.buttonText}>Disconnect</Text> | ||
| 638 | </TouchableOpacity> | ||
| 639 | )} | ||
| 640 | <ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.actionsRow}> | ||
| 641 | {actions.map(a => ( | ||
| 642 | <TouchableOpacity | ||
| 643 | key={a.label} | ||
| 644 | style={[s.actionButton, !connected && s.actionDisabled]} | ||
| 645 | disabled={!connected} | ||
| 646 | onPress={a.fn}> | ||
| 647 | <Text style={[s.actionText, !connected && s.actionTextDisabled]}>{a.label}</Text> | ||
| 648 | </TouchableOpacity> | ||
| 649 | ))} | ||
| 650 | </ScrollView> | ||
| 651 | <Text style={s.sectionTitle}>Log</Text> | ||
| 652 | <FlatList | ||
| 653 | data={log} | ||
| 654 | keyExtractor={(_, i) => String(i)} | ||
| 655 | renderItem={({item}) => <Text style={s.logLine}>{item}</Text>} | ||
| 656 | style={s.logList} | ||
| 657 | /> | ||
| 658 | </SafeAreaView> | ||
| 659 | ); | ||
| 660 | } | ||
| 661 | |||
| 434 | // ── App Root ───────────────────────────────────────────────────────── | 662 | // ── App Root ───────────────────────────────────────────────────────── |
| 435 | 663 | ||
| 436 | function App() { | 664 | function App() { |
| 437 | const [selectedDevice, setSelectedDevice] = useState<Device | null>(null); | 665 | const [screen, setScreen] = useState<Screen>('home'); |
| 666 | const [debugDevice, setDebugDevice] = useState<Device | null>(null); | ||
| 438 | 667 | ||
| 439 | return ( | 668 | return ( |
| 440 | <SafeAreaProvider> | 669 | <SafeAreaProvider> |
| 441 | {selectedDevice ? ( | 670 | {screen === 'home' && <HomeScreen onNav={setScreen} />} |
| 442 | <DeviceScreen | 671 | {screen === 'dashboard' && ( |
| 443 | device={selectedDevice} | 672 | <DashboardScreen onBack={() => setScreen('home')} /> |
| 444 | onBack={() => setSelectedDevice(null)} | 673 | )} |
| 674 | {screen === 'debug' && ( | ||
| 675 | <DebugScannerScreen | ||
| 676 | onBack={() => setScreen('home')} | ||
| 677 | onSelectDevice={d => { setDebugDevice(d); setScreen('debug-device'); }} | ||
| 678 | /> | ||
| 679 | )} | ||
| 680 | {screen === 'debug-device' && debugDevice && ( | ||
| 681 | <DebugDeviceScreen | ||
| 682 | device={debugDevice} | ||
| 683 | onBack={() => setScreen('debug')} | ||
| 445 | /> | 684 | /> |
| 446 | ) : ( | ||
| 447 | <ScannerScreen onSelectDevice={setSelectedDevice} /> | ||
| 448 | )} | 685 | )} |
| 449 | </SafeAreaProvider> | 686 | </SafeAreaProvider> |
| 450 | ); | 687 | ); |
| @@ -452,28 +689,41 @@ function App() { | |||
| 452 | 689 | ||
| 453 | // ── Styles ─────────────────────────────────────────────────────────── | 690 | // ── Styles ─────────────────────────────────────────────────────────── |
| 454 | 691 | ||
| 455 | const styles = StyleSheet.create({ | 692 | const s = StyleSheet.create({ |
| 456 | container: {flex: 1, backgroundColor: '#f5f5f5', paddingHorizontal: 16}, | 693 | container: {flex: 1, backgroundColor: '#f5f5f5', paddingHorizontal: 16}, |
| 694 | // Home | ||
| 695 | homeCenter: {flex: 1, justifyContent: 'center'}, | ||
| 696 | homeTitle: {fontSize: 36, fontWeight: '800', color: '#1a1a1a', textAlign: 'center'}, | ||
| 697 | homeSubtitle: {fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 40}, | ||
| 698 | homeButton: { | ||
| 699 | backgroundColor: '#2196F3', padding: 20, borderRadius: 14, | ||
| 700 | marginBottom: 12, alignItems: 'center', | ||
| 701 | }, | ||
| 702 | homeButtonSecondary: {backgroundColor: '#fff', borderWidth: 1, borderColor: '#ddd'}, | ||
| 703 | homeButtonText: {color: '#fff', fontSize: 18, fontWeight: '700'}, | ||
| 704 | homeButtonTextSecondary: {color: '#333'}, | ||
| 705 | homeButtonSub: {color: 'rgba(255,255,255,0.8)', fontSize: 13, marginTop: 4}, | ||
| 706 | // Common | ||
| 457 | title: {fontSize: 28, fontWeight: '700', color: '#1a1a1a', marginTop: 16}, | 707 | title: {fontSize: 28, fontWeight: '700', color: '#1a1a1a', marginTop: 16}, |
| 458 | subtitle: {fontSize: 14, color: '#666', marginTop: 4, marginBottom: 16}, | 708 | subtitle: {fontSize: 14, color: '#666', marginTop: 4, marginBottom: 16}, |
| 459 | error: { | 709 | sectionTitle: {fontSize: 16, fontWeight: '600', color: '#333', marginTop: 8, marginBottom: 8}, |
| 460 | color: '#d32f2f', fontSize: 13, marginBottom: 8, | 710 | error: {color: '#d32f2f', fontSize: 13, marginBottom: 8, backgroundColor: '#ffebee', padding: 8, borderRadius: 6}, |
| 461 | backgroundColor: '#ffebee', padding: 8, borderRadius: 6, | 711 | button: {backgroundColor: '#2196F3', paddingVertical: 14, borderRadius: 10, alignItems: 'center', marginBottom: 12}, |
| 462 | }, | ||
| 463 | button: { | ||
| 464 | backgroundColor: '#2196F3', paddingVertical: 14, | ||
| 465 | borderRadius: 10, alignItems: 'center', marginBottom: 16, | ||
| 466 | }, | ||
| 467 | buttonStop: {backgroundColor: '#f44336'}, | 712 | buttonStop: {backgroundColor: '#f44336'}, |
| 468 | buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, | 713 | buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, |
| 714 | buttonOutline: {backgroundColor: 'transparent', borderWidth: 1.5, borderColor: '#2196F3'}, | ||
| 715 | buttonTextOutline: {color: '#2196F3'}, | ||
| 469 | list: {flex: 1}, | 716 | list: {flex: 1}, |
| 470 | emptyList: {flex: 1, justifyContent: 'center', alignItems: 'center'}, | 717 | emptyList: {flex: 1, justifyContent: 'center', alignItems: 'center'}, |
| 471 | emptyText: {color: '#999', fontSize: 15, textAlign: 'center'}, | 718 | emptyText: {color: '#999', fontSize: 15, textAlign: 'center'}, |
| 719 | backButton: {marginTop: 12, marginBottom: 4}, | ||
| 720 | backText: {color: '#2196F3', fontSize: 16}, | ||
| 721 | chevron: {fontSize: 18, color: '#ccc', marginLeft: 8}, | ||
| 722 | // Device rows | ||
| 472 | deviceRow: { | 723 | deviceRow: { |
| 473 | flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', | 724 | flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', |
| 474 | padding: 14, borderRadius: 10, marginBottom: 8, | 725 | padding: 14, borderRadius: 10, marginBottom: 8, |
| 475 | shadowColor: '#000', shadowOffset: {width: 0, height: 1}, | 726 | shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, |
| 476 | shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, | ||
| 477 | }, | 727 | }, |
| 478 | deviceIcon: { | 728 | deviceIcon: { |
| 479 | width: 44, height: 44, borderRadius: 22, backgroundColor: '#e3f2fd', | 729 | width: 44, height: 44, borderRadius: 22, backgroundColor: '#e3f2fd', |
| @@ -482,37 +732,51 @@ const styles = StyleSheet.create({ | |||
| 482 | deviceIconText: {fontSize: 12, fontWeight: '700', color: '#1565c0'}, | 732 | deviceIconText: {fontSize: 12, fontWeight: '700', color: '#1565c0'}, |
| 483 | deviceInfo: {flex: 1}, | 733 | deviceInfo: {flex: 1}, |
| 484 | deviceType: {fontSize: 16, fontWeight: '600', color: '#1a1a1a'}, | 734 | deviceType: {fontSize: 16, fontWeight: '600', color: '#1a1a1a'}, |
| 485 | deviceMac: { | 735 | deviceMac: {fontSize: 12, color: '#888', marginTop: 2, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, |
| 486 | fontSize: 12, color: '#888', marginTop: 2, | ||
| 487 | fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', | ||
| 488 | }, | ||
| 489 | deviceRssi: {fontSize: 12, color: '#999', marginLeft: 8}, | 736 | deviceRssi: {fontSize: 12, color: '#999', marginLeft: 8}, |
| 490 | chevron: {fontSize: 18, color: '#ccc', marginLeft: 8}, | 737 | // Dashboard |
| 491 | // Device screen | 738 | savedRow: { |
| 492 | backButton: {marginTop: 12, marginBottom: 4}, | 739 | flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', |
| 493 | backText: {color: '#2196F3', fontSize: 16}, | 740 | padding: 14, borderRadius: 10, marginBottom: 8, elevation: 1, |
| 494 | statusBadge: { | ||
| 495 | fontSize: 13, color: '#f44336', marginTop: 8, marginBottom: 16, | ||
| 496 | fontWeight: '600', | ||
| 497 | }, | 741 | }, |
| 498 | statusConnected: {color: '#4caf50'}, | 742 | removeText: {color: '#f44336', fontSize: 13, fontWeight: '600'}, |
| 499 | sectionTitle: { | 743 | deviceOffline: {opacity: 0.5}, |
| 500 | fontSize: 16, fontWeight: '600', color: '#333', | 744 | deviceIconSaved: {backgroundColor: '#c8e6c9'}, |
| 501 | marginTop: 8, marginBottom: 8, | 745 | savedBadge: {fontSize: 10, color: '#4caf50', fontWeight: '700'}, |
| 746 | foundRow: { | ||
| 747 | flexDirection: 'row', alignItems: 'center', backgroundColor: '#e8f5e9', | ||
| 748 | padding: 14, borderRadius: 10, marginBottom: 8, | ||
| 502 | }, | 749 | }, |
| 503 | actionsRow: {flexGrow: 0, marginBottom: 12}, | 750 | addButton: {backgroundColor: '#4caf50', paddingVertical: 8, paddingHorizontal: 16, borderRadius: 8}, |
| 504 | actionButton: { | 751 | addButtonText: {color: '#fff', fontSize: 14, fontWeight: '600'}, |
| 505 | backgroundColor: '#e3f2fd', paddingVertical: 10, paddingHorizontal: 16, | 752 | syncBar: {flexDirection: 'row', alignItems: 'center', padding: 10, backgroundColor: '#e3f2fd', borderRadius: 8, marginBottom: 8}, |
| 506 | borderRadius: 8, marginRight: 8, | 753 | syncText: {color: '#1565c0', fontSize: 13, marginLeft: 8}, |
| 754 | syncDone: {color: '#4caf50', fontSize: 13, marginBottom: 8, fontWeight: '600'}, | ||
| 755 | scanningRow: {flexDirection: 'row', alignItems: 'center', padding: 12}, | ||
| 756 | scanningText: {color: '#666', fontSize: 13, marginLeft: 8}, | ||
| 757 | // Readings | ||
| 758 | readingRow: { | ||
| 759 | backgroundColor: '#fff', padding: 14, borderRadius: 10, marginBottom: 8, elevation: 1, | ||
| 507 | }, | 760 | }, |
| 761 | readingValues: {flexDirection: 'row', alignItems: 'baseline'}, | ||
| 762 | readingSys: {fontSize: 28, fontWeight: '700', color: '#1a1a1a'}, | ||
| 763 | readingSlash: {fontSize: 20, color: '#999', marginHorizontal: 4}, | ||
| 764 | readingDia: {fontSize: 28, fontWeight: '700', color: '#1a1a1a'}, | ||
| 765 | readingUnit: {fontSize: 12, color: '#999', marginLeft: 6}, | ||
| 766 | readingPulse: {fontSize: 14, color: '#666', marginLeft: 16}, | ||
| 767 | readingMeta: {flexDirection: 'row', justifyContent: 'space-between', marginTop: 4}, | ||
| 768 | readingDate: {fontSize: 12, color: '#999'}, | ||
| 769 | readingMac: {fontSize: 10, color: '#bbb', fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, | ||
| 770 | // Debug device | ||
| 771 | statusBadge: {fontSize: 13, color: '#f44336', marginTop: 8, marginBottom: 16, fontWeight: '600'}, | ||
| 772 | statusConnected: {color: '#4caf50'}, | ||
| 773 | actionsRow: {flexGrow: 0, marginBottom: 12}, | ||
| 774 | actionButton: {backgroundColor: '#e3f2fd', paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, marginRight: 8}, | ||
| 508 | actionDisabled: {backgroundColor: '#eee'}, | 775 | actionDisabled: {backgroundColor: '#eee'}, |
| 509 | actionText: {color: '#1565c0', fontSize: 13, fontWeight: '600'}, | 776 | actionText: {color: '#1565c0', fontSize: 13, fontWeight: '600'}, |
| 510 | actionTextDisabled: {color: '#bbb'}, | 777 | actionTextDisabled: {color: '#bbb'}, |
| 511 | logList: {flex: 1, marginTop: 4}, | 778 | logList: {flex: 1, marginTop: 4}, |
| 512 | logLine: { | 779 | logLine: {fontSize: 11, color: '#555', paddingVertical: 3, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, |
| 513 | fontSize: 11, color: '#555', paddingVertical: 3, | ||
| 514 | fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', | ||
| 515 | }, | ||
| 516 | }); | 780 | }); |
| 517 | 781 | ||
| 518 | export default App; | 782 | export default App; |
diff --git a/package-lock.json b/package-lock.json index b5abdac..f5a42c2 100644 --- a/package-lock.json +++ b/package-lock.json | |||
| @@ -9,6 +9,7 @@ | |||
| 9 | "version": "0.0.1", | 9 | "version": "0.0.1", |
| 10 | "dependencies": { | 10 | "dependencies": { |
| 11 | "@ihealth/ihealthlibrary-react-native": "file:./libs/ihealth-sdk", | 11 | "@ihealth/ihealthlibrary-react-native": "file:./libs/ihealth-sdk", |
| 12 | "@react-native-async-storage/async-storage": "^2.2.0", | ||
| 12 | "@react-native/new-app-screen": "0.85.0", | 13 | "@react-native/new-app-screen": "0.85.0", |
| 13 | "react": "19.2.3", | 14 | "react": "19.2.3", |
| 14 | "react-native": "0.85.0", | 15 | "react-native": "0.85.0", |
| @@ -2758,6 +2759,18 @@ | |||
| 2758 | "node": ">= 8" | 2759 | "node": ">= 8" |
| 2759 | } | 2760 | } |
| 2760 | }, | 2761 | }, |
| 2762 | "node_modules/@react-native-async-storage/async-storage": { | ||
| 2763 | "version": "2.2.0", | ||
| 2764 | "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", | ||
| 2765 | "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", | ||
| 2766 | "license": "MIT", | ||
| 2767 | "dependencies": { | ||
| 2768 | "merge-options": "^3.0.4" | ||
| 2769 | }, | ||
| 2770 | "peerDependencies": { | ||
| 2771 | "react-native": "^0.0.0-0 || >=0.65 <1.0" | ||
| 2772 | } | ||
| 2773 | }, | ||
| 2761 | "node_modules/@react-native-community/cli": { | 2774 | "node_modules/@react-native-community/cli": { |
| 2762 | "version": "20.1.0", | 2775 | "version": "20.1.0", |
| 2763 | "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.0.tgz", | 2776 | "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.0.tgz", |
| @@ -7452,6 +7465,15 @@ | |||
| 7452 | "node": ">=8" | 7465 | "node": ">=8" |
| 7453 | } | 7466 | } |
| 7454 | }, | 7467 | }, |
| 7468 | "node_modules/is-plain-obj": { | ||
| 7469 | "version": "2.1.0", | ||
| 7470 | "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", | ||
| 7471 | "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", | ||
| 7472 | "license": "MIT", | ||
| 7473 | "engines": { | ||
| 7474 | "node": ">=8" | ||
| 7475 | } | ||
| 7476 | }, | ||
| 7455 | "node_modules/is-regex": { | 7477 | "node_modules/is-regex": { |
| 7456 | "version": "1.2.1", | 7478 | "version": "1.2.1", |
| 7457 | "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", | 7479 | "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", |
| @@ -8823,6 +8845,18 @@ | |||
| 8823 | "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", | 8845 | "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", |
| 8824 | "license": "MIT" | 8846 | "license": "MIT" |
| 8825 | }, | 8847 | }, |
| 8848 | "node_modules/merge-options": { | ||
| 8849 | "version": "3.0.4", | ||
| 8850 | "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", | ||
| 8851 | "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", | ||
| 8852 | "license": "MIT", | ||
| 8853 | "dependencies": { | ||
| 8854 | "is-plain-obj": "^2.1.0" | ||
| 8855 | }, | ||
| 8856 | "engines": { | ||
| 8857 | "node": ">=10" | ||
| 8858 | } | ||
| 8859 | }, | ||
| 8826 | "node_modules/merge-stream": { | 8860 | "node_modules/merge-stream": { |
| 8827 | "version": "2.0.0", | 8861 | "version": "2.0.0", |
| 8828 | "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", | 8862 | "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", |
diff --git a/package.json b/package.json index 961302d..2e1b03e 100644 --- a/package.json +++ b/package.json | |||
| @@ -11,6 +11,7 @@ | |||
| 11 | }, | 11 | }, |
| 12 | "dependencies": { | 12 | "dependencies": { |
| 13 | "@ihealth/ihealthlibrary-react-native": "file:./libs/ihealth-sdk", | 13 | "@ihealth/ihealthlibrary-react-native": "file:./libs/ihealth-sdk", |
| 14 | "@react-native-async-storage/async-storage": "^2.2.0", | ||
| 14 | "@react-native/new-app-screen": "0.85.0", | 15 | "@react-native/new-app-screen": "0.85.0", |
| 15 | "react": "19.2.3", | 16 | "react": "19.2.3", |
| 16 | "react-native": "0.85.0", | 17 | "react-native": "0.85.0", |
