Files
SvenandClaude Opus 4.8 e36fc69edc Feature "Wunschziel für heute" entfernen und BWV-Seite ergänzen
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>
2026-06-24 08:11:54 +02:00

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),
),
),
),
],
),
);
}
}