Final commit.
This commit is contained in:
@ -0,0 +1,324 @@
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user