Fix deployment error handling, build info display, Android UI improvements

- Add build number/timestamp display on login screen
- Improve error message when beneficiary has no deployment (user-friendly text instead of crash)
- Fix verify-otp screen layout for Android (smaller spacing, icon sizes)
- Add KeyboardAvoidingView to setup-wifi screen
- Save WiFi passwords per SSID (auto-fill on reconnect)
- Suppress BLE "operation cancelled" noise in logs
- Add build-info generation script (npm run build-info)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-27 21:49:02 -08:00
parent 7149d25ba4
commit 994e2faadb
8 changed files with 110 additions and 32 deletions

View File

@ -3,6 +3,7 @@ import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { Input } from '@/components/ui/Input';
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext';
import { BUILD_DISPLAY } from '@/constants/build-info';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
@ -172,7 +173,7 @@ export default function LoginScreen() {
</Text>
</View>
<Text style={styles.version}>WellNuo v1.0.0</Text>
<Text style={styles.version}>WellNuo v1.0.0{'\n'}{BUILD_DISPLAY}</Text>
</ScrollView>
</KeyboardAvoidingView>
);

View File

@ -9,6 +9,7 @@ import {
TouchableOpacity,
TextInput,
ActivityIndicator,
Keyboard,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@ -246,12 +247,14 @@ export default function VerifyOTPScreen() {
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
>
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
@ -259,7 +262,7 @@ export default function VerifyOTPScreen() {
<View style={styles.iconContainer}>
<View style={styles.iconCircle}>
<Ionicons name="mail-open" size={48} color={AppColors.primary} />
<Ionicons name="mail-open" size={Platform.OS === 'android' ? 36 : 48} color={AppColors.primary} />
</View>
</View>
@ -342,7 +345,7 @@ const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xl,
paddingTop: Platform.OS === 'android' ? Spacing.md : Spacing.xl,
paddingBottom: Spacing.xl,
},
autoLoginContainer: {
@ -361,29 +364,29 @@ const styles = StyleSheet.create({
height: 44,
justifyContent: 'center',
alignItems: 'flex-start',
marginBottom: Spacing.xl,
marginBottom: Platform.OS === 'android' ? Spacing.sm : Spacing.xl,
},
iconContainer: {
alignItems: 'center',
marginBottom: Spacing.xl,
marginBottom: Platform.OS === 'android' ? Spacing.md : Spacing.xl,
},
iconCircle: {
width: 100,
height: 100,
borderRadius: 50,
width: Platform.OS === 'android' ? 72 : 100,
height: Platform.OS === 'android' ? 72 : 100,
borderRadius: Platform.OS === 'android' ? 36 : 50,
backgroundColor: `${AppColors.primary}15`,
justifyContent: 'center',
alignItems: 'center',
},
header: {
alignItems: 'center',
marginBottom: Spacing.lg,
marginBottom: Platform.OS === 'android' ? Spacing.sm : Spacing.lg,
},
title: {
fontSize: FontSizes['2xl'],
fontWeight: '700',
color: AppColors.textPrimary,
marginBottom: Spacing.md,
marginBottom: Platform.OS === 'android' ? Spacing.xs : Spacing.md,
textAlign: 'center',
},
subtitle: {
@ -401,7 +404,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'center',
gap: Spacing.sm,
marginVertical: Spacing.xl,
marginVertical: Platform.OS === 'android' ? Spacing.md : Spacing.xl,
},
codeBox: {
width: 48,

View File

@ -8,6 +8,8 @@ import {
Alert,
ActivityIndicator,
TextInput,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -111,19 +113,28 @@ export default function SetupWiFiScreen() {
const setupInProgressRef = useRef(false);
const shouldCancelRef = useRef(false);
// Load saved WiFi password on mount
// Saved WiFi passwords map (SSID -> password)
const savedPasswordsRef = useRef<Record<string, string>>({});
// Load saved WiFi passwords on mount
useEffect(() => {
const loadSavedPassword = async () => {
const loadSavedPasswords = async () => {
try {
const savedPassword = await AsyncStorage.getItem('LAST_WIFI_PASSWORD');
if (savedPassword) {
setPassword(savedPassword);
const saved = await AsyncStorage.getItem('WIFI_PASSWORDS');
if (saved) {
savedPasswordsRef.current = JSON.parse(saved);
console.log('[SetupWiFi] Loaded saved passwords for', Object.keys(savedPasswordsRef.current).length, 'networks');
}
// Also load legacy single password
const legacyPassword = await AsyncStorage.getItem('LAST_WIFI_PASSWORD');
if (legacyPassword && !saved) {
setPassword(legacyPassword);
}
} catch (error) {
console.log('[SetupWiFi] Failed to load saved password:', error);
console.log('[SetupWiFi] Failed to load saved passwords:', error);
}
};
loadSavedPassword();
loadSavedPasswords();
loadWiFiNetworks();
}, []);
@ -162,7 +173,9 @@ export default function SetupWiFiScreen() {
const handleSelectNetwork = (network: WiFiNetwork) => {
setSelectedNetwork(network);
setPassword('');
// Auto-fill saved password for this network
const savedPwd = savedPasswordsRef.current[network.ssid];
setPassword(savedPwd || '');
};
// Update a specific step for a sensor
@ -395,10 +408,12 @@ export default function SetupWiFiScreen() {
return;
}
// Save password for next time
// Save password for this network (by SSID)
try {
await AsyncStorage.setItem('LAST_WIFI_PASSWORD', password);
console.log('[SetupWiFi] Password saved for future use');
savedPasswordsRef.current[selectedNetwork.ssid] = password;
await AsyncStorage.setItem('WIFI_PASSWORDS', JSON.stringify(savedPasswordsRef.current));
await AsyncStorage.setItem('LAST_WIFI_PASSWORD', password); // legacy compat
console.log('[SetupWiFi] Password saved for network:', selectedNetwork.ssid);
} catch (error) {
console.log('[SetupWiFi] Failed to save password:', error);
}
@ -571,7 +586,12 @@ export default function SetupWiFiScreen() {
<View style={styles.placeholder} />
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
<KeyboardAvoidingView
style={styles.content}
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 20}
>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
{/* Device Info Card */}
<View style={styles.deviceCard}>
<View style={styles.deviceIcon}>
@ -724,6 +744,7 @@ export default function SetupWiFiScreen() {
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

5
constants/build-info.ts Normal file
View File

@ -0,0 +1,5 @@
// Auto-generated by scripts/generate-build-info.js
// DO NOT EDIT MANUALLY
export const BUILD_NUMBER = 1;
export const BUILD_TIMESTAMP = '2026-01-28T05:16:19.402Z';
export const BUILD_DISPLAY = 'build 1 · Jan 27, 21:16';

View File

@ -5,8 +5,9 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"build-info": "node scripts/generate-build-info.js",
"android": "npm run build-info && expo run:android",
"ios": "npm run build-info && expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"postinstall": "patch-package"

View File

@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Generates build-info.ts with auto-incrementing build number and timestamp.
* Run before each build: node scripts/generate-build-info.js
*/
const fs = require('fs');
const path = require('path');
const BUILD_INFO_PATH = path.join(__dirname, '..', 'constants', 'build-info.ts');
// Read current build number
let buildNumber = 0;
try {
const existing = fs.readFileSync(BUILD_INFO_PATH, 'utf8');
const match = existing.match(/BUILD_NUMBER\s*=\s*(\d+)/);
if (match) {
buildNumber = parseInt(match[1], 10);
}
} catch {
// First build
}
buildNumber++;
const now = new Date();
const timestamp = now.toISOString();
// Short format for UI: "Jan 28, 08:15"
const shortDate = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const shortTime = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
const content = `// Auto-generated by scripts/generate-build-info.js
// DO NOT EDIT MANUALLY
export const BUILD_NUMBER = ${buildNumber};
export const BUILD_TIMESTAMP = '${timestamp}';
export const BUILD_DISPLAY = 'build ${buildNumber} · ${shortDate}, ${shortTime}';
`;
fs.writeFileSync(BUILD_INFO_PATH, content, 'utf8');
console.log(`Build info generated: #${buildNumber} at ${timestamp}`);

View File

@ -1842,7 +1842,7 @@ class ApiService {
if (!deploymentId) {
console.error('[API] Beneficiary has no deploymentId');
throw new Error('Beneficiary has no deployment');
throw new Error('No deployment configured for this beneficiary. Please remove and re-add the beneficiary to fix this.');
}
const creds = await this.getLegacyCredentials();

View File

@ -291,11 +291,17 @@ export class RealBLEManager implements IBLEManager {
// Wrap callback in try-catch to prevent crashes
try {
if (error) {
const errCode = (error as any)?.errorCode;
// errorCode 2 = "Operation was cancelled" — normal BLE cleanup, not a real error
if (errCode === 2) {
console.log('[BLE] Notification cancelled (normal cleanup)');
} else {
console.error('[BLE] Notification error:', {
message: error?.message || 'null',
errorCode: (error as any)?.errorCode || 'null',
errorCode: errCode || 'null',
reason: (error as any)?.reason || 'null',
});
}
safeReject(error);
return;
}