Entfernt den Button und die komplette Funktionalität "Wunschziel für heute" (desired_zip): Seite, Netzwerk-Nachricht, Preference-Konstante, Übermittlung beim Session-Start sowie alle Übersetzungen. Ergänzt die neue BWV-Seite samt SetBwvLocationMessage und preferencesRemove-Helfer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
545 lines
17 KiB
Dart
545 lines
17 KiB
Dart
import 'package:dart_mq/dart_mq.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:stadtbote/content_views/utils/content_view_widget.dart';
|
|
import 'package:stadtbote/messages/pop_content_view_message.dart';
|
|
import 'package:stadtbote/network_messages/set_bwv_location_message.dart';
|
|
import 'package:stadtbote/tools/messages/publish_payload_message.dart';
|
|
|
|
import '../tools/app_localizations.dart';
|
|
import '../tools/constants.dart';
|
|
import '../tools/util.dart';
|
|
|
|
class BWVPage extends ContentViewWidget {
|
|
const BWVPage({super.key});
|
|
|
|
@override
|
|
State<BWVPage> createState() => _BWVPageState();
|
|
|
|
@override
|
|
Future<Widget> getHeaderWidget(BuildContext context) async {
|
|
await Future.delayed(const Duration(microseconds: 0));
|
|
|
|
return _HeaderWidget();
|
|
}
|
|
}
|
|
|
|
class _BWVPageState extends State<BWVPage> with ProducerMixin {
|
|
// Wann bin ich wo zum Start verfügbar (Uhrzeiten, Datum = heute)
|
|
TimeOfDay? _startFrom;
|
|
TimeOfDay? _startTo;
|
|
final TextEditingController _startFromController = TextEditingController();
|
|
final TextEditingController _startToController = TextEditingController();
|
|
final TextEditingController _startPlaceController = TextEditingController();
|
|
|
|
// Wo möchte ich mit dem nächsten BWV Auftrag enden oder beginnen (Datum + Uhrzeit)
|
|
DateTime? _nextFrom;
|
|
DateTime? _nextTo;
|
|
final TextEditingController _nextFromController = TextEditingController();
|
|
final TextEditingController _nextToController = TextEditingController();
|
|
final TextEditingController _nextPlaceController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadPersisted();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_startFromController.dispose();
|
|
_startToController.dispose();
|
|
_startPlaceController.dispose();
|
|
_nextFromController.dispose();
|
|
_nextToController.dispose();
|
|
_nextPlaceController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildCard(
|
|
title: 'bwv_start_availability'.tr,
|
|
onClear: _clearStartBlock,
|
|
children: [
|
|
_buildFieldRow(
|
|
label: 'bwv_from'.tr,
|
|
child: _buildPickerField(
|
|
_startFromController,
|
|
() => _pickTime(_startFromController, (t) => _startFrom = t),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildFieldRow(
|
|
label: 'bwv_to'.tr,
|
|
child: _buildPickerField(
|
|
_startToController,
|
|
() => _pickTime(_startToController, (t) => _startTo = t),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildFieldRow(
|
|
label: 'bwv_place'.tr,
|
|
child: _buildZipField(_startPlaceController),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
_buildCard(
|
|
title: 'bwv_next_job_location'.tr,
|
|
onClear: _clearNextBlock,
|
|
children: [
|
|
_buildFieldRow(
|
|
label: 'bwv_from'.tr,
|
|
child: _buildPickerField(
|
|
_nextFromController,
|
|
() => _pickDateTime(_nextFromController, (d) => _nextFrom = d),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildFieldRow(
|
|
label: 'bwv_to'.tr,
|
|
child: _buildPickerField(
|
|
_nextToController,
|
|
() => _pickDateTime(_nextToController, (d) => _nextTo = d),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildFieldRow(
|
|
label: 'bwv_place'.tr,
|
|
child: _buildZipField(_nextPlaceController),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
getDefaultButton('apply'.tr, buttonHeight: 50, () => _apply()),
|
|
const SizedBox(height: 24),
|
|
Image.asset(
|
|
'assets/sb_logo.png',
|
|
width: 80,
|
|
height: 80,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCard({
|
|
required String title,
|
|
required VoidCallback onClear,
|
|
required List<Widget> children,
|
|
}) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color.fromARGB(255, 245, 245, 245),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: const Border(
|
|
left: BorderSide(color: Constants.colorPrimary, width: 4),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style:
|
|
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'bwv_clear_block'.tr,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
icon: const Icon(Icons.backspace_outlined,
|
|
size: 20, color: Constants.colorPrimary),
|
|
onPressed: onClear,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
...children,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFieldRow({required String label, required Widget child}) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 50,
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(fontSize: 13, color: Colors.black54),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: child),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Lesefeld mit Picker (per onTap).
|
|
Widget _buildPickerField(TextEditingController controller, VoidCallback onTap) {
|
|
return TextField(
|
|
controller: controller,
|
|
readOnly: true,
|
|
onTap: onTap,
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
border: Util.getDefaultBorder(),
|
|
enabledBorder: Util.getDefaultBorder(),
|
|
focusedBorder: Util.getDefaultFocusedBorder(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildZipField(TextEditingController controller) {
|
|
return TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
LengthLimitingTextInputFormatter(5),
|
|
],
|
|
maxLength: 5,
|
|
decoration: InputDecoration(
|
|
counterText: '',
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
border: Util.getDefaultBorder(),
|
|
enabledBorder: Util.getDefaultBorder(),
|
|
focusedBorder: Util.getDefaultFocusedBorder(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickTime(
|
|
TextEditingController controller, void Function(TimeOfDay) onPicked) async {
|
|
final picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.now(),
|
|
builder: (context, child) => MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
|
|
child: child!,
|
|
),
|
|
);
|
|
|
|
if (picked != null) {
|
|
onPicked(picked);
|
|
controller.text = '${_two(picked.hour)}:${_two(picked.minute)}';
|
|
}
|
|
}
|
|
|
|
Future<void> _pickDateTime(
|
|
TextEditingController controller, void Function(DateTime) onPicked) async {
|
|
final now = DateTime.now();
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: now,
|
|
firstDate: now.subtract(const Duration(days: 1)),
|
|
lastDate: now.add(const Duration(days: 365)),
|
|
);
|
|
|
|
if (date == null || !mounted) return;
|
|
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.now(),
|
|
builder: (context, child) => MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
|
|
child: child!,
|
|
),
|
|
);
|
|
|
|
if (time == null) return;
|
|
|
|
final dt =
|
|
DateTime(date.year, date.month, date.day, time.hour, time.minute);
|
|
onPicked(dt);
|
|
controller.text =
|
|
'${_two(dt.day)}.${_two(dt.month)}.${dt.year} ${_two(dt.hour)}:${_two(dt.minute)}';
|
|
}
|
|
|
|
String _two(int value) => value.toString().padLeft(2, '0');
|
|
|
|
void _clearStartBlock() {
|
|
setState(() {
|
|
_startFrom = null;
|
|
_startTo = null;
|
|
_startFromController.clear();
|
|
_startToController.clear();
|
|
_startPlaceController.clear();
|
|
});
|
|
}
|
|
|
|
void _clearNextBlock() {
|
|
setState(() {
|
|
_nextFrom = null;
|
|
_nextTo = null;
|
|
_nextFromController.clear();
|
|
_nextToController.clear();
|
|
_nextPlaceController.clear();
|
|
});
|
|
}
|
|
|
|
/// Anzahl der gefüllten Felder eines Blocks (für die "Alles oder nichts"-Regel).
|
|
int _filledCount(List<bool> filled) => filled.where((f) => f).length;
|
|
|
|
void _apply() {
|
|
// Block "Verfügbarkeit": entweder alle Felder gefüllt oder alle leer.
|
|
final startFilled = [
|
|
_startFrom != null,
|
|
_startTo != null,
|
|
_startPlaceController.text.isNotEmpty,
|
|
];
|
|
final startCount = _filledCount(startFilled);
|
|
if (startCount != 0 && startCount != startFilled.length) {
|
|
showAlertDialog('error'.tr, 'bwv_incomplete_block'.tr);
|
|
return;
|
|
}
|
|
|
|
// Block "nächster Auftrag": entweder alle Felder gefüllt oder alle leer.
|
|
final nextFilled = [
|
|
_nextFrom != null,
|
|
_nextTo != null,
|
|
_nextPlaceController.text.isNotEmpty,
|
|
];
|
|
final nextCount = _filledCount(nextFilled);
|
|
if (nextCount != 0 && nextCount != nextFilled.length) {
|
|
showAlertDialog('error'.tr, 'bwv_incomplete_block'.tr);
|
|
return;
|
|
}
|
|
|
|
// PLZ-Validierung (fünfstellig, sofern gefüllt).
|
|
for (final zip in [_startPlaceController.text, _nextPlaceController.text]) {
|
|
if (zip.isNotEmpty && zip.length != 5) {
|
|
showAlertDialog('error'.tr, 'zip_validation_error'.tr);
|
|
return;
|
|
}
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
|
|
// Verfügbarkeit: Uhrzeiten beziehen sich auf das heutige Datum.
|
|
BwvTimeWindow? availabilityStart;
|
|
if (startCount == startFilled.length) {
|
|
final from = DateTime(
|
|
now.year, now.month, now.day, _startFrom!.hour, _startFrom!.minute);
|
|
final to = DateTime(
|
|
now.year, now.month, now.day, _startTo!.hour, _startTo!.minute);
|
|
// Limit nur im logischen Sinne: von < bis.
|
|
if (!from.isBefore(to)) {
|
|
showAlertDialog('error'.tr, 'bwv_invalid_range'.tr);
|
|
return;
|
|
}
|
|
availabilityStart = BwvTimeWindow(
|
|
fromUtc: Util.toUnixSecondsUtc(from),
|
|
toUtc: Util.toUnixSecondsUtc(to),
|
|
postalCode: _startPlaceController.text,
|
|
);
|
|
}
|
|
|
|
BwvTimeWindow? bwvLocationPreference;
|
|
if (nextCount == nextFilled.length) {
|
|
// Limit nur im logischen Sinne: von < bis.
|
|
if (!_nextFrom!.isBefore(_nextTo!)) {
|
|
showAlertDialog('error'.tr, 'bwv_invalid_range'.tr);
|
|
return;
|
|
}
|
|
bwvLocationPreference = BwvTimeWindow(
|
|
fromUtc: Util.toUnixSecondsUtc(_nextFrom!),
|
|
toUtc: Util.toUnixSecondsUtc(_nextTo!),
|
|
postalCode: _nextPlaceController.text,
|
|
);
|
|
}
|
|
|
|
_persist();
|
|
|
|
final message = SetBwvLocationMessage(
|
|
timestamp: Util.nowUnixSecondsUtc(),
|
|
availabilityStart: availabilityStart,
|
|
bwvLocationPreference: bwvLocationPreference,
|
|
);
|
|
|
|
sendMessage(
|
|
routingKey: Constants.messageTopicPublishPayload,
|
|
payload: PublishPayloadMessage(
|
|
topic: "set_bwv_location",
|
|
message: message.toJson(),
|
|
acknowledgeRequired: false,
|
|
),
|
|
);
|
|
|
|
sendMessage(
|
|
routingKey: Constants.messageTopicPopContentView,
|
|
payload: PopContentViewMessage(count: 1),
|
|
);
|
|
}
|
|
|
|
/// Lädt die zuletzt gespeicherten Werte (App ist datenführend).
|
|
/// Ist der Zeitraum des unteren Blocks abgelaufen, werden dessen Felder genullt.
|
|
void _loadPersisted() {
|
|
final startFrom =
|
|
Util.preferencesGetString(Constants.prefsBwvStartFrom, null);
|
|
final startTo = Util.preferencesGetString(Constants.prefsBwvStartTo, null);
|
|
final startPlace =
|
|
Util.preferencesGetString(Constants.prefsBwvStartPlace, null);
|
|
if (startFrom != null && startTo != null) {
|
|
_startFrom = _parseTime(startFrom);
|
|
_startTo = _parseTime(startTo);
|
|
_startFromController.text = startFrom;
|
|
_startToController.text = startTo;
|
|
_startPlaceController.text = startPlace ?? '';
|
|
}
|
|
|
|
final nextFromMillis =
|
|
Util.preferencesGetInt(Constants.prefsBwvNextFrom, null);
|
|
final nextToMillis = Util.preferencesGetInt(Constants.prefsBwvNextTo, null);
|
|
final nextPlace =
|
|
Util.preferencesGetString(Constants.prefsBwvNextPlace, null);
|
|
if (nextFromMillis != null && nextToMillis != null) {
|
|
final from = DateTime.fromMillisecondsSinceEpoch(nextFromMillis);
|
|
final to = DateTime.fromMillisecondsSinceEpoch(nextToMillis);
|
|
|
|
if (to.isBefore(DateTime.now())) {
|
|
// Zeitraum abgelaufen -> Felder nullen und Persistenz bereinigen.
|
|
_clearPersistedNextBlock();
|
|
} else {
|
|
_nextFrom = from;
|
|
_nextTo = to;
|
|
_nextFromController.text =
|
|
'${_two(from.day)}.${_two(from.month)}.${from.year} ${_two(from.hour)}:${_two(from.minute)}';
|
|
_nextToController.text =
|
|
'${_two(to.day)}.${_two(to.month)}.${to.year} ${_two(to.hour)}:${_two(to.minute)}';
|
|
_nextPlaceController.text = nextPlace ?? '';
|
|
}
|
|
}
|
|
}
|
|
|
|
TimeOfDay? _parseTime(String value) {
|
|
final parts = value.split(':');
|
|
if (parts.length != 2) return null;
|
|
final hour = int.tryParse(parts[0]);
|
|
final minute = int.tryParse(parts[1]);
|
|
if (hour == null || minute == null) return null;
|
|
return TimeOfDay(hour: hour, minute: minute);
|
|
}
|
|
|
|
void _persist() {
|
|
// Block "Verfügbarkeit"
|
|
if (_startFrom != null && _startTo != null) {
|
|
Util.preferencesSaveString(Constants.prefsBwvStartFrom,
|
|
'${_two(_startFrom!.hour)}:${_two(_startFrom!.minute)}');
|
|
Util.preferencesSaveString(Constants.prefsBwvStartTo,
|
|
'${_two(_startTo!.hour)}:${_two(_startTo!.minute)}');
|
|
Util.preferencesSaveString(
|
|
Constants.prefsBwvStartPlace, _startPlaceController.text);
|
|
} else {
|
|
Util.preferencesRemove(Constants.prefsBwvStartFrom);
|
|
Util.preferencesRemove(Constants.prefsBwvStartTo);
|
|
Util.preferencesRemove(Constants.prefsBwvStartPlace);
|
|
}
|
|
|
|
// Block "nächster Auftrag"
|
|
if (_nextFrom != null && _nextTo != null) {
|
|
Util.preferencesSaveInt(
|
|
Constants.prefsBwvNextFrom, _nextFrom!.millisecondsSinceEpoch);
|
|
Util.preferencesSaveInt(
|
|
Constants.prefsBwvNextTo, _nextTo!.millisecondsSinceEpoch);
|
|
Util.preferencesSaveString(
|
|
Constants.prefsBwvNextPlace, _nextPlaceController.text);
|
|
} else {
|
|
_clearPersistedNextBlock();
|
|
}
|
|
}
|
|
|
|
void _clearPersistedNextBlock() {
|
|
Util.preferencesRemove(Constants.prefsBwvNextFrom);
|
|
Util.preferencesRemove(Constants.prefsBwvNextTo);
|
|
Util.preferencesRemove(Constants.prefsBwvNextPlace);
|
|
}
|
|
}
|
|
|
|
class _HeaderWidget extends StatefulWidget {
|
|
@override
|
|
State<_HeaderWidget> createState() => _HeaderWidgetState();
|
|
}
|
|
|
|
class _HeaderWidgetState extends State<_HeaderWidget> with ProducerMixin {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var cellWidth = (MediaQuery.of(context).size.width - 60 - 60 - 25) / 2;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Container(
|
|
width: 60,
|
|
height: 60,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white,
|
|
),
|
|
child: FittedBox(
|
|
child: GestureDetector(
|
|
onTap: () async {
|
|
sendMessage(
|
|
routingKey: Constants.messageTopicPopContentView,
|
|
payload: PopContentViewMessage(count: 1),
|
|
);
|
|
},
|
|
child: const CircleAvatar(
|
|
radius: 10,
|
|
backgroundColor: Constants.colorPrimary,
|
|
child: Padding(
|
|
padding: EdgeInsets.all(1.0),
|
|
child: Icon(Icons.arrow_back,
|
|
size: 20.0, color: Colors.white)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 60),
|
|
],
|
|
),
|
|
SizedBox(
|
|
height: 100,
|
|
width: cellWidth * 2,
|
|
child: Center(
|
|
child: Text(
|
|
textAlign: TextAlign.center,
|
|
'bwv'.tr,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|