316 lines
9.8 KiB
Dart
316 lines
9.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import '../models/stop.dart';
|
|
import '../services/database.dart';
|
|
import '../services/geocoding.dart';
|
|
import '../hoogerheide_streets.dart';
|
|
|
|
class BuilderPage extends StatefulWidget {
|
|
final int routeId;
|
|
final VoidCallback onSave;
|
|
const BuilderPage({super.key, required this.routeId, required this.onSave});
|
|
@override
|
|
State<BuilderPage> createState() => _BuilderPageState();
|
|
}
|
|
|
|
class _BuilderPageState extends State<BuilderPage> {
|
|
final _db = DatabaseService();
|
|
final _geocoding = GeocodingService();
|
|
final _streetCtrl = TextEditingController();
|
|
final _numCtrl = TextEditingController();
|
|
|
|
final List<_StreetEntry> _entries = [];
|
|
final List<String> _currentNums = [];
|
|
final Map<String, Set<String>> _currentPapers = {};
|
|
|
|
bool _saving = false;
|
|
|
|
static const _availablePapers = ['BN', 'AD', 'TEL', 'VK'];
|
|
static const _npColors = {
|
|
'BN': Colors.red,
|
|
'AD': Colors.blue,
|
|
'TEL': Colors.orange,
|
|
'VK': Colors.purple,
|
|
};
|
|
|
|
@override
|
|
void dispose() {
|
|
_streetCtrl.dispose();
|
|
_numCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _addHouseNumber() {
|
|
final n = _numCtrl.text.trim();
|
|
if (n.isNotEmpty && !_currentNums.contains(n)) {
|
|
setState(() {
|
|
_currentNums.add(n);
|
|
_currentPapers[n] = {'BN'}; // default newspaper
|
|
});
|
|
}
|
|
_numCtrl.clear();
|
|
}
|
|
|
|
void _togglePaper(String num, String paper) {
|
|
setState(() {
|
|
final current = _currentPapers[num] ?? {'BN'};
|
|
if (current.contains(paper)) {
|
|
current.remove(paper);
|
|
} else {
|
|
current.add(paper);
|
|
}
|
|
_currentPapers[num] = current;
|
|
});
|
|
}
|
|
|
|
void _saveStreet() {
|
|
if (_currentNums.isEmpty || _streetCtrl.text.trim().isEmpty) return;
|
|
setState(() {
|
|
_entries.add(_StreetEntry(
|
|
street: _streetCtrl.text.trim(),
|
|
numPapers: Map.from(_currentPapers),
|
|
));
|
|
_currentNums.clear();
|
|
_currentPapers.clear();
|
|
_streetCtrl.clear();
|
|
});
|
|
}
|
|
|
|
void _removeEntry(_StreetEntry entry) {
|
|
setState(() => _entries.remove(entry));
|
|
}
|
|
|
|
Future<void> _saveAll() async {
|
|
if (_entries.isEmpty) return;
|
|
setState(() => _saving = true);
|
|
|
|
try {
|
|
var seq = (await _db.getMaxSequence(widget.routeId) ?? -1) + 1;
|
|
|
|
for (final entry in _entries) {
|
|
for (final npEntry in entry.numPapers.entries) {
|
|
final houseNum = npEntry.key;
|
|
final papers = npEntry.value.isEmpty ? {'BN'} : npEntry.value;
|
|
|
|
// Geocode: try GeocodingService first, fallback to hoogerheide_streets.dart
|
|
LatLng coords;
|
|
try {
|
|
coords = await _geocoding.geocode(entry.street, houseNum);
|
|
} catch (_) {
|
|
coords = _geocodeFromStreets(entry.street, houseNum);
|
|
}
|
|
|
|
await _db.insertStop(
|
|
Stop(
|
|
street: entry.street,
|
|
houseNumber: houseNum,
|
|
newspapers: papers.toList(),
|
|
lat: coords.latitude,
|
|
lng: coords.longitude,
|
|
sequence: seq++,
|
|
),
|
|
widget.routeId,
|
|
);
|
|
}
|
|
}
|
|
|
|
widget.onSave();
|
|
if (mounted) Navigator.pop(context);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Error saving: $e'), backgroundColor: Colors.red),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
/// Fallback geocode using hoogerheide_streets.dart with house-number offset
|
|
LatLng _geocodeFromStreets(String street, String houseNum) {
|
|
final ns = _normalize(street);
|
|
|
|
// Exact match
|
|
for (final entry in HoogerheideStreets.streetCoords.entries) {
|
|
if (_normalize(entry.key) == ns) {
|
|
return _offsetByHouseNumber(entry.value[0], entry.value[1], houseNum);
|
|
}
|
|
}
|
|
// Partial match
|
|
for (final entry in HoogerheideStreets.streetCoords.entries) {
|
|
final ek = _normalize(entry.key);
|
|
if (ns.contains(ek) || ek.contains(ns)) {
|
|
return _offsetByHouseNumber(entry.value[0], entry.value[1], houseNum);
|
|
}
|
|
}
|
|
return const LatLng(51.4243390, 4.3238380);
|
|
}
|
|
|
|
LatLng _offsetByHouseNumber(double baseLat, double baseLng, String houseNum) {
|
|
final hn = int.tryParse(houseNum.replaceAll(RegExp(r'[A-Za-z]'), '')) ?? 1;
|
|
return LatLng(
|
|
baseLat + (hn - 1) * 0.00015,
|
|
baseLng + ((hn / 25).floor() % 2 == 0 ? 0 : 0.0002),
|
|
);
|
|
}
|
|
|
|
String _normalize(String name) =>
|
|
name.replaceAll(RegExp(r'[–—]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Add Stops'),
|
|
actions: [
|
|
if (_saving)
|
|
const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: SizedBox(
|
|
width: 20, height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
),
|
|
)
|
|
else
|
|
IconButton(
|
|
icon: const Icon(Icons.check),
|
|
onPressed: _entries.isEmpty ? null : _saveAll,
|
|
tooltip: 'Save all stops',
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Input card
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: _streetCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Street name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
textCapitalization: TextCapitalization.words,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _numCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'House number',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onSubmitted: (_) => _addHouseNumber(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filled(
|
|
onPressed: _addHouseNumber,
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
],
|
|
),
|
|
if (_currentNums.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _currentNums.map(_buildHouseNumberChip).toList(),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _currentNums.isEmpty ? null : _saveStreet,
|
|
icon: const Icon(Icons.add_road),
|
|
label: const Text('Add Street'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Saved entries
|
|
if (_entries.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
const Text('Stops to add:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
const SizedBox(height: 8),
|
|
..._entries.map((entry) => Card(
|
|
child: ListTile(
|
|
leading: const CircleAvatar(child: Icon(Icons.location_on)),
|
|
title: Text(entry.street),
|
|
subtitle: Text(
|
|
entry.numPapers.entries.map((e) => '${e.key}: ${e.value.join(",")}').join('; '),
|
|
),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
|
onPressed: () => _removeEntry(entry),
|
|
),
|
|
),
|
|
)),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHouseNumberChip(String num) {
|
|
final current = _currentPapers[num] ?? {'BN'};
|
|
return Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[200],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(num, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
const SizedBox(width: 4),
|
|
GestureDetector(
|
|
onTap: () => setState(() {
|
|
_currentNums.remove(num);
|
|
_currentPapers.remove(num);
|
|
}),
|
|
child: const Icon(Icons.close, size: 16),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Wrap(
|
|
spacing: 4,
|
|
children: _availablePapers.map((np) => FilterChip(
|
|
label: Text(np, style: const TextStyle(fontSize: 11)),
|
|
selected: current.contains(np),
|
|
selectedColor: _npColors[np],
|
|
onSelected: (_) => _togglePaper(num, np),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
)).toList(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StreetEntry {
|
|
final String street;
|
|
final Map<String, Set<String>> numPapers;
|
|
_StreetEntry({required this.street, required this.numPapers});
|
|
}
|