second draft

This commit is contained in:
2025-10-29 20:57:37 +01:00
parent ed1ffc8be4
commit 3054027ebf
5 changed files with 1164 additions and 212 deletions

View File

@@ -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,7 @@ class Item {
position: (json['position'] is int)
? json['position']
: int.tryParse(json['position'].toString()) ?? 0,
sectionId: json['section'] as String?,
);
}
}
@@ -90,14 +96,12 @@ class StoreSection {
final String layoutId;
String name;
int position;
String? category;
StoreSection({
required this.id,
required this.layoutId,
required this.name,
this.position = 0,
this.category,
});
factory StoreSection.fromJson(Map<String, dynamic> json) {
@@ -111,7 +115,6 @@ class StoreSection {
layoutId: json['layout'] as String? ?? '',
name: json['name'] as String? ?? '',
position: parsePosition(json['position']),
category: json['category'] as String?,
);
}
}

View File

@@ -4,6 +4,7 @@ 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;
@@ -15,10 +16,15 @@ class LayoutDetailPage extends StatefulWidget {
class _LayoutDetailPageState extends State<LayoutDetailPage> {
late StoreLayout _layout;
late Future<List<StoreSection>> _sectionsFuture;
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;
@@ -26,19 +32,24 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
void initState() {
super.initState();
_layout = widget.layout;
_sectionsFuture = apiService.getStoreSections(_layout.id);
_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();
}
@@ -49,10 +60,57 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
}
}
void _loadSections() {
Future<void> _loadSections() async {
if (!mounted) return;
setState(() {
_sectionsFuture = apiService.getStoreSections(_layout.id);
_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 {
@@ -248,27 +306,15 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
if (!_isOwner) return;
final TextEditingController nameCtl = TextEditingController();
final TextEditingController categoryCtl = TextEditingController();
final bool? ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Bereich hinzufügen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
TextField(
controller: categoryCtl,
decoration: const InputDecoration(
labelText: 'Kategorie (optional)',
),
),
],
content: TextField(
controller: nameCtl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
actions: [
TextButton(
@@ -294,9 +340,6 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
await apiService.createStoreSection(
layoutId: _layout.id,
name: nameCtl.text.trim(),
category: categoryCtl.text.trim().isEmpty
? null
: categoryCtl.text.trim(),
position: maxPos + 1,
);
_loadSections();
@@ -315,29 +358,15 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
final TextEditingController nameCtl = TextEditingController(
text: section.name,
);
final TextEditingController categoryCtl = TextEditingController(
text: section.category ?? '',
);
final bool? ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Bereich bearbeiten'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
TextField(
controller: categoryCtl,
decoration: const InputDecoration(
labelText: 'Kategorie (optional)',
),
),
],
content: TextField(
controller: nameCtl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
actions: [
TextButton(
@@ -354,15 +383,10 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
if (ok != true || nameCtl.text.trim().isEmpty) return;
final String categoryText = categoryCtl.text.trim();
final String? category = categoryText.isEmpty ? null : categoryText;
try {
await apiService.updateStoreSection(
section.id,
name: nameCtl.text.trim(),
category: category,
clearCategory: category == null,
);
_loadSections();
} catch (e) {
@@ -412,6 +436,47 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
}
}
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,
@@ -427,8 +492,222 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
);
}
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),
@@ -455,109 +734,7 @@ class _LayoutDetailPageState extends State<LayoutDetailPage> {
child: const Icon(Icons.add),
)
: null,
body: FutureBuilder<List<StoreSection>>(
future: _sectionsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Fehler: ${snapshot.error}'));
}
_sections = snapshot.data ?? [];
if (_sections.isEmpty && !_isOwner) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (_layout.address != null && _layout.address!.isNotEmpty)
_buildInfoTile(
icon: Icons.location_on_outlined,
label: 'Adresse',
value: _layout.address,
),
if (_layout.latitude != null && _layout.longitude != null)
_buildInfoTile(
icon: Icons.map_outlined,
label: 'Koordinaten',
value:
'${_layout.latitude!.toStringAsFixed(6)}, ${_layout.longitude!.toStringAsFixed(6)}',
),
const SizedBox(height: 24),
const Text(
'Noch keine Bereiche hinterlegt.',
textAlign: TextAlign.center,
),
],
);
}
return ListView(
children: [
if (_layout.address != null && _layout.address!.isNotEmpty)
_buildInfoTile(
icon: Icons.location_on_outlined,
label: 'Adresse',
value: _layout.address,
),
if (_layout.latitude != null && _layout.longitude != null)
_buildInfoTile(
icon: Icons.map_outlined,
label: 'Koordinaten',
value:
'${_layout.latitude!.toStringAsFixed(6)}, ${_layout.longitude!.toStringAsFixed(6)}',
),
if (_layout.isPublic)
const ListTile(
leading: Icon(Icons.public),
title: Text('Öffentlich sichtbar'),
),
if (_sections.isNotEmpty)
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.category_outlined),
title: Text(section.name),
subtitle:
section.category != null && section.category!.isNotEmpty
? Text(section.category!)
: null,
trailing: _isOwner
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _editSection(section),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _deleteSection(section),
),
],
)
: null,
),
),
if (_sections.isEmpty && _isOwner)
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Noch keine Bereiche. Tippe auf + um welche anzulegen.',
),
),
const SizedBox(height: 24),
],
);
},
),
body: body,
);
}
}

View File

@@ -4,6 +4,7 @@ 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 {
@@ -18,6 +19,16 @@ 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() {
@@ -26,11 +37,21 @@ class _ListDetailPageState extends State<ListDetailPage> {
_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();
}
@@ -40,6 +61,321 @@ class _ListDetailPageState extends State<ListDetailPage> {
});
}
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,
@@ -52,40 +388,105 @@ class _ListDetailPageState extends State<ListDetailPage> {
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) => 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'),
),
],
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();
if (newName == item.name) return;
final bool nameChanged = newName != item.name;
final bool sectionChanged = selectedSectionId != item.sectionId;
if (!nameChanged && !sectionChanged) return;
final String oldName = item.name;
setState(() => item.name = newName);
final String? oldSection = item.sectionId;
setState(() {
if (nameChanged) item.name = newName;
if (sectionChanged) item.sectionId = selectedSectionId;
});
try {
await apiService.updateItem(item.id, name: newName);
await apiService.updateItem(
item.id,
name: nameChanged ? newName : null,
sectionId: sectionChanged ? selectedSectionId : null,
clearSection: sectionChanged && selectedSectionId == null,
);
} catch (e) {
setState(() => item.name = oldName);
if (mounted) {
setState(() {
item.name = oldName;
item.sectionId = oldSection;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
@@ -93,27 +494,101 @@ class _ListDetailPageState extends State<ListDetailPage> {
}
}
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) => 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'),
),
],
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;
@@ -123,7 +598,14 @@ class _ListDetailPageState extends State<ListDetailPage> {
-1,
(max, item) => item.position > max ? item.position : max,
);
await apiService.createItem(widget.list.id, ctl.text.trim(), maxPos + 1);
await apiService.createItem(
widget.list.id,
ctl.text.trim(),
maxPos + 1,
sectionId: (_assignedLayout != null && !_layoutSectionsLoading)
? selectedSectionId
: null,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
@@ -175,6 +657,15 @@ class _ListDetailPageState extends State<ListDetailPage> {
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',
@@ -194,15 +685,34 @@ class _ListDetailPageState extends State<ListDetailPage> {
_items = snapshot.data ?? [];
final layoutCard = _buildLayoutCard();
if (_items.isEmpty) {
return const Center(
child: Text('Füge deinen ersten Eintrag hinzu!'),
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(
itemCount: _items.length,
padding: const EdgeInsets.fromLTRB(0, 0, 0, 80),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
final Item item = _items[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,
@@ -216,9 +726,75 @@ class _ListDetailPageState extends State<ListDetailPage> {
: 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),

View File

@@ -20,6 +20,8 @@ class ListsPage extends StatefulWidget {
class _ListsPageState extends State<ListsPage> {
late Future<List<Liste>> _listsFuture;
StreamSubscription? _listsSubscription;
Map<String, String> _layoutNameById = {};
bool _layoutsLoading = false;
@override
void initState() {
@@ -39,6 +41,25 @@ class _ListsPageState extends State<ListsPage> {
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 {
@@ -46,6 +67,13 @@ class _ListsPageState extends State<ListsPage> {
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'),
@@ -62,11 +90,107 @@ class _ListsPageState extends State<ListsPage> {
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,
@@ -243,10 +367,39 @@ class _ListsPageState extends State<ListsPage> {
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,
);
},
);

View File

@@ -75,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;
@@ -84,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);
}
@@ -118,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(
@@ -127,6 +142,7 @@ class ApiService {
'name': name,
'position': position,
'checked': false,
if (sectionId != null) 'section': sectionId,
},
);
return Item.fromJson(rec.toJson());
@@ -136,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;
@@ -145,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);
}
@@ -162,6 +192,31 @@ class ApiService {
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);
@@ -318,18 +373,10 @@ class ApiService {
required String layoutId,
required String name,
int position = 0,
String? category,
}) async {
final RecordModel rec = await pb
.collection('store_sections')
.create(
body: {
'layout': layoutId,
'name': name,
'position': position,
if (category != null && category.isNotEmpty) 'category': category,
},
);
.create(body: {'layout': layoutId, 'name': name, 'position': position});
return StoreSection.fromJson(rec.toJson());
}
@@ -337,14 +384,10 @@ class ApiService {
String sectionId, {
String? name,
int? position,
String? category,
bool clearCategory = false,
}) async {
final Map<String, dynamic> body = {};
if (name != null) body['name'] = name;
if (position != null) body['position'] = position;
if (category != null) body['category'] = category;
if (clearCategory && category == null) body['category'] = null;
if (body.isEmpty) return;
await pb.collection('store_sections').update(sectionId, body: body);
}