Compare commits
No commits in common. "9f9124fdabdb2c2ef5d73c4d4bd7e334fc61218a" and "6046655c10411fec709540f3999febac6b5d57f8" have entirely different histories.
9f9124fdab
...
6046655c10
@ -1,17 +0,0 @@
|
|||||||
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: []
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# 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
355
PRD-SENSORS.md
@ -1,355 +0,0 @@
|
|||||||
# 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,40 +37,11 @@ export default function AddSensorScreen() {
|
|||||||
connectDevice,
|
connectDevice,
|
||||||
} = useBLE();
|
} = useBLE();
|
||||||
|
|
||||||
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
|
const [selectedDevice, setSelectedDevice] = useState<WPDevice | null>(null);
|
||||||
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();
|
||||||
@ -80,26 +51,36 @@ export default function AddSensorScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSelected = () => {
|
const handleConnect = async (device: WPDevice) => {
|
||||||
if (selectedCount === 0) {
|
setIsConnecting(true);
|
||||||
Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.');
|
setSelectedDevice(device);
|
||||||
return;
|
|
||||||
|
try {
|
||||||
|
const success = await connectDevice(device.id);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Navigate to Setup WiFi screen
|
||||||
|
router.push({
|
||||||
|
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
||||||
|
params: {
|
||||||
|
deviceId: device.id,
|
||||||
|
deviceName: device.name,
|
||||||
|
wellId: device.wellId?.toString() || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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 devices = foundDevices.filter(d => selectedDevices.has(d.id));
|
|
||||||
|
|
||||||
// Navigate to Setup WiFi screen with selected devices
|
|
||||||
router.push({
|
|
||||||
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
|
|
||||||
params: {
|
|
||||||
devices: JSON.stringify(devices.map(d => ({
|
|
||||||
id: d.id,
|
|
||||||
name: d.name,
|
|
||||||
mac: d.mac,
|
|
||||||
wellId: d.wellId,
|
|
||||||
}))),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSignalIcon = (rssi: number) => {
|
const getSignalIcon = (rssi: number) => {
|
||||||
@ -191,52 +172,28 @@ 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.rescanButton} onPress={handleScan}>
|
||||||
<TouchableOpacity style={styles.selectAllButton} onPress={toggleSelectAll}>
|
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
||||||
<Ionicons
|
<Text style={styles.rescanText}>Rescan</Text>
|
||||||
name={selectedDevices.size === foundDevices.length ? 'checkbox' : 'square-outline'}
|
</TouchableOpacity>
|
||||||
size={18}
|
|
||||||
color={AppColors.primary}
|
|
||||||
/>
|
|
||||||
<Text style={styles.selectAllText}>
|
|
||||||
{selectedDevices.size === foundDevices.length ? 'Deselect All' : 'Select All'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.rescanButton} onPress={handleScan}>
|
|
||||||
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
|
||||||
<Text style={styles.rescanText}>Rescan</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<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 isSelected = selectedDevices.has(device.id);
|
const isConnectingThis = isConnecting && selectedDevice?.id === 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={() => toggleDeviceSelection(device.id)}
|
onPress={() => handleConnect(device)}
|
||||||
disabled={isConnected}
|
disabled={isConnectingThis || 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} />
|
||||||
@ -259,30 +216,19 @@ export default function AddSensorScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isConnected && (
|
{isConnectingThis ? (
|
||||||
<View style={styles.alreadyAddedBadge}>
|
<ActivityIndicator size="small" color={AppColors.primary} />
|
||||||
<Text style={styles.alreadyAddedText}>Added</Text>
|
) : isConnected ? (
|
||||||
|
<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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -460,21 +406,6 @@ 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',
|
||||||
@ -496,37 +427,12 @@ 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,
|
||||||
@ -565,37 +471,8 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.xs,
|
fontSize: FontSizes.xs,
|
||||||
fontWeight: FontWeights.medium,
|
fontWeight: FontWeights.medium,
|
||||||
},
|
},
|
||||||
alreadyAddedBadge: {
|
connectedBadge: {
|
||||||
backgroundColor: AppColors.successLight,
|
padding: Spacing.xs,
|
||||||
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,7 +7,6 @@ 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';
|
||||||
@ -30,12 +29,10 @@ interface SensorInfo {
|
|||||||
wellId: number;
|
wellId: number;
|
||||||
mac: string;
|
mac: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'online' | 'warning' | 'offline';
|
status: 'online' | '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() {
|
||||||
@ -55,11 +52,6 @@ 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!);
|
||||||
|
|
||||||
@ -74,16 +66,14 @@ 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 || !response.data) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load sensor info');
|
throw new Error('Failed to load sensor info');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sensor = response.data.find((s: SensorInfo) => s.deviceId === deviceId);
|
const sensor = response.data.find((s: any) => 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');
|
||||||
}
|
}
|
||||||
@ -184,55 +174,6 @@ 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();
|
||||||
@ -347,55 +288,6 @@ 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>
|
||||||
@ -665,45 +557,6 @@ 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,13 +39,14 @@ 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, scanDevices, stopScan, foundDevices, isScanning: isBLEScanning } = useBLE();
|
const { isBLEAvailable, scanForDevices, stopScan } = 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';
|
||||||
@ -71,7 +72,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
|
||||||
@ -89,56 +90,54 @@ export default function EquipmentScreen() {
|
|||||||
|
|
||||||
// BLE Scan for nearby sensors
|
// BLE Scan for nearby sensors
|
||||||
const handleScanNearby = async () => {
|
const handleScanNearby = async () => {
|
||||||
if (isBLEScanning) {
|
if (isScanning) {
|
||||||
// Stop scan
|
// Stop scan
|
||||||
stopScan();
|
stopScan();
|
||||||
|
setIsScanning(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsScanning(true);
|
||||||
setBleSensors([]); // Clear previous results
|
setBleSensors([]); // Clear previous results
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await scanDevices();
|
const devices = await scanForDevices(10000); // 10 second scan
|
||||||
// foundDevices will be updated by BLEContext
|
|
||||||
|
// Convert BLE devices to WPSensor format
|
||||||
|
const nearbyWPSensors: WPSensor[] = devices
|
||||||
|
.filter(d => d.name?.startsWith('WP_')) // Only WP sensors
|
||||||
|
.map(d => {
|
||||||
|
// Parse WP_<wellId>_<mac> format
|
||||||
|
const parts = d.name!.split('_');
|
||||||
|
const wellId = parseInt(parts[1], 10) || 0;
|
||||||
|
const mac = parts[2] || d.id.slice(-6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: d.id,
|
||||||
|
wellId: wellId,
|
||||||
|
mac: mac,
|
||||||
|
name: d.name!,
|
||||||
|
status: 'offline' as const, // Nearby but not attached
|
||||||
|
lastSeen: new Date(),
|
||||||
|
beneficiaryId: id!,
|
||||||
|
deploymentId: 0, // Not attached yet
|
||||||
|
source: 'ble' as const, // From BLE scan
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out sensors that are already in API list
|
||||||
|
const apiDeviceIds = new Set(apiSensors.map(s => s.mac));
|
||||||
|
const uniqueBleSensors = nearbyWPSensors.filter(s => !apiDeviceIds.has(s.mac));
|
||||||
|
|
||||||
|
setBleSensors(uniqueBleSensors);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Equipment] BLE scan failed:', error);
|
console.error('[Equipment] BLE scan failed:', error);
|
||||||
Alert.alert('Scan Failed', 'Could not scan for nearby sensors. Make sure Bluetooth is enabled.');
|
Alert.alert('Scan Failed', 'Could not scan for nearby sensors. Make sure Bluetooth is enabled.');
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Effect to convert BLE foundDevices to WPSensor format
|
|
||||||
useEffect(() => {
|
|
||||||
if (foundDevices.length === 0) return;
|
|
||||||
|
|
||||||
// Convert BLE devices to WPSensor format
|
|
||||||
const nearbyWPSensors: WPSensor[] = foundDevices
|
|
||||||
.filter((d: { name?: string }) => d.name?.startsWith('WP_')) // Only WP sensors
|
|
||||||
.map((d: { id: string; name?: string }) => {
|
|
||||||
// Parse WP_<wellId>_<mac> format
|
|
||||||
const parts = d.name!.split('_');
|
|
||||||
const wellId = parseInt(parts[1], 10) || 0;
|
|
||||||
const mac = parts[2] || d.id.slice(-6);
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId: d.id,
|
|
||||||
wellId: wellId,
|
|
||||||
mac: mac,
|
|
||||||
name: d.name!,
|
|
||||||
status: 'offline' as const, // Nearby but not attached
|
|
||||||
lastSeen: new Date(),
|
|
||||||
beneficiaryId: id!,
|
|
||||||
deploymentId: 0, // Not attached yet
|
|
||||||
source: 'ble' as const, // From BLE scan
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter out sensors that are already in API list
|
|
||||||
const apiDeviceIds = new Set(apiSensors.map(s => s.mac));
|
|
||||||
const uniqueBleSensors = nearbyWPSensors.filter(s => !apiDeviceIds.has(s.mac));
|
|
||||||
|
|
||||||
setBleSensors(uniqueBleSensors);
|
|
||||||
}, [foundDevices, apiSensors, id]);
|
|
||||||
|
|
||||||
// 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) => {
|
||||||
// For offline API sensors - show reconnect options
|
// For offline API sensors - show reconnect options
|
||||||
@ -452,48 +451,26 @@ 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>
|
||||||
<TouchableOpacity
|
{sensor.location && (
|
||||||
onPress={(e) => {
|
<Text style={styles.deviceLocation}>{sensor.location}</Text>
|
||||||
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
|
||||||
<TouchableOpacity
|
style={styles.detachButton}
|
||||||
style={styles.settingsButton}
|
onPress={(e) => {
|
||||||
onPress={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
handleDetachDevice(sensor);
|
||||||
handleDeviceSettings(sensor);
|
}}
|
||||||
}}
|
disabled={isDetachingThis}
|
||||||
>
|
>
|
||||||
<Ionicons name="settings-outline" size={20} color={AppColors.primary} />
|
{isDetachingThis ? (
|
||||||
</TouchableOpacity>
|
<ActivityIndicator size="small" color={AppColors.error} />
|
||||||
<TouchableOpacity
|
) : (
|
||||||
style={styles.detachButton}
|
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
|
||||||
onPress={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
</TouchableOpacity>
|
||||||
handleDetachDevice(sensor);
|
|
||||||
}}
|
|
||||||
disabled={isDetachingThis}
|
|
||||||
>
|
|
||||||
{isDetachingThis ? (
|
|
||||||
<ActivityIndicator size="small" color={AppColors.error} />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="unlink-outline" size={20} color={AppColors.error} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -511,10 +488,11 @@ export default function EquipmentScreen() {
|
|||||||
|
|
||||||
{/* Scan Nearby Button */}
|
{/* Scan Nearby Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.scanButton, isBLEScanning && styles.scanButtonActive]}
|
style={[styles.scanButton, isScanning && styles.scanButtonActive]}
|
||||||
onPress={handleScanNearby}
|
onPress={handleScanNearby}
|
||||||
|
disabled={!isBLEAvailable}
|
||||||
>
|
>
|
||||||
{isBLEScanning ? (
|
{isScanning ? (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
@ -739,19 +717,6 @@ 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,
|
||||||
@ -884,7 +849,7 @@ const styles = StyleSheet.create({
|
|||||||
...Shadows.md,
|
...Shadows.md,
|
||||||
},
|
},
|
||||||
scanButtonActive: {
|
scanButtonActive: {
|
||||||
backgroundColor: AppColors.primaryDark,
|
backgroundColor: AppColors.secondary,
|
||||||
},
|
},
|
||||||
scanButtonText: {
|
scanButtonText: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
@ -902,8 +867,4 @@ 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, useCallback, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -16,13 +16,6 @@ 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,
|
||||||
@ -32,95 +25,32 @@ import {
|
|||||||
Shadows,
|
Shadows,
|
||||||
} from '@/constants/theme';
|
} from '@/constants/theme';
|
||||||
|
|
||||||
// Type for device passed via navigation params
|
|
||||||
interface DeviceParam {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mac: string;
|
|
||||||
wellId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SetupPhase = 'wifi_selection' | 'batch_setup' | 'results';
|
|
||||||
|
|
||||||
// Initialize steps for a sensor
|
|
||||||
function createInitialSteps(): SensorSetupStep[] {
|
|
||||||
return [
|
|
||||||
{ name: 'connect', status: 'pending' },
|
|
||||||
{ name: 'unlock', status: 'pending' },
|
|
||||||
{ name: 'wifi', status: 'pending' },
|
|
||||||
{ name: 'attach', status: 'pending' },
|
|
||||||
{ name: 'reboot', status: 'pending' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize sensor state
|
|
||||||
function createSensorState(device: DeviceParam): SensorSetupState {
|
|
||||||
return {
|
|
||||||
deviceId: device.id,
|
|
||||||
deviceName: device.name,
|
|
||||||
wellId: device.wellId,
|
|
||||||
mac: device.mac,
|
|
||||||
status: 'pending',
|
|
||||||
steps: createInitialSteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SetupWiFiScreen() {
|
export default function SetupWiFiScreen() {
|
||||||
const { id, devices: devicesParam } = useLocalSearchParams<{
|
const { id, deviceId, deviceName, wellId } = useLocalSearchParams<{
|
||||||
id: string;
|
id: string;
|
||||||
devices: string; // JSON string of DeviceParam[]
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
wellId: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const {
|
const { getWiFiList, setWiFi, disconnectDevice } = useBLE();
|
||||||
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);
|
||||||
@ -135,319 +65,70 @@ export default function SetupWiFiScreen() {
|
|||||||
setPassword('');
|
setPassword('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update a specific step for a sensor
|
const handleConnect = async () => {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sensor states
|
setIsConnecting(true);
|
||||||
const initialStates = selectedDevices.map(createSensorState);
|
|
||||||
setSensors(initialStates);
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setIsPaused(false);
|
|
||||||
setPhase('batch_setup');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start processing after phase change
|
try {
|
||||||
useEffect(() => {
|
// Step 1: Set WiFi on the device via BLE
|
||||||
if (phase === 'batch_setup' && sensors.length > 0 && !setupInProgressRef.current) {
|
const success = await setWiFi(deviceId!, selectedNetwork.ssid, password);
|
||||||
runBatchSetup();
|
|
||||||
}
|
|
||||||
}, [phase, sensors.length, runBatchSetup]);
|
|
||||||
|
|
||||||
// Retry failed sensor
|
if (!success) {
|
||||||
const handleRetry = (deviceId: string) => {
|
throw new Error('Failed to configure WiFi on sensor');
|
||||||
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
}
|
||||||
if (index >= 0) {
|
|
||||||
setSensors(prev => prev.map(s =>
|
|
||||||
s.deviceId === deviceId
|
|
||||||
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
|
||||||
: s
|
|
||||||
));
|
|
||||||
setCurrentIndex(index);
|
|
||||||
setIsPaused(false);
|
|
||||||
runBatchSetup();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip failed sensor
|
// Step 2: Attach device to beneficiary via API (skip in simulator/mock mode)
|
||||||
const handleSkip = (deviceId: string) => {
|
const isSimulator = !Device.isDevice;
|
||||||
setSensors(prev => prev.map(s =>
|
|
||||||
s.deviceId === deviceId
|
|
||||||
? { ...s, status: 'skipped' as SensorSetupStatus }
|
|
||||||
: s
|
|
||||||
));
|
|
||||||
setIsPaused(false);
|
|
||||||
|
|
||||||
// Move to next sensor
|
if (!isSimulator) {
|
||||||
const nextIndex = currentIndex + 1;
|
const attachResponse = await api.attachDeviceToBeneficiary(
|
||||||
if (nextIndex < sensors.length) {
|
id!,
|
||||||
setCurrentIndex(nextIndex);
|
parseInt(wellId!, 10),
|
||||||
runBatchSetup();
|
selectedNetwork.ssid,
|
||||||
} else {
|
password
|
||||||
setPhase('results');
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cancel all
|
if (!attachResponse.ok) {
|
||||||
const handleCancelAll = () => {
|
throw new Error('Failed to attach sensor to beneficiary');
|
||||||
Alert.alert(
|
}
|
||||||
'Cancel Setup',
|
} else {
|
||||||
'Are you sure you want to cancel? Progress will be lost.',
|
console.log('[SetupWiFi] Simulator mode - skipping API attach');
|
||||||
[
|
}
|
||||||
{ text: 'Continue Setup', style: 'cancel' },
|
|
||||||
{
|
// Step 3: Disconnect BLE connection (sensor will reboot and connect to WiFi)
|
||||||
text: 'Cancel',
|
await disconnectDevice(deviceId!);
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => {
|
// Success!
|
||||||
shouldCancelRef.current = true;
|
Alert.alert(
|
||||||
setupInProgressRef.current = false;
|
'Success!',
|
||||||
// Disconnect all devices
|
`${deviceName} has been configured and attached.\n\nThe sensor will now reboot and connect to "${selectedNetwork.ssid}". This may take a minute.`,
|
||||||
selectedDevices.forEach(d => disconnectDevice(d.id));
|
[
|
||||||
router.back();
|
{
|
||||||
|
text: 'Done',
|
||||||
|
onPress: () => {
|
||||||
|
// Navigate back to Equipment screen
|
||||||
|
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]
|
);
|
||||||
);
|
} 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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -472,43 +153,6 @@ 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 */}
|
||||||
@ -516,7 +160,8 @@ export default function SetupWiFiScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
selectedDevices.forEach(d => disconnectDevice(d.id));
|
// Disconnect BLE before going back
|
||||||
|
disconnectDevice(deviceId!);
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -533,28 +178,15 @@ 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}>
|
||||||
{selectedDevices.length === 1 ? (
|
<Text style={styles.deviceName}>{deviceName}</Text>
|
||||||
<>
|
<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}>
|
||||||
{selectedDevices.length === 1
|
Select the WiFi network your sensor should connect to. Make sure the network has internet access.
|
||||||
? '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>
|
||||||
|
|
||||||
@ -650,17 +282,22 @@ export default function SetupWiFiScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.connectButton,
|
styles.connectButton,
|
||||||
!password && styles.connectButtonDisabled,
|
(!password || isConnecting) && styles.connectButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleStartBatchSetup}
|
onPress={handleConnect}
|
||||||
disabled={!password}
|
disabled={!password || isConnecting}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark" size={20} color={AppColors.white} />
|
{isConnecting ? (
|
||||||
<Text style={styles.connectButtonText}>
|
<>
|
||||||
{selectedDevices.length === 1
|
<ActivityIndicator size="small" color={AppColors.white} />
|
||||||
? 'Connect & Complete Setup'
|
<Text style={styles.connectButtonText}>Connecting...</Text>
|
||||||
: `Connect All ${selectedDevices.length} Sensors`}
|
</>
|
||||||
</Text>
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="checkmark" size={20} color={AppColors.white} />
|
||||||
|
<Text style={styles.connectButtonText}>Connect & Complete Setup</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -715,10 +352,6 @@ 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',
|
||||||
|
|||||||
@ -1,946 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,529 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,577 +0,0 @@
|
|||||||
# 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,10 +48,6 @@ 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 {
|
||||||
@ -1539,16 +1535,6 @@ 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -1559,17 +1545,8 @@ 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();
|
||||||
@ -1702,17 +1679,8 @@ 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();
|
||||||
@ -1759,120 +1727,6 @@ 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,18 +67,6 @@ 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;
|
||||||
@ -205,48 +193,3 @@ 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