From 8c1de0f8832fc8c77230c43171a780274b80d67b Mon Sep 17 00:00:00 2001 From: Flummi Date: Sun, 26 Oct 2025 17:53:53 +0100 Subject: [PATCH] blah --- android/app/src/main/AndroidManifest.xml | 1 + lib/main.dart | 844 +---------------------- lib/pages/list_detail.dart | 231 +++++++ lib/pages/lists.dart | 250 +++++++ lib/pages/login.dart | 88 +++ lib/services/api.dart | 44 ++ lib/widgets/auth_gate.dart | 43 ++ lib/widgets/members_dialog.dart | 264 +++++++ 8 files changed, 922 insertions(+), 843 deletions(-) create mode 100644 lib/pages/list_detail.dart create mode 100644 lib/pages/lists.dart create mode 100644 lib/pages/login.dart create mode 100644 lib/widgets/auth_gate.dart create mode 100644 lib/widgets/members_dialog.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3f4773a..72441bd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + createState() => _AuthGateState(); -} - -class _AuthGateState extends State { - StreamSubscription? _authSub; - bool _loggedIn = false; - - @override - void initState() { - super.initState(); - _loggedIn = pb.authStore.isValid; - _authSub = pb.authStore.onChange.listen((_) { - setState(() => _loggedIn = pb.authStore.isValid); - }); - } - - @override - void dispose() { - _authSub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_loggedIn) { - return const LoginPage(); - } else { - return const ListsPage(); - } - } -} - -class LoginPage extends StatefulWidget { - const LoginPage({super.key}); - - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - final TextEditingController _emailCtl = TextEditingController(); - final TextEditingController _pwCtl = TextEditingController(); - bool _isRegister = false; - bool _loading = false; - String? _error; - - Future _submit() async { - setState(() { - _loading = true; - _error = null; - }); - try { - final String email = _emailCtl.text.trim(); - final String password = _pwCtl.text.trim(); - if (_isRegister) { - await apiService.register(email, password); - } else { - await apiService.login(email, password); - } - } catch (e) { - setState(() => _error = e.toString()); - } finally { - if (mounted) { - setState(() => _loading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(_isRegister ? 'Registrierung' : 'Login')), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextField( - controller: _emailCtl, - decoration: const InputDecoration(labelText: 'Email'), - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 8), - TextField( - controller: _pwCtl, - decoration: const InputDecoration(labelText: 'Passwort'), - obscureText: true, - ), - const SizedBox(height: 20), - if (_error != null) ...[ - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 12), - ], - ElevatedButton( - onPressed: _loading ? null : _submit, - child: _loading - ? const CircularProgressIndicator() - : Text(_isRegister ? 'Registrieren' : 'Einloggen'), - ), - TextButton( - onPressed: _loading - ? null - : () => setState(() => _isRegister = !_isRegister), - child: Text( - _isRegister ? 'Ich habe schon ein Konto' : 'Neu registrieren', - ), - ), - ], - ), - ), - ); - } -} - -class ListsPage extends StatefulWidget { - const ListsPage({super.key}); - - @override - State createState() => _ListsPageState(); -} - -class _ListsPageState extends State { - late Future> _listsFuture; - - @override - void initState() { - super.initState(); - _loadLists(); - } - - void _loadLists() { - _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 { - final TextEditingController titleCtl = TextEditingController(); - final bool? ok = await showDialog( - context: context, - builder: (c) => AlertDialog( - title: const Text('Neue Liste'), - 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('Erstellen'), - ), - ], - ), - ); - if (ok != true || titleCtl.text.trim().isEmpty) return; - - try { - await apiService.createList(titleCtl.text.trim()); - _loadLists(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); - } - } - } - - void _openList(Liste list) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => ListDetailPage(list: list)), - ).then((_) => _loadLists()); - } - - @override - Widget build(BuildContext context) { - final ThemeProvider themeProvider = Provider.of( - context, - listen: false, - ); - - return Scaffold( - appBar: AppBar( - title: const Text('Deine Listen'), - actions: [ - PopupMenuButton( - onSelected: (mode) => themeProvider.setThemeMode(mode), - itemBuilder: (context) => [ - const PopupMenuItem(value: ThemeMode.light, child: Text('Hell')), - const PopupMenuItem(value: ThemeMode.dark, child: Text('Dunkel')), - const PopupMenuItem( - value: ThemeMode.system, - child: Text('System'), - ), - ], - icon: const Icon(Icons.brightness_6_outlined), - ), - IconButton( - tooltip: 'Ausloggen', - icon: const Icon(Icons.logout), - onPressed: () { - apiService.logout(); - }, - ), - ], - ), - body: FutureBuilder>( - future: _listsFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center(child: Text('Fehler: ${snapshot.error}')); - } - final lists = snapshot.data ?? []; - if (lists.isEmpty) { - return const Center(child: Text('Keine Listen gefunden.')); - } - return ListView.builder( - itemCount: lists.length, - itemBuilder: (c, i) { - final l = lists[i]; - return ListTile( - title: Text(l.title), - onTap: () => _openList(l), - onLongPress: () => _showListMenu(l), - ); - }, - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: _createList, - child: const Icon(Icons.add), - ), - ); - } -} - -class ListDetailPage extends StatefulWidget { - final Liste list; - const ListDetailPage({super.key, required this.list}); - - @override - State createState() => _ListDetailPageState(); -} - -class _ListDetailPageState extends State { - late Future> _itemsFuture; - List _items = []; - - @override - void initState() { - super.initState(); - _loadItems(); - } - - void _loadItems() { - _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 { - final TextEditingController ctl = TextEditingController(); - final bool? ok = await showDialog( - context: context, - builder: (c) => AlertDialog( - title: const Text('Eintrag hinzufügen'), - 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('Hinzufügen'), - ), - ], - ), - ); - if (ok != true || ctl.text.trim().isEmpty) return; - - try { - final int maxPos = _items.fold( - -1, - (max, item) => item.position > max ? item.position : max, - ); - await apiService.createItem(widget.list.id, ctl.text.trim(), maxPos + 1); - _loadItems(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); - } - } - } - - 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); - await apiService.updateItem(item.id, checked: item.checked); - } catch (e) { - setState(() => item.checked = !item.checked); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Fehler beim Aktualisieren: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - 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) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center(child: Text('Fehler: ${snapshot.error}')); - } - if (_items.isEmpty && - snapshot.connectionState != ConnectionState.waiting) { - return const Center(child: Text('Keine Einträge vorhanden.')); - } - return ListView.builder( - itemCount: _items.length, - itemBuilder: (c, i) { - 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, - ), - ), - ), - ); - }, - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: _addItem, - child: const Icon(Icons.add), - ), - ); - } -} - -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/pages/list_detail.dart b/lib/pages/list_detail.dart new file mode 100644 index 0000000..cc66496 --- /dev/null +++ b/lib/pages/list_detail.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:shoppinglist/main.dart'; +import 'package:shoppinglist/models/models.dart'; +import 'package:shoppinglist/widgets/members_dialog.dart'; + +class ListDetailPage extends StatefulWidget { + final Liste list; + const ListDetailPage({super.key, required this.list}); + + @override + State createState() => _ListDetailPageState(); +} + +class _ListDetailPageState extends State { + late Future> _itemsFuture; + StreamSubscription? _itemsSubscription; + + @override + void initState() { + super.initState(); + _itemsFuture = apiService.getItems(widget.list.id); + _itemsSubscription = apiService.watchItems(widget.list.id).listen((_) { + _loadItems(); + }); + } + + @override + void dispose() { + _itemsSubscription?.cancel(); + super.dispose(); + } + + void _loadItems() { + setState(() => _itemsFuture = apiService.getItems(widget.list.id)); + } + + 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 String newName = ctl.text.trim(); + await apiService.updateItem(item.id, name: newName); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + Future _addItem(List currentItems) async { + final TextEditingController ctl = TextEditingController(); + final bool? ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Eintrag hinzufügen'), + 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('Hinzufügen'), + ), + ], + ), + ); + if (ok != true || ctl.text.trim().isEmpty) return; + + try { + final int maxPos = currentItems.fold( + -1, + (max, item) => item.position > max ? item.position : max, + ); + await apiService.createItem(widget.list.id, ctl.text.trim(), maxPos + 1); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + Future _deleteItem(Item item) async { + try { + await apiService.deleteItem(item.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"${item.name}" gelöscht.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler beim Löschen: $e')), + ); + } + } + } + + Future _toggleItem(Item item) async { + setState(() => item.checked = !item.checked); + try { + await apiService.updateItem(item.id, checked: item.checked); + } catch (e) { + setState(() => item.checked = !item.checked); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler beim Aktualisieren: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + 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) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Fehler: ${snapshot.error}')); + } + + final List items = snapshot.data ?? []; + + if (items.isEmpty) { + return const Center( + child: Text('Füge deinen ersten Eintrag hinzu!'), + ); + } + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final Item item = items[index]; + return ListTile( + leading: Checkbox( + value: item.checked, + onChanged: (_) => _toggleItem(item), + ), + title: Text( + item.name, + style: TextStyle( + decoration: + item.checked ? TextDecoration.lineThrough : null, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _editItem(item), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _deleteItem(item), + ), + ], + ), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () async { + final currentItems = await _itemsFuture; + _addItem(currentItems); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/pages/lists.dart b/lib/pages/lists.dart new file mode 100644 index 0000000..5a53d84 --- /dev/null +++ b/lib/pages/lists.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:shoppinglist/main.dart'; +import 'package:shoppinglist/models/models.dart'; +import 'package:shoppinglist/pages/list_detail.dart'; +import 'package:shoppinglist/providers/theme.dart'; + +class ListsPage extends StatefulWidget { + const ListsPage({super.key}); + + @override + State createState() => _ListsPageState(); +} + +class _ListsPageState extends State { + late Future> _listsFuture; + StreamSubscription? _listsSubscription; + + @override + void initState() { + super.initState(); + _loadLists(); + _listsSubscription = apiService.watchLists().listen((_) { + _loadLists(); + }); + } + + @override + void dispose() { + _listsSubscription?.cancel(); + super.dispose(); + } + + void _loadLists() { + _listsFuture = apiService.getLists(); + setState(() {}); + } + + Future _showListMenu(Liste list) async { + final String? 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 { + final TextEditingController titleCtl = TextEditingController(); + final bool? ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Neue Liste'), + 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('Erstellen'), + ), + ], + ), + ); + if (ok != true || titleCtl.text.trim().isEmpty) return; + + try { + await apiService.createList(titleCtl.text.trim()); + _loadLists(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + void _openList(Liste list) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => ListDetailPage(list: list)), + ).then((_) => _loadLists()); + } + + @override + Widget build(BuildContext context) { + final ThemeProvider themeProvider = Provider.of( + context, + listen: false, + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Deine Listen'), + actions: [ + PopupMenuButton( + onSelected: (mode) => themeProvider.setThemeMode(mode), + itemBuilder: (context) => [ + const PopupMenuItem(value: ThemeMode.light, child: Text('Hell')), + const PopupMenuItem(value: ThemeMode.dark, child: Text('Dunkel')), + const PopupMenuItem( + value: ThemeMode.system, + child: Text('System'), + ), + ], + icon: const Icon(Icons.brightness_6_outlined), + ), + IconButton( + tooltip: 'Ausloggen', + icon: const Icon(Icons.logout), + onPressed: () { + apiService.logout(); + }, + ), + ], + ), + body: FutureBuilder>( + future: _listsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Fehler: ${snapshot.error}')); + } + final lists = snapshot.data ?? []; + if (lists.isEmpty) { + return const Center(child: Text('Keine Listen gefunden.')); + } + return ListView.builder( + itemCount: lists.length, + itemBuilder: (c, i) { + final l = lists[i]; + return ListTile( + title: Text(l.title), + onTap: () => _openList(l), + onLongPress: () => _showListMenu(l), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _createList, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/pages/login.dart b/lib/pages/login.dart new file mode 100644 index 0000000..e34777c --- /dev/null +++ b/lib/pages/login.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:shoppinglist/main.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final TextEditingController _emailCtl = TextEditingController(); + final TextEditingController _pwCtl = TextEditingController(); + bool _isRegister = false; + bool _loading = false; + String? _error; + + Future _submit() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final String email = _emailCtl.text.trim(); + final String password = _pwCtl.text.trim(); + if (_isRegister) { + await apiService.register(email, password); + } else { + await apiService.login(email, password); + } + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) { + setState(() => _loading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(_isRegister ? 'Registrierung' : 'Login')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _emailCtl, + decoration: const InputDecoration(labelText: 'Email'), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 8), + TextField( + controller: _pwCtl, + decoration: const InputDecoration(labelText: 'Passwort'), + obscureText: true, + ), + const SizedBox(height: 20), + if (_error != null) ...[ + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 12), + ], + ElevatedButton( + onPressed: _loading ? null : _submit, + child: _loading + ? const CircularProgressIndicator() + : Text(_isRegister ? 'Registrieren' : 'Einloggen'), + ), + TextButton( + onPressed: _loading + ? null + : () => setState(() => _isRegister = !_isRegister), + child: Text( + _isRegister ? 'Ich habe schon ein Konto' : 'Neu registrieren', + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/api.dart b/lib/services/api.dart index 495968a..ffbb111 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:pocketbase/pocketbase.dart'; import 'package:shoppinglist/models/models.dart'; @@ -31,6 +33,28 @@ class ApiService { pb.authStore.clear(); } + Stream watchLists() { + if (userId == null) return Stream.value(null); + + late final StreamController controller; + Future Function()? unsubscribe; + + controller = StreamController( + onListen: () async { + unsubscribe = await pb.collection('lists').subscribe('*', (e) { + if (!controller.isClosed) { + controller.add(null); + } + }, filter: 'owner = "$userId" || members ?~ "$userId"'); + }, + onCancel: () { + unsubscribe?.call(); + }, + ); + + return controller.stream; + } + Future> getLists() async { if (userId == null) return []; final List res = await pb @@ -67,6 +91,26 @@ class ApiService { await pb.collection('lists').delete(listId); } + Stream watchItems(String listId) { + late final StreamController controller; + Future Function()? unsubscribe; + + controller = StreamController( + onListen: () async { + unsubscribe = await pb.collection('items').subscribe('*', (e) { + if (!controller.isClosed) { + controller.add(null); + } + }, filter: 'list = "$listId"'); + }, + onCancel: () { + unsubscribe?.call(); + }, + ); + + return controller.stream; + } + Future> getItems(String listId) async { final List res = await pb .collection('items') diff --git a/lib/widgets/auth_gate.dart b/lib/widgets/auth_gate.dart new file mode 100644 index 0000000..600d8c5 --- /dev/null +++ b/lib/widgets/auth_gate.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:shoppinglist/main.dart'; +import 'package:shoppinglist/pages/lists.dart'; +import 'package:shoppinglist/pages/login.dart'; + +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + StreamSubscription? _authSub; + bool _loggedIn = false; + + @override + void initState() { + super.initState(); + _loggedIn = pb.authStore.isValid; + _authSub = pb.authStore.onChange.listen((_) { + setState(() => _loggedIn = pb.authStore.isValid); + }); + } + + @override + void dispose() { + _authSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_loggedIn) { + return const LoginPage(); + } else { + return const ListsPage(); + } + } +} diff --git a/lib/widgets/members_dialog.dart b/lib/widgets/members_dialog.dart new file mode 100644 index 0000000..7e23322 --- /dev/null +++ b/lib/widgets/members_dialog.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; + +import 'package:pocketbase/pocketbase.dart'; + +import 'package:shoppinglist/main.dart'; +import 'package:shoppinglist/models/models.dart'; + +class MembersDialog extends StatefulWidget { + final Liste list; + const MembersDialog({super.key, required this.list}); + + @override + State createState() => _MembersDialogState(); +} + +class _MembersDialogState extends State { + final TextEditingController _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'), + ), + ], + ); + } +}