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