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,225 @@
import 'package:flutter/material.dart';
/// Ergebnis des Reason-Pickers: getrimmter Grund + optionale Menge.
class ReasonPickerResult {
const ReasonPickerResult({required this.reason, this.quantity});
final String reason;
/// Gewählte Menge (1…maxQuantity). `null`, wenn das Sheet **ohne**
/// Mengen-Auswahl geöffnet wurde (`maxQuantity == null`, z. B. bei
/// Lieferungs-Gründen wie Pausieren/Abbrechen) — dann gilt „ganze Zeile".
final int? quantity;
}
/// Bottom-Sheet zur Auswahl einer Begründung. Zeigt eine Radio-Liste der
/// vordefinierten Gründe + den Sonderfall „Anderer Grund", der ein
/// Freitext-Feld einblendet.
///
/// Optional — wenn [maxQuantity] gesetzt und > 1 — zusätzlich eine
/// **Mengen-Auswahl** (Stepper 1…maxQuantity, Vorbelegung = maxQuantity) für
/// die Teilmengen-Löschung/-Gutschrift.
///
/// Rückgabe: [ReasonPickerResult] oder `null` bei Abbruch.
///
/// Validierung:
/// * Vordefinierter Grund → direkt OK
/// * „Anderer Grund" mit leerem Freitext → Bestätigen disabled
Future<ReasonPickerResult?> showReasonPickerSheet({
required BuildContext context,
required String title,
required List<String> presets,
String confirmLabel = 'Übernehmen',
int? maxQuantity,
}) {
return showModalBottomSheet<ReasonPickerResult>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => _ReasonPickerSheet(
title: title,
presets: presets,
confirmLabel: confirmLabel,
maxQuantity: maxQuantity,
),
);
}
class _ReasonPickerSheet extends StatefulWidget {
const _ReasonPickerSheet({
required this.title,
required this.presets,
required this.confirmLabel,
required this.maxQuantity,
});
final String title;
final List<String> presets;
final String confirmLabel;
final int? maxQuantity;
@override
State<_ReasonPickerSheet> createState() => _ReasonPickerSheetState();
}
/// Sentinel-Wert für die „Anderer Grund"-Option. Eigene Klasse, damit der
/// Vergleich nicht mit echten Reason-Strings kollidieren kann (z. B.
/// jemand definiert tatsächlich „Anderer Grund" als Standard-Reason).
const String _otherSentinel = ' other';
class _ReasonPickerSheetState extends State<_ReasonPickerSheet> {
String? _selected;
final _customController = TextEditingController();
final _customFocus = FocusNode();
late int _qty;
@override
void initState() {
super.initState();
// Vorbelegung: ganze Restmenge.
_qty = widget.maxQuantity ?? 1;
}
@override
void dispose() {
_customController.dispose();
_customFocus.dispose();
super.dispose();
}
bool get _isOther => _selected == _otherSentinel;
/// Mengen-Stepper nur zeigen, wenn überhaupt eine Auswahl Sinn ergibt.
bool get _showQuantity => (widget.maxQuantity ?? 0) > 1;
String? get _effectiveReason {
if (_selected == null) return null;
if (_isOther) {
final v = _customController.text.trim();
return v.isEmpty ? null : v;
}
return _selected;
}
void _onConfirm() {
final reason = _effectiveReason;
if (reason == null) return;
Navigator.of(context).pop(
ReasonPickerResult(
reason: reason,
// Ohne Mengen-Kontext (maxQuantity == null) → null = ganze Zeile.
quantity: widget.maxQuantity == null ? null : _qty,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 8,
// Tastatur-Höhe rein-clipping, damit das Freitext-Feld sichtbar bleibt.
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
// Scrollbar, damit Mengen-Stepper + Gründe + Freitext bei wenig Platz
// (offene Tastatur) nicht overflowen.
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (_showQuantity) ...[
Text(
'Wie viele entfernen? (1${widget.maxQuantity})',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 4),
Row(
children: [
IconButton.filled(
onPressed:
_qty > 1 ? () => setState(() => _qty -= 1) : null,
icon: const Icon(Icons.remove),
),
Expanded(
child: Text(
'$_qty',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
IconButton.filled(
onPressed: _qty < widget.maxQuantity!
? () => setState(() => _qty += 1)
: null,
icon: const Icon(Icons.add),
),
],
),
const Divider(height: 24),
],
for (final preset in widget.presets)
RadioListTile<String>(
value: preset,
groupValue: _selected,
onChanged: (v) => setState(() => _selected = v),
title: Text(preset),
contentPadding: EdgeInsets.zero,
dense: true,
),
RadioListTile<String>(
value: _otherSentinel,
groupValue: _selected,
onChanged: (v) {
setState(() => _selected = v);
WidgetsBinding.instance.addPostFrameCallback((_) {
_customFocus.requestFocus();
});
},
title: const Text('Anderer Grund'),
contentPadding: EdgeInsets.zero,
dense: true,
),
if (_isOther)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: TextField(
controller: _customController,
focusNode: _customFocus,
autocorrect: false,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Begründung',
),
onChanged: (_) => setState(() {}),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _onConfirm(),
),
),
const SizedBox(height: 8),
FilledButton(
onPressed: _effectiveReason == null ? null : _onConfirm,
child: Text(widget.confirmLabel),
),
],
),
),
),
);
}
}