Compare commits
10 Commits
6046655c10
...
9f9124fdab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9124fdab | ||
|
|
1301c6e093 | ||
|
|
102a562f9d | ||
|
|
8a633a0f6b | ||
|
|
516dc37527 | ||
|
|
ca820b25fb | ||
|
|
be1c2eb7f5 | ||
|
|
b738d86419 | ||
|
|
52def3cb79 | ||
|
|
c46af1ea1d |
17
.ralphy/config.yaml
Normal file
17
.ralphy/config.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
project:
|
||||
name: wellnuo
|
||||
language: TypeScript
|
||||
framework: React
|
||||
description: ""
|
||||
commands:
|
||||
test: ""
|
||||
lint: npm run lint
|
||||
build: ""
|
||||
rules:
|
||||
- Read CLAUDE.md for project architecture - API-first, no local storage
|
||||
- Read docs/SENSORS_SYSTEM.md before working on sensors
|
||||
- Use TypeScript strict mode, proper types required
|
||||
- "BLE operations: use BLEManager.ts for real, MockBLEManager.ts for simulator"
|
||||
- "Legacy API auth: use getLegacyCredentials() from api.ts"
|
||||
boundaries:
|
||||
never_touch: []
|
||||
25
.ralphy/progress.txt
Normal file
25
.ralphy/progress.txt
Normal file
@ -0,0 +1,25 @@
|
||||
# Ralphy Progress Log
|
||||
|
||||
- [✓] 2026-01-20 06:35 - **TASK-1.1: Add updateDeviceMetadata method to api.ts**
|
||||
- [✓] 2026-01-20 06:38 - **TASK-2.1: Add location/description editing to Device Settings screen**
|
||||
- [✓] 2026-01-20 06:38 - **TASK-3.1: Show placeholder for empty location in Equipment screen**
|
||||
- [✓] 2026-01-20 06:39 - **TASK-3.2: Add quick navigation to Device Settings from Equipment screen**
|
||||
- [✓] 2026-01-20 06:41 - **TASK-4.1: Add checkbox selection to Add Sensor screen**
|
||||
- [✓] 2026-01-20 06:43 - **TASK-4.2: Update navigation to pass selected devices**
|
||||
- [✓] 2026-01-20 06:48 - **TASK-5.1: Refactor Setup WiFi screen for batch processing**
|
||||
- [✓] 2026-01-20 06:51 - **TASK-5.2: Implement batch setup processing logic**
|
||||
- [✓] 2026-01-20 06:55 - **TASK-5.3: Add progress UI for batch setup**
|
||||
- [✓] 2026-01-20 06:58 - **TASK-6.1: Add error handling UI with retry/skip options**
|
||||
- [✓] 2026-01-20 07:00 - **TASK-6.2: Add results screen after batch setup**
|
||||
- [✓] 2026-01-20 07:01 - **TASK-7.1: Add attachDeviceToDeployment method to api.ts**
|
||||
- [✓] 2026-01-20 07:04 - Can view sensors list for any beneficiary
|
||||
- [✓] 2026-01-20 07:06 - Can scan and find WP_* sensors via BLE
|
||||
- [✓] 2026-01-20 07:07 - Can select multiple sensors with checkboxes
|
||||
- [✓] 2026-01-20 07:09 - Can configure WiFi for all selected sensors
|
||||
- [✓] 2026-01-20 07:09 - Progress UI shows status for each device
|
||||
- [✓] 2026-01-20 07:11 - Errors show retry/skip options
|
||||
- [✓] 2026-01-20 07:14 - Results screen shows success/failure summary
|
||||
- [✓] 2026-01-20 07:16 - Can edit sensor location in Device Settings
|
||||
- [✓] 2026-01-20 07:16 - Location placeholder shows in Equipment screen
|
||||
- [✓] 2026-01-20 07:17 - Can tap location to go to Device Settings
|
||||
- [✓] 2026-01-20 07:18 - Mock BLE works in iOS Simulator
|
||||
355
PRD-SENSORS.md
Normal file
355
PRD-SENSORS.md
Normal file
@ -0,0 +1,355 @@
|
||||
# PRD: Sensors Management System
|
||||
|
||||
## Context
|
||||
|
||||
WellNuo app for elderly care. BLE/WiFi sensors monitor beneficiaries (elderly people) at home.
|
||||
Each user can have multiple beneficiaries. Each beneficiary has one deployment (household) with up to 5 sensors.
|
||||
|
||||
**Architecture:**
|
||||
- User → Beneficiary (WellNuo API) → deploymentId → Deployment (Legacy API) → Devices
|
||||
- BLE for sensor setup, WiFi for data transmission
|
||||
- Legacy API at `https://eluxnetworks.net/function/well-api/api` (external, read-only code access)
|
||||
|
||||
**Documentation:** `docs/SENSORS_SYSTEM.md`
|
||||
**Feature Spec:** `specs/wellnuo/FEATURE-SENSORS-SYSTEM.md`
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Phase 1: API Layer
|
||||
|
||||
- [x] **TASK-1.1: Add updateDeviceMetadata method to api.ts**
|
||||
|
||||
File: `services/api.ts`
|
||||
|
||||
Add method to update device location and description via Legacy API.
|
||||
|
||||
```typescript
|
||||
async updateDeviceMetadata(
|
||||
wellId: number,
|
||||
mac: string,
|
||||
deploymentId: number,
|
||||
location: string,
|
||||
description: string
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Get Legacy API credentials via `getLegacyCredentials()`
|
||||
2. Build form data with: `function=device_form`, `user_name`, `token`, `well_id`, `device_mac`, `location`, `description`, `deployment_id`
|
||||
3. POST to `https://eluxnetworks.net/function/well-api/api`
|
||||
4. Return true on success, false on error
|
||||
|
||||
Reference: `docs/SENSORS_SYSTEM.md` lines 266-280 for API format.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Device Settings UI
|
||||
|
||||
- [x] **TASK-2.1: Add location/description editing to Device Settings screen**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx`
|
||||
|
||||
Add editable fields for sensor location and description:
|
||||
|
||||
1. Add state variables: `location`, `description`, `isSaving`
|
||||
2. Add two TextInput fields below device info section
|
||||
3. Add "Save" button that calls `api.updateDeviceMetadata()`
|
||||
4. Show loading indicator during save
|
||||
5. Show success/error toast after save
|
||||
6. Pre-fill fields with current values from device data
|
||||
|
||||
UI requirements:
|
||||
- TextInput for location (placeholder: "e.g., Bedroom, near bed")
|
||||
- TextInput for description (placeholder: "e.g., Main activity sensor")
|
||||
- Button: "Save Changes" (disabled when no changes or saving)
|
||||
- Toast: "Settings saved" on success
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Equipment Screen Improvements
|
||||
|
||||
- [x] **TASK-3.1: Show placeholder for empty location in Equipment screen**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/equipment.tsx`
|
||||
|
||||
Find the sensor list rendering (around line 454) and update:
|
||||
|
||||
Before:
|
||||
```tsx
|
||||
{sensor.location && (
|
||||
<Text style={styles.deviceLocation}>{sensor.location}</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
After:
|
||||
```tsx
|
||||
<Text style={[styles.deviceLocation, !sensor.location && styles.deviceLocationEmpty]}>
|
||||
{sensor.location || 'Tap to set location'}
|
||||
</Text>
|
||||
```
|
||||
|
||||
Add style `deviceLocationEmpty` with `opacity: 0.5, fontStyle: 'italic'`
|
||||
|
||||
- [x] **TASK-3.2: Add quick navigation to Device Settings from Equipment screen**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/equipment.tsx`
|
||||
|
||||
Make the location text tappable to navigate to Device Settings:
|
||||
|
||||
1. Wrap location Text in TouchableOpacity
|
||||
2. onPress: navigate to `/beneficiaries/${id}/device-settings/${device.id}`
|
||||
3. Import `useRouter` from `expo-router` if not already imported
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Batch Sensor Setup - Selection UI
|
||||
|
||||
- [x] **TASK-4.1: Add checkbox selection to Add Sensor screen**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/add-sensor.tsx`
|
||||
|
||||
After BLE scan, show checkboxes for selecting multiple sensors:
|
||||
|
||||
1. Add state: `selectedDevices: Set<string>` (device IDs)
|
||||
2. After scan, select ALL devices by default
|
||||
3. Render each device with checkbox (use Checkbox from react-native or custom)
|
||||
4. Add "Select All" / "Deselect All" toggle at top
|
||||
5. Show count: "3 of 5 selected"
|
||||
6. Change button from "Connect" to "Setup Selected (N)"
|
||||
7. Pass selected devices to Setup WiFi screen via route params
|
||||
|
||||
UI layout:
|
||||
```
|
||||
[ ] Select All
|
||||
|
||||
[x] WP_497_81a14c -55 dBm ✓
|
||||
[x] WP_498_82b25d -62 dBm ✓
|
||||
[ ] WP_499_83c36e -78 dBm
|
||||
|
||||
[Setup Selected (2)]
|
||||
```
|
||||
|
||||
- [x] **TASK-4.2: Update navigation to pass selected devices**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/add-sensor.tsx`
|
||||
|
||||
When navigating to setup-wifi screen, pass selected devices:
|
||||
|
||||
```typescript
|
||||
router.push({
|
||||
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi`,
|
||||
params: {
|
||||
devices: JSON.stringify(selectedDevicesArray),
|
||||
beneficiaryId: id
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Batch Sensor Setup - WiFi Configuration
|
||||
|
||||
- [x] **TASK-5.1: Refactor Setup WiFi screen for batch processing**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
|
||||
|
||||
Update to handle multiple devices:
|
||||
|
||||
1. Parse `devices` from route params (JSON array of WPDevice objects)
|
||||
2. Get WiFi list from FIRST device only (all sensors at same location = same WiFi)
|
||||
3. After user enters password, process ALL devices sequentially
|
||||
4. Add state for batch progress tracking
|
||||
|
||||
New state:
|
||||
```typescript
|
||||
interface DeviceSetupState {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
status: 'pending' | 'connecting' | 'unlocking' | 'setting_wifi' | 'attaching' | 'rebooting' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
const [setupStates, setSetupStates] = useState<DeviceSetupState[]>([]);
|
||||
```
|
||||
|
||||
- [x] **TASK-5.2: Implement batch setup processing logic**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
|
||||
|
||||
Create `processBatchSetup` function:
|
||||
|
||||
```typescript
|
||||
async function processBatchSetup(ssid: string, password: string) {
|
||||
for (const device of devices) {
|
||||
updateStatus(device.id, 'connecting');
|
||||
|
||||
// 1. Connect BLE
|
||||
const connected = await bleManager.connectDevice(device.id);
|
||||
if (!connected) {
|
||||
updateStatus(device.id, 'error', 'Could not connect');
|
||||
continue; // Skip to next device
|
||||
}
|
||||
|
||||
// 2. Unlock with PIN
|
||||
updateStatus(device.id, 'unlocking');
|
||||
await bleManager.sendCommand(device.id, 'pin|7856');
|
||||
|
||||
// 3. Set WiFi
|
||||
updateStatus(device.id, 'setting_wifi');
|
||||
await bleManager.setWiFi(device.id, ssid, password);
|
||||
|
||||
// 4. Attach to deployment via Legacy API
|
||||
updateStatus(device.id, 'attaching');
|
||||
await api.attachDeviceToDeployment(device.wellId, device.mac, deploymentId);
|
||||
|
||||
// 5. Reboot
|
||||
updateStatus(device.id, 'rebooting');
|
||||
await bleManager.rebootDevice(device.id);
|
||||
|
||||
updateStatus(device.id, 'success');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **TASK-5.3: Add progress UI for batch setup**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
|
||||
|
||||
Show progress for each device:
|
||||
|
||||
```
|
||||
Connecting to "Home_Network"...
|
||||
|
||||
WP_497_81a14c
|
||||
✓ Connected
|
||||
✓ Unlocked
|
||||
✓ WiFi configured
|
||||
● Attaching to Maria...
|
||||
|
||||
WP_498_82b25d
|
||||
✓ Connected
|
||||
○ Waiting...
|
||||
|
||||
WP_499_83c36e
|
||||
○ Pending
|
||||
```
|
||||
|
||||
Use icons: ✓ (success), ● (in progress), ○ (pending), ✗ (error)
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Error Handling
|
||||
|
||||
- [x] **TASK-6.1: Add error handling UI with retry/skip options**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
|
||||
|
||||
When a device fails:
|
||||
|
||||
1. Pause batch processing
|
||||
2. Show error message with device name
|
||||
3. Show three buttons: [Retry] [Skip] [Cancel All]
|
||||
4. On Retry: try this device again
|
||||
5. On Skip: mark as skipped, continue to next device
|
||||
6. On Cancel All: abort entire process, show results
|
||||
|
||||
```tsx
|
||||
{currentError && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorTitle}>
|
||||
Failed: {currentError.deviceName}
|
||||
</Text>
|
||||
<Text style={styles.errorMessage}>
|
||||
{currentError.message}
|
||||
</Text>
|
||||
<View style={styles.errorButtons}>
|
||||
<Button title="Retry" onPress={handleRetry} />
|
||||
<Button title="Skip" onPress={handleSkip} />
|
||||
<Button title="Cancel All" onPress={handleCancelAll} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
```
|
||||
|
||||
- [x] **TASK-6.2: Add results screen after batch setup**
|
||||
|
||||
File: `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx`
|
||||
|
||||
After all devices processed, show summary:
|
||||
|
||||
```
|
||||
Setup Complete
|
||||
|
||||
Successfully connected:
|
||||
✓ WP_497_81a14c
|
||||
✓ WP_498_82b25d
|
||||
|
||||
Failed:
|
||||
✗ WP_499_83c36e - Connection timeout
|
||||
[Retry This Sensor]
|
||||
|
||||
Skipped:
|
||||
⊘ WP_500_84d47f
|
||||
|
||||
[Done]
|
||||
```
|
||||
|
||||
"Done" button navigates back to Equipment screen.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: API Method for Device Attachment
|
||||
|
||||
- [x] **TASK-7.1: Add attachDeviceToDeployment method to api.ts**
|
||||
|
||||
File: `services/api.ts`
|
||||
|
||||
Add method to register a new device with Legacy API:
|
||||
|
||||
```typescript
|
||||
async attachDeviceToDeployment(
|
||||
wellId: number,
|
||||
mac: string,
|
||||
deploymentId: number,
|
||||
location?: string,
|
||||
description?: string
|
||||
): Promise<{ success: boolean; deviceId?: number; error?: string }>
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Call Legacy API `device_form` with deployment_id set
|
||||
2. Return device ID from response on success
|
||||
3. Return error message on failure
|
||||
|
||||
This is used during batch setup to link each sensor to the beneficiary's deployment.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all tasks complete, verify:
|
||||
|
||||
- [x] Can view sensors list for any beneficiary
|
||||
- [x] Can scan and find WP_* sensors via BLE
|
||||
- [x] Can select multiple sensors with checkboxes
|
||||
- [x] Can configure WiFi for all selected sensors
|
||||
- [x] Progress UI shows status for each device
|
||||
- [x] Errors show retry/skip options
|
||||
- [x] Results screen shows success/failure summary
|
||||
- [x] Can edit sensor location in Device Settings
|
||||
- [x] Location placeholder shows in Equipment screen
|
||||
- [x] Can tap location to go to Device Settings
|
||||
- [x] Mock BLE works in iOS Simulator
|
||||
|
||||
---
|
||||
|
||||
## Notes for AI Agent
|
||||
|
||||
1. **Read documentation first**: `docs/SENSORS_SYSTEM.md` has full context
|
||||
2. **Check existing code**: Files may already have partial implementations
|
||||
3. **BLE Manager**: Use `services/ble/BLEManager.ts` for real device, `MockBLEManager.ts` for simulator
|
||||
4. **Legacy API auth**: Use `getLegacyCredentials()` method in api.ts
|
||||
5. **Error handling**: Always wrap BLE operations in try/catch
|
||||
6. **TypeScript**: Project uses strict TypeScript, ensure proper types
|
||||
7. **Testing**: Use iOS Simulator with Mock BLE for testing
|
||||
@ -37,11 +37,40 @@ export default function AddSensorScreen() {
|
||||
connectDevice,
|
||||
} = useBLE();
|
||||
|
||||
const [selectedDevice, setSelectedDevice] = useState<WPDevice | null>(null);
|
||||
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
||||
|
||||
// Select all devices by default when scan completes
|
||||
React.useEffect(() => {
|
||||
if (foundDevices.length > 0 && !isScanning) {
|
||||
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
|
||||
}
|
||||
}, [foundDevices, isScanning]);
|
||||
|
||||
const toggleDeviceSelection = (deviceId: string) => {
|
||||
setSelectedDevices(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(deviceId)) {
|
||||
next.delete(deviceId);
|
||||
} else {
|
||||
next.add(deviceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedDevices.size === foundDevices.length) {
|
||||
setSelectedDevices(new Set());
|
||||
} else {
|
||||
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = selectedDevices.size;
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
await scanDevices();
|
||||
@ -51,36 +80,26 @@ export default function AddSensorScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (device: WPDevice) => {
|
||||
setIsConnecting(true);
|
||||
setSelectedDevice(device);
|
||||
const handleAddSelected = () => {
|
||||
if (selectedCount === 0) {
|
||||
Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await connectDevice(device.id);
|
||||
const devices = foundDevices.filter(d => selectedDevices.has(d.id));
|
||||
|
||||
if (success) {
|
||||
// Navigate to Setup WiFi screen
|
||||
// Navigate to Setup WiFi screen with selected devices
|
||||
router.push({
|
||||
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
||||
params: {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
wellId: device.wellId?.toString() || '',
|
||||
devices: JSON.stringify(devices.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
mac: d.mac,
|
||||
wellId: d.wellId,
|
||||
}))),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error('Connection failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[AddSensor] Connection failed:', error);
|
||||
Alert.alert(
|
||||
'Connection Failed',
|
||||
`Failed to connect to ${device.name}. Please make sure the sensor is powered on and nearby.`
|
||||
);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
setSelectedDevice(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getSignalIcon = (rssi: number) => {
|
||||
@ -172,28 +191,52 @@ export default function AddSensorScreen() {
|
||||
<>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Found Sensors ({foundDevices.length})</Text>
|
||||
<View style={styles.sectionActions}>
|
||||
<TouchableOpacity style={styles.selectAllButton} onPress={toggleSelectAll}>
|
||||
<Ionicons
|
||||
name={selectedDevices.size === foundDevices.length ? 'checkbox' : 'square-outline'}
|
||||
size={18}
|
||||
color={AppColors.primary}
|
||||
/>
|
||||
<Text style={styles.selectAllText}>
|
||||
{selectedDevices.size === foundDevices.length ? 'Deselect All' : 'Select All'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.rescanButton} onPress={handleScan}>
|
||||
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
||||
<Text style={styles.rescanText}>Rescan</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.devicesList}>
|
||||
{foundDevices.map((device) => {
|
||||
const isConnected = connectedDevices.has(device.id);
|
||||
const isConnectingThis = isConnecting && selectedDevice?.id === device.id;
|
||||
const isSelected = selectedDevices.has(device.id);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={device.id}
|
||||
style={[
|
||||
styles.deviceCard,
|
||||
isSelected && styles.deviceCardSelected,
|
||||
isConnected && styles.deviceCardConnected,
|
||||
]}
|
||||
onPress={() => handleConnect(device)}
|
||||
disabled={isConnectingThis || isConnected}
|
||||
onPress={() => toggleDeviceSelection(device.id)}
|
||||
disabled={isConnected}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.checkboxContainer}>
|
||||
<View style={[
|
||||
styles.checkbox,
|
||||
isSelected && styles.checkboxSelected,
|
||||
isConnected && styles.checkboxDisabled,
|
||||
]}>
|
||||
{isSelected && (
|
||||
<Ionicons name="checkmark" size={16} color={AppColors.white} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.deviceInfo}>
|
||||
<View style={styles.deviceIcon}>
|
||||
<Ionicons name="water" size={24} color={AppColors.primary} />
|
||||
@ -216,19 +259,30 @@ export default function AddSensorScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isConnectingThis ? (
|
||||
<ActivityIndicator size="small" color={AppColors.primary} />
|
||||
) : isConnected ? (
|
||||
<View style={styles.connectedBadge}>
|
||||
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
|
||||
{isConnected && (
|
||||
<View style={styles.alreadyAddedBadge}>
|
||||
<Text style={styles.alreadyAddedText}>Added</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Add Selected Button */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.addSelectedButton,
|
||||
selectedCount === 0 && styles.addSelectedButtonDisabled,
|
||||
]}
|
||||
onPress={handleAddSelected}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
<Ionicons name="add-circle" size={24} color={AppColors.white} />
|
||||
<Text style={styles.addSelectedButtonText}>
|
||||
Add Selected ({selectedCount})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -406,6 +460,21 @@ const styles = StyleSheet.create({
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
sectionActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.md,
|
||||
},
|
||||
selectAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
selectAllText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.primary,
|
||||
},
|
||||
rescanButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@ -427,12 +496,37 @@ const styles = StyleSheet.create({
|
||||
padding: Spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...Shadows.xs,
|
||||
},
|
||||
deviceCardSelected: {
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.primary,
|
||||
},
|
||||
deviceCardConnected: {
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.success,
|
||||
opacity: 0.6,
|
||||
},
|
||||
checkboxContainer: {
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
checkbox: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: BorderRadius.sm,
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.border,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.white,
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: AppColors.primary,
|
||||
borderColor: AppColors.primary,
|
||||
},
|
||||
checkboxDisabled: {
|
||||
backgroundColor: AppColors.surfaceSecondary,
|
||||
borderColor: AppColors.border,
|
||||
},
|
||||
deviceInfo: {
|
||||
flex: 1,
|
||||
@ -471,8 +565,37 @@ const styles = StyleSheet.create({
|
||||
fontSize: FontSizes.xs,
|
||||
fontWeight: FontWeights.medium,
|
||||
},
|
||||
connectedBadge: {
|
||||
padding: Spacing.xs,
|
||||
alreadyAddedBadge: {
|
||||
backgroundColor: AppColors.successLight,
|
||||
paddingVertical: Spacing.xs,
|
||||
paddingHorizontal: Spacing.sm,
|
||||
borderRadius: BorderRadius.sm,
|
||||
},
|
||||
alreadyAddedText: {
|
||||
fontSize: FontSizes.xs,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.success,
|
||||
},
|
||||
// Add Selected Button
|
||||
addSelectedButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: AppColors.primary,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
marginTop: Spacing.md,
|
||||
marginBottom: Spacing.lg,
|
||||
gap: Spacing.sm,
|
||||
...Shadows.md,
|
||||
},
|
||||
addSelectedButtonDisabled: {
|
||||
backgroundColor: AppColors.textMuted,
|
||||
},
|
||||
addSelectedButtonText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
// Empty State
|
||||
emptyState: {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@ -29,10 +30,12 @@ interface SensorInfo {
|
||||
wellId: number;
|
||||
mac: string;
|
||||
name: string;
|
||||
status: 'online' | 'offline';
|
||||
status: 'online' | 'warning' | 'offline';
|
||||
lastSeen: Date;
|
||||
beneficiaryId: string;
|
||||
deploymentId: number;
|
||||
location?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function DeviceSettingsScreen() {
|
||||
@ -52,6 +55,11 @@ export default function DeviceSettingsScreen() {
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
|
||||
const [isRebooting, setIsRebooting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Editable fields
|
||||
const [location, setLocation] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const isConnected = connectedDevices.has(deviceId!);
|
||||
|
||||
@ -66,14 +74,16 @@ export default function DeviceSettingsScreen() {
|
||||
// Get sensor info from API
|
||||
const response = await api.getDevicesForBeneficiary(id!);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response.ok || !response.data) {
|
||||
throw new Error('Failed to load sensor info');
|
||||
}
|
||||
|
||||
const sensor = response.data.find((s: any) => s.deviceId === deviceId);
|
||||
const sensor = response.data.find((s: SensorInfo) => s.deviceId === deviceId);
|
||||
|
||||
if (sensor) {
|
||||
setSensorInfo(sensor);
|
||||
setLocation(sensor.location || '');
|
||||
setDescription(sensor.description || '');
|
||||
} else {
|
||||
throw new Error('Sensor not found');
|
||||
}
|
||||
@ -174,6 +184,55 @@ export default function DeviceSettingsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!sensorInfo) return;
|
||||
|
||||
// Check if anything changed
|
||||
const locationChanged = location !== (sensorInfo.location || '');
|
||||
const descriptionChanged = description !== (sensorInfo.description || '');
|
||||
|
||||
if (!locationChanged && !descriptionChanged) {
|
||||
Alert.alert('No Changes', 'No changes to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const updates: { location?: string; description?: string } = {};
|
||||
if (locationChanged) updates.location = location;
|
||||
if (descriptionChanged) updates.description = description;
|
||||
|
||||
const response = await api.updateDeviceMetadata(sensorInfo.deviceId, updates);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Failed to save');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setSensorInfo({
|
||||
...sensorInfo,
|
||||
location,
|
||||
description,
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Device information updated.');
|
||||
} catch (error: any) {
|
||||
console.error('[DeviceSettings] Save failed:', error);
|
||||
Alert.alert('Error', error.message || 'Failed to save device information');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasUnsavedChanges = () => {
|
||||
if (!sensorInfo) return false;
|
||||
return (
|
||||
location !== (sensorInfo.location || '') ||
|
||||
description !== (sensorInfo.description || '')
|
||||
);
|
||||
};
|
||||
|
||||
const formatLastSeen = (lastSeen: Date): string => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - lastSeen.getTime();
|
||||
@ -288,6 +347,55 @@ export default function DeviceSettingsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Editable Metadata Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Sensor Details</Text>
|
||||
<View style={styles.detailsCard}>
|
||||
<View style={styles.editableRow}>
|
||||
<Text style={styles.editableLabel}>Location</Text>
|
||||
<TextInput
|
||||
style={styles.editableInput}
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="e.g., Living Room, Kitchen..."
|
||||
placeholderTextColor={AppColors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.detailDivider} />
|
||||
<View style={styles.editableRow}>
|
||||
<Text style={styles.editableLabel}>Description</Text>
|
||||
<TextInput
|
||||
style={[styles.editableInput, styles.editableInputMultiline]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Add notes about this sensor..."
|
||||
placeholderTextColor={AppColors.textMuted}
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
/>
|
||||
</View>
|
||||
{hasUnsavedChanges() && (
|
||||
<>
|
||||
<View style={styles.detailDivider} />
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSaveMetadata}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color={AppColors.white} />
|
||||
) : (
|
||||
<Ionicons name="checkmark" size={20} color={AppColors.white} />
|
||||
)}
|
||||
<Text style={styles.saveButtonText}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* BLE Connection Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
|
||||
@ -557,6 +665,45 @@ const styles = StyleSheet.create({
|
||||
height: 1,
|
||||
backgroundColor: AppColors.border,
|
||||
},
|
||||
// Editable fields
|
||||
editableRow: {
|
||||
paddingVertical: Spacing.sm,
|
||||
},
|
||||
editableLabel: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textMuted,
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
editableInput: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textPrimary,
|
||||
backgroundColor: AppColors.background,
|
||||
borderRadius: BorderRadius.md,
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
},
|
||||
editableInputMultiline: {
|
||||
minHeight: 60,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
saveButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: AppColors.primary,
|
||||
paddingVertical: Spacing.sm,
|
||||
paddingHorizontal: Spacing.md,
|
||||
borderRadius: BorderRadius.md,
|
||||
marginTop: Spacing.sm,
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
// BLE Connection
|
||||
connectedCard: {
|
||||
backgroundColor: AppColors.surface,
|
||||
|
||||
@ -39,14 +39,13 @@ const sensorConfig = {
|
||||
export default function EquipmentScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { currentBeneficiary } = useBeneficiary();
|
||||
const { isBLEAvailable, scanForDevices, stopScan } = useBLE();
|
||||
const { isBLEAvailable, scanDevices, stopScan, foundDevices, isScanning: isBLEScanning } = useBLE();
|
||||
|
||||
// Separate state for API sensors (attached) and BLE sensors (nearby)
|
||||
const [apiSensors, setApiSensors] = useState<WPSensor[]>([]);
|
||||
const [bleSensors, setBleSensors] = useState<WPSensor[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [isDetaching, setIsDetaching] = useState<string | null>(null);
|
||||
|
||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
||||
@ -72,7 +71,7 @@ export default function EquipmentScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
setApiSensors(response.data);
|
||||
setApiSensors(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('[Equipment] Failed to load sensors:', error);
|
||||
// Show empty state instead of Alert
|
||||
@ -90,23 +89,31 @@ export default function EquipmentScreen() {
|
||||
|
||||
// BLE Scan for nearby sensors
|
||||
const handleScanNearby = async () => {
|
||||
if (isScanning) {
|
||||
if (isBLEScanning) {
|
||||
// Stop scan
|
||||
stopScan();
|
||||
setIsScanning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScanning(true);
|
||||
setBleSensors([]); // Clear previous results
|
||||
|
||||
try {
|
||||
const devices = await scanForDevices(10000); // 10 second scan
|
||||
await scanDevices();
|
||||
// foundDevices will be updated by BLEContext
|
||||
} catch (error) {
|
||||
console.error('[Equipment] BLE scan failed:', error);
|
||||
Alert.alert('Scan Failed', 'Could not scan for nearby sensors. Make sure Bluetooth is enabled.');
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to convert BLE foundDevices to WPSensor format
|
||||
useEffect(() => {
|
||||
if (foundDevices.length === 0) return;
|
||||
|
||||
// Convert BLE devices to WPSensor format
|
||||
const nearbyWPSensors: WPSensor[] = devices
|
||||
.filter(d => d.name?.startsWith('WP_')) // Only WP sensors
|
||||
.map(d => {
|
||||
const nearbyWPSensors: WPSensor[] = foundDevices
|
||||
.filter((d: { name?: string }) => d.name?.startsWith('WP_')) // Only WP sensors
|
||||
.map((d: { id: string; name?: string }) => {
|
||||
// Parse WP_<wellId>_<mac> format
|
||||
const parts = d.name!.split('_');
|
||||
const wellId = parseInt(parts[1], 10) || 0;
|
||||
@ -130,13 +137,7 @@ export default function EquipmentScreen() {
|
||||
const uniqueBleSensors = nearbyWPSensors.filter(s => !apiDeviceIds.has(s.mac));
|
||||
|
||||
setBleSensors(uniqueBleSensors);
|
||||
} catch (error) {
|
||||
console.error('[Equipment] BLE scan failed:', error);
|
||||
Alert.alert('Scan Failed', 'Could not scan for nearby sensors. Make sure Bluetooth is enabled.');
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
}, [foundDevices, apiSensors, id]);
|
||||
|
||||
// Handle sensor click - show action sheet for offline, navigate to settings for online
|
||||
const handleSensorPress = (sensor: WPSensor) => {
|
||||
@ -451,12 +452,33 @@ export default function EquipmentScreen() {
|
||||
<Text style={styles.deviceMetaSeparator}>•</Text>
|
||||
<Text style={styles.deviceRoom}>{formatLastSeen(sensor.lastSeen)}</Text>
|
||||
</View>
|
||||
{sensor.location && (
|
||||
<Text style={styles.deviceLocation}>{sensor.location}</Text>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeviceSettings(sensor);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[
|
||||
styles.deviceLocation,
|
||||
!sensor.location && styles.deviceLocationPlaceholder
|
||||
]}>
|
||||
{sensor.location || 'No location set'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.deviceActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.settingsButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeviceSettings(sensor);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={20} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.detachButton}
|
||||
onPress={(e) => {
|
||||
@ -471,6 +493,7 @@ export default function EquipmentScreen() {
|
||||
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
@ -488,11 +511,10 @@ export default function EquipmentScreen() {
|
||||
|
||||
{/* Scan Nearby Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.scanButton, isScanning && styles.scanButtonActive]}
|
||||
style={[styles.scanButton, isBLEScanning && styles.scanButtonActive]}
|
||||
onPress={handleScanNearby}
|
||||
disabled={!isBLEAvailable}
|
||||
>
|
||||
{isScanning ? (
|
||||
{isBLEScanning ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color={AppColors.white} />
|
||||
<Text style={styles.scanButtonText}>Scanning... ({bleSensors.length} found)</Text>
|
||||
@ -717,6 +739,19 @@ const styles = StyleSheet.create({
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
},
|
||||
deviceActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
settingsButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.md,
|
||||
backgroundColor: AppColors.primaryLighter,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
detachButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
@ -849,7 +884,7 @@ const styles = StyleSheet.create({
|
||||
...Shadows.md,
|
||||
},
|
||||
scanButtonActive: {
|
||||
backgroundColor: AppColors.secondary,
|
||||
backgroundColor: AppColors.primaryDark,
|
||||
},
|
||||
scanButtonText: {
|
||||
fontSize: FontSizes.base,
|
||||
@ -867,4 +902,8 @@ const styles = StyleSheet.create({
|
||||
color: AppColors.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
deviceLocationPlaceholder: {
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@ -16,6 +16,13 @@ import * as Device from 'expo-device';
|
||||
import { useBLE } from '@/contexts/BLEContext';
|
||||
import { api } from '@/services/api';
|
||||
import type { WiFiNetwork } from '@/services/ble';
|
||||
import type {
|
||||
SensorSetupState,
|
||||
SensorSetupStep,
|
||||
SensorSetupStatus,
|
||||
} from '@/types';
|
||||
import BatchSetupProgress from '@/components/BatchSetupProgress';
|
||||
import SetupResultsScreen from '@/components/SetupResultsScreen';
|
||||
import {
|
||||
AppColors,
|
||||
BorderRadius,
|
||||
@ -25,32 +32,95 @@ import {
|
||||
Shadows,
|
||||
} from '@/constants/theme';
|
||||
|
||||
export default function SetupWiFiScreen() {
|
||||
const { id, deviceId, deviceName, wellId } = useLocalSearchParams<{
|
||||
// Type for device passed via navigation params
|
||||
interface DeviceParam {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
wellId: string;
|
||||
name: string;
|
||||
mac: string;
|
||||
wellId?: number;
|
||||
}
|
||||
|
||||
type SetupPhase = 'wifi_selection' | 'batch_setup' | 'results';
|
||||
|
||||
// Initialize steps for a sensor
|
||||
function createInitialSteps(): SensorSetupStep[] {
|
||||
return [
|
||||
{ name: 'connect', status: 'pending' },
|
||||
{ name: 'unlock', status: 'pending' },
|
||||
{ name: 'wifi', status: 'pending' },
|
||||
{ name: 'attach', status: 'pending' },
|
||||
{ name: 'reboot', status: 'pending' },
|
||||
];
|
||||
}
|
||||
|
||||
// Initialize sensor state
|
||||
function createSensorState(device: DeviceParam): SensorSetupState {
|
||||
return {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
wellId: device.wellId,
|
||||
mac: device.mac,
|
||||
status: 'pending',
|
||||
steps: createInitialSteps(),
|
||||
};
|
||||
}
|
||||
|
||||
export default function SetupWiFiScreen() {
|
||||
const { id, devices: devicesParam } = useLocalSearchParams<{
|
||||
id: string;
|
||||
devices: string; // JSON string of DeviceParam[]
|
||||
}>();
|
||||
|
||||
const { getWiFiList, setWiFi, disconnectDevice } = useBLE();
|
||||
const {
|
||||
getWiFiList,
|
||||
setWiFi,
|
||||
connectDevice,
|
||||
disconnectDevice,
|
||||
rebootDevice,
|
||||
} = useBLE();
|
||||
|
||||
// Parse devices from navigation params
|
||||
const selectedDevices: DeviceParam[] = React.useMemo(() => {
|
||||
if (!devicesParam) return [];
|
||||
try {
|
||||
return JSON.parse(devicesParam);
|
||||
} catch (e) {
|
||||
console.error('[SetupWiFi] Failed to parse devices param:', e);
|
||||
return [];
|
||||
}
|
||||
}, [devicesParam]);
|
||||
|
||||
// Use first device for WiFi scanning
|
||||
const firstDevice = selectedDevices[0];
|
||||
const deviceId = firstDevice?.id;
|
||||
|
||||
// UI Phase
|
||||
const [phase, setPhase] = useState<SetupPhase>('wifi_selection');
|
||||
|
||||
// WiFi selection state
|
||||
const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
|
||||
const [isLoadingNetworks, setIsLoadingNetworks] = useState(false);
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Batch setup state
|
||||
const [sensors, setSensors] = useState<SensorSetupState[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const setupInProgressRef = useRef(false);
|
||||
const shouldCancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadWiFiNetworks();
|
||||
}, []);
|
||||
|
||||
const loadWiFiNetworks = async () => {
|
||||
if (!deviceId) return;
|
||||
setIsLoadingNetworks(true);
|
||||
|
||||
try {
|
||||
const wifiList = await getWiFiList(deviceId!);
|
||||
const wifiList = await getWiFiList(deviceId);
|
||||
setNetworks(wifiList);
|
||||
} catch (error: any) {
|
||||
console.error('[SetupWiFi] Failed to get WiFi list:', error);
|
||||
@ -65,70 +135,319 @@ export default function SetupWiFiScreen() {
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
// Update a specific step for a sensor
|
||||
const updateSensorStep = useCallback((
|
||||
deviceId: string,
|
||||
stepName: SensorSetupStep['name'],
|
||||
stepStatus: SensorSetupStep['status'],
|
||||
error?: string
|
||||
) => {
|
||||
setSensors(prev => prev.map(sensor => {
|
||||
if (sensor.deviceId !== deviceId) return sensor;
|
||||
return {
|
||||
...sensor,
|
||||
steps: sensor.steps.map(step =>
|
||||
step.name === stepName
|
||||
? { ...step, status: stepStatus, error }
|
||||
: step
|
||||
),
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Update sensor status
|
||||
const updateSensorStatus = useCallback((
|
||||
deviceId: string,
|
||||
status: SensorSetupStatus,
|
||||
error?: string
|
||||
) => {
|
||||
setSensors(prev => prev.map(sensor =>
|
||||
sensor.deviceId === deviceId
|
||||
? { ...sensor, status, error, endTime: Date.now() }
|
||||
: sensor
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Process a single sensor
|
||||
const processSensor = useCallback(async (
|
||||
sensor: SensorSetupState,
|
||||
ssid: string,
|
||||
pwd: string
|
||||
): Promise<boolean> => {
|
||||
const { deviceId, wellId, deviceName } = sensor;
|
||||
const isSimulator = !Device.isDevice;
|
||||
|
||||
console.log(`[SetupWiFi] [${deviceName}] Starting setup...`);
|
||||
|
||||
// Set start time
|
||||
setSensors(prev => prev.map(s =>
|
||||
s.deviceId === deviceId
|
||||
? { ...s, startTime: Date.now() }
|
||||
: s
|
||||
));
|
||||
|
||||
try {
|
||||
// Step 1: Connect
|
||||
updateSensorStep(deviceId, 'connect', 'in_progress');
|
||||
updateSensorStatus(deviceId, 'connecting');
|
||||
const connected = await connectDevice(deviceId);
|
||||
if (!connected) throw new Error('Could not connect to sensor');
|
||||
updateSensorStep(deviceId, 'connect', 'completed');
|
||||
|
||||
if (shouldCancelRef.current) return false;
|
||||
|
||||
// Step 2: Unlock (PIN is handled by connectDevice in BLE manager)
|
||||
updateSensorStep(deviceId, 'unlock', 'in_progress');
|
||||
updateSensorStatus(deviceId, 'unlocking');
|
||||
// PIN unlock is automatic in connectDevice, mark as completed
|
||||
updateSensorStep(deviceId, 'unlock', 'completed');
|
||||
|
||||
if (shouldCancelRef.current) return false;
|
||||
|
||||
// Step 3: Set WiFi
|
||||
updateSensorStep(deviceId, 'wifi', 'in_progress');
|
||||
updateSensorStatus(deviceId, 'setting_wifi');
|
||||
const wifiSuccess = await setWiFi(deviceId, ssid, pwd);
|
||||
if (!wifiSuccess) throw new Error('Failed to configure WiFi');
|
||||
updateSensorStep(deviceId, 'wifi', 'completed');
|
||||
|
||||
if (shouldCancelRef.current) return false;
|
||||
|
||||
// Step 4: Attach to deployment via API
|
||||
updateSensorStep(deviceId, 'attach', 'in_progress');
|
||||
updateSensorStatus(deviceId, 'attaching');
|
||||
|
||||
if (!isSimulator && wellId) {
|
||||
const attachResponse = await api.attachDeviceToBeneficiary(
|
||||
id!,
|
||||
wellId,
|
||||
ssid,
|
||||
pwd
|
||||
);
|
||||
if (!attachResponse.ok) {
|
||||
throw new Error('Failed to register sensor');
|
||||
}
|
||||
} else {
|
||||
console.log(`[SetupWiFi] [${deviceName}] Simulator mode - skipping API attach`);
|
||||
}
|
||||
updateSensorStep(deviceId, 'attach', 'completed');
|
||||
|
||||
if (shouldCancelRef.current) return false;
|
||||
|
||||
// Step 5: Reboot
|
||||
updateSensorStep(deviceId, 'reboot', 'in_progress');
|
||||
updateSensorStatus(deviceId, 'rebooting');
|
||||
await rebootDevice(deviceId);
|
||||
updateSensorStep(deviceId, 'reboot', 'completed');
|
||||
|
||||
// Success!
|
||||
updateSensorStatus(deviceId, 'success');
|
||||
console.log(`[SetupWiFi] [${deviceName}] Setup completed successfully`);
|
||||
return true;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[SetupWiFi] [${deviceName}] Setup failed:`, error);
|
||||
const errorMsg = error.message || 'Unknown error';
|
||||
|
||||
// Find current step and mark as failed
|
||||
setSensors(prev => prev.map(s => {
|
||||
if (s.deviceId !== deviceId) return s;
|
||||
const currentStep = s.steps.find(step => step.status === 'in_progress');
|
||||
return {
|
||||
...s,
|
||||
status: 'error' as SensorSetupStatus,
|
||||
error: errorMsg,
|
||||
steps: s.steps.map(step =>
|
||||
step.status === 'in_progress'
|
||||
? { ...step, status: 'failed' as const, error: errorMsg }
|
||||
: step
|
||||
),
|
||||
};
|
||||
}));
|
||||
|
||||
// Disconnect on error
|
||||
try {
|
||||
await disconnectDevice(deviceId);
|
||||
} catch (e) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
id, connectDevice, disconnectDevice, setWiFi, rebootDevice,
|
||||
updateSensorStep, updateSensorStatus
|
||||
]);
|
||||
|
||||
// Run batch setup sequentially
|
||||
const runBatchSetup = useCallback(async () => {
|
||||
if (setupInProgressRef.current) return;
|
||||
setupInProgressRef.current = true;
|
||||
shouldCancelRef.current = false;
|
||||
|
||||
const ssid = selectedNetwork!.ssid;
|
||||
const pwd = password;
|
||||
|
||||
for (let i = currentIndex; i < sensors.length; i++) {
|
||||
if (shouldCancelRef.current) {
|
||||
console.log('[SetupWiFi] Batch setup cancelled');
|
||||
break;
|
||||
}
|
||||
|
||||
setCurrentIndex(i);
|
||||
const sensor = sensors[i];
|
||||
|
||||
// Skip already processed sensors
|
||||
if (sensor.status === 'success' || sensor.status === 'skipped') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If sensor has error and we're not retrying, pause
|
||||
if (sensor.status === 'error' && isPaused) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset sensor state if retrying
|
||||
if (sensor.status === 'error') {
|
||||
setSensors(prev => prev.map(s =>
|
||||
s.deviceId === sensor.deviceId
|
||||
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
||||
: s
|
||||
));
|
||||
}
|
||||
|
||||
const success = await processSensor(
|
||||
sensors[i],
|
||||
ssid,
|
||||
pwd
|
||||
);
|
||||
|
||||
// Check for cancellation after each sensor
|
||||
if (shouldCancelRef.current) break;
|
||||
|
||||
// If failed, pause for user input
|
||||
if (!success) {
|
||||
setIsPaused(true);
|
||||
setupInProgressRef.current = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// All done
|
||||
setupInProgressRef.current = false;
|
||||
|
||||
// Check if we should show results
|
||||
const finalSensors = sensors;
|
||||
const allProcessed = finalSensors.every(
|
||||
s => s.status === 'success' || s.status === 'error' || s.status === 'skipped'
|
||||
);
|
||||
|
||||
if (allProcessed || shouldCancelRef.current) {
|
||||
setPhase('results');
|
||||
}
|
||||
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor]);
|
||||
|
||||
// Start batch setup
|
||||
const handleStartBatchSetup = () => {
|
||||
if (!selectedNetwork) {
|
||||
Alert.alert('Error', 'Please select a WiFi network');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
Alert.alert('Error', 'Please enter WiFi password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
// Initialize sensor states
|
||||
const initialStates = selectedDevices.map(createSensorState);
|
||||
setSensors(initialStates);
|
||||
setCurrentIndex(0);
|
||||
setIsPaused(false);
|
||||
setPhase('batch_setup');
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Set WiFi on the device via BLE
|
||||
const success = await setWiFi(deviceId!, selectedNetwork.ssid, password);
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Failed to configure WiFi on sensor');
|
||||
// Start processing after phase change
|
||||
useEffect(() => {
|
||||
if (phase === 'batch_setup' && sensors.length > 0 && !setupInProgressRef.current) {
|
||||
runBatchSetup();
|
||||
}
|
||||
}, [phase, sensors.length, runBatchSetup]);
|
||||
|
||||
// Step 2: Attach device to beneficiary via API (skip in simulator/mock mode)
|
||||
const isSimulator = !Device.isDevice;
|
||||
|
||||
if (!isSimulator) {
|
||||
const attachResponse = await api.attachDeviceToBeneficiary(
|
||||
id!,
|
||||
parseInt(wellId!, 10),
|
||||
selectedNetwork.ssid,
|
||||
password
|
||||
);
|
||||
|
||||
if (!attachResponse.ok) {
|
||||
throw new Error('Failed to attach sensor to beneficiary');
|
||||
// Retry failed sensor
|
||||
const handleRetry = (deviceId: string) => {
|
||||
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
||||
if (index >= 0) {
|
||||
setSensors(prev => prev.map(s =>
|
||||
s.deviceId === deviceId
|
||||
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
||||
: s
|
||||
));
|
||||
setCurrentIndex(index);
|
||||
setIsPaused(false);
|
||||
runBatchSetup();
|
||||
}
|
||||
};
|
||||
|
||||
// Skip failed sensor
|
||||
const handleSkip = (deviceId: string) => {
|
||||
setSensors(prev => prev.map(s =>
|
||||
s.deviceId === deviceId
|
||||
? { ...s, status: 'skipped' as SensorSetupStatus }
|
||||
: s
|
||||
));
|
||||
setIsPaused(false);
|
||||
|
||||
// Move to next sensor
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex < sensors.length) {
|
||||
setCurrentIndex(nextIndex);
|
||||
runBatchSetup();
|
||||
} else {
|
||||
console.log('[SetupWiFi] Simulator mode - skipping API attach');
|
||||
setPhase('results');
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Disconnect BLE connection (sensor will reboot and connect to WiFi)
|
||||
await disconnectDevice(deviceId!);
|
||||
|
||||
// Success!
|
||||
// Cancel all
|
||||
const handleCancelAll = () => {
|
||||
Alert.alert(
|
||||
'Success!',
|
||||
`${deviceName} has been configured and attached.\n\nThe sensor will now reboot and connect to "${selectedNetwork.ssid}". This may take a minute.`,
|
||||
'Cancel Setup',
|
||||
'Are you sure you want to cancel? Progress will be lost.',
|
||||
[
|
||||
{ text: 'Continue Setup', style: 'cancel' },
|
||||
{
|
||||
text: 'Done',
|
||||
text: 'Cancel',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
// Navigate back to Equipment screen
|
||||
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
|
||||
shouldCancelRef.current = true;
|
||||
setupInProgressRef.current = false;
|
||||
// Disconnect all devices
|
||||
selectedDevices.forEach(d => disconnectDevice(d.id));
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('[SetupWiFi] Failed to connect:', error);
|
||||
Alert.alert(
|
||||
'Connection Failed',
|
||||
error.message || 'Failed to configure WiFi. Please check the password and try again.'
|
||||
);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
// Done - navigate back
|
||||
const handleDone = () => {
|
||||
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
|
||||
};
|
||||
|
||||
// Retry a single sensor from results screen
|
||||
const handleRetryFromResults = (deviceId: string) => {
|
||||
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
||||
if (index >= 0) {
|
||||
// Reset the sensor state
|
||||
setSensors(prev => prev.map(s =>
|
||||
s.deviceId === deviceId
|
||||
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
||||
: s
|
||||
));
|
||||
setCurrentIndex(index);
|
||||
setIsPaused(false);
|
||||
// Go back to batch setup phase
|
||||
setPhase('batch_setup');
|
||||
}
|
||||
};
|
||||
|
||||
@ -153,6 +472,43 @@ export default function SetupWiFiScreen() {
|
||||
return 'wifi-outline';
|
||||
};
|
||||
|
||||
// Results screen
|
||||
if (phase === 'results') {
|
||||
return (
|
||||
<SetupResultsScreen
|
||||
sensors={sensors}
|
||||
onRetry={handleRetryFromResults}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Batch setup progress screen
|
||||
if (phase === 'batch_setup') {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.placeholder} />
|
||||
<Text style={styles.headerTitle}>Setting Up Sensors</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<View style={styles.batchContent}>
|
||||
<BatchSetupProgress
|
||||
sensors={sensors}
|
||||
currentIndex={currentIndex}
|
||||
ssid={selectedNetwork?.ssid || ''}
|
||||
isPaused={isPaused}
|
||||
onRetry={handleRetry}
|
||||
onSkip={handleSkip}
|
||||
onCancelAll={handleCancelAll}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// WiFi selection screen (default)
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
@ -160,8 +516,7 @@ export default function SetupWiFiScreen() {
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => {
|
||||
// Disconnect BLE before going back
|
||||
disconnectDevice(deviceId!);
|
||||
selectedDevices.forEach(d => disconnectDevice(d.id));
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
@ -178,15 +533,28 @@ export default function SetupWiFiScreen() {
|
||||
<Ionicons name="water" size={32} color={AppColors.primary} />
|
||||
</View>
|
||||
<View style={styles.deviceInfo}>
|
||||
<Text style={styles.deviceName}>{deviceName}</Text>
|
||||
<Text style={styles.deviceMeta}>Well ID: {wellId}</Text>
|
||||
{selectedDevices.length === 1 ? (
|
||||
<>
|
||||
<Text style={styles.deviceName}>{firstDevice?.name}</Text>
|
||||
<Text style={styles.deviceMeta}>Well ID: {firstDevice?.wellId}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.deviceName}>{selectedDevices.length} Sensors Selected</Text>
|
||||
<Text style={styles.deviceMeta}>
|
||||
{selectedDevices.map(d => d.name).join(', ')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Instructions */}
|
||||
<View style={styles.instructionsCard}>
|
||||
<Text style={styles.instructionsText}>
|
||||
Select the WiFi network your sensor should connect to. Make sure the network has internet access.
|
||||
{selectedDevices.length === 1
|
||||
? 'Select the WiFi network your sensor should connect to. Make sure the network has internet access.'
|
||||
: `Select the WiFi network for all ${selectedDevices.length} sensors. They will all be configured with the same WiFi credentials.`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@ -282,22 +650,17 @@ export default function SetupWiFiScreen() {
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.connectButton,
|
||||
(!password || isConnecting) && styles.connectButtonDisabled,
|
||||
!password && styles.connectButtonDisabled,
|
||||
]}
|
||||
onPress={handleConnect}
|
||||
disabled={!password || isConnecting}
|
||||
onPress={handleStartBatchSetup}
|
||||
disabled={!password}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color={AppColors.white} />
|
||||
<Text style={styles.connectButtonText}>Connecting...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark" size={20} color={AppColors.white} />
|
||||
<Text style={styles.connectButtonText}>Connect & Complete Setup</Text>
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.connectButtonText}>
|
||||
{selectedDevices.length === 1
|
||||
? 'Connect & Complete Setup'
|
||||
: `Connect All ${selectedDevices.length} Sensors`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@ -352,6 +715,10 @@ const styles = StyleSheet.create({
|
||||
padding: Spacing.lg,
|
||||
paddingBottom: Spacing.xxl,
|
||||
},
|
||||
batchContent: {
|
||||
flex: 1,
|
||||
padding: Spacing.lg,
|
||||
},
|
||||
// Device Card
|
||||
deviceCard: {
|
||||
flexDirection: 'row',
|
||||
|
||||
946
components/BatchSetupProgress.tsx
Normal file
946
components/BatchSetupProgress.tsx
Normal file
@ -0,0 +1,946 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
DimensionValue,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { SensorSetupState, SensorSetupStep } from '@/types';
|
||||
import {
|
||||
AppColors,
|
||||
BorderRadius,
|
||||
FontSizes,
|
||||
FontWeights,
|
||||
Spacing,
|
||||
Shadows,
|
||||
} from '@/constants/theme';
|
||||
|
||||
// User-friendly error messages based on error type
|
||||
function getErrorMessage(error: string | undefined): { title: string; description: string; hint: string } {
|
||||
if (!error) {
|
||||
return {
|
||||
title: 'Unknown Error',
|
||||
description: 'Something went wrong.',
|
||||
hint: 'Try again or skip this sensor.',
|
||||
};
|
||||
}
|
||||
|
||||
const lowerError = error.toLowerCase();
|
||||
|
||||
if (lowerError.includes('connect') || lowerError.includes('connection')) {
|
||||
return {
|
||||
title: 'Connection Failed',
|
||||
description: 'Could not connect to the sensor via Bluetooth.',
|
||||
hint: 'Move closer to the sensor and ensure it\'s powered on.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
|
||||
return {
|
||||
title: 'Unlock Failed',
|
||||
description: 'Could not unlock the sensor for configuration.',
|
||||
hint: 'The sensor may need to be reset. Try again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes('wifi') || lowerError.includes('network')) {
|
||||
return {
|
||||
title: 'WiFi Configuration Failed',
|
||||
description: 'Could not set up the WiFi connection on the sensor.',
|
||||
hint: 'Check that the WiFi password is correct.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
|
||||
return {
|
||||
title: 'Registration Failed',
|
||||
description: 'Could not register the sensor with your account.',
|
||||
hint: 'Check your internet connection and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
|
||||
return {
|
||||
title: 'Sensor Not Responding',
|
||||
description: 'The sensor stopped responding during setup.',
|
||||
hint: 'Move closer or check if the sensor is still powered on.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes('reboot')) {
|
||||
return {
|
||||
title: 'Reboot Failed',
|
||||
description: 'Could not restart the sensor.',
|
||||
hint: 'The sensor may still work. Try checking it in Equipment.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Setup Failed',
|
||||
description: error,
|
||||
hint: 'Try again or skip this sensor to continue with others.',
|
||||
};
|
||||
}
|
||||
|
||||
interface BatchSetupProgressProps {
|
||||
sensors: SensorSetupState[];
|
||||
currentIndex: number;
|
||||
ssid: string;
|
||||
isPaused: boolean;
|
||||
onRetry?: (deviceId: string) => void;
|
||||
onSkip?: (deviceId: string) => void;
|
||||
onCancelAll?: () => void;
|
||||
}
|
||||
|
||||
// Error Action Modal Component
|
||||
function ErrorActionModal({
|
||||
visible,
|
||||
sensor,
|
||||
onRetry,
|
||||
onSkip,
|
||||
onCancelAll,
|
||||
}: {
|
||||
visible: boolean;
|
||||
sensor: SensorSetupState | null;
|
||||
onRetry: () => void;
|
||||
onSkip: () => void;
|
||||
onCancelAll: () => void;
|
||||
}) {
|
||||
if (!sensor) return null;
|
||||
|
||||
const errorInfo = getErrorMessage(sensor.error);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={modalStyles.overlay}>
|
||||
<View style={modalStyles.container}>
|
||||
{/* Error Icon */}
|
||||
<View style={modalStyles.iconContainer}>
|
||||
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
|
||||
</View>
|
||||
|
||||
{/* Error Title */}
|
||||
<Text style={modalStyles.title}>{errorInfo.title}</Text>
|
||||
|
||||
{/* Sensor Name */}
|
||||
<View style={modalStyles.sensorBadge}>
|
||||
<Ionicons name="water" size={14} color={AppColors.primary} />
|
||||
<Text style={modalStyles.sensorName}>{sensor.deviceName}</Text>
|
||||
</View>
|
||||
|
||||
{/* Error Description */}
|
||||
<Text style={modalStyles.description}>{errorInfo.description}</Text>
|
||||
|
||||
{/* Hint */}
|
||||
<View style={modalStyles.hintContainer}>
|
||||
<Ionicons name="bulb-outline" size={16} color={AppColors.info} />
|
||||
<Text style={modalStyles.hintText}>{errorInfo.hint}</Text>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={modalStyles.actions}>
|
||||
<TouchableOpacity style={modalStyles.retryButton} onPress={onRetry}>
|
||||
<Ionicons name="refresh" size={18} color={AppColors.white} />
|
||||
<Text style={modalStyles.retryText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={modalStyles.skipButton} onPress={onSkip}>
|
||||
<Ionicons name="arrow-forward" size={18} color={AppColors.textPrimary} />
|
||||
<Text style={modalStyles.skipText}>Skip Sensor</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Cancel All */}
|
||||
<TouchableOpacity style={modalStyles.cancelAllButton} onPress={onCancelAll}>
|
||||
<Text style={modalStyles.cancelAllText}>Cancel All Setup</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const modalStyles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.lg,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: BorderRadius.xl,
|
||||
padding: Spacing.xl,
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
alignItems: 'center',
|
||||
...Shadows.lg,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: AppColors.errorLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSizes.xl,
|
||||
fontWeight: FontWeights.bold,
|
||||
color: AppColors.textPrimary,
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
sensorBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.primaryLighter,
|
||||
paddingHorizontal: Spacing.sm,
|
||||
paddingVertical: Spacing.xs,
|
||||
borderRadius: BorderRadius.md,
|
||||
gap: Spacing.xs,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
sensorName: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.primary,
|
||||
},
|
||||
description: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: Spacing.md,
|
||||
lineHeight: 22,
|
||||
},
|
||||
hintContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: AppColors.infoLight,
|
||||
padding: Spacing.md,
|
||||
borderRadius: BorderRadius.md,
|
||||
marginBottom: Spacing.lg,
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.info,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
retryButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: AppColors.primary,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
gap: Spacing.xs,
|
||||
...Shadows.sm,
|
||||
},
|
||||
retryText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
skipButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: AppColors.surfaceSecondary,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
gap: Spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
},
|
||||
skipText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
cancelAllButton: {
|
||||
paddingVertical: Spacing.sm,
|
||||
},
|
||||
cancelAllText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.error,
|
||||
},
|
||||
});
|
||||
|
||||
// Format elapsed time as "Xs" or "Xm Xs"
|
||||
function formatElapsedTime(startTime?: number, endTime?: number): string {
|
||||
if (!startTime) return '';
|
||||
const end = endTime || Date.now();
|
||||
const elapsed = Math.floor((end - startTime) / 1000);
|
||||
if (elapsed < 60) return `${elapsed}s`;
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
const STEP_LABELS: Record<SensorSetupStep['name'], string> = {
|
||||
connect: 'Connecting',
|
||||
unlock: 'Unlocking',
|
||||
wifi: 'Setting WiFi',
|
||||
attach: 'Registering',
|
||||
reboot: 'Rebooting',
|
||||
};
|
||||
|
||||
function StepIndicator({ step }: { step: SensorSetupStep }) {
|
||||
const getIcon = () => {
|
||||
switch (step.status) {
|
||||
case 'completed':
|
||||
return <Ionicons name="checkmark" size={12} color={AppColors.success} />;
|
||||
case 'in_progress':
|
||||
return <ActivityIndicator size={10} color={AppColors.primary} />;
|
||||
case 'failed':
|
||||
return <Ionicons name="close" size={12} color={AppColors.error} />;
|
||||
default:
|
||||
return <View style={styles.pendingDot} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
switch (step.status) {
|
||||
case 'completed':
|
||||
return AppColors.success;
|
||||
case 'in_progress':
|
||||
return AppColors.primary;
|
||||
case 'failed':
|
||||
return AppColors.error;
|
||||
default:
|
||||
return AppColors.textMuted;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.stepRow}>
|
||||
<View style={styles.stepIcon}>{getIcon()}</View>
|
||||
<Text style={[styles.stepLabel, { color: getTextColor() }]}>
|
||||
{STEP_LABELS[step.name]}
|
||||
</Text>
|
||||
{step.error && (
|
||||
<Text style={styles.stepError}>{step.error}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SensorCard({
|
||||
sensor,
|
||||
isActive,
|
||||
index,
|
||||
total,
|
||||
onRetry,
|
||||
onSkip,
|
||||
}: {
|
||||
sensor: SensorSetupState;
|
||||
isActive: boolean;
|
||||
index: number;
|
||||
total: number;
|
||||
onRetry?: () => void;
|
||||
onSkip?: () => void;
|
||||
}) {
|
||||
const [elapsedTime, setElapsedTime] = useState('');
|
||||
|
||||
// Update elapsed time for active sensors
|
||||
useEffect(() => {
|
||||
if (isActive && sensor.startTime && !sensor.endTime) {
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(formatElapsedTime(sensor.startTime));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
} else if (sensor.endTime && sensor.startTime) {
|
||||
setElapsedTime(formatElapsedTime(sensor.startTime, sensor.endTime));
|
||||
}
|
||||
}, [isActive, sensor.startTime, sensor.endTime]);
|
||||
const getStatusColor = () => {
|
||||
switch (sensor.status) {
|
||||
case 'success':
|
||||
return AppColors.success;
|
||||
case 'error':
|
||||
return AppColors.error;
|
||||
case 'skipped':
|
||||
return AppColors.warning;
|
||||
case 'pending':
|
||||
return AppColors.textMuted;
|
||||
default:
|
||||
return AppColors.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (sensor.status) {
|
||||
case 'success':
|
||||
return <Ionicons name="checkmark-circle" size={24} color={AppColors.success} />;
|
||||
case 'error':
|
||||
return <Ionicons name="close-circle" size={24} color={AppColors.error} />;
|
||||
case 'skipped':
|
||||
return <Ionicons name="remove-circle" size={24} color={AppColors.warning} />;
|
||||
case 'pending':
|
||||
return <Ionicons name="ellipse-outline" size={24} color={AppColors.textMuted} />;
|
||||
default:
|
||||
return <ActivityIndicator size={20} color={AppColors.primary} />;
|
||||
}
|
||||
};
|
||||
|
||||
const showActions = sensor.status === 'error' && onRetry && onSkip;
|
||||
const showProgress = sensor.status !== 'pending' && sensor.status !== 'skipped';
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.sensorCard,
|
||||
isActive && styles.sensorCardActive,
|
||||
sensor.status === 'success' && styles.sensorCardSuccess,
|
||||
sensor.status === 'error' && styles.sensorCardError,
|
||||
]}>
|
||||
{/* Index Badge */}
|
||||
<View style={[styles.indexBadge, { backgroundColor: getStatusColor() }]}>
|
||||
<Text style={styles.indexBadgeText}>{index + 1}/{total}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.sensorHeader}>
|
||||
<View style={[styles.sensorIcon, { backgroundColor: `${getStatusColor()}20` }]}>
|
||||
<Ionicons name="water" size={20} color={getStatusColor()} />
|
||||
</View>
|
||||
<View style={styles.sensorInfo}>
|
||||
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
|
||||
<View style={styles.sensorMetaRow}>
|
||||
{sensor.wellId && (
|
||||
<Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text>
|
||||
)}
|
||||
{elapsedTime && showProgress && (
|
||||
<Text style={styles.elapsedTime}>{elapsedTime}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.statusIcon}>{getStatusIcon()}</View>
|
||||
</View>
|
||||
|
||||
{/* Show steps for active or completed sensors */}
|
||||
{(isActive || sensor.status === 'success' || sensor.status === 'error') && (
|
||||
<View style={styles.stepsContainer}>
|
||||
{sensor.steps.map((step, index) => (
|
||||
<StepIndicator key={step.name} step={step} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error message - enhanced display */}
|
||||
{sensor.error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<View style={styles.errorHeader}>
|
||||
<Ionicons name="alert-circle" size={18} color={AppColors.error} />
|
||||
<Text style={styles.errorTitle}>
|
||||
{getErrorMessage(sensor.error).title}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.errorText}>
|
||||
{getErrorMessage(sensor.error).description}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Action buttons for failed sensors - improved styling */}
|
||||
{showActions && (
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
||||
<Ionicons name="refresh" size={16} color={AppColors.white} />
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
|
||||
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
|
||||
<Text style={styles.skipText}>Skip</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchSetupProgress({
|
||||
sensors,
|
||||
currentIndex,
|
||||
ssid,
|
||||
isPaused,
|
||||
onRetry,
|
||||
onSkip,
|
||||
onCancelAll,
|
||||
}: BatchSetupProgressProps) {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
|
||||
const progressAnim = useRef(new Animated.Value(0)).current;
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
|
||||
const completedCount = sensors.filter(s => s.status === 'success').length;
|
||||
const failedCount = sensors.filter(s => s.status === 'error').length;
|
||||
const skippedCount = sensors.filter(s => s.status === 'skipped').length;
|
||||
const totalProcessed = completedCount + failedCount + skippedCount;
|
||||
const progress = (totalProcessed / sensors.length) * 100;
|
||||
|
||||
// Find the current failed sensor for the modal
|
||||
const failedSensor = sensors.find(s => s.status === 'error');
|
||||
|
||||
// Show error modal when paused due to error
|
||||
useEffect(() => {
|
||||
if (isPaused && failedSensor) {
|
||||
// Small delay for better UX - let the card error state render first
|
||||
const timer = setTimeout(() => setShowErrorModal(true), 300);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowErrorModal(false);
|
||||
}
|
||||
}, [isPaused, failedSensor]);
|
||||
|
||||
const handleRetryFromModal = () => {
|
||||
setShowErrorModal(false);
|
||||
if (failedSensor && onRetry) {
|
||||
onRetry(failedSensor.deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipFromModal = () => {
|
||||
setShowErrorModal(false);
|
||||
if (failedSensor && onSkip) {
|
||||
onSkip(failedSensor.deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAllFromModal = () => {
|
||||
setShowErrorModal(false);
|
||||
if (onCancelAll) {
|
||||
onCancelAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Animate progress bar
|
||||
useEffect(() => {
|
||||
Animated.timing(progressAnim, {
|
||||
toValue: progress,
|
||||
duration: 300,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [progress, progressAnim]);
|
||||
|
||||
// Auto-scroll to current sensor
|
||||
useEffect(() => {
|
||||
if (currentIndex >= 0 && scrollViewRef.current) {
|
||||
// Calculate approximate scroll position (each card ~120px + spacing)
|
||||
const cardHeight = 120;
|
||||
const spacing = 12;
|
||||
const scrollTo = currentIndex * (cardHeight + spacing);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: Math.max(0, scrollTo - 20), // Small offset for visibility
|
||||
animated: true,
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [currentIndex]);
|
||||
|
||||
const animatedWidth = progressAnim.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: ['0%', '100%'],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Progress Header */}
|
||||
<View style={styles.progressHeader}>
|
||||
<View style={styles.progressTitleRow}>
|
||||
<Text style={styles.progressTitle}>
|
||||
Connecting to "{ssid}"
|
||||
</Text>
|
||||
{!isPaused && currentIndex < sensors.length && (
|
||||
<View style={styles.currentSensorBadge}>
|
||||
<Text style={styles.currentSensorBadgeText}>
|
||||
{currentIndex + 1}/{sensors.length}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.progressSubtitle}>
|
||||
{isPaused ? (
|
||||
<Text style={styles.pausedText}>Paused - action required</Text>
|
||||
) : totalProcessed === sensors.length ? (
|
||||
'Setup complete!'
|
||||
) : (
|
||||
`Processing sensor ${currentIndex + 1} of ${sensors.length}...`
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Animated Progress bar */}
|
||||
<View style={styles.progressBarContainer}>
|
||||
<Animated.View style={[styles.progressBar, { width: animatedWidth }]} />
|
||||
{/* Success/Error segments */}
|
||||
<View style={styles.progressSegments}>
|
||||
{sensors.map((sensor) => {
|
||||
const segmentWidth: DimensionValue = `${100 / sensors.length}%`;
|
||||
let backgroundColor = 'transparent';
|
||||
if (sensor.status === 'success') backgroundColor = AppColors.success;
|
||||
else if (sensor.status === 'error') backgroundColor = AppColors.error;
|
||||
else if (sensor.status === 'skipped') backgroundColor = AppColors.warning;
|
||||
return (
|
||||
<View
|
||||
key={sensor.deviceId}
|
||||
style={[
|
||||
styles.progressSegment,
|
||||
{ width: segmentWidth, backgroundColor },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsRow}>
|
||||
{completedCount > 0 && (
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="checkmark-circle" size={14} color={AppColors.success} />
|
||||
<Text style={[styles.statText, { color: AppColors.success }]}>{completedCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="close-circle" size={14} color={AppColors.error} />
|
||||
<Text style={[styles.statText, { color: AppColors.error }]}>{failedCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="remove-circle" size={14} color={AppColors.warning} />
|
||||
<Text style={[styles.statText, { color: AppColors.warning }]}>{skippedCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sensors List */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.sensorsList}
|
||||
contentContainerStyle={styles.sensorsListContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{sensors.map((sensor, index) => (
|
||||
<SensorCard
|
||||
key={sensor.deviceId}
|
||||
sensor={sensor}
|
||||
isActive={index === currentIndex && !isPaused}
|
||||
index={index}
|
||||
total={sensors.length}
|
||||
onRetry={onRetry ? () => onRetry(sensor.deviceId) : undefined}
|
||||
onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Cancel button */}
|
||||
{onCancelAll && (
|
||||
<TouchableOpacity style={styles.cancelAllButton} onPress={onCancelAll}>
|
||||
<Text style={styles.cancelAllText}>Cancel Setup</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Error Action Modal */}
|
||||
<ErrorActionModal
|
||||
visible={showErrorModal}
|
||||
sensor={failedSensor || null}
|
||||
onRetry={handleRetryFromModal}
|
||||
onSkip={handleSkipFromModal}
|
||||
onCancelAll={handleCancelAllFromModal}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
progressHeader: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
progressTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
progressTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
flex: 1,
|
||||
},
|
||||
currentSensorBadge: {
|
||||
backgroundColor: AppColors.primary,
|
||||
paddingHorizontal: Spacing.sm,
|
||||
paddingVertical: Spacing.xs,
|
||||
borderRadius: BorderRadius.md,
|
||||
},
|
||||
currentSensorBadgeText: {
|
||||
fontSize: FontSizes.xs,
|
||||
fontWeight: FontWeights.bold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
progressSubtitle: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
pausedText: {
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeights.medium,
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: 6,
|
||||
backgroundColor: AppColors.border,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
backgroundColor: AppColors.primary,
|
||||
borderRadius: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
progressSegments: {
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
progressSegment: {
|
||||
height: '100%',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: Spacing.sm,
|
||||
gap: Spacing.md,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statText: {
|
||||
fontSize: FontSizes.xs,
|
||||
fontWeight: FontWeights.medium,
|
||||
},
|
||||
sensorsList: {
|
||||
flex: 1,
|
||||
},
|
||||
sensorsListContent: {
|
||||
gap: Spacing.md,
|
||||
paddingBottom: Spacing.lg,
|
||||
},
|
||||
sensorCard: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: BorderRadius.lg,
|
||||
padding: Spacing.md,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...Shadows.xs,
|
||||
},
|
||||
sensorCardActive: {
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.primary,
|
||||
},
|
||||
sensorCardSuccess: {
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.success,
|
||||
},
|
||||
sensorCardError: {
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.error,
|
||||
},
|
||||
indexBadge: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: Spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomLeftRadius: BorderRadius.md,
|
||||
},
|
||||
indexBadgeText: {
|
||||
fontSize: FontSizes.xs,
|
||||
fontWeight: FontWeights.bold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
sensorHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sensorIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.md,
|
||||
backgroundColor: AppColors.primaryLighter,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
sensorInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
sensorName: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
sensorMetaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
sensorMeta: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
},
|
||||
elapsedTime: {
|
||||
fontSize: FontSizes.xs,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.primary,
|
||||
},
|
||||
statusIcon: {
|
||||
marginLeft: Spacing.sm,
|
||||
},
|
||||
stepsContainer: {
|
||||
marginTop: Spacing.md,
|
||||
paddingTop: Spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: AppColors.border,
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
stepIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pendingDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: AppColors.textMuted,
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.medium,
|
||||
},
|
||||
stepError: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.error,
|
||||
flex: 1,
|
||||
},
|
||||
errorContainer: {
|
||||
marginTop: Spacing.sm,
|
||||
padding: Spacing.md,
|
||||
backgroundColor: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.md,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: AppColors.error,
|
||||
},
|
||||
errorHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.error,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textSecondary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
marginTop: Spacing.md,
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
retryButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: Spacing.sm,
|
||||
paddingHorizontal: Spacing.md,
|
||||
backgroundColor: AppColors.primary,
|
||||
borderRadius: BorderRadius.md,
|
||||
gap: Spacing.xs,
|
||||
...Shadows.xs,
|
||||
},
|
||||
retryText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
skipButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: Spacing.sm,
|
||||
paddingHorizontal: Spacing.md,
|
||||
backgroundColor: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.border,
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
skipText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
cancelAllButton: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: Spacing.sm,
|
||||
marginTop: Spacing.md,
|
||||
},
|
||||
cancelAllText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.error,
|
||||
},
|
||||
});
|
||||
529
components/SetupResultsScreen.tsx
Normal file
529
components/SetupResultsScreen.tsx
Normal file
@ -0,0 +1,529 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { SensorSetupState } from '@/types';
|
||||
import {
|
||||
AppColors,
|
||||
BorderRadius,
|
||||
FontSizes,
|
||||
FontWeights,
|
||||
Spacing,
|
||||
Shadows,
|
||||
} from '@/constants/theme';
|
||||
|
||||
interface SetupResultsScreenProps {
|
||||
sensors: SensorSetupState[];
|
||||
onRetry: (deviceId: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// Format elapsed time as readable string
|
||||
function formatElapsedTime(startTime?: number, endTime?: number): string {
|
||||
if (!startTime || !endTime) return '';
|
||||
const elapsed = Math.floor((endTime - startTime) / 1000);
|
||||
if (elapsed < 60) return `${elapsed}s`;
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
// Get user-friendly error message
|
||||
function getErrorMessage(error: string | undefined): string {
|
||||
if (!error) return 'Unknown error';
|
||||
|
||||
const lowerError = error.toLowerCase();
|
||||
|
||||
if (lowerError.includes('connect') || lowerError.includes('connection')) {
|
||||
return 'Could not connect via Bluetooth';
|
||||
}
|
||||
if (lowerError.includes('wifi') || lowerError.includes('network')) {
|
||||
return 'WiFi configuration failed';
|
||||
}
|
||||
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
|
||||
return 'Could not register sensor';
|
||||
}
|
||||
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
|
||||
return 'Sensor not responding';
|
||||
}
|
||||
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
|
||||
return 'Could not unlock sensor';
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
export default function SetupResultsScreen({
|
||||
sensors,
|
||||
onRetry,
|
||||
onDone,
|
||||
}: SetupResultsScreenProps) {
|
||||
const successSensors = sensors.filter(s => s.status === 'success');
|
||||
const failedSensors = sensors.filter(s => s.status === 'error');
|
||||
const skippedSensors = sensors.filter(s => s.status === 'skipped');
|
||||
|
||||
const allSuccess = successSensors.length === sensors.length;
|
||||
const allFailed = failedSensors.length + skippedSensors.length === sensors.length;
|
||||
const partialSuccess = !allSuccess && !allFailed && successSensors.length > 0;
|
||||
|
||||
// Calculate total setup time
|
||||
const totalTime = sensors.reduce((acc, sensor) => {
|
||||
if (sensor.startTime && sensor.endTime) {
|
||||
return acc + (sensor.endTime - sensor.startTime);
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const totalTimeStr = totalTime > 0 ? formatElapsedTime(0, totalTime) : null;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.placeholder} />
|
||||
<Text style={styles.headerTitle}>Setup Complete</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Summary Card */}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={[
|
||||
styles.summaryIcon,
|
||||
{ backgroundColor: allSuccess ? AppColors.successLight :
|
||||
allFailed ? AppColors.errorLight : AppColors.warningLight }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={allSuccess ? 'checkmark-circle' :
|
||||
allFailed ? 'close-circle' : 'alert-circle'}
|
||||
size={56}
|
||||
color={allSuccess ? AppColors.success :
|
||||
allFailed ? AppColors.error : AppColors.warning}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.summaryTitle}>
|
||||
{allSuccess ? 'All Sensors Connected!' :
|
||||
allFailed ? 'Setup Failed' :
|
||||
'Partial Success'}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.summarySubtitle}>
|
||||
{successSensors.length} of {sensors.length} sensors configured successfully
|
||||
</Text>
|
||||
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsRow}>
|
||||
{successSensors.length > 0 && (
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.statDot, { backgroundColor: AppColors.success }]} />
|
||||
<Text style={styles.statText}>{successSensors.length} Success</Text>
|
||||
</View>
|
||||
)}
|
||||
{failedSensors.length > 0 && (
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.statDot, { backgroundColor: AppColors.error }]} />
|
||||
<Text style={styles.statText}>{failedSensors.length} Failed</Text>
|
||||
</View>
|
||||
)}
|
||||
{skippedSensors.length > 0 && (
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.statDot, { backgroundColor: AppColors.warning }]} />
|
||||
<Text style={styles.statText}>{skippedSensors.length} Skipped</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{totalTimeStr && (
|
||||
<Text style={styles.totalTime}>Total time: {totalTimeStr}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Success List */}
|
||||
{successSensors.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
|
||||
<Text style={styles.sectionTitle}>Successfully Connected</Text>
|
||||
</View>
|
||||
|
||||
{successSensors.map(sensor => (
|
||||
<View key={sensor.deviceId} style={styles.sensorItem}>
|
||||
<View style={styles.sensorInfo}>
|
||||
<View style={[styles.sensorIcon, { backgroundColor: AppColors.successLight }]}>
|
||||
<Ionicons name="water" size={18} color={AppColors.success} />
|
||||
</View>
|
||||
<View style={styles.sensorDetails}>
|
||||
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
|
||||
<View style={styles.sensorMeta}>
|
||||
{sensor.wellId && (
|
||||
<Text style={styles.sensorMetaText}>Well ID: {sensor.wellId}</Text>
|
||||
)}
|
||||
{sensor.startTime && sensor.endTime && (
|
||||
<Text style={styles.sensorMetaText}>
|
||||
{formatElapsedTime(sensor.startTime, sensor.endTime)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="checkmark" size={20} color={AppColors.success} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Failed List */}
|
||||
{failedSensors.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="close-circle" size={18} color={AppColors.error} />
|
||||
<Text style={styles.sectionTitle}>Failed</Text>
|
||||
</View>
|
||||
|
||||
{failedSensors.map(sensor => (
|
||||
<View key={sensor.deviceId} style={styles.sensorItemWithAction}>
|
||||
<View style={styles.sensorInfo}>
|
||||
<View style={[styles.sensorIcon, { backgroundColor: AppColors.errorLight }]}>
|
||||
<Ionicons name="water" size={18} color={AppColors.error} />
|
||||
</View>
|
||||
<View style={styles.sensorDetails}>
|
||||
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
|
||||
<Text style={styles.errorText}>{getErrorMessage(sensor.error)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => onRetry(sensor.deviceId)}
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color={AppColors.primary} />
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skipped List */}
|
||||
{skippedSensors.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Ionicons name="remove-circle" size={18} color={AppColors.warning} />
|
||||
<Text style={styles.sectionTitle}>Skipped</Text>
|
||||
</View>
|
||||
|
||||
{skippedSensors.map(sensor => (
|
||||
<View key={sensor.deviceId} style={styles.sensorItemWithAction}>
|
||||
<View style={styles.sensorInfo}>
|
||||
<View style={[styles.sensorIcon, { backgroundColor: AppColors.warningLight }]}>
|
||||
<Ionicons name="water" size={18} color={AppColors.warning} />
|
||||
</View>
|
||||
<View style={styles.sensorDetails}>
|
||||
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
|
||||
<Text style={styles.skippedText}>Skipped by user</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => onRetry(sensor.deviceId)}
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color={AppColors.primary} />
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Help Info */}
|
||||
<View style={styles.helpCard}>
|
||||
<View style={styles.helpHeader}>
|
||||
<Ionicons name="information-circle" size={20} color={AppColors.info} />
|
||||
<Text style={styles.helpTitle}>What's Next</Text>
|
||||
</View>
|
||||
<Text style={styles.helpText}>
|
||||
{successSensors.length > 0 ? (
|
||||
'• Successfully connected sensors will appear in Equipment\n' +
|
||||
'• It may take up to 1 minute for sensors to come online\n' +
|
||||
'• You can configure sensor locations in Device Settings'
|
||||
) : (
|
||||
'• Return to Equipment and try adding sensors again\n' +
|
||||
'• Make sure sensors are powered on and nearby\n' +
|
||||
'• Check that WiFi password is correct'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<View style={styles.bottomActions}>
|
||||
{(failedSensors.length > 0 || skippedSensors.length > 0) && (
|
||||
<TouchableOpacity
|
||||
style={styles.retryAllButton}
|
||||
onPress={() => {
|
||||
[...failedSensors, ...skippedSensors].forEach(s => onRetry(s.deviceId));
|
||||
}}
|
||||
>
|
||||
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
||||
<Text style={styles.retryAllButtonText}>
|
||||
Retry All Failed ({failedSensors.length + skippedSensors.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.doneButton} onPress={onDone}>
|
||||
<Text style={styles.doneButtonText}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.md,
|
||||
paddingVertical: Spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
backgroundColor: AppColors.surface,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
placeholder: {
|
||||
width: 32,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: Spacing.lg,
|
||||
paddingBottom: Spacing.xxl,
|
||||
},
|
||||
// Summary Card
|
||||
summaryCard: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: BorderRadius.xl,
|
||||
padding: Spacing.xl,
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
...Shadows.sm,
|
||||
},
|
||||
summaryIcon: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
summaryTitle: {
|
||||
fontSize: FontSizes['2xl'],
|
||||
fontWeight: FontWeights.bold,
|
||||
color: AppColors.textPrimary,
|
||||
marginBottom: Spacing.xs,
|
||||
textAlign: 'center',
|
||||
},
|
||||
summarySubtitle: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: Spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.lg,
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
statDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeights.medium,
|
||||
},
|
||||
totalTime: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textMuted,
|
||||
marginTop: Spacing.xs,
|
||||
},
|
||||
// Sections
|
||||
section: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: BorderRadius.lg,
|
||||
padding: Spacing.md,
|
||||
marginBottom: Spacing.md,
|
||||
...Shadows.xs,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.sm,
|
||||
marginBottom: Spacing.md,
|
||||
paddingBottom: Spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.border,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
// Sensor Items
|
||||
sensorItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: Spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.borderLight,
|
||||
},
|
||||
sensorItemWithAction: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: Spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: AppColors.borderLight,
|
||||
},
|
||||
sensorInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
sensorIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.sm,
|
||||
},
|
||||
sensorDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
sensorName: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.medium,
|
||||
color: AppColors.textPrimary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
sensorMeta: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
sensorMetaText: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.error,
|
||||
},
|
||||
skippedText: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.warning,
|
||||
},
|
||||
// Buttons
|
||||
retryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
paddingVertical: Spacing.xs,
|
||||
paddingHorizontal: Spacing.sm,
|
||||
backgroundColor: AppColors.primaryLighter,
|
||||
borderRadius: BorderRadius.md,
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.primary,
|
||||
},
|
||||
// Help Card
|
||||
helpCard: {
|
||||
backgroundColor: AppColors.infoLight,
|
||||
borderRadius: BorderRadius.lg,
|
||||
padding: Spacing.md,
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
helpHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.sm,
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
helpTitle: {
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.info,
|
||||
},
|
||||
helpText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.info,
|
||||
lineHeight: 20,
|
||||
},
|
||||
// Bottom Actions
|
||||
bottomActions: {
|
||||
padding: Spacing.lg,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: AppColors.border,
|
||||
backgroundColor: AppColors.surface,
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
retryAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: Spacing.sm,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.primary,
|
||||
backgroundColor: AppColors.primaryLighter,
|
||||
},
|
||||
retryAllButtonText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.primary,
|
||||
},
|
||||
doneButton: {
|
||||
backgroundColor: AppColors.primary,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
...Shadows.md,
|
||||
},
|
||||
doneButtonText: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
});
|
||||
577
docs/SENSORS_SYSTEM.md
Normal file
577
docs/SENSORS_SYSTEM.md
Normal file
@ -0,0 +1,577 @@
|
||||
# WellNuo Sensors System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture and user flows for managing BLE/WiFi sensors in the WellNuo app. Sensors monitor elderly people (beneficiaries) and send activity data via WiFi.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Entities](#entities)
|
||||
2. [Data Storage](#data-storage)
|
||||
3. [Entity Relationships](#entity-relationships)
|
||||
4. [User Flows](#user-flows)
|
||||
5. [BLE Protocol](#ble-protocol)
|
||||
6. [Legacy API Endpoints](#legacy-api-endpoints)
|
||||
7. [UI Screens](#ui-screens)
|
||||
8. [Batch Sensor Setup](#batch-sensor-setup)
|
||||
9. [Error Handling](#error-handling)
|
||||
10. [Open Questions](#open-questions)
|
||||
|
||||
---
|
||||
|
||||
## Entities
|
||||
|
||||
### User (Caregiver)
|
||||
The person who uses the app to monitor beneficiaries.
|
||||
- Can have multiple beneficiaries
|
||||
- Stored in: **WellNuo API**
|
||||
|
||||
### Beneficiary
|
||||
An elderly person being monitored.
|
||||
- Has profile data (name, avatar, date of birth)
|
||||
- Linked to exactly one Deployment
|
||||
- One user can have **multiple beneficiaries** (e.g., 5)
|
||||
- Stored in: **WellNuo API**
|
||||
|
||||
### Deployment
|
||||
A "household" in Legacy API where the beneficiary lives.
|
||||
- Contains address, timezone
|
||||
- Contains array of devices
|
||||
- One beneficiary = one deployment
|
||||
- Stored in: **Legacy API**
|
||||
|
||||
### Device (WP Sensor)
|
||||
Physical BLE/WiFi sensor installed in beneficiary's home.
|
||||
- Measures activity, temperature, etc.
|
||||
- Sends data via WiFi to Legacy API
|
||||
- Up to **5 sensors per beneficiary**
|
||||
- Stored in: **Legacy API**
|
||||
|
||||
---
|
||||
|
||||
## Data Storage
|
||||
|
||||
| Data | Storage | Notes |
|
||||
|------|---------|-------|
|
||||
| User profile | WellNuo API | Our database |
|
||||
| Beneficiary profile | WellNuo API | Our database |
|
||||
| Beneficiary.deploymentId | WellNuo API | Links to Legacy |
|
||||
| Deployment | Legacy API | External service |
|
||||
| Device | Legacy API | External service |
|
||||
| Sensor readings | Legacy API | External service |
|
||||
|
||||
**Important:** We do NOT have access to Legacy API source code. We only use their API endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Entity Relationships
|
||||
|
||||
```
|
||||
User (WellNuo API)
|
||||
│
|
||||
├── Beneficiary 1
|
||||
│ └── deploymentId: 22 ──► Deployment 22 (Legacy API)
|
||||
│ ├── Device (WP_497)
|
||||
│ ├── Device (WP_498)
|
||||
│ ├── Device (WP_499)
|
||||
│ ├── Device (WP_500)
|
||||
│ └── Device (WP_501)
|
||||
│
|
||||
├── Beneficiary 2
|
||||
│ └── deploymentId: 23 ──► Deployment 23 (Legacy API)
|
||||
│ ├── Device (WP_510)
|
||||
│ └── Device (WP_511)
|
||||
│
|
||||
├── Beneficiary 3
|
||||
│ └── deploymentId: 24 ──► Deployment 24 (Legacy API)
|
||||
│ └── (no devices yet)
|
||||
│
|
||||
└── ... up to N beneficiaries
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
1. Each beneficiary has their own deployment
|
||||
2. Devices are attached to deployment, not directly to beneficiary
|
||||
3. When adding sensors, user must first select which beneficiary
|
||||
4. Sensors found via BLE scan could belong to ANY beneficiary's home
|
||||
|
||||
---
|
||||
|
||||
## User Flows
|
||||
|
||||
### Flow 1: View Sensors for a Beneficiary
|
||||
|
||||
```
|
||||
1. User opens Beneficiaries list
|
||||
2. User taps on a specific beneficiary (e.g., "Maria")
|
||||
3. User navigates to Equipment tab
|
||||
4. App fetches:
|
||||
a. GET /me/beneficiaries/{id} → get deploymentId
|
||||
b. POST Legacy API device_list_by_deployment → get devices
|
||||
5. App displays sensor list with status (online/warning/offline)
|
||||
```
|
||||
|
||||
### Flow 2: Add Sensors (Batch)
|
||||
|
||||
**Prerequisites:**
|
||||
- User has selected a specific beneficiary
|
||||
- User is physically at that beneficiary's location
|
||||
- Sensors are powered on
|
||||
|
||||
```
|
||||
1. User is on Equipment screen for "Maria"
|
||||
2. User taps "Add Sensor"
|
||||
3. App shows instructions
|
||||
4. User taps "Scan for Sensors"
|
||||
5. App performs BLE scan (10 seconds)
|
||||
6. App filters devices with name starting with "WP_"
|
||||
7. App shows list with checkboxes (all selected by default)
|
||||
8. User can uncheck sensors they don't want to add
|
||||
9. User taps "Add Selected (N)"
|
||||
10. App shows WiFi selection screen
|
||||
11. User selects WiFi network (from sensor's scan)
|
||||
12. User enters WiFi password
|
||||
13. User taps "Connect All"
|
||||
14. App performs batch setup (see Batch Sensor Setup section)
|
||||
15. App shows results screen
|
||||
16. User taps "Done"
|
||||
```
|
||||
|
||||
### Flow 3: Edit Sensor Location/Description
|
||||
|
||||
**TODO:** Not implemented yet. Needs UI.
|
||||
|
||||
```
|
||||
1. User taps on a sensor in Equipment list
|
||||
2. App shows Device Settings screen
|
||||
3. User can edit:
|
||||
- location (text field, e.g., "Bedroom, near bed")
|
||||
- description (text field)
|
||||
4. User taps "Save"
|
||||
5. App calls Legacy API device_form to update
|
||||
```
|
||||
|
||||
### Flow 4: Detach Sensor
|
||||
|
||||
```
|
||||
1. User taps detach button on a sensor
|
||||
2. App shows confirmation dialog
|
||||
3. User confirms
|
||||
4. App calls Legacy API device_form with deployment_id=0
|
||||
5. Sensor is unlinked from this beneficiary
|
||||
6. Sensor can now be attached to another beneficiary
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BLE Protocol
|
||||
|
||||
### Service UUID
|
||||
```
|
||||
4fafc201-1fb5-459e-8fcc-c5c9c331914b
|
||||
```
|
||||
|
||||
### Characteristic UUID
|
||||
```
|
||||
beb5483e-36e1-4688-b7f5-ea07361b26a8
|
||||
```
|
||||
|
||||
### Device Name Format
|
||||
```
|
||||
WP_{wellId}_{mac6chars}
|
||||
|
||||
Example: WP_497_81a14c
|
||||
- wellId: 497
|
||||
- mac last 6 chars: 81a14c
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Format | Description |
|
||||
|---------|--------|-------------|
|
||||
| PIN Unlock | `pin\|7856` | Unlock device for configuration |
|
||||
| Get WiFi List | `w` | Request available WiFi networks |
|
||||
| Set WiFi | `W\|SSID,PASSWORD` | Configure WiFi connection |
|
||||
| Get WiFi Status | `a` | Get current WiFi connection |
|
||||
| Reboot | `s` | Restart the sensor |
|
||||
| Disconnect | `D` | Disconnect BLE |
|
||||
|
||||
### WiFi List Response Format
|
||||
```
|
||||
SSID1,-55;SSID2,-70;SSID3,-80
|
||||
```
|
||||
Where -55, -70, -80 are RSSI values in dBm.
|
||||
|
||||
### Timeouts
|
||||
- Scan: 10 seconds
|
||||
- Command response: 5 seconds
|
||||
|
||||
### WiFi Status Response (command `a`)
|
||||
|
||||
When connected via BLE, you can query current WiFi status:
|
||||
|
||||
**Command:** `a`
|
||||
|
||||
**Response format:**
|
||||
```
|
||||
{ssid},{rssi},{connected}
|
||||
|
||||
Example: Home_Network,-62,1
|
||||
- ssid: "Home_Network"
|
||||
- rssi: -62 dBm
|
||||
- connected: 1 (true) or 0 (false)
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Show which WiFi network the sensor is connected to
|
||||
- Display WiFi signal strength
|
||||
- Diagnose connectivity issues
|
||||
|
||||
---
|
||||
|
||||
## Legacy API Endpoints
|
||||
|
||||
Base URL: `https://eluxnetworks.net/function/well-api/api`
|
||||
|
||||
Authentication: Form-encoded POST with `user_name` and `token`
|
||||
|
||||
### device_list_by_deployment
|
||||
|
||||
Get all devices for a deployment.
|
||||
|
||||
**Request:**
|
||||
```
|
||||
function=device_list_by_deployment
|
||||
user_name={username}
|
||||
token={token}
|
||||
deployment_id={id}
|
||||
first=0
|
||||
last=100
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"result_list": [
|
||||
[deviceId, wellId, mac, lastSeenTimestamp, location, description],
|
||||
[1, 497, "142B2F81A14C", 1705426800, "Bedroom", "Main sensor"],
|
||||
[2, 498, "142B2F82B25D", 1705426700, "", ""]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### device_form
|
||||
|
||||
Create or update a device.
|
||||
|
||||
**Request:**
|
||||
```
|
||||
function=device_form
|
||||
user_name={username}
|
||||
token={token}
|
||||
well_id={wellId}
|
||||
device_mac={mac}
|
||||
location={location}
|
||||
description={description}
|
||||
deployment_id={deploymentId}
|
||||
```
|
||||
|
||||
### request_devices
|
||||
|
||||
Get online devices (with fresh=true filter).
|
||||
|
||||
**Request:**
|
||||
```
|
||||
function=request_devices
|
||||
user_name={username}
|
||||
token={token}
|
||||
deployment_id={id}
|
||||
group_id=All
|
||||
location=All
|
||||
fresh=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Screens
|
||||
|
||||
### Equipment Screen
|
||||
**Path:** `/(tabs)/beneficiaries/:id/equipment`
|
||||
|
||||
**Features:**
|
||||
- Summary card (total/online/warning/offline counts)
|
||||
- List of connected sensors
|
||||
- BLE scan button for nearby sensors
|
||||
- Detach sensor button
|
||||
|
||||
**Current limitations:**
|
||||
- No editing of location/description
|
||||
|
||||
### Add Sensor Screen
|
||||
**Path:** `/(tabs)/beneficiaries/:id/add-sensor`
|
||||
|
||||
**Features:**
|
||||
- Instructions (4 steps)
|
||||
- Scan button
|
||||
- List of found devices with RSSI
|
||||
|
||||
**TODO:** Add checkbox selection for batch setup
|
||||
|
||||
### Setup WiFi Screen
|
||||
**Path:** `/(tabs)/beneficiaries/:id/setup-wifi`
|
||||
|
||||
**Features:**
|
||||
- WiFi network list (from sensor)
|
||||
- Password input
|
||||
- Connect button
|
||||
|
||||
**TODO:** Support batch setup (multiple sensors, one WiFi config)
|
||||
|
||||
### Device Settings Screen
|
||||
**Path:** `/(tabs)/beneficiaries/:id/device-settings/:deviceId`
|
||||
|
||||
**Features:**
|
||||
- View Well ID, MAC, Deployment ID
|
||||
- Current WiFi status
|
||||
- Change WiFi button
|
||||
- Reboot button
|
||||
|
||||
**TODO:** Add location/description editing
|
||||
|
||||
---
|
||||
|
||||
## Batch Sensor Setup
|
||||
|
||||
### Overview
|
||||
|
||||
When adding multiple sensors (up to 5), the app should:
|
||||
1. Allow selecting multiple sensors from BLE scan
|
||||
2. Configure WiFi once for all sensors
|
||||
3. Process sensors sequentially with progress UI
|
||||
4. Handle errors gracefully
|
||||
|
||||
### Sensor States During Setup
|
||||
|
||||
```
|
||||
pending — waiting in queue
|
||||
connecting — BLE connection in progress
|
||||
unlocking — sending PIN command
|
||||
setting_wifi — configuring WiFi
|
||||
attaching — calling Legacy API to link to deployment
|
||||
rebooting — restarting sensor
|
||||
success — completed successfully
|
||||
error — failed (with error message)
|
||||
skipped — user chose to skip after error
|
||||
```
|
||||
|
||||
### Progress UI
|
||||
|
||||
```
|
||||
Connecting sensors to WiFi "Home_Network"...
|
||||
|
||||
WP_497_81a14c
|
||||
✓ Connected
|
||||
✓ Unlocked
|
||||
✓ WiFi configured
|
||||
✓ Attached to Maria
|
||||
● Rebooting...
|
||||
|
||||
WP_498_82b25d
|
||||
✓ Connected
|
||||
✓ Unlocked
|
||||
● Setting WiFi...
|
||||
|
||||
WP_499_83c36e
|
||||
○ Waiting...
|
||||
|
||||
WP_500_84d47f
|
||||
✗ Error: Could not connect
|
||||
[Retry] [Skip]
|
||||
```
|
||||
|
||||
### Results Screen
|
||||
|
||||
```
|
||||
Setup Complete
|
||||
|
||||
Successfully connected:
|
||||
✓ WP_497_81a14c
|
||||
✓ WP_498_82b25d
|
||||
✓ WP_499_83c36e
|
||||
|
||||
Failed:
|
||||
✗ WP_500_84d47f — Connection timeout
|
||||
[Retry This Sensor]
|
||||
|
||||
[Done]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Possible Errors
|
||||
|
||||
| Error | Cause | User Message | Actions |
|
||||
|-------|-------|--------------|---------|
|
||||
| BLE Connect Failed | Sensor too far, off, or busy | "Could not connect. Move closer or check if sensor is on." | Retry, Skip |
|
||||
| PIN Unlock Failed | Wrong PIN (unlikely) | "Could not unlock sensor." | Retry, Skip |
|
||||
| WiFi Set Failed | Command timeout | "Could not configure WiFi." | Retry, Skip |
|
||||
| API Attach Failed | No internet, server down | "Could not register sensor. Check internet." | Retry, Skip |
|
||||
| Timeout | Sensor not responding | "Sensor not responding." | Retry, Skip |
|
||||
|
||||
### Error Recovery Options
|
||||
|
||||
When an error occurs on sensor N:
|
||||
|
||||
1. **Pause** the batch process
|
||||
2. **Show** error with options:
|
||||
- `[Retry]` — try this sensor again
|
||||
- `[Skip]` — move to next sensor
|
||||
- `[Cancel All]` — abort entire process
|
||||
3. **Continue** based on user choice
|
||||
|
||||
### All Sensors Failed
|
||||
|
||||
```
|
||||
Setup Failed
|
||||
|
||||
Could not connect any sensors.
|
||||
|
||||
Possible reasons:
|
||||
• Sensors are too far away
|
||||
• Sensors are not powered on
|
||||
• Bluetooth is disabled
|
||||
|
||||
[Try Again] [Cancel]
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
For debugging, log all operations:
|
||||
|
||||
```
|
||||
[BLE] Starting batch setup for 5 sensors
|
||||
[BLE] [1/5] WP_497 — connecting...
|
||||
[BLE] [1/5] WP_497 — connected (took 1.2s)
|
||||
[BLE] [1/5] WP_497 — sending PIN...
|
||||
[BLE] [1/5] WP_497 — unlocked
|
||||
[BLE] [1/5] WP_497 — setting WiFi "Home"...
|
||||
[BLE] [1/5] WP_497 — WiFi configured
|
||||
[API] [1/5] WP_497 — attaching to deployment 22...
|
||||
[API] [1/5] WP_497 — attached (device_id: 1)
|
||||
[BLE] [1/5] WP_497 — rebooting...
|
||||
[BLE] [1/5] WP_497 — SUCCESS (total: 8.5s)
|
||||
|
||||
[BLE] [2/5] WP_498 — connecting...
|
||||
[BLE] [2/5] WP_498 — ERROR: connection timeout after 5s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sensor Activity & Status
|
||||
|
||||
### Status Calculation
|
||||
|
||||
Sensor status is calculated based on `lastSeen` timestamp from Legacy API:
|
||||
|
||||
| Time since last signal | Status | Color | Meaning |
|
||||
|------------------------|--------|-------|---------|
|
||||
| < 5 minutes | online | Green | Sensor is working normally |
|
||||
| 5-60 minutes | warning | Yellow | Possible issue, check sensor |
|
||||
| > 60 minutes | offline | Red | Sensor is not sending data |
|
||||
|
||||
### Activity Display in UI
|
||||
|
||||
**Equipment Screen shows for each sensor:**
|
||||
- Sensor name (WP_497_81a14c)
|
||||
- Status badge (Online/Warning/Offline)
|
||||
- Last activity time ("5 min ago", "2 hours ago")
|
||||
- Location label (if set)
|
||||
|
||||
### Detailed WiFi Status (via BLE)
|
||||
|
||||
When user opens Device Settings for an ONLINE sensor, app can:
|
||||
|
||||
1. Connect to sensor via BLE
|
||||
2. Send command `a` (Get WiFi Status)
|
||||
3. Display:
|
||||
- WiFi network name
|
||||
- Signal strength (RSSI in dBm or as Excellent/Good/Fair/Weak)
|
||||
- Connection status
|
||||
|
||||
**WiFi Signal Strength Interpretation:**
|
||||
|
||||
| RSSI | Quality |
|
||||
|------|---------|
|
||||
| -50 or better | Excellent |
|
||||
| -50 to -60 | Good |
|
||||
| -60 to -70 | Fair |
|
||||
| -70 or worse | Weak |
|
||||
|
||||
This helps diagnose connectivity issues — if sensor goes offline, user can check if WiFi signal is weak.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Q1: Multiple Beneficiaries Context
|
||||
|
||||
**Question:** User has 5 beneficiaries. When scanning BLE, sensors from different locations might appear. How to handle?
|
||||
|
||||
**Decision:** Trust the user. They select beneficiary first, then add sensors. If they make a mistake, they can detach and reattach to correct beneficiary. No confirmation needed.
|
||||
|
||||
### Q2: WiFi Configuration Timing
|
||||
|
||||
**Question:** When should user set sensor location label?
|
||||
|
||||
**Options:**
|
||||
- A) During initial setup (add step after WiFi)
|
||||
- B) Later, in Device Settings screen
|
||||
- C) Both (optional during setup, editable later)
|
||||
|
||||
### Q3: Background Processing
|
||||
|
||||
**Question:** If user closes app during batch setup, what happens?
|
||||
|
||||
**Options:**
|
||||
- A) Continue in background (complex)
|
||||
- B) Cancel everything (simple, but frustrating)
|
||||
- C) Pause and resume when app reopens
|
||||
|
||||
### Q4: Sensor Already Attached
|
||||
|
||||
**Question:** What if scanned sensor is already attached to ANOTHER beneficiary (different deployment)?
|
||||
|
||||
**Options:**
|
||||
- A) Show error: "This sensor is already in use"
|
||||
- B) Offer to move it: "Move to Maria's home?"
|
||||
- C) Just attach it (will detach from previous)
|
||||
|
||||
---
|
||||
|
||||
## File References
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `services/ble/BLEManager.ts` | BLE operations |
|
||||
| `services/ble/types.ts` | BLE types |
|
||||
| `services/ble/MockBLEManager.ts` | Mock for simulator |
|
||||
| `contexts/BLEContext.tsx` | BLE React context |
|
||||
| `services/api.ts` | API methods |
|
||||
| `types/index.ts` | WPSensor type (lines 47-59) |
|
||||
| `app/(tabs)/beneficiaries/[id]/equipment.tsx` | Equipment screen |
|
||||
| `app/(tabs)/beneficiaries/[id]/add-sensor.tsx` | Add sensor screen |
|
||||
| `app/(tabs)/beneficiaries/[id]/setup-wifi.tsx` | WiFi setup screen |
|
||||
| `app/(tabs)/beneficiaries/[id]/device-settings/[deviceId].tsx` | Device settings |
|
||||
|
||||
---
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Changes |
|
||||
|------|--------|---------|
|
||||
| 2026-01-19 | Claude | Initial documentation |
|
||||
150
services/api.ts
150
services/api.ts
@ -48,6 +48,10 @@ function formatTimeAgo(date: Date): string {
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
// API URLs as instance properties for consistency
|
||||
private readonly baseUrl = WELLNUO_API_URL;
|
||||
private readonly legacyApiUrl = API_BASE_URL;
|
||||
|
||||
// Public method to get the access token (used by AuthContext)
|
||||
async getToken(): Promise<string | null> {
|
||||
try {
|
||||
@ -1535,6 +1539,16 @@ class ApiService {
|
||||
return this.DEMO_DEPLOYMENT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Legacy API credentials for device operations
|
||||
* Uses the same credentials as getLegacyWebViewCredentials but returns only what's needed
|
||||
*/
|
||||
async getLegacyCredentials(): Promise<{ userName: string; token: string } | null> {
|
||||
const creds = await this.getLegacyWebViewCredentials();
|
||||
if (!creds) return null;
|
||||
return { userName: creds.userName, token: creds.token };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WP SENSORS / DEVICES MANAGEMENT
|
||||
// ============================================================================
|
||||
@ -1545,8 +1559,17 @@ class ApiService {
|
||||
*/
|
||||
async getDevicesForBeneficiary(beneficiaryId: string) {
|
||||
try {
|
||||
// Get auth token for WellNuo API
|
||||
const token = await this.getToken();
|
||||
if (!token) return { ok: false, error: 'Not authenticated' };
|
||||
|
||||
// Get beneficiary's deployment_id from PostgreSQL
|
||||
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`);
|
||||
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to get beneficiary');
|
||||
|
||||
const beneficiary = await response.json();
|
||||
@ -1679,8 +1702,17 @@ class ApiService {
|
||||
password: string
|
||||
) {
|
||||
try {
|
||||
// Get auth token for WellNuo API
|
||||
const token = await this.getToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
// Get beneficiary details
|
||||
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`);
|
||||
const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to get beneficiary');
|
||||
|
||||
const beneficiary = await response.json();
|
||||
@ -1727,6 +1759,120 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update device metadata (location, description) in Legacy API
|
||||
* Uses device_form endpoint
|
||||
*/
|
||||
async updateDeviceMetadata(
|
||||
deviceId: string,
|
||||
updates: {
|
||||
location?: string;
|
||||
description?: string;
|
||||
}
|
||||
): Promise<ApiResponse<{ success: boolean }>> {
|
||||
try {
|
||||
const creds = await this.getLegacyWebViewCredentials();
|
||||
if (!creds) {
|
||||
return { ok: false, error: { message: 'Not authenticated with Legacy API', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
function: 'device_form',
|
||||
user_name: creds.userName,
|
||||
token: creds.token,
|
||||
device_id: deviceId,
|
||||
});
|
||||
|
||||
// Add optional fields if provided
|
||||
if (updates.location !== undefined) {
|
||||
formData.append('location', updates.location);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
formData.append('description', updates.description);
|
||||
}
|
||||
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: { message: 'Failed to update device' } };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== '200 OK') {
|
||||
return { ok: false, error: { message: data.message || 'Failed to update device' } };
|
||||
}
|
||||
|
||||
return { ok: true, data: { success: true } };
|
||||
} catch (error: any) {
|
||||
console.error('[API] updateDeviceMetadata error:', error);
|
||||
return { ok: false, error: { message: error.message || 'Network error', code: 'NETWORK_ERROR' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach device to deployment via Legacy API
|
||||
* Uses set_deployment endpoint to link a WP sensor to a beneficiary's deployment
|
||||
*
|
||||
* @param deploymentId - The deployment ID to attach the device to
|
||||
* @param wellId - The device's well_id (from BLE scan, e.g., 497)
|
||||
* @param ssid - WiFi network SSID
|
||||
* @param password - WiFi network password
|
||||
*/
|
||||
async attachDeviceToDeployment(
|
||||
deploymentId: number,
|
||||
wellId: number,
|
||||
ssid: string,
|
||||
password: string
|
||||
): Promise<ApiResponse<{ success: boolean }>> {
|
||||
try {
|
||||
const creds = await this.getLegacyWebViewCredentials();
|
||||
if (!creds) {
|
||||
return { ok: false, error: { message: 'Not authenticated with Legacy API', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
// Call set_deployment to attach device
|
||||
const formData = new URLSearchParams({
|
||||
function: 'set_deployment',
|
||||
user_name: creds.userName,
|
||||
token: creds.token,
|
||||
deployment: deploymentId.toString(),
|
||||
devices: JSON.stringify([wellId]),
|
||||
wifis: JSON.stringify([`${ssid}|${password}`]),
|
||||
reuse_existing_devices: '1',
|
||||
});
|
||||
|
||||
console.log('[API] attachDeviceToDeployment: deployment=', deploymentId, 'wellId=', wellId, 'ssid=', ssid);
|
||||
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: { message: 'Failed to attach device to deployment' } };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== '200 OK') {
|
||||
console.error('[API] attachDeviceToDeployment failed:', data);
|
||||
return { ok: false, error: { message: data.message || 'Failed to attach device' } };
|
||||
}
|
||||
|
||||
console.log('[API] attachDeviceToDeployment success');
|
||||
return { ok: true, data: { success: true } };
|
||||
} catch (error: any) {
|
||||
console.error('[API] attachDeviceToDeployment error:', error);
|
||||
return { ok: false, error: { message: error.message || 'Network error', code: 'NETWORK_ERROR' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach device from beneficiary
|
||||
*/
|
||||
|
||||
@ -67,6 +67,18 @@ export type EquipmentStatus =
|
||||
| 'active' // Equipment activated and working
|
||||
| 'demo'; // Demo mode (DEMO-00000)
|
||||
|
||||
// Deployment (location where beneficiary can be monitored)
|
||||
export interface Deployment {
|
||||
id: number;
|
||||
beneficiary_id: number;
|
||||
name: string; // e.g., "Home", "Office", "Vacation Home"
|
||||
address?: string;
|
||||
is_primary: boolean; // One deployment per beneficiary is primary
|
||||
legacy_deployment_id?: number; // Link to Legacy API deployment
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Beneficiary Types (elderly people being monitored)
|
||||
export interface Beneficiary {
|
||||
id: number;
|
||||
@ -193,3 +205,48 @@ export interface ApiResponse<T> {
|
||||
error?: ApiError;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
// Batch Sensor Setup Types
|
||||
|
||||
/** States a sensor can be in during batch setup */
|
||||
export type SensorSetupStatus =
|
||||
| 'pending' // Waiting in queue
|
||||
| 'connecting' // BLE connection in progress
|
||||
| 'unlocking' // Sending PIN command
|
||||
| 'setting_wifi' // Configuring WiFi
|
||||
| 'attaching' // Calling Legacy API to link to deployment
|
||||
| 'rebooting' // Restarting sensor
|
||||
| 'success' // Completed successfully
|
||||
| 'error' // Failed (with error message)
|
||||
| 'skipped'; // User chose to skip after error
|
||||
|
||||
/** Step within a sensor's setup process */
|
||||
export interface SensorSetupStep {
|
||||
name: 'connect' | 'unlock' | 'wifi' | 'attach' | 'reboot';
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** State of a single sensor during batch setup */
|
||||
export interface SensorSetupState {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
wellId?: number;
|
||||
mac: string;
|
||||
status: SensorSetupStatus;
|
||||
steps: SensorSetupStep[];
|
||||
error?: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
/** Overall batch setup state */
|
||||
export interface BatchSetupState {
|
||||
sensors: SensorSetupState[];
|
||||
currentIndex: number;
|
||||
ssid: string;
|
||||
password: string;
|
||||
isPaused: boolean;
|
||||
isComplete: boolean;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user