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:
parent
4c880190d5
commit
966d8e2aba
@ -42,6 +42,7 @@ import {
|
||||
shouldShowSubscriptionWarning,
|
||||
} from '@/services/BeneficiaryDetailController';
|
||||
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||
import { ImageLightbox } from '@/components/ImageLightbox';
|
||||
|
||||
// WebView Dashboard URL - opens specific deployment directly
|
||||
const getDashboardUrl = (deploymentId?: number) => {
|
||||
@ -74,6 +75,9 @@ export default function BeneficiaryDetailScreen() {
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined });
|
||||
|
||||
// Avatar lightbox state
|
||||
const [lightboxVisible, setLightboxVisible] = useState(false);
|
||||
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
|
||||
// Load legacy credentials for WebView dashboard
|
||||
@ -335,6 +339,14 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
{/* Avatar + Name + Role */}
|
||||
<View style={styles.headerCenter}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder')) {
|
||||
setLightboxVisible(true);
|
||||
}
|
||||
}}
|
||||
disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')}
|
||||
>
|
||||
{beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? (
|
||||
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
||||
) : (
|
||||
@ -344,6 +356,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||
</View>
|
||||
|
||||
@ -516,6 +529,13 @@ export default function BeneficiaryDetailScreen() {
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
|
||||
{/* Avatar Lightbox */}
|
||||
<ImageLightbox
|
||||
visible={lightboxVisible}
|
||||
imageUri={beneficiary?.avatar || null}
|
||||
onClose={() => setLightboxVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,201 +4,9 @@ import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
|
||||
// Test HTML page with buttons that send messages to React Native
|
||||
const TEST_HTML = `
|
||||
<!DOCTYPE html>
|
||||
<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>
|
||||
`;
|
||||
// Remote URL for the test page (loaded from server)
|
||||
// Add cache-busting query param to force reload
|
||||
const TEST_PAGE_URL = `https://wellnuo.smartlaunchhub.com/test-bridge.html?v=${Date.now()}`;
|
||||
|
||||
export default function BugScreen() {
|
||||
const router = useRouter();
|
||||
@ -300,13 +108,15 @@ export default function BugScreen() {
|
||||
<SafeAreaView style={styles.container}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html: TEST_HTML }}
|
||||
source={{ uri: TEST_PAGE_URL }}
|
||||
style={styles.webview}
|
||||
onMessage={handleMessage}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
startInLoadingState={true}
|
||||
scalesPageToFit={true}
|
||||
cacheEnabled={false}
|
||||
incognito={true}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@ -13,8 +13,11 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
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 { useAuth } from '@/contexts/AuthContext';
|
||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||
import { ProfileDrawer } from '@/components/ProfileDrawer';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
@ -43,6 +46,7 @@ const generateInviteCode = (identifier: string): string => {
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, logout } = useAuth();
|
||||
const { clearAllBeneficiaryData } = useBeneficiary();
|
||||
const toast = useToast();
|
||||
|
||||
// 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();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Permission needed', 'Please allow access to your photo library to change avatar.');
|
||||
@ -84,13 +89,18 @@ export default function ProfileScreen() {
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.5,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const uri = result.assets[0].uri;
|
||||
setAvatarUri(uri);
|
||||
await SecureStore.setItemAsync('userAvatar', uri);
|
||||
const originalUri = result.assets[0].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',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
// Clear local UI state
|
||||
setAvatarUri(null);
|
||||
// Clear beneficiary context
|
||||
await clearAllBeneficiaryData();
|
||||
// Logout (clears SecureStore and AsyncStorage)
|
||||
await logout();
|
||||
router.replace('/(auth)/login');
|
||||
},
|
||||
@ -161,18 +176,21 @@ export default function ProfileScreen() {
|
||||
>
|
||||
{/* Profile Card */}
|
||||
<View style={styles.profileCard}>
|
||||
<TouchableOpacity style={styles.avatarSection} onPress={handleAvatarPress}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<View style={styles.avatarSection}>
|
||||
<TouchableOpacity
|
||||
style={styles.avatarContainer}
|
||||
onPress={handleAvatarChange}
|
||||
>
|
||||
{avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Text style={styles.avatarText}>{userInitial}</Text>
|
||||
)}
|
||||
<View style={styles.avatarEditBadge}>
|
||||
<Ionicons name="camera" size={14} color={AppColors.white} />
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.avatarEditBadge} onPress={handleAvatarChange}>
|
||||
<Ionicons name="camera" size={14} color={AppColors.white} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.displayName}>{displayName}</Text>
|
||||
<Text style={styles.userEmail}>{user?.email || ''}</Text>
|
||||
@ -247,6 +265,7 @@ export default function ProfileScreen() {
|
||||
settings={settings}
|
||||
onSettingChange={handleSettingChange}
|
||||
/>
|
||||
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -299,6 +318,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
avatarSection: {
|
||||
marginBottom: Spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
avatarContainer: {
|
||||
width: AvatarSizes.xl,
|
||||
@ -307,7 +327,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: AppColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarImage: {
|
||||
width: AvatarSizes.xl,
|
||||
|
||||
87
components/ImageLightbox.tsx
Normal file
87
components/ImageLightbox.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -20,6 +20,8 @@ interface BeneficiaryContextType {
|
||||
addLocalBeneficiary: (data: string | AddBeneficiaryData) => Promise<Beneficiary>;
|
||||
updateLocalBeneficiary: (id: number, data: Partial<Beneficiary>) => Promise<Beneficiary | null>;
|
||||
removeLocalBeneficiary: (id: number) => Promise<void>;
|
||||
// Clear all data (used on logout)
|
||||
clearAllBeneficiaryData: () => Promise<void>;
|
||||
// Helper to format beneficiary context for AI
|
||||
getBeneficiaryContext: () => string;
|
||||
}
|
||||
@ -116,6 +118,13 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
|
||||
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(() => {
|
||||
if (!currentBeneficiary) {
|
||||
return '';
|
||||
@ -182,6 +191,7 @@ export function BeneficiaryProvider({ children }: { children: React.ReactNode })
|
||||
addLocalBeneficiary,
|
||||
updateLocalBeneficiary,
|
||||
removeLocalBeneficiary,
|
||||
clearAllBeneficiaryData,
|
||||
getBeneficiaryContext,
|
||||
}}
|
||||
>
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@ -26,6 +26,7 @@
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "^14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.21",
|
||||
@ -10769,6 +10770,18 @@
|
||||
"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": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "^14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.21",
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashb
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import { File } from 'expo-file-system';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Callback for handling unauthorized responses (401)
|
||||
let onUnauthorizedCallback: (() => void) | null = null;
|
||||
@ -158,6 +159,10 @@ class ApiService {
|
||||
await SecureStore.deleteItemAsync('legacyAccessToken');
|
||||
await SecureStore.deleteItemAsync('privileges');
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user