first draft
This commit is contained in:
@@ -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<String, dynamic> json) {
|
||||
double? parseCoord(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is double) return value;
|
||||
if (value is int) return value.toDouble();
|
||||
return double.tryParse(value.toString());
|
||||
}
|
||||
|
||||
return StoreLayout(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String? ?? '',
|
||||
owner: json['owner'] as String? ?? '',
|
||||
latitude: parseCoord(json['latitude']),
|
||||
longitude: parseCoord(json['longitude']),
|
||||
address: json['address'] as String?,
|
||||
isPublic: json['isPublic'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSection {
|
||||
final String id;
|
||||
final String layoutId;
|
||||
String name;
|
||||
int position;
|
||||
String? category;
|
||||
|
||||
StoreSection({
|
||||
required this.id,
|
||||
required this.layoutId,
|
||||
required this.name,
|
||||
this.position = 0,
|
||||
this.category,
|
||||
});
|
||||
|
||||
factory StoreSection.fromJson(Map<String, dynamic> json) {
|
||||
int parsePosition(dynamic value) {
|
||||
if (value is int) return value;
|
||||
return int.tryParse(value.toString()) ?? 0;
|
||||
}
|
||||
|
||||
return StoreSection(
|
||||
id: json['id'] as String,
|
||||
layoutId: json['layout'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
position: parsePosition(json['position']),
|
||||
category: json['category'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
563
lib/pages/layout_detail.dart
Normal file
563
lib/pages/layout_detail.dart
Normal file
@@ -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<LayoutDetailPage> createState() => _LayoutDetailPageState();
|
||||
}
|
||||
|
||||
class _LayoutDetailPageState extends State<LayoutDetailPage> {
|
||||
late StoreLayout _layout;
|
||||
late Future<List<StoreSection>> _sectionsFuture;
|
||||
StreamSubscription? _layoutSubscription;
|
||||
StreamSubscription? _sectionsSubscription;
|
||||
List<StoreSection> _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<void> _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<void> _editLayout() async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final TextEditingController nameCtl = TextEditingController(
|
||||
text: _layout.name,
|
||||
);
|
||||
final TextEditingController addressCtl = TextEditingController(
|
||||
text: _layout.address ?? '',
|
||||
);
|
||||
final TextEditingController latCtl = TextEditingController(
|
||||
text: _layout.latitude != null
|
||||
? _layout.latitude!.toStringAsFixed(6)
|
||||
: '',
|
||||
);
|
||||
final TextEditingController lonCtl = TextEditingController(
|
||||
text: _layout.longitude != null
|
||||
? _layout.longitude!.toStringAsFixed(6)
|
||||
: '',
|
||||
);
|
||||
bool isPublic = _layout.isPublic;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Layout bearbeiten'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
TextField(
|
||||
controller: addressCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Adresse (optional)',
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: latCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Breitengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: lonCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Längengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: isPublic,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => isPublic = value);
|
||||
},
|
||||
title: const Text('Für andere sichtbar'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final String name = nameCtl.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
bool clearLatitude = false;
|
||||
bool clearLongitude = false;
|
||||
|
||||
final String latText = latCtl.text.trim();
|
||||
final String lonText = lonCtl.text.trim();
|
||||
final String addressText = addressCtl.text.trim();
|
||||
|
||||
if (latText.isNotEmpty) {
|
||||
latitude = double.tryParse(latText.replaceAll(',', '.'));
|
||||
if (latitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Breitengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearLatitude = true;
|
||||
}
|
||||
|
||||
if (lonText.isNotEmpty) {
|
||||
longitude = double.tryParse(lonText.replaceAll(',', '.'));
|
||||
if (longitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Längengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearLongitude = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiService.updateStoreLayout(
|
||||
_layout.id,
|
||||
name: name,
|
||||
address: addressText.isEmpty ? null : addressText,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isPublic: isPublic,
|
||||
clearLatitude: clearLatitude,
|
||||
clearLongitude: clearLongitude,
|
||||
clearAddress: addressText.isEmpty,
|
||||
);
|
||||
await _refreshLayout();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Speichern: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteLayout() async {
|
||||
if (!_isOwner) return;
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Layout löschen?'),
|
||||
content: Text('Möchtest du "${_layout.name}" wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
try {
|
||||
await apiService.deleteStoreLayout(_layout.id);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Löschen: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addSection() async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final TextEditingController nameCtl = TextEditingController();
|
||||
final 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)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Hinzufügen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true || nameCtl.text.trim().isEmpty) return;
|
||||
|
||||
final int maxPos = _sections.fold<int>(
|
||||
-1,
|
||||
(max, section) => section.position > max ? section.position : max,
|
||||
);
|
||||
|
||||
try {
|
||||
await apiService.createStoreSection(
|
||||
layoutId: _layout.id,
|
||||
name: nameCtl.text.trim(),
|
||||
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<void> _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<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)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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<void> _deleteSection(StoreSection section) async {
|
||||
if (!_isOwner) return;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Bereich löschen?'),
|
||||
content: Text('"${section.name}" entfernen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final int index = _sections.indexOf(section);
|
||||
setState(() => _sections.remove(section));
|
||||
|
||||
try {
|
||||
await apiService.deleteStoreSection(section.id);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _sections.insert(index, section));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler beim Löschen: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_editLayout();
|
||||
} else if (value == 'delete') {
|
||||
_deleteLayout();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('Layout bearbeiten')),
|
||||
PopupMenuItem(value: 'delete', child: Text('Layout löschen')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _isOwner
|
||||
? FloatingActionButton(
|
||||
onPressed: _addSection,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
body: 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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
224
lib/pages/layouts.dart
Normal file
224
lib/pages/layouts.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:listenmeister/main.dart';
|
||||
import 'package:listenmeister/models/models.dart';
|
||||
import 'package:listenmeister/pages/layout_detail.dart';
|
||||
|
||||
class LayoutsPage extends StatefulWidget {
|
||||
const LayoutsPage({super.key});
|
||||
|
||||
@override
|
||||
State<LayoutsPage> createState() => _LayoutsPageState();
|
||||
}
|
||||
|
||||
class _LayoutsPageState extends State<LayoutsPage> {
|
||||
late Future<List<StoreLayout>> _layoutsFuture;
|
||||
StreamSubscription? _layoutsSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLayouts();
|
||||
_layoutsSubscription = apiService.watchStoreLayouts().listen((_) {
|
||||
_loadLayouts();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_layoutsSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadLayouts() {
|
||||
_layoutsFuture = apiService.getStoreLayouts();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _createLayout() async {
|
||||
final TextEditingController nameCtl = TextEditingController();
|
||||
final TextEditingController addressCtl = TextEditingController();
|
||||
final TextEditingController latCtl = TextEditingController();
|
||||
final TextEditingController lonCtl = TextEditingController();
|
||||
bool isPublic = false;
|
||||
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Neues Ladenlayout'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
TextField(
|
||||
controller: addressCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Adresse (optional)',
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: latCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Breitengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: lonCtl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Längengrad (optional)',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
value: isPublic,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => isPublic = value);
|
||||
},
|
||||
title: const Text('Für andere sichtbar'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Erstellen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final String name = nameCtl.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
final String latText = latCtl.text.trim();
|
||||
final String lonText = lonCtl.text.trim();
|
||||
|
||||
if (latText.isNotEmpty) {
|
||||
latitude = double.tryParse(latText.replaceAll(',', '.'));
|
||||
if (latitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Breitengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lonText.isNotEmpty) {
|
||||
longitude = double.tryParse(lonText.replaceAll(',', '.'));
|
||||
if (longitude == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ungültiger Längengrad.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await apiService.createStoreLayout(
|
||||
name: name,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: addressCtl.text.trim().isEmpty ? null : addressCtl.text.trim(),
|
||||
isPublic: isPublic,
|
||||
);
|
||||
_loadLayouts();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openLayout(StoreLayout layout) async {
|
||||
await Navigator.of(context).push<StoreLayout>(
|
||||
MaterialPageRoute(builder: (_) => LayoutDetailPage(layout: layout)),
|
||||
);
|
||||
if (mounted) {
|
||||
_loadLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Ladenlayouts')),
|
||||
body: FutureBuilder<List<StoreLayout>>(
|
||||
future: _layoutsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Fehler: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
final layouts = snapshot.data ?? [];
|
||||
if (layouts.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Layouts vorhanden.'));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: layouts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final layout = layouts[index];
|
||||
return ListTile(
|
||||
title: Text(layout.name),
|
||||
subtitle: layout.address != null && layout.address!.isNotEmpty
|
||||
? Text(layout.address!)
|
||||
: null,
|
||||
leading: Icon(
|
||||
layout.owner == apiService.userId
|
||||
? Icons.store_mall_directory
|
||||
: Icons.share,
|
||||
),
|
||||
trailing: layout.isPublic
|
||||
? const Icon(Icons.public, size: 20)
|
||||
: null,
|
||||
onTap: () => _openLayout(layout),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _createLayout,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ListsPage> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Deine Listen'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Ladenlayouts',
|
||||
icon: const Icon(Icons.store_mall_directory_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LayoutsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton<ThemeMode>(
|
||||
onSelected: (mode) => themeProvider.setThemeMode(mode),
|
||||
itemBuilder: (context) => [
|
||||
|
||||
@@ -161,4 +161,195 @@ class ApiService {
|
||||
final String filter = ids.map((id) => 'id = "$id"').join(' || ');
|
||||
return await pb.collection('users').getFullList(filter: filter);
|
||||
}
|
||||
|
||||
Stream<void> watchStoreLayouts() {
|
||||
if (userId == null) return Stream.value(null);
|
||||
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
final String filter = 'owner = "$userId" || isPublic = true';
|
||||
unsubscribe = await pb.collection('store_layouts').subscribe('*', (e) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
}, filter: filter);
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Stream<void> watchStoreLayout(String layoutId) {
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
unsubscribe = await pb.collection('store_layouts').subscribe(layoutId, (
|
||||
e,
|
||||
) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<List<StoreLayout>> getStoreLayouts({bool includePublic = true}) async {
|
||||
if (userId == null) return [];
|
||||
final List<String> filterParts = ['owner = "$userId"'];
|
||||
if (includePublic) {
|
||||
filterParts.add('isPublic = true');
|
||||
}
|
||||
final String filter = filterParts.join(' || ');
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('store_layouts')
|
||||
.getFullList(filter: filter, sort: '-updated');
|
||||
final layouts = res.map((r) => StoreLayout.fromJson(r.toJson())).toList();
|
||||
layouts.sort((a, b) {
|
||||
final bool aOwn = a.owner == userId;
|
||||
final bool bOwn = b.owner == userId;
|
||||
if (aOwn == bOwn) {
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
}
|
||||
return aOwn ? -1 : 1;
|
||||
});
|
||||
return layouts;
|
||||
}
|
||||
|
||||
Future<StoreLayout> createStoreLayout({
|
||||
required String name,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? address,
|
||||
bool isPublic = false,
|
||||
}) async {
|
||||
if (userId == null) throw Exception('Nicht eingeloggt');
|
||||
final Map<String, dynamic> body = {
|
||||
'name': name,
|
||||
'owner': userId,
|
||||
'isPublic': isPublic,
|
||||
};
|
||||
if (latitude != null) body['latitude'] = latitude;
|
||||
if (longitude != null) body['longitude'] = longitude;
|
||||
if (address != null && address.isNotEmpty) body['address'] = address;
|
||||
|
||||
final RecordModel rec = await pb
|
||||
.collection('store_layouts')
|
||||
.create(body: body);
|
||||
return StoreLayout.fromJson(rec.toJson());
|
||||
}
|
||||
|
||||
Future<StoreLayout?> getStoreLayoutById(String layoutId) async {
|
||||
try {
|
||||
final RecordModel rec = await pb
|
||||
.collection('store_layouts')
|
||||
.getOne(layoutId);
|
||||
return StoreLayout.fromJson(rec.toJson());
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStoreLayout(
|
||||
String layoutId, {
|
||||
String? name,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? address,
|
||||
bool? isPublic,
|
||||
bool clearLatitude = false,
|
||||
bool clearLongitude = false,
|
||||
bool clearAddress = false,
|
||||
}) async {
|
||||
final Map<String, dynamic> body = {};
|
||||
if (name != null) body['name'] = name;
|
||||
if (latitude != null) body['latitude'] = latitude;
|
||||
if (clearLatitude && latitude == null) body['latitude'] = null;
|
||||
if (longitude != null) body['longitude'] = longitude;
|
||||
if (clearLongitude && longitude == null) body['longitude'] = null;
|
||||
if (address != null) body['address'] = address;
|
||||
if (clearAddress && address == null) body['address'] = null;
|
||||
if (isPublic != null) body['isPublic'] = isPublic;
|
||||
|
||||
if (body.isEmpty) return;
|
||||
|
||||
await pb.collection('store_layouts').update(layoutId, body: body);
|
||||
}
|
||||
|
||||
Future<void> deleteStoreLayout(String layoutId) async {
|
||||
await pb.collection('store_layouts').delete(layoutId);
|
||||
}
|
||||
|
||||
Stream<void> watchStoreSections(String layoutId) {
|
||||
late final StreamController<void> controller;
|
||||
Future<void> Function()? unsubscribe;
|
||||
|
||||
controller = StreamController<void>(
|
||||
onListen: () async {
|
||||
unsubscribe = await pb.collection('store_sections').subscribe('*', (e) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(null);
|
||||
}
|
||||
}, filter: 'layout = "$layoutId"');
|
||||
},
|
||||
onCancel: () => unsubscribe?.call(),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<List<StoreSection>> getStoreSections(String layoutId) async {
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('store_sections')
|
||||
.getFullList(filter: 'layout = "$layoutId"', sort: 'position');
|
||||
return res.map((r) => StoreSection.fromJson(r.toJson())).toList();
|
||||
}
|
||||
|
||||
Future<StoreSection> createStoreSection({
|
||||
required String layoutId,
|
||||
required String name,
|
||||
int position = 0,
|
||||
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<void> updateStoreSection(
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> deleteStoreSection(String sectionId) async {
|
||||
await pb.collection('store_sections').delete(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user