feat: 2-column grid layout + voice input for walkthrough
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled

- Cards now display in 2-column grid on desktop (md:grid-cols-2)
- Wider container (max-w-4xl) for better desktop layout
- Voice input button on notes fields using Web Speech API
- Tap mic to dictate notes hands-free, tap again to stop
This commit is contained in:
fullsizemalt 2025-12-19 21:19:32 -08:00
parent 8dbcd51246
commit 78f8b334e3

View file

@ -4,7 +4,7 @@ import { settingsApi, WalkthroughSettings } from '../lib/settingsApi';
import { walkthroughApi, ReservoirCheckData, IrrigationCheckData, PlantHealthCheckData, Walkthrough } from '../lib/walkthroughApi';
import {
Check, Loader2, Droplets, Sprout, Bug, ArrowLeft,
Camera, X, Minus, Plus, ChevronDown, ChevronUp, Upload, CheckCircle2, Clock
Camera, X, Minus, Plus, ChevronDown, ChevronUp, Upload, CheckCircle2, Clock, Mic, MicOff
} from 'lucide-react';
import { useToast } from '../context/ToastContext';
import { cn } from '../lib/utils';
@ -239,7 +239,7 @@ export default function DailyWalkthroughPage() {
<div className="min-h-screen bg-primary pb-28">
{/* Header - fixed with progress */}
<div className="sticky top-0 z-20 bg-elevated border-b border-subtle">
<div className="max-w-2xl mx-auto px-4 py-3">
<div className="max-w-4xl mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/" className="p-2 -ml-2 rounded-lg hover:bg-tertiary text-tertiary">
@ -271,7 +271,7 @@ export default function DailyWalkthroughPage() {
</div>
{/* Sections */}
<div className="max-w-2xl mx-auto px-4 py-4 space-y-3">
<div className="max-w-4xl mx-auto px-4 py-4 space-y-3">
{/* Reservoirs */}
{settings?.enableReservoirs !== false && (
<Section
@ -282,7 +282,7 @@ export default function DailyWalkthroughPage() {
expanded={expandedSections.reservoirs}
onToggle={() => toggleSection('reservoirs')}
>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TANKS.map(tank => (
<ReservoirCard
key={tank.name}
@ -305,7 +305,7 @@ export default function DailyWalkthroughPage() {
expanded={expandedSections.irrigation}
onToggle={() => toggleSection('irrigation')}
>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{ZONES.map(zone => (
<IrrigationCard
key={zone.name}
@ -328,7 +328,7 @@ export default function DailyWalkthroughPage() {
expanded={expandedSections.plantHealth}
onToggle={() => toggleSection('plantHealth')}
>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{HEALTH_ZONES.map(zone => (
<HealthCard
key={zone}
@ -344,7 +344,7 @@ export default function DailyWalkthroughPage() {
{/* Submit button - fixed bottom */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-elevated border-t border-subtle">
<div className="max-w-2xl mx-auto">
<div className="max-w-4xl mx-auto">
<button
onClick={handleSubmit}
disabled={!isComplete || isSubmitting}
@ -446,6 +446,66 @@ function PhotoButton({ photoUrl, onCapture }: { photoUrl?: string | null; onCapt
);
}
// Voice input for notes - uses Web Speech API
function VoiceInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<any>(null);
const startListening = () => {
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
alert('Voice input not supported in this browser');
return;
}
const SpeechRecognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition;
recognitionRef.current = new SpeechRecognition();
recognitionRef.current.continuous = false;
recognitionRef.current.interimResults = false;
recognitionRef.current.lang = 'en-US';
recognitionRef.current.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
onChange(value ? `${value} ${transcript}` : transcript);
setIsListening(false);
};
recognitionRef.current.onerror = () => setIsListening(false);
recognitionRef.current.onend = () => setIsListening(false);
recognitionRef.current.start();
setIsListening(true);
};
const stopListening = () => {
if (recognitionRef.current) {
recognitionRef.current.stop();
}
setIsListening(false);
};
return (
<div className="flex gap-2">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="input flex-1 py-3"
/>
<button
type="button"
onClick={isListening ? stopListening : startListening}
className={cn(
"w-12 h-12 rounded-lg flex items-center justify-center transition-colors",
isListening ? "bg-destructive text-white animate-pulse" : "bg-tertiary text-secondary active:bg-secondary"
)}
>
{isListening ? <MicOff size={20} /> : <Mic size={20} />}
</button>
</div>
);
}
// Reservoir card - large touch targets
function ReservoirCard({ tank, data, onChange }: {
tank: { name: string; type: 'VEG' | 'FLOWER' };
@ -525,14 +585,8 @@ function ReservoirCard({ tank, data, onChange }: {
</div>
</div>
{/* Notes */}
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes (optional)"
className="input w-full py-3"
/>
{/* Notes with voice */}
<VoiceInput value={notes} onChange={setNotes} placeholder="Notes (optional)" />
{/* Photo */}
<PhotoButton photoUrl={photo} onCapture={setPhoto} />
@ -728,13 +782,7 @@ function HealthCard({ zoneName, data, onChange }: {
{/* Notes + Photo for issues */}
{(health !== 'GOOD' || pests) && (
<>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Describe issue..."
className="input w-full py-3"
/>
<VoiceInput value={notes} onChange={setNotes} placeholder="Describe issue..." />
<PhotoButton photoUrl={photo} onCapture={setPhoto} />
</>
)}