Files
Greenlens/app/(tabs)/index.tsx
2026-05-10 22:37:01 +02:00

1064 lines
31 KiB
TypeScript

import React, { useMemo, useState, useRef, useEffect } from 'react';
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
Dimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSafeAnalytics } from '../../services/analytics';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { SafeImage } from '../../components/SafeImage';
import { Plant } from '../../types';
import { useCoachMarks } from '../../context/CoachMarksContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
type FilterKey = 'all' | 'today' | 'week' | 'healthy' | 'dormant';
const DAY_MS = 24 * 60 * 60 * 1000;
const CONTENT_BOTTOM_PADDING = 12;
const FAB_BOTTOM_OFFSET = 16;
type OnboardingStepId = 'scan' | 'reminder' | 'lexicon' | 'theme' | 'collection';
function OnboardingChecklist({
plants,
appearanceMode,
colorPalette,
lexiconExplored,
customizationDone,
colors,
router,
t,
getLexiconSearchHistory,
registerLayout,
posthog,
}: {
plants: Plant[];
appearanceMode: string;
colorPalette: string;
lexiconExplored: boolean;
customizationDone: boolean;
colors: any;
router: any;
t: any;
getLexiconSearchHistory: () => string[];
registerLayout: (key: string, layout: { x: number; y: number; width: number; height: number }) => void;
posthog: any;
}) {
const cardRef = useRef<View>(null);
const previousProgressRef = useRef<number | null>(null);
const lexiconHistoryCount = getLexiconSearchHistory().length;
const plantsCount = plants.length;
const reminderReady = plants.some((plant) => Boolean(plant.notificationsEnabled));
const themeCustomized = customizationDone || appearanceMode !== 'system' || colorPalette !== 'forest';
const lexiconCompleted = lexiconExplored || lexiconHistoryCount > 0;
const checklist = [
{ id: 'scan' as const, label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline' },
{ id: 'reminder' as const, label: t.stepReminder, completed: reminderReady, icon: 'notifications-outline' },
{ id: 'lexicon' as const, label: t.stepLexicon, completed: lexiconCompleted, icon: 'search-outline' },
{ id: 'theme' as const, label: t.stepTheme, completed: themeCustomized, icon: 'color-palette-outline' },
{ id: 'collection' as const, label: t.stepCollection, completed: plantsCount >= 3, icon: 'albums-outline' },
];
const completedCount = checklist.filter((item) => item.completed).length;
const progressRatio = completedCount / checklist.length;
const progressWidth: `${number}%` = completedCount === 0 ? '0%' : `${Math.max(progressRatio * 100, 12)}%`;
const nextItem = checklist.find((item) => !item.completed) ?? null;
useEffect(() => {
if (previousProgressRef.current === completedCount) return;
previousProgressRef.current = completedCount;
posthog.capture('onboarding_progress_updated', {
completed_count: completedCount,
total_count: checklist.length,
next_step_id: nextItem?.id ?? 'complete',
});
}, [completedCount, checklist.length, nextItem?.id, posthog]);
if (completedCount === checklist.length) {
return null;
}
const navigateToStep = (stepId: OnboardingStepId) => {
posthog.capture('onboarding_step_opened', {
step_id: stepId,
completed_count: completedCount,
});
if (stepId === 'scan' || stepId === 'collection') {
router.push('/scanner');
return;
}
if (stepId === 'reminder') {
if (plants[0]?.id) {
router.push(`/plant/${plants[0].id}`);
} else {
router.push('/scanner');
}
return;
}
if (stepId === 'lexicon') {
router.push('/lexicon');
return;
}
router.push('/onboarding/customize');
};
return (
<View
ref={cardRef}
style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}
onLayout={() => {
cardRef.current?.measureInWindow((x, y, width, height) => {
registerLayout('onboarding_checklist', { x, y, width, height });
});
}}
>
<View style={styles.checklistHeader}>
<View style={styles.checklistHeaderCopy}>
<Text style={[styles.checklistEyebrow, { color: colors.primary }]}>
{t.onboardingChecklistIntro}
</Text>
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.onboardingChecklistTitle}</Text>
<Text style={[styles.checklistSubtitle, { color: colors.textSecondary }]}>
{(nextItem ? t.onboardingChecklistNextLabel : t.onboardingChecklistDone).replace('{0}', nextItem?.label ?? '')}
</Text>
</View>
<View style={[styles.checklistProgressPill, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.checklistProgressText, { color: colors.primaryDark }]}>
{t.onboardingChecklistProgress
.replace('{0}', completedCount.toString())
.replace('{1}', checklist.length.toString())}
</Text>
</View>
</View>
<View style={[styles.progressTrack, { backgroundColor: colors.surfaceMuted }]}>
<View
style={[
styles.progressFill,
{
backgroundColor: colors.primary,
width: progressWidth,
},
]}
/>
</View>
<View style={styles.checklistGrid}>
{checklist.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.checklistItem}
onPress={() => navigateToStep(item.id)}
disabled={item.completed}
>
<View style={[styles.checkIcon, { backgroundColor: item.completed ? colors.successSoft : colors.surfaceMuted }]}>
<Ionicons
name={item.completed ? 'checkmark-circle' : item.icon as any}
size={18}
color={item.completed ? colors.success : colors.textMuted}
/>
</View>
<Text
style={[
styles.checklistText,
{
color: item.completed ? colors.textMuted : colors.text,
textDecorationLine: item.completed ? 'line-through' : 'none',
},
]}
numberOfLines={1}
>
{item.label}
</Text>
{!item.completed && <Ionicons name="chevron-forward" size={12} color={colors.textMuted} />}
</TouchableOpacity>
))}
</View>
</View>
);
}
const getDaysUntilWatering = (plant: Plant): number => {
const lastWateredTs = new Date(plant.lastWatered).getTime();
if (Number.isNaN(lastWateredTs)) return 0;
const dueTs = lastWateredTs + (plant.careInfo.waterIntervalDays * DAY_MS);
const remainingMs = dueTs - Date.now();
if (remainingMs <= 0) return 0;
return Math.ceil(remainingMs / DAY_MS);
};
export default function HomeScreen() {
const {
session,
plants,
isLoadingPlants,
profileImageUri,
profileName,
billingSummary,
isLoadingBilling,
t,
isDarkMode,
appearanceMode,
colorPalette,
getLexiconSearchHistory,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const [onboardingSignals, setOnboardingSignals] = useState({
lexiconExplored: false,
customizationDone: false,
});
const { layouts, registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null);
const tourStartRequestedRef = useRef(false);
const posthog = useSafeAnalytics();
useFocusEffect(
React.useCallback(() => {
if (!session?.userId) {
setOnboardingSignals({
lexiconExplored: false,
customizationDone: false,
});
return;
}
setOnboardingSignals(OnboardingProgressService.getSignals(session.userId));
}, [session?.userId]),
);
// Tour nach Registrierung starten
useEffect(() => {
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const tourSteps = [
{
elementKey: 'fab',
title: t.tourFabTitle,
description: t.tourFabDesc,
tooltipSide: 'above' as const,
},
{
elementKey: 'tab_search',
title: t.tourSearchTitle,
description: t.tourSearchDesc,
tooltipSide: 'above' as const,
},
{
elementKey: 'tab_profile',
title: t.tourProfileTitle,
description: t.tourProfileDesc,
tooltipSide: 'above' as const,
},
{
elementKey: 'onboarding_checklist',
title: t.tourChecklistTitle,
description: t.tourChecklistDesc,
tooltipSide: 'below' as const,
},
];
const registerTabLayouts = () => {
const tabBarBottom = SCREEN_H - 85;
const tabW = SCREEN_W / 3;
registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 });
registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 });
};
const checkTour = async () => {
if (tourStartRequestedRef.current) return;
const flag = await AsyncStorage.getItem('greenlens_show_tour');
if (flag !== 'true') return;
tourStartRequestedRef.current = true;
const startWhenReady = async (attempt = 0) => {
if (cancelled) return;
registerTabLayouts();
if (!layouts.fab && attempt < 10) {
retryTimer = setTimeout(() => startWhenReady(attempt + 1), 250);
return;
}
startTour(tourSteps);
await AsyncStorage.removeItem('greenlens_show_tour');
tourStartRequestedRef.current = false;
};
retryTimer = setTimeout(() => startWhenReady(), 1000);
};
checkTour();
return () => {
cancelled = true;
if (retryTimer) {
clearTimeout(retryTimer);
}
};
}, [layouts, registerLayout, startTour, t.tourChecklistDesc, t.tourChecklistTitle, t.tourFabDesc, t.tourFabTitle, t.tourProfileDesc, t.tourProfileTitle, t.tourSearchDesc, t.tourSearchTitle]);
const copy = t;
const greetingText = useMemo(() => {
const hour = new Date().getHours();
if (hour < 12) return copy.greetingMorning;
if (hour < 18) return copy.greetingAfternoon;
return copy.greetingEvening;
}, [copy.greetingAfternoon, copy.greetingEvening, copy.greetingMorning]);
const creditsText = useMemo(() => {
if (isLoadingBilling && !billingSummary) {
return '...';
}
if (!billingSummary) {
return `-- ${copy.creditsLabel}`;
}
return `${billingSummary.credits.available} ${copy.creditsLabel}`;
}, [billingSummary, copy.creditsLabel, isLoadingBilling]);
const thirstyCount = useMemo(
() => plants.filter(plant => getDaysUntilWatering(plant) === 0).length,
[plants]
);
const dueTodayPlants = useMemo(
() => plants.filter(plant => getDaysUntilWatering(plant) === 0),
[plants]
);
const filteredPlants = useMemo(() => {
return plants.filter((plant) => {
if (activeFilter === 'all') return true;
const daysUntil = getDaysUntilWatering(plant);
if (activeFilter === 'today') return daysUntil === 0;
if (activeFilter === 'week') return daysUntil <= 7;
if (activeFilter === 'healthy') return daysUntil >= 2;
return plant.careInfo.waterIntervalDays >= 14;
});
}, [plants, activeFilter]);
const chips: Array<{ key: FilterKey; label: string }> = [
{ key: 'all', label: copy.all },
{ key: 'today', label: copy.today },
{ key: 'week', label: copy.week },
{ key: 'healthy', label: copy.healthy },
{ key: 'dormant', label: copy.dormant },
];
const handleBellPress = () => {
setActiveFilter('today');
if (dueTodayPlants.length === 0) {
Alert.alert(copy.reminderTitle, copy.reminderNone);
return;
}
const previewNames = dueTodayPlants
.slice(0, 6)
.map((plant) => `- ${plant.name}`)
.join('\n');
const remainingCount = dueTodayPlants.length - 6;
const remainingText = remainingCount > 0
? `\n+ ${remainingCount} ${copy.more}`
: '';
Alert.alert(
copy.reminderTitle,
`${copy.reminderDue.replace('{0}', dueTodayPlants.length.toString())}\n\n${previewNames}${remainingText}`
);
};
if (isLoadingPlants) {
return (
<SafeAreaView style={[styles.loadingContainer, { backgroundColor: colors.background }]} edges={['top', 'left', 'right']}>
<ThemeBackdrop colors={colors} />
<ActivityIndicator size="large" color={colors.primary} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={['top', 'left', 'right']}>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: Math.max(CONTENT_BOTTOM_PADDING, insets.bottom + CONTENT_BOTTOM_PADDING) },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={[styles.avatarWrap, { backgroundColor: colors.primaryDark }]}>
{profileImageUri ? (
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
) : (
<Ionicons name="leaf" size={20} color={colors.iconOnImage} />
)}
</View>
<View style={styles.headerTextBlock}>
<Text style={[styles.greetingText, { color: colors.textSecondary }]}>{greetingText}</Text>
<View style={styles.nameRow}>
<Text style={[styles.nameText, { color: colors.text }]} numberOfLines={1}>
{profileName || ''}
</Text>
<View
style={[
styles.creditsPill,
{
backgroundColor: colors.cardBg,
borderColor: colors.cardBorder,
},
]}
>
<Text style={[styles.creditsText, { color: colors.textSecondary }]} numberOfLines={1}>
{creditsText}
</Text>
</View>
</View>
</View>
</View>
<TouchableOpacity
style={[
styles.bellBtn,
{
backgroundColor: colors.cardBg,
borderColor: colors.cardBorder,
shadowColor: colors.cardShadow,
},
]}
onPress={handleBellPress}
activeOpacity={0.8}
>
<Ionicons name="notifications-outline" size={20} color={colors.text} />
</TouchableOpacity>
</View>
<View style={[styles.priorityCard, { backgroundColor: colors.primaryDark }]}>
<View style={styles.priorityLabelRow}>
<Ionicons name="water-outline" size={14} color={colors.heroButton} />
<Text style={[styles.priorityLabel, { color: colors.heroButton }]}>{copy.needsWaterToday}</Text>
</View>
<Text style={[styles.priorityTitle, { color: colors.iconOnImage }]}>
{copy.plantsThirsty.replace('{0}', thirstyCount.toString())}
</Text>
<TouchableOpacity
style={[
styles.priorityButton,
{
backgroundColor: colors.heroButton,
borderColor: colors.heroButtonBorder,
},
]}
onPress={handleBellPress}
activeOpacity={0.85}
>
<Text style={[styles.priorityButtonText, { color: colors.text }]}>{copy.viewSchedule}</Text>
<Ionicons name="arrow-forward" size={14} color={colors.text} />
</TouchableOpacity>
<Ionicons
name="water"
size={124}
color={colors.overlay}
style={styles.priorityBgIcon}
/>
</View>
<OnboardingChecklist
plants={plants}
appearanceMode={appearanceMode}
colorPalette={colorPalette}
lexiconExplored={onboardingSignals.lexiconExplored}
customizationDone={onboardingSignals.customizationDone}
colors={colors}
router={router}
t={t}
getLexiconSearchHistory={getLexiconSearchHistory}
registerLayout={registerLayout}
posthog={posthog}
/>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
{chips.map(chip => (
<TouchableOpacity
key={chip.key}
style={[
activeFilter === chip.key ? styles.filterChipActive : styles.filterChip,
activeFilter === chip.key
? { backgroundColor: colors.primary, shadowColor: colors.fabShadow }
: { backgroundColor: colors.cardBg, borderColor: colors.cardBorder },
]}
onPress={() => setActiveFilter(chip.key)}
activeOpacity={0.85}
>
<Text
style={[
activeFilter === chip.key ? styles.filterTextActive : styles.filterText,
{ color: activeFilter === chip.key ? colors.onPrimary : colors.textSecondary },
]}
>
{chip.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={styles.collectionHeader}>
<Text style={[styles.collectionTitle, { color: colors.text }]}>{copy.collectionTitle}</Text>
<View style={[styles.collectionCountPill, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
<Text style={[styles.collectionCountText, { color: colors.textSecondary }]}>
{copy.collectionCount.replace('{0}', filteredPlants.length.toString())}
</Text>
</View>
</View>
{filteredPlants.length === 0 ? (
<View style={[styles.emptyState, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="leaf-outline" size={28} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>
{plants.length === 0 ? copy.emptyCollectionTitle : copy.noneInFilter}
</Text>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{plants.length === 0 ? copy.emptyCollectionHint : copy.noneInFilter}
</Text>
{plants.length === 0 && (
<TouchableOpacity
style={[styles.emptyCta, { backgroundColor: colors.primary, shadowColor: colors.fabShadow }]}
onPress={() => router.push('/scanner')}
activeOpacity={0.86}
>
<Ionicons name="scan-outline" size={15} color={colors.onPrimary} />
<Text style={[styles.emptyCtaText, { color: colors.onPrimary }]}>{copy.scanFirstPlant}</Text>
</TouchableOpacity>
)}
</View>
) : (
filteredPlants.map((plant) => {
const daysUntil = getDaysUntilWatering(plant);
const thirsty = daysUntil === 0;
const nextWaterText = thirsty
? copy.today
: t.inXDays.replace('{0}', daysUntil.toString());
return (
<TouchableOpacity
key={plant.id}
style={[
styles.plantCard,
{
backgroundColor: colors.cardBg,
borderColor: colors.cardBorder,
shadowColor: colors.cardShadow,
},
]}
activeOpacity={0.9}
onPress={() => router.push(`/plant/${plant.id}`)}
>
<View style={[styles.plantImageWrap, { borderColor: colors.border }]}>
<SafeImage uri={plant.imageUri} style={[styles.plantImage, { backgroundColor: colors.surfaceStrong }]} />
</View>
<View style={styles.plantBody}>
<View style={styles.plantHeadRow}>
<View style={styles.plantTitleCol}>
<Text style={[styles.plantName, { color: colors.text }]} numberOfLines={1}>
{plant.name}
</Text>
<Text style={[styles.botanicalName, { color: colors.textSecondary }]} numberOfLines={1}>
{plant.botanicalName}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
<View style={styles.metaRow}>
<View
style={[
thirsty ? styles.statusThirsty : styles.statusHealthy,
{ backgroundColor: thirsty ? colors.dangerSoft : colors.successSoft },
]}
>
<Text
style={[
thirsty ? styles.statusThirstyText : styles.statusHealthyText,
{ color: thirsty ? colors.danger : colors.success },
]}
>
{thirsty ? copy.thirsty : copy.healthyStatus}
</Text>
</View>
<View style={[styles.nextWaterPill, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Ionicons name="water-outline" size={12} color={colors.textMuted} />
<Text style={[styles.waterMeta, { color: colors.textMuted }]}>
{copy.nextWaterLabel}: {nextWaterText}
</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
})
)}
</ScrollView>
<TouchableOpacity
ref={fabRef}
style={[
styles.fab,
{ bottom: Math.max(FAB_BOTTOM_OFFSET, insets.bottom + FAB_BOTTOM_OFFSET) },
{
backgroundColor: colors.fabBg,
borderColor: colors.surface,
shadowColor: colors.fabShadow,
},
]}
activeOpacity={0.8}
onPress={() => router.push('/scanner')}
onLayout={() => {
fabRef.current?.measureInWindow((x, y, width, height) => {
registerLayout('fab', { x, y, width, height });
});
}}
>
<Ionicons name="add" size={30} color={colors.onPrimary} />
</TouchableOpacity>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
content: {
paddingHorizontal: 24,
paddingTop: 14,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 26,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
headerTextBlock: {
flex: 1,
minWidth: 0,
},
avatarWrap: {
width: 48,
height: 48,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
avatarImage: {
width: '100%',
height: '100%',
},
greetingText: {
fontSize: 13,
fontWeight: '600',
},
nameRow: {
marginTop: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minWidth: 0,
},
nameText: {
fontSize: 21,
fontWeight: '700',
letterSpacing: 0.1,
flexShrink: 1,
},
creditsPill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 3,
},
creditsText: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.3,
textTransform: 'uppercase',
},
bellBtn: {
width: 46,
height: 46,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
priorityCard: {
borderRadius: 32,
padding: 24,
marginBottom: 20,
overflow: 'hidden',
},
priorityLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 8,
},
priorityLabel: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.9,
},
priorityTitle: {
fontSize: 29,
fontWeight: '700',
lineHeight: 36,
maxWidth: 260,
marginBottom: 14,
},
priorityButton: {
alignSelf: 'flex-start',
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
priorityButtonText: {
fontSize: 13,
fontWeight: '700',
},
priorityBgIcon: {
position: 'absolute',
right: -18,
bottom: -22,
},
filterRow: {
gap: 10,
paddingVertical: 2,
paddingRight: 4,
marginBottom: 16,
},
collectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
collectionTitle: {
fontSize: 19,
fontWeight: '700',
letterSpacing: 0.2,
},
collectionCountPill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 4,
},
collectionCountText: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
filterChip: {
paddingHorizontal: 22,
paddingVertical: 10,
borderRadius: 16,
borderWidth: 1,
},
filterChipActive: {
paddingHorizontal: 22,
paddingVertical: 10,
borderRadius: 16,
shadowOpacity: 0.24,
shadowRadius: 10,
shadowOffset: { width: 0, height: 3 },
elevation: 3,
},
filterText: {
fontSize: 13,
fontWeight: '700',
},
filterTextActive: {
fontSize: 13,
fontWeight: '700',
},
plantCard: {
borderRadius: 30,
borderWidth: 1,
padding: 15,
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
marginBottom: 14,
shadowOpacity: 0.1,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
},
plantImageWrap: {
borderWidth: 1,
borderRadius: 20,
overflow: 'hidden',
},
plantImage: {
width: 98,
height: 98,
borderRadius: 20,
},
plantBody: {
flex: 1,
paddingTop: 2,
},
plantHeadRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 8,
gap: 8,
},
plantTitleCol: {
flex: 1,
minWidth: 0,
},
plantName: {
fontWeight: '700',
fontSize: 19,
letterSpacing: 0.15,
},
botanicalName: {
fontSize: 12.5,
fontStyle: 'italic',
fontWeight: '500',
marginTop: 2,
opacity: 0.95,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
},
statusThirsty: {
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 4,
},
statusHealthy: {
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 4,
},
statusThirstyText: {
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
statusHealthyText: {
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
waterMeta: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
nextWaterPill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 9,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
emptyState: {
borderRadius: 24,
borderWidth: 1,
paddingVertical: 32,
paddingHorizontal: 22,
alignItems: 'center',
gap: 10,
},
emptyIconWrap: {
width: 56,
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
emptyTitle: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
emptyText: {
fontSize: 13,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
},
emptyCta: {
marginTop: 8,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
shadowOpacity: 0.3,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
emptyCtaText: {
fontSize: 13,
fontWeight: '700',
},
fab: {
position: 'absolute',
right: 24,
width: 66,
height: 66,
borderRadius: 33,
borderWidth: 4,
alignItems: 'center',
justifyContent: 'center',
shadowOpacity: 0.38,
shadowRadius: 14,
shadowOffset: { width: 0, height: 5 },
elevation: 9,
},
checklistCard: {
borderRadius: 24,
borderWidth: 1,
padding: 20,
marginBottom: 20,
},
checklistHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
marginBottom: 14,
},
checklistHeaderCopy: {
flex: 1,
gap: 4,
},
checklistEyebrow: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
checklistTitle: {
fontSize: 16,
fontWeight: '700',
},
checklistSubtitle: {
fontSize: 13,
lineHeight: 18,
},
checklistProgressPill: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
checklistProgressText: {
fontSize: 12,
fontWeight: '700',
},
progressTrack: {
height: 8,
borderRadius: 999,
overflow: 'hidden',
marginBottom: 16,
},
progressFill: {
height: '100%',
borderRadius: 999,
},
checklistGrid: {
gap: 12,
},
checklistItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
checkIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
checklistText: {
flex: 1,
fontSize: 14,
fontWeight: '500',
},
});