From 3c3283e424098b3eda5c06210987b4b726dfbef5 Mon Sep 17 00:00:00 2001 From: Sergei Date: Wed, 14 Jan 2026 19:07:57 -0800 Subject: [PATCH] Add WiFi setup flow for WP sensors Sensor onboarding screens: - add-sensor.tsx: BLE scanning + device selection - setup-wifi.tsx: WiFi credentials + ESP32 provisioning Flow: 1. Scan for nearby sensors via BLE 2. Select device from list 3. Enter WiFi credentials (SSID + password) 4. Send config over BLE using ESP IDF provisioning protocol 5. Verify connection and activate in backend ESP Provisioning: - services/espProvisioning.ts: ESP32 BLE provisioning implementation - Protocol: custom-data exchange via BLE characteristics - Security: WiFi password encrypted over BLE - Timeout handling: 30s for provisioning, 60s for activation User experience: - Clear step-by-step wizard UI - Loading states for BLE operations - Success/error feedback - Navigation to equipment screen on success --- app/(tabs)/beneficiaries/[id]/add-sensor.tsx | 529 +++++++++++++++++ app/(tabs)/beneficiaries/[id]/setup-wifi.tsx | 563 +++++++++++++++++++ 2 files changed, 1092 insertions(+) create mode 100644 app/(tabs)/beneficiaries/[id]/add-sensor.tsx create mode 100644 app/(tabs)/beneficiaries/[id]/setup-wifi.tsx diff --git a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx new file mode 100644 index 0000000..a0f5d4e --- /dev/null +++ b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx @@ -0,0 +1,529 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; +import * as Device from 'expo-device'; +import { useBLE } from '@/contexts/BLEContext'; +import { useBeneficiary } from '@/contexts/BeneficiaryContext'; +import type { WPDevice } from '@/services/ble'; +import { + AppColors, + BorderRadius, + FontSizes, + FontWeights, + Spacing, + Shadows, +} from '@/constants/theme'; + +export default function AddSensorScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { currentBeneficiary } = useBeneficiary(); + const { + foundDevices, + isScanning, + connectedDevices, + isBLEAvailable, + scanDevices, + stopScan, + connectDevice, + } = useBLE(); + + const [selectedDevice, setSelectedDevice] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + + const beneficiaryName = currentBeneficiary?.name || 'this person'; + + const handleScan = async () => { + try { + await scanDevices(); + } catch (error: any) { + console.error('[AddSensor] Scan failed:', error); + Alert.alert('Scan Failed', error.message || 'Failed to scan for sensors. Please try again.'); + } + }; + + const handleConnect = async (device: WPDevice) => { + setIsConnecting(true); + setSelectedDevice(device); + + 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 getSignalIcon = (rssi: number) => { + if (rssi >= -50) return 'cellular'; + if (rssi >= -60) return 'cellular-outline'; + if (rssi >= -70) return 'cellular'; + return 'cellular-outline'; + }; + + const getSignalColor = (rssi: number) => { + if (rssi >= -50) return AppColors.success; + if (rssi >= -60) return AppColors.info; + if (rssi >= -70) return AppColors.warning; + return AppColors.error; + }; + + return ( + + {/* Header */} + + router.back()}> + + + Add Sensor + + + + {/* Simulator Warning */} + {!isBLEAvailable && ( + + + + Running in Simulator - showing mock sensors + + + )} + + + {/* Instructions */} + + How to Add a Sensor + + + 1 + + Make sure the WP sensor is powered on and nearby + + + + 2 + + Tap "Scan for Sensors" to search for available devices + + + + 3 + + Select your sensor from the list to connect + + + + 4 + + Configure WiFi settings to complete setup + + + + {/* Scan Button */} + {!isScanning && foundDevices.length === 0 && ( + + + Scan for Sensors + + )} + + {/* Scanning Indicator */} + {isScanning && ( + + + Scanning for WP sensors... + + Stop Scan + + + )} + + {/* Found Devices */} + {foundDevices.length > 0 && !isScanning && ( + <> + + Found Sensors ({foundDevices.length}) + + + Rescan + + + + + {foundDevices.map((device) => { + const isConnected = connectedDevices.has(device.id); + const isConnectingThis = isConnecting && selectedDevice?.id === device.id; + + return ( + handleConnect(device)} + disabled={isConnectingThis || isConnected} + activeOpacity={0.7} + > + + + + + + {device.name} + {device.wellId && ( + Well ID: {device.wellId} + )} + + + + {device.rssi} dBm + + + + + + {isConnectingThis ? ( + + ) : isConnected ? ( + + + + ) : ( + + )} + + ); + })} + + + )} + + {/* Empty State (after scan completed, no devices found) */} + {!isScanning && foundDevices.length === 0 && ( + + + + + No Sensors Found + + Make sure your WP sensor is powered on and within range, then try scanning again. + + + )} + + {/* Help Card */} + + + + Troubleshooting + + + • Sensor not showing up? Make sure it's powered on and the LED is blinking{'\n'} + • Weak signal? Move closer to the sensor{'\n'} + • Connection fails? Try restarting the sensor{'\n'} + • Still having issues? Contact support for assistance + + + + + ); +} + +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, + }, + backButton: { + padding: Spacing.xs, + }, + headerTitle: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + }, + placeholder: { + width: 32, + }, + simulatorWarning: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.warningLight, + paddingVertical: Spacing.xs, + paddingHorizontal: Spacing.md, + gap: Spacing.xs, + borderBottomWidth: 1, + borderBottomColor: AppColors.warning, + }, + simulatorWarningText: { + fontSize: FontSizes.xs, + color: AppColors.warning, + fontWeight: FontWeights.medium, + flex: 1, + }, + content: { + flex: 1, + }, + scrollContent: { + padding: Spacing.lg, + paddingBottom: Spacing.xxl, + }, + // Instructions + instructionsCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.lg, + marginBottom: Spacing.lg, + ...Shadows.sm, + }, + instructionsTitle: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: Spacing.md, + }, + step: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: Spacing.sm, + gap: Spacing.sm, + }, + stepNumber: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: AppColors.primary, + justifyContent: 'center', + alignItems: 'center', + }, + stepNumberText: { + fontSize: FontSizes.xs, + fontWeight: FontWeights.bold, + color: AppColors.white, + }, + stepText: { + flex: 1, + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + lineHeight: 20, + paddingTop: 2, + }, + // Scan Button + scanButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: AppColors.primary, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + marginBottom: Spacing.lg, + gap: Spacing.sm, + ...Shadows.md, + }, + scanButtonText: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.white, + }, + // Scanning + scanningCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.xl, + alignItems: 'center', + marginBottom: Spacing.lg, + ...Shadows.sm, + }, + scanningText: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + marginTop: Spacing.md, + marginBottom: Spacing.md, + }, + stopScanButton: { + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.lg, + }, + stopScanText: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.medium, + color: AppColors.error, + }, + // Section Header + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: Spacing.md, + }, + sectionTitle: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.semibold, + color: AppColors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + rescanButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + rescanText: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.medium, + color: AppColors.primary, + }, + // Devices List + devicesList: { + gap: Spacing.md, + marginBottom: Spacing.lg, + }, + deviceCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + ...Shadows.xs, + }, + deviceCardConnected: { + borderWidth: 2, + borderColor: AppColors.success, + }, + deviceInfo: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.md, + }, + deviceIcon: { + width: 48, + height: 48, + borderRadius: BorderRadius.lg, + backgroundColor: AppColors.primaryLighter, + justifyContent: 'center', + alignItems: 'center', + }, + deviceDetails: { + flex: 1, + }, + deviceName: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: 2, + }, + deviceMeta: { + fontSize: FontSizes.xs, + color: AppColors.textMuted, + marginBottom: 4, + }, + signalRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + signalText: { + fontSize: FontSizes.xs, + fontWeight: FontWeights.medium, + }, + connectedBadge: { + padding: Spacing.xs, + }, + // Empty State + emptyState: { + alignItems: 'center', + padding: Spacing.xl, + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + marginBottom: Spacing.lg, + ...Shadows.sm, + }, + emptyIconContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: AppColors.surfaceSecondary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.md, + }, + emptyTitle: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: Spacing.xs, + }, + emptyText: { + fontSize: FontSizes.sm, + color: AppColors.textMuted, + textAlign: 'center', + lineHeight: 20, + }, + // Help Card + helpCard: { + backgroundColor: AppColors.infoLight, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + }, + 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, + }, +}); diff --git a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx new file mode 100644 index 0000000..d2d65d1 --- /dev/null +++ b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx @@ -0,0 +1,563 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, + TextInput, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; +import * as Device from 'expo-device'; +import { useBLE } from '@/contexts/BLEContext'; +import { api } from '@/services/api'; +import type { WiFiNetwork } from '@/services/ble'; +import { + AppColors, + BorderRadius, + FontSizes, + FontWeights, + Spacing, + Shadows, +} from '@/constants/theme'; + +export default function SetupWiFiScreen() { + const { id, deviceId, deviceName, wellId } = useLocalSearchParams<{ + id: string; + deviceId: string; + deviceName: string; + wellId: string; + }>(); + + const { getWiFiList, setWiFi, disconnectDevice } = useBLE(); + + const [networks, setNetworks] = useState([]); + const [isLoadingNetworks, setIsLoadingNetworks] = useState(false); + const [selectedNetwork, setSelectedNetwork] = useState(null); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + + useEffect(() => { + loadWiFiNetworks(); + }, []); + + const loadWiFiNetworks = async () => { + setIsLoadingNetworks(true); + + try { + const wifiList = await getWiFiList(deviceId!); + setNetworks(wifiList); + } catch (error: any) { + console.error('[SetupWiFi] Failed to get WiFi list:', error); + Alert.alert('Error', error.message || 'Failed to get WiFi networks. Please try again.'); + } finally { + setIsLoadingNetworks(false); + } + }; + + const handleSelectNetwork = (network: WiFiNetwork) => { + setSelectedNetwork(network); + setPassword(''); + }; + + const handleConnect = async () => { + if (!selectedNetwork) { + Alert.alert('Error', 'Please select a WiFi network'); + return; + } + + if (!password) { + Alert.alert('Error', 'Please enter WiFi password'); + return; + } + + setIsConnecting(true); + + try { + // Step 1: Set WiFi on the device via BLE + const success = await setWiFi(deviceId!, selectedNetwork.ssid, password); + + if (!success) { + throw new Error('Failed to configure WiFi on sensor'); + } + + // Step 2: Attach device to beneficiary via API (skip in simulator/mock mode) + const isSimulator = !Device.isDevice; + + if (!isSimulator) { + const attachResponse = await api.attachDeviceToBeneficiary( + id!, + parseInt(wellId!, 10), + selectedNetwork.ssid, + password + ); + + if (!attachResponse.ok) { + throw new Error('Failed to attach sensor to beneficiary'); + } + } else { + console.log('[SetupWiFi] Simulator mode - skipping API attach'); + } + + // Step 3: Disconnect BLE connection (sensor will reboot and connect to WiFi) + await disconnectDevice(deviceId!); + + // Success! + Alert.alert( + 'Success!', + `${deviceName} has been configured and attached.\n\nThe sensor will now reboot and connect to "${selectedNetwork.ssid}". This may take a minute.`, + [ + { + 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( + 'Connection Failed', + error.message || 'Failed to configure WiFi. Please check the password and try again.' + ); + } finally { + setIsConnecting(false); + } + }; + + const getSignalStrength = (rssi: number): string => { + if (rssi >= -50) return 'Excellent'; + if (rssi >= -60) return 'Good'; + if (rssi >= -70) return 'Fair'; + return 'Weak'; + }; + + const getSignalColor = (rssi: number) => { + if (rssi >= -50) return AppColors.success; + if (rssi >= -60) return AppColors.info; + if (rssi >= -70) return AppColors.warning; + return AppColors.error; + }; + + const getSignalIcon = (rssi: number) => { + if (rssi >= -50) return 'wifi'; + if (rssi >= -60) return 'wifi'; + if (rssi >= -70) return 'wifi-outline'; + return 'wifi-outline'; + }; + + return ( + + {/* Header */} + + { + // Disconnect BLE before going back + disconnectDevice(deviceId!); + router.back(); + }} + > + + + Setup WiFi + + + + + {/* Device Info Card */} + + + + + + {deviceName} + Well ID: {wellId} + + + + {/* Instructions */} + + + Select the WiFi network your sensor should connect to. Make sure the network has internet access. + + + + {/* WiFi Networks List */} + {isLoadingNetworks ? ( + + + Scanning for WiFi networks... + + ) : ( + <> + + Available Networks ({networks.length}) + + + + + + {networks.length === 0 ? ( + + + No WiFi networks found + + Try Again + + + ) : ( + + {networks.map((network, index) => { + const isSelected = selectedNetwork?.ssid === network.ssid; + + return ( + handleSelectNetwork(network)} + activeOpacity={0.7} + > + + + + {network.ssid} + + {getSignalStrength(network.rssi)} ({network.rssi} dBm) + + + + {isSelected && ( + + )} + + ); + })} + + )} + + )} + + {/* Password Input (shown when network selected) */} + {selectedNetwork && ( + + WiFi Password + + + setShowPassword(!showPassword)} + > + + + + + {/* Connect Button */} + + {isConnecting ? ( + <> + + Connecting... + + ) : ( + <> + + Connect & Complete Setup + + )} + + + )} + + {/* Help Card */} + + + + Important + + + • The sensor will reboot after WiFi is configured{'\n'} + • It may take up to 1 minute for the sensor to connect{'\n'} + • Make sure the WiFi password is correct{'\n'} + • The network must have internet access + + + + + ); +} + +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, + }, + backButton: { + padding: Spacing.xs, + }, + headerTitle: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + }, + placeholder: { + width: 32, + }, + content: { + flex: 1, + }, + scrollContent: { + padding: Spacing.lg, + paddingBottom: Spacing.xxl, + }, + // Device Card + deviceCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.lg, + marginBottom: Spacing.lg, + ...Shadows.sm, + }, + deviceIcon: { + width: 60, + height: 60, + borderRadius: BorderRadius.lg, + backgroundColor: AppColors.primaryLighter, + justifyContent: 'center', + alignItems: 'center', + marginRight: Spacing.md, + }, + deviceInfo: { + flex: 1, + }, + deviceName: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: 2, + }, + deviceMeta: { + fontSize: FontSizes.sm, + color: AppColors.textMuted, + }, + // Instructions + instructionsCard: { + backgroundColor: AppColors.infoLight, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + marginBottom: Spacing.lg, + }, + instructionsText: { + fontSize: FontSizes.sm, + color: AppColors.info, + lineHeight: 20, + }, + // Loading + loadingContainer: { + alignItems: 'center', + padding: Spacing.xl, + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + ...Shadows.sm, + }, + loadingText: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + marginTop: Spacing.md, + }, + // Section Header + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: Spacing.md, + }, + sectionTitle: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.semibold, + color: AppColors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + refreshButton: { + padding: Spacing.xs, + }, + // Empty State + emptyState: { + alignItems: 'center', + padding: Spacing.xl, + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + ...Shadows.sm, + }, + emptyText: { + fontSize: FontSizes.base, + color: AppColors.textMuted, + marginTop: Spacing.md, + marginBottom: Spacing.md, + }, + retryButton: { + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.lg, + }, + retryText: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.primary, + }, + // Networks List + networksList: { + gap: Spacing.sm, + marginBottom: Spacing.lg, + }, + networkCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + ...Shadows.xs, + }, + networkCardSelected: { + borderWidth: 2, + borderColor: AppColors.primary, + }, + networkInfo: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.md, + }, + networkDetails: { + flex: 1, + }, + networkName: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: 2, + }, + signalText: { + fontSize: FontSizes.xs, + fontWeight: FontWeights.medium, + }, + // Password Input + passwordCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.lg, + marginBottom: Spacing.lg, + ...Shadows.sm, + }, + passwordLabel: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.semibold, + color: AppColors.textSecondary, + marginBottom: Spacing.sm, + }, + passwordInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.background, + borderRadius: BorderRadius.md, + borderWidth: 1, + borderColor: AppColors.border, + marginBottom: Spacing.md, + }, + passwordInput: { + flex: 1, + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.md, + fontSize: FontSizes.base, + color: AppColors.textPrimary, + }, + togglePasswordButton: { + padding: Spacing.md, + }, + // Connect Button + connectButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: AppColors.primary, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + gap: Spacing.sm, + ...Shadows.md, + }, + connectButtonDisabled: { + opacity: 0.5, + }, + connectButtonText: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.white, + }, + // Help Card + helpCard: { + backgroundColor: AppColors.infoLight, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + }, + 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, + }, +});