leafittome/lib/features/household/presentation/household_screen.dart
cschlaefke 778b3bd34d V2.2: Besitzer-Konzept, Haushalte umbenennen, Austreten/Entfernen, Checkliste über alle Haushalte
- ownerUid je Haushalt (Alt-Haushalte: erster memberUids-Eintrag = Ersteller);
  Besitzer-Anzeige in Mitglieder- und Haushaltsliste
- Haushalt umbenennen (Dialog im Haushalts-Screen); Rules erlauben Clients am
  Haushalts-Dokument nur noch das Namensfeld
- Cloud Functions leaveHousehold (selbst austreten, auch als Sitter) und
  removeMember (nur Besitzer); beide biegen den aktiven Zeiger des Betroffenen
  um und legen notfalls einen frischen eigenen Haushalt an
- Haushalts-Wechsler im Drawer (PopupMenu, ab zwei Haushalten)
- Tages-Checkliste und nächste Fälligkeit aggregieren über alle Haushalte,
  mit Zwischenüberschrift je Haushalt; Bestätigen schreibt in den
  Herkunfts-Haushalt der Aufgabe
- Widget-Tests: Gruppierung, Besitzer-Rechte, Umbenennen

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

438 lines
15 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> _switchTo(Household target) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await ref.read(householdRepositoryProvider).switchHousehold(target.id);
if (!mounted) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(l10n.switchHouseholdSuccess(target.name))),
);
}
Future<void> _rename(Household household) async {
final newName = await showDialog<String>(
context: context,
builder: (_) => _RenameDialog(initialName: household.name),
);
if (newName == null || newName.isEmpty || newName == household.name) {
return;
}
await ref
.read(householdRepositoryProvider)
.renameHousehold(household.id, newName);
}
Future<void> _leave(Household household) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.leaveHouseholdConfirmTitle),
content: Text(l10n.leaveHouseholdConfirmBody(household.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.leaveHousehold),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(householdRepositoryProvider)
.leaveHousehold(household.id);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(l10n.leftHousehold(household.name))));
} on FirebaseFunctionsException {
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.authErrorGeneric)));
}
}
Future<void> _removeMember(
Household household, HouseholdMember member) async {
final l10n = AppLocalizations.of(context);
final label = member.email.isEmpty ? l10n.roleMember : member.email;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.removeMemberConfirmTitle),
content: Text(l10n.removeMemberConfirmBody(label)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.removeMemberAction),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(householdRepositoryProvider).removeMember(
householdId: household.id,
memberUid: member.uid,
);
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.memberRemoved(label))));
} on FirebaseFunctionsException {
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.authErrorGeneric)));
}
}
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 myHouseholds = ref.watch(myHouseholdsProvider).value ?? const [];
final myRole = ref.watch(myRoleProvider);
final myUid = ref.watch(authStateProvider).value?.uid;
final iAmOwner =
household != null && myUid != null && household.isOwner(myUid);
return Scaffold(
appBar: AppBar(
title: Text(household?.name ?? l10n.householdTitle),
actions: [
if (household != null && myRole == HouseholdRole.member)
IconButton(
icon: const Icon(Icons.edit),
tooltip: l10n.renameHousehold,
onPressed: () => _rename(household),
),
],
),
drawer: const AppDrawer(),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (myHouseholds.length > 1) ...[
Text(l10n.myHouseholds, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final entry in myHouseholds)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
entry.id == household?.id
? Icons.home
: Icons.home_outlined,
color: entry.id == household?.id
? theme.colorScheme.primary
: null,
),
title: Text(entry.name),
subtitle: Text(
myUid != null && entry.isOwner(myUid)
? l10n.ownerIsMe
: l10n.ownerIs(
entry.memberByUid(entry.ownerUid)?.email ?? '?'),
),
trailing: entry.id == household?.id
? Chip(label: Text(l10n.activeHouseholdLabel))
: null,
onTap: entry.id == household?.id
? null
: () => _switchTo(entry),
),
const Divider(height: 32),
],
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,
if (household.isOwner(member.uid)) l10n.ownerLabel,
].join(' · ')),
trailing: member.uid == myUid
? Chip(label: Text(l10n.meLabel))
: iAmOwner
? IconButton(
icon: Icon(Icons.person_remove,
color: theme.colorScheme.error),
tooltip: l10n.removeMemberAction,
onPressed: () =>
_removeMember(household, member),
)
: null,
),
if (household != null && myUid != null && !iAmOwner) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _leave(household),
icon: Icon(Icons.logout, color: theme.colorScheme.error),
label: Text(
l10n.leaveHousehold,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
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),
),
],
),
);
}
}
/// Eigener Dialog fürs Umbenennen: Der TextEditingController gehört dem
/// Dialog-State und lebt damit bis zum Ende der Zuklapp-Animation
/// (sofortiges dispose nach showDialog crasht während der Animation).
class _RenameDialog extends StatefulWidget {
const _RenameDialog({required this.initialName});
final String initialName;
@override
State<_RenameDialog> createState() => _RenameDialogState();
}
class _RenameDialogState extends State<_RenameDialog> {
late final TextEditingController _controller =
TextEditingController(text: widget.initialName);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.renameHousehold),
content: TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.householdNameLabel,
border: const OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, _controller.text.trim()),
child: Text(l10n.saveButton),
),
],
);
}
}