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,
|
connectDevice,
|
||||||
} = useBLE();
|
} = useBLE();
|
||||||
|
|
||||||
const [selectedDevice, setSelectedDevice] = useState<WPDevice | null>(null);
|
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
|
||||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
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 () => {
|
const handleScan = async () => {
|
||||||
try {
|
try {
|
||||||
await scanDevices();
|
await scanDevices();
|
||||||
@ -51,36 +80,26 @@ export default function AddSensorScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConnect = async (device: WPDevice) => {
|
const handleAddSelected = () => {
|
||||||
setIsConnecting(true);
|
if (selectedCount === 0) {
|
||||||
setSelectedDevice(device);
|
Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const devices = foundDevices.filter(d => selectedDevices.has(d.id));
|
||||||
const success = await connectDevice(device.id);
|
|
||||||
|
|
||||||
if (success) {
|
// Navigate to Setup WiFi screen with selected devices
|
||||||
// Navigate to Setup WiFi screen
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
||||||
params: {
|
params: {
|
||||||
deviceId: device.id,
|
devices: JSON.stringify(devices.map(d => ({
|
||||||
deviceName: device.name,
|
id: d.id,
|
||||||
wellId: device.wellId?.toString() || '',
|
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) => {
|
const getSignalIcon = (rssi: number) => {
|
||||||
@ -172,28 +191,52 @@ export default function AddSensorScreen() {
|
|||||||
<>
|
<>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>Found Sensors ({foundDevices.length})</Text>
|
<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}>
|
<TouchableOpacity style={styles.rescanButton} onPress={handleScan}>
|
||||||
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
||||||
<Text style={styles.rescanText}>Rescan</Text>
|
<Text style={styles.rescanText}>Rescan</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.devicesList}>
|
<View style={styles.devicesList}>
|
||||||
{foundDevices.map((device) => {
|
{foundDevices.map((device) => {
|
||||||
const isConnected = connectedDevices.has(device.id);
|
const isConnected = connectedDevices.has(device.id);
|
||||||
const isConnectingThis = isConnecting && selectedDevice?.id === device.id;
|
const isSelected = selectedDevices.has(device.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={device.id}
|
key={device.id}
|
||||||
style={[
|
style={[
|
||||||
styles.deviceCard,
|
styles.deviceCard,
|
||||||
|
isSelected && styles.deviceCardSelected,
|
||||||
isConnected && styles.deviceCardConnected,
|
isConnected && styles.deviceCardConnected,
|
||||||
]}
|
]}
|
||||||
onPress={() => handleConnect(device)}
|
onPress={() => toggleDeviceSelection(device.id)}
|
||||||
disabled={isConnectingThis || isConnected}
|
disabled={isConnected}
|
||||||
activeOpacity={0.7}
|
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.deviceInfo}>
|
||||||
<View style={styles.deviceIcon}>
|
<View style={styles.deviceIcon}>
|
||||||
<Ionicons name="water" size={24} color={AppColors.primary} />
|
<Ionicons name="water" size={24} color={AppColors.primary} />
|
||||||
@ -216,19 +259,30 @@ export default function AddSensorScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isConnectingThis ? (
|
{isConnected && (
|
||||||
<ActivityIndicator size="small" color={AppColors.primary} />
|
<View style={styles.alreadyAddedBadge}>
|
||||||
) : isConnected ? (
|
<Text style={styles.alreadyAddedText}>Added</Text>
|
||||||
<View style={styles.connectedBadge}>
|
|
||||||
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</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',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
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: {
|
rescanButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -427,12 +496,37 @@ const styles = StyleSheet.create({
|
|||||||
padding: Spacing.md,
|
padding: Spacing.md,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
|
||||||
...Shadows.xs,
|
...Shadows.xs,
|
||||||
},
|
},
|
||||||
|
deviceCardSelected: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: AppColors.primary,
|
||||||
|
},
|
||||||
deviceCardConnected: {
|
deviceCardConnected: {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: AppColors.success,
|
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: {
|
deviceInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -471,8 +565,37 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.xs,
|
fontSize: FontSizes.xs,
|
||||||
fontWeight: FontWeights.medium,
|
fontWeight: FontWeights.medium,
|
||||||
},
|
},
|
||||||
connectedBadge: {
|
alreadyAddedBadge: {
|
||||||
padding: Spacing.xs,
|
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
|
// Empty State
|
||||||
emptyState: {
|
emptyState: {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Alert,
|
Alert,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@ -29,10 +30,12 @@ interface SensorInfo {
|
|||||||
wellId: number;
|
wellId: number;
|
||||||
mac: string;
|
mac: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'online' | 'offline';
|
status: 'online' | 'warning' | 'offline';
|
||||||
lastSeen: Date;
|
lastSeen: Date;
|
||||||
beneficiaryId: string;
|
beneficiaryId: string;
|
||||||
deploymentId: number;
|
deploymentId: number;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviceSettingsScreen() {
|
export default function DeviceSettingsScreen() {
|
||||||
@ -52,6 +55,11 @@ export default function DeviceSettingsScreen() {
|
|||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
|
const [isLoadingWiFi, setIsLoadingWiFi] = useState(false);
|
||||||
const [isRebooting, setIsRebooting] = 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!);
|
const isConnected = connectedDevices.has(deviceId!);
|
||||||
|
|
||||||
@ -66,14 +74,16 @@ export default function DeviceSettingsScreen() {
|
|||||||
// Get sensor info from API
|
// Get sensor info from API
|
||||||
const response = await api.getDevicesForBeneficiary(id!);
|
const response = await api.getDevicesForBeneficiary(id!);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok || !response.data) {
|
||||||
throw new Error('Failed to load sensor info');
|
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) {
|
if (sensor) {
|
||||||
setSensorInfo(sensor);
|
setSensorInfo(sensor);
|
||||||
|
setLocation(sensor.location || '');
|
||||||
|
setDescription(sensor.description || '');
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Sensor not found');
|
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 formatLastSeen = (lastSeen: Date): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - lastSeen.getTime();
|
const diffMs = now.getTime() - lastSeen.getTime();
|
||||||
@ -288,6 +347,55 @@ export default function DeviceSettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</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 */}
|
{/* BLE Connection Section */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
|
<Text style={styles.sectionTitle}>Bluetooth Connection</Text>
|
||||||
@ -557,6 +665,45 @@ const styles = StyleSheet.create({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: AppColors.border,
|
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
|
// BLE Connection
|
||||||
connectedCard: {
|
connectedCard: {
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
|
|||||||
@ -39,14 +39,13 @@ const sensorConfig = {
|
|||||||
export default function EquipmentScreen() {
|
export default function EquipmentScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { currentBeneficiary } = useBeneficiary();
|
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)
|
// Separate state for API sensors (attached) and BLE sensors (nearby)
|
||||||
const [apiSensors, setApiSensors] = useState<WPSensor[]>([]);
|
const [apiSensors, setApiSensors] = useState<WPSensor[]>([]);
|
||||||
const [bleSensors, setBleSensors] = useState<WPSensor[]>([]);
|
const [bleSensors, setBleSensors] = useState<WPSensor[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
|
||||||
const [isDetaching, setIsDetaching] = useState<string | null>(null);
|
const [isDetaching, setIsDetaching] = useState<string | null>(null);
|
||||||
|
|
||||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
||||||
@ -72,7 +71,7 @@ export default function EquipmentScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setApiSensors(response.data);
|
setApiSensors(response.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Equipment] Failed to load sensors:', error);
|
console.error('[Equipment] Failed to load sensors:', error);
|
||||||
// Show empty state instead of Alert
|
// Show empty state instead of Alert
|
||||||
@ -90,23 +89,31 @@ export default function EquipmentScreen() {
|
|||||||
|
|
||||||
// BLE Scan for nearby sensors
|
// BLE Scan for nearby sensors
|
||||||
const handleScanNearby = async () => {
|
const handleScanNearby = async () => {
|
||||||
if (isScanning) {
|
if (isBLEScanning) {
|
||||||
// Stop scan
|
// Stop scan
|
||||||
stopScan();
|
stopScan();
|
||||||
setIsScanning(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsScanning(true);
|
|
||||||
setBleSensors([]); // Clear previous results
|
setBleSensors([]); // Clear previous results
|
||||||
|
|
||||||
try {
|
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
|
// Convert BLE devices to WPSensor format
|
||||||
const nearbyWPSensors: WPSensor[] = devices
|
const nearbyWPSensors: WPSensor[] = foundDevices
|
||||||
.filter(d => d.name?.startsWith('WP_')) // Only WP sensors
|
.filter((d: { name?: string }) => d.name?.startsWith('WP_')) // Only WP sensors
|
||||||
.map(d => {
|
.map((d: { id: string; name?: string }) => {
|
||||||
// Parse WP_<wellId>_<mac> format
|
// Parse WP_<wellId>_<mac> format
|
||||||
const parts = d.name!.split('_');
|
const parts = d.name!.split('_');
|
||||||
const wellId = parseInt(parts[1], 10) || 0;
|
const wellId = parseInt(parts[1], 10) || 0;
|
||||||
@ -130,13 +137,7 @@ export default function EquipmentScreen() {
|
|||||||
const uniqueBleSensors = nearbyWPSensors.filter(s => !apiDeviceIds.has(s.mac));
|
const uniqueBleSensors = nearbyWPSensors.filter(s => !apiDeviceIds.has(s.mac));
|
||||||
|
|
||||||
setBleSensors(uniqueBleSensors);
|
setBleSensors(uniqueBleSensors);
|
||||||
} catch (error) {
|
}, [foundDevices, apiSensors, id]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle sensor click - show action sheet for offline, navigate to settings for online
|
// Handle sensor click - show action sheet for offline, navigate to settings for online
|
||||||
const handleSensorPress = (sensor: WPSensor) => {
|
const handleSensorPress = (sensor: WPSensor) => {
|
||||||
@ -451,12 +452,33 @@ export default function EquipmentScreen() {
|
|||||||
<Text style={styles.deviceMetaSeparator}>•</Text>
|
<Text style={styles.deviceMetaSeparator}>•</Text>
|
||||||
<Text style={styles.deviceRoom}>{formatLastSeen(sensor.lastSeen)}</Text>
|
<Text style={styles.deviceRoom}>{formatLastSeen(sensor.lastSeen)}</Text>
|
||||||
</View>
|
</View>
|
||||||
{sensor.location && (
|
<TouchableOpacity
|
||||||
<Text style={styles.deviceLocation}>{sensor.location}</Text>
|
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>
|
</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
|
<TouchableOpacity
|
||||||
style={styles.detachButton}
|
style={styles.detachButton}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
@ -471,6 +493,7 @@ export default function EquipmentScreen() {
|
|||||||
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
|
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -488,11 +511,10 @@ export default function EquipmentScreen() {
|
|||||||
|
|
||||||
{/* Scan Nearby Button */}
|
{/* Scan Nearby Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.scanButton, isScanning && styles.scanButtonActive]}
|
style={[styles.scanButton, isBLEScanning && styles.scanButtonActive]}
|
||||||
onPress={handleScanNearby}
|
onPress={handleScanNearby}
|
||||||
disabled={!isBLEAvailable}
|
|
||||||
>
|
>
|
||||||
{isScanning ? (
|
{isBLEScanning ? (
|
||||||
<>
|
<>
|
||||||
<ActivityIndicator size="small" color={AppColors.white} />
|
<ActivityIndicator size="small" color={AppColors.white} />
|
||||||
<Text style={styles.scanButtonText}>Scanning... ({bleSensors.length} found)</Text>
|
<Text style={styles.scanButtonText}>Scanning... ({bleSensors.length} found)</Text>
|
||||||
@ -717,6 +739,19 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.xs,
|
fontSize: FontSizes.xs,
|
||||||
color: AppColors.textMuted,
|
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: {
|
detachButton: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@ -849,7 +884,7 @@ const styles = StyleSheet.create({
|
|||||||
...Shadows.md,
|
...Shadows.md,
|
||||||
},
|
},
|
||||||
scanButtonActive: {
|
scanButtonActive: {
|
||||||
backgroundColor: AppColors.secondary,
|
backgroundColor: AppColors.primaryDark,
|
||||||
},
|
},
|
||||||
scanButtonText: {
|
scanButtonText: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
@ -867,4 +902,8 @@ const styles = StyleSheet.create({
|
|||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
marginTop: 2,
|
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 {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -16,6 +16,13 @@ import * as Device from 'expo-device';
|
|||||||
import { useBLE } from '@/contexts/BLEContext';
|
import { useBLE } from '@/contexts/BLEContext';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import type { WiFiNetwork } from '@/services/ble';
|
import type { WiFiNetwork } from '@/services/ble';
|
||||||
|
import type {
|
||||||
|
SensorSetupState,
|
||||||
|
SensorSetupStep,
|
||||||
|
SensorSetupStatus,
|
||||||
|
} from '@/types';
|
||||||
|
import BatchSetupProgress from '@/components/BatchSetupProgress';
|
||||||
|
import SetupResultsScreen from '@/components/SetupResultsScreen';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
@ -25,32 +32,95 @@ import {
|
|||||||
Shadows,
|
Shadows,
|
||||||
} from '@/constants/theme';
|
} from '@/constants/theme';
|
||||||
|
|
||||||
export default function SetupWiFiScreen() {
|
// Type for device passed via navigation params
|
||||||
const { id, deviceId, deviceName, wellId } = useLocalSearchParams<{
|
interface DeviceParam {
|
||||||
id: string;
|
id: string;
|
||||||
deviceId: string;
|
name: string;
|
||||||
deviceName: string;
|
mac: string;
|
||||||
wellId: 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 [networks, setNetworks] = useState<WiFiNetwork[]>([]);
|
||||||
const [isLoadingNetworks, setIsLoadingNetworks] = useState(false);
|
const [isLoadingNetworks, setIsLoadingNetworks] = useState(false);
|
||||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
|
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadWiFiNetworks();
|
loadWiFiNetworks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadWiFiNetworks = async () => {
|
const loadWiFiNetworks = async () => {
|
||||||
|
if (!deviceId) return;
|
||||||
setIsLoadingNetworks(true);
|
setIsLoadingNetworks(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wifiList = await getWiFiList(deviceId!);
|
const wifiList = await getWiFiList(deviceId);
|
||||||
setNetworks(wifiList);
|
setNetworks(wifiList);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[SetupWiFi] Failed to get WiFi list:', error);
|
console.error('[SetupWiFi] Failed to get WiFi list:', error);
|
||||||
@ -65,70 +135,319 @@ export default function SetupWiFiScreen() {
|
|||||||
setPassword('');
|
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) {
|
if (!selectedNetwork) {
|
||||||
Alert.alert('Error', 'Please select a WiFi network');
|
Alert.alert('Error', 'Please select a WiFi network');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
Alert.alert('Error', 'Please enter WiFi password');
|
Alert.alert('Error', 'Please enter WiFi password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsConnecting(true);
|
// Initialize sensor states
|
||||||
|
const initialStates = selectedDevices.map(createSensorState);
|
||||||
|
setSensors(initialStates);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setIsPaused(false);
|
||||||
|
setPhase('batch_setup');
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
// Start processing after phase change
|
||||||
// Step 1: Set WiFi on the device via BLE
|
useEffect(() => {
|
||||||
const success = await setWiFi(deviceId!, selectedNetwork.ssid, password);
|
if (phase === 'batch_setup' && sensors.length > 0 && !setupInProgressRef.current) {
|
||||||
|
runBatchSetup();
|
||||||
if (!success) {
|
|
||||||
throw new Error('Failed to configure WiFi on sensor');
|
|
||||||
}
|
}
|
||||||
|
}, [phase, sensors.length, runBatchSetup]);
|
||||||
|
|
||||||
// Step 2: Attach device to beneficiary via API (skip in simulator/mock mode)
|
// Retry failed sensor
|
||||||
const isSimulator = !Device.isDevice;
|
const handleRetry = (deviceId: string) => {
|
||||||
|
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
||||||
if (!isSimulator) {
|
if (index >= 0) {
|
||||||
const attachResponse = await api.attachDeviceToBeneficiary(
|
setSensors(prev => prev.map(s =>
|
||||||
id!,
|
s.deviceId === deviceId
|
||||||
parseInt(wellId!, 10),
|
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
||||||
selectedNetwork.ssid,
|
: s
|
||||||
password
|
));
|
||||||
);
|
setCurrentIndex(index);
|
||||||
|
setIsPaused(false);
|
||||||
if (!attachResponse.ok) {
|
runBatchSetup();
|
||||||
throw new Error('Failed to attach sensor to beneficiary');
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
console.log('[SetupWiFi] Simulator mode - skipping API attach');
|
setPhase('results');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Step 3: Disconnect BLE connection (sensor will reboot and connect to WiFi)
|
// Cancel all
|
||||||
await disconnectDevice(deviceId!);
|
const handleCancelAll = () => {
|
||||||
|
|
||||||
// Success!
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Success!',
|
'Cancel Setup',
|
||||||
`${deviceName} has been configured and attached.\n\nThe sensor will now reboot and connect to "${selectedNetwork.ssid}". This may take a minute.`,
|
'Are you sure you want to cancel? Progress will be lost.',
|
||||||
[
|
[
|
||||||
|
{ text: 'Continue Setup', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Done',
|
text: 'Cancel',
|
||||||
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
// Navigate back to Equipment screen
|
shouldCancelRef.current = true;
|
||||||
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
|
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(
|
// Done - navigate back
|
||||||
'Connection Failed',
|
const handleDone = () => {
|
||||||
error.message || 'Failed to configure WiFi. Please check the password and try again.'
|
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
|
||||||
);
|
};
|
||||||
} finally {
|
|
||||||
setIsConnecting(false);
|
// 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';
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -160,8 +516,7 @@ export default function SetupWiFiScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// Disconnect BLE before going back
|
selectedDevices.forEach(d => disconnectDevice(d.id));
|
||||||
disconnectDevice(deviceId!);
|
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -178,15 +533,28 @@ export default function SetupWiFiScreen() {
|
|||||||
<Ionicons name="water" size={32} color={AppColors.primary} />
|
<Ionicons name="water" size={32} color={AppColors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.deviceInfo}>
|
<View style={styles.deviceInfo}>
|
||||||
<Text style={styles.deviceName}>{deviceName}</Text>
|
{selectedDevices.length === 1 ? (
|
||||||
<Text style={styles.deviceMeta}>Well ID: {wellId}</Text>
|
<>
|
||||||
|
<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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<View style={styles.instructionsCard}>
|
<View style={styles.instructionsCard}>
|
||||||
<Text style={styles.instructionsText}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -282,22 +650,17 @@ export default function SetupWiFiScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.connectButton,
|
styles.connectButton,
|
||||||
(!password || isConnecting) && styles.connectButtonDisabled,
|
!password && styles.connectButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleConnect}
|
onPress={handleStartBatchSetup}
|
||||||
disabled={!password || isConnecting}
|
disabled={!password}
|
||||||
>
|
>
|
||||||
{isConnecting ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator size="small" color={AppColors.white} />
|
|
||||||
<Text style={styles.connectButtonText}>Connecting...</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons name="checkmark" size={20} color={AppColors.white} />
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -352,6 +715,10 @@ const styles = StyleSheet.create({
|
|||||||
padding: Spacing.lg,
|
padding: Spacing.lg,
|
||||||
paddingBottom: Spacing.xxl,
|
paddingBottom: Spacing.xxl,
|
||||||
},
|
},
|
||||||
|
batchContent: {
|
||||||
|
flex: 1,
|
||||||
|
padding: Spacing.lg,
|
||||||
|
},
|
||||||
// Device Card
|
// Device Card
|
||||||
deviceCard: {
|
deviceCard: {
|
||||||
flexDirection: 'row',
|
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 {
|
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)
|
// Public method to get the access token (used by AuthContext)
|
||||||
async getToken(): Promise<string | null> {
|
async getToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@ -1535,6 +1539,16 @@ class ApiService {
|
|||||||
return this.DEMO_DEPLOYMENT_ID;
|
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
|
// WP SENSORS / DEVICES MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -1545,8 +1559,17 @@ class ApiService {
|
|||||||
*/
|
*/
|
||||||
async getDevicesForBeneficiary(beneficiaryId: string) {
|
async getDevicesForBeneficiary(beneficiaryId: string) {
|
||||||
try {
|
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
|
// 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');
|
if (!response.ok) throw new Error('Failed to get beneficiary');
|
||||||
|
|
||||||
const beneficiary = await response.json();
|
const beneficiary = await response.json();
|
||||||
@ -1679,8 +1702,17 @@ class ApiService {
|
|||||||
password: string
|
password: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
// Get auth token for WellNuo API
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
|
||||||
// Get beneficiary details
|
// 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');
|
if (!response.ok) throw new Error('Failed to get beneficiary');
|
||||||
|
|
||||||
const beneficiary = await response.json();
|
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
|
* Detach device from beneficiary
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -67,6 +67,18 @@ export type EquipmentStatus =
|
|||||||
| 'active' // Equipment activated and working
|
| 'active' // Equipment activated and working
|
||||||
| 'demo'; // Demo mode (DEMO-00000)
|
| '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)
|
// Beneficiary Types (elderly people being monitored)
|
||||||
export interface Beneficiary {
|
export interface Beneficiary {
|
||||||
id: number;
|
id: number;
|
||||||
@ -193,3 +205,48 @@ export interface ApiResponse<T> {
|
|||||||
error?: ApiError;
|
error?: ApiError;
|
||||||
ok: boolean;
|
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