| |
|
| |
|
| |
|
| |
|
| | import React, { useState, useCallback, useRef, useEffect } from 'react'; |
| | import { useLocalStorage } from '../hooks/useLocalStorage'; |
| | import { runHealthCheck, isAIConfigured } from '../services/geminiService'; |
| | import type { View, HealthCheckResult, BonsaiTree, DiaryLog } from '../types'; |
| | import { AppStatus, LogTag } from '../types'; |
| | import { StethoscopeIcon, LeafIcon, DropletIcon, BugIcon, BonsaiIcon, SparklesIcon, AlertTriangleIcon, UploadCloudIcon, CheckCircleIcon, ArrowLeftIcon } from '../components/icons'; |
| | import Spinner from '../components/Spinner'; |
| |
|
| | type Stage = 'selecting_problem' | 'uploading_photo' | 'analyzing' | 'results'; |
| |
|
| | const problemCategories = [ |
| | { name: 'Leaf Discoloration', description: 'Yellowing, browning, or strange colors on leaves.', icon: LeafIcon, instruction: "Take a clear, well-lit photo of the discolored leaves. Show both the top and underside if possible." }, |
| | { name: 'Spots or Residue', description: 'Powdery mildew, black spots, or sticky residue.', icon: DropletIcon, instruction: "Get a close-up of the spots or residue. Try to have a healthy leaf in the background for comparison." }, |
| | { name: 'Pests or Damage', description: 'Visible insects, webbing, or chewed leaves.', icon: BugIcon, instruction: "Photograph the pests or the damage they've caused. If the pest is tiny, get as close as you can while maintaining focus." }, |
| | { name: 'Wilting or Drooping', description: 'Leaves or branches are losing turgidity and hanging down.', icon: BonsaiIcon, instruction: "Show the entire wilting branch or section of the tree. Also, include a photo of the soil surface if possible." }, |
| | { name: 'General Weakness', description: 'Overall lack of vigor, poor growth, or branch dieback.', icon: AlertTriangleIcon, instruction: "Take a photo of the entire tree so its overall structure and condition are visible." }, |
| | ]; |
| |
|
| | const HealthCheckView: React.FC<{ setActiveView: (view: View) => void }> = ({ setActiveView }) => { |
| | const [stage, setStage] = useState<Stage>('selecting_problem'); |
| | const [selectedCategory, setSelectedCategory] = useState<(typeof problemCategories)[0] | null>(null); |
| | const [trees, setTrees] = useLocalStorage<BonsaiTree[]>('bonsai-diary-trees', []); |
| | const [selectedTreeId, setSelectedTreeId] = useState<string>(''); |
| | const [treeInfo, setTreeInfo] = useState({ species: '', location: '' }); |
| | const [image, setImage] = useState<{ preview: string; base64: string } | null>(null); |
| | const [result, setResult] = useState<HealthCheckResult | null>(null); |
| | const [error, setError] = useState<string>(''); |
| | const fileInputRef = useRef<HTMLInputElement>(null); |
| | const aiConfigured = isAIConfigured(); |
| |
|
| | useEffect(() => { |
| | if (selectedTreeId) { |
| | const tree = trees.find(t => t.id === selectedTreeId); |
| | if (tree) { |
| | setTreeInfo({ species: tree.species, location: tree.location }); |
| | } |
| | } else { |
| | setTreeInfo({ species: '', location: '' }); |
| | } |
| | }, [selectedTreeId, trees]); |
| |
|
| |
|
| | const handleSelectCategory = (category: (typeof problemCategories)[0]) => { |
| | setSelectedCategory(category); |
| | setStage('uploading_photo'); |
| | }; |
| | |
| | const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
| | const file = event.target.files?.[0]; |
| | if (file) { |
| | if (file.size > 4 * 1024 * 1024) { |
| | setError("File size exceeds 4MB. Please upload a smaller image."); |
| | return; |
| | } |
| | const reader = new FileReader(); |
| | reader.onloadend = () => { |
| | const base64String = (reader.result as string).split(',')[1]; |
| | setImage({ preview: reader.result as string, base64: base64String }); |
| | setError(''); |
| | }; |
| | reader.onerror = () => setError("Failed to read the file."); |
| | reader.readAsDataURL(file); |
| | } |
| | }; |
| | |
| | const handleRunAnalysis = async () => { |
| | if (!image || !treeInfo.species || !treeInfo.location || !selectedCategory) { |
| | setError("Please provide all required information: an image, species, and location."); |
| | return; |
| | } |
| | setStage('analyzing'); |
| | setError(''); |
| |
|
| | try { |
| | const analysisResult = await runHealthCheck(image.base64, treeInfo.species, treeInfo.location, selectedCategory.name); |
| | if(analysisResult) { |
| | setResult(analysisResult); |
| | setStage('results'); |
| | } else { |
| | throw new Error("Failed to get a diagnosis from the AI. It might be busy, or the image could not be processed. Please try again."); |
| | } |
| | } catch (e: any) { |
| | setError(e.message); |
| | setStage('uploading_photo'); |
| | } |
| | }; |
| | |
| | const handleSaveToLog = () => { |
| | if (!selectedTreeId || !result) { |
| | alert("No tree selected or no result to save."); |
| | return; |
| | }; |
| |
|
| | const newLog: DiaryLog = { |
| | id: `log-${Date.now()}`, |
| | date: new Date().toISOString(), |
| | title: `Health Check: ${result.probableCause}`, |
| | notes: `Ran a diagnostic for "${selectedCategory?.name}". The AI diagnosed the issue with ${result.confidence} confidence.`, |
| | photos: image ? [image.base64] : [], |
| | tags: [LogTag.HealthDiagnosis], |
| | healthCheckResult: result |
| | }; |
| |
|
| | const treeToUpdate = trees.find(t => t.id === selectedTreeId); |
| | if (treeToUpdate) { |
| | const updatedLogs = [newLog, ...treeToUpdate.logs].sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime()); |
| | const updatedTree = { ...treeToUpdate, logs: updatedLogs }; |
| | const newTreeList = trees.map(t => t.id === selectedTreeId ? updatedTree : t); |
| | |
| | const storageKey = `yuki-app-bonsai-diary-trees`; |
| | window.localStorage.setItem(storageKey, JSON.stringify(newTreeList)); |
| |
|
| | alert("Diagnosis saved to your tree's log!"); |
| | setActiveView('garden'); |
| | } |
| | }; |
| | |
| | const reset = () => { |
| | setStage('selecting_problem'); |
| | setSelectedCategory(null); |
| | setImage(null); |
| | setResult(null); |
| | setError(''); |
| | |
| | }; |
| |
|
| | const renderHeader = () => ( |
| | <header className="text-center"> |
| | <h2 className="text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl flex items-center justify-center gap-3"> |
| | <StethoscopeIcon className="w-8 h-8 text-red-600" /> |
| | Bonsai Health Check-up |
| | </h2> |
| | <p className="mt-4 text-lg leading-8 text-stone-600 max-w-2xl mx-auto"> |
| | Get a quick, focused diagnosis for a specific problem with your bonsai. |
| | </p> |
| | </header> |
| | ); |
| |
|
| | const renderContent = () => { |
| | switch(stage) { |
| | case 'selecting_problem': |
| | return ( |
| | <div className="bg-white p-6 rounded-xl shadow-lg border border-stone-200"> |
| | <h3 className="text-xl font-semibold text-center text-stone-800 mb-2">What seems to be the problem?</h3> |
| | <p className="text-center text-stone-600 mb-6">Select a category to begin the diagnosis.</p> |
| | <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> |
| | {problemCategories.map(cat => { |
| | const Icon = cat.icon; |
| | return ( |
| | <div key={cat.name} onClick={() => handleSelectCategory(cat)} className="bg-stone-50 rounded-lg p-6 border-2 border-transparent hover:border-green-600 hover:bg-white cursor-pointer transition-all duration-200 text-center flex flex-col items-center shadow-sm hover:shadow-xl"> |
| | <Icon className="w-12 h-12 text-green-700 mb-3" /> |
| | <h4 className="font-bold text-stone-900">{cat.name}</h4> |
| | <p className="text-sm text-stone-600 mt-1">{cat.description}</p> |
| | </div> |
| | )})} |
| | </div> |
| | </div> |
| | ); |
| | |
| | case 'uploading_photo': |
| | return ( |
| | <div className="bg-white p-8 rounded-2xl shadow-lg border border-stone-200 max-w-2xl mx-auto space-y-6"> |
| | <button onClick={() => setStage('selecting_problem')} className="flex items-center gap-2 text-sm font-semibold text-green-700 hover:text-green-600"> |
| | <ArrowLeftIcon className="w-5 h-5"/> Back to Categories |
| | </button> |
| | <div> |
| | <h3 className="text-2xl font-bold text-stone-800">{selectedCategory?.name}</h3> |
| | <p className="text-stone-600 mt-2"><strong className="text-stone-800">Photo Instructions:</strong> {selectedCategory?.instruction}</p> |
| | </div> |
| | <div onClick={() => fileInputRef.current?.click()} className="mt-2 flex justify-center rounded-lg border-2 border-dashed border-stone-300 px-6 py-10 hover:border-green-600 transition-colors cursor-pointer"> |
| | <div className="text-center"> |
| | {image ? <img src={image.preview} alt="Bonsai preview" className="mx-auto h-40 w-auto rounded-md object-cover" /> : ( |
| | <> |
| | <UploadCloudIcon className="mx-auto h-12 w-12 text-stone-400" /> |
| | <p className="mt-2 text-sm font-semibold text-green-700">Upload a file or drag and drop</p> |
| | <p className="text-xs text-stone-500">PNG, JPG up to 4MB</p> |
| | </> )} |
| | </div> |
| | <input ref={fileInputRef} type="file" className="sr-only" onChange={handleFileChange} accept="image/png, image/jpeg" /> |
| | </div> |
| | <div className="space-y-4"> |
| | <select value={selectedTreeId} onChange={(e) => setSelectedTreeId(e.target.value)} className="w-full p-2 border rounded-md" required> |
| | <option value="">Select a tree from your garden...</option> |
| | {trees.map(tree => <option key={tree.id} value={tree.id}>{tree.name} ({tree.species})</option>)} |
| | <option value="new">-- This is a new tree --</option> |
| | </select> |
| | {selectedTreeId === 'new' && ( |
| | <div className="grid grid-cols-2 gap-4"> |
| | <input type="text" placeholder="Species" value={treeInfo.species} onChange={(e) => setTreeInfo(t => ({...t, species: e.target.value}))} className="w-full p-2 border rounded-md"/> |
| | <input type="text" placeholder="Location" value={treeInfo.location} onChange={(e) => setTreeInfo(t => ({...t, location: e.target.value}))} className="w-full p-2 border rounded-md"/> |
| | </div> |
| | )} |
| | </div> |
| | {error && <p className="text-sm text-red-600">{error}</p>} |
| | <button onClick={handleRunAnalysis} disabled={!image || !aiConfigured} className="w-full flex items-center justify-center gap-2 rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-red-500 disabled:bg-stone-400 disabled:cursor-not-allowed"> |
| | <StethoscopeIcon className="w-5 h-5"/> Get Diagnosis |
| | </button> |
| | {!aiConfigured && ( |
| | <div className="mt-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg border border-yellow-200 text-center"> |
| | <p className="text-sm"> |
| | AI features are disabled. Please set your Gemini API key in the{' '} |
| | <button onClick={() => setActiveView('settings')} className="font-bold underline hover:text-yellow-900"> |
| | Settings page |
| | </button>. |
| | </p> |
| | </div> |
| | )} |
| | </div> |
| | ); |
| |
|
| | case 'analyzing': |
| | return <Spinner text={`Yuki is diagnosing the ${selectedCategory?.name.toLowerCase()}...`} />; |
| |
|
| | case 'results': |
| | if (!result) return null; |
| | const confidenceColor = result.confidence === 'High' ? 'bg-red-100 text-red-800' : result.confidence === 'Medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'; |
| | return ( |
| | <div className="bg-white p-8 rounded-2xl shadow-lg border border-stone-200 max-w-3xl mx-auto space-y-6"> |
| | <h3 className="text-2xl font-bold text-stone-800">Diagnosis Complete</h3> |
| | <div className="p-4 bg-stone-50 rounded-lg border"> |
| | <div className="flex justify-between items-baseline"> |
| | <h4 className="text-xl font-bold text-stone-900">{result.probableCause}</h4> |
| | <span className={`px-2 py-0.5 text-xs font-medium rounded-full ${confidenceColor}`}>{result.confidence} Confidence</span> |
| | </div> |
| | <p className="mt-2 text-stone-600">{result.explanation}</p> |
| | </div> |
| | |
| | <div> |
| | <h4 className="text-lg font-semibold text-stone-800 mb-2">Treatment Plan</h4> |
| | <ol className="space-y-4"> |
| | {result.treatmentPlan.map(step => ( |
| | <li key={step.step} className="flex gap-4"> |
| | <div className="flex-shrink-0 w-8 h-8 bg-green-600 text-white rounded-full flex items-center justify-center font-bold">{step.step}</div> |
| | <div> |
| | <p className="font-bold text-stone-800">{step.action}</p> |
| | <p className="text-stone-600">{step.details}</p> |
| | </div> |
| | </li>))} |
| | </ol> |
| | </div> |
| | |
| | <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> |
| | <div className="p-4 bg-green-50 rounded-lg"> |
| | <h4 className="font-semibold text-green-800">Organic Alternatives</h4> |
| | <p className="text-sm text-green-700 mt-1">{result.organicAlternatives}</p> |
| | </div> |
| | <div className="p-4 bg-blue-50 rounded-lg"> |
| | <h4 className="font-semibold text-blue-800">Future Prevention</h4> |
| | <p className="text-sm text-blue-700 mt-1">{result.preventativeMeasures}</p> |
| | </div> |
| | </div> |
| | |
| | <div className="flex flex-col sm:flex-row gap-4 pt-6 border-t"> |
| | <button onClick={reset} className="flex-1 w-full bg-stone-200 text-stone-700 font-semibold py-3 px-6 rounded-lg hover:bg-stone-300 transition-colors">Run New Diagnosis</button> |
| | {selectedTreeId && selectedTreeId !== 'new' && ( |
| | <button onClick={handleSaveToLog} className="flex-1 w-full flex items-center justify-center gap-2 rounded-md bg-green-700 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-600"> |
| | <CheckCircleIcon className="w-5 h-5"/> Save to Garden Log |
| | </button> |
| | )} |
| | </div> |
| | </div> |
| | ); |
| | } |
| | } |
| |
|
| | return ( |
| | <div className="space-y-8"> |
| | {renderHeader()} |
| | <div>{renderContent()}</div> |
| | </div> |
| | ); |
| | }; |
| |
|
| | export default HealthCheckView; |