Compare commits
10 Commits
348746c0d4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c6c19c4c9 | |||
| 3054027ebf | |||
| ed1ffc8be4 | |||
| 0ce1d34eab | |||
| 1d00272e5a | |||
| f41750206f | |||
| 3e0adee0ad | |||
| f8ee76784a | |||
| f4ed89a550 | |||
| 8c1de0f883 |
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.shoppinglist"
|
||||
namespace = "io.flumm.listenmeister"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -21,7 +21,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.shoppinglist"
|
||||
applicationId = "io.flumm.listenmeister"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/logo.xcf
Normal file
BIN
assets/logo.xcf
Normal file
Binary file not shown.
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
853
lib/main.dart
853
lib/main.dart
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -9,12 +8,13 @@ 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';
|
||||
import 'package:listenmeister/widgets/auth_gate.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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ class Liste {
|
||||
String title;
|
||||
final String owner;
|
||||
List<String> members;
|
||||
String? layoutId;
|
||||
|
||||
Liste({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.owner,
|
||||
required this.members,
|
||||
this.layoutId,
|
||||
});
|
||||
|
||||
factory Liste.fromJson(Map<String, dynamic> json) {
|
||||
@@ -17,6 +19,7 @@ class Liste {
|
||||
title: json['title'] as String,
|
||||
owner: json['owner'] as String? ?? '',
|
||||
members: List<String>.from(json['members'] as List? ?? []),
|
||||
layoutId: json['layout'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,12 +29,14 @@ class Item {
|
||||
String name;
|
||||
bool checked;
|
||||
int position;
|
||||
String? sectionId;
|
||||
|
||||
Item({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.checked = false,
|
||||
this.position = 0,
|
||||
this.sectionId,
|
||||
});
|
||||
|
||||
factory Item.fromJson(Map<String, dynamic> json) {
|
||||
@@ -42,6 +47,74 @@ class Item {
|
||||
position: (json['position'] is int)
|
||||
? json['position']
|
||||
: int.tryParse(json['position'].toString()) ?? 0,
|
||||
sectionId: json['section'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreLayout {
|
||||
final String id;
|
||||
String name;
|
||||
String owner;
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
String? address;
|
||||
bool isPublic;
|
||||
|
||||
StoreLayout({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.owner,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.address,
|
||||
this.isPublic = false,
|
||||
});
|
||||
|
||||
factory StoreLayout.fromJson(Map<String, dynamic> json) {
|
||||
double? parseCoord(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is double) return value;
|
||||
if (value is int) return value.toDouble();
|
||||
return double.tryParse(value.toString());
|
||||
}
|
||||
|
||||
return StoreLayout(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String? ?? '',
|
||||
owner: json['owner'] as String? ?? '',
|
||||
latitude: parseCoord(json['latitude']),
|
||||
longitude: parseCoord(json['longitude']),
|
||||
address: json['address'] as String?,
|
||||
isPublic: json['isPublic'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSection {
|
||||
final String id;
|
||||
final String layoutId;
|
||||
String name;
|
||||
int position;
|
||||
|
||||
StoreSection({
|
||||
required this.id,
|
||||
required this.layoutId,
|
||||
required this.name,
|
||||
this.position = 0,
|
||||
});
|
||||
|
||||
factory StoreSection.fromJson(Map<String, dynamic> json) {
|
||||
int parsePosition(dynamic value) {
|
||||
if (value is int) return value;
|
||||
return int.tryParse(value.toString()) ?? 0;
|
||||
}
|
||||
|
||||
return StoreSection(
|
||||
id: json['id'] as String,
|
||||
layoutId: json['layout'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
position: parsePosition(json['position']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
749
lib/pages/layout_detail.dart
Normal file
749
lib/pages/layout_detail.dart
Normal file
@@ -0,0 +1,749 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
import 'package:listenmeister/pages/list_detail.dart';
|
||||
|
||||
class LayoutDetailPage extends StatefulWidget {
|
||||
final StoreLayout layout;
|
||||
const LayoutDetailPage({super.key, required this.layout});
|
||||
|
||||
@override
|
||||
State<LayoutDetailPage> createState() => _LayoutDetailPageState();
|
||||
}
|
||||
|
||||
class _LayoutDetailPageState extends State<LayoutDetailPage> {
|
||||
late StoreLayout _layout;
|
||||
StreamSubscription? _layoutSubscription;
|
||||
StreamSubscription? _sectionsSubscription;
|
||||
StreamSubscription? _listsSubscription;
|
||||
List<StoreSection> _sections = [];
|
||||
bool _sectionsLoading = true;
|
||||
String? _sectionsError;
|
||||
List<Liste> _linkedLists = [];
|
||||
bool _listsLoading = true;
|
||||
String? _listsError;
|
||||
|
||||
bool get _isOwner => _layout.owner == apiService.userId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_layout = widget.layout;
|
||||
_loadSections();
|
||||
_layoutSubscription = apiService.watchStoreLayout(_layout.id).listen((_) {
|
||||
_refreshLayout();
|
||||
});
|
||||
_sectionsSubscription = apiService
|
||||
.watchStoreSections(_layout.id)
|
||||
.listen((_) => _loadSections());
|
||||
_listsSubscription = apiService
|
||||
.watchListsForLayout(_layout.id)
|
||||
.listen((_) => _loadLinkedLists());
|
||||
_loadLinkedLists();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_layoutSubscription?.cancel();
|
||||
_sectionsSubscription?.cancel();
|
||||
_listsSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshLayout() async {
|
||||
final updated = await apiService.getStoreLayoutById(_layout.id);
|
||||
if (!mounted) return;
|
||||
if (updated != null) {
|
||||
setState(() => _layout = updated);
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Dieses Layout ist nicht mehr verfügbar.')),
|
||||
);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSections() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sectionsLoading = true;
|
||||
_sectionsError = null;
|
||||
});
|
||||
try {
|
||||
final sections = await apiService.getStoreSections(_layout.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sections = sections;
|
||||
_sectionsLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sectionsError = e.toString();
|
||||
_sectionsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLinkedLists() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_listsLoading = true;
|
||||
_listsError = null;
|
||||
});
|
||||
try {
|
||||
final lists = await apiService.getListsForLayout(_layout.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_linkedLists = lists;
|
||||
_listsLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_listsError = e.toString();
|
||||
_listsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openList(Liste list) async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => ListDetailPage(list: list)));
|
||||
if (mounted) {
|
||||
_loadLinkedLists();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editLayout() async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final TextEditingController nameCtl = TextEditingController(
|
||||
text: _layout.name,
|
||||
);
|
||||
final TextEditingController addressCtl = TextEditingController(
|
||||
text: _layout.address ?? '',
|
||||
);
|
||||
final TextEditingController latCtl = TextEditingController(
|
||||
text: _layout.latitude != null
|
||||
? _layout.latitude!.toStringAsFixed(6)
|
||||
: '',
|
||||
);
|
||||
final TextEditingController lonCtl = TextEditingController(
|
||||
text: _layout.longitude != null
|
||||
? _layout.longitude!.toStringAsFixed(6)
|
||||
: '',
|
||||
);
|
||||
bool isPublic = _layout.isPublic;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Layout bearbeiten'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
TextField(
|
||||
controller: addressCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Adresse (optional)',
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: latCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Breitengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: lonCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Längengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: isPublic,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => isPublic = value);
|
||||
},
|
||||
title: const Text('Für andere sichtbar'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final String name = nameCtl.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
bool clearLatitude = false;
|
||||
bool clearLongitude = false;
|
||||
|
||||
final String latText = latCtl.text.trim();
|
||||
final String lonText = lonCtl.text.trim();
|
||||
final String addressText = addressCtl.text.trim();
|
||||
|
||||
if (latText.isNotEmpty) {
|
||||
latitude = double.tryParse(latText.replaceAll(',', '.'));
|
||||
if (latitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Breitengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearLatitude = true;
|
||||
}
|
||||
|
||||
if (lonText.isNotEmpty) {
|
||||
longitude = double.tryParse(lonText.replaceAll(',', '.'));
|
||||
if (longitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Längengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearLongitude = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiService.updateStoreLayout(
|
||||
_layout.id,
|
||||
name: name,
|
||||
address: addressText.isEmpty ? null : addressText,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isPublic: isPublic,
|
||||
clearLatitude: clearLatitude,
|
||||
clearLongitude: clearLongitude,
|
||||
clearAddress: addressText.isEmpty,
|
||||
);
|
||||
await _refreshLayout();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Speichern: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteLayout() async {
|
||||
if (!_isOwner) return;
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Layout löschen?'),
|
||||
content: Text('Möchtest du "${_layout.name}" wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
try {
|
||||
await apiService.deleteStoreLayout(_layout.id);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Löschen: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addSection() async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final TextEditingController nameCtl = TextEditingController();
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Bereich hinzufügen'),
|
||||
content: TextField(
|
||||
controller: nameCtl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Hinzufügen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true || nameCtl.text.trim().isEmpty) return;
|
||||
|
||||
final int maxPos = _sections.fold<int>(
|
||||
-1,
|
||||
(max, section) => section.position > max ? section.position : max,
|
||||
);
|
||||
|
||||
try {
|
||||
await apiService.createStoreSection(
|
||||
layoutId: _layout.id,
|
||||
name: nameCtl.text.trim(),
|
||||
position: maxPos + 1,
|
||||
);
|
||||
_loadSections();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editSection(StoreSection section) async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final TextEditingController nameCtl = TextEditingController(
|
||||
text: section.name,
|
||||
);
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Bereich bearbeiten'),
|
||||
content: TextField(
|
||||
controller: nameCtl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true || nameCtl.text.trim().isEmpty) return;
|
||||
|
||||
try {
|
||||
await apiService.updateStoreSection(
|
||||
section.id,
|
||||
name: nameCtl.text.trim(),
|
||||
);
|
||||
_loadSections();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSection(StoreSection section) async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Bereich löschen?'),
|
||||
content: Text('"${section.name}" entfernen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final int index = _sections.indexOf(section);
|
||||
setState(() => _sections.remove(section));
|
||||
|
||||
try {
|
||||
await apiService.deleteStoreSection(section.id);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _sections.insert(index, section));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Löschen: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||
if (!_isOwner) return;
|
||||
if (oldIndex < 0 || oldIndex >= _sections.length) return;
|
||||
int targetIndex = newIndex;
|
||||
if (targetIndex > oldIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
if (targetIndex < 0 || targetIndex >= _sections.length) {
|
||||
targetIndex = _sections.length - 1;
|
||||
}
|
||||
if (targetIndex == oldIndex) return;
|
||||
|
||||
setState(() {
|
||||
final section = _sections.removeAt(oldIndex);
|
||||
_sections.insert(targetIndex, section);
|
||||
});
|
||||
|
||||
final List<Future<void>> updates = [];
|
||||
for (int i = 0; i < _sections.length; i++) {
|
||||
final section = _sections[i];
|
||||
if (section.position != i) {
|
||||
section.position = i;
|
||||
updates.add(apiService.updateStoreSection(section.id, position: i));
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.isEmpty) return;
|
||||
|
||||
try {
|
||||
await Future.wait(updates);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Sortierung konnte nicht gespeichert werden: $e'),
|
||||
),
|
||||
);
|
||||
_loadSections();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInfoTile({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
String? value,
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
subtitle: Text(value),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildLayoutInfoTiles() {
|
||||
final tiles = <Widget>[];
|
||||
if (_layout.address != null && _layout.address!.isNotEmpty) {
|
||||
tiles.add(
|
||||
_buildInfoTile(
|
||||
icon: Icons.location_on_outlined,
|
||||
label: 'Adresse',
|
||||
value: _layout.address,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_layout.latitude != null && _layout.longitude != null) {
|
||||
tiles.add(
|
||||
_buildInfoTile(
|
||||
icon: Icons.map_outlined,
|
||||
label: 'Koordinaten',
|
||||
value:
|
||||
'${_layout.latitude!.toStringAsFixed(6)}, ${_layout.longitude!.toStringAsFixed(6)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_layout.isPublic) {
|
||||
tiles.add(
|
||||
const ListTile(
|
||||
leading: Icon(Icons.public),
|
||||
title: Text('Öffentlich sichtbar'),
|
||||
),
|
||||
);
|
||||
}
|
||||
tiles.addAll(_buildLinkedListsTiles());
|
||||
return tiles;
|
||||
}
|
||||
|
||||
List<Widget> _buildLinkedListsTiles() {
|
||||
final tiles = <Widget>[];
|
||||
|
||||
if (_listsLoading) {
|
||||
tiles.add(
|
||||
const ListTile(
|
||||
leading: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Verknüpfte Listen'),
|
||||
subtitle: Text('Wird geladen...'),
|
||||
),
|
||||
);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
if (_listsError != null) {
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.error_outline, color: Colors.redAccent),
|
||||
title: const Text('Verknüpfte Listen'),
|
||||
subtitle: Text('Fehler: $_listsError'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadLinkedLists,
|
||||
),
|
||||
),
|
||||
);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list_alt_outlined),
|
||||
title: const Text('Verknüpfte Listen'),
|
||||
subtitle: Text(
|
||||
_linkedLists.isEmpty
|
||||
? 'Keine Listen verknüpft.'
|
||||
: '${_linkedLists.length} Listen verknüpft.',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (final list in _linkedLists) {
|
||||
tiles.add(
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 72, right: 16),
|
||||
title: Text(list.title),
|
||||
leading: const Icon(Icons.chevron_right),
|
||||
onTap: () => _openList(list),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
Widget _buildOwnerSections(List<Widget> infoTiles) {
|
||||
if (_sections.isEmpty) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
...infoTiles,
|
||||
if (infoTiles.isNotEmpty) const Divider(height: 0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Noch keine Bereiche. Tippe auf + um welche anzulegen.',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ReorderableListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
buildDefaultDragHandles: false,
|
||||
header: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...infoTiles,
|
||||
if (infoTiles.isNotEmpty) const Divider(height: 0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Bereiche',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Text(
|
||||
'Zum Sortieren das Griff-Symbol gedrückt halten und ziehen.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
itemCount: _sections.length,
|
||||
onReorder: _onReorder,
|
||||
itemBuilder: (context, index) {
|
||||
final section = _sections[index];
|
||||
return ListTile(
|
||||
key: ValueKey(section.id),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_indicator),
|
||||
),
|
||||
title: Text(section.name),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editSection(section),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _deleteSection(section),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewerSections(List<Widget> infoTiles) {
|
||||
if (_sections.isEmpty) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
...infoTiles,
|
||||
if (infoTiles.isNotEmpty) const Divider(height: 0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Noch keine Bereiche hinterlegt.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
...infoTiles,
|
||||
if (infoTiles.isNotEmpty) const Divider(height: 0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Bereiche',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
..._sections.map(
|
||||
(section) => ListTile(
|
||||
leading: const Icon(Icons.storefront_outlined),
|
||||
title: Text(section.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final infoTiles = _buildLayoutInfoTiles();
|
||||
Widget body;
|
||||
|
||||
if (_sectionsLoading) {
|
||||
body = const Center(child: CircularProgressIndicator());
|
||||
} else if (_sectionsError != null) {
|
||||
body = Center(child: Text('Fehler: $_sectionsError'));
|
||||
} else if (_isOwner) {
|
||||
body = _buildOwnerSections(infoTiles);
|
||||
} else {
|
||||
body = _buildViewerSections(infoTiles);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_layout.name),
|
||||
actions: [
|
||||
if (_isOwner)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_editLayout();
|
||||
} else if (value == 'delete') {
|
||||
_deleteLayout();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('Layout bearbeiten')),
|
||||
PopupMenuItem(value: 'delete', child: Text('Layout löschen')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _isOwner
|
||||
? FloatingActionButton(
|
||||
onPressed: _addSection,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
224
lib/pages/layouts.dart
Normal file
224
lib/pages/layouts.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
import 'package:listenmeister/pages/layout_detail.dart';
|
||||
|
||||
class LayoutsPage extends StatefulWidget {
|
||||
const LayoutsPage({super.key});
|
||||
|
||||
@override
|
||||
State<LayoutsPage> createState() => _LayoutsPageState();
|
||||
}
|
||||
|
||||
class _LayoutsPageState extends State<LayoutsPage> {
|
||||
late Future<List<StoreLayout>> _layoutsFuture;
|
||||
StreamSubscription? _layoutsSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLayouts();
|
||||
_layoutsSubscription = apiService.watchStoreLayouts().listen((_) {
|
||||
_loadLayouts();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_layoutsSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadLayouts() {
|
||||
_layoutsFuture = apiService.getStoreLayouts();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _createLayout() async {
|
||||
final TextEditingController nameCtl = TextEditingController();
|
||||
final TextEditingController addressCtl = TextEditingController();
|
||||
final TextEditingController latCtl = TextEditingController();
|
||||
final TextEditingController lonCtl = TextEditingController();
|
||||
bool isPublic = false;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Neues Ladenlayout'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
TextField(
|
||||
controller: addressCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Adresse (optional)',
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: latCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Breitengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: lonCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Längengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: isPublic,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => isPublic = value);
|
||||
},
|
||||
title: const Text('Für andere sichtbar'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Erstellen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final String name = nameCtl.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
final String latText = latCtl.text.trim();
|
||||
final String lonText = lonCtl.text.trim();
|
||||
|
||||
if (latText.isNotEmpty) {
|
||||
latitude = double.tryParse(latText.replaceAll(',', '.'));
|
||||
if (latitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Breitengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lonText.isNotEmpty) {
|
||||
longitude = double.tryParse(lonText.replaceAll(',', '.'));
|
||||
if (longitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Längengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await apiService.createStoreLayout(
|
||||
name: name,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: addressCtl.text.trim().isEmpty ? null : addressCtl.text.trim(),
|
||||
isPublic: isPublic,
|
||||
);
|
||||
_loadLayouts();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openLayout(StoreLayout layout) async {
|
||||
await Navigator.of(context).push<StoreLayout>(
|
||||
MaterialPageRoute(builder: (_) => LayoutDetailPage(layout: layout)),
|
||||
);
|
||||
if (mounted) {
|
||||
_loadLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Ladenlayouts')),
|
||||
body: FutureBuilder<List<StoreLayout>>(
|
||||
future: _layoutsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Fehler: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
final layouts = snapshot.data ?? [];
|
||||
if (layouts.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Layouts vorhanden.'));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: layouts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final layout = layouts[index];
|
||||
return ListTile(
|
||||
title: Text(layout.name),
|
||||
subtitle: layout.address != null && layout.address!.isNotEmpty
|
||||
? Text(layout.address!)
|
||||
: null,
|
||||
leading: Icon(
|
||||
layout.owner == apiService.userId
|
||||
? Icons.store_mall_directory
|
||||
: Icons.share,
|
||||
),
|
||||
trailing: layout.isPublic
|
||||
? const Icon(Icons.public, size: 20)
|
||||
: null,
|
||||
onTap: () => _openLayout(layout),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _createLayout,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
817
lib/pages/list_detail.dart
Normal file
817
lib/pages/list_detail.dart
Normal file
@@ -0,0 +1,817 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
import 'package:listenmeister/pages/layout_detail.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;
|
||||
List<Item> _items = [];
|
||||
StoreLayout? _assignedLayout;
|
||||
bool _loadingLayout = false;
|
||||
bool _layoutUnavailable = false;
|
||||
StreamSubscription? _layoutSectionsSubscription;
|
||||
List<StoreSection> _layoutSections = [];
|
||||
bool _layoutSectionsLoading = false;
|
||||
String? _currentLayoutId;
|
||||
bool _layoutSectionsErrorShown = false;
|
||||
|
||||
bool get _isOwner => widget.list.owner == apiService.userId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_itemsFuture = apiService.getItems(widget.list.id);
|
||||
_itemsSubscription = apiService.watchItems(widget.list.id).listen((_) {
|
||||
_loadItems();
|
||||
});
|
||||
_loadAssignedLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ListDetailPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.list.layoutId != widget.list.layoutId) {
|
||||
_loadAssignedLayout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_itemsSubscription?.cancel();
|
||||
_layoutSectionsSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadItems() {
|
||||
setState(() {
|
||||
_itemsFuture = apiService.getItems(widget.list.id);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAssignedLayout() async {
|
||||
final String? layoutId = widget.list.layoutId;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadingLayout = true;
|
||||
});
|
||||
if (layoutId == null) {
|
||||
await _updateLayoutAssignment(null, layout: null);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadingLayout = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
StoreLayout? layout;
|
||||
try {
|
||||
layout = await apiService.getStoreLayoutById(layoutId);
|
||||
} catch (_) {
|
||||
layout = null;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await _updateLayoutAssignment(layoutId, layout: layout);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadingLayout = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateLayoutAssignment(
|
||||
String? layoutId, {
|
||||
required StoreLayout? layout,
|
||||
}) async {
|
||||
await _layoutSectionsSubscription?.cancel();
|
||||
_layoutSectionsSubscription = null;
|
||||
if (!mounted) return;
|
||||
|
||||
_currentLayoutId = layoutId;
|
||||
_layoutSectionsErrorShown = false;
|
||||
|
||||
setState(() {
|
||||
_assignedLayout = layoutId == null ? null : layout;
|
||||
_layoutUnavailable = layoutId != null && layout == null;
|
||||
_layoutSections = [];
|
||||
_layoutSectionsLoading = layoutId != null && layout != null;
|
||||
});
|
||||
|
||||
if (layoutId == null || layout == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String watchId = layoutId;
|
||||
_layoutSectionsSubscription = apiService.watchStoreSections(watchId).listen(
|
||||
(_) {
|
||||
_fetchLayoutSections(watchId);
|
||||
},
|
||||
);
|
||||
|
||||
await _fetchLayoutSections(watchId);
|
||||
}
|
||||
|
||||
Future<void> _fetchLayoutSections(String layoutId) async {
|
||||
try {
|
||||
final sections = await apiService.getStoreSections(layoutId);
|
||||
if (!mounted || _currentLayoutId != layoutId) return;
|
||||
setState(() {
|
||||
_layoutSections = sections;
|
||||
_layoutSectionsLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted || _currentLayoutId != layoutId) return;
|
||||
setState(() {
|
||||
_layoutSectionsLoading = false;
|
||||
});
|
||||
if (!_layoutSectionsErrorShown) {
|
||||
_layoutSectionsErrorShown = true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Bereiche konnten nicht geladen werden: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openAssignedLayout() async {
|
||||
final layout = _assignedLayout;
|
||||
if (layout == null) return;
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => LayoutDetailPage(layout: layout)));
|
||||
if (mounted) {
|
||||
await _loadAssignedLayout();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _chooseLayout({bool includeViewOption = false}) async {
|
||||
if (!_isOwner) return;
|
||||
setState(() => _loadingLayout = true);
|
||||
List<StoreLayout> layouts = [];
|
||||
try {
|
||||
layouts = await apiService.getStoreLayouts();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _loadingLayout = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Layouts konnten nicht geladen werden: $e')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingLayout = false);
|
||||
|
||||
const String noLayoutOption = '__no_layout__';
|
||||
final String? currentId = widget.list.layoutId;
|
||||
|
||||
final String? selection = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (includeViewOption && _assignedLayout != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_outlined),
|
||||
title: const Text('Aktuelles Layout ansehen'),
|
||||
onTap: () => Navigator.pop(context, '__view__'),
|
||||
),
|
||||
if (includeViewOption && _assignedLayout != null)
|
||||
const Divider(height: 0),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: const Text('Kein Layout zuordnen'),
|
||||
trailing: currentId == null
|
||||
? const Icon(Icons.check, size: 20)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, noLayoutOption),
|
||||
),
|
||||
if (layouts.isNotEmpty) const Divider(height: 0),
|
||||
if (layouts.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Keine Layouts verfügbar.'),
|
||||
),
|
||||
...layouts.map((layout) {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
layout.owner == apiService.userId
|
||||
? Icons.store_mall_directory
|
||||
: Icons.share,
|
||||
),
|
||||
title: Text(layout.name),
|
||||
subtitle: layout.address != null && layout.address!.isNotEmpty
|
||||
? Text(layout.address!)
|
||||
: null,
|
||||
trailing: currentId == layout.id
|
||||
? const Icon(Icons.check, size: 20)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, layout.id),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (selection == null) return;
|
||||
|
||||
if (includeViewOption && selection == '__view__') {
|
||||
await _openAssignedLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
final String? newLayoutId = selection == noLayoutOption ? null : selection;
|
||||
if (newLayoutId == currentId) return;
|
||||
|
||||
try {
|
||||
await apiService.updateList(
|
||||
widget.list.id,
|
||||
layoutId: newLayoutId,
|
||||
clearLayout: newLayoutId == null,
|
||||
);
|
||||
widget.list.layoutId = newLayoutId;
|
||||
if (!mounted) return;
|
||||
|
||||
if (newLayoutId == null) {
|
||||
setState(() => _loadingLayout = true);
|
||||
await _updateLayoutAssignment(null, layout: null);
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingLayout = false);
|
||||
} else {
|
||||
StoreLayout? selectedLayout;
|
||||
for (final layout in layouts) {
|
||||
if (layout.id == newLayoutId) {
|
||||
selectedLayout = layout;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayout != null) {
|
||||
setState(() => _loadingLayout = true);
|
||||
await _updateLayoutAssignment(newLayoutId, layout: selectedLayout);
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingLayout = false);
|
||||
} else {
|
||||
await _loadAssignedLayout();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Layout konnte nicht gespeichert werden: $e')),
|
||||
);
|
||||
await _loadAssignedLayout();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLayoutCard() {
|
||||
final bool hasLayout = _assignedLayout != null;
|
||||
final Widget subtitle;
|
||||
if (_loadingLayout) {
|
||||
subtitle = const Text('Lädt...');
|
||||
} else if (_layoutUnavailable && widget.list.layoutId != null) {
|
||||
subtitle = const Text('Layout nicht verfügbar');
|
||||
} else {
|
||||
subtitle = Text(
|
||||
hasLayout ? _assignedLayout!.name : 'Kein Layout zugeordnet',
|
||||
);
|
||||
}
|
||||
|
||||
final List<Widget> actionButtons = [];
|
||||
if (hasLayout) {
|
||||
actionButtons.add(
|
||||
TextButton.icon(
|
||||
onPressed: _loadingLayout ? null : _openAssignedLayout,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('Layout ansehen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_isOwner) {
|
||||
actionButtons.add(
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadingLayout
|
||||
? null
|
||||
: () => _chooseLayout(includeViewOption: true),
|
||||
icon: const Icon(Icons.store_mall_directory_outlined, size: 18),
|
||||
label: Text(hasLayout ? 'Layout wechseln' : 'Layout auswählen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.store_mall_directory_outlined),
|
||||
title: const Text('Ladenlayout'),
|
||||
subtitle: subtitle,
|
||||
onTap: () {
|
||||
if (_loadingLayout) return;
|
||||
if (_isOwner) {
|
||||
_chooseLayout(includeViewOption: true);
|
||||
} else if (hasLayout) {
|
||||
_openAssignedLayout();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (actionButtons.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(spacing: 8, runSpacing: 4, children: actionButtons),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveSectionName(String? sectionId) {
|
||||
if (sectionId == null) return null;
|
||||
for (final section in _layoutSections) {
|
||||
if (section.id == sectionId) {
|
||||
return section.name;
|
||||
}
|
||||
}
|
||||
if (_assignedLayout != null && !_layoutSectionsLoading) {
|
||||
return 'Bereich nicht verfügbar';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _showLayoutOptions() async {
|
||||
if (_loadingLayout) return;
|
||||
|
||||
if (_isOwner) {
|
||||
await _chooseLayout(includeViewOption: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_assignedLayout != null) {
|
||||
await _openAssignedLayout();
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Kein Layout hinterlegt.')));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
String? selectedSectionId = item.sectionId;
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Eintrag bearbeiten'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: ctl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
if (_assignedLayout != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _layoutSectionsLoading
|
||||
? const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Bereiche werden geladen...'),
|
||||
)
|
||||
: _layoutSections.isEmpty
|
||||
? const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Keine Bereiche im Layout.'),
|
||||
)
|
||||
: DropdownButtonFormField<String?>(
|
||||
initialValue: selectedSectionId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('Keine Abteilung'),
|
||||
),
|
||||
..._layoutSections.map(
|
||||
(section) => DropdownMenuItem<String?>(
|
||||
value: section.id,
|
||||
child: Text(section.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Abteilung',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setDialogState(() => selectedSectionId = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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;
|
||||
|
||||
final String newName = ctl.text.trim();
|
||||
final bool nameChanged = newName != item.name;
|
||||
final bool sectionChanged = selectedSectionId != item.sectionId;
|
||||
if (!nameChanged && !sectionChanged) return;
|
||||
|
||||
final String oldName = item.name;
|
||||
final String? oldSection = item.sectionId;
|
||||
setState(() {
|
||||
if (nameChanged) item.name = newName;
|
||||
if (sectionChanged) item.sectionId = selectedSectionId;
|
||||
});
|
||||
|
||||
try {
|
||||
await apiService.updateItem(
|
||||
item.id,
|
||||
name: nameChanged ? newName : null,
|
||||
sectionId: sectionChanged ? selectedSectionId : null,
|
||||
clearSection: sectionChanged && selectedSectionId == null,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
item.name = oldName;
|
||||
item.sectionId = oldSection;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateItemSection(Item item, String? newSectionId) async {
|
||||
if (_assignedLayout == null) return;
|
||||
final String? previousSection = item.sectionId;
|
||||
if (newSectionId == previousSection) return;
|
||||
|
||||
setState(() => item.sectionId = newSectionId);
|
||||
|
||||
try {
|
||||
await apiService.updateItem(
|
||||
item.id,
|
||||
sectionId: newSectionId,
|
||||
clearSection: newSectionId == null,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => item.sectionId = previousSection);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Bereich konnte nicht gespeichert werden: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addItem() async {
|
||||
final TextEditingController ctl = TextEditingController();
|
||||
String? selectedSectionId;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Eintrag hinzufügen'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: ctl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
if (_assignedLayout != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _layoutSectionsLoading
|
||||
? const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Bereiche werden geladen...'),
|
||||
)
|
||||
: _layoutSections.isEmpty
|
||||
? const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Keine Bereiche im Layout.'),
|
||||
)
|
||||
: DropdownButtonFormField<String?>(
|
||||
initialValue: selectedSectionId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('Keine Abteilung'),
|
||||
),
|
||||
..._layoutSections.map(
|
||||
(section) => DropdownMenuItem<String?>(
|
||||
value: section.id,
|
||||
child: Text(section.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Abteilung',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setDialogState(() => selectedSectionId = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
sectionId: (_assignedLayout != null && !_layoutSectionsLoading)
|
||||
? selectedSectionId
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteItem(Item item) async {
|
||||
final int index = _items.indexOf(item);
|
||||
setState(() => _items.remove(item));
|
||||
|
||||
try {
|
||||
await apiService.deleteItem(item.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('"${item.name}" gelöscht.')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _items.insert(index, item));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Löschen: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleItem(Item item) async {
|
||||
final bool oldState = item.checked;
|
||||
setState(() => item.checked = !item.checked);
|
||||
try {
|
||||
await apiService.updateItem(item.id, checked: item.checked);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => item.checked = oldState);
|
||||
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.store_mall_directory_outlined),
|
||||
tooltip: _loadingLayout
|
||||
? 'Lädt...'
|
||||
: _assignedLayout != null
|
||||
? (_isOwner ? 'Layout wechseln' : 'Layout anzeigen')
|
||||
: 'Layout auswählen',
|
||||
onPressed: _loadingLayout ? null : _showLayoutOptions,
|
||||
),
|
||||
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}'));
|
||||
}
|
||||
|
||||
_items = snapshot.data ?? [];
|
||||
|
||||
final layoutCard = _buildLayoutCard();
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 80),
|
||||
children: [
|
||||
layoutCard,
|
||||
const SizedBox(height: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('Füge deinen ersten Eintrag hinzu!'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 80),
|
||||
itemCount: _items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return layoutCard;
|
||||
}
|
||||
final Item item = _items[index - 1];
|
||||
final String? sectionName = _resolveSectionName(item.sectionId);
|
||||
final bool showSectionMenu = _assignedLayout != null;
|
||||
final String? currentSectionId = item.sectionId;
|
||||
return ListTile(
|
||||
leading: Checkbox(
|
||||
value: item.checked,
|
||||
onChanged: (_) => _toggleItem(item),
|
||||
),
|
||||
title: Text(
|
||||
item.name,
|
||||
style: TextStyle(
|
||||
decoration: item.checked
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
subtitle: sectionName != null
|
||||
? Text('Abteilung: $sectionName')
|
||||
: null,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showSectionMenu)
|
||||
PopupMenuButton<String?>(
|
||||
tooltip: _layoutSectionsLoading
|
||||
? 'Bereiche werden geladen...'
|
||||
: 'Bereich auswählen',
|
||||
icon: const Icon(Icons.segment),
|
||||
enabled: !_layoutSectionsLoading,
|
||||
onSelected: (value) async {
|
||||
if (_layoutSections.isEmpty && value == null) return;
|
||||
await _updateItemSection(item, value);
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
if (_layoutSectionsLoading) {
|
||||
return [
|
||||
const PopupMenuItem<String?>(
|
||||
value: null,
|
||||
enabled: false,
|
||||
child: Text('Bereiche werden geladen...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_layoutSections.isEmpty) {
|
||||
return [
|
||||
const PopupMenuItem<String?>(
|
||||
value: null,
|
||||
enabled: false,
|
||||
child: Text('Keine Bereiche im Layout'),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
PopupMenuItem<String?>(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (currentSectionId == null)
|
||||
const Icon(Icons.check, size: 18)
|
||||
else
|
||||
const SizedBox(width: 18),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Keine Abteilung'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
..._layoutSections.map(
|
||||
(section) => PopupMenuItem<String?>(
|
||||
value: section.id,
|
||||
child: Row(
|
||||
children: [
|
||||
if (currentSectionId == section.id)
|
||||
const Icon(Icons.check, size: 18)
|
||||
else
|
||||
const SizedBox(width: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(section.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editItem(item),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _deleteItem(item),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _addItem,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
414
lib/pages/lists.dart
Normal file
414
lib/pages/lists.dart
Normal file
@@ -0,0 +1,414 @@
|
||||
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/pages/layouts.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;
|
||||
Map<String, String> _layoutNameById = {};
|
||||
bool _layoutsLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLists();
|
||||
_listsSubscription = apiService.watchLists().listen((_) {
|
||||
_loadLists();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_listsSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadLists() {
|
||||
_listsFuture = apiService.getLists();
|
||||
setState(() {});
|
||||
_refreshLayoutCache();
|
||||
}
|
||||
|
||||
Future<void> _refreshLayoutCache() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _layoutsLoading = true);
|
||||
try {
|
||||
final layouts = await apiService.getStoreLayouts();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_layoutNameById = {
|
||||
for (final layout in layouts) layout.id: layout.name,
|
||||
};
|
||||
_layoutsLoading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _layoutsLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showListMenu(Liste list) async {
|
||||
final String? result = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (c) => Wrap(
|
||||
children: <Widget>[
|
||||
if (list.owner == apiService.userId)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.store_mall_directory_outlined),
|
||||
title: const Text('Layout zuordnen'),
|
||||
onTap: () => Navigator.pop(c, 'layout'),
|
||||
),
|
||||
if (list.owner == apiService.userId) const Divider(height: 0),
|
||||
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 == 'layout') {
|
||||
_assignLayout(list);
|
||||
} else if (result == 'delete') {
|
||||
_deleteList(list);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _assignLayout(Liste list) async {
|
||||
if (list.owner != apiService.userId) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Nur Besitzer können Layouts zuordnen.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
List<StoreLayout> layouts = [];
|
||||
try {
|
||||
layouts = await apiService.getStoreLayouts();
|
||||
if (!mounted) return;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Layouts konnten nicht geladen werden: $e')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const String noLayoutOption = '__no_layout__';
|
||||
final String? currentId = list.layoutId;
|
||||
|
||||
final String? selection = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: const Text('Kein Layout zuordnen'),
|
||||
trailing: currentId == null
|
||||
? const Icon(Icons.check, size: 20)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, noLayoutOption),
|
||||
),
|
||||
if (layouts.isNotEmpty) const Divider(height: 0),
|
||||
if (layouts.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Keine Layouts verfügbar.'),
|
||||
),
|
||||
...layouts.map((layout) {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
layout.owner == apiService.userId
|
||||
? Icons.store_mall_directory
|
||||
: Icons.share,
|
||||
),
|
||||
title: Text(layout.name),
|
||||
subtitle: layout.address != null && layout.address!.isNotEmpty
|
||||
? Text(layout.address!)
|
||||
: null,
|
||||
trailing: currentId == layout.id
|
||||
? const Icon(Icons.check, size: 20)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, layout.id),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (selection == null) return;
|
||||
|
||||
final String? newLayoutId = selection == noLayoutOption ? null : selection;
|
||||
if (newLayoutId == currentId) return;
|
||||
|
||||
try {
|
||||
await apiService.updateList(
|
||||
list.id,
|
||||
layoutId: newLayoutId,
|
||||
clearLayout: newLayoutId == null,
|
||||
);
|
||||
list.layoutId = newLayoutId;
|
||||
_loadLists();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Layout konnte nicht gespeichert werden: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
IconButton(
|
||||
tooltip: 'Ladenlayouts',
|
||||
icon: const Icon(Icons.store_mall_directory_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LayoutsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
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];
|
||||
final String? layoutName = l.layoutId != null
|
||||
? _layoutNameById[l.layoutId!]
|
||||
: null;
|
||||
final String? subtitle = layoutName != null
|
||||
? 'Layout: $layoutName'
|
||||
: l.layoutId != null
|
||||
? (_layoutsLoading
|
||||
? 'Layout wird geladen...'
|
||||
: 'Layout-ID: ${l.layoutId}')
|
||||
: null;
|
||||
Widget? trailing;
|
||||
if (l.owner == apiService.userId) {
|
||||
trailing = IconButton(
|
||||
tooltip: l.layoutId != null
|
||||
? 'Layout wechseln'
|
||||
: 'Layout zuordnen',
|
||||
icon: Icon(
|
||||
l.layoutId != null
|
||||
? Icons.store_mall_directory
|
||||
: Icons.store_mall_directory_outlined,
|
||||
),
|
||||
onPressed: () => _assignLayout(l),
|
||||
);
|
||||
} else if (l.layoutId != null) {
|
||||
trailing = const Icon(Icons.store_mall_directory_outlined);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(l.title),
|
||||
subtitle: subtitle != null ? Text(subtitle) : null,
|
||||
onTap: () => _openList(l),
|
||||
onLongPress: () => _showListMenu(l),
|
||||
trailing: trailing,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _createList,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/pages/login.dart
Normal file
97
lib/pages/login.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
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) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtl.dispose();
|
||||
_pwCtl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -51,8 +75,12 @@ class ApiService {
|
||||
String listId, {
|
||||
String? title,
|
||||
List<String>? members,
|
||||
String? layoutId,
|
||||
bool clearLayout = false,
|
||||
}) async {
|
||||
if (title == null && members == null) return;
|
||||
if (title == null && members == null && layoutId == null && !clearLayout) {
|
||||
return;
|
||||
}
|
||||
final body = <String, dynamic>{};
|
||||
if (title != null) {
|
||||
body['title'] = title;
|
||||
@@ -60,6 +88,12 @@ class ApiService {
|
||||
if (members != null) {
|
||||
body['members'] = members;
|
||||
}
|
||||
if (layoutId != null) {
|
||||
body['layout'] = layoutId;
|
||||
}
|
||||
if (clearLayout && layoutId == null) {
|
||||
body['layout'] = null;
|
||||
}
|
||||
await pb.collection('lists').update(listId, body: body);
|
||||
}
|
||||
|
||||
@@ -67,6 +101,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')
|
||||
@@ -74,7 +128,12 @@ class ApiService {
|
||||
return res.map((r) => Item.fromJson(r.toJson())).toList();
|
||||
}
|
||||
|
||||
Future<Item> createItem(String listId, String name, int position) async {
|
||||
Future<Item> createItem(
|
||||
String listId,
|
||||
String name,
|
||||
int position, {
|
||||
String? sectionId,
|
||||
}) async {
|
||||
final RecordModel rec = await pb
|
||||
.collection('items')
|
||||
.create(
|
||||
@@ -83,6 +142,7 @@ class ApiService {
|
||||
'name': name,
|
||||
'position': position,
|
||||
'checked': false,
|
||||
if (sectionId != null) 'section': sectionId,
|
||||
},
|
||||
);
|
||||
return Item.fromJson(rec.toJson());
|
||||
@@ -92,8 +152,16 @@ class ApiService {
|
||||
await pb.collection('items').delete(itemId);
|
||||
}
|
||||
|
||||
Future<void> updateItem(String itemId, {bool? checked, String? name}) async {
|
||||
if (checked == null && name == null) return;
|
||||
Future<void> updateItem(
|
||||
String itemId, {
|
||||
bool? checked,
|
||||
String? name,
|
||||
String? sectionId,
|
||||
bool clearSection = false,
|
||||
}) async {
|
||||
if (checked == null && name == null && sectionId == null && !clearSection) {
|
||||
return;
|
||||
}
|
||||
final Map<String, dynamic> body = <String, dynamic>{};
|
||||
if (checked != null) {
|
||||
body['checked'] = checked;
|
||||
@@ -101,6 +169,12 @@ class ApiService {
|
||||
if (name != null) {
|
||||
body['name'] = name;
|
||||
}
|
||||
if (sectionId != null) {
|
||||
body['section'] = sectionId;
|
||||
}
|
||||
if (clearSection && sectionId == null) {
|
||||
body['section'] = null;
|
||||
}
|
||||
await pb.collection('items').update(itemId, body: body);
|
||||
}
|
||||
|
||||
@@ -117,4 +191,208 @@ class ApiService {
|
||||
final String filter = ids.map((id) => 'id = "$id"').join(' || ');
|
||||
return await pb.collection('users').getFullList(filter: filter);
|
||||
}
|
||||
|
||||
Stream<void> watchListsForLayout(String layoutId) {
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
unsubscribe = await pb.collection('lists').subscribe('*', (event) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
}, filter: 'layout = "$layoutId"');
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<List<Liste>> getListsForLayout(String layoutId) async {
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('lists')
|
||||
.getFullList(filter: 'layout = "$layoutId"');
|
||||
return res.map((r) => Liste.fromJson(r.toJson())).toList();
|
||||
}
|
||||
|
||||
Stream<void> watchStoreLayouts() {
|
||||
if (userId == null) return Stream.value(null);
|
||||
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
final String filter = 'owner = "$userId" || isPublic = true';
|
||||
unsubscribe = await pb.collection('store_layouts').subscribe('*', (e) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
}, filter: filter);
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Stream<void> watchStoreLayout(String layoutId) {
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
unsubscribe = await pb.collection('store_layouts').subscribe(layoutId, (
|
||||
e,
|
||||
) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<List<StoreLayout>> getStoreLayouts({bool includePublic = true}) async {
|
||||
if (userId == null) return [];
|
||||
final List<String> filterParts = ['owner = "$userId"'];
|
||||
if (includePublic) {
|
||||
filterParts.add('isPublic = true');
|
||||
}
|
||||
final String filter = filterParts.join(' || ');
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('store_layouts')
|
||||
.getFullList(filter: filter, sort: '-updated');
|
||||
final layouts = res.map((r) => StoreLayout.fromJson(r.toJson())).toList();
|
||||
layouts.sort((a, b) {
|
||||
final bool aOwn = a.owner == userId;
|
||||
final bool bOwn = b.owner == userId;
|
||||
if (aOwn == bOwn) {
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
}
|
||||
return aOwn ? -1 : 1;
|
||||
});
|
||||
return layouts;
|
||||
}
|
||||
|
||||
Future<StoreLayout> createStoreLayout({
|
||||
required String name,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? address,
|
||||
bool isPublic = false,
|
||||
}) async {
|
||||
if (userId == null) throw Exception('Nicht eingeloggt');
|
||||
final Map<String, dynamic> body = {
|
||||
'name': name,
|
||||
'owner': userId,
|
||||
'isPublic': isPublic,
|
||||
};
|
||||
if (latitude != null) body['latitude'] = latitude;
|
||||
if (longitude != null) body['longitude'] = longitude;
|
||||
if (address != null && address.isNotEmpty) body['address'] = address;
|
||||
|
||||
final RecordModel rec = await pb
|
||||
.collection('store_layouts')
|
||||
.create(body: body);
|
||||
return StoreLayout.fromJson(rec.toJson());
|
||||
}
|
||||
|
||||
Future<StoreLayout?> getStoreLayoutById(String layoutId) async {
|
||||
try {
|
||||
final RecordModel rec = await pb
|
||||
.collection('store_layouts')
|
||||
.getOne(layoutId);
|
||||
return StoreLayout.fromJson(rec.toJson());
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStoreLayout(
|
||||
String layoutId, {
|
||||
String? name,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? address,
|
||||
bool? isPublic,
|
||||
bool clearLatitude = false,
|
||||
bool clearLongitude = false,
|
||||
bool clearAddress = false,
|
||||
}) async {
|
||||
final Map<String, dynamic> body = {};
|
||||
if (name != null) body['name'] = name;
|
||||
if (latitude != null) body['latitude'] = latitude;
|
||||
if (clearLatitude && latitude == null) body['latitude'] = null;
|
||||
if (longitude != null) body['longitude'] = longitude;
|
||||
if (clearLongitude && longitude == null) body['longitude'] = null;
|
||||
if (address != null) body['address'] = address;
|
||||
if (clearAddress && address == null) body['address'] = null;
|
||||
if (isPublic != null) body['isPublic'] = isPublic;
|
||||
|
||||
if (body.isEmpty) return;
|
||||
|
||||
await pb.collection('store_layouts').update(layoutId, body: body);
|
||||
}
|
||||
|
||||
Future<void> deleteStoreLayout(String layoutId) async {
|
||||
await pb.collection('store_layouts').delete(layoutId);
|
||||
}
|
||||
|
||||
Stream<void> watchStoreSections(String layoutId) {
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
unsubscribe = await pb.collection('store_sections').subscribe('*', (e) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
}, filter: 'layout = "$layoutId"');
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<List<StoreSection>> getStoreSections(String layoutId) async {
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('store_sections')
|
||||
.getFullList(filter: 'layout = "$layoutId"', sort: 'position');
|
||||
return res.map((r) => StoreSection.fromJson(r.toJson())).toList();
|
||||
}
|
||||
|
||||
Future<StoreSection> createStoreSection({
|
||||
required String layoutId,
|
||||
required String name,
|
||||
int position = 0,
|
||||
}) async {
|
||||
final RecordModel rec = await pb
|
||||
.collection('store_sections')
|
||||
.create(body: {'layout': layoutId, 'name': name, 'position': position});
|
||||
return StoreSection.fromJson(rec.toJson());
|
||||
}
|
||||
|
||||
Future<void> updateStoreSection(
|
||||
String sectionId, {
|
||||
String? name,
|
||||
int? position,
|
||||
}) async {
|
||||
final Map<String, dynamic> body = {};
|
||||
if (name != null) body['name'] = name;
|
||||
if (position != null) body['position'] = position;
|
||||
if (body.isEmpty) return;
|
||||
await pb.collection('store_sections').update(sectionId, body: body);
|
||||
}
|
||||
|
||||
Future<void> deleteStoreSection(String sectionId) async {
|
||||
await pb.collection('store_sections').delete(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
282
lib/widgets/members_dialog.dart
Normal file
282
lib/widgets/members_dialog.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
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);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
widget.list.members = newMembers;
|
||||
_userEmails[user.id] = user.data['email'] as String;
|
||||
_emailCtl.clear();
|
||||
_hasChanges = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
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);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
widget.list.members = newMembers;
|
||||
_hasChanges = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "shoppinglist")
|
||||
set(BINARY_NAME "ListenMeister")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.shoppinglist")
|
||||
set(APPLICATION_ID "io.flumm.listenmeister")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
||||
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "shoppinglist");
|
||||
gtk_header_bar_set_title(header_bar, "ListenMeister");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "shoppinglist");
|
||||
gtk_window_set_title(window, "ListenMeister");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -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