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:
parent
7149d25ba4
commit
994e2faadb
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
5
constants/build-info.ts
Normal 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';
|
||||||
@ -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"
|
||||||
|
|||||||
41
scripts/generate-build-info.js
Normal file
41
scripts/generate-build-info.js
Normal 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}`);
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user