leafittome/lib/features/household/presentation/household_screen.dart
cschlaefke 1b2c880c89 V2: Haushalt teilen – Einladungscodes, Mitglieder- und Sitter-Rolle, sichtbare Bestätigungen
- Einladen per einmaligem 6-stelligem Code (7 Tage gültig), wahlweise als Mitglied oder Pflanzen-Sitter
- Beitritt über Cloud Function joinHousehold (Transaktion, Admin-Rechte)
- Security Rules: Sitter dürfen an Pflanzen nur Bestätigungs-Felder ändern; invites nur erstellen, nie lesen
- Haushalts-Screen: Mitgliederliste mit Rollen, Einladen, Beitreten mit Wechsel-Warnung
- UI-Gating: Sitter sehen keine Anlegen-/Bearbeiten-/Löschen-Aktionen
- Bestätigungen speichern den Namen (lastWateredBy/lastFertilizedBy), Anzeige im Profil
- Tests: Members-Map im Seed, neuer Sitter-Test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:14:34 +02:00

221 lines
7.4 KiB
Dart

import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/firebase/firebase_providers.dart';
import '../../../core/widgets/app_drawer.dart';
import '../../../l10n/generated/app_localizations.dart';
import '../data/household_providers.dart';
import '../domain/household.dart';
/// Haushalt: Mitglieder mit Rollen, Einladen per Code, Beitreten per Code.
class HouseholdScreen extends ConsumerStatefulWidget {
const HouseholdScreen({super.key});
@override
ConsumerState<HouseholdScreen> createState() => _HouseholdScreenState();
}
class _HouseholdScreenState extends ConsumerState<HouseholdScreen> {
final _codeController = TextEditingController();
bool _joining = false;
@override
void dispose() {
_codeController.dispose();
super.dispose();
}
Future<void> _createInvite(HouseholdRole role) async {
final l10n = AppLocalizations.of(context);
final householdId = ref.read(householdIdProvider).value;
if (householdId == null) return;
final code = await ref
.read(householdRepositoryProvider)
.createInvite(householdId: householdId, role: role);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.inviteCodeTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(
code,
style: Theme.of(dialogContext)
.textTheme
.displaySmall
?.copyWith(letterSpacing: 6, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(l10n.inviteCodeHint),
],
),
actions: [
TextButton.icon(
icon: const Icon(Icons.copy),
label: Text(l10n.copyCode),
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await Clipboard.setData(ClipboardData(text: code));
messenger.showSnackBar(
SnackBar(content: Text(l10n.codeCopied)),
);
},
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('OK'),
),
],
),
);
}
Future<void> _join() async {
final l10n = AppLocalizations.of(context);
final code = _codeController.text.trim();
if (code.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.joinWarningTitle),
content: Text(l10n.joinWarningBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.joinButton),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _joining = true);
try {
final name =
await ref.read(householdRepositoryProvider).joinHousehold(code);
if (!mounted) return;
_codeController.clear();
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.joinSuccess(name))));
} on FirebaseFunctionsException catch (e) {
if (!mounted) return;
final message = switch (e.code) {
'not-found' => l10n.joinErrorInvalid,
'failed-precondition' => l10n.joinErrorUsed,
_ => l10n.authErrorGeneric,
};
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.authErrorGeneric)));
} finally {
if (mounted) setState(() => _joining = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final household = ref.watch(householdProvider).value;
final myRole = ref.watch(myRoleProvider);
final myUid = ref.watch(authStateProvider).value?.uid;
return Scaffold(
appBar: AppBar(title: Text(household?.name ?? l10n.householdTitle)),
drawer: const AppDrawer(),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.householdMembers, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (household != null)
for (final member in household.members)
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Icon(member.role == HouseholdRole.sitter
? Icons.volunteer_activism
: Icons.person),
),
title: Text(
member.uid == myUid && member.email.isEmpty
? l10n.meLabel
: member.email,
),
subtitle: Text(member.role == HouseholdRole.sitter
? l10n.roleSitter
: l10n.roleMember),
trailing: member.uid == myUid
? Chip(label: Text(l10n.meLabel))
: null,
),
if (myRole == HouseholdRole.member) ...[
const Divider(height: 32),
Text(l10n.inviteTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.inviteExplanation,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => _createInvite(HouseholdRole.member),
icon: const Icon(Icons.person_add),
label: Text(l10n.inviteMember),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () => _createInvite(HouseholdRole.sitter),
icon: const Icon(Icons.volunteer_activism),
label: Text(l10n.inviteSitter),
),
],
const Divider(height: 32),
Text(l10n.joinTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
TextField(
controller: _codeController,
decoration: InputDecoration(
labelText: l10n.joinCodeLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key),
),
textCapitalization: TextCapitalization.characters,
autocorrect: false,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _joining ? null : _join,
icon: _joining
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.group_add),
label: Text(l10n.joinButton),
),
],
),
);
}
}