From 024eec66865696adf3c981b90a15ca986a7c0684 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Wed, 28 Jan 2026 11:43:24 +0100 Subject: [PATCH] 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. --- .gitignore | 24 + App.tsx | 680 ++++++++++++++++++++++++++++ README.md | 25 +- components/PlantCard.tsx | 59 +++ components/PlantDetail.tsx | 302 ++++++++++++ components/PlantSkeleton.tsx | 21 + components/ResultCard.tsx | 140 ++++++ components/TabBar.tsx | 51 +++ components/Toast.tsx | 37 ++ index.html | 81 ++++ index.tsx | 15 + metadata.json | 7 + package.json | 24 + services/plantDatabaseService.ts | 253 +++++++++++ services/plantRecognitionService.ts | 85 ++++ services/storageService.ts | 50 ++ tsconfig.json | 29 ++ types.ts | 35 ++ utils/translations.ts | 314 +++++++++++++ vite.config.ts | 23 + 20 files changed, 2247 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/PlantCard.tsx create mode 100644 components/PlantDetail.tsx create mode 100644 components/PlantSkeleton.tsx create mode 100644 components/ResultCard.tsx create mode 100644 components/TabBar.tsx create mode 100644 components/Toast.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/plantDatabaseService.ts create mode 100644 services/plantRecognitionService.ts create mode 100644 services/storageService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils/translations.ts create mode 100644 vite.config.ts 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, '.'), + } + } + }; +});