Files
Dennis Nemec a9bf8ecdd1 Final commit.
2026-06-01 17:12:28 +02:00

226 lines
7.3 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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