diff options
Diffstat (limited to 'App.tsx')
| -rw-r--r-- | App.tsx | 561 |
1 files changed, 364 insertions, 197 deletions
| @@ -5,14 +5,18 @@ import { | |||
| 5 | Text, | 5 | Text, |
| 6 | TouchableOpacity, | 6 | TouchableOpacity, |
| 7 | FlatList, | 7 | FlatList, |
| 8 | ScrollView, | ||
| 8 | Platform, | 9 | Platform, |
| 9 | PermissionsAndroid, | 10 | PermissionsAndroid, |
| 10 | DeviceEventEmitter, | 11 | DeviceEventEmitter, |
| 12 | NativeEventEmitter, | ||
| 11 | NativeModules, | 13 | NativeModules, |
| 14 | Alert, | ||
| 12 | } from 'react-native'; | 15 | } from 'react-native'; |
| 13 | import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; | 16 | import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; |
| 14 | 17 | ||
| 15 | // Device type string names accepted by startDiscovery on both platforms | 18 | // ── Native module types ────────────────────────────────────────────── |
| 19 | |||
| 16 | const DEVICE_TYPE_KEYS = [ | 20 | const DEVICE_TYPE_KEYS = [ |
| 17 | 'AM3S', 'AM4', 'PO3', 'BP5', 'BP5S', 'BP3L', 'BP7', 'BP7S', | 21 | 'AM3S', 'AM4', 'PO3', 'BP5', 'BP5S', 'BP3L', 'BP7', 'BP7S', |
| 18 | 'KN550', 'HS2', 'HS2S', 'HS4S', 'BG1', 'BG1S', 'BG5', 'BG5S', | 22 | 'KN550', 'HS2', 'HS2S', 'HS4S', 'BG1', 'BG1S', 'BG5', 'BG5S', |
| @@ -20,40 +24,22 @@ const DEVICE_TYPE_KEYS = [ | |||
| 20 | ] as const; | 24 | ] as const; |
| 21 | 25 | ||
| 22 | type DeviceTypeName = (typeof DEVICE_TYPE_KEYS)[number]; | 26 | type DeviceTypeName = (typeof DEVICE_TYPE_KEYS)[number]; |
| 23 | |||
| 24 | // The native module exports string constants on iOS, number constants on Android. | ||
| 25 | type DiscoveryConstant = string | number; | 27 | type DiscoveryConstant = string | number; |
| 26 | 28 | ||
| 27 | interface IHealthDeviceManager { | 29 | interface IHealthDeviceManager { |
| 28 | // Device type constants (string on iOS, number on Android) | 30 | AM3S: DiscoveryConstant; AM4: DiscoveryConstant; PO3: DiscoveryConstant; |
| 29 | AM3S: DiscoveryConstant; | 31 | BP5: DiscoveryConstant; BP5S: DiscoveryConstant; BP3L: DiscoveryConstant; |
| 30 | AM4: DiscoveryConstant; | 32 | BP7: DiscoveryConstant; BP7S: DiscoveryConstant; KN550: DiscoveryConstant; |
| 31 | PO3: DiscoveryConstant; | 33 | HS2: DiscoveryConstant; HS2S: DiscoveryConstant; HS4S: DiscoveryConstant; |
| 32 | BP5: DiscoveryConstant; | 34 | BG1: DiscoveryConstant; BG1S: DiscoveryConstant; BG5: DiscoveryConstant; |
| 33 | BP5S: DiscoveryConstant; | 35 | BG5S: DiscoveryConstant; ECG3: DiscoveryConstant; BTM: DiscoveryConstant; |
| 34 | BP3L: DiscoveryConstant; | 36 | TS28B: DiscoveryConstant; NT13B: DiscoveryConstant; |
| 35 | BP7: DiscoveryConstant; | ||
| 36 | BP7S: DiscoveryConstant; | ||
| 37 | KN550: DiscoveryConstant; | ||
| 38 | HS2: DiscoveryConstant; | ||
| 39 | HS2S: DiscoveryConstant; | ||
| 40 | HS4S: DiscoveryConstant; | ||
| 41 | BG1: DiscoveryConstant; | ||
| 42 | BG1S: DiscoveryConstant; | ||
| 43 | BG5: DiscoveryConstant; | ||
| 44 | BG5S: DiscoveryConstant; | ||
| 45 | ECG3: DiscoveryConstant; | ||
| 46 | BTM: DiscoveryConstant; | ||
| 47 | TS28B: DiscoveryConstant; | ||
| 48 | NT13B: DiscoveryConstant; | ||
| 49 | // Event name constants | ||
| 50 | Event_Scan_Device: string; | 37 | Event_Scan_Device: string; |
| 51 | Event_Scan_Finish: string; | 38 | Event_Scan_Finish: string; |
| 52 | Event_Device_Connected: string; | 39 | Event_Device_Connected: string; |
| 53 | Event_Device_Connect_Failed: string; | 40 | Event_Device_Connect_Failed: string; |
| 54 | Event_Device_Disconnect: string; | 41 | Event_Device_Disconnect: string; |
| 55 | Event_Authenticate_Result: string; | 42 | Event_Authenticate_Result: string; |
| 56 | // Methods — startDiscovery takes a device type name string per official docs | ||
| 57 | startDiscovery(type: DeviceTypeName | string): void; | 43 | startDiscovery(type: DeviceTypeName | string): void; |
| 58 | stopDiscovery(): void; | 44 | stopDiscovery(): void; |
| 59 | connectDevice(mac: string, type: string): void; | 45 | connectDevice(mac: string, type: string): void; |
| @@ -63,8 +49,20 @@ interface IHealthDeviceManager { | |||
| 63 | getDevicesIDPS(mac: string, callback: (idps: Record<string, string>) => void): void; | 49 | getDevicesIDPS(mac: string, callback: (idps: Record<string, string>) => void): void; |
| 64 | } | 50 | } |
| 65 | 51 | ||
| 52 | interface IBP550BTModule { | ||
| 53 | Event_Notify: string; | ||
| 54 | getBattery(mac: string): void; | ||
| 55 | getOffLineNum(mac: string): void; | ||
| 56 | getOffLineData(mac: string): void; | ||
| 57 | getFunctionInfo(mac: string): void; | ||
| 58 | disconnect(mac: string): void; | ||
| 59 | getAllConnectedDevices(): void; | ||
| 60 | } | ||
| 61 | |||
| 66 | const iHealthDeviceManagerModule = | 62 | const iHealthDeviceManagerModule = |
| 67 | NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; | 63 | NativeModules.iHealthDeviceManagerModule as IHealthDeviceManager; |
| 64 | const BP550BTModule = | ||
| 65 | NativeModules.BP550BTModule as IBP550BTModule; | ||
| 68 | 66 | ||
| 69 | type Device = { | 67 | type Device = { |
| 70 | mac: string; | 68 | mac: string; |
| @@ -73,45 +71,227 @@ type Device = { | |||
| 73 | timestamp: number; | 71 | timestamp: number; |
| 74 | }; | 72 | }; |
| 75 | 73 | ||
| 74 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 75 | |||
| 76 | async function requestAndroidPermissions(): Promise<boolean> { | 76 | async function requestAndroidPermissions(): Promise<boolean> { |
| 77 | if (Platform.OS !== 'android') return true; | 77 | if (Platform.OS !== 'android') return true; |
| 78 | |||
| 79 | const apiLevel = Platform.Version; | ||
| 80 | const permissions: string[] = []; | 78 | const permissions: string[] = []; |
| 81 | 79 | if (Platform.Version >= 31) { | |
| 82 | if (apiLevel >= 31) { | ||
| 83 | permissions.push( | 80 | permissions.push( |
| 84 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, | 81 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, |
| 85 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, | 82 | PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, |
| 86 | ); | 83 | ); |
| 87 | } | 84 | } |
| 88 | permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); | 85 | permissions.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); |
| 89 | |||
| 90 | const results = await PermissionsAndroid.requestMultiple(permissions as any); | 86 | const results = await PermissionsAndroid.requestMultiple(permissions as any); |
| 91 | return Object.values(results).every( | 87 | return Object.values(results).every( |
| 92 | r => r === PermissionsAndroid.RESULTS.GRANTED, | 88 | r => r === PermissionsAndroid.RESULTS.GRANTED, |
| 93 | ); | 89 | ); |
| 94 | } | 90 | } |
| 95 | 91 | ||
| 96 | function DeviceIcon({type}: {type: string}) { | 92 | function getEmitter() { |
| 97 | const icons: Record<string, string> = { | 93 | return Platform.OS === 'ios' |
| 98 | BP: 'BP', | 94 | ? new NativeEventEmitter(NativeModules.iHealthDeviceManagerModule) |
| 99 | AM: 'AM', | 95 | : DeviceEventEmitter; |
| 100 | PO: 'PO', | 96 | } |
| 101 | BG: 'BG', | 97 | |
| 102 | HS: 'HS', | 98 | function getBPEmitter() { |
| 103 | ECG: 'ECG', | 99 | return Platform.OS === 'ios' |
| 104 | BTM: 'BTM', | 100 | ? new NativeEventEmitter(NativeModules.BP550BTModule) |
| 101 | : DeviceEventEmitter; | ||
| 102 | } | ||
| 103 | |||
| 104 | // ── Device Detail Screen ───────────────────────────────────────────── | ||
| 105 | |||
| 106 | function DeviceScreen({ | ||
| 107 | device, | ||
| 108 | onBack, | ||
| 109 | }: { | ||
| 110 | device: Device; | ||
| 111 | onBack: () => void; | ||
| 112 | }) { | ||
| 113 | const [connected, setConnected] = useState(false); | ||
| 114 | const [connecting, setConnecting] = useState(false); | ||
| 115 | const [log, setLog] = useState<string[]>([]); | ||
| 116 | |||
| 117 | const addLog = (msg: string) => | ||
| 118 | setLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]); | ||
| 119 | |||
| 120 | useEffect(() => { | ||
| 121 | const emitter = getEmitter(); | ||
| 122 | |||
| 123 | const connSub = emitter.addListener( | ||
| 124 | iHealthDeviceManagerModule.Event_Device_Connected ?? 'event_device_connected', | ||
| 125 | (e: {mac: string; type: string}) => { | ||
| 126 | if (e.mac === device.mac) { | ||
| 127 | setConnected(true); | ||
| 128 | setConnecting(false); | ||
| 129 | addLog(`Connected to ${e.type}`); | ||
| 130 | } | ||
| 131 | }, | ||
| 132 | ); | ||
| 133 | |||
| 134 | const failSub = emitter.addListener( | ||
| 135 | iHealthDeviceManagerModule.Event_Device_Connect_Failed ?? 'event_device_connect_failed', | ||
| 136 | (e: {mac: string; errorid?: number}) => { | ||
| 137 | if (e.mac === device.mac) { | ||
| 138 | setConnecting(false); | ||
| 139 | addLog(`Connection failed (error: ${e.errorid ?? 'unknown'})`); | ||
| 140 | } | ||
| 141 | }, | ||
| 142 | ); | ||
| 143 | |||
| 144 | const dcSub = emitter.addListener( | ||
| 145 | iHealthDeviceManagerModule.Event_Device_Disconnect ?? 'event_device_disconnect', | ||
| 146 | (e: {mac: string}) => { | ||
| 147 | if (e.mac === device.mac) { | ||
| 148 | setConnected(false); | ||
| 149 | addLog('Disconnected'); | ||
| 150 | } | ||
| 151 | }, | ||
| 152 | ); | ||
| 153 | |||
| 154 | // BP550BT event listener | ||
| 155 | const bpEmitter = getBPEmitter(); | ||
| 156 | const notifySub = bpEmitter.addListener( | ||
| 157 | BP550BTModule?.Event_Notify ?? 'event_notify', | ||
| 158 | (e: Record<string, unknown>) => { | ||
| 159 | addLog(JSON.stringify(e, null, 2)); | ||
| 160 | }, | ||
| 161 | ); | ||
| 162 | |||
| 163 | return () => { | ||
| 164 | connSub.remove(); | ||
| 165 | failSub.remove(); | ||
| 166 | dcSub.remove(); | ||
| 167 | notifySub.remove(); | ||
| 168 | }; | ||
| 169 | }, [device.mac]); | ||
| 170 | |||
| 171 | const connect = () => { | ||
| 172 | setConnecting(true); | ||
| 173 | addLog(`Connecting to ${device.mac}...`); | ||
| 174 | iHealthDeviceManagerModule.connectDevice(device.mac, device.type); | ||
| 175 | }; | ||
| 176 | |||
| 177 | const disconnect = () => { | ||
| 178 | addLog('Disconnecting...'); | ||
| 179 | if (BP550BTModule) { | ||
| 180 | BP550BTModule.disconnect(device.mac); | ||
| 181 | } else { | ||
| 182 | iHealthDeviceManagerModule.disconnectDevice(device.mac, device.type); | ||
| 183 | } | ||
| 105 | }; | 184 | }; |
| 106 | const prefix = Object.keys(icons).find(k => type.startsWith(k)) || '?'; | 185 | |
| 186 | const actions: {label: string; fn: () => void; needsConnect: boolean}[] = [ | ||
| 187 | { | ||
| 188 | label: 'Get Battery', | ||
| 189 | needsConnect: true, | ||
| 190 | fn: () => { | ||
| 191 | addLog('Requesting battery...'); | ||
| 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 | |||
| 107 | return ( | 231 | return ( |
| 108 | <View style={styles.deviceIcon}> | 232 | <SafeAreaView style={styles.container}> |
| 109 | <Text style={styles.deviceIconText}>{icons[prefix] || '?'}</Text> | 233 | <TouchableOpacity style={styles.backButton} onPress={onBack}> |
| 110 | </View> | 234 | <Text style={styles.backText}>Back</Text> |
| 235 | </TouchableOpacity> | ||
| 236 | |||
| 237 | <Text style={styles.title}>{device.type}</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 | |||
| 243 | {!connected && !connecting && ( | ||
| 244 | <TouchableOpacity style={styles.button} onPress={connect}> | ||
| 245 | <Text style={styles.buttonText}>Connect</Text> | ||
| 246 | </TouchableOpacity> | ||
| 247 | )} | ||
| 248 | {connected && ( | ||
| 249 | <TouchableOpacity | ||
| 250 | style={[styles.button, styles.buttonStop]} | ||
| 251 | onPress={disconnect}> | ||
| 252 | <Text style={styles.buttonText}>Disconnect</Text> | ||
| 253 | </TouchableOpacity> | ||
| 254 | )} | ||
| 255 | |||
| 256 | <Text style={styles.sectionTitle}>Actions</Text> | ||
| 257 | <ScrollView | ||
| 258 | horizontal | ||
| 259 | showsHorizontalScrollIndicator={false} | ||
| 260 | style={styles.actionsRow}> | ||
| 261 | {actions.map(a => ( | ||
| 262 | <TouchableOpacity | ||
| 263 | key={a.label} | ||
| 264 | style={[ | ||
| 265 | styles.actionButton, | ||
| 266 | a.needsConnect && !connected && styles.actionDisabled, | ||
| 267 | ]} | ||
| 268 | disabled={a.needsConnect && !connected} | ||
| 269 | onPress={a.fn}> | ||
| 270 | <Text | ||
| 271 | style={[ | ||
| 272 | styles.actionText, | ||
| 273 | a.needsConnect && !connected && styles.actionTextDisabled, | ||
| 274 | ]}> | ||
| 275 | {a.label} | ||
| 276 | </Text> | ||
| 277 | </TouchableOpacity> | ||
| 278 | ))} | ||
| 279 | </ScrollView> | ||
| 280 | |||
| 281 | <Text style={styles.sectionTitle}>Log</Text> | ||
| 282 | <FlatList | ||
| 283 | data={log} | ||
| 284 | keyExtractor={(_, i) => String(i)} | ||
| 285 | renderItem={({item}) => <Text style={styles.logLine}>{item}</Text>} | ||
| 286 | style={styles.logList} | ||
| 287 | /> | ||
| 288 | </SafeAreaView> | ||
| 111 | ); | 289 | ); |
| 112 | } | 290 | } |
| 113 | 291 | ||
| 114 | function App() { | 292 | // ── Scanner Screen ─────────────────────────────────────────────────── |
| 293 | |||
| 294 | function ScannerScreen({onSelectDevice}: {onSelectDevice: (d: Device) => void}) { | ||
| 115 | const [devices, setDevices] = useState<Device[]>([]); | 295 | const [devices, setDevices] = useState<Device[]>([]); |
| 116 | const [scanning, setScanning] = useState(false); | 296 | const [scanning, setScanning] = useState(false); |
| 117 | const [error, setError] = useState<string | null>(null); | 297 | const [error, setError] = useState<string | null>(null); |
| @@ -119,18 +299,15 @@ function App() { | |||
| 119 | 299 | ||
| 120 | useEffect(() => { | 300 | useEffect(() => { |
| 121 | if (!iHealthDeviceManagerModule) { | 301 | if (!iHealthDeviceManagerModule) { |
| 122 | setError('iHealth native module not found. Check linking.'); | 302 | setError('iHealth native module not found.'); |
| 123 | return; | 303 | return; |
| 124 | } | 304 | } |
| 305 | const emitter = getEmitter(); | ||
| 125 | 306 | ||
| 126 | // The iHealth SDK uses the global DeviceEventEmitter (not NativeEventEmitter) | 307 | const scanSub = emitter.addListener( |
| 127 | // because the iOS native module doesn't subclass RCTEventEmitter. | ||
| 128 | const scanSub = DeviceEventEmitter.addListener( | ||
| 129 | iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device', | 308 | iHealthDeviceManagerModule.Event_Scan_Device ?? 'event_scan_device', |
| 130 | (event: {mac: string; type: string; rssi?: number}) => { | 309 | (event: {mac: string; type: string; rssi?: number}) => { |
| 131 | const {mac = '', type = 'Unknown', rssi} = event; | 310 | const {mac = '', type = 'Unknown', rssi} = event; |
| 132 | |||
| 133 | // Filter out AM3 — likely false positives from non-iHealth heart rate devices | ||
| 134 | if (type === 'AM3') return; | 311 | if (type === 'AM3') return; |
| 135 | 312 | ||
| 136 | const existing = devicesRef.current.findIndex(d => d.mac === mac); | 313 | const existing = devicesRef.current.findIndex(d => d.mac === mac); |
| @@ -149,11 +326,9 @@ function App() { | |||
| 149 | }, | 326 | }, |
| 150 | ); | 327 | ); |
| 151 | 328 | ||
| 152 | const finishSub = DeviceEventEmitter.addListener( | 329 | const finishSub = emitter.addListener( |
| 153 | iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish', | 330 | iHealthDeviceManagerModule.Event_Scan_Finish ?? 'event_scan_finish', |
| 154 | () => { | 331 | () => setScanning(false), |
| 155 | setScanning(false); | ||
| 156 | }, | ||
| 157 | ); | 332 | ); |
| 158 | 333 | ||
| 159 | return () => { | 334 | return () => { |
| @@ -164,187 +339,179 @@ function App() { | |||
| 164 | 339 | ||
| 165 | const startScan = async () => { | 340 | const startScan = async () => { |
| 166 | setError(null); | 341 | setError(null); |
| 167 | |||
| 168 | const granted = await requestAndroidPermissions(); | 342 | const granted = await requestAndroidPermissions(); |
| 169 | if (!granted) { | 343 | if (!granted) { |
| 170 | setError('Bluetooth permissions denied'); | 344 | setError('Bluetooth permissions denied'); |
| 171 | return; | 345 | return; |
| 172 | } | 346 | } |
| 173 | |||
| 174 | devicesRef.current = []; | 347 | devicesRef.current = []; |
| 175 | setDevices([]); | 348 | setDevices([]); |
| 176 | setScanning(true); | 349 | setScanning(true); |
| 177 | 350 | ||
| 178 | // Discover all device types. 'ALL' hits the default case in getDiscoveryType() | 351 | if (Platform.OS === 'android') { |
| 179 | // which maps to DiscoveryTypeEnum.All (MIX = BLE + BT Classic + WiFi). | 352 | try { iHealthDeviceManagerModule.startDiscovery('ALL'); } |
| 180 | // Requires ACCESS_NETWORK_STATE permission for WiFi scan. | 353 | catch (e) { console.log('Discovery failed:', e); } |
| 181 | try { | 354 | } else { |
| 182 | iHealthDeviceManagerModule.startDiscovery('ALL'); | 355 | const iosTypes = ['KN550', 'BP3L', 'BP5S', 'BP7S', 'AM3S', 'AM4', |
| 183 | } catch (e) { | 356 | 'PO3', 'HS2', 'HS2S', 'HS4S', 'BG5S', 'BG1S', 'PO1', 'ECG3']; |
| 184 | console.log('Failed to start discovery:', e); | 357 | let i = 0; |
| 358 | const scanNext = () => { | ||
| 359 | if (i < iosTypes.length) { | ||
| 360 | try { iHealthDeviceManagerModule.startDiscovery(iosTypes[i]); } | ||
| 361 | catch (e) { console.log(`Failed: ${iosTypes[i]}`, e); } | ||
| 362 | i++; | ||
| 363 | setTimeout(scanNext, 2000); | ||
| 364 | } | ||
| 365 | }; | ||
| 366 | setTimeout(scanNext, 2000); | ||
| 185 | } | 367 | } |
| 186 | }; | 368 | }; |
| 187 | 369 | ||
| 188 | const stopScan = () => { | 370 | const stopScan = () => { |
| 189 | try { | 371 | try { iHealthDeviceManagerModule.stopDiscovery(); } |
| 190 | iHealthDeviceManagerModule.stopDiscovery(); | 372 | catch (e) { console.log('Stop failed:', e); } |
| 191 | } catch (e) { | ||
| 192 | console.log('Failed to stop discovery:', e); | ||
| 193 | } | ||
| 194 | setScanning(false); | 373 | setScanning(false); |
| 195 | }; | 374 | }; |
| 196 | 375 | ||
| 197 | const renderDevice = ({item}: {item: Device}) => ( | ||
| 198 | <View style={styles.deviceRow}> | ||
| 199 | <DeviceIcon type={item.type} /> | ||
| 200 | <View style={styles.deviceInfo}> | ||
| 201 | <Text style={styles.deviceType}>{item.type}</Text> | ||
| 202 | <Text style={styles.deviceMac}>{item.mac}</Text> | ||
| 203 | </View> | ||
| 204 | {item.rssi != null && ( | ||
| 205 | <Text style={styles.deviceRssi}>{item.rssi} dBm</Text> | ||
| 206 | )} | ||
| 207 | </View> | ||
| 208 | ); | ||
| 209 | |||
| 210 | return ( | 376 | return ( |
| 211 | <SafeAreaProvider> | 377 | <SafeAreaView style={styles.container}> |
| 212 | <SafeAreaView style={styles.container}> | 378 | <Text style={styles.title}>iHealth Scanner</Text> |
| 213 | <Text style={styles.title}>iHealth Scanner</Text> | 379 | <Text style={styles.subtitle}> |
| 214 | <Text style={styles.subtitle}> | 380 | {scanning |
| 215 | {scanning | 381 | ? `Scanning... (${devices.length} found)` |
| 216 | ? `Scanning... (${devices.length} found)` | 382 | : `${devices.length} device(s) found`} |
| 217 | : `${devices.length} device(s) found`} | 383 | </Text> |
| 218 | </Text> | ||
| 219 | 384 | ||
| 220 | {error && <Text style={styles.error}>{error}</Text>} | 385 | {error && <Text style={styles.error}>{error}</Text>} |
| 221 | 386 | ||
| 222 | <TouchableOpacity | 387 | <TouchableOpacity |
| 223 | style={[styles.button, scanning && styles.buttonStop]} | 388 | style={[styles.button, scanning && styles.buttonStop]} |
| 224 | onPress={scanning ? stopScan : startScan}> | 389 | onPress={scanning ? stopScan : startScan}> |
| 225 | <Text style={styles.buttonText}> | 390 | <Text style={styles.buttonText}> |
| 226 | {scanning ? 'Stop Scan' : 'Start Scan'} | 391 | {scanning ? 'Stop Scan' : 'Start Scan'} |
| 392 | </Text> | ||
| 393 | </TouchableOpacity> | ||
| 394 | |||
| 395 | <FlatList | ||
| 396 | data={devices} | ||
| 397 | keyExtractor={item => item.mac} | ||
| 398 | renderItem={({item}) => ( | ||
| 399 | <TouchableOpacity | ||
| 400 | style={styles.deviceRow} | ||
| 401 | onPress={() => { | ||
| 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> | ||
| 410 | <View style={styles.deviceInfo}> | ||
| 411 | <Text style={styles.deviceType}>{item.type}</Text> | ||
| 412 | <Text style={styles.deviceMac}>{item.mac}</Text> | ||
| 413 | </View> | ||
| 414 | {item.rssi != null && ( | ||
| 415 | <Text style={styles.deviceRssi}>{item.rssi} dBm</Text> | ||
| 416 | )} | ||
| 417 | <Text style={styles.chevron}>{'>'}</Text> | ||
| 418 | </TouchableOpacity> | ||
| 419 | )} | ||
| 420 | style={styles.list} | ||
| 421 | contentContainerStyle={devices.length === 0 && styles.emptyList} | ||
| 422 | ListEmptyComponent={ | ||
| 423 | <Text style={styles.emptyText}> | ||
| 424 | {scanning | ||
| 425 | ? 'Looking for iHealth devices...' | ||
| 426 | : 'Tap "Start Scan" to find nearby iHealth devices'} | ||
| 227 | </Text> | 427 | </Text> |
| 228 | </TouchableOpacity> | 428 | } |
| 429 | /> | ||
| 430 | </SafeAreaView> | ||
| 431 | ); | ||
| 432 | } | ||
| 229 | 433 | ||
| 230 | <FlatList | 434 | // ── App Root ───────────────────────────────────────────────────────── |
| 231 | data={devices} | 435 | |
| 232 | keyExtractor={item => item.mac} | 436 | function App() { |
| 233 | renderItem={renderDevice} | 437 | const [selectedDevice, setSelectedDevice] = useState<Device | null>(null); |
| 234 | style={styles.list} | 438 | |
| 235 | contentContainerStyle={devices.length === 0 && styles.emptyList} | 439 | return ( |
| 236 | ListEmptyComponent={ | 440 | <SafeAreaProvider> |
| 237 | <Text style={styles.emptyText}> | 441 | {selectedDevice ? ( |
| 238 | {scanning | 442 | <DeviceScreen |
| 239 | ? 'Looking for iHealth devices...' | 443 | device={selectedDevice} |
| 240 | : 'Tap "Start Scan" to find nearby iHealth devices'} | 444 | onBack={() => setSelectedDevice(null)} |
| 241 | </Text> | ||
| 242 | } | ||
| 243 | /> | 445 | /> |
| 244 | </SafeAreaView> | 446 | ) : ( |
| 447 | <ScannerScreen onSelectDevice={setSelectedDevice} /> | ||
| 448 | )} | ||
| 245 | </SafeAreaProvider> | 449 | </SafeAreaProvider> |
| 246 | ); | 450 | ); |
| 247 | } | 451 | } |
| 248 | 452 | ||
| 453 | // ── Styles ─────────────────────────────────────────────────────────── | ||
| 454 | |||
| 249 | const styles = StyleSheet.create({ | 455 | const styles = StyleSheet.create({ |
| 250 | container: { | 456 | container: {flex: 1, backgroundColor: '#f5f5f5', paddingHorizontal: 16}, |
| 251 | flex: 1, | 457 | title: {fontSize: 28, fontWeight: '700', color: '#1a1a1a', marginTop: 16}, |
| 252 | backgroundColor: '#f5f5f5', | 458 | subtitle: {fontSize: 14, color: '#666', marginTop: 4, marginBottom: 16}, |
| 253 | paddingHorizontal: 16, | ||
| 254 | }, | ||
| 255 | title: { | ||
| 256 | fontSize: 28, | ||
| 257 | fontWeight: '700', | ||
| 258 | color: '#1a1a1a', | ||
| 259 | marginTop: 16, | ||
| 260 | }, | ||
| 261 | subtitle: { | ||
| 262 | fontSize: 14, | ||
| 263 | color: '#666', | ||
| 264 | marginTop: 4, | ||
| 265 | marginBottom: 16, | ||
| 266 | }, | ||
| 267 | error: { | 459 | error: { |
| 268 | color: '#d32f2f', | 460 | color: '#d32f2f', fontSize: 13, marginBottom: 8, |
| 269 | fontSize: 13, | 461 | backgroundColor: '#ffebee', padding: 8, borderRadius: 6, |
| 270 | marginBottom: 8, | ||
| 271 | backgroundColor: '#ffebee', | ||
| 272 | padding: 8, | ||
| 273 | borderRadius: 6, | ||
| 274 | }, | 462 | }, |
| 275 | button: { | 463 | button: { |
| 276 | backgroundColor: '#2196F3', | 464 | backgroundColor: '#2196F3', paddingVertical: 14, |
| 277 | paddingVertical: 14, | 465 | borderRadius: 10, alignItems: 'center', marginBottom: 16, |
| 278 | borderRadius: 10, | ||
| 279 | alignItems: 'center', | ||
| 280 | marginBottom: 16, | ||
| 281 | }, | ||
| 282 | buttonStop: { | ||
| 283 | backgroundColor: '#f44336', | ||
| 284 | }, | ||
| 285 | buttonText: { | ||
| 286 | color: '#fff', | ||
| 287 | fontSize: 16, | ||
| 288 | fontWeight: '600', | ||
| 289 | }, | ||
| 290 | list: { | ||
| 291 | flex: 1, | ||
| 292 | }, | ||
| 293 | emptyList: { | ||
| 294 | flex: 1, | ||
| 295 | justifyContent: 'center', | ||
| 296 | alignItems: 'center', | ||
| 297 | }, | ||
| 298 | emptyText: { | ||
| 299 | color: '#999', | ||
| 300 | fontSize: 15, | ||
| 301 | textAlign: 'center', | ||
| 302 | }, | 466 | }, |
| 467 | buttonStop: {backgroundColor: '#f44336'}, | ||
| 468 | buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, | ||
| 469 | list: {flex: 1}, | ||
| 470 | emptyList: {flex: 1, justifyContent: 'center', alignItems: 'center'}, | ||
| 471 | emptyText: {color: '#999', fontSize: 15, textAlign: 'center'}, | ||
| 303 | deviceRow: { | 472 | deviceRow: { |
| 304 | flexDirection: 'row', | 473 | flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', |
| 305 | alignItems: 'center', | 474 | padding: 14, borderRadius: 10, marginBottom: 8, |
| 306 | backgroundColor: '#fff', | 475 | shadowColor: '#000', shadowOffset: {width: 0, height: 1}, |
| 307 | padding: 14, | 476 | shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, |
| 308 | borderRadius: 10, | ||
| 309 | marginBottom: 8, | ||
| 310 | shadowColor: '#000', | ||
| 311 | shadowOffset: {width: 0, height: 1}, | ||
| 312 | shadowOpacity: 0.05, | ||
| 313 | shadowRadius: 2, | ||
| 314 | elevation: 1, | ||
| 315 | }, | 477 | }, |
| 316 | deviceIcon: { | 478 | deviceIcon: { |
| 317 | width: 44, | 479 | width: 44, height: 44, borderRadius: 22, backgroundColor: '#e3f2fd', |
| 318 | height: 44, | 480 | justifyContent: 'center', alignItems: 'center', marginRight: 12, |
| 319 | borderRadius: 22, | ||
| 320 | backgroundColor: '#e3f2fd', | ||
| 321 | justifyContent: 'center', | ||
| 322 | alignItems: 'center', | ||
| 323 | marginRight: 12, | ||
| 324 | }, | ||
| 325 | deviceIconText: { | ||
| 326 | fontSize: 13, | ||
| 327 | fontWeight: '700', | ||
| 328 | color: '#1565c0', | ||
| 329 | }, | 481 | }, |
| 330 | deviceInfo: { | 482 | deviceIconText: {fontSize: 12, fontWeight: '700', color: '#1565c0'}, |
| 331 | flex: 1, | 483 | deviceInfo: {flex: 1}, |
| 484 | deviceType: {fontSize: 16, fontWeight: '600', color: '#1a1a1a'}, | ||
| 485 | deviceMac: { | ||
| 486 | fontSize: 12, color: '#888', marginTop: 2, | ||
| 487 | fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', | ||
| 332 | }, | 488 | }, |
| 333 | deviceType: { | 489 | deviceRssi: {fontSize: 12, color: '#999', marginLeft: 8}, |
| 334 | fontSize: 16, | 490 | chevron: {fontSize: 18, color: '#ccc', marginLeft: 8}, |
| 491 | // Device screen | ||
| 492 | backButton: {marginTop: 12, marginBottom: 4}, | ||
| 493 | backText: {color: '#2196F3', fontSize: 16}, | ||
| 494 | statusBadge: { | ||
| 495 | fontSize: 13, color: '#f44336', marginTop: 8, marginBottom: 16, | ||
| 335 | fontWeight: '600', | 496 | fontWeight: '600', |
| 336 | color: '#1a1a1a', | ||
| 337 | }, | 497 | }, |
| 338 | deviceMac: { | 498 | statusConnected: {color: '#4caf50'}, |
| 339 | fontSize: 12, | 499 | sectionTitle: { |
| 340 | color: '#888', | 500 | fontSize: 16, fontWeight: '600', color: '#333', |
| 341 | fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', | 501 | marginTop: 8, marginBottom: 8, |
| 342 | marginTop: 2, | 502 | }, |
| 503 | actionsRow: {flexGrow: 0, marginBottom: 12}, | ||
| 504 | actionButton: { | ||
| 505 | backgroundColor: '#e3f2fd', paddingVertical: 10, paddingHorizontal: 16, | ||
| 506 | borderRadius: 8, marginRight: 8, | ||
| 343 | }, | 507 | }, |
| 344 | deviceRssi: { | 508 | actionDisabled: {backgroundColor: '#eee'}, |
| 345 | fontSize: 12, | 509 | actionText: {color: '#1565c0', fontSize: 13, fontWeight: '600'}, |
| 346 | color: '#999', | 510 | actionTextDisabled: {color: '#bbb'}, |
| 347 | marginLeft: 8, | 511 | logList: {flex: 1, marginTop: 4}, |
| 512 | logLine: { | ||
| 513 | fontSize: 11, color: '#555', paddingVertical: 3, | ||
| 514 | fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', | ||
| 348 | }, | 515 | }, |
| 349 | }); | 516 | }); |
| 350 | 517 | ||
