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>
This commit is contained in:
1 parent
10b3a647e7
commit
e36fc69edc
12 files changed
+626
-278
No files matched your search
@@ -0,0 +1,544 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user