From 3054027ebfcbdda8e55797faf94a34d83f995279 Mon Sep 17 00:00:00 2001 From: Flummi Date: Wed, 29 Oct 2025 20:57:37 +0100 Subject: [PATCH] second draft --- lib/models/models.dart | 9 +- lib/pages/layout_detail.dart | 475 +++++++++++++++++-------- lib/pages/list_detail.dart | 662 ++++++++++++++++++++++++++++++++--- lib/pages/lists.dart | 153 ++++++++ lib/services/api.dart | 77 +++- 5 files changed, 1164 insertions(+), 212 deletions(-) diff --git a/lib/models/models.dart b/lib/models/models.dart index 17af5cb..51eff59 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -3,12 +3,14 @@ class Liste { String title; final String owner; List members; + String? layoutId; Liste({ required this.id, required this.title, required this.owner, required this.members, + this.layoutId, }); factory Liste.fromJson(Map json) { @@ -17,6 +19,7 @@ class Liste { title: json['title'] as String, owner: json['owner'] as String? ?? '', members: List.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 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 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?, ); } } diff --git a/lib/pages/layout_detail.dart b/lib/pages/layout_detail.dart index 80fa8bb..b17ad35 100644 --- a/lib/pages/layout_detail.dart +++ b/lib/pages/layout_detail.dart @@ -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 { late StoreLayout _layout; - late Future> _sectionsFuture; StreamSubscription? _layoutSubscription; StreamSubscription? _sectionsSubscription; + StreamSubscription? _listsSubscription; List _sections = []; + bool _sectionsLoading = true; + String? _sectionsError; + List _linkedLists = []; + bool _listsLoading = true; + String? _listsError; bool get _isOwner => _layout.owner == apiService.userId; @@ -26,19 +32,24 @@ class _LayoutDetailPageState extends State { 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 { } } - void _loadSections() { + Future _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 _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 _openList(Liste list) async { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => ListDetailPage(list: list))); + if (mounted) { + _loadLinkedLists(); + } } Future _editLayout() async { @@ -248,27 +306,15 @@ class _LayoutDetailPageState extends State { if (!_isOwner) return; final TextEditingController nameCtl = TextEditingController(); - final TextEditingController categoryCtl = TextEditingController(); final bool? ok = await showDialog( 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 { 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 { final TextEditingController nameCtl = TextEditingController( text: section.name, ); - final TextEditingController categoryCtl = TextEditingController( - text: section.category ?? '', - ); final bool? ok = await showDialog( 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 { 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 { } } + Future _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> 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 { ); } + List _buildLayoutInfoTiles() { + final tiles = []; + 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 _buildLinkedListsTiles() { + final tiles = []; + + 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 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 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 { child: const Icon(Icons.add), ) : null, - body: FutureBuilder>( - 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, ); } } diff --git a/lib/pages/list_detail.dart b/lib/pages/list_detail.dart index fa1690c..b6b37bf 100644 --- a/lib/pages/list_detail.dart +++ b/lib/pages/list_detail.dart @@ -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 { late Future> _itemsFuture; StreamSubscription? _itemsSubscription; List _items = []; + StoreLayout? _assignedLayout; + bool _loadingLayout = false; + bool _layoutUnavailable = false; + StreamSubscription? _layoutSectionsSubscription; + List _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 { _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 { }); } + Future _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 _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 _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 _openAssignedLayout() async { + final layout = _assignedLayout; + if (layout == null) return; + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => LayoutDetailPage(layout: layout))); + if (mounted) { + await _loadAssignedLayout(); + } + } + + Future _chooseLayout({bool includeViewOption = false}) async { + if (!_isOwner) return; + setState(() => _loadingLayout = true); + List 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( + 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 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 _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 _showMembersDialog() async { final bool? updated = await showDialog( context: context, @@ -52,40 +388,105 @@ class _ListDetailPageState extends State { Future _editItem(Item item) async { final TextEditingController ctl = TextEditingController(text: item.name); + String? selectedSectionId = item.sectionId; final bool? ok = await showDialog( context: context, - builder: (c) => AlertDialog( - title: const Text('Eintrag bearbeiten'), - content: TextField( - controller: ctl, - decoration: const InputDecoration(labelText: 'Name'), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c, false), - child: const Text('Abbrechen'), - ), - TextButton( - onPressed: () => Navigator.pop(c, true), - child: const Text('Speichern'), - ), - ], + 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( + initialValue: selectedSectionId, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Keine Abteilung'), + ), + ..._layoutSections.map( + (section) => DropdownMenuItem( + 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 { } } + Future _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 _addItem() async { final TextEditingController ctl = TextEditingController(); + String? selectedSectionId; + final bool? ok = await showDialog( context: context, - builder: (c) => AlertDialog( - title: const Text('Eintrag hinzufügen'), - content: TextField( - controller: ctl, - decoration: const InputDecoration(labelText: 'Name'), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c, false), - child: const Text('Abbrechen'), - ), - TextButton( - onPressed: () => Navigator.pop(c, true), - child: const Text('Hinzufügen'), - ), - ], + 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( + initialValue: selectedSectionId, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Keine Abteilung'), + ), + ..._layoutSections.map( + (section) => DropdownMenuItem( + 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 { -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 { 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 { _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 { : null, ), ), + subtitle: sectionName != null + ? Text('Abteilung: $sectionName') + : null, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (showSectionMenu) + PopupMenuButton( + 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( + value: null, + enabled: false, + child: Text('Bereiche werden geladen...'), + ), + ]; + } + if (_layoutSections.isEmpty) { + return [ + const PopupMenuItem( + value: null, + enabled: false, + child: Text('Keine Bereiche im Layout'), + ), + ]; + } + return [ + PopupMenuItem( + 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( + 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), diff --git a/lib/pages/lists.dart b/lib/pages/lists.dart index 5a286ab..a17a981 100644 --- a/lib/pages/lists.dart +++ b/lib/pages/lists.dart @@ -20,6 +20,8 @@ class ListsPage extends StatefulWidget { class _ListsPageState extends State { late Future> _listsFuture; StreamSubscription? _listsSubscription; + Map _layoutNameById = {}; + bool _layoutsLoading = false; @override void initState() { @@ -39,6 +41,25 @@ class _ListsPageState extends State { void _loadLists() { _listsFuture = apiService.getLists(); setState(() {}); + _refreshLayoutCache(); + } + + Future _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 _showListMenu(Liste list) async { @@ -46,6 +67,13 @@ class _ListsPageState extends State { context: context, builder: (c) => Wrap( children: [ + 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 { if (result == 'edit') { _editList(list); + } else if (result == 'layout') { + _assignLayout(list); } else if (result == 'delete') { _deleteList(list); } } + Future _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 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( + 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 _editList(Liste list) async { final TextEditingController titleCtl = TextEditingController( text: list.title, @@ -243,10 +367,39 @@ class _ListsPageState extends State { 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, ); }, ); diff --git a/lib/services/api.dart b/lib/services/api.dart index b6710f5..fdab002 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -75,8 +75,12 @@ class ApiService { String listId, { String? title, List? members, + String? layoutId, + bool clearLayout = false, }) async { - if (title == null && members == null) return; + if (title == null && members == null && layoutId == null && !clearLayout) { + return; + } final body = {}; 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 createItem(String listId, String name, int position) async { + Future 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 updateItem(String itemId, {bool? checked, String? name}) async { - if (checked == null && name == null) return; + Future updateItem( + String itemId, { + bool? checked, + String? name, + String? sectionId, + bool clearSection = false, + }) async { + if (checked == null && name == null && sectionId == null && !clearSection) { + return; + } final Map body = {}; 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 watchListsForLayout(String layoutId) { + late final StreamController controller; + Future Function()? unsubscribe; + + controller = StreamController( + 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> getListsForLayout(String layoutId) async { + final List res = await pb + .collection('lists') + .getFullList(filter: 'layout = "$layoutId"'); + return res.map((r) => Liste.fromJson(r.toJson())).toList(); + } + Stream 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 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); }