import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pocketbase/pocketbase.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; import 'package:shoppinglist/services/api.dart'; import 'package:shoppinglist/models/models.dart'; import 'package:shoppinglist/providers/theme.dart'; late final PocketBase pb; late final ApiService apiService; void main() async { WidgetsFlutterBinding.ensureInitialized(); if (Platform.isAndroid || Platform.isIOS) { await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); } if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { await windowManager.ensureInitialized(); const WindowOptions windowOptions = WindowOptions( size: Size(400, 720), minimumSize: Size(380, 600), center: true, title: 'Einkaufsliste', ); windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.show(); await windowManager.focus(); }); } final SharedPreferences prefs = await SharedPreferences.getInstance(); final AsyncAuthStore store = AsyncAuthStore( save: (String data) async => prefs.setString('pb_auth', data), initial: prefs.getString('pb_auth'), ); pb = PocketBase('https://pb.flumm.io', authStore: store); apiService = ApiService(pb); runApp( ChangeNotifierProvider( create: (_) => ThemeProvider(prefs), child: const MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return Consumer( builder: (context, themeProvider, child) { return MaterialApp( title: 'Einkaufsliste', debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.deepPurple, brightness: Brightness.light, ), useMaterial3: true, ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.deepPurple, brightness: Brightness.dark, ), useMaterial3: true, ), themeMode: themeProvider.themeMode, home: const AuthGate(), ); }, ); } } 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(); } } } 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'), ), ], ); } }