226 lines
7.3 KiB
Dart
226 lines
7.3 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|