Various improvements and fixes

- Added ImageLightbox component for avatar viewing
- Updated beneficiary detail page with lightbox support
- Profile page improvements
- Bug page cleanup
- API and context updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-10 08:25:39 -08:00
parent 4c880190d5
commit 966d8e2aba
8 changed files with 184 additions and 218 deletions

View File

@ -42,6 +42,7 @@ import {
shouldShowSubscriptionWarning, shouldShowSubscriptionWarning,
} from '@/services/BeneficiaryDetailController'; } from '@/services/BeneficiaryDetailController';
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
import { ImageLightbox } from '@/components/ImageLightbox';
// WebView Dashboard URL - opens specific deployment directly // WebView Dashboard URL - opens specific deployment directly
const getDashboardUrl = (deploymentId?: number) => { const getDashboardUrl = (deploymentId?: number) => {
@ -74,6 +75,9 @@ export default function BeneficiaryDetailScreen() {
const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined }); const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined });
// Avatar lightbox state
const [lightboxVisible, setLightboxVisible] = useState(false);
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
// Load legacy credentials for WebView dashboard // Load legacy credentials for WebView dashboard
@ -335,15 +339,24 @@ export default function BeneficiaryDetailScreen() {
{/* Avatar + Name + Role */} {/* Avatar + Name + Role */}
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
{beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? ( <TouchableOpacity
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} /> onPress={() => {
) : ( if (beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder')) {
<View style={styles.headerAvatar}> setLightboxVisible(true);
<Text style={styles.headerAvatarText}> }
{beneficiary.name.charAt(0).toUpperCase()} }}
</Text> disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')}
</View> >
)} {beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? (
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
) : (
<View style={styles.headerAvatar}>
<Text style={styles.headerAvatarText}>
{beneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
)}
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiary.name}</Text> <Text style={styles.headerTitle}>{beneficiary.name}</Text>
</View> </View>
@ -516,6 +529,13 @@ export default function BeneficiaryDetailScreen() {
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</Modal> </Modal>
{/* Avatar Lightbox */}
<ImageLightbox
visible={lightboxVisible}
imageUri={beneficiary?.avatar || null}
onClose={() => setLightboxVisible(false)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@ -4,201 +4,9 @@ import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { AppColors } from '@/constants/theme'; import { AppColors } from '@/constants/theme';
// Test HTML page with buttons that send messages to React Native // Remote URL for the test page (loaded from server)
const TEST_HTML = ` // Add cache-busting query param to force reload
<!DOCTYPE html> const TEST_PAGE_URL = `https://wellnuo.smartlaunchhub.com/test-bridge.html?v=${Date.now()}`;
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: white;
}
.container {
max-width: 400px;
margin: 0 auto;
}
h1 {
font-size: 24px;
margin-bottom: 10px;
text-align: center;
}
.subtitle {
font-size: 14px;
opacity: 0.8;
text-align: center;
margin-bottom: 30px;
}
.card {
background: rgba(255,255,255,0.15);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.btn {
display: block;
width: 100%;
padding: 16px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-bottom: 12px;
transition: transform 0.1s, opacity 0.1s;
}
.btn:active {
transform: scale(0.98);
opacity: 0.9;
}
.btn-primary {
background: white;
color: #667eea;
}
.btn-secondary {
background: rgba(255,255,255,0.2);
color: white;
border: 2px solid rgba(255,255,255,0.3);
}
.btn-danger {
background: #ff6b6b;
color: white;
}
.log {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 12px;
font-family: monospace;
font-size: 12px;
max-height: 150px;
overflow-y: auto;
margin-top: 20px;
}
.log-entry {
margin-bottom: 4px;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<h1>WebView Bridge Test</h1>
<p class="subtitle">Test communication between Web and React Native</p>
<div class="card">
<div class="card-title">Navigation Commands</div>
<button class="btn btn-primary" onclick="navigateTo('beneficiaries')">
Open Beneficiaries
</button>
<button class="btn btn-secondary" onclick="navigateTo('chat')">
Open Chat
</button>
<button class="btn btn-secondary" onclick="navigateTo('profile')">
Open Profile
</button>
</div>
<div class="card">
<div class="card-title">Native Features</div>
<button class="btn btn-primary" onclick="requestNativeAction('bluetooth')">
Scan Bluetooth Devices
</button>
<button class="btn btn-secondary" onclick="requestNativeAction('camera')">
Open Camera
</button>
</div>
<div class="card">
<div class="card-title">Send Custom Data</div>
<button class="btn btn-danger" onclick="sendCustomMessage()">
Send Test Message
</button>
</div>
<div class="log" id="log">
<div class="log-entry">Ready to communicate...</div>
</div>
</div>
<script>
function log(message) {
const logEl = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logEl.innerHTML += '<div class="log-entry">[' + time + '] ' + message + '</div>';
logEl.scrollTop = logEl.scrollHeight;
}
function sendToRN(data) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(data));
log('Sent: ' + JSON.stringify(data));
} else {
log('ERROR: Not in WebView');
alert('This page must be opened in the mobile app');
}
}
function navigateTo(screen) {
sendToRN({
action: 'NAVIGATE',
screen: screen
});
}
function requestNativeAction(feature) {
sendToRN({
action: 'NATIVE_FEATURE',
feature: feature
});
}
function sendCustomMessage() {
sendToRN({
action: 'CUSTOM',
payload: {
timestamp: Date.now(),
message: 'Hello from WebView!',
data: { foo: 'bar', count: 42 }
}
});
}
// Listen for messages FROM React Native
window.addEventListener('message', function(event) {
try {
const data = JSON.parse(event.data);
log('Received from RN: ' + JSON.stringify(data));
} catch (e) {
log('Received: ' + event.data);
}
});
// Also handle React Native's onMessage format
document.addEventListener('message', function(event) {
try {
const data = JSON.parse(event.data);
log('Received from RN: ' + JSON.stringify(data));
} catch (e) {
log('Received: ' + event.data);
}
});
log('WebView Bridge initialized');
</script>
</body>
</html>
`;
export default function BugScreen() { export default function BugScreen() {
const router = useRouter(); const router = useRouter();
@ -300,13 +108,15 @@ export default function BugScreen() {
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<WebView <WebView
ref={webViewRef} ref={webViewRef}
source={{ html: TEST_HTML }} source={{ uri: TEST_PAGE_URL }}
style={styles.webview} style={styles.webview}
onMessage={handleMessage} onMessage={handleMessage}
javaScriptEnabled={true} javaScriptEnabled={true}
domStorageEnabled={true} domStorageEnabled={true}
startInLoadingState={true} startInLoadingState={true}
scalesPageToFit={true} scalesPageToFit={true}
cacheEnabled={false}
incognito={true}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@ -13,8 +13,11 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
// ImageLightbox removed - tap on avatar directly opens picker
import { optimizeAvatarImage } from '@/utils/imageUtils';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { ProfileDrawer } from '@/components/ProfileDrawer'; import { ProfileDrawer } from '@/components/ProfileDrawer';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { import {
@ -43,6 +46,7 @@ const generateInviteCode = (identifier: string): string => {
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { clearAllBeneficiaryData } = useBeneficiary();
const toast = useToast(); const toast = useToast();
// Drawer state // Drawer state
@ -73,7 +77,8 @@ export default function ProfileScreen() {
} }
}; };
const handleAvatarPress = async () => { // Change avatar (tap on avatar or camera badge)
const handleAvatarChange = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
Alert.alert('Permission needed', 'Please allow access to your photo library to change avatar.'); Alert.alert('Permission needed', 'Please allow access to your photo library to change avatar.');
@ -84,13 +89,18 @@ export default function ProfileScreen() {
mediaTypes: ['images'], mediaTypes: ['images'],
allowsEditing: true, allowsEditing: true,
aspect: [1, 1], aspect: [1, 1],
quality: 0.5, quality: 0.8,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
const uri = result.assets[0].uri; const originalUri = result.assets[0].uri;
setAvatarUri(uri);
await SecureStore.setItemAsync('userAvatar', uri); // Optimize image: resize to 400x400 and compress
const optimizedUri = await optimizeAvatarImage(originalUri);
setAvatarUri(optimizedUri);
await SecureStore.setItemAsync('userAvatar', optimizedUri);
toast.success('Avatar updated');
} }
}; };
@ -112,6 +122,11 @@ export default function ProfileScreen() {
text: 'Logout', text: 'Logout',
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
// Clear local UI state
setAvatarUri(null);
// Clear beneficiary context
await clearAllBeneficiaryData();
// Logout (clears SecureStore and AsyncStorage)
await logout(); await logout();
router.replace('/(auth)/login'); router.replace('/(auth)/login');
}, },
@ -161,18 +176,21 @@ export default function ProfileScreen() {
> >
{/* Profile Card */} {/* Profile Card */}
<View style={styles.profileCard}> <View style={styles.profileCard}>
<TouchableOpacity style={styles.avatarSection} onPress={handleAvatarPress}> <View style={styles.avatarSection}>
<View style={styles.avatarContainer}> <TouchableOpacity
style={styles.avatarContainer}
onPress={handleAvatarChange}
>
{avatarUri ? ( {avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} /> <Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : ( ) : (
<Text style={styles.avatarText}>{userInitial}</Text> <Text style={styles.avatarText}>{userInitial}</Text>
)} )}
<View style={styles.avatarEditBadge}> </TouchableOpacity>
<Ionicons name="camera" size={14} color={AppColors.white} /> <TouchableOpacity style={styles.avatarEditBadge} onPress={handleAvatarChange}>
</View> <Ionicons name="camera" size={14} color={AppColors.white} />
</View> </TouchableOpacity>
</TouchableOpacity> </View>
<Text style={styles.displayName}>{displayName}</Text> <Text style={styles.displayName}>{displayName}</Text>
<Text style={styles.userEmail}>{user?.email || ''}</Text> <Text style={styles.userEmail}>{user?.email || ''}</Text>
@ -247,6 +265,7 @@ export default function ProfileScreen() {
settings={settings} settings={settings}
onSettingChange={handleSettingChange} onSettingChange={handleSettingChange}
/> />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -299,6 +318,7 @@ const styles = StyleSheet.create({
}, },
avatarSection: { avatarSection: {
marginBottom: Spacing.md, marginBottom: Spacing.md,
position: 'relative',
}, },
avatarContainer: { avatarContainer: {
width: AvatarSizes.xl, width: AvatarSizes.xl,
@ -307,7 +327,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
position: 'relative', overflow: 'hidden',
}, },
avatarImage: { avatarImage: {
width: AvatarSizes.xl, width: AvatarSizes.xl,

View File

@ -0,0 +1,87 @@
import React from 'react';
import {
Modal,
View,
Image,
StyleSheet,
TouchableOpacity,
Dimensions,
StatusBar,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { AppColors } from '@/constants/theme';
interface ImageLightboxProps {
visible: boolean;
imageUri: string | null;
onClose: () => void;
}
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export function ImageLightbox({ visible, imageUri, onClose }: ImageLightboxProps) {
if (!imageUri) return null;
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={onClose}
>
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.95)" />
<View style={styles.container}>
{/* Close button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={28} color={AppColors.white} />
</TouchableOpacity>
{/* Image */}
<TouchableOpacity
style={styles.imageContainer}
activeOpacity={1}
onPress={onClose}
>
<Image
source={{ uri: imageUri }}
style={styles.image}
resizeMode="contain"
/>
</TouchableOpacity>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
top: 50,
right: 20,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
imageContainer: {
width: screenWidth,
height: screenHeight,
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: screenWidth - 40,
height: screenWidth - 40,
borderRadius: 20,
},
});

View File

@ -20,6 +20,8 @@ interface BeneficiaryContextType {
addLocalBeneficiary: (data: string | AddBeneficiaryData) => Promise<Beneficiary>; addLocalBeneficiary: (data: string | AddBeneficiaryData) => Promise<Beneficiary>;
updateLocalBeneficiary: (id: number, data: Partial<Beneficiary>) => Promise<Beneficiary | null>; updateLocalBeneficiary: (id: number, data: Partial<Beneficiary>) => Promise<Beneficiary | null>;
removeLocalBeneficiary: (id: number) => Promise<void>; removeLocalBeneficiary: (id: number) => Promise<void>;
// Clear all data (used on logout)
clearAllBeneficiaryData: () => Promise<void>;
// Helper to format beneficiary context for AI // Helper to format beneficiary context for AI
getBeneficiaryContext: () => string; getBeneficiaryContext: () => string;
} }
@ -116,6 +118,13 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
setCurrentBeneficiary(null); setCurrentBeneficiary(null);
}, []); }, []);
// Clear all beneficiary data (called on logout)
const clearAllBeneficiaryData = useCallback(async () => {
setCurrentBeneficiary(null);
setLocalBeneficiaries([]);
await AsyncStorage.removeItem(LOCAL_BENEFICIARIES_KEY);
}, []);
const getBeneficiaryContext = useCallback(() => { const getBeneficiaryContext = useCallback(() => {
if (!currentBeneficiary) { if (!currentBeneficiary) {
return ''; return '';
@ -182,6 +191,7 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
addLocalBeneficiary, addLocalBeneficiary,
updateLocalBeneficiary, updateLocalBeneficiary,
removeLocalBeneficiary, removeLocalBeneficiary,
clearAllBeneficiaryData,
getBeneficiaryContext, getBeneficiaryContext,
}} }}
> >

13
package-lock.json generated
View File

@ -26,6 +26,7 @@
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-manipulator": "^14.0.8",
"expo-image-picker": "~17.0.10", "expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-router": "~6.0.21", "expo-router": "~6.0.21",
@ -10769,6 +10770,18 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": { "node_modules/expo-image-picker": {
"version": "17.0.10", "version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",

View File

@ -29,6 +29,7 @@
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-manipulator": "^14.0.8",
"expo-image-picker": "~17.0.10", "expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-router": "~6.0.21", "expo-router": "~6.0.21",

View File

@ -2,6 +2,7 @@ import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashb
import * as Crypto from 'expo-crypto'; import * as Crypto from 'expo-crypto';
import { File } from 'expo-file-system'; import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Callback for handling unauthorized responses (401) // Callback for handling unauthorized responses (401)
let onUnauthorizedCallback: (() => void) | null = null; let onUnauthorizedCallback: (() => void) | null = null;
@ -158,6 +159,10 @@ class ApiService {
await SecureStore.deleteItemAsync('legacyAccessToken'); await SecureStore.deleteItemAsync('legacyAccessToken');
await SecureStore.deleteItemAsync('privileges'); await SecureStore.deleteItemAsync('privileges');
await SecureStore.deleteItemAsync('maxRole'); await SecureStore.deleteItemAsync('maxRole');
// Clear user profile data (avatar, etc.)
await SecureStore.deleteItemAsync('userAvatar');
// Clear local cached data (beneficiaries, etc.)
await AsyncStorage.removeItem('wellnuo_local_beneficiaries');
} }
// Save user email (for OTP auth flow) // Save user email (for OTP auth flow)