diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..19e62f8 --- /dev/null +++ b/App.tsx @@ -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.HOME); + const [plants, setPlants] = useState([]); + const [isDarkMode, setIsDarkMode] = useState(false); + const [language, setLanguage] = useState('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(null); + + // Analysis State + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisProgress, setAnalysisProgress] = useState(0); + const [analysisResult, setAnalysisResult] = useState(null); + + // Detail State + const [selectedPlant, setSelectedPlant] = useState(null); + + // Toast State + const [toast, setToast] = useState({ message: '', visible: false }); + + // Refs + const fileInputRef = useRef(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) => { + 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 = () => ( +
+
+

{t.myPlants}

+ +
+ + {/* Filters */} +
+ + +
+ + {isLoadingPlants ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : plants.length === 0 ? ( +
+ +

{t.noPlants}

+
+ ) : ( +
+ {plants.map(plant => ( + handlePlantClick(plant)} t={t} /> + ))} +
+ )} + + {/* FAB */} + +
+ ); + + 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 ( +
+
+

{t.searchTitle}

+ +
+ + 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 && ( + + )} +
+
+ + {searchQuery ? ( +
+

+ {filteredPlants.length} {t.resultsInPlants} +

+ {filteredPlants.length > 0 ? ( +
+ {filteredPlants.map(plant => ( + handlePlantClick(plant)} t={t} /> + ))} +
+ ) : ( +
+

{t.noResults}

+
+ )} +
+ ) : ( +
+

{t.categories}

+
+ {categories.map((cat) => ( + + ))} +
+ +
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" + > +
+

+ + {t.lexiconTitle} +

+

{t.lexiconDesc}

+ {t.browseLexicon} +
+ +
+
+ )} +
+ ); + }; + + const renderLexicon = () => { + if (!isLexiconOpen) return null; + + // If we have a selected item from Lexicon, show ResultCard (Detail View) + if (analysisResult && selectedImage) { + return ( +
+ +
+ ); + } + + const lexiconPlants = PlantDatabaseService.searchPlants(lexiconSearchQuery, language); + + return ( +
+ {/* Header */} +
+ +

{t.lexiconTitle}

+
+ + {/* Content */} +
+ + {/* Search */} +
+ + 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" + /> +
+ + {/* Grid - NOW 3 COLUMNS */} +
+ {lexiconPlants.map((plant, index) => ( + + ))} + {lexiconPlants.length === 0 && ( +
+ {t.noResults} +
+ )} +
+
+
+ ); + }; + + 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 ( +
+

{t.settingsTitle}

+ + {/* Dark Mode Settings */} +
+ {t.darkMode} + +
+ + {/* Language Settings (Dropdown Style) */} +
+
setIsLanguageDropdownOpen(!isLanguageDropdownOpen)} + > +
+ + {t.language} +
+ +
+ {currentLangLabel} + {isLanguageDropdownOpen ? : } +
+
+ + {/* Dropdown Content */} + {isLanguageDropdownOpen && ( +
+
+ {languages.map((lang) => ( + + ))} +
+
+ )} +
+
+ ); + }; + + const renderScannerModal = () => { + if (!isScannerOpen) return null; + + // 1. Result View + if (analysisResult && selectedImage) { + return ( +
+ +
+ ); + } + + // 2. Scanner View + return ( +
+ {/* Header */} +
+ + {t.scanner} + +
+ + {/* Main Camera Area */} +
+ {selectedImage ? ( + + ) : ( +
+ )} + + {/* Background Grid */} +
+
+ + {/* Scan Frame */} +
+ + {/* SHOW SELECTED IMAGE IN FRAME */} + {selectedImage && ( + Scan preview + )} + +
+
+
+
+ + {/* Laser Line */} + {isAnalyzing || !selectedImage ? ( +
+ ) : null} +
+
+ + {/* Analyzing Sheet Overlay - Loading Animation */} + {isAnalyzing && ( +
+
+ + {analysisProgress < 100 ? t.analyzing : t.result} + + + {Math.round(analysisProgress)}% + +
+ + {/* Progress Bar */} +
+
+
+ + {/* Stage Indicators */} +
+
+
+ {t.localProcessing} +
+ + {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} + +
+
+ )} + + {/* Bottom Controls */} +
+
+ + {t.gallery} +
+ + + +
+ + {t.help} +
+ + +
+
+ ); + }; + + return ( +
+
+ {activeTab === Tab.HOME && renderHome()} + {activeTab === Tab.SETTINGS && renderSettings()} + {activeTab === Tab.SEARCH && renderSearch()} +
+ + + + {/* Modal Layer for Detail View */} + {selectedPlant && ( + + )} + + {/* Modal Layer for Scanner */} + {renderScannerModal()} + + {/* Lexicon Overlay */} + {renderLexicon()} + + {/* Toast Notification */} + +
+ ); +}; + +export default App; diff --git a/README.md b/README.md index 2241000..e36e573 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# 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` diff --git a/components/PlantCard.tsx b/components/PlantCard.tsx new file mode 100644 index 0000000..b6521dd --- /dev/null +++ b/components/PlantCard.tsx @@ -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 = ({ 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 ( + + ); +}; \ No newline at end of file diff --git a/components/PlantDetail.tsx b/components/PlantDetail.tsx new file mode 100644 index 0000000..df8fcb3 --- /dev/null +++ b/components/PlantDetail.tsx @@ -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 = ({ plant, onClose, onDelete, onUpdate, t, language }) => { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Map internal language codes to locale strings for Date + const localeMap: Record = { + 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 ( +
+ + {/* Header */} +
+ +
+ + +
+
+ +
+ {/* Hero Image */} +
+ {plant.name} +
+ +
+

+ {plant.name} +

+

+ {plant.botanicalName} +

+
+
+ + {/* Content Container */} +
+ {/* Added Date Info */} +
+
+ + {t.addedOn} {formattedAddedDate} +
+
+ + {/* Main Action: Water */} +
+
+ {lastWateredText} + + {nextWateringText} + +
+ +
+ + {/* Reminder Toggle */} +
+
+
+ {plant.notificationsEnabled ? : } +
+
+ {t.reminder} + {plant.notificationsEnabled ? t.reminderOn : t.reminderOff} +
+
+ +
+ +

{t.aboutPlant}

+

+ {plant.description || t.noDescription} +

+ + {/* Care Info */} +

{t.careTips}

+
+
+
+ +
+ {t.water} + + {plant.careInfo.waterIntervalDays} {t.days || 'Tage'} + +
+ +
+
+ +
+ {t.light} + + {plant.careInfo.light} + +
+ +
+
+ +
+ {t.temp} + + {plant.careInfo.temp} + +
+
+ + {/* Watering History Section */} +

{t.wateringHistory}

+
+ {(!plant.wateringHistory || plant.wateringHistory.length === 0) ? ( +
+ + {t.noHistory} +
+ ) : ( +
    + {plant.wateringHistory.slice(0, 5).map((dateStr, index) => ( +
  • +
    +
    + +
    + + {new Date(dateStr).toLocaleDateString(localeMap[language] || 'de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} + +
    + + {new Date(dateStr).toLocaleTimeString(localeMap[language] || 'de-DE', { hour: '2-digit', minute: '2-digit' })} + +
  • + ))} +
+ )} +
+ + {/* Danger Zone */} +
+ +
+
+
+ + {/* Delete Confirmation Modal */} + {showDeleteConfirm && ( +
+
+
+ +
+

{t.deleteConfirmTitle}

+

+ {t.deleteConfirmMessage} +

+ +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/components/PlantSkeleton.tsx b/components/PlantSkeleton.tsx new file mode 100644 index 0000000..49e122c --- /dev/null +++ b/components/PlantSkeleton.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export const PlantSkeleton: React.FC = () => { + return ( +
+ + {/* Badge Placeholder */} +
+
+
+ + {/* Content Placeholder */} +
+ {/* Title */} +
+ {/* Subtitle */} +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/ResultCard.tsx b/components/ResultCard.tsx new file mode 100644 index 0000000..dfaaf0e --- /dev/null +++ b/components/ResultCard.tsx @@ -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 = ({ result, imageUri, onSave, onClose, t }) => { + const [showDetails, setShowDetails] = useState(false); + + return ( +
+ + {/* Header */} +
+ + {t.result} + +
+ +
+ {/* Hero Image */} +
+ Analyzed Plant +
+ + {Math.round(result.confidence * 100)}% {t.match} +
+
+ + {/* Info */} +
+

+ {result.name} +

+

+ {result.botanicalName} +

+ +

+ {result.description || t.noDescription} +

+ + {/* Care Check */} +
+

{t.careCheck}

+ +
+ +
+
+
+ +
+ {t.water} + + {result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle} + +
+ +
+
+ +
+ {t.light} + + {result.careInfo.light} + +
+ +
+
+ +
+ {t.temp} + + {result.careInfo.temp} + +
+
+ + {/* Expanded Details */} + {showDetails && ( +
+

{t.detailedCare}

+
    +
  • + + {t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString())} +
  • +
  • + + {t.careTextLight.replace('{0}', result.careInfo.light)} +
  • +
  • + + {t.careTextTemp.replace('{0}', result.careInfo.temp)} +
  • +
+
+ )} + + {/* Save Button */} +
+
+
+
+ {t.dataSavedLocally} +
+ + +
+
+
+
+ ); +}; diff --git a/components/TabBar.tsx b/components/TabBar.tsx new file mode 100644 index 0000000..d97c588 --- /dev/null +++ b/components/TabBar.tsx @@ -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 = ({ currentTab, onTabChange, labels }) => { + return ( +
+
+ + + + + +
+
+ ); +}; \ No newline at end of file diff --git a/components/Toast.tsx b/components/Toast.tsx new file mode 100644 index 0000000..745ebfe --- /dev/null +++ b/components/Toast.tsx @@ -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 = ({ 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 ( +
+
+ + {message} +
+
+ ); +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..bf8a32e --- /dev/null +++ b/index.html @@ -0,0 +1,81 @@ + + + + + + GreenLens + + + + + + + +
+ + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -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( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..8fbb427 --- /dev/null +++ b/metadata.json @@ -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" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d6e27f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/plantDatabaseService.ts b/services/plantDatabaseService.ts new file mode 100644 index 0000000..944d071 --- /dev/null +++ b/services/plantDatabaseService.ts @@ -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 = { + 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)]; + } +}; diff --git a/services/plantRecognitionService.ts b/services/plantRecognitionService.ts new file mode 100644 index 0000000..af27302 --- /dev/null +++ b/services/plantRecognitionService.ts @@ -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 => { + // 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); + }); + } +}; diff --git a/services/storageService.ts b/services/storageService.ts new file mode 100644 index 0000000..b6f014e --- /dev/null +++ b/services/storageService.ts @@ -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); + } +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..dff160f --- /dev/null +++ b/types.ts @@ -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'; diff --git a/utils/translations.ts b/utils/translations.ts new file mode 100644 index 0000000..77e8a92 --- /dev/null +++ b/utils/translations.ts @@ -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]; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});