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>
325 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|