527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
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',
|
|
}
|
|
});
|