Compare commits
2 Commits
348746c0d4
...
f4ed89a550
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ed89a550 | |||
| 8c1de0f883 |
@@ -1,6 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<application
|
||||
android:label="shoppinglist"
|
||||
android:label="ListenMeister"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.shoppinglist
|
||||
package io.flumm.listenmeister
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
853
lib/main.dart
853
lib/main.dart
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -7,14 +6,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:listenmeister/widgets/auth_gate.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';
|
||||
import 'package:listenmeister/services/api.dart';
|
||||
import 'package:listenmeister/providers/theme.dart';
|
||||
|
||||
late final PocketBase pb;
|
||||
late final ApiService apiService;
|
||||
const String appName = "ListenMeister";
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -29,7 +29,7 @@ void main() async {
|
||||
size: Size(400, 720),
|
||||
minimumSize: Size(380, 600),
|
||||
center: true,
|
||||
title: 'Einkaufsliste',
|
||||
title: appName,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
@@ -63,7 +63,7 @@ class MyApp extends StatelessWidget {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
return MaterialApp(
|
||||
title: 'Einkaufsliste',
|
||||
title: appName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -86,844 +86,3 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthGate extends StatefulWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
@override
|
||||
State<AuthGate> createState() => _AuthGateState();
|
||||
}
|
||||
|
||||
class _AuthGateState extends State<AuthGate> {
|
||||
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<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final TextEditingController _emailCtl = TextEditingController();
|
||||
final TextEditingController _pwCtl = TextEditingController();
|
||||
bool _isRegister = false;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _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<ListsPage> createState() => _ListsPageState();
|
||||
}
|
||||
|
||||
class _ListsPageState extends State<ListsPage> {
|
||||
late Future<List<Liste>> _listsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLists();
|
||||
}
|
||||
|
||||
void _loadLists() {
|
||||
_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 {
|
||||
final TextEditingController titleCtl = TextEditingController();
|
||||
final bool? ok = await showDialog<bool>(
|
||||
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<ThemeProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Deine Listen'),
|
||||
actions: [
|
||||
PopupMenuButton<ThemeMode>(
|
||||
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<List<Liste>>(
|
||||
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<ListDetailPage> createState() => _ListDetailPageState();
|
||||
}
|
||||
|
||||
class _ListDetailPageState extends State<ListDetailPage> {
|
||||
late Future<List<Item>> _itemsFuture;
|
||||
List<Item> _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<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 {
|
||||
final TextEditingController ctl = TextEditingController();
|
||||
final bool? ok = await showDialog<bool>(
|
||||
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<int>(
|
||||
-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<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);
|
||||
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<List<Item>>(
|
||||
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<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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
231
lib/pages/list_detail.dart
Normal file
231
lib/pages/list_detail.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
import 'package:listenmeister/widgets/members_dialog.dart';
|
||||
|
||||
class ListDetailPage extends StatefulWidget {
|
||||
final Liste list;
|
||||
const ListDetailPage({super.key, required this.list});
|
||||
|
||||
@override
|
||||
State<ListDetailPage> createState() => _ListDetailPageState();
|
||||
}
|
||||
|
||||
class _ListDetailPageState extends State<ListDetailPage> {
|
||||
late Future<List<Item>> _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<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 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<void> _addItem(List<Item> currentItems) async {
|
||||
final TextEditingController ctl = TextEditingController();
|
||||
final bool? ok = await showDialog<bool>(
|
||||
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<int>(
|
||||
-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<void> _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<void> _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<List<Item>>(
|
||||
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<Item> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
250
lib/pages/lists.dart
Normal file
250
lib/pages/lists.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
import 'package:listenmeister/pages/list_detail.dart';
|
||||
import 'package:listenmeister/providers/theme.dart';
|
||||
|
||||
class ListsPage extends StatefulWidget {
|
||||
const ListsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ListsPage> createState() => _ListsPageState();
|
||||
}
|
||||
|
||||
class _ListsPageState extends State<ListsPage> {
|
||||
late Future<List<Liste>> _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<void> _showListMenu(Liste list) async {
|
||||
final String? 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 {
|
||||
final TextEditingController titleCtl = TextEditingController();
|
||||
final bool? ok = await showDialog<bool>(
|
||||
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<ThemeProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Deine Listen'),
|
||||
actions: [
|
||||
PopupMenuButton<ThemeMode>(
|
||||
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<List<Liste>>(
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/pages/login.dart
Normal file
88
lib/pages/login.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final TextEditingController _emailCtl = TextEditingController();
|
||||
final TextEditingController _pwCtl = TextEditingController();
|
||||
bool _isRegister = false;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import 'package:shoppinglist/models/models.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
|
||||
class ApiService {
|
||||
final PocketBase pb;
|
||||
@@ -31,6 +33,28 @@ class ApiService {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
|
||||
Stream<void> watchLists() {
|
||||
if (userId == null) return Stream.value(null);
|
||||
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
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<List<Liste>> getLists() async {
|
||||
if (userId == null) return [];
|
||||
final List<RecordModel> res = await pb
|
||||
@@ -67,6 +91,26 @@ class ApiService {
|
||||
await pb.collection('lists').delete(listId);
|
||||
}
|
||||
|
||||
Stream<void> watchItems(String listId) {
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
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<List<Item>> getItems(String listId) async {
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('items')
|
||||
|
||||
43
lib/widgets/auth_gate.dart
Normal file
43
lib/widgets/auth_gate.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/pages/lists.dart';
|
||||
import 'package:listenmeister/pages/login.dart';
|
||||
|
||||
class AuthGate extends StatefulWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
@override
|
||||
State<AuthGate> createState() => _AuthGateState();
|
||||
}
|
||||
|
||||
class _AuthGateState extends State<AuthGate> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
264
lib/widgets/members_dialog.dart
Normal file
264
lib/widgets/members_dialog.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
|
||||
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 TextEditingController _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: shoppinglist
|
||||
name: listenmeister
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
|
||||
Reference in New Issue
Block a user