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

View File

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

View File

@ -8,6 +8,8 @@ import {
Alert, Alert,
ActivityIndicator, ActivityIndicator,
TextInput, TextInput,
KeyboardAvoidingView,
Platform,
} 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';
@ -111,19 +113,28 @@ export default function SetupWiFiScreen() {
const setupInProgressRef = useRef(false); const setupInProgressRef = useRef(false);
const shouldCancelRef = 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(() => { useEffect(() => {
const loadSavedPassword = async () => { const loadSavedPasswords = async () => {
try { try {
const savedPassword = await AsyncStorage.getItem('LAST_WIFI_PASSWORD'); const saved = await AsyncStorage.getItem('WIFI_PASSWORDS');
if (savedPassword) { if (saved) {
setPassword(savedPassword); 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) { } catch (error) {
console.log('[SetupWiFi] Failed to load saved password:', error); console.log('[SetupWiFi] Failed to load saved passwords:', error);
} }
}; };
loadSavedPassword(); loadSavedPasswords();
loadWiFiNetworks(); loadWiFiNetworks();
}, []); }, []);
@ -162,7 +173,9 @@ export default function SetupWiFiScreen() {
const handleSelectNetwork = (network: WiFiNetwork) => { const handleSelectNetwork = (network: WiFiNetwork) => {
setSelectedNetwork(network); 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 // Update a specific step for a sensor
@ -395,10 +408,12 @@ export default function SetupWiFiScreen() {
return; return;
} }
// Save password for next time // Save password for this network (by SSID)
try { try {
await AsyncStorage.setItem('LAST_WIFI_PASSWORD', password); savedPasswordsRef.current[selectedNetwork.ssid] = password;
console.log('[SetupWiFi] Password saved for future use'); 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) { } catch (error) {
console.log('[SetupWiFi] Failed to save password:', error); console.log('[SetupWiFi] Failed to save password:', error);
} }
@ -571,7 +586,12 @@ export default function SetupWiFiScreen() {
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </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 */} {/* Device Info Card */}
<View style={styles.deviceCard}> <View style={styles.deviceCard}>
<View style={styles.deviceIcon}> <View style={styles.deviceIcon}>
@ -724,6 +744,7 @@ export default function SetupWiFiScreen() {
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView>
</SafeAreaView> </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": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android", "build-info": "node scripts/generate-build-info.js",
"ios": "expo run:ios", "android": "npm run build-info && expo run:android",
"ios": "npm run build-info && expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"postinstall": "patch-package" "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) { if (!deploymentId) {
console.error('[API] Beneficiary has no 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(); const creds = await this.getLegacyCredentials();

View File

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