From 348746c0d4b5f587deb587253326bdaddd38b7ee Mon Sep 17 00:00:00 2001 From: Flummi Date: Sun, 26 Oct 2025 16:41:01 +0100 Subject: [PATCH] blah --- lib/main.dart | 512 +++++++++++++++++++++++++++++++++++++-- lib/models/models.dart | 16 +- lib/providers/theme.dart | 3 +- lib/services/api.dart | 54 ++++- 4 files changed, 561 insertions(+), 24 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 1c556ec..a2964cb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -103,9 +103,7 @@ class _AuthGateState extends State { super.initState(); _loggedIn = pb.authStore.isValid; _authSub = pb.authStore.onChange.listen((_) { - setState(() { - _loggedIn = pb.authStore.isValid; - }); + setState(() => _loggedIn = pb.authStore.isValid); }); } @@ -227,7 +225,107 @@ class _ListsPageState extends State { } void _loadLists() { - setState(() => _listsFuture = apiService.getLists()); + _listsFuture = apiService.getLists(); + setState(() {}); + } + + Future _showListMenu(Liste list) async { + final result = await showModalBottomSheet( + context: context, + builder: (c) => Wrap( + children: [ + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Umbenennen'), + onTap: () => Navigator.pop(c, 'edit'), + ), + ListTile( + leading: const Icon(Icons.delete), + title: const Text('Löschen'), + onTap: () => Navigator.pop(c, 'delete'), + ), + ], + ), + ); + + if (result == 'edit') { + _editList(list); + } else if (result == 'delete') { + _deleteList(list); + } + } + + Future _editList(Liste list) async { + final TextEditingController titleCtl = TextEditingController( + text: list.title, + ); + final bool? ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Liste umbenennen'), + content: TextField( + controller: titleCtl, + decoration: const InputDecoration(labelText: 'Titel'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => Navigator.pop(c, true), + child: const Text('Speichern'), + ), + ], + ), + ); + if (ok != true || titleCtl.text.trim().isEmpty) return; + + try { + await apiService.updateList(list.id, title: titleCtl.text.trim()); + _loadLists(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + Future _deleteList(Liste list) async { + final bool? ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Liste löschen?'), + content: Text( + 'Möchtest du die Liste "${list.title}" wirklich löschen?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => Navigator.pop(c, true), + child: const Text('Löschen'), + ), + ], + ), + ); + if (ok != true) return; + + try { + await apiService.deleteList(list.id); + _loadLists(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } } Future _createList() async { @@ -323,7 +421,11 @@ class _ListsPageState extends State { itemCount: lists.length, itemBuilder: (c, i) { final l = lists[i]; - return ListTile(title: Text(l.title), onTap: () => _openList(l)); + return ListTile( + title: Text(l.title), + onTap: () => _openList(l), + onLongPress: () => _showListMenu(l), + ); }, ); }, @@ -346,6 +448,7 @@ class ListDetailPage extends StatefulWidget { class _ListDetailPageState extends State { late Future> _itemsFuture; + List _items = []; @override void initState() { @@ -354,7 +457,63 @@ class _ListDetailPageState extends State { } void _loadItems() { - setState(() => _itemsFuture = apiService.getItems(widget.list.id)); + _itemsFuture = apiService.getItems(widget.list.id); + setState(() {}); + _itemsFuture.then((items) { + if (mounted) { + setState(() => _items = items); + } + }); + } + + Future _showMembersDialog() async { + final bool? updated = await showDialog( + context: context, + builder: (_) => MembersDialog(list: widget.list), + ); + if (updated == true && mounted) { + setState(() {}); + } + } + + Future _editItem(Item item) async { + final TextEditingController ctl = TextEditingController(text: item.name); + final bool? ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Eintrag bearbeiten'), + content: TextField( + controller: ctl, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => Navigator.pop(c, true), + child: const Text('Speichern'), + ), + ], + ), + ); + if (ok != true || ctl.text.trim().isEmpty) return; + + try { + final newName = ctl.text.trim(); + await apiService.updateItem(item.id, name: newName); + setState(() { + item.name = newName; + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } } Future _addItem() async { @@ -383,8 +542,7 @@ class _ListDetailPageState extends State { if (ok != true || ctl.text.trim().isEmpty) return; try { - final List items = await _itemsFuture; - final int maxPos = items.fold( + final int maxPos = _items.fold( -1, (max, item) => item.position > max ? item.position : max, ); @@ -399,6 +557,39 @@ class _ListDetailPageState extends State { } } + Future _deleteItem(Item item) async { + final int originalIndex = _items.indexOf(item); + setState(() => _items.remove(item)); + + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text('"${item.name}" gelöscht.'), + action: SnackBarAction( + label: 'Rückgängig', + onPressed: () { + setState(() { + _items.insert(originalIndex, item); + }); + }, + ), + ), + ) + .closed + .then((reason) { + if (reason != SnackBarClosedReason.action) { + apiService.deleteItem(item.id).catchError((e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler beim Löschen: $e')), + ); + _loadItems(); + } + }); + } + }); + } + Future _toggleItem(Item item) async { try { setState(() => item.checked = !item.checked); @@ -416,7 +607,16 @@ class _ListDetailPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(widget.list.title)), + appBar: AppBar( + title: Text(widget.list.title), + actions: [ + IconButton( + icon: const Icon(Icons.people_alt_outlined), + tooltip: 'Mitglieder verwalten', + onPressed: _showMembersDialog, + ), + ], + ), body: FutureBuilder>( future: _itemsFuture, builder: (context, snapshot) { @@ -426,15 +626,40 @@ class _ListDetailPageState extends State { if (snapshot.hasError) { return Center(child: Text('Fehler: ${snapshot.error}')); } - final items = snapshot.data ?? []; + if (_items.isEmpty && + snapshot.connectionState != ConnectionState.waiting) { + return const Center(child: Text('Keine Einträge vorhanden.')); + } return ListView.builder( - itemCount: items.length, + itemCount: _items.length, itemBuilder: (c, i) { - final it = items[i]; - return CheckboxListTile( - title: Text(it.name), - value: it.checked, - onChanged: (_) => _toggleItem(it), + final it = _items[i]; + return Dismissible( + key: ValueKey(it.id), + direction: DismissDirection.endToStart, + onDismissed: (_) => _deleteItem(it), + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + child: CheckboxListTile( + value: it.checked, + onChanged: (_) => _toggleItem(it), + title: GestureDetector( + onTap: () => _editItem(it), + child: Text( + it.name, + style: it.checked + ? TextStyle( + decoration: TextDecoration.lineThrough, + color: Theme.of(context).disabledColor, + ) + : null, + ), + ), + ), ); }, ); @@ -447,3 +672,258 @@ class _ListDetailPageState extends State { ); } } + +class MembersDialog extends StatefulWidget { + final Liste list; + const MembersDialog({super.key, required this.list}); + + @override + State createState() => _MembersDialogState(); +} + +class _MembersDialogState extends State { + final _emailCtl = TextEditingController(); + bool _isLoading = false; + String? _error; + bool _isUsersLoading = true; + final Map _userEmails = {}; + bool _showAddMemberField = false; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadUsers(); + } + + Future _loadUsers() async { + setState(() => _isUsersLoading = true); + try { + final List ids = { + widget.list.owner, + ...widget.list.members, + }.where((id) => id.isNotEmpty).toList(); + if (ids.isEmpty) { + setState(() => _isUsersLoading = false); + return; + } + final List users = await apiService.getUsersByIds(ids); + if (mounted) { + setState(() { + for (final RecordModel user in users) { + _userEmails[user.id] = user.data['email'] as String; + } + _isUsersLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'Fehler beim Laden der Benutzer.'; + _isUsersLoading = false; + }); + } + } + } + + Future _addMember() async { + final String email = _emailCtl.text.trim(); + if (email.isEmpty) return; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final RecordModel? user = await apiService.findUserByEmail(email); + if (user == null) { + throw Exception('Benutzer nicht gefunden.'); + } + if (widget.list.owner == user.id || + widget.list.members.contains(user.id)) { + throw Exception('Benutzer ist bereits Mitglied.'); + } + + final List newMembers = [...widget.list.members, user.id]; + await apiService.updateList(widget.list.id, members: newMembers); + + setState(() { + widget.list.members = newMembers; + _userEmails[user.id] = user.data['email'] as String; + _emailCtl.clear(); + _hasChanges = true; + }); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + setState(() => _isLoading = false); + } + } + + Future _removeMember(String memberId) async { + final String? memberEmail = _userEmails[memberId]; + final bool? ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Mitglied entfernen?'), + content: Text( + 'Möchtest du "${memberEmail ?? memberId}" wirklich aus der Liste entfernen?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => Navigator.pop(c, true), + child: const Text('Entfernen'), + ), + ], + ), + ); + + if (ok != true) return; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final List newMembers = widget.list.members + .where((id) => id != memberId) + .toList(); + await apiService.updateList(widget.list.id, members: newMembers); + + setState(() { + widget.list.members = newMembers; + _hasChanges = true; + }); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isOwner = widget.list.owner == apiService.userId; + + return AlertDialog( + title: const Text('Mitglieder'), + content: SizedBox( + width: double.maxFinite, + child: _isUsersLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.list.members.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 4.0), + child: Text( + 'Niemand', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ) + else + ...widget.list.members.map( + (id) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + "${_userEmails[id] ?? 'Unbekannt'}${_userEmails[widget.list.owner] == (_userEmails[id] ?? id) ? " (Besitzer)" : ""}", + ), + ), + if (isOwner) + IconButton( + icon: const Icon(Icons.remove_circle_outline), + tooltip: 'Entfernen', + onPressed: _isLoading || _userEmails[widget.list.owner] == (_userEmails[id] ?? id) + ? null + : () => _removeMember(id), + ), + ], + ), + ), + if (isOwner) ...[ + const SizedBox(height: 12), + Row( + children: [ + ...[ + _showAddMemberField + ? Expanded( + child: TextField( + controller: _emailCtl, + decoration: const InputDecoration( + labelText: 'Email des Mitglieds', + isDense: true, + ), + keyboardType: TextInputType.emailAddress, + onSubmitted: (_) => _addMember(), + autofocus: true, + ), + ) + : Expanded(child: SizedBox.shrink()), + ], + IconButton( + icon: Icon( + _showAddMemberField + ? Icons.close + : Icons.add_circle_outline, + ), + tooltip: _showAddMemberField + ? 'Schließen' + : 'Mitglied hinzufügen', + onPressed: () { + setState(() { + _showAddMemberField = !_showAddMemberField; + _error = null; + if (!_showAddMemberField) { + _emailCtl.clear(); + } + }); + }, + ), + ], + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ], + ), + ), + ), + actions: [ + if (isOwner && _showAddMemberField) + TextButton( + onPressed: _isLoading ? null : _addMember, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Hinzufügen'), + ), + TextButton( + onPressed: () => Navigator.pop(context, _hasChanges), + child: const Text('Schließen'), + ), + ], + ); + } +} diff --git a/lib/models/models.dart b/lib/models/models.dart index a6216ae..61cbd55 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,11 +1,23 @@ class Liste { final String id; String title; + final String owner; + List members; - Liste({required this.id, required this.title}); + Liste({ + required this.id, + required this.title, + required this.owner, + required this.members, + }); factory Liste.fromJson(Map json) { - return Liste(id: json['id'] as String, title: json['title'] as String); + return Liste( + id: json['id'] as String, + title: json['title'] as String, + owner: json['owner'] as String? ?? '', + members: List.from(json['members'] as List? ?? []), + ); } } diff --git a/lib/providers/theme.dart b/lib/providers/theme.dart index 8010aae..d487951 100644 --- a/lib/providers/theme.dart +++ b/lib/providers/theme.dart @@ -13,7 +13,7 @@ class ThemeProvider extends ChangeNotifier { } void _loadTheme() { - final themeString = _prefs.getString(_themeKey); + final String? themeString = _prefs.getString(_themeKey); switch (themeString) { case 'light': _themeMode = ThemeMode.light; @@ -40,7 +40,6 @@ class ThemeProvider extends ChangeNotifier { case ThemeMode.dark: themeString = 'dark'; break; - case ThemeMode.system: default: themeString = 'system'; break; diff --git a/lib/services/api.dart b/lib/services/api.dart index 6725c10..495968a 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -7,7 +7,7 @@ class ApiService { ApiService(this.pb); - String? get userId => pb.authStore.model?.id; + String? get userId => pb.authStore.record?.id; Future login(String email, String password) async { await pb.collection('users').authWithPassword(email, password); @@ -21,6 +21,7 @@ class ApiService { 'email': email, 'password': password, 'passwordConfirm': password, + 'emailVisibility': true, }, ); await login(email, password); @@ -46,6 +47,26 @@ class ApiService { return Liste.fromJson(rec.toJson()); } + Future updateList( + String listId, { + String? title, + List? members, + }) async { + if (title == null && members == null) return; + final body = {}; + if (title != null) { + body['title'] = title; + } + if (members != null) { + body['members'] = members; + } + await pb.collection('lists').update(listId, body: body); + } + + Future deleteList(String listId) async { + await pb.collection('lists').delete(listId); + } + Future> getItems(String listId) async { final List res = await pb .collection('items') @@ -67,8 +88,33 @@ class ApiService { return Item.fromJson(rec.toJson()); } - Future updateItem(String itemId, {bool? checked}) async { - if (checked == null) return; - await pb.collection('items').update(itemId, body: {'checked': checked}); + Future deleteItem(String itemId) async { + await pb.collection('items').delete(itemId); + } + + Future updateItem(String itemId, {bool? checked, String? name}) async { + if (checked == null && name == null) return; + final Map body = {}; + if (checked != null) { + body['checked'] = checked; + } + if (name != null) { + body['name'] = name; + } + await pb.collection('items').update(itemId, body: body); + } + + Future findUserByEmail(String email) async { + try { + return await pb.collection('users').getFirstListItem('email = "$email"'); + } catch (e) { + return null; + } + } + + Future> getUsersByIds(List ids) async { + if (ids.isEmpty) return []; + final String filter = ids.map((id) => 'id = "$id"').join(' || '); + return await pb.collection('users').getFullList(filter: filter); } }