Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -0,0 +1,355 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/feature_flags.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';
/// Gutschriften-Editor: ±10 €, max 150 €, Begründung Pflicht.
///
/// Backend-gestützt: „Speichern" feuert `SetDeliveryCredit`, „Entfernen"
/// `RemoveDeliveryCredit` am `TourBloc` → `POST /deliveries/{id}/credit`
/// (append-only, idempotent). Der aktuelle Stand kommt aus dem Tour-Aggregat
/// (`TourDetails.creditOf`).
class DiscountEditor extends StatefulWidget {
const DiscountEditor({
super.key,
required this.deliveryId,
required this.active,
});
final String deliveryId;
/// Nur bei aktiver Lieferung darf die Gutschrift geändert werden. Bei
/// abgeschlossener/abgebrochener/pausierter Lieferung bleibt der Editor
/// sichtbar, aber gesperrt (reine Anzeige des gespeicherten Stands).
final bool active;
@override
State<DiscountEditor> createState() => _DiscountEditorState();
}
class _DiscountEditorState extends State<DiscountEditor> {
static const int step = 10; // €-Schrittweite (nur die Stepper-Variante)
static const int max = 150; // € Obergrenze
static const int maxCents = max * 100;
/// Betrag in Cent — erlaubt Dezimalbeträge (z. B. 19,99 € = 1999).
int _amountCents = 0;
late final TextEditingController _reasonController;
late final TextEditingController _amountController;
@override
void initState() {
super.initState();
_reasonController = TextEditingController();
// Einmalige Übernahme des aktuellen Server-Stands aus dem Bloc — VOR dem
// Anhängen des Listeners, damit das Setzen des Textes kein `setState`
// (über den Listener) während des ersten Builds auslöst.
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
final current = state.details.creditOf(widget.deliveryId);
if (current != null) {
_amountCents = current.amountCents;
_reasonController.text = current.reason;
}
}
// Freitext-Betragsfeld (Dezimal, € mit Cent): vorbelegt; Listener parst die
// Eingabe in `_amountCents`. Erst NACH dem Vorbelegen anhängen.
_amountController = TextEditingController(text: _formatCents(_amountCents));
_amountController.addListener(() {
setState(() => _amountCents = _parseCents(_amountController.text) ?? 0);
});
_reasonController.addListener(() => setState(() {}));
}
@override
void dispose() {
_reasonController.dispose();
_amountController.dispose();
super.dispose();
}
/// "19,99" / "19.99" / "20" → Cent. `null` bei leer/ungültig.
static int? _parseCents(String raw) {
final t = raw.trim().replaceAll(',', '.');
if (t.isEmpty) return null;
final euros = double.tryParse(t);
if (euros == null) return null;
return (euros * 100).round();
}
/// Cent → Anzeige-String in € (mit Komma). 0 → leer.
static String _formatCents(int cents) {
if (cents <= 0) return '';
if (cents % 100 == 0) return '${cents ~/ 100}';
return (cents / 100).toStringAsFixed(2).replaceAll('.', ',');
}
bool get _canDecrement => widget.active && _amountCents > 0;
bool get _canIncrement =>
widget.active && _amountCents + step * 100 <= maxCents;
bool get _isReasonValid => _reasonController.text.trim().isNotEmpty;
/// Backend-Regel: >0, ≤150 €. (Beliebige Beträge inkl. Cent.)
bool get _amountValid => _amountCents > 0 && _amountCents <= maxCents;
bool get _canSave => widget.active && _amountValid && _isReasonValid;
// Stepper-Variante (Feature-Flag): bewegt sich in 10-€-Schritten. Das
// Textfeld ist dann nicht sichtbar, daher kein Controller-Sync nötig.
void _decrement() {
if (!_canDecrement) return;
setState(() => _amountCents = (_amountCents - step * 100).clamp(0, maxCents));
}
void _increment() {
if (!_canIncrement) return;
setState(() => _amountCents = (_amountCents + step * 100).clamp(0, maxCents));
}
String _actorCarId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
if (state is CarSelectComplete) return state.selectedCar.id;
return '00000000-0000-0000-0000-000000000000';
}
void _save() {
context.read<TourBloc>().add(SetDeliveryCredit(
deliveryId: widget.deliveryId,
amountCents: _amountCents,
reason: _reasonController.text.trim(),
actorCarId: _actorCarId(context),
));
}
void _remove() {
context.read<TourBloc>().add(RemoveDeliveryCredit(
deliveryId: widget.deliveryId,
actorCarId: _actorCarId(context),
));
setState(() {
_amountCents = 0;
_reasonController.clear();
_amountController.clear();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<TourBloc, TourState>(
buildWhen: (a, b) {
if (a is! TourLoaded || b is! TourLoaded) return true;
return a.details.creditOf(widget.deliveryId) !=
b.details.creditOf(widget.deliveryId);
},
builder: (context, state) {
final current = state is TourLoaded
? state.details.creditOf(widget.deliveryId)
: null;
final isSaved = current != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Betrag',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
// Default: freies Betrags-Textfeld. Hinter dem Feature-Flag
// `discountAmountStepper` liegt die ursprüngliche +/-Variante.
if (FeatureFlags.discountAmountStepper)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filled(
onPressed: _canDecrement ? _decrement : null,
icon: const Icon(Icons.remove),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
_canDecrement ? Colors.red.shade400 : Colors.grey,
),
),
),
const SizedBox(width: 16),
Column(
children: [
Text(
'${_amountCents ~/ 100}',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
Text(
'max. $max € · Schritt $step',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(width: 16),
IconButton.filled(
onPressed: _canIncrement ? _increment : null,
icon: const Icon(Icons.add),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
_canIncrement ? Colors.green.shade600 : Colors.grey,
),
),
),
],
)
else
TextField(
controller: _amountController,
enabled: widget.active,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
prefixText: '',
hintText: '0,00',
helperText: 'max. $max € · Cent erlaubt (z. B. 19,99)',
// Fehlertext nur bei nicht-leerer, ungültiger Eingabe.
errorText: (widget.active &&
_amountController.text.trim().isNotEmpty &&
!_amountValid)
? 'Betrag muss > 0 und ≤ $max € sein'
: null,
),
),
const SizedBox(height: 16),
Text(
'Begründung (Pflicht)',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
TextField(
controller: _reasonController,
enabled: widget.active,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'z. B. Transportschaden, Verzögerung …',
),
),
const SizedBox(height: 12),
if (!widget.active) ...[
_LockedHint(
text: isSaved
? 'Lieferung abgeschlossen — Gutschrift nicht mehr änderbar.'
: 'Gutschrift nur bei aktiver Lieferung änderbar.',
),
const SizedBox(height: 12),
],
if (isSaved && widget.active) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
size: 14,
color: Colors.green.shade700,
),
const SizedBox(width: 4),
Text(
'Gespeichert',
style: TextStyle(
fontSize: 12,
color: Colors.green.shade800,
),
),
],
),
),
const SizedBox(height: 8),
],
// Buttons in einem Wrap: brechen auf schmalen Cards um, statt
// (wie zuvor in einer Row mit Spacer) rechts überzulaufen. Volle
// Breite, damit WrapAlignment.end rechtsbündig wirkt.
SizedBox(
width: double.infinity,
child: Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
if (isSaved)
TextButton.icon(
onPressed: widget.active ? _remove : null,
icon: const Icon(Icons.delete_outline),
label: const Text('Entfernen'),
),
FilledButton.icon(
onPressed: _canSave ? _save : null,
icon: const Icon(Icons.save),
label: Text(isSaved ? 'Aktualisieren' : 'Speichern'),
),
],
),
),
],
);
},
);
}
}
/// Kleiner Hinweis-Balken, wenn eine Aktion gesperrt ist (Lieferung nicht
/// aktiv). Bewusst dezent — der Editor bleibt als Anzeige sichtbar.
class _LockedHint extends StatelessWidget {
const _LockedHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.lock_outline,
size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}