Files
Holzleitner-Lieferservice-App/lib/feature/delivery/detail/presentation/steps/step_services.dart
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

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