WellNuo/app/(auth)/wifi-setup.tsx
Sergei d453126c89 feat: Room location picker + robster credentials
- Backend: Update Legacy API credentials to robster/rob2
- Frontend: ROOM_LOCATIONS with icons and legacyCode mapping
- Device Settings: Modal picker for room selection
- api.ts: Bidirectional conversion (code ↔ name)
- Various UI/UX improvements across screens

PRD-DEPLOYMENT.md completed (Score: 9/10)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:22:40 -08:00

743 lines
22 KiB
TypeScript

/**
* WiFi Setup Screen
*
* Allows user to configure WiFi on WellNuo ESP32 sensors via Bluetooth.
* Uses @orbital-systems/react-native-esp-idf-provisioning for BLE communication.
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
ScrollView,
ActivityIndicator,
Alert,
FlatList,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
import {
espProvisioning,
type WellNuoDevice,
type WifiNetwork,
type ESPDevice,
} from '@/services/espProvisioning';
type Step = 'scan' | 'connect' | 'wifi-select' | 'wifi-password' | 'provisioning' | 'complete';
export default function WifiSetupScreen() {
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
const lovedOneName = params.lovedOneName || '';
// State
const [step, setStep] = useState<Step>('scan');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Devices
const [devices, setDevices] = useState<WellNuoDevice[]>([]);
const [selectedDevice, setSelectedDevice] = useState<WellNuoDevice | null>(null);
// WiFi
const [wifiNetworks, setWifiNetworks] = useState<WifiNetwork[]>([]);
const [selectedWifi, setSelectedWifi] = useState<WifiNetwork | null>(null);
const [wifiPassword, setWifiPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
// Cleanup on unmount
useEffect(() => {
return () => {
espProvisioning.disconnect();
};
}, []);
// Step 1: Scan for BLE devices
const handleScanDevices = useCallback(async () => {
setIsLoading(true);
setError(null);
setDevices([]);
try {
const foundDevices = await espProvisioning.scanForDevices(10000);
if (foundDevices.length === 0) {
setError('No WellNuo sensors found. Make sure your sensor is powered on and in setup mode.');
} else {
setDevices(foundDevices);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to scan: ${errorMessage}`);
} finally {
setIsLoading(false);
}
}, []);
// Auto-scan on mount
useEffect(() => {
handleScanDevices();
}, [handleScanDevices]);
// Step 2: Connect to selected device
const handleSelectDevice = async (device: WellNuoDevice) => {
setSelectedDevice(device);
setStep('connect');
setIsLoading(true);
setError(null);
try {
await espProvisioning.connect(device.device);
setStep('wifi-select');
// Auto-scan WiFi networks after connecting
handleScanWifi();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to connect: ${errorMessage}`);
setStep('scan');
} finally {
setIsLoading(false);
}
};
// Step 3: Scan for WiFi networks
const handleScanWifi = async () => {
setIsLoading(true);
setError(null);
setWifiNetworks([]);
try {
const networks = await espProvisioning.scanWifiNetworks();
// Sort by signal strength
const sorted = networks.sort((a, b) => b.rssi - a.rssi);
setWifiNetworks(sorted);
if (sorted.length === 0) {
setError('No WiFi networks found. Make sure you are in range of your WiFi network.');
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to scan WiFi: ${errorMessage}`);
} finally {
setIsLoading(false);
}
};
// Step 4: Select WiFi network
const handleSelectWifi = (network: WifiNetwork) => {
setSelectedWifi(network);
setWifiPassword('');
setStep('wifi-password');
};
// Step 5: Provision with password
const handleProvision = async () => {
if (!selectedWifi) return;
setStep('provisioning');
setIsLoading(true);
setError(null);
try {
await espProvisioning.provisionWifi(selectedWifi.ssid, wifiPassword);
setStep('complete');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to configure WiFi: ${errorMessage}`);
setStep('wifi-password');
} finally {
setIsLoading(false);
}
};
// Complete and navigate
const handleComplete = async () => {
await espProvisioning.disconnect();
// Navigate to activate screen or back
if (params.beneficiaryId) {
router.replace({
pathname: '/(auth)/activate',
params: { beneficiaryId: params.beneficiaryId, lovedOneName },
});
} else {
router.back();
}
};
// Render signal strength icon
const renderSignalIcon = (rssi: number) => {
let iconName: 'wifi' | 'wifi-outline' = 'wifi';
let color = AppColors.success;
if (rssi < -70) {
color = AppColors.warning;
}
if (rssi < -80) {
color = AppColors.error;
}
return <Ionicons name={iconName} size={20} color={color} />;
};
// Step 1: Scan for devices
if (step === 'scan' || step === 'connect') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>WiFi Setup</Text>
<View style={styles.placeholder} />
</View>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="bluetooth" size={64} color={AppColors.primary} />
</View>
{/* Instructions */}
<Text style={styles.instructions}>
{step === 'connect'
? `Connecting to ${selectedDevice?.name}...`
: 'Searching for WellNuo sensors nearby...'}
</Text>
{/* Loading or device list */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>
{step === 'connect' ? 'Connecting...' : 'Scanning for devices...'}
</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={handleScanDevices}>
<Text style={styles.retryButtonText}>Retry Scan</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={devices}
keyExtractor={(item) => item.name}
style={styles.deviceList}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.deviceItem}
onPress={() => handleSelectDevice(item)}
>
<View style={styles.deviceIcon}>
<Ionicons name="hardware-chip" size={24} color={AppColors.primary} />
</View>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name}</Text>
{item.wellId && (
<Text style={styles.deviceMeta}>Sensor ID: {item.wellId}</Text>
)}
</View>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
)}
ListEmptyComponent={
<Text style={styles.emptyText}>No devices found</Text>
}
/>
)}
{/* Rescan button */}
{!isLoading && devices.length > 0 && (
<TouchableOpacity style={styles.secondaryButton} onPress={handleScanDevices}>
<Ionicons name="refresh" size={20} color={AppColors.primary} />
<Text style={styles.secondaryButtonText}>Scan Again</Text>
</TouchableOpacity>
)}
</View>
</SafeAreaView>
);
}
// Step 3: Select WiFi network
if (step === 'wifi-select') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={async () => {
await espProvisioning.disconnect();
setStep('scan');
}}
>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>Select WiFi</Text>
<View style={styles.placeholder} />
</View>
{/* Connected device info */}
<View style={styles.connectedDevice}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
<Text style={styles.connectedText}>Connected to {selectedDevice?.name}</Text>
</View>
{/* Instructions */}
<Text style={styles.instructions}>
Select the WiFi network for your sensor
</Text>
{/* Loading or WiFi list */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Scanning WiFi networks...</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={handleScanWifi}>
<Text style={styles.retryButtonText}>Retry Scan</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={wifiNetworks}
keyExtractor={(item, index) => `${item.ssid}-${index}`}
style={styles.deviceList}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.deviceItem}
onPress={() => handleSelectWifi(item)}
>
{renderSignalIcon(item.rssi)}
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.ssid}</Text>
<Text style={styles.deviceMeta}>{item.auth}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
)}
ListEmptyComponent={
<Text style={styles.emptyText}>No WiFi networks found</Text>
}
/>
)}
{/* Rescan button */}
{!isLoading && (
<TouchableOpacity style={styles.secondaryButton} onPress={handleScanWifi}>
<Ionicons name="refresh" size={20} color={AppColors.primary} />
<Text style={styles.secondaryButtonText}>Scan Again</Text>
</TouchableOpacity>
)}
</View>
</SafeAreaView>
);
}
// Step 4: Enter WiFi password
if (step === 'wifi-password' || step === 'provisioning') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => setStep('wifi-select')}
disabled={step === 'provisioning'}
>
<Ionicons
name="arrow-back"
size={24}
color={step === 'provisioning' ? AppColors.textMuted : AppColors.textPrimary}
/>
</TouchableOpacity>
<Text style={styles.title}>Enter Password</Text>
<View style={styles.placeholder} />
</View>
{/* WiFi icon */}
<View style={styles.iconContainer}>
<Ionicons name="wifi" size={64} color={AppColors.primary} />
</View>
{/* Selected network */}
<View style={styles.selectedNetwork}>
<Text style={styles.selectedNetworkLabel}>Network:</Text>
<Text style={styles.selectedNetworkName}>{selectedWifi?.ssid}</Text>
</View>
{/* Password input */}
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={wifiPassword}
onChangeText={setWifiPassword}
placeholder="Enter WiFi password"
placeholderTextColor={AppColors.textMuted}
secureTextEntry={!showPassword}
autoCorrect={false}
editable={step !== 'provisioning'}
/>
<TouchableOpacity
style={styles.showPasswordButton}
onPress={() => setShowPassword(!showPassword)}
>
<Ionicons
name={showPassword ? 'eye-off' : 'eye'}
size={24}
color={AppColors.textMuted}
/>
</TouchableOpacity>
</View>
{/* Error */}
{error && (
<View style={styles.inlineError}>
<Ionicons name="alert-circle" size={16} color={AppColors.error} />
<Text style={styles.inlineErrorText}>{error}</Text>
</View>
)}
{/* Connect button */}
<TouchableOpacity
style={[styles.primaryButton, step === 'provisioning' && styles.buttonDisabled]}
onPress={handleProvision}
disabled={step === 'provisioning' || !wifiPassword}
>
{step === 'provisioning' ? (
<>
<ActivityIndicator color={AppColors.white} style={{ marginRight: 8 }} />
<Text style={styles.primaryButtonText}>Configuring...</Text>
</>
) : (
<Text style={styles.primaryButtonText}>Connect to WiFi</Text>
)}
</TouchableOpacity>
{/* Note for open networks */}
{selectedWifi?.auth === 'Open' && (
<Text style={styles.noteText}>
This is an open network. You can leave the password empty.
</Text>
)}
</ScrollView>
</SafeAreaView>
);
}
// Step 5: Complete
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
{/* Success */}
<View style={styles.successContainer}>
<View style={styles.successIcon}>
<Ionicons name="checkmark-circle" size={80} color={AppColors.success} />
</View>
<Text style={styles.successTitle}>WiFi Configured!</Text>
<Text style={styles.successMessage}>
Your sensor <Text style={styles.highlight}>{selectedDevice?.name}</Text> is now
connected to <Text style={styles.highlight}>{selectedWifi?.ssid}</Text>
</Text>
{/* Next steps */}
<View style={styles.nextSteps}>
<Text style={styles.nextStepsTitle}>What happens next:</Text>
<View style={styles.stepItem}>
<Ionicons name="checkmark" size={20} color={AppColors.success} />
<Text style={styles.stepText}>Sensor will connect to your WiFi</Text>
</View>
<View style={styles.stepItem}>
<Ionicons name="checkmark" size={20} color={AppColors.success} />
<Text style={styles.stepText}>Data will start syncing to WellNuo</Text>
</View>
<View style={styles.stepItem}>
<Ionicons name="checkmark" size={20} color={AppColors.success} />
<Text style={styles.stepText}>You'll see updates in the dashboard</Text>
</View>
</View>
</View>
{/* Complete button */}
<TouchableOpacity style={styles.primaryButton} onPress={handleComplete}>
<Text style={styles.primaryButtonText}>Continue</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
content: {
flex: 1,
padding: Spacing.lg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.xl,
},
backButton: {
padding: Spacing.sm,
marginLeft: -Spacing.sm,
},
title: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
placeholder: {
width: 40,
},
iconContainer: {
alignItems: 'center',
marginBottom: Spacing.lg,
},
instructions: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.lg,
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
errorContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: Spacing.lg,
},
errorText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.error,
textAlign: 'center',
},
retryButton: {
marginTop: Spacing.lg,
paddingHorizontal: Spacing.xl,
paddingVertical: Spacing.md,
backgroundColor: AppColors.primary,
borderRadius: BorderRadius.lg,
},
retryButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
deviceList: {
flex: 1,
marginBottom: Spacing.md,
},
deviceItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.sm,
gap: Spacing.md,
},
deviceIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: `${AppColors.primary}20`,
alignItems: 'center',
justifyContent: 'center',
},
deviceInfo: {
flex: 1,
},
deviceName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
deviceMeta: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: 2,
},
emptyText: {
fontSize: FontSizes.base,
color: AppColors.textMuted,
textAlign: 'center',
marginTop: Spacing.xl,
},
secondaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
borderWidth: 1,
borderColor: AppColors.primary,
},
secondaryButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
connectedDevice: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: `${AppColors.success}15`,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.md,
marginBottom: Spacing.md,
},
connectedText: {
fontSize: FontSizes.sm,
color: AppColors.success,
fontWeight: FontWeights.medium,
},
selectedNetwork: {
alignItems: 'center',
marginBottom: Spacing.lg,
},
selectedNetworkLabel: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
selectedNetworkName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginTop: 4,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
borderWidth: 1,
borderColor: AppColors.border,
marginBottom: Spacing.md,
},
input: {
flex: 1,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
showPasswordButton: {
padding: Spacing.md,
},
inlineError: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.md,
},
inlineErrorText: {
fontSize: FontSizes.sm,
color: AppColors.error,
flex: 1,
},
primaryButton: {
flexDirection: 'row',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
marginTop: Spacing.md,
},
buttonDisabled: {
opacity: 0.7,
},
primaryButtonText: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
noteText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
marginTop: Spacing.md,
},
successContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
successIcon: {
marginBottom: Spacing.xl,
},
successTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
successMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.xl,
paddingHorizontal: Spacing.lg,
},
highlight: {
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
nextSteps: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.lg,
},
nextStepsTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
stepItem: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
marginBottom: Spacing.sm,
},
stepText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
flex: 1,
},
});