This commit is contained in:
2025-10-26 16:41:01 +01:00
parent e41f781c3f
commit 348746c0d4
4 changed files with 561 additions and 24 deletions

View File

@@ -103,9 +103,7 @@ class _AuthGateState extends State<AuthGate> {
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<ListsPage> {
}
void _loadLists() {
setState(() => _listsFuture = apiService.getLists());
_listsFuture = apiService.getLists();
setState(() {});
}
Future<void> _showListMenu(Liste list) async {
final result = await showModalBottomSheet<String>(
context: context,
builder: (c) => Wrap(
children: <Widget>[
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<void> _editList(Liste list) async {
final TextEditingController titleCtl = TextEditingController(
text: list.title,
);
final bool? ok = await showDialog<bool>(
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<void> _deleteList(Liste list) async {
final bool? ok = await showDialog<bool>(
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<void> _createList() async {
@@ -323,7 +421,11 @@ class _ListsPageState extends State<ListsPage> {
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<ListDetailPage> {
late Future<List<Item>> _itemsFuture;
List<Item> _items = [];
@override
void initState() {
@@ -354,7 +457,63 @@ class _ListDetailPageState extends State<ListDetailPage> {
}
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<void> _showMembersDialog() async {
final bool? updated = await showDialog<bool>(
context: context,
builder: (_) => MembersDialog(list: widget.list),
);
if (updated == true && mounted) {
setState(() {});
}
}
Future<void> _editItem(Item item) async {
final TextEditingController ctl = TextEditingController(text: item.name);
final bool? ok = await showDialog<bool>(
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<void> _addItem() async {
@@ -383,8 +542,7 @@ class _ListDetailPageState extends State<ListDetailPage> {
if (ok != true || ctl.text.trim().isEmpty) return;
try {
final List<Item> items = await _itemsFuture;
final int maxPos = items.fold<int>(
final int maxPos = _items.fold<int>(
-1,
(max, item) => item.position > max ? item.position : max,
);
@@ -399,6 +557,39 @@ class _ListDetailPageState extends State<ListDetailPage> {
}
}
Future<void> _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<void> _toggleItem(Item item) async {
try {
setState(() => item.checked = !item.checked);
@@ -416,7 +607,16 @@ class _ListDetailPageState extends State<ListDetailPage> {
@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<List<Item>>(
future: _itemsFuture,
builder: (context, snapshot) {
@@ -426,15 +626,40 @@ class _ListDetailPageState extends State<ListDetailPage> {
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<ListDetailPage> {
);
}
}
class MembersDialog extends StatefulWidget {
final Liste list;
const MembersDialog({super.key, required this.list});
@override
State<MembersDialog> createState() => _MembersDialogState();
}
class _MembersDialogState extends State<MembersDialog> {
final _emailCtl = TextEditingController();
bool _isLoading = false;
String? _error;
bool _isUsersLoading = true;
final Map<String, String> _userEmails = {};
bool _showAddMemberField = false;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadUsers();
}
Future<void> _loadUsers() async {
setState(() => _isUsersLoading = true);
try {
final List<String> ids = <String>{
widget.list.owner,
...widget.list.members,
}.where((id) => id.isNotEmpty).toList();
if (ids.isEmpty) {
setState(() => _isUsersLoading = false);
return;
}
final List<RecordModel> 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<void> _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<String> 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<void> _removeMember(String memberId) async {
final String? memberEmail = _userEmails[memberId];
final bool? ok = await showDialog<bool>(
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<String> 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'),
),
],
);
}
}

View File

@@ -1,11 +1,23 @@
class Liste {
final String id;
String title;
final String owner;
List<String> 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<String, dynamic> 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<String>.from(json['members'] as List? ?? []),
);
}
}

View File

@@ -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;

View File

@@ -7,7 +7,7 @@ class ApiService {
ApiService(this.pb);
String? get userId => pb.authStore.model?.id;
String? get userId => pb.authStore.record?.id;
Future<void> 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<void> updateList(
String listId, {
String? title,
List<String>? members,
}) async {
if (title == null && members == null) return;
final body = <String, dynamic>{};
if (title != null) {
body['title'] = title;
}
if (members != null) {
body['members'] = members;
}
await pb.collection('lists').update(listId, body: body);
}
Future<void> deleteList(String listId) async {
await pb.collection('lists').delete(listId);
}
Future<List<Item>> getItems(String listId) async {
final List<RecordModel> res = await pb
.collection('items')
@@ -67,8 +88,33 @@ class ApiService {
return Item.fromJson(rec.toJson());
}
Future<void> updateItem(String itemId, {bool? checked}) async {
if (checked == null) return;
await pb.collection('items').update(itemId, body: {'checked': checked});
Future<void> deleteItem(String itemId) async {
await pb.collection('items').delete(itemId);
}
Future<void> updateItem(String itemId, {bool? checked, String? name}) async {
if (checked == null && name == null) return;
final Map<String, dynamic> body = <String, dynamic>{};
if (checked != null) {
body['checked'] = checked;
}
if (name != null) {
body['name'] = name;
}
await pb.collection('items').update(itemId, body: body);
}
Future<RecordModel?> findUserByEmail(String email) async {
try {
return await pb.collection('users').getFirstListItem('email = "$email"');
} catch (e) {
return null;
}
}
Future<List<RecordModel>> getUsersByIds(List<String> ids) async {
if (ids.isEmpty) return [];
final String filter = ids.map((id) => 'id = "$id"').join(' || ');
return await pb.collection('users').getFullList(filter: filter);
}
}