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 createState() => _BWVPageState(); @override Future getHeaderWidget(BuildContext context) async { await Future.delayed(const Duration(microseconds: 0)); return _HeaderWidget(); } } class _BWVPageState extends State 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 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 _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 _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 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), ), ), ), ], ), ); } }