From ed1ffc8be4e923629e37cc05a743af5b47329502 Mon Sep 17 00:00:00 2001 From: Flummi Date: Wed, 29 Oct 2025 17:20:44 +0100 Subject: [PATCH] first draft --- lib/models/models.dart | 70 +++++ lib/pages/layout_detail.dart | 563 +++++++++++++++++++++++++++++++++++ lib/pages/layouts.dart | 224 ++++++++++++++ lib/pages/lists.dart | 11 + lib/services/api.dart | 191 ++++++++++++ 5 files changed, 1059 insertions(+) create mode 100644 lib/pages/layout_detail.dart create mode 100644 lib/pages/layouts.dart diff --git a/lib/models/models.dart b/lib/models/models.dart index 61cbd55..17af5cb 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -45,3 +45,73 @@ class Item { ); } } + +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 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; + String? category; + + StoreSection({ + required this.id, + required this.layoutId, + required this.name, + this.position = 0, + this.category, + }); + + factory StoreSection.fromJson(Map 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']), + category: json['category'] as String?, + ); + } +} diff --git a/lib/pages/layout_detail.dart b/lib/pages/layout_detail.dart new file mode 100644 index 0000000..80fa8bb --- /dev/null +++ b/lib/pages/layout_detail.dart @@ -0,0 +1,563 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:listenmeister/main.dart'; +import 'package:listenmeister/models/models.dart'; + +class LayoutDetailPage extends StatefulWidget { + final StoreLayout layout; + const LayoutDetailPage({super.key, required this.layout}); + + @override + State createState() => _LayoutDetailPageState(); +} + +class _LayoutDetailPageState extends State { + late StoreLayout _layout; + late Future> _sectionsFuture; + StreamSubscription? _layoutSubscription; + StreamSubscription? _sectionsSubscription; + List _sections = []; + + bool get _isOwner => _layout.owner == apiService.userId; + + @override + void initState() { + super.initState(); + _layout = widget.layout; + _sectionsFuture = apiService.getStoreSections(_layout.id); + _layoutSubscription = apiService.watchStoreLayout(_layout.id).listen((_) { + _refreshLayout(); + }); + _sectionsSubscription = apiService + .watchStoreSections(_layout.id) + .listen((_) => _loadSections()); + } + + @override + void dispose() { + _layoutSubscription?.cancel(); + _sectionsSubscription?.cancel(); + super.dispose(); + } + + Future _refreshLayout() async { + final updated = await apiService.getStoreLayoutById(_layout.id); + if (updated != null && mounted) { + setState(() => _layout = updated); + } + } + + void _loadSections() { + setState(() { + _sectionsFuture = apiService.getStoreSections(_layout.id); + }); + } + + Future _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( + 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 _deleteLayout() async { + if (!_isOwner) return; + final bool? ok = await showDialog( + 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 _addSection() async { + 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)', + ), + ), + ], + ), + 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( + -1, + (max, section) => section.position > max ? section.position : max, + ); + + try { + await apiService.createStoreSection( + layoutId: _layout.id, + name: nameCtl.text.trim(), + category: categoryCtl.text.trim().isEmpty + ? null + : categoryCtl.text.trim(), + position: maxPos + 1, + ); + _loadSections(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + Future _editSection(StoreSection section) async { + if (!_isOwner) return; + + 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)', + ), + ), + ], + ), + 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; + + 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) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + Future _deleteSection(StoreSection section) async { + if (!_isOwner) return; + + final bool? ok = await showDialog( + 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'))); + } + } + } + + 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), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_layout.name), + actions: [ + if (_isOwner) + PopupMenuButton( + 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: 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), + ], + ); + }, + ), + ); + } +} diff --git a/lib/pages/layouts.dart b/lib/pages/layouts.dart new file mode 100644 index 0000000..67768bc --- /dev/null +++ b/lib/pages/layouts.dart @@ -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 createState() => _LayoutsPageState(); +} + +class _LayoutsPageState extends State { + late Future> _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 _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( + 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 _openLayout(StoreLayout layout) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => LayoutDetailPage(layout: layout)), + ); + if (mounted) { + _loadLayouts(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ladenlayouts')), + body: FutureBuilder>( + 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), + ), + ); + } +} diff --git a/lib/pages/lists.dart b/lib/pages/lists.dart index e9a3ab7..5a286ab 100644 --- a/lib/pages/lists.dart +++ b/lib/pages/lists.dart @@ -7,6 +7,7 @@ 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 { @@ -194,6 +195,16 @@ class _ListsPageState extends State { 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( onSelected: (mode) => themeProvider.setThemeMode(mode), itemBuilder: (context) => [ diff --git a/lib/services/api.dart b/lib/services/api.dart index 6b611f8..b6710f5 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -161,4 +161,195 @@ class ApiService { final String filter = ids.map((id) => 'id = "$id"').join(' || '); return await pb.collection('users').getFullList(filter: filter); } + + Stream watchStoreLayouts() { + if (userId == null) return Stream.value(null); + + late final StreamController controller; + Future Function()? unsubscribe; + + controller = StreamController( + 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 watchStoreLayout(String layoutId) { + late final StreamController controller; + Future Function()? unsubscribe; + + controller = StreamController( + onListen: () async { + unsubscribe = await pb.collection('store_layouts').subscribe(layoutId, ( + e, + ) { + if (!controller.isClosed) { + controller.add(null); + } + }); + }, + onCancel: () => unsubscribe?.call(), + ); + + return controller.stream; + } + + Future> getStoreLayouts({bool includePublic = true}) async { + if (userId == null) return []; + final List filterParts = ['owner = "$userId"']; + if (includePublic) { + filterParts.add('isPublic = true'); + } + final String filter = filterParts.join(' || '); + final List 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 createStoreLayout({ + required String name, + double? latitude, + double? longitude, + String? address, + bool isPublic = false, + }) async { + if (userId == null) throw Exception('Nicht eingeloggt'); + final Map 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 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 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 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 deleteStoreLayout(String layoutId) async { + await pb.collection('store_layouts').delete(layoutId); + } + + Stream watchStoreSections(String layoutId) { + late final StreamController controller; + Future Function()? unsubscribe; + + controller = StreamController( + 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> getStoreSections(String layoutId) async { + final List res = await pb + .collection('store_sections') + .getFullList(filter: 'layout = "$layoutId"', sort: 'position'); + return res.map((r) => StoreSection.fromJson(r.toJson())).toList(); + } + + Future createStoreSection({ + 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, + }, + ); + return StoreSection.fromJson(rec.toJson()); + } + + Future updateStoreSection( + 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); + } + + Future deleteStoreSection(String sectionId) async { + await pb.collection('store_sections').delete(sectionId); + } }