feat: Initialize GreenLens project with core dependencies and structure

Sets up the project using Vite, React, and TypeScript. Includes initial configuration for Tailwind CSS, Gemini API integration, and local storage management. Defines basic types for plant data and UI elements. The README is updated with local development instructions.
This commit is contained in:
knuthtimo-lab 2026-01-28 11:43:24 +01:00
parent 85960de05f
commit 024eec6686
20 changed files with 2247 additions and 8 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

680
App.tsx Normal file
View File

@ -0,0 +1,680 @@
import React, { useState, useEffect, useRef } from 'react';
import { Tab, Plant, IdentificationResult, Language } from './types';
import { StorageService } from './services/storageService';
import { PlantRecognitionService } from './services/plantRecognitionService';
import { PlantDatabaseService } from './services/plantDatabaseService';
import { getTranslation } from './utils/translations';
import { TabBar } from './components/TabBar';
import { PlantCard } from './components/PlantCard';
import { PlantSkeleton } from './components/PlantSkeleton';
import { ResultCard } from './components/ResultCard';
import { PlantDetail } from './components/PlantDetail';
import { Toast } from './components/Toast';
import { Camera, Image as ImageIcon, HelpCircle, X, Settings as SettingsIcon, ScanLine, Leaf, Plus, Zap, Search, ArrowRight, ArrowLeft, Globe, ChevronDown, ChevronUp, Check, Cpu, BookOpen } from 'lucide-react';
const generateId = () => Math.random().toString(36).substr(2, 9);
const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<Tab>(Tab.HOME);
const [plants, setPlants] = useState<Plant[]>([]);
const [isDarkMode, setIsDarkMode] = useState(false);
const [language, setLanguage] = useState<Language>('de');
const [isLoadingPlants, setIsLoadingPlants] = useState(true);
// Search State
const [searchQuery, setSearchQuery] = useState('');
// Lexicon State
const [isLexiconOpen, setIsLexiconOpen] = useState(false);
const [lexiconSearchQuery, setLexiconSearchQuery] = useState('');
// Settings State
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
// Scanner Modal State
const [isScannerOpen, setIsScannerOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// Analysis State
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
// Detail State
const [selectedPlant, setSelectedPlant] = useState<Plant | null>(null);
// Toast State
const [toast, setToast] = useState({ message: '', visible: false });
// Refs
const fileInputRef = useRef<HTMLInputElement>(null);
// Derived state for translations
const t = getTranslation(language);
useEffect(() => {
const loadData = async () => {
setIsLoadingPlants(true);
await new Promise(resolve => setTimeout(resolve, 800));
setPlants(StorageService.getPlants());
setLanguage(StorageService.getLanguage());
setIsLoadingPlants(false);
};
loadData();
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
setIsDarkMode(true);
document.documentElement.classList.add('dark');
}
}, []);
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode);
if (!isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
const changeLanguage = (lang: Language) => {
setLanguage(lang);
StorageService.saveLanguage(lang);
setIsLanguageDropdownOpen(false);
};
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setSelectedImage(base64String);
analyzeImage(base64String);
};
reader.readAsDataURL(file);
}
};
const analyzeImage = async (imageUri: string) => {
setIsAnalyzing(true);
setAnalysisProgress(0);
setAnalysisResult(null);
// Simulate realistic progress
const progressInterval = setInterval(() => {
setAnalysisProgress(prev => {
// Fast start
if (prev < 30) return prev + Math.random() * 8;
// Slower middle (processing)
if (prev < 70) return prev + Math.random() * 2;
// Stall at end (waiting for API)
if (prev < 90) return prev + 0.5;
return prev;
});
}, 150);
try {
// Pass the current language to the service
const result = await PlantRecognitionService.identify(imageUri, language);
clearInterval(progressInterval);
setAnalysisProgress(100);
// Short delay to allow user to see 100% completion
setTimeout(() => {
setAnalysisResult(result);
setIsAnalyzing(false);
}, 500);
} catch (error) {
clearInterval(progressInterval);
console.error("Analysis failed", error);
alert("Fehler bei der Analyse.");
setSelectedImage(null);
setIsAnalyzing(false);
}
};
const showToast = (message: string) => {
setToast({ message, visible: true });
};
const hideToast = () => {
setToast(prev => ({ ...prev, visible: false }));
};
const savePlant = () => {
if (analysisResult && selectedImage) {
const now = new Date().toISOString();
const newPlant: Plant = {
id: generateId(),
name: analysisResult.name,
botanicalName: analysisResult.botanicalName,
imageUri: selectedImage,
dateAdded: now,
careInfo: analysisResult.careInfo,
lastWatered: now,
wateringHistory: [now], // Initialize history with the creation date/first watering
description: analysisResult.description,
notificationsEnabled: false // Default off
};
StorageService.savePlant(newPlant);
setPlants(StorageService.getPlants());
closeScanner();
// Also close lexicon if open
setIsLexiconOpen(false);
showToast(t.plantAddedSuccess);
}
};
const closeScanner = () => {
setIsScannerOpen(false);
setSelectedImage(null);
setAnalysisResult(null);
setIsAnalyzing(false);
setAnalysisProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const openScanner = () => {
setIsScannerOpen(true);
};
const handlePlantClick = (plant: Plant) => {
setSelectedPlant(plant);
};
const closeDetail = () => {
setSelectedPlant(null);
};
const handleDeletePlant = (id: string) => {
StorageService.deletePlant(id);
setPlants(prev => prev.filter(p => p.id !== id));
closeDetail();
showToast(t.plantDeleted);
};
const handleUpdatePlant = (updatedPlant: Plant) => {
StorageService.updatePlant(updatedPlant);
setPlants(prev => prev.map(p => p.id === updatedPlant.id ? updatedPlant : p));
setSelectedPlant(updatedPlant);
showToast(t.wateredSuccess);
};
// Lexicon Handling
const handleLexiconItemClick = (item: any) => {
// We treat this like a "Scan Result" for simplicity, reusing the ResultCard
setAnalysisResult(item);
setSelectedImage(item.imageUri);
// Since ResultCard is rendered conditionally based on analysisResult && selectedImage,
// we need to make sure the view is visible.
// We will render ResultCard inside the Lexicon view if selected.
};
const closeLexiconResult = () => {
setAnalysisResult(null);
setSelectedImage(null);
};
// --- SCREENS ---
const renderHome = () => (
<div className="pt-8 pb-24 px-6 min-h-screen bg-stone-50 dark:bg-stone-950">
<header className="mb-6 flex justify-between items-center">
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{t.myPlants}</h1>
<button onClick={() => setActiveTab(Tab.SETTINGS)}>
<SettingsIcon size={24} className="text-stone-900 dark:text-stone-100" />
</button>
</header>
{/* Filters */}
<div className="flex space-x-3 mb-6 overflow-x-auto no-scrollbar">
<button className="flex items-center space-x-2 px-4 py-2 bg-white dark:bg-stone-900 rounded-full shadow-sm border border-stone-100 dark:border-stone-800 flex-shrink-0">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">{t.allGood}</span>
</button>
<button className="flex items-center space-x-2 px-4 py-2 bg-stone-100 dark:bg-stone-900/50 rounded-full text-stone-400 flex-shrink-0">
<span className="text-xs font-medium">{t.toWater} (0)</span>
</button>
</div>
{isLoadingPlants ? (
<div className="grid grid-cols-2 gap-4 animate-in fade-in duration-500">
{[1, 2, 3, 4].map((i) => (
<PlantSkeleton key={i} />
))}
</div>
) : plants.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center opacity-60 animate-in fade-in slide-in-from-bottom-4 duration-700">
<Leaf size={64} className="text-stone-300 dark:text-stone-700 mb-4" />
<p className="text-lg font-medium text-stone-600 dark:text-stone-400">{t.noPlants}</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 animate-in fade-in duration-500">
{plants.map(plant => (
<PlantCard key={plant.id} plant={plant} onClick={() => handlePlantClick(plant)} t={t} />
))}
</div>
)}
{/* FAB */}
<button
onClick={openScanner}
className="fixed bottom-24 right-6 w-14 h-14 bg-primary-500 hover:bg-primary-600 rounded-full shadow-lg shadow-primary-500/40 flex items-center justify-center text-white z-30 transition-transform active:scale-90"
>
<Camera size={26} />
</button>
</div>
);
const renderSearch = () => {
const filteredPlants = plants.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.botanicalName.toLowerCase().includes(searchQuery.toLowerCase())
);
const categories = [
{ name: t.catCareEasy, color: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" },
{ name: t.catSucculents, color: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" },
{ name: t.catLowLight, color: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400" },
{ name: t.catPetFriendly, color: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400" },
{ name: t.catAirPurifier, color: "bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" },
{ name: t.catFlowering, color: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-400" },
];
return (
<div className="pt-8 pb-24 px-6 min-h-screen bg-stone-50 dark:bg-stone-950">
<header className="mb-6">
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-6">{t.searchTitle}</h1>
<div className="relative">
<Search className="absolute left-4 top-3.5 text-stone-400" size={20} />
<input
type="text"
placeholder={t.searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/50 placeholder:text-stone-400"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-3.5 text-stone-400 hover:text-stone-600 dark:hover:text-stone-200"
>
<X size={20} />
</button>
)}
</div>
</header>
{searchQuery ? (
<div>
<h2 className="text-sm font-bold text-stone-500 dark:text-stone-400 uppercase tracking-wider mb-4">
{filteredPlants.length} {t.resultsInPlants}
</h2>
{filteredPlants.length > 0 ? (
<div className="grid grid-cols-2 gap-4">
{filteredPlants.map(plant => (
<PlantCard key={plant.id} plant={plant} onClick={() => handlePlantClick(plant)} t={t} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-stone-500 dark:text-stone-400">{t.noResults}</p>
</div>
)}
</div>
) : (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-4">{t.categories}</h2>
<div className="grid grid-cols-2 gap-3">
{categories.map((cat) => (
<button
key={cat.name}
className={`p-4 rounded-xl text-left font-medium transition-transform active:scale-95 flex justify-between items-center group ${cat.color}`}
>
<span>{cat.name}</span>
<ArrowRight size={16} className="opacity-0 group-hover:opacity-100 transition-opacity transform group-hover:translate-x-1" />
</button>
))}
</div>
<div
onClick={() => setIsLexiconOpen(true)}
className="mt-8 p-6 bg-gradient-to-br from-primary-600 to-primary-800 rounded-2xl text-white shadow-lg relative overflow-hidden cursor-pointer active:scale-[0.98] transition-transform"
>
<div className="relative z-10">
<h3 className="text-xl font-serif font-bold mb-2 flex items-center">
<BookOpen size={20} className="mr-2" />
{t.lexiconTitle}
</h3>
<p className="text-primary-100 text-sm mb-4">{t.lexiconDesc}</p>
<span className="inline-block bg-white/20 px-3 py-1 rounded-full text-xs font-bold backdrop-blur-sm">{t.browseLexicon}</span>
</div>
<Leaf size={120} className="absolute -bottom-6 -right-6 text-white/10 rotate-12" />
</div>
</div>
)}
</div>
);
};
const renderLexicon = () => {
if (!isLexiconOpen) return null;
// If we have a selected item from Lexicon, show ResultCard (Detail View)
if (analysisResult && selectedImage) {
return (
<div className="fixed inset-0 z-[60] bg-stone-50 dark:bg-black">
<ResultCard
result={analysisResult}
imageUri={selectedImage}
onSave={savePlant}
onClose={closeLexiconResult}
t={t}
/>
</div>
);
}
const lexiconPlants = PlantDatabaseService.searchPlants(lexiconSearchQuery, language);
return (
<div className="fixed inset-0 z-50 bg-stone-50 dark:bg-stone-950 flex flex-col animate-in slide-in-from-bottom duration-300">
{/* Header */}
<div className="p-6 bg-white dark:bg-stone-900 border-b border-stone-200 dark:border-stone-800 flex items-center space-x-4 sticky top-0 z-10">
<button onClick={() => setIsLexiconOpen(false)} className="p-2 -ml-2 text-stone-500 hover:text-stone-900 dark:hover:text-stone-100">
<ArrowLeft size={24} />
</button>
<h1 className="text-xl font-bold font-serif text-stone-900 dark:text-stone-100">{t.lexiconTitle}</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 no-scrollbar">
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-4 top-3.5 text-stone-400" size={20} />
<input
type="text"
placeholder={t.lexiconSearchPlaceholder}
value={lexiconSearchQuery}
onChange={(e) => setLexiconSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/50 placeholder:text-stone-400"
/>
</div>
{/* Grid - NOW 3 COLUMNS */}
<div className="grid grid-cols-3 gap-3 pb-20">
{lexiconPlants.map((plant, index) => (
<button
key={index}
onClick={() => handleLexiconItemClick(plant)}
className="text-left bg-white dark:bg-stone-900 rounded-2xl overflow-hidden shadow-sm border border-stone-100 dark:border-stone-800 group active:scale-[0.98] transition-all"
>
<div className="aspect-square relative">
<img src={plant.imageUri} className="w-full h-full object-cover" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-60"></div>
</div>
<div className="p-2">
<h3 className="text-sm font-bold text-stone-900 dark:text-stone-100 truncate">{plant.name}</h3>
<p className="text-[10px] text-stone-500 dark:text-stone-400 italic truncate">{plant.botanicalName}</p>
</div>
</button>
))}
{lexiconPlants.length === 0 && (
<div className="col-span-3 text-center py-10 text-stone-400">
{t.noResults}
</div>
)}
</div>
</div>
</div>
);
};
const renderSettings = () => {
const languages: { code: Language; label: string }[] = [
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' }
];
const currentLangLabel = languages.find(l => l.code === language)?.label;
return (
<div className="pt-12 px-6 h-screen bg-stone-50 dark:bg-stone-950 pb-24">
<h1 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-8">{t.settingsTitle}</h1>
{/* Dark Mode Settings */}
<div className="bg-white dark:bg-stone-900 p-4 rounded-2xl shadow-sm border border-stone-100 dark:border-stone-800 flex justify-between items-center mb-4">
<span className="font-medium text-stone-900 dark:text-stone-200">{t.darkMode}</span>
<button onClick={toggleDarkMode} className={`w-12 h-7 rounded-full relative ${isDarkMode ? 'bg-primary-600' : 'bg-stone-300'}`}>
<div className={`absolute top-1 w-5 h-5 bg-white rounded-full transition-all ${isDarkMode ? 'left-6' : 'left-1'}`} />
</button>
</div>
{/* Language Settings (Dropdown Style) */}
<div className="bg-white dark:bg-stone-900 p-4 rounded-2xl shadow-sm border border-stone-100 dark:border-stone-800 transition-all duration-300">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
>
<div className="flex items-center space-x-2">
<Globe size={18} className="text-stone-400" />
<span className="font-medium text-stone-900 dark:text-stone-200">{t.language}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-bold text-primary-600 dark:text-primary-400">{currentLangLabel}</span>
{isLanguageDropdownOpen ? <ChevronUp size={16} className="text-stone-400" /> : <ChevronDown size={16} className="text-stone-400" />}
</div>
</div>
{/* Dropdown Content */}
{isLanguageDropdownOpen && (
<div className="mt-4 pt-4 border-t border-stone-100 dark:border-stone-800 animate-in slide-in-from-top-2">
<div className="space-y-2">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex justify-between items-center p-3 rounded-xl transition-colors ${
language === lang.code
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300'
: 'hover:bg-stone-50 dark:hover:bg-stone-800 text-stone-600 dark:text-stone-300'
}`}
>
<span className="font-medium text-sm">{lang.label}</span>
{language === lang.code && <Check size={16} />}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
};
const renderScannerModal = () => {
if (!isScannerOpen) return null;
// 1. Result View
if (analysisResult && selectedImage) {
return (
<div className="fixed inset-0 z-50 bg-stone-50 dark:bg-black">
<ResultCard
result={analysisResult}
imageUri={selectedImage}
onSave={savePlant}
onClose={closeScanner}
t={t}
/>
</div>
);
}
// 2. Scanner View
return (
<div className="fixed inset-0 z-50 bg-stone-900 flex flex-col">
{/* Header */}
<div className="absolute top-0 left-0 right-0 p-6 flex justify-between items-center z-10 text-white">
<button onClick={closeScanner}><X size={28} /></button>
<span className="font-medium text-lg">{t.scanner}</span>
<button><Zap size={24} className="text-white/50" /></button>
</div>
{/* Main Camera Area */}
<div className="flex-1 relative overflow-hidden flex items-center justify-center">
{selectedImage ? (
<img src={selectedImage} className="absolute inset-0 w-full h-full object-cover opacity-50 blur-sm" />
) : (
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-transparent to-black/60 pointer-events-none"></div>
)}
{/* Background Grid */}
<div className="absolute inset-0 opacity-10 pointer-events-none"
style={{ backgroundImage: 'radial-gradient(#ffffff 1px, transparent 1px)', backgroundSize: '32px 32px' }}>
</div>
{/* Scan Frame */}
<div className="w-64 h-80 border-[3px] border-white/30 rounded-[2rem] relative flex items-center justify-center overflow-hidden backdrop-blur-[2px]">
{/* SHOW SELECTED IMAGE IN FRAME */}
{selectedImage && (
<img
src={selectedImage}
className="absolute inset-0 w-full h-full object-cover"
alt="Scan preview"
/>
)}
<div className="absolute top-4 left-4 w-6 h-6 border-t-4 border-l-4 border-white rounded-tl-xl z-10"></div>
<div className="absolute top-4 right-4 w-6 h-6 border-t-4 border-r-4 border-white rounded-tr-xl z-10"></div>
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-4 border-l-4 border-white rounded-bl-xl z-10"></div>
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-4 border-r-4 border-white rounded-br-xl z-10"></div>
{/* Laser Line */}
{isAnalyzing || !selectedImage ? (
<div className="absolute left-0 right-0 h-0.5 bg-primary-400 shadow-[0_0_15px_rgba(74,222,128,0.8)] animate-scan z-20"></div>
) : null}
</div>
</div>
{/* Analyzing Sheet Overlay - Loading Animation */}
{isAnalyzing && (
<div className="absolute bottom-32 left-4 right-4 bg-white dark:bg-stone-800 rounded-2xl p-4 shadow-xl flex flex-col animate-in slide-in-from-bottom-5 z-30">
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-stone-900 dark:text-white text-sm transition-all">
{analysisProgress < 100 ? t.analyzing : t.result}
</span>
<span className="font-mono text-xs font-bold text-stone-500 dark:text-stone-400">
{Math.round(analysisProgress)}%
</span>
</div>
{/* Progress Bar */}
<div className="h-2 w-full bg-stone-100 dark:bg-stone-700 rounded-full overflow-hidden mb-3 relative">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
style={{ width: `${analysisProgress}%` }}
></div>
</div>
{/* Stage Indicators */}
<div className="flex justify-between items-center text-[10px] text-stone-400 font-bold uppercase tracking-wider">
<div className="flex items-center">
<div className={`w-2 h-2 rounded-full mr-1.5 ${analysisProgress < 100 ? 'bg-amber-400 animate-pulse' : 'bg-green-500'}`}></div>
{t.localProcessing}
</div>
<span className="opacity-70 transition-opacity duration-300 text-right">
{analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3}
</span>
</div>
</div>
)}
{/* Bottom Controls */}
<div className="bg-white rounded-t-[2.5rem] px-8 pt-8 pb-12 flex justify-between items-center">
<div className="flex flex-col items-center space-y-1">
<button onClick={() => fileInputRef.current?.click()} className="p-4 bg-stone-100 rounded-2xl text-stone-600 active:scale-95 transition-transform">
<ImageIcon size={24} />
</button>
<span className="text-xs font-medium text-stone-500">{t.gallery}</span>
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="w-20 h-20 bg-primary-500 rounded-full border-4 border-stone-100 shadow-xl flex items-center justify-center active:scale-95 transition-transform"
>
<div className="w-16 h-16 bg-white/20 rounded-full"></div>
</button>
<div className="flex flex-col items-center space-y-1">
<button className="p-4 bg-stone-50 rounded-2xl text-stone-400">
<HelpCircle size={24} />
</button>
<span className="text-xs font-medium text-stone-300">{t.help}</span>
</div>
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
ref={fileInputRef}
onChange={handleImageSelect}
/>
</div>
</div>
);
};
return (
<div className="max-w-md mx-auto min-h-screen relative overflow-hidden bg-stone-50 dark:bg-stone-950 shadow-2xl">
<div className="h-full overflow-y-auto no-scrollbar">
{activeTab === Tab.HOME && renderHome()}
{activeTab === Tab.SETTINGS && renderSettings()}
{activeTab === Tab.SEARCH && renderSearch()}
</div>
<TabBar
currentTab={activeTab}
onTabChange={setActiveTab}
labels={{ home: t.tabPlants, search: t.tabSearch, settings: t.tabProfile }}
/>
{/* Modal Layer for Detail View */}
{selectedPlant && (
<PlantDetail
plant={selectedPlant}
onClose={closeDetail}
onDelete={handleDeletePlant}
onUpdate={handleUpdatePlant}
t={t}
language={language}
/>
)}
{/* Modal Layer for Scanner */}
{renderScannerModal()}
{/* Lexicon Overlay */}
{renderLexicon()}
{/* Toast Notification */}
<Toast message={toast.message} isVisible={toast.visible} onClose={hideToast} />
</div>
);
};
export default App;

View File

@ -1,11 +1,20 @@
<div align="center"> <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div> </div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1cQZAaCmJNVx9ML_ZZV-F0gNCNhjSrgd8
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

59
components/PlantCard.tsx Normal file
View File

@ -0,0 +1,59 @@
import React from 'react';
import { Plant } from '../types';
import { Droplets } from 'lucide-react';
interface PlantCardProps {
plant: Plant;
onClick: () => void;
t: any; // Using any for simplicity with the dynamic translation object
}
export const PlantCard: React.FC<PlantCardProps> = ({ plant, onClick, t }) => {
const daysUntilWatering = plant.careInfo.waterIntervalDays;
// Very basic check logic for MVP
const isUrgent = daysUntilWatering <= 1;
const wateringText = isUrgent
? t.waterToday
: t.inXDays.replace('{0}', daysUntilWatering.toString());
return (
<button
onClick={onClick}
className="relative aspect-[4/5] rounded-2xl overflow-hidden shadow-md group active:scale-[0.98] transition-transform w-full text-left"
>
<img
src={plant.imageUri}
alt={plant.name}
className="w-full h-full object-cover"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
{/* Badge */}
<div className="absolute top-3 left-3">
<div className={`flex items-center space-x-1.5 px-2.5 py-1 rounded-full backdrop-blur-md ${
isUrgent
? 'bg-orange-500 text-white'
: 'bg-black/40 text-stone-200 border border-white/10'
}`}>
<Droplets size={10} className="fill-current" />
<span className="text-[10px] font-bold">
{wateringText}
</span>
</div>
</div>
{/* Content */}
<div className="absolute bottom-4 left-3 right-3">
<h3 className="text-white font-bold text-lg leading-tight font-serif mb-0.5 shadow-sm">
{plant.name}
</h3>
<p className="text-stone-300 text-xs truncate">
{plant.botanicalName}
</p>
</div>
</button>
);
};

302
components/PlantDetail.tsx Normal file
View File

@ -0,0 +1,302 @@
import React, { useState } from 'react';
import { Plant, Language } from '../types';
import { Droplets, Sun, Thermometer, ArrowLeft, Calendar, Trash2, Share2, Edit2, AlertCircle, Check, Clock, Bell, BellOff } from 'lucide-react';
interface PlantDetailProps {
plant: Plant;
onClose: () => void;
onDelete: (id: string) => void;
onUpdate: (plant: Plant) => void;
t: any;
language: Language;
}
export const PlantDetail: React.FC<PlantDetailProps> = ({ plant, onClose, onDelete, onUpdate, t, language }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Map internal language codes to locale strings for Date
const localeMap: Record<string, string> = {
de: 'de-DE',
en: 'en-US',
es: 'es-ES'
};
const formattedAddedDate = new Date(plant.dateAdded).toLocaleDateString(localeMap[language] || 'de-DE');
const formattedWateredDate = new Date(plant.lastWatered).toLocaleDateString(localeMap[language] || 'de-DE');
// Calculate next watering date
const lastWateredObj = new Date(plant.lastWatered);
const nextWateringDate = new Date(lastWateredObj);
nextWateringDate.setDate(lastWateredObj.getDate() + plant.careInfo.waterIntervalDays);
const formattedNextWatering = nextWateringDate.toLocaleDateString(localeMap[language] || 'de-DE', { weekday: 'long', day: 'numeric', month: 'numeric' });
const nextWateringText = t.nextWatering.replace('{0}', formattedNextWatering);
const lastWateredText = t.lastWateredDate.replace('{0}', formattedWateredDate);
// Check if watered today
const isWateredToday = new Date(plant.lastWatered).toDateString() === new Date().toDateString();
const handleWaterPlant = () => {
const now = new Date().toISOString();
// Update history: add new date to the beginning, keep last 10 entries max
const currentHistory = plant.wateringHistory || [];
const newHistory = [now, ...currentHistory].slice(0, 10);
const updatedPlant = {
...plant,
lastWatered: now,
wateringHistory: newHistory
};
onUpdate(updatedPlant);
};
const toggleReminder = async () => {
const newValue = !plant.notificationsEnabled;
if (newValue) {
// Request permission if enabling
if (!('Notification' in window)) {
alert("Notifications are not supported by this browser.");
return;
}
if (Notification.permission === 'granted') {
onUpdate({ ...plant, notificationsEnabled: true });
} else if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
onUpdate({ ...plant, notificationsEnabled: true });
}
} else {
alert(t.reminderPermissionNeeded);
}
} else {
// Disabling
onUpdate({ ...plant, notificationsEnabled: false });
}
};
const handleDeleteClick = () => {
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
onDelete(plant.id);
};
const handleCancelDelete = () => {
setShowDeleteConfirm(false);
};
return (
<div className="fixed inset-0 z-50 flex flex-col h-full bg-stone-50 dark:bg-stone-950 overflow-y-auto no-scrollbar animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 flex justify-between items-center p-6 text-stone-900 dark:text-white">
<button onClick={onClose} className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
<ArrowLeft size={20} />
</button>
<div className="flex space-x-2">
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
<Share2 size={20} />
</button>
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
<Edit2 size={20} />
</button>
</div>
</div>
<div className="flex-1">
{/* Hero Image */}
<div className="relative w-full aspect-[4/5] md:aspect-video rounded-b-[2.5rem] overflow-hidden shadow-lg mb-6">
<img src={plant.imageUri} alt={plant.name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="absolute bottom-8 left-6 right-6">
<h1 className="text-3xl font-serif font-bold text-white leading-tight mb-1 shadow-sm">
{plant.name}
</h1>
<p className="text-stone-200 italic text-sm">
{plant.botanicalName}
</p>
</div>
</div>
{/* Content Container */}
<div className="px-6 pb-24">
{/* Added Date Info */}
<div className="flex justify-between items-center text-xs text-stone-500 dark:text-stone-400 mb-6">
<div className="flex items-center space-x-1.5 bg-white dark:bg-stone-900 px-3 py-1.5 rounded-full border border-stone-100 dark:border-stone-800">
<Calendar size={12} />
<span>{t.addedOn} {formattedAddedDate}</span>
</div>
</div>
{/* Main Action: Water */}
<div className={`mb-3 p-4 rounded-2xl border flex justify-between items-center transition-colors ${
isWateredToday
? 'bg-green-50 dark:bg-green-900/10 border-green-100 dark:border-green-800/30'
: 'bg-blue-50 dark:bg-blue-900/10 border-blue-100 dark:border-blue-800/30'
}`}>
<div>
<span className="block text-xs text-stone-500 dark:text-stone-400 font-medium mb-0.5">{lastWateredText}</span>
<span className={`block text-sm font-bold ${isWateredToday ? 'text-green-700 dark:text-green-300' : 'text-stone-900 dark:text-stone-200'}`}>
{nextWateringText}
</span>
</div>
<button
onClick={handleWaterPlant}
disabled={isWateredToday}
className={`px-4 py-2.5 rounded-xl font-bold text-xs flex items-center shadow-lg transition-all ${
isWateredToday
? 'bg-green-500 text-white cursor-default shadow-green-500/30'
: 'bg-blue-500 hover:bg-blue-600 active:scale-95 text-white shadow-blue-500/30'
}`}
>
{isWateredToday ? (
<>
<Check size={14} className="mr-2" />
{t.watered}
</>
) : (
<>
<Droplets size={14} className="mr-2 fill-current" />
{t.waterNow}
</>
)}
</button>
</div>
{/* Reminder Toggle */}
<div className="mb-8 flex items-center justify-between p-3 rounded-xl bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${plant.notificationsEnabled ? 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-stone-100 text-stone-400 dark:bg-stone-800 dark:text-stone-500'}`}>
{plant.notificationsEnabled ? <Bell size={18} /> : <BellOff size={18} />}
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-stone-900 dark:text-stone-100">{t.reminder}</span>
<span className="text-[10px] text-stone-500">{plant.notificationsEnabled ? t.reminderOn : t.reminderOff}</span>
</div>
</div>
<button
onClick={toggleReminder}
className={`w-11 h-6 rounded-full relative transition-colors duration-300 ${plant.notificationsEnabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-stone-700'}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all duration-300 shadow-sm ${plant.notificationsEnabled ? 'left-6' : 'left-1'}`} />
</button>
</div>
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-2">{t.aboutPlant}</h3>
<p className="text-stone-600 dark:text-stone-300 text-sm leading-relaxed mb-8">
{plant.description || t.noDescription}
</p>
{/* Care Info */}
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-4">{t.careTips}</h3>
<div className="grid grid-cols-3 gap-3 mb-10">
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center mb-2">
<Droplets size={16} className="fill-current" />
</div>
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.water}</span>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
{plant.careInfo.waterIntervalDays} {t.days || 'Tage'}
</span>
</div>
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
<div className="w-8 h-8 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
<Sun size={16} className="fill-current" />
</div>
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.light}</span>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200 truncate w-full">
{plant.careInfo.light}
</span>
</div>
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
<div className="w-8 h-8 rounded-full bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
<Thermometer size={16} className="fill-current" />
</div>
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.temp}</span>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
{plant.careInfo.temp}
</span>
</div>
</div>
{/* Watering History Section */}
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-4">{t.wateringHistory}</h3>
<div className="bg-white dark:bg-stone-900 rounded-2xl border border-stone-100 dark:border-stone-800 overflow-hidden mb-10 shadow-sm">
{(!plant.wateringHistory || plant.wateringHistory.length === 0) ? (
<div className="p-6 text-center text-stone-400 text-sm">
<Clock size={24} className="mx-auto mb-2 opacity-50" />
{t.noHistory}
</div>
) : (
<ul className="divide-y divide-stone-100 dark:divide-stone-800">
{plant.wateringHistory.slice(0, 5).map((dateStr, index) => (
<li key={index} className="px-5 py-3 flex justify-between items-center group hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-colors">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/10 text-blue-500 flex items-center justify-center">
<Droplets size={14} className="fill-current" />
</div>
<span className="text-sm font-medium text-stone-700 dark:text-stone-300">
{new Date(dateStr).toLocaleDateString(localeMap[language] || 'de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</span>
</div>
<span className="text-xs font-mono text-stone-400 bg-stone-100 dark:bg-stone-800 px-2 py-1 rounded-md">
{new Date(dateStr).toLocaleTimeString(localeMap[language] || 'de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
</li>
))}
</ul>
)}
</div>
{/* Danger Zone */}
<div className="border-t border-stone-200 dark:border-stone-800 pt-6 flex justify-center">
<button
onClick={handleDeleteClick}
className="flex items-center space-x-2 text-red-500 hover:text-red-600 px-4 py-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={16} />
<span className="text-sm font-medium">{t.delete}</span>
</button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in duration-200">
<div className="bg-white dark:bg-stone-900 w-full max-w-sm rounded-2xl p-6 shadow-2xl scale-100 animate-in zoom-in-95 duration-200">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 text-red-500 rounded-full flex items-center justify-center mb-4 mx-auto">
<AlertCircle size={24} />
</div>
<h3 className="text-xl font-bold text-center text-stone-900 dark:text-white mb-2">{t.deleteConfirmTitle}</h3>
<p className="text-stone-500 dark:text-stone-400 text-center text-sm mb-6 leading-relaxed">
{t.deleteConfirmMessage}
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleCancelDelete}
className="py-3 px-4 rounded-xl bg-stone-100 dark:bg-stone-800 text-stone-700 dark:text-stone-300 font-bold text-sm"
>
{t.cancel}
</button>
<button
onClick={handleConfirmDelete}
className="py-3 px-4 rounded-xl bg-red-500 text-white font-bold text-sm hover:bg-red-600"
>
{t.confirm}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
export const PlantSkeleton: React.FC = () => {
return (
<div className="relative aspect-[4/5] rounded-2xl overflow-hidden bg-stone-200 dark:bg-stone-800 animate-pulse border border-stone-300 dark:border-stone-700/50">
{/* Badge Placeholder */}
<div className="absolute top-3 left-3">
<div className="h-6 w-20 bg-stone-300 dark:bg-stone-700 rounded-full" />
</div>
{/* Content Placeholder */}
<div className="absolute bottom-4 left-3 right-3 space-y-2">
{/* Title */}
<div className="h-6 w-3/4 bg-stone-300 dark:bg-stone-700 rounded-md" />
{/* Subtitle */}
<div className="h-3 w-1/2 bg-stone-300 dark:bg-stone-700 rounded-md" />
</div>
</div>
);
};

140
components/ResultCard.tsx Normal file
View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { IdentificationResult } from '../types';
import { Droplets, Sun, Thermometer, CheckCircle2, ArrowLeft, Share2 } from 'lucide-react';
interface ResultCardProps {
result: IdentificationResult;
imageUri: string;
onSave: () => void;
onClose: () => void;
t: any;
}
export const ResultCard: React.FC<ResultCardProps> = ({ result, imageUri, onSave, onClose, t }) => {
const [showDetails, setShowDetails] = useState(false);
return (
<div className="flex flex-col h-full bg-stone-50 dark:bg-stone-950 overflow-y-auto no-scrollbar animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 flex justify-between items-center p-6 text-stone-900 dark:text-white">
<button onClick={onClose} className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
<ArrowLeft size={20} />
</button>
<span className="font-bold text-sm bg-white/80 dark:bg-black/50 backdrop-blur-md px-3 py-1 rounded-full">{t.result}</span>
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
<Share2 size={20} />
</button>
</div>
<div className="p-4 pt-20">
{/* Hero Image */}
<div className="relative w-full aspect-[4/3] rounded-[2rem] overflow-hidden shadow-lg mb-6">
<img src={imageUri} alt="Analyzed Plant" className="w-full h-full object-cover" />
<div className="absolute bottom-4 left-4 bg-white/90 backdrop-blur-sm text-primary-700 px-3 py-1.5 rounded-full text-xs font-bold shadow-sm flex items-center">
<CheckCircle2 size={14} className="mr-1.5 fill-primary-600 text-white" />
{Math.round(result.confidence * 100)}% {t.match}
</div>
</div>
{/* Info */}
<div className="px-2">
<h1 className="text-3xl font-serif font-bold text-stone-900 dark:text-stone-100 leading-tight mb-1">
{result.name}
</h1>
<p className="text-stone-400 dark:text-stone-500 italic text-sm mb-6">
{result.botanicalName}
</p>
<p className="text-stone-600 dark:text-stone-300 text-sm leading-relaxed mb-8">
{result.description || t.noDescription}
</p>
{/* Care Check */}
<div className="flex justify-between items-end mb-4">
<h3 className="font-bold text-stone-900 dark:text-stone-100">{t.careCheck}</h3>
<button
onClick={() => setShowDetails(!showDetails)}
className="text-[10px] font-bold text-primary-600 uppercase tracking-wide"
>
{showDetails ? t.hideDetails : t.showDetails}
</button>
</div>
<div className="grid grid-cols-3 gap-3 mb-8">
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center mb-2">
<Droplets size={16} className="fill-current" />
</div>
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.water}</span>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
{result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle}
</span>
</div>
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
<div className="w-8 h-8 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
<Sun size={16} className="fill-current" />
</div>
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.light}</span>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200 truncate w-full">
{result.careInfo.light}
</span>
</div>
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
<div className="w-8 h-8 rounded-full bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
<Thermometer size={16} className="fill-current" />
</div>
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.temp}</span>
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
{result.careInfo.temp}
</span>
</div>
</div>
{/* Expanded Details */}
{showDetails && (
<div className="mb-8 p-5 bg-stone-100 dark:bg-stone-900/50 rounded-2xl animate-in fade-in slide-in-from-top-2 border border-stone-200 dark:border-stone-800">
<h4 className="font-bold text-xs mb-3 text-stone-700 dark:text-stone-300 uppercase tracking-wide">{t.detailedCare}</h4>
<ul className="space-y-3 text-sm text-stone-600 dark:text-stone-300">
<li className="flex items-start">
<span className="mr-3 text-primary-500"></span>
<span>{t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString())}</span>
</li>
<li className="flex items-start">
<span className="mr-3 text-amber-500"></span>
<span>{t.careTextLight.replace('{0}', result.careInfo.light)}</span>
</li>
<li className="flex items-start">
<span className="mr-3 text-rose-500"></span>
<span>{t.careTextTemp.replace('{0}', result.careInfo.temp)}</span>
</li>
</ul>
</div>
)}
{/* Save Button */}
<div className="flex items-center justify-center space-x-2 text-xs text-stone-400 mb-4">
<div className="w-3 h-3 rounded-sm border border-stone-300 flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-stone-400 rounded-[1px]"></div>
</div>
<span>{t.dataSavedLocally}</span>
</div>
<button
onClick={onSave}
className="w-full py-4 bg-primary-500 hover:bg-primary-600 text-black font-bold text-sm rounded-xl shadow-lg shadow-primary-500/30 active:scale-[0.98] transition-all flex items-center justify-center"
>
<div className="bg-black/20 rounded-full p-0.5 mr-2">
<CheckCircle2 size={14} className="text-black" />
</div>
{t.addToPlants}
</button>
<div className="h-8"></div>
</div>
</div>
</div>
);
};

51
components/TabBar.tsx Normal file
View File

@ -0,0 +1,51 @@
import React from 'react';
import { Tab } from '../types';
import { LayoutGrid, Search, User } from 'lucide-react';
interface TabBarProps {
currentTab: Tab;
onTabChange: (tab: Tab) => void;
labels: {
home: string;
search: string;
settings: string;
};
}
export const TabBar: React.FC<TabBarProps> = ({ currentTab, onTabChange, labels }) => {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-stone-900 border-t border-stone-200 dark:border-stone-800 pb-safe pt-2 px-6 z-40">
<div className="flex justify-between items-center h-16 max-w-sm mx-auto">
<button
onClick={() => onTabChange(Tab.HOME)}
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
currentTab === Tab.HOME ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
}`}
>
<LayoutGrid size={24} strokeWidth={currentTab === Tab.HOME ? 2.5 : 2} />
<span className="text-[10px] font-medium">{labels.home}</span>
</button>
<button
onClick={() => onTabChange(Tab.SEARCH)}
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
currentTab === Tab.SEARCH ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
}`}
>
<Search size={24} strokeWidth={currentTab === Tab.SEARCH ? 2.5 : 2} />
<span className="text-[10px] font-medium">{labels.search}</span>
</button>
<button
onClick={() => onTabChange(Tab.SETTINGS)}
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
currentTab === Tab.SETTINGS ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
}`}
>
<User size={24} strokeWidth={currentTab === Tab.SETTINGS ? 2.5 : 2} />
<span className="text-[10px] font-medium">{labels.settings}</span>
</button>
</div>
</div>
);
};

37
components/Toast.tsx Normal file
View File

@ -0,0 +1,37 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle2 } from 'lucide-react';
interface ToastProps {
message: string;
isVisible: boolean;
onClose: () => void;
}
export const Toast: React.FC<ToastProps> = ({ message, isVisible, onClose }) => {
const [show, setShow] = useState(false);
useEffect(() => {
if (isVisible) {
setShow(true);
const timer = setTimeout(() => {
setShow(false);
setTimeout(onClose, 300); // Wait for animation
}, 3000);
return () => clearTimeout(timer);
} else {
setShow(false);
}
}, [isVisible, onClose]);
if (!isVisible && !show) return null;
return (
<div className={`fixed bottom-20 left-0 right-0 z-[70] flex justify-center pointer-events-none transition-all duration-300 transform ${show ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}`}>
<div className="bg-stone-900 dark:bg-white text-white dark:text-stone-900 px-4 py-3 rounded-full shadow-lg flex items-center space-x-2">
<CheckCircle2 size={18} className="text-green-500" />
<span className="text-sm font-medium">{message}</span>
</div>
</div>
);
};

81
index.html Normal file
View File

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>GreenLens</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
},
stone: {
850: '#1f1f1e',
950: '#0c0c0c',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Merriweather', 'serif'],
},
animation: {
'scan': 'scan 2s linear infinite',
},
keyframes: {
scan: {
'0%': { top: '0%' },
'50%': { top: '100%' },
'100%': { top: '0%' },
}
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
-webkit-tap-highlight-color: transparent;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@^19.2.4",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"react/": "https://esm.sh/react@^19.2.4/",
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"uuid": "https://esm.sh/uuid@^13.0.0"
}
}
</script>
</head>
<body class="bg-stone-100 dark:bg-black text-stone-900 dark:text-stone-100 antialiased selection:bg-primary-500 selection:text-white">
<div id="root"></div>
</body>
</html>

15
index.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

7
metadata.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "GreenLens",
"description": "Eine faire, datenschutzfreundliche Pflanzen-Identifikations-App für den DACH-Markt. Offline-First und ohne Abo-Zwang.",
"requestFramePermissions": [
"camera"
]
}

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "greenlens",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"@google/genai": "^1.38.0",
"lucide-react": "^0.563.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1,253 @@
import { IdentificationResult, Language } from '../types';
interface DatabaseEntry extends IdentificationResult {
imageUri: string; // Default image for the lexicon
categories: string[];
}
const PLANT_DATABASE: Record<Language, DatabaseEntry[]> = {
de: [
{
name: "Monstera",
botanicalName: "Monstera deliciosa",
confidence: 1.0,
careInfo: { waterIntervalDays: 7, light: "Halbschatten", temp: "18-24°C" },
description: "Die Monstera Deliciosa, auch Fensterblatt genannt, ist bekannt für ihre großen, geteilten Blätter. Sie ist pflegeleicht und reinigt die Luft in Innenräumen effektiv.",
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
categories: ["easy", "low_light", "air_purifier"]
},
{
name: "Birkenfeige",
botanicalName: "Ficus benjamina",
confidence: 1.0,
careInfo: { waterIntervalDays: 5, light: "Hell", temp: "16-24°C" },
description: "Die Birkenfeige ist eine beliebte Zimmerpflanze mit eleganten, überhängenden Zweigen und glänzenden Blättern. Sie reagiert empfindlich auf Standortwechsel.",
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
categories: ["tree", "bright_light"]
},
{
name: "Echeveria",
botanicalName: "Echeveria elegans",
confidence: 1.0,
careInfo: { waterIntervalDays: 14, light: "Sonnig", temp: "18-28°C" },
description: "Diese Sukkulente bildet wunderschöne Rosetten und speichert Wasser in ihren dicken Blättern. Sie ist ideal für sonnige Fensterbänke und sehr pflegeleicht.",
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "easy", "small"]
},
{
name: "Bogenhanf",
botanicalName: "Sansevieria trifasciata",
confidence: 1.0,
careInfo: { waterIntervalDays: 21, light: "Schatten bis Sonne", temp: "15-30°C" },
description: "Der Bogenhanf ist fast unzerstörbar. Er kommt mit wenig Licht und Wasser aus und ist einer der besten Luftreiniger für das Schlafzimmer.",
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "easy", "low_light", "air_purifier"]
},
{
name: "Echte Aloe",
botanicalName: "Aloe vera",
confidence: 1.0,
careInfo: { waterIntervalDays: 14, light: "Sonnig", temp: "20-30°C" },
description: "Eine Heilpflanze, deren Gel bei Sonnenbrand hilft. Sie benötigt einen sehr hellen Standort und wenig Wasser.",
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "medicinal", "sun"]
},
{
name: "Grünlilie",
botanicalName: "Chlorophytum comosum",
confidence: 1.0,
careInfo: { waterIntervalDays: 7, light: "Halbschatten", temp: "15-23°C" },
description: "Die Grünlilie ist extrem anpassungsfähig und bildet schnell Ableger. Sie verzeiht Gießfehler und ist ideal für Anfänger.",
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
categories: ["easy", "hanging", "pet_friendly"]
},
{
name: "Einblatt",
botanicalName: "Spathiphyllum",
confidence: 1.0,
careInfo: { waterIntervalDays: 5, light: "Halbschatten", temp: "18-25°C" },
description: "Das Einblatt zeigt durch hängende Blätter an, wann es Wasser braucht. Es blüht auch bei weniger Licht wunderschön weiß.",
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
categories: ["flowering", "low_light", "air_purifier"]
},
{
name: "Korbmarante",
botanicalName: "Calathea",
confidence: 1.0,
careInfo: { waterIntervalDays: 4, light: "Halbschatten", temp: "18-24°C" },
description: "Calatheas sind bekannt für ihre gemusterten Blätter, die sich nachts zusammenfalten. Sie benötigen hohe Luftfeuchtigkeit.",
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
categories: ["patterned", "pet_friendly", "high_humidity"]
}
],
en: [
{
name: "Monstera",
botanicalName: "Monstera deliciosa",
confidence: 1.0,
careInfo: { waterIntervalDays: 7, light: "Partial Shade", temp: "18-24°C" },
description: "The Monstera Deliciosa, also known as the Swiss Cheese Plant, is known for its large, split leaves. It is easy to care for and effectively purifies indoor air.",
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
categories: ["easy", "low_light", "air_purifier"]
},
{
name: "Weeping Fig",
botanicalName: "Ficus benjamina",
confidence: 1.0,
careInfo: { waterIntervalDays: 5, light: "Bright", temp: "16-24°C" },
description: "The Weeping Fig is a popular houseplant with elegant, drooping branches and glossy leaves. It is sensitive to changes in location.",
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
categories: ["tree", "bright_light"]
},
{
name: "Mexican Snowball",
botanicalName: "Echeveria elegans",
confidence: 1.0,
careInfo: { waterIntervalDays: 14, light: "Sunny", temp: "18-28°C" },
description: "This succulent forms beautiful rosettes and stores water in its thick leaves. It is ideal for sunny windowsills and very low maintenance.",
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "easy", "small"]
},
{
name: "Snake Plant",
botanicalName: "Sansevieria trifasciata",
confidence: 1.0,
careInfo: { waterIntervalDays: 21, light: "Shade to Sun", temp: "15-30°C" },
description: "The Snake Plant is nearly indestructible. It tolerates low light and drought and is one of the best air purifiers for the bedroom.",
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "easy", "low_light", "air_purifier"]
},
{
name: "Aloe Vera",
botanicalName: "Aloe vera",
confidence: 1.0,
careInfo: { waterIntervalDays: 14, light: "Sunny", temp: "20-30°C" },
description: "A medicinal plant whose gel helps with sunburn. It requires a very bright spot and little water.",
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "medicinal", "sun"]
},
{
name: "Spider Plant",
botanicalName: "Chlorophytum comosum",
confidence: 1.0,
careInfo: { waterIntervalDays: 7, light: "Partial Shade", temp: "15-23°C" },
description: "The Spider Plant is extremely adaptable and quickly forms offshoots. It forgives watering mistakes and is ideal for beginners.",
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
categories: ["easy", "hanging", "pet_friendly"]
},
{
name: "Peace Lily",
botanicalName: "Spathiphyllum",
confidence: 1.0,
careInfo: { waterIntervalDays: 5, light: "Partial Shade", temp: "18-25°C" },
description: "The Peace Lily shows when it needs water by drooping its leaves. It blooms beautifully white even in lower light.",
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
categories: ["flowering", "low_light", "air_purifier"]
},
{
name: "Calathea",
botanicalName: "Calathea",
confidence: 1.0,
careInfo: { waterIntervalDays: 4, light: "Partial Shade", temp: "18-24°C" },
description: "Calatheas are known for their patterned leaves that fold up at night. They require high humidity.",
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
categories: ["patterned", "pet_friendly", "high_humidity"]
}
],
es: [
{
name: "Costilla de Adán",
botanicalName: "Monstera deliciosa",
confidence: 1.0,
careInfo: { waterIntervalDays: 7, light: "Sombra Parcial", temp: "18-24°C" },
description: "La Monstera Deliciosa es conocida por sus grandes hojas divididas. Es fácil de cuidar y purifica el aire interior de manera efectiva.",
imageUri: "https://images.unsplash.com/photo-1614594975525-e45190c55d0b?q=80&w=400&auto=format&fit=crop",
categories: ["easy", "low_light", "air_purifier"]
},
{
name: "Ficus Benjamina",
botanicalName: "Ficus benjamina",
confidence: 1.0,
careInfo: { waterIntervalDays: 5, light: "Brillante", temp: "16-24°C" },
description: "El Ficus Benjamina es una planta de interior popular con ramas elegantes y caídas y hojas brillantes. Es sensible a los cambios de ubicación.",
imageUri: "https://images.unsplash.com/photo-1509223197845-458d87318791?q=80&w=400&auto=format&fit=crop",
categories: ["tree", "bright_light"]
},
{
name: "Rosa de Alabastro",
botanicalName: "Echeveria elegans",
confidence: 1.0,
careInfo: { waterIntervalDays: 14, light: "Soleado", temp: "18-28°C" },
description: "Esta suculenta forma hermosas rosetas y almacena agua en sus hojas gruesas. Es ideal para alféizares soleados y requiere muy poco mantenimiento.",
imageUri: "https://images.unsplash.com/photo-1520302669765-66b37fb890d7?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "easy", "small"]
},
{
name: "Lengua de Suegra",
botanicalName: "Sansevieria trifasciata",
confidence: 1.0,
careInfo: { waterIntervalDays: 21, light: "Sombra a Sol", temp: "15-30°C" },
description: "La Sansevieria es casi indestructible. Tolera poca luz y sequía, y es uno de los mejores purificadores de aire para el dormitorio.",
imageUri: "https://images.unsplash.com/photo-1620127530668-37c2275ae158?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "easy", "low_light", "air_purifier"]
},
{
name: "Aloe Vera",
botanicalName: "Aloe vera",
confidence: 1.0,
careInfo: { waterIntervalDays: 14, light: "Soleado", temp: "20-30°C" },
description: "Una planta medicinal cuyo gel ayuda con las quemaduras solares. Requiere un lugar muy luminoso y poca agua.",
imageUri: "https://images.unsplash.com/photo-1567689265771-828557d4766c?q=80&w=400&auto=format&fit=crop",
categories: ["succulent", "medicinal", "sun"]
},
{
name: "Cinta",
botanicalName: "Chlorophytum comosum",
confidence: 1.0,
careInfo: { waterIntervalDays: 7, light: "Sombra Parcial", temp: "15-23°C" },
description: "La Cinta es extremadamente adaptable y forma retoños rápidamente. Perdona los errores de riego y es ideal para principiantes.",
imageUri: "https://images.unsplash.com/photo-1616766649725-b44c698308eb?q=80&w=400&auto=format&fit=crop",
categories: ["easy", "hanging", "pet_friendly"]
},
{
name: "Cuna de Moisés",
botanicalName: "Spathiphyllum",
confidence: 1.0,
careInfo: { waterIntervalDays: 5, light: "Sombra Parcial", temp: "18-25°C" },
description: "La Cuna de Moisés muestra cuándo necesita agua al dejar caer sus hojas. Florece hermosamente en blanco incluso con poca luz.",
imageUri: "https://images.unsplash.com/photo-1610496185876-06835a64627d?q=80&w=400&auto=format&fit=crop",
categories: ["flowering", "low_light", "air_purifier"]
},
{
name: "Calathea",
botanicalName: "Calathea",
confidence: 1.0,
careInfo: { waterIntervalDays: 4, light: "Sombra Parcial", temp: "18-24°C" },
description: "Las Calatheas son conocidas por sus hojas estampadas que se pliegan por la noche. Requieren alta humedad.",
imageUri: "https://images.unsplash.com/photo-1600869680373-b82fa72f8823?q=80&w=400&auto=format&fit=crop",
categories: ["patterned", "pet_friendly", "high_humidity"]
}
]
};
export const PlantDatabaseService = {
getAllPlants: (lang: Language): DatabaseEntry[] => {
return PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
},
searchPlants: (query: string, lang: Language): DatabaseEntry[] => {
const plants = PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
const lowerQuery = query.toLowerCase();
return plants.filter(p =>
p.name.toLowerCase().includes(lowerQuery) ||
p.botanicalName.toLowerCase().includes(lowerQuery)
);
},
getRandomPlant: (lang: Language): DatabaseEntry => {
const plants = PLANT_DATABASE[lang] || PLANT_DATABASE['de'];
return plants[Math.floor(Math.random() * plants.length)];
}
};

View File

@ -0,0 +1,85 @@
import { IdentificationResult, Language } from '../types';
import { GoogleGenAI, Type } from "@google/genai";
import { PlantDatabaseService } from './plantDatabaseService';
// Helper to convert base64 data URL to raw base64 string
const cleanBase64 = (dataUrl: string) => {
return dataUrl.split(',')[1];
};
export const PlantRecognitionService = {
identify: async (imageUri: string, lang: Language = 'de'): Promise<IdentificationResult> => {
// 1. Check if we have an API Key. If so, use Gemini
if (process.env.API_KEY) {
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Dynamic prompt based on language
const promptLang = lang === 'de' ? 'German' : lang === 'es' ? 'Spanish' : 'English';
const promptText = `Identify this plant. Provide the common ${promptLang} name, the botanical name, a description (2 sentences) in ${promptLang}, an estimated confidence (0-1), and care info (water interval in days, light in ${promptLang}, temp). Response must be JSON.`;
const response = await ai.models.generateContent({
model: 'gemini-3-pro-preview',
contents: {
parts: [
{
inlineData: {
mimeType: 'image/jpeg',
data: cleanBase64(imageUri),
},
},
{
text: promptText
}
],
},
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING },
botanicalName: { type: Type.STRING },
description: { type: Type.STRING },
confidence: { type: Type.NUMBER },
careInfo: {
type: Type.OBJECT,
properties: {
waterIntervalDays: { type: Type.NUMBER },
light: { type: Type.STRING },
temp: { type: Type.STRING },
},
required: ["waterIntervalDays", "light", "temp"]
}
},
required: ["name", "botanicalName", "confidence", "careInfo", "description"]
}
}
});
if (response.text) {
return JSON.parse(response.text) as IdentificationResult;
}
} catch (error) {
console.error("Gemini analysis failed, falling back to mock.", error);
}
}
// 2. Mock Process (Fallback)
return new Promise((resolve) => {
setTimeout(() => {
// Use the centralized database service for consistent mock results
const randomResult = PlantDatabaseService.getRandomPlant(lang);
// Create a clean IdentificationResult without categories/imageUri if we want to strictly adhere to that type,
// though Typescript allows extra props.
// We simulate that the recognition might not be 100% like the db
resolve({
...randomResult,
confidence: 0.85 + Math.random() * 0.14
});
}, 2500);
});
}
};

View File

@ -0,0 +1,50 @@
import { Plant, Language } from '../types';
const STORAGE_KEY = 'greenlens_plants';
const LANG_KEY = 'greenlens_language';
export const StorageService = {
getPlants: (): Plant[] => {
try {
const json = localStorage.getItem(STORAGE_KEY);
return json ? JSON.parse(json) : [];
} catch (e) {
console.error('Failed to load plants', e);
return [];
}
},
savePlant: (plant: Plant): void => {
const plants = StorageService.getPlants();
const updatedPlants = [plant, ...plants];
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
},
deletePlant: (id: string): void => {
const plants = StorageService.getPlants();
const updatedPlants = plants.filter(p => p.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPlants));
},
updatePlant: (updatedPlant: Plant): void => {
const plants = StorageService.getPlants();
const index = plants.findIndex(p => p.id === updatedPlant.id);
if (index !== -1) {
plants[index] = updatedPlant;
localStorage.setItem(STORAGE_KEY, JSON.stringify(plants));
}
},
getLanguage: (): Language => {
try {
const lang = localStorage.getItem(LANG_KEY);
return (lang as Language) || 'de';
} catch (e) {
return 'de';
}
},
saveLanguage: (lang: Language): void => {
localStorage.setItem(LANG_KEY, lang);
}
};

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

35
types.ts Normal file
View File

@ -0,0 +1,35 @@
export interface CareInfo {
waterIntervalDays: number;
light: string;
temp: string;
}
export interface Plant {
id: string;
name: string;
botanicalName: string;
imageUri: string;
dateAdded: string; // Serialized Date
careInfo: CareInfo;
lastWatered: string; // Serialized Date
wateringHistory?: string[]; // Array of serialized dates
description?: string;
notificationsEnabled?: boolean;
}
export interface IdentificationResult {
name: string;
botanicalName: string;
confidence: number;
careInfo: CareInfo;
description?: string;
}
export enum Tab {
HOME = 'home',
SEARCH = 'search',
SETTINGS = 'settings',
}
export type Language = 'de' | 'en' | 'es';

314
utils/translations.ts Normal file
View File

@ -0,0 +1,314 @@
import { Language } from '../types';
export const translations = {
de: {
// Tabs
tabPlants: 'Pflanzen',
tabSearch: 'Suche',
tabProfile: 'Profil',
// Headers
myPlants: 'Meine Pflanzen',
searchTitle: 'Suche',
settingsTitle: 'Einstellungen',
// Settings
darkMode: 'Dark Mode',
language: 'Sprache',
// Empty States / Info
noPlants: 'Noch keine Pflanzen.',
// Filters
allGood: 'Alles gut',
toWater: 'Zu gießen',
// Search
searchPlaceholder: 'Pflanzen suchen...',
categories: 'Kategorien entdecken',
resultsInPlants: 'Ergebnisse in "Meine Pflanzen"',
noResults: 'Keine Pflanzen gefunden.',
// Categories
catCareEasy: "Pflegeleicht",
catSucculents: "Sukkulenten",
catLowLight: "Wenig Licht",
catPetFriendly: "Tierfreundlich",
catAirPurifier: "Luftreiniger",
catFlowering: "Blühend",
// Dictionary
lexiconTitle: "Pflanzen-Lexikon",
lexiconDesc: "Durchsuche unsere Datenbank und finde die perfekte Ergänzung für dein Zuhause.",
lexiconSearchPlaceholder: "Lexikon durchsuchen...",
browseLexicon: "Im Lexikon stöbern",
backToSearch: "Zurück zur Suche",
// Misc
comingSoon: 'Bald verfügbar',
gallery: 'Galerie',
help: 'Hilfe',
scanner: 'Scanner',
analyzing: 'Pflanze wird analysiert...',
localProcessing: "Lokale Verarbeitung",
// Scan Stages
scanStage1: "Bildqualität wird geprüft...",
scanStage2: "Blattstrukturen werden analysiert...",
scanStage3: "Abgleich mit Pflanzendatenbank...",
// Plant Card / Detail / Result
result: "Ergebnis",
match: "Übereinstimmung",
careCheck: "Pflege-Check",
showDetails: "Details anzeigen",
hideDetails: "Details verbergen",
dataSavedLocally: "Daten werden lokal gespeichert",
addToPlants: "Zu meinen Pflanzen hinzufügen",
aboutPlant: "Über die Pflanze",
noDescription: "Keine Beschreibung verfügbar.",
careTips: "Pflegehinweise",
addedOn: "Hinzugefügt am",
plantAddedSuccess: "Pflanze erfolgreich hinzugefügt",
// Detailed Care
detailedCare: "Detaillierte Pflege",
careTextWater: "Bodenfeuchtigkeit alle {0} Tage prüfen.",
careTextLight: "Ideal für Standorte: {0}.",
careTextTemp: "Wohlfühltemperatur: {0}.",
// Care Attributes
water: "Gießen",
light: "Licht",
temp: "Temperatur",
// Care Values (UI Helper)
waterModerate: "Mäßig",
waterLittle: "Wenig",
waterEveryXDays: "Alle {0} Tage",
waterToday: "Heute gießen",
inXDays: "In {0} Tagen",
nextWatering: "Nächstes Gießen: {0}",
days: "Tage",
// Actions
delete: "Löschen",
edit: "Bearbeiten",
share: "Teilen",
waterNow: "Jetzt gießen",
watered: "Gegossen",
wateredSuccess: "Pflanze wurde gegossen!",
plantDeleted: "Pflanze entfernt.",
deleteConfirmTitle: "Pflanze löschen?",
deleteConfirmMessage: "Möchtest du diese Pflanze wirklich aus deiner Sammlung entfernen? Dies kann nicht rückgängig gemacht werden.",
cancel: "Abbrechen",
confirm: "Löschen",
lastWateredDate: "Zuletzt gegossen: {0}",
// History
wateringHistory: "Gießhistorie",
noHistory: "Noch keine Einträge.",
// Reminder
reminder: "Erinnerung",
reminderDesc: "Benachrichtigung am Stichtag",
reminderOn: "Aktiviert",
reminderOff: "Deaktiviert",
reminderPermissionNeeded: "Berechtigung für Benachrichtigungen erforderlich.",
},
en: {
tabPlants: 'Plants',
tabSearch: 'Search',
tabProfile: 'Profile',
myPlants: 'My Plants',
searchTitle: 'Search',
settingsTitle: 'Settings',
darkMode: 'Dark Mode',
language: 'Language',
noPlants: 'No plants yet.',
allGood: 'All good',
toWater: 'To water',
searchPlaceholder: 'Search plants...',
categories: 'Discover Categories',
resultsInPlants: 'Results in "My Plants"',
noResults: 'No plants found.',
catCareEasy: "Easy Care",
catSucculents: "Succulents",
catLowLight: "Low Light",
catPetFriendly: "Pet Friendly",
catAirPurifier: "Air Purifier",
catFlowering: "Flowering",
lexiconTitle: "Plant Encyclopedia",
lexiconDesc: "Browse our database and find the perfect addition for your home.",
lexiconSearchPlaceholder: "Search encyclopedia...",
browseLexicon: "Browse Encyclopedia",
backToSearch: "Back to Search",
comingSoon: 'Coming Soon',
gallery: 'Gallery',
help: 'Help',
scanner: 'Scanner',
analyzing: 'Analyzing plant...',
localProcessing: "Local Processing",
// Scan Stages
scanStage1: "Checking image quality...",
scanStage2: "Analyzing leaf structures...",
scanStage3: "Matching with plant database...",
result: "Result",
match: "Match",
careCheck: "Care Check",
showDetails: "Show Details",
hideDetails: "Hide Details",
dataSavedLocally: "Data is saved locally",
addToPlants: "Add to my plants",
aboutPlant: "About the plant",
noDescription: "No description available.",
careTips: "Care Tips",
addedOn: "Added on",
plantAddedSuccess: "Plant successfully added",
detailedCare: "Detailed Care",
careTextWater: "Check soil moisture every {0} days.",
careTextLight: "Ideal location: {0}.",
careTextTemp: "Ideal temperature: {0}.",
water: "Water",
light: "Light",
temp: "Temperature",
waterModerate: "Moderate",
waterLittle: "Little",
waterEveryXDays: "Every {0} days",
waterToday: "Water today",
inXDays: "In {0} days",
nextWatering: "Next watering: {0}",
days: "Days",
delete: "Delete",
edit: "Edit",
share: "Share",
waterNow: "Water Now",
watered: "Watered",
wateredSuccess: "Plant watered!",
plantDeleted: "Plant removed.",
deleteConfirmTitle: "Delete plant?",
deleteConfirmMessage: "Do you really want to remove this plant from your collection? This cannot be undone.",
cancel: "Cancel",
confirm: "Delete",
lastWateredDate: "Last watered: {0}",
// History
wateringHistory: "Watering History",
noHistory: "No entries yet.",
// Reminder
reminder: "Reminder",
reminderDesc: "Notification on due date",
reminderOn: "Enabled",
reminderOff: "Disabled",
reminderPermissionNeeded: "Notification permission required.",
},
es: {
tabPlants: 'Plantas',
tabSearch: 'Buscar',
tabProfile: 'Perfil',
myPlants: 'Mis Plantas',
searchTitle: 'Buscar',
settingsTitle: 'Ajustes',
darkMode: 'Modo Oscuro',
language: 'Idioma',
noPlants: 'Aún no hay plantas.',
allGood: 'Todo bien',
toWater: 'Regar',
searchPlaceholder: 'Buscar plantas...',
categories: 'Descubrir Categorías',
resultsInPlants: 'Resultados en "Mis Plantas"',
noResults: 'No se encontraron plantas.',
catCareEasy: "Fácil Cuidado",
catSucculents: "Suculentas",
catLowLight: "Poca Luz",
catPetFriendly: "Pet Friendly",
catAirPurifier: "Purificador",
catFlowering: "Con Flores",
lexiconTitle: "Enciclopedia",
lexiconDesc: "Explora nuestra base de datos y encuentra la adición perfecta para tu hogar.",
lexiconSearchPlaceholder: "Buscar en enciclopedia...",
browseLexicon: "Explorar Enciclopedia",
backToSearch: "Volver a Buscar",
comingSoon: 'Próximamente',
gallery: 'Galería',
help: 'Ayuda',
scanner: 'Escáner',
analyzing: 'Analizando planta...',
localProcessing: "Procesamiento Local",
// Scan Stages
scanStage1: "Verificando calidad de imagen...",
scanStage2: "Analizando estructuras...",
scanStage3: "Comparando con base de datos...",
result: "Resultado",
match: "Coincidencia",
careCheck: "Chequeo de Cuidados",
showDetails: "Ver Detalles",
hideDetails: "Ocultar Detalles",
dataSavedLocally: "Datos guardados localmente",
addToPlants: "Añadir a mis plantas",
aboutPlant: "Sobre la planta",
noDescription: "Sin descripción disponible.",
careTips: "Consejos de Cuidado",
addedOn: "Añadido el",
plantAddedSuccess: "Planta añadida con éxito",
detailedCare: "Cuidado Detallado",
careTextWater: "Revisar humedad cada {0} días.",
careTextLight: "Ubicación ideal: {0}.",
careTextTemp: "Temperatura ideal: {0}.",
water: "Riego",
light: "Luz",
temp: "Temperatura",
waterModerate: "Moderado",
waterLittle: "Poco",
waterEveryXDays: "Cada {0} días",
waterToday: "Regar hoy",
inXDays: "En {0} días",
nextWatering: "Próximo riego: {0}",
days: "Días",
delete: "Eliminar",
edit: "Editar",
share: "Compartir",
waterNow: "Regar ahora",
watered: "Regada",
wateredSuccess: "¡Planta regada!",
plantDeleted: "Planta eliminada.",
deleteConfirmTitle: "¿Eliminar planta?",
deleteConfirmMessage: "¿Realmente quieres eliminar esta planta de tu colección? Esto no se puede deshacer.",
cancel: "Cancelar",
confirm: "Eliminar",
lastWateredDate: "Último riego: {0}",
// History
wateringHistory: "Historial de Riego",
noHistory: "Sin entradas aún.",
// Reminder
reminder: "Recordatorio",
reminderDesc: "Notificación el día de riego",
reminderOn: "Activado",
reminderOff: "Desactivado",
reminderPermissionNeeded: "Permiso de notificación requerido.",
}
};
export const getTranslation = (lang: Language) => translations[lang];

23
vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});