leafittome/lib/features/locations/presentation/location_detail_screen.dart
cschlaefke f0b420b656 V3: Stellplatz-Bewertung (Standort-Analyse per Foto + Eignungs-Sterne auf Abruf)
Letzter Roadmap-Punkt von V3. Foto-basierte Lichtverhältnis-Analyse pro
Stellplatz (Cloud Function analyzeLocation) und darauf aufbauende, rein
textbasierte Eignungs-Bewertung einer Pflanze (assessPlantFit) — beides
ausschließlich auf Abruf, nicht automatisch (Kostenkontrolle, eigener
Anthropic-Key). Neue Stellplatz-Detailseite, Verlinkung im Pflanzen-Detail,
Erkennung veralteter Bewertungen bei Umzug/Neuanalyse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:17:18 +02:00

325 lines
11 KiB
Dart

import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../l10n/generated/app_localizations.dart';
import '../../household/data/household_providers.dart';
import '../../household/domain/household.dart';
import '../../plants/data/plant_fit_service.dart';
import '../../plants/data/plants_provider.dart';
import '../../plants/domain/plant.dart';
import '../data/location_analysis_service.dart';
import '../data/locations_provider.dart';
import '../domain/plant_location.dart';
class LocationDetailScreen extends ConsumerWidget {
const LocationDetailScreen({super.key, required this.locationId});
final String locationId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final location = ref.watch(locationByIdProvider(locationId));
if (location == null) {
// Stellplatz wurde gelöscht, während der Screen offen war.
return Scaffold(appBar: AppBar(), body: const SizedBox.shrink());
}
final plants = (ref.watch(plantsProvider).value ?? const [])
.where((plant) => plant.locationId == locationId)
.toList();
final canEdit = ref.watch(myRoleProvider) == HouseholdRole.member;
return Scaffold(
appBar: AppBar(title: Text(location.name)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_LocationPhotoSection(location: location, canEdit: canEdit),
const Divider(height: 32),
Text(
l10n.locationAssignedPlants,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (plants.isEmpty)
Text(
l10n.locationNoPlantsAssigned,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else
for (final plant in plants) ...[
_PlantFitTile(plant: plant, location: location),
const Divider(height: 24),
],
],
),
);
}
}
/// Foto vom Stellplatz aufnehmen und von Claude die Lichtverhältnisse
/// einschätzen lassen (V3). Nur volle Mitglieder dürfen das auslösen.
class _LocationPhotoSection extends ConsumerStatefulWidget {
const _LocationPhotoSection({required this.location, required this.canEdit});
final PlantLocation location;
final bool canEdit;
@override
ConsumerState<_LocationPhotoSection> createState() =>
_LocationPhotoSectionState();
}
class _LocationPhotoSectionState extends ConsumerState<_LocationPhotoSection> {
final _picker = ImagePicker();
bool _busy = false;
Future<void> _pickAndAnalyze(ImageSource source) async {
final l10n = AppLocalizations.of(context);
final picked = await _picker.pickImage(
source: source,
maxWidth: 1280,
imageQuality: 80,
);
if (picked == null || !mounted) return;
final bytes = await picked.readAsBytes();
setState(() => _busy = true);
try {
final service = ref.read(locationAnalysisServiceProvider);
final photoUrl = await service.uploadPhoto(bytes);
final result = await service.analyze(bytes);
await ref.read(locationRepositoryProvider).updateAnalysis(
widget.location.id,
photoUrl: photoUrl,
lightCategory: result.lightCategory,
lightAssessment: result.description,
);
} on FirebaseFunctionsException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(e.message ?? l10n.locationAnalysisFailed)));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.locationAnalysisFailed)));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final location = widget.location;
Widget preview;
if (location.photoUrl != null) {
preview = Image.network(location.photoUrl!, fit: BoxFit.cover);
} else {
preview = Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Icon(
Icons.place,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 180,
child: Stack(
fit: StackFit.expand,
children: [
preview,
if (_busy)
Container(
color: Colors.black45,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 12),
Text(
l10n.locationAnalyzing,
style: const TextStyle(color: Colors.white),
),
],
),
),
],
),
),
),
const SizedBox(height: 12),
if (widget.canEdit)
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed:
_busy ? null : () => _pickAndAnalyze(ImageSource.camera),
icon: const Icon(Icons.photo_camera),
label: Text(l10n.takePhoto),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _busy
? null
: () => _pickAndAnalyze(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: Text(l10n.fromGallery),
),
),
],
),
const SizedBox(height: 12),
if (location.lightCategory != null) ...[
Chip(label: Text(location.lightCategory!)),
if (location.lightAssessment != null) ...[
const SizedBox(height: 8),
Text(
location.lightAssessment!,
style: theme.textTheme.bodyMedium,
),
],
] else
Text(
l10n.locationAnalysisEmpty,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
);
}
}
/// Eine zugeordnete Pflanze mit ihrer Eignungs-Bewertung (auf Abruf) oder
/// einem Button, um sie anzustoßen. Für alle Rollen nutzbar (informativ),
/// setzt aber eine bereits analysierte Standort-Analyse voraus.
class _PlantFitTile extends ConsumerStatefulWidget {
const _PlantFitTile({required this.plant, required this.location});
final Plant plant;
final PlantLocation location;
@override
ConsumerState<_PlantFitTile> createState() => _PlantFitTileState();
}
class _PlantFitTileState extends ConsumerState<_PlantFitTile> {
bool _busy = false;
Future<void> _check() async {
final l10n = AppLocalizations.of(context);
setState(() => _busy = true);
try {
final result = await ref.read(plantFitServiceProvider).assessFit(
plant: widget.plant,
location: widget.location,
);
await ref.read(plantRepositoryProvider).saveFitAssessment(
widget.plant.id,
stars: result.stars,
reasoning: result.reasoning,
locationId: widget.location.id,
);
} on FirebaseFunctionsException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(e.message ?? l10n.fitCheckFailed)));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.fitCheckFailed)));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final plant = widget.plant;
final canAnalyze = widget.location.lightCategory != null;
final stale = isFitStale(plant, widget.location);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(plant.nickname, style: theme.textTheme.titleMedium),
if (plant.fitStars != null && !stale) ...[
const SizedBox(height: 2),
Text(
'' * plant.fitStars! + '' * (5 - plant.fitStars!),
style: theme.textTheme.bodyLarge,
),
if (plant.fitReasoning != null)
Text(
plant.fitReasoning!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
] else if (plant.fitStars != null && stale) ...[
const SizedBox(height: 2),
Text(
l10n.fitCheckStale,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _busy || !canAnalyze ? null : _check,
icon: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.star_outline, size: 18),
label: Text(
_busy
? l10n.fitChecking
: !canAnalyze
? l10n.fitCheckNeedsAnalysis
: l10n.fitCheckButton,
),
),
],
),
);
}
}