325 lines
11 KiB
Dart
325 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
|
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
|
import 'package:hl_lieferservice/domain/entity/service.dart';
|
|
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
|
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
|
|
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
|
|
|
/// Step 4 — Services (früher „Lieferoptionen").
|
|
///
|
|
/// Rendert die aktiven Service-Definitionen (`TourDetails.services`,
|
|
/// admin-konfigurierbar) und lässt den Fahrer sie pro Lieferung auswählen:
|
|
/// `boolean` → Checkbox, `numeric` → Zahlenfeld mit min/max. Werte landen
|
|
/// über den `TourBloc` (`SetDeliveryServiceValue`/`RemoveDeliveryServiceValue`)
|
|
/// im Backend. Setzen nur bei aktiver Lieferung.
|
|
class StepServices extends StatelessWidget {
|
|
const StepServices({super.key, required this.delivery, required this.details});
|
|
|
|
final Delivery delivery;
|
|
final TourDetails details;
|
|
|
|
String _actorCarId(BuildContext context) {
|
|
final state = context.read<CarSelectBloc>().state;
|
|
if (state is CarSelectComplete) return state.selectedCar.id;
|
|
return '00000000-0000-0000-0000-000000000000';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final active = delivery.state == DeliveryState.active;
|
|
|
|
return BlocBuilder<TourBloc, TourState>(
|
|
buildWhen: (a, b) {
|
|
if (a is! TourLoaded || b is! TourLoaded) return true;
|
|
return a.details.services != b.details.services ||
|
|
a.details.serviceValuesByDeliveryId[delivery.id] !=
|
|
b.details.serviceValuesByDeliveryId[delivery.id];
|
|
},
|
|
builder: (context, state) {
|
|
final d = state is TourLoaded ? state.details : details;
|
|
final services = d.services;
|
|
|
|
if (services.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.construction_outlined,
|
|
size: 56, color: theme.colorScheme.onSurfaceVariant),
|
|
const SizedBox(height: 12),
|
|
Text('Keine Services konfiguriert',
|
|
style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Ein Administrator kann Services anlegen.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Wie im alten `delivery_options.dart`: zwei Kategorien —
|
|
// „Auswählbare Optionen" (Checkboxen) und „Zahlenwerte".
|
|
final bools =
|
|
services.where((s) => s.kind == ServiceKind.boolean).toList();
|
|
final numerics =
|
|
services.where((s) => s.kind == ServiceKind.numeric).toList();
|
|
|
|
_ServiceTile tileFor(Service service) => _ServiceTile(
|
|
service: service,
|
|
value: d.serviceValueOf(delivery.id, service.id),
|
|
enabled: active,
|
|
onSetBool: (v) => context.read<TourBloc>().add(
|
|
SetDeliveryServiceValue(
|
|
deliveryId: delivery.id,
|
|
serviceId: service.id,
|
|
boolValue: v,
|
|
actorCarId: _actorCarId(context),
|
|
),
|
|
),
|
|
onSetNumeric: (n) => context.read<TourBloc>().add(
|
|
SetDeliveryServiceValue(
|
|
deliveryId: delivery.id,
|
|
serviceId: service.id,
|
|
numericValue: n,
|
|
actorCarId: _actorCarId(context),
|
|
),
|
|
),
|
|
onClear: () => context.read<TourBloc>().add(
|
|
RemoveDeliveryServiceValue(
|
|
deliveryId: delivery.id,
|
|
serviceId: service.id,
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget sectionCard(List<Service> items) => Card(
|
|
margin: EdgeInsets.zero,
|
|
child: Column(
|
|
children: [
|
|
for (int i = 0; i < items.length; i++) ...[
|
|
tileFor(items[i]),
|
|
if (i < items.length - 1)
|
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
children: [
|
|
if (!active)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.lock_outline,
|
|
size: 16, color: theme.colorScheme.onSurfaceVariant),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Nur bei aktiver Lieferung änderbar.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (bools.isNotEmpty) ...[
|
|
const _CategoryHeader(
|
|
icon: Icons.check_box_outlined,
|
|
text: 'Auswählbare Optionen',
|
|
),
|
|
const SizedBox(height: 8),
|
|
sectionCard(bools),
|
|
],
|
|
if (bools.isNotEmpty && numerics.isNotEmpty)
|
|
const SizedBox(height: 24),
|
|
if (numerics.isNotEmpty) ...[
|
|
const _CategoryHeader(
|
|
icon: Icons.pin_outlined,
|
|
text: 'Zahlenwerte',
|
|
),
|
|
const SizedBox(height: 8),
|
|
sectionCard(numerics),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Kategorie-Überschrift (Icon + Titel) — trennt Checkboxen von Zahlenwerten.
|
|
class _CategoryHeader extends StatelessWidget {
|
|
const _CategoryHeader({required this.icon, required this.text});
|
|
final IconData icon;
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Row(
|
|
children: [
|
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
text,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Eine Service-Zeile — Checkbox (boolean) oder Zahlenfeld (numeric).
|
|
class _ServiceTile extends StatelessWidget {
|
|
const _ServiceTile({
|
|
required this.service,
|
|
required this.value,
|
|
required this.enabled,
|
|
required this.onSetBool,
|
|
required this.onSetNumeric,
|
|
required this.onClear,
|
|
});
|
|
|
|
final Service service;
|
|
final DeliveryServiceValue? value;
|
|
final bool enabled;
|
|
final ValueChanged<bool> onSetBool;
|
|
final ValueChanged<int> onSetNumeric;
|
|
final VoidCallback onClear;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
switch (service.kind) {
|
|
case ServiceKind.boolean:
|
|
return CheckboxListTile(
|
|
value: value?.boolValue ?? false,
|
|
onChanged: enabled ? (v) => onSetBool(v ?? false) : null,
|
|
title: Text(service.name),
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
dense: true,
|
|
);
|
|
case ServiceKind.numeric:
|
|
return _NumericServiceField(
|
|
key: ValueKey(service.id),
|
|
service: service,
|
|
initial: value?.numericValue,
|
|
enabled: enabled,
|
|
onSetNumeric: onSetNumeric,
|
|
onClear: onClear,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Zahlenfeld eines numerischen Service — eigener Controller, persistiert beim
|
|
/// Verlassen/Submit, klemmt auf [min,max]. Leeres Feld → Wert entfernen.
|
|
class _NumericServiceField extends StatefulWidget {
|
|
const _NumericServiceField({
|
|
super.key,
|
|
required this.service,
|
|
required this.initial,
|
|
required this.enabled,
|
|
required this.onSetNumeric,
|
|
required this.onClear,
|
|
});
|
|
|
|
final Service service;
|
|
final int? initial;
|
|
final bool enabled;
|
|
final ValueChanged<int> onSetNumeric;
|
|
final VoidCallback onClear;
|
|
|
|
@override
|
|
State<_NumericServiceField> createState() => _NumericServiceFieldState();
|
|
}
|
|
|
|
class _NumericServiceFieldState extends State<_NumericServiceField> {
|
|
late final TextEditingController _controller =
|
|
TextEditingController(text: widget.initial?.toString() ?? '');
|
|
|
|
@override
|
|
void didUpdateWidget(_NumericServiceField old) {
|
|
super.didUpdateWidget(old);
|
|
// Server-Stand übernehmen, wenn er sich geändert hat (z. B. nach
|
|
// Reconcile) und sich vom angezeigten Text unterscheidet.
|
|
final incoming = widget.initial?.toString() ?? '';
|
|
if (old.initial != widget.initial && _controller.text != incoming) {
|
|
_controller.text = incoming;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _commit() {
|
|
final raw = _controller.text.trim();
|
|
if (raw.isEmpty) {
|
|
widget.onClear();
|
|
return;
|
|
}
|
|
final parsed = int.tryParse(raw);
|
|
if (parsed == null) {
|
|
_controller.text = widget.initial?.toString() ?? '';
|
|
return;
|
|
}
|
|
var n = parsed;
|
|
final min = widget.service.minValue;
|
|
final max = widget.service.maxValue;
|
|
if (min != null && n < min) n = min;
|
|
if (max != null && n > max) n = max;
|
|
if (n.toString() != _controller.text) {
|
|
_controller.text = n.toString();
|
|
}
|
|
widget.onSetNumeric(n);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = widget.service;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: TextField(
|
|
controller: _controller,
|
|
enabled: widget.enabled,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
textInputAction: TextInputAction.done,
|
|
decoration: InputDecoration(
|
|
labelText: s.name,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
onTapOutside: (_) {
|
|
FocusScope.of(context).unfocus();
|
|
_commit();
|
|
},
|
|
onSubmitted: (_) => _commit(),
|
|
),
|
|
);
|
|
}
|
|
}
|