Initial commit for Greenlens
This commit is contained in:
526
app/(tabs)/profile.tsx
Normal file
526
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Image,
|
||||
Alert,
|
||||
TextInput,
|
||||
Keyboard,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { Language } from '../../types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const getDaysUntilWatering = (lastWatered: string, intervalDays: number): number => {
|
||||
const lastWateredTs = new Date(lastWatered).getTime();
|
||||
if (Number.isNaN(lastWateredTs)) return 0;
|
||||
const dueTs = lastWateredTs + (intervalDays * DAY_MS);
|
||||
const remainingMs = dueTs - Date.now();
|
||||
if (remainingMs <= 0) return 0;
|
||||
return Math.ceil(remainingMs / DAY_MS);
|
||||
};
|
||||
|
||||
const getProfileCopy = (language: Language) => {
|
||||
if (language === 'de') {
|
||||
return {
|
||||
overviewLabel: 'Überblick',
|
||||
statPlants: 'Pflanzen',
|
||||
statDueToday: 'Heute fällig',
|
||||
statReminders: 'Erinnerungen an',
|
||||
account: 'Account',
|
||||
changePhoto: 'Foto ändern',
|
||||
removePhoto: 'Foto entfernen',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'Dein Name',
|
||||
saveName: 'Name speichern',
|
||||
photoErrorTitle: 'Fehler',
|
||||
photoErrorMessage: 'Profilfoto konnte nicht geladen werden.',
|
||||
nameErrorTitle: 'Name fehlt',
|
||||
nameErrorMessage: 'Bitte gib einen Namen ein.',
|
||||
menuSettings: 'Einstellungen',
|
||||
menuBilling: 'Abo & Credits',
|
||||
menuData: 'Daten & Datenschutz',
|
||||
logout: 'Abmelden',
|
||||
logoutConfirmTitle: 'Abmelden?',
|
||||
logoutConfirmMessage: 'Möchtest du dich wirklich abmelden?',
|
||||
logoutConfirmBtn: 'Abmelden',
|
||||
};
|
||||
}
|
||||
if (language === 'es') {
|
||||
return {
|
||||
overviewLabel: 'Resumen',
|
||||
statPlants: 'Plantas',
|
||||
statDueToday: 'Vencen hoy',
|
||||
statReminders: 'Recordatorios',
|
||||
account: 'Cuenta',
|
||||
changePhoto: 'Cambiar foto',
|
||||
removePhoto: 'Eliminar foto',
|
||||
nameLabel: 'Nombre',
|
||||
namePlaceholder: 'Tu nombre',
|
||||
saveName: 'Guardar nombre',
|
||||
photoErrorTitle: 'Error',
|
||||
photoErrorMessage: 'No se pudo cargar la foto.',
|
||||
nameErrorTitle: 'Falta nombre',
|
||||
nameErrorMessage: 'Por favor ingresa un nombre.',
|
||||
menuSettings: 'Ajustes',
|
||||
menuBilling: 'Suscripción y Créditos',
|
||||
menuData: 'Datos y Privacidad',
|
||||
logout: 'Cerrar sesión',
|
||||
logoutConfirmTitle: '¿Cerrar sesión?',
|
||||
logoutConfirmMessage: '¿Realmente quieres cerrar sesión?',
|
||||
logoutConfirmBtn: 'Cerrar sesión',
|
||||
};
|
||||
}
|
||||
return {
|
||||
overviewLabel: 'Overview',
|
||||
statPlants: 'Plants',
|
||||
statDueToday: 'Due today',
|
||||
statReminders: 'Reminders on',
|
||||
account: 'Account',
|
||||
changePhoto: 'Change photo',
|
||||
removePhoto: 'Remove photo',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'Your name',
|
||||
saveName: 'Save name',
|
||||
photoErrorTitle: 'Error',
|
||||
photoErrorMessage: 'Could not load profile photo.',
|
||||
nameErrorTitle: 'Name missing',
|
||||
nameErrorMessage: 'Please enter a name.',
|
||||
menuSettings: 'Preferences',
|
||||
menuBilling: 'Billing & Credits',
|
||||
menuData: 'Data & Privacy',
|
||||
logout: 'Sign Out',
|
||||
logoutConfirmTitle: 'Sign out?',
|
||||
logoutConfirmMessage: 'Do you really want to sign out?',
|
||||
logoutConfirmBtn: 'Sign Out',
|
||||
};
|
||||
};
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const {
|
||||
plants,
|
||||
language,
|
||||
t,
|
||||
isDarkMode,
|
||||
colorPalette,
|
||||
profileImageUri,
|
||||
setProfileImage,
|
||||
profileName,
|
||||
setProfileName,
|
||||
signOut,
|
||||
} = useApp();
|
||||
|
||||
const router = useRouter();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
const [isUpdatingImage, setIsUpdatingImage] = useState(false);
|
||||
const [isSavingName, setIsSavingName] = useState(false);
|
||||
const [draftName, setDraftName] = useState(profileName);
|
||||
|
||||
const copy = useMemo(() => getProfileCopy(language), [language]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftName(profileName);
|
||||
}, [profileName]);
|
||||
|
||||
const normalizedDraftName = draftName.trim();
|
||||
const canSaveName = normalizedDraftName.length > 0 && normalizedDraftName !== profileName;
|
||||
|
||||
const dueTodayCount = useMemo(
|
||||
() => plants.filter(plant => getDaysUntilWatering(plant.lastWatered, plant.careInfo.waterIntervalDays) === 0).length,
|
||||
[plants]
|
||||
);
|
||||
|
||||
const remindersEnabledCount = useMemo(
|
||||
() => plants.filter(plant => Boolean(plant.notificationsEnabled)).length,
|
||||
[plants]
|
||||
);
|
||||
|
||||
const handlePickProfileImage = async () => {
|
||||
if (isUpdatingImage) return;
|
||||
|
||||
setIsUpdatingImage(true);
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.85,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
const imageUri = asset.base64
|
||||
? `data:image/jpeg;base64,${asset.base64}`
|
||||
: asset.uri;
|
||||
await setProfileImage(imageUri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick profile image', error);
|
||||
Alert.alert(copy.photoErrorTitle, copy.photoErrorMessage);
|
||||
} finally {
|
||||
setIsUpdatingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = async () => {
|
||||
await setProfileImage(null);
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!normalizedDraftName) {
|
||||
Alert.alert(copy.nameErrorTitle, copy.nameErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSaveName) {
|
||||
Keyboard.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingName(true);
|
||||
try {
|
||||
await setProfileName(normalizedDraftName);
|
||||
Keyboard.dismiss();
|
||||
} finally {
|
||||
setIsSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ label: copy.menuSettings, icon: 'settings-outline', route: '/profile/preferences' as any },
|
||||
{ label: copy.menuBilling, icon: 'card-outline', route: '/profile/billing' as any },
|
||||
{ label: copy.menuData, icon: 'shield-checkmark-outline', route: '/profile/data' as any },
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['top', 'left', 'right']} style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.tabProfile}</Text>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.overviewLabel}</Text>
|
||||
<View style={styles.statsRow}>
|
||||
{[
|
||||
{ label: copy.statPlants, value: plants.length.toString() },
|
||||
{ label: copy.statDueToday, value: dueTodayCount.toString() },
|
||||
{ label: copy.statReminders, value: remindersEnabledCount.toString() },
|
||||
].map((item) => (
|
||||
<View
|
||||
key={item.label}
|
||||
style={[styles.statCard, { backgroundColor: colors.surfaceStrong, borderColor: colors.borderStrong }]}
|
||||
>
|
||||
<Text style={[styles.statValue, { color: colors.text }]}>{item.value}</Text>
|
||||
<Text style={[styles.statLabel, { color: colors.textSecondary }]}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, styles.accountCard, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.account}</Text>
|
||||
|
||||
<View style={styles.accountRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.avatarFrame, { backgroundColor: colors.primaryDark }]}
|
||||
onPress={handlePickProfileImage}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{profileImageUri ? (
|
||||
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Ionicons name="person" size={34} color={colors.iconOnImage} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.accountMeta}>
|
||||
<Text style={[styles.currentName, { color: colors.text }]}>{profileName}</Text>
|
||||
<Text style={[styles.plantsCount, { color: colors.textSecondary }]}>
|
||||
{plants.length} {copy.statPlants}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.photoButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.photoActionBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={handlePickProfileImage}
|
||||
activeOpacity={0.85}
|
||||
disabled={isUpdatingImage}
|
||||
>
|
||||
<Text style={[styles.photoActionText, { color: colors.onPrimary }]}>
|
||||
{isUpdatingImage ? '...' : copy.changePhoto}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{profileImageUri ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.photoActionBtn, styles.photoSecondaryBtn, { borderColor: colors.borderStrong }]}
|
||||
onPress={handleRemovePhoto}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.photoSecondaryText, { color: colors.textSecondary }]}>
|
||||
{copy.removePhoto}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.fieldLabel, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
|
||||
<View style={styles.nameRow}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.nameInput,
|
||||
{
|
||||
color: colors.text,
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: colors.inputBorder,
|
||||
},
|
||||
]}
|
||||
value={draftName}
|
||||
placeholder={copy.namePlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
onChangeText={setDraftName}
|
||||
maxLength={40}
|
||||
autoCapitalize="words"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSaveName}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveNameBtn,
|
||||
{
|
||||
backgroundColor: canSaveName ? colors.primary : colors.surfaceStrong,
|
||||
borderColor: canSaveName ? colors.primaryDark : colors.borderStrong,
|
||||
},
|
||||
]}
|
||||
onPress={handleSaveName}
|
||||
disabled={!canSaveName || isSavingName}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.saveNameText, { color: canSaveName ? colors.onPrimary : colors.textMuted }]}>
|
||||
{isSavingName ? '...' : copy.saveName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, padding: 0, overflow: 'hidden' }]}>
|
||||
{menuItems.map((item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={item.route}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
idx !== menuItems.length - 1 && { borderBottomWidth: 1, borderBottomColor: colors.borderStrong }
|
||||
]}
|
||||
onPress={() => router.push(item.route)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<Ionicons name={item.icon as any} size={20} color={colors.text} />
|
||||
<Text style={[styles.menuItemText, { color: colors.text }]}>{item.label}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
{/* Logout */}
|
||||
<TouchableOpacity
|
||||
style={[styles.logoutBtn, { borderColor: colors.dangerSoft, backgroundColor: colors.dangerSoft }]}
|
||||
activeOpacity={0.78}
|
||||
onPress={() => {
|
||||
Alert.alert(copy.logoutConfirmTitle, copy.logoutConfirmMessage, [
|
||||
{ text: t.cancel, style: 'cancel' },
|
||||
{
|
||||
text: copy.logoutConfirmBtn,
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
router.replace('/auth/login');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={18} color={colors.danger} />
|
||||
<Text style={[styles.logoutText, { color: colors.danger }]}>{copy.logout}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
title: {
|
||||
marginTop: 14,
|
||||
marginBottom: 14,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
gap: 10,
|
||||
},
|
||||
accountCard: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
logoutText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 22,
|
||||
lineHeight: 24,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
accountRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
accountMeta: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
},
|
||||
currentName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
plantsCount: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
avatarFrame: {
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
photoButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
photoActionBtn: {
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
photoSecondaryBtn: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
photoActionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
photoSecondaryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
fieldLabel: {
|
||||
marginTop: 2,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
nameInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveNameBtn: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
saveNameText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
menuItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user