Final commit.
This commit is contained in:
@ -1,34 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list_item.dart';
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
|
||||
class ArticleList extends StatefulWidget {
|
||||
const ArticleList({
|
||||
super.key,
|
||||
required this.articles,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
final List<Article> articles;
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ArticleListState();
|
||||
}
|
||||
|
||||
class _ArticleListState extends State<ArticleList> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder:
|
||||
(context, index) => ArticleListItem(
|
||||
article: widget.articles[index],
|
||||
deliveryId: widget.deliveryId,
|
||||
),
|
||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
||||
itemCount: widget.articles.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_reset_scan_dialog.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_unscan_dialog.dart';
|
||||
import 'package:hl_lieferservice/model/article.dart';
|
||||
|
||||
class ArticleListItem extends StatefulWidget {
|
||||
const ArticleListItem({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
final Article article;
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ArticleListItem();
|
||||
}
|
||||
|
||||
class _ArticleListItem extends State<ArticleListItem> {
|
||||
Widget _leading() {
|
||||
int amount = widget.article.getScannedAmount();
|
||||
Color? color;
|
||||
Color? textColor;
|
||||
|
||||
if (!widget.article.scannable) {
|
||||
amount = widget.article.amount;
|
||||
}
|
||||
|
||||
if (amount == 0) {
|
||||
color = Colors.redAccent;
|
||||
textColor = Theme.of(context).colorScheme.onSecondary;
|
||||
}
|
||||
|
||||
return CircleAvatar(
|
||||
backgroundColor: color,
|
||||
child: Text("${amount}x", style: TextStyle(color: textColor)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget actionButton = IconButton.outlined(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Colors.redAccent),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => ArticleUnscanDialog(
|
||||
article: widget.article,
|
||||
deliveryId: widget.deliveryId,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
);
|
||||
|
||||
if ((widget.article.unscanned() && widget.article.scannable) ||
|
||||
!widget.article.scannable && widget.article.amount == 0) {
|
||||
actionButton = IconButton.outlined(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(Colors.blueAccent),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => ResetArticleAmountDialog(
|
||||
article: widget.article,
|
||||
deliveryId: widget.deliveryId,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(0),
|
||||
child: ListTile(
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
title: Text(widget.article.name),
|
||||
leading: _leading(),
|
||||
subtitle: Text("Artikelnr. ${widget.article.articleNumber}"),
|
||||
trailing: actionButton,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
|
||||
import '../../../../../model/article.dart';
|
||||
|
||||
class ResetArticleAmountDialog extends StatefulWidget {
|
||||
const ResetArticleAmountDialog({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
final Article article;
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ResetArticleAmountDialogState();
|
||||
}
|
||||
|
||||
class _ResetArticleAmountDialogState extends State<ResetArticleAmountDialog> {
|
||||
int _selectedAmount = 1;
|
||||
|
||||
void _reset() {
|
||||
String deliveryId = widget.deliveryId;
|
||||
String articleId = widget.article.internalId.toString();
|
||||
|
||||
if (widget.article.scannable) {
|
||||
context.read<TourBloc>().add(
|
||||
ResetScanAmountEvent(
|
||||
articleId: widget.article.internalId.toString(),
|
||||
deliveryId: widget.deliveryId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint("ID: $articleId");
|
||||
debugPrint("AMOUNT :$_selectedAmount");
|
||||
|
||||
context.read<TourBloc>().add(
|
||||
SetArticleAmountEvent(
|
||||
deliveryId: deliveryId,
|
||||
articleId: articleId,
|
||||
amount: _selectedAmount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Widget _amountSelection() {
|
||||
final list = List.generate(3, (index) => index + 1);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Anzahl:", style: Theme.of(context).textTheme.labelLarge),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children:
|
||||
list
|
||||
.map(
|
||||
(index) => ChoiceChip(
|
||||
label: Text("$index"),
|
||||
selected: _selectedAmount == index,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_selectedAmount = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text("Anzahl Artikel zurücksetzen?"),
|
||||
content: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.25,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("Wollen Sie die entfernten Artikel wieder hinzufügen?"),
|
||||
!widget.article.scannable ? _amountSelection() : Container(),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.spaceEvenly,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: _reset,
|
||||
child:
|
||||
widget.article.scannable
|
||||
? const Text("Zurücksetzen")
|
||||
: const Text("Hinzufügen"),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,179 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
|
||||
import '../../../../../model/article.dart';
|
||||
|
||||
class ArticleUnscanDialog extends StatefulWidget {
|
||||
const ArticleUnscanDialog({
|
||||
super.key,
|
||||
required this.article,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
final String deliveryId;
|
||||
final Article article;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ArticleUnscanDialogState();
|
||||
}
|
||||
|
||||
class _ArticleUnscanDialogState extends State<ArticleUnscanDialog> {
|
||||
late TextEditingController unscanAmountController;
|
||||
late TextEditingController unscanNoteController;
|
||||
bool isValidText = false;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
void _unscan() {
|
||||
int amountToBeDeleted = int.parse(unscanAmountController.text);
|
||||
String deliveryId = widget.deliveryId;
|
||||
String articleId = widget.article.internalId.toString();
|
||||
String reason = unscanNoteController.text;
|
||||
|
||||
if (widget.article.scannable) {
|
||||
context.read<TourBloc>().add(
|
||||
UnscanArticleEvent(
|
||||
deliveryId: deliveryId,
|
||||
articleId: articleId,
|
||||
newAmount: amountToBeDeleted,
|
||||
reason: reason,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// If the article is not scannable we need to adjust the quantity of the article
|
||||
// directly.
|
||||
context.read<TourBloc>().add(
|
||||
SetArticleAmountEvent(
|
||||
deliveryId: deliveryId,
|
||||
articleId: articleId,
|
||||
amount: widget.article.amount - amountToBeDeleted,
|
||||
reason: reason
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
unscanAmountController = TextEditingController(text: "1");
|
||||
unscanNoteController = TextEditingController(text: "");
|
||||
|
||||
unscanNoteController.addListener(() {
|
||||
setState(() {
|
||||
isValidText = _isValid();
|
||||
});
|
||||
});
|
||||
|
||||
unscanAmountController.addListener(() {
|
||||
setState(() {
|
||||
isValidText = _isValid();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unscanAmountController.dispose();
|
||||
unscanNoteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isValid() {
|
||||
return _isAmountValid() && unscanNoteController.text.isNotEmpty;
|
||||
}
|
||||
|
||||
bool _isAmountValid() {
|
||||
final amount = int.tryParse(unscanAmountController.text);
|
||||
return amount != null && amount > 0 && amount <= widget.article.amount;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text("Scan rückgängig machen"),
|
||||
content: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 350,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Text(
|
||||
"Wollen Sie den Scanvorgang des Artikel '${widget.article.name}' rückgängig machen und den Artikel aus der Bestellung entfernen?",
|
||||
),
|
||||
Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return "Geben Sie eine Zahl ein";
|
||||
}
|
||||
|
||||
final amount = int.tryParse(text);
|
||||
if (amount == null || amount <= 0) {
|
||||
return "Geben Sie eine gültige Zahl ein";
|
||||
}
|
||||
|
||||
if (amount > widget.article.amount) {
|
||||
return "Maximal ${widget.article.amount} möglich.";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
controller: unscanAmountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Menge zu löschender Artikel",
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
controller: unscanNoteController,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Grund für die Entfernung",
|
||||
),
|
||||
validator: (text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return "Geben Sie einen Grund an.";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: isValidText ? _unscan : null,
|
||||
child: const Text("Entfernen"),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,173 +1,257 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:easy_stepper/easy_stepper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/payment_method.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_sign.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_articles.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_notes.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_services.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
|
||||
|
||||
class DeliveryDetail extends StatefulWidget {
|
||||
final String deliveryId;
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Backend-TODOs (siehe Roadmap unten in diesem File)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Die folgenden UI-Features sind aktuell als lokaler Stub im
|
||||
// `DeliveryWorkflowBloc` gebaut. Sie persistieren nichts am Server und
|
||||
// gehen verloren, sobald die Detail-Page geschlossen wird:
|
||||
//
|
||||
// * **B1 Bild-Notizen**: Foto-Upload-Endpoint fehlt. Domain-Feld
|
||||
// `DeliveryNote.image_attachment` existiert bereits als
|
||||
// Storage-Key — wir brauchen einen Endpoint, der Multipart-Upload
|
||||
// entgegennimmt und den Key zurückgibt, dann hier
|
||||
// `tourBloc.add(AddDeliveryNote(imageAttachment: key))` aufrufen.
|
||||
//
|
||||
// * ~~B4 Zahlungsmethode beim Abschluss ändern~~ — ERLEDIGT: Die im
|
||||
// Summary gewählte Methode (`paymentMethodOverrideId` im Workflow-State)
|
||||
// reist beim Abschluss am `/complete`-Endpoint mit und wird atomar auf
|
||||
// der Lieferung persistiert (Server prüft existiert + aktiv).
|
||||
//
|
||||
// * **B5 Unterschrift**: Signature-Pad-Bilder (Kunde + Fahrer)
|
||||
// hochladen + auf der Lieferung speichern. Backend hat dafür weder
|
||||
// Felder noch Endpoint. Explizit „nach der Session" verschoben.
|
||||
//
|
||||
// * **B6 Notiz-Templates**: Stammdaten (vordefinierte Notiz-Texte
|
||||
// zum Auswählen). Im alten Stand schon UI-seitig im NoteAddDialog
|
||||
// vorbereitet; aktuell zeigen wir nur das freie Textfeld.
|
||||
//
|
||||
// * **B7 `completeDelivery` im Frontend**: Repository-Methode +
|
||||
// Bloc-Event fehlt. Backend-Endpoint existiert (parameterlos).
|
||||
// Trigger: „Unterschreiben"-Button im Summary-Step — derzeit
|
||||
// SnackBar-Stub.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Multi-Step Detail-Page einer einzelnen Lieferung. Hülle für 5 Steps;
|
||||
/// jeder Step bekommt die aktuelle `Delivery` + `TourDetails` als Props,
|
||||
/// damit die Steps keine eigenen Bloc-Subscriptions auf die Tour brauchen.
|
||||
///
|
||||
/// Lifetime: die Page bringt ihren eigenen `DeliveryWorkflowBloc` mit
|
||||
/// (siehe `BlocProvider` unten). Beim Pop wird der Bloc samt Drafts
|
||||
/// disposed — gewollt, denn ein neuer Besuch der Detail-Page startet
|
||||
/// frisch im Step „Info".
|
||||
class DeliveryDetail extends StatelessWidget {
|
||||
const DeliveryDetail({super.key, required this.deliveryId});
|
||||
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryDetailState();
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => DeliveryWorkflowBloc(deliveryId: deliveryId),
|
||||
child: _DeliveryDetailScaffold(deliveryId: deliveryId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
late int _step;
|
||||
late List<EasyStep> _steps;
|
||||
class _DeliveryDetailScaffold extends StatelessWidget {
|
||||
const _DeliveryDetailScaffold({required this.deliveryId});
|
||||
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Reset Note BLOC
|
||||
// otherwise the notes of the previously
|
||||
// opened delivery would be loaded
|
||||
context.read<NoteBloc>().add(ResetNotes());
|
||||
|
||||
// Initialize steps
|
||||
_step = 0;
|
||||
_steps = [
|
||||
EasyStep(
|
||||
icon: const Icon(Icons.info),
|
||||
customTitle: Text("Info", textAlign: TextAlign.center),
|
||||
),
|
||||
EasyStep(
|
||||
icon: const Icon(Icons.book),
|
||||
customTitle: Text("Notizen", textAlign: TextAlign.center),
|
||||
),
|
||||
EasyStep(
|
||||
icon: const Icon(Icons.shopping_cart),
|
||||
customTitle: Text("Artikel/Gutschriften", textAlign: TextAlign.center),
|
||||
),
|
||||
EasyStep(
|
||||
icon: const Icon(Icons.settings),
|
||||
customTitle: Text("Optionen", textAlign: TextAlign.center),
|
||||
),
|
||||
EasyStep(
|
||||
icon: const Icon(Icons.check_box),
|
||||
customTitle: Text(
|
||||
"Überprüfen",
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _stepInfo() {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(),
|
||||
child: SizedBox(
|
||||
height: 115,
|
||||
child: EasyStepper(
|
||||
activeStep: _step,
|
||||
showLoadingAnimation: false,
|
||||
activeStepTextColor: Theme.of(context).primaryColor,
|
||||
activeStepBorderType: BorderType.dotted,
|
||||
finishedStepBorderType: BorderType.normal,
|
||||
unreachedStepBorderType: BorderType.normal,
|
||||
activeStepBackgroundColor: Colors.white,
|
||||
borderThickness: 2,
|
||||
internalPadding: 0.0,
|
||||
enableStepTapping: true,
|
||||
stepRadius: 25.0,
|
||||
onStepReached:
|
||||
(index) => {
|
||||
setState(() {
|
||||
_step = index;
|
||||
}),
|
||||
},
|
||||
steps: _steps,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepMissingWarning() {
|
||||
return Center(
|
||||
child: Text("Kein Inhalt für den aktuellen Step $_step gefunden."),
|
||||
);
|
||||
}
|
||||
|
||||
void _clickForward() {
|
||||
if (_step < _steps.length) {
|
||||
setState(() {
|
||||
_step += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _clickBack() {
|
||||
if (_step > 0) {
|
||||
setState(() {
|
||||
_step -= 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _openSignatureView(Delivery delivery) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<NoteBloc>(),
|
||||
child: SignatureView(onSigned: _onSign, delivery: delivery),
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, tourState) {
|
||||
if (tourState is! TourLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSign(Uint8List customer, Uint8List driver) async {
|
||||
context.read<TourBloc>().add(
|
||||
FinishDeliveryEvent(
|
||||
deliveryId: widget.deliveryId,
|
||||
customerSignature: customer,
|
||||
driverSignature: driver,
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Widget _stepsNavigation(Delivery delivery) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _step == 0 ? null : _clickBack,
|
||||
child: const Text("zurück"),
|
||||
}
|
||||
final details = tourState.details;
|
||||
final delivery = _findDelivery(details);
|
||||
if (delivery == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Lieferung')),
|
||||
body: Center(
|
||||
child: Text('Lieferung $deliveryId nicht in der Tour gefunden.'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (_step == _steps.length - 1) {
|
||||
_openSignatureView(delivery);
|
||||
} else {
|
||||
_clickForward();
|
||||
}
|
||||
},
|
||||
child:
|
||||
_step == _steps.length - 1
|
||||
? const Text("Unterschreiben")
|
||||
: const Text("weiter"),
|
||||
);
|
||||
}
|
||||
final customer = details.customerOf(delivery);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: theme.primaryColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
title: Text(customer?.name ?? 'Lieferung'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const _StepHeader(),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: _StepBody(delivery: delivery, details: details),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_BottomNav(delivery: delivery, details: details),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Delivery? _findDelivery(TourDetails details) {
|
||||
for (final d in details.deliveries) {
|
||||
if (d.id == deliveryId) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step-Header (Pills) ────────────────────────────────────────────────
|
||||
|
||||
class _StepHeader extends StatelessWidget {
|
||||
const _StepHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < WorkflowStep.values.length; i++) ...[
|
||||
Expanded(
|
||||
child: _StepPill(
|
||||
index: i,
|
||||
step: WorkflowStep.values[i],
|
||||
isActive: state.step.index == i,
|
||||
isPassed: state.step.index > i,
|
||||
onTap: () => context
|
||||
.read<DeliveryWorkflowBloc>()
|
||||
.add(WorkflowGoToStep(WorkflowStep.values[i])),
|
||||
),
|
||||
),
|
||||
if (i < WorkflowStep.values.length - 1)
|
||||
_StepConnector(isPassed: state.step.index > i),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepPill extends StatelessWidget {
|
||||
const _StepPill({
|
||||
required this.index,
|
||||
required this.step,
|
||||
required this.isActive,
|
||||
required this.isPassed,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final WorkflowStep step;
|
||||
final bool isActive;
|
||||
final bool isPassed;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primary = theme.colorScheme.primary;
|
||||
|
||||
final Color circleColor;
|
||||
final Color circleFg;
|
||||
final Color labelColor;
|
||||
final FontWeight labelWeight;
|
||||
|
||||
if (isActive) {
|
||||
circleColor = primary;
|
||||
circleFg = theme.colorScheme.onPrimary;
|
||||
labelColor = primary;
|
||||
labelWeight = FontWeight.w700;
|
||||
} else if (isPassed) {
|
||||
circleColor = primary.withValues(alpha: 0.85);
|
||||
circleFg = theme.colorScheme.onPrimary;
|
||||
labelColor = primary;
|
||||
labelWeight = FontWeight.w600;
|
||||
} else {
|
||||
circleColor = theme.colorScheme.surfaceContainerHighest;
|
||||
circleFg = theme.colorScheme.onSurfaceVariant;
|
||||
labelColor = theme.colorScheme.onSurfaceVariant;
|
||||
labelWeight = FontWeight.w500;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: circleColor,
|
||||
shape: BoxShape.circle,
|
||||
border: isActive
|
||||
? Border.all(color: primary, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: isPassed && !isActive
|
||||
? Icon(Icons.check, color: circleFg, size: 16)
|
||||
: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
color: circleFg,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
step.shortName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: labelColor,
|
||||
fontSize: 11,
|
||||
fontWeight: labelWeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -175,37 +259,186 @@ class _DeliveryDetailState extends State<DeliveryDetail> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepConnector extends StatelessWidget {
|
||||
const _StepConnector({required this.isPassed});
|
||||
final bool isPassed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
builder: (context, state) {
|
||||
Delivery? delivery;
|
||||
if (state is TourLoaded) {
|
||||
delivery = state.tour.deliveries.firstWhere(
|
||||
(d) => d.id == widget.deliveryId,
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 2,
|
||||
color: isPassed
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Auslieferungsdetails")),
|
||||
body: delivery == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
_stepInfo(),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child:
|
||||
StepFactory().make(_step, delivery) ??
|
||||
_stepMissingWarning(),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar:
|
||||
delivery == null ? null : _stepsNavigation(delivery),
|
||||
);
|
||||
// ─── Step-Body Router ───────────────────────────────────────────────────
|
||||
|
||||
class _StepBody extends StatelessWidget {
|
||||
const _StepBody({required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||||
buildWhen: (prev, curr) => prev.step != curr.step,
|
||||
builder: (context, state) {
|
||||
switch (state.step) {
|
||||
case WorkflowStep.info:
|
||||
return StepInfo(delivery: delivery, details: details);
|
||||
case WorkflowStep.notes:
|
||||
return StepNotes(delivery: delivery, details: details);
|
||||
case WorkflowStep.articles:
|
||||
return StepArticles(delivery: delivery, details: details);
|
||||
case WorkflowStep.services:
|
||||
return StepServices(delivery: delivery, details: details);
|
||||
case WorkflowStep.summary:
|
||||
return StepSummary(delivery: delivery, details: details);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bottom-Navigation ──────────────────────────────────────────────────
|
||||
|
||||
class _BottomNav extends StatelessWidget {
|
||||
const _BottomNav({required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
/// Öffnet den zweistufigen Unterschrift-Flow (Kunde → Fahrer). Erst nach
|
||||
/// beiden Unterschriften triggert die View den Backend-Abschluss via
|
||||
/// `CompleteDelivery`; danach poppt sie zurück auf die Detail-Page, die
|
||||
/// dann den `completed`-Status zeigt.
|
||||
void _onSign(BuildContext context) {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
// Die im Summary-Step gewählte Zahlungsmethode lebt im Workflow-State.
|
||||
// Beim Abschluss reisen wir sie mit ans Backend (atomar mit der Signatur);
|
||||
// `null` = die am Beleg hinterlegte Methode bleibt.
|
||||
final paymentMethodOverrideId =
|
||||
context.read<DeliveryWorkflowBloc>().state.paymentMethodOverrideId;
|
||||
|
||||
// Offener Betrag = Warenwert − Anzahlung − Gutschrift (≥ 0). EXAKT die
|
||||
// Formel aus StepSummary und dem Backend-Inkasso-Gate.
|
||||
final creditEuros =
|
||||
(details.creditOf(delivery.id)?.amountCents ?? 0) / 100.0;
|
||||
final warenwert =
|
||||
delivery.items.fold<double>(0, (acc, item) => acc + item.lineTotal);
|
||||
final open = (warenwert - delivery.prepaidAmount - creditEuros)
|
||||
.clamp(0.0, double.infinity)
|
||||
.toDouble();
|
||||
|
||||
// Effektive Methode (Override > Beleg) auflösen, um Vor-Ort-Inkasso
|
||||
// (Bar/EC) von „Auf Rechnung" zu unterscheiden.
|
||||
final effectiveMethodId =
|
||||
paymentMethodOverrideId ?? delivery.paymentMethodId;
|
||||
final pmState = context.read<PaymentMethodsCubit>().state;
|
||||
PaymentMethod? method;
|
||||
if (pmState is PaymentMethodsLoaded) {
|
||||
for (final m in pmState.methods) {
|
||||
if (m.id == effectiveMethodId) {
|
||||
method = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inkasso-Pflicht: offener Betrag > 0 UND Bar/EC. „Auf Rechnung" → nein.
|
||||
final requiresCollection =
|
||||
open > 0 && (method?.code == 'cash' || method?.code == 'ec_card');
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (routeContext) => SignatureView(
|
||||
delivery: delivery,
|
||||
details: details,
|
||||
requiresCollection: requiresCollection,
|
||||
openAmount: open,
|
||||
paymentMethodLabel: method?.name ?? '',
|
||||
onSigned: (result) {
|
||||
tourBloc.add(CompleteDelivery(
|
||||
deliveryId: delivery.id,
|
||||
customerSignaturePng: result.customerSignaturePng,
|
||||
driverSignaturePng: result.driverSignaturePng,
|
||||
receiptConfirmed: result.receiptConfirmed,
|
||||
notesAcknowledged: result.notesAcknowledged,
|
||||
acknowledgedNoteIds: result.acknowledgedNoteIds,
|
||||
paymentMethodId: paymentMethodOverrideId,
|
||||
paymentCollected: result.paymentCollected,
|
||||
));
|
||||
Navigator.of(routeContext).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||||
builder: (context, state) {
|
||||
final isFirst = state.step.index == 0;
|
||||
final isLast = state.step.index == WorkflowStep.values.length - 1;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: isFirst
|
||||
? null
|
||||
: () => context
|
||||
.read<DeliveryWorkflowBloc>()
|
||||
.add(const WorkflowPreviousStep()),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Zurück'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isLast)
|
||||
// Unterschreiben/Abschließen nur bei aktiver Lieferung.
|
||||
// Ist sie bereits abgeschlossen (oder pausiert/abgebrochen),
|
||||
// bleibt der Button gesperrt.
|
||||
Builder(builder: (context) {
|
||||
final isActive =
|
||||
delivery.state == DeliveryState.active;
|
||||
final isCompleted =
|
||||
delivery.state == DeliveryState.completed;
|
||||
return FilledButton.icon(
|
||||
onPressed: isActive ? () => _onSign(context) : null,
|
||||
icon: Icon(isCompleted
|
||||
? Icons.check_circle_outline
|
||||
: Icons.draw_outlined),
|
||||
label: Text(
|
||||
isCompleted ? 'Abgeschlossen' : 'Unterschreiben',
|
||||
),
|
||||
);
|
||||
})
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () => context
|
||||
.read<DeliveryWorkflowBloc>()
|
||||
.add(const WorkflowNextStep()),
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('Weiter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,221 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
import '../../bloc/tour_event.dart';
|
||||
|
||||
class DeliveryDiscount extends StatefulWidget {
|
||||
const DeliveryDiscount({
|
||||
super.key,
|
||||
this.discount,
|
||||
required this.disabled,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
final bool disabled;
|
||||
final Discount? discount;
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryDiscountState();
|
||||
}
|
||||
|
||||
class _DeliveryDiscountState extends State<DeliveryDiscount> {
|
||||
final int stepSize = 10;
|
||||
|
||||
late TextEditingController _reasonController;
|
||||
late bool _isReasonEmpty;
|
||||
late bool _isUpdated;
|
||||
late int _discountValue;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_reasonController = TextEditingController(text: widget.discount?.note);
|
||||
_isReasonEmpty = _reasonController.text.isEmpty;
|
||||
_reasonController.addListener(() {
|
||||
setState(() {
|
||||
_isReasonEmpty = _reasonController.text.isEmpty;
|
||||
});
|
||||
});
|
||||
|
||||
_discountValue =
|
||||
widget.discount?.article.getGrossPrice().floor().abs() ?? 0;
|
||||
|
||||
_isUpdated = _discountValue > 0 && _reasonController.text.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_reasonController.dispose();
|
||||
}
|
||||
|
||||
bool _maximumReached() {
|
||||
return _discountValue >= 150;
|
||||
}
|
||||
|
||||
bool _minimumReached() {
|
||||
return _discountValue <= 0;
|
||||
}
|
||||
|
||||
Widget _incrementDiscount() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed:
|
||||
_minimumReached() || widget.disabled
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
if (_discountValue - stepSize >= 0) {
|
||||
_discountValue -= stepSize;
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
style: ButtonStyle(
|
||||
backgroundColor:
|
||||
_minimumReached() || widget.disabled
|
||||
? WidgetStateProperty.all(Colors.grey)
|
||||
: WidgetStateProperty.all(Colors.red),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
"${_discountValue.abs()}€",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18.0,
|
||||
),
|
||||
),
|
||||
const Text("max. 150€", style: TextStyle(fontSize: 10.0)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed:
|
||||
_maximumReached() || widget.disabled
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_discountValue += stepSize;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
style: ButtonStyle(
|
||||
backgroundColor:
|
||||
_maximumReached() || widget.disabled
|
||||
? WidgetStateProperty.all(Colors.grey)
|
||||
: WidgetStateProperty.all(Colors.green),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _resetValues() async {
|
||||
setState(() {
|
||||
_discountValue = 0;
|
||||
_reasonController.clear();
|
||||
_isUpdated = false;
|
||||
});
|
||||
|
||||
context.read<TourBloc>().add(
|
||||
RemoveDiscountEvent(deliveryId: widget.deliveryId),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateValues() async {
|
||||
if (_isUpdated) {
|
||||
context.read<TourBloc>().add(
|
||||
UpdateDiscountEvent(
|
||||
deliveryId: widget.deliveryId,
|
||||
value: _discountValue,
|
||||
reason: _reasonController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
context.read<TourBloc>().add(
|
||||
AddDiscountEvent(
|
||||
deliveryId: widget.deliveryId,
|
||||
value: _discountValue,
|
||||
reason: _reasonController.text,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
setState(() {
|
||||
_isUpdated = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Betrag:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_incrementDiscount(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
"Begründung:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: TextFormField(
|
||||
controller: _reasonController,
|
||||
validator: (text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return "Begründung für Gutschrift notwendig.";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed:
|
||||
!_isReasonEmpty && _discountValue > 0
|
||||
? _updateValues
|
||||
: null,
|
||||
child: const Text("Speichern"),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _discountValue > 0 && widget.discount != null ? _resetValues : null,
|
||||
child: const Text("Gutschrift entfernen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart' as model;
|
||||
|
||||
import '../../bloc/tour_event.dart';
|
||||
|
||||
class DeliveryOptionsView extends StatefulWidget {
|
||||
const DeliveryOptionsView({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
final List<model.DeliveryOption> options;
|
||||
final String deliveryId;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryOptionsViewState();
|
||||
}
|
||||
|
||||
class _DeliveryOptionsViewState extends State<DeliveryOptionsView> {
|
||||
late Map<String, TextEditingController> _controllers;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controllers = {};
|
||||
for (final option in widget.options.where((option) => option.numerical)) {
|
||||
_controllers[option.key] = TextEditingController(text: option.getValue().toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DeliveryOptionsView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
void _update(model.DeliveryOption option, dynamic value) {
|
||||
if (value is bool) {
|
||||
context.read<TourBloc>().add(
|
||||
UpdateDeliveryOptionEvent(
|
||||
key: option.key,
|
||||
value: !value,
|
||||
deliveryId: widget.deliveryId,
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<TourBloc>().add(
|
||||
UpdateDeliveryOptionEvent(
|
||||
key: option.key,
|
||||
value: value,
|
||||
deliveryId: widget.deliveryId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _options() {
|
||||
List<Widget> boolOptions =
|
||||
widget.options.where((option) => !option.numerical).map((option) {
|
||||
return CheckboxListTile(
|
||||
value: option.getValue(),
|
||||
onChanged: (value) {
|
||||
_update(option, option.getValue());
|
||||
},
|
||||
title: Text(option.display),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
List<Widget> numericalOptions =
|
||||
widget.options.where((option) => option.numerical).map((option) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(labelText: option.display),
|
||||
controller: _controllers[option.key],
|
||||
keyboardType: TextInputType.number,
|
||||
onTapOutside: (event) {
|
||||
FocusScope.of(context).unfocus();
|
||||
_update(option, _controllers[option.key]?.text);
|
||||
},
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (value) {
|
||||
_update(option, value);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 5),
|
||||
child: Text(
|
||||
"Auswählbare Optionen",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
...boolOptions,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
"Zahlenwerte",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
...numericalOptions,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: ListView(children: _options()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,56 +1,129 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:signature/signature.dart';
|
||||
|
||||
enum _SigningPhase { customerAcceptance, customerSignature, driverSignature }
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
|
||||
/// Daten, die der Abschluss-Flow an den Aufrufer zurückgibt: beide
|
||||
/// Unterschriften als PNG plus die dokumentierten Bestätigungen des Kunden.
|
||||
class SignatureResult {
|
||||
const SignatureResult({
|
||||
required this.customerSignaturePng,
|
||||
required this.driverSignaturePng,
|
||||
required this.receiptConfirmed,
|
||||
required this.notesAcknowledged,
|
||||
required this.acknowledgedNoteIds,
|
||||
required this.paymentCollected,
|
||||
});
|
||||
|
||||
final Uint8List customerSignaturePng;
|
||||
final Uint8List driverSignaturePng;
|
||||
final bool receiptConfirmed;
|
||||
final bool notesAcknowledged;
|
||||
final List<String> acknowledgedNoteIds;
|
||||
|
||||
/// Fahrer hat das Inkasso (Bar/EC) des offenen Betrags bestätigt. `false`,
|
||||
/// wenn kein Inkasso anfiel (offen == 0 oder „Auf Rechnung").
|
||||
final bool paymentCollected;
|
||||
}
|
||||
|
||||
/// Mehrstufiger Unterschrift-Flow zum Abschließen einer Lieferung.
|
||||
///
|
||||
/// Stufe 0 (Fahrer, optional): nur wenn beim Abschluss ein offener Betrag
|
||||
/// per Vor-Ort-Inkasso (Bar/EC) zu kassieren ist ([requiresCollection]).
|
||||
/// Der Fahrer bestätigt, dass der Betrag erhalten/abgerechnet wurde — VOR
|
||||
/// beiden Unterschriften.
|
||||
/// Stufe 1 (Kunde): sieht die Anmerkungen zur Lieferung, hakt zwei
|
||||
/// Bestätigungen ab (Anmerkungen-Kenntnisnahme — nur Pflicht, wenn Notizen
|
||||
/// vorhanden; Empfangsbestätigung — immer Pflicht) und unterschreibt.
|
||||
/// Stufe 2 (Fahrer): unterschreibt.
|
||||
///
|
||||
/// Erst nach beiden Unterschriften ruft die View [onSigned] mit dem
|
||||
/// vollständigen [SignatureResult] auf — der Aufrufer triggert dann den
|
||||
/// Backend-Abschluss und schließt die Seite.
|
||||
class SignatureView extends StatefulWidget {
|
||||
const SignatureView({
|
||||
super.key,
|
||||
required this.onSigned,
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
required this.onSigned,
|
||||
this.requiresCollection = false,
|
||||
this.openAmount = 0,
|
||||
this.paymentMethodLabel = '',
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
final void Function(SignatureResult result) onSigned;
|
||||
|
||||
/// Callback that is called when the user has signed.
|
||||
/// The parameter stores the path to the image file of the signature.
|
||||
final void Function(
|
||||
Uint8List customerSignaturePng,
|
||||
Uint8List driverSignaturePng,
|
||||
)
|
||||
onSigned;
|
||||
/// Offener Betrag muss vor Ort kassiert werden (offen > 0 UND Bar/EC).
|
||||
/// Schaltet Stufe 0 (Inkasso-Bestätigung) frei.
|
||||
final bool requiresCollection;
|
||||
|
||||
/// Offener Betrag in Euro (nur für die Anzeige in Stufe 0).
|
||||
final double openAmount;
|
||||
|
||||
/// Anzeigename der Zahlungsmethode (z. B. „Bar", „EC-Karte").
|
||||
final String paymentMethodLabel;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SignatureViewState();
|
||||
State<SignatureView> createState() => _SignatureViewState();
|
||||
}
|
||||
|
||||
/// Stufen des Abschluss-Flows.
|
||||
enum _SignStage { payment, customer, driver }
|
||||
|
||||
class _SignatureViewState extends State<SignatureView> {
|
||||
static const String _receiptText =
|
||||
'Ich bestätige, dass ich die Ware im ordnungsgemäßen Zustand erhalten '
|
||||
'habe und, dass die Aufstell- und Einbauarbeiten korrekt durchgeführt '
|
||||
'wurden.';
|
||||
static const String _notesText =
|
||||
'Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.';
|
||||
|
||||
final SignatureController _customerController = SignatureController(
|
||||
penStrokeWidth: 5,
|
||||
penStrokeWidth: 3,
|
||||
penColor: Colors.black,
|
||||
exportBackgroundColor: Colors.white,
|
||||
);
|
||||
|
||||
final SignatureController _driverController = SignatureController(
|
||||
penStrokeWidth: 5,
|
||||
penStrokeWidth: 3,
|
||||
penColor: Colors.black,
|
||||
exportBackgroundColor: Colors.white,
|
||||
);
|
||||
|
||||
_SigningPhase _phase = _SigningPhase.customerAcceptance;
|
||||
late final List<DeliveryNote> _notes;
|
||||
late _SignStage _stage;
|
||||
bool _paymentConfirmed = false;
|
||||
bool _receiptAccepted = false;
|
||||
bool _notesAccepted = false;
|
||||
bool _customerEmpty = true;
|
||||
bool _driverEmpty = true;
|
||||
|
||||
bool get _notesEmpty => _notes.isEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
|
||||
// Inkasso-Bestätigung (Stufe 0) nur wenn gefordert, sonst direkt zum Kunden.
|
||||
_stage =
|
||||
widget.requiresCollection ? _SignStage.payment : _SignStage.customer;
|
||||
_notes = widget.details.notesByDeliveryId[widget.delivery.id] ??
|
||||
const <DeliveryNote>[];
|
||||
_customerController.addListener(() {
|
||||
if (_customerEmpty != _customerController.isEmpty) {
|
||||
setState(() => _customerEmpty = _customerController.isEmpty);
|
||||
}
|
||||
});
|
||||
_driverController.addListener(() {
|
||||
if (_driverEmpty != _driverController.isEmpty) {
|
||||
setState(() => _driverEmpty = _driverController.isEmpty);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -60,314 +133,333 @@ class _SignatureViewState extends State<SignatureView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onAcceptanceDone() {
|
||||
setState(() => _phase = _SigningPhase.customerSignature);
|
||||
}
|
||||
bool get _customerStepValid =>
|
||||
_receiptAccepted && (_notesAccepted || _notesEmpty) && !_customerEmpty;
|
||||
|
||||
void _onCustomerSigned() {
|
||||
setState(() => _phase = _SigningPhase.driverSignature);
|
||||
}
|
||||
|
||||
Future<void> _onDriverSigned() async {
|
||||
widget.onSigned(
|
||||
(await _customerController.toPngBytes())!,
|
||||
(await _driverController.toPngBytes())!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (_phase) {
|
||||
_SigningPhase.customerAcceptance => _AcceptanceStep(
|
||||
onContinue: _onAcceptanceDone,
|
||||
),
|
||||
_SigningPhase.customerSignature => _SignaturePadStep(
|
||||
controller: _customerController,
|
||||
delivery: widget.delivery,
|
||||
appBarTitle: "Unterschrift des Kunden",
|
||||
buttonLabel: "Weiter",
|
||||
onContinue: _onCustomerSigned,
|
||||
),
|
||||
_SigningPhase.driverSignature => _SignaturePadStep(
|
||||
controller: _driverController,
|
||||
delivery: widget.delivery,
|
||||
appBarTitle: "Unterschrift des Fahrers",
|
||||
buttonLabel: "Absenden",
|
||||
onContinue: _onDriverSigned,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _AcceptanceStep extends StatefulWidget {
|
||||
const _AcceptanceStep({required this.onContinue});
|
||||
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<_AcceptanceStep> createState() => _AcceptanceStepState();
|
||||
}
|
||||
|
||||
class _AcceptanceStepState extends State<_AcceptanceStep> {
|
||||
bool _customerAccepted = false;
|
||||
bool _noteAccepted = false;
|
||||
|
||||
Widget _notesContent(NoteState noteState) {
|
||||
if (noteState is! NoteLoaded) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
/// Ist der Primär-Button auf der aktuellen Stufe aktiv?
|
||||
bool get _stageValid {
|
||||
switch (_stage) {
|
||||
case _SignStage.payment:
|
||||
return _paymentConfirmed;
|
||||
case _SignStage.customer:
|
||||
return _customerStepValid;
|
||||
case _SignStage.driver:
|
||||
return !_driverEmpty;
|
||||
}
|
||||
if (noteState.notes.isEmpty) {
|
||||
return const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(child: Text("Keine Notizen vorhanden")),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.event_note_outlined),
|
||||
title: Text(noteState.notes[index].content),
|
||||
contentPadding: const EdgeInsets.all(20),
|
||||
tileColor: Theme.of(context).colorScheme.onSecondary,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
||||
itemCount: noteState.notes.length,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notes(NoteState noteState) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 15),
|
||||
child: Text(
|
||||
"Notizen",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
_notesContent(noteState),
|
||||
const Padding(padding: EdgeInsets.only(top: 25), child: Divider()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NoteBloc, NoteState>(
|
||||
builder: (context, noteState) {
|
||||
final notesEmpty = switch (noteState) {
|
||||
NoteLoadedBase(notes: final ns) => ns.isEmpty,
|
||||
_ => true,
|
||||
};
|
||||
final isButtonEnabled =
|
||||
_customerAccepted && (_noteAccepted || notesEmpty);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Unterschrift des Kunden")),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25, bottom: 0),
|
||||
child: _notes(noteState),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25.0, bottom: 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _noteAccepted,
|
||||
onChanged: notesEmpty
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_noteAccepted = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Flexible(
|
||||
child: InkWell(
|
||||
onTap: notesEmpty
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_noteAccepted = !_noteAccepted;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
"Ich nehme die oben genannten Anmerkungen zur Lieferung zur Kenntnis.",
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25.0, bottom: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _customerAccepted,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_customerAccepted = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Flexible(
|
||||
child: InkWell(
|
||||
child: Text(
|
||||
"Ware in ordnungsgemäßem Zustand erhalten. Aufstell- und Einbauarbeiten wurden korrekt durchgeführt",
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_customerAccepted = !_customerAccepted;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed: isButtonEnabled ? widget.onContinue : null,
|
||||
child: const Text("Unterschreiben"),
|
||||
),
|
||||
),
|
||||
),
|
||||
Future<void> _onPrimaryPressed() async {
|
||||
switch (_stage) {
|
||||
case _SignStage.payment:
|
||||
setState(() => _stage = _SignStage.customer);
|
||||
return;
|
||||
case _SignStage.customer:
|
||||
setState(() => _stage = _SignStage.driver);
|
||||
return;
|
||||
case _SignStage.driver:
|
||||
final customerPng = await _customerController.toPngBytes();
|
||||
final driverPng = await _driverController.toPngBytes();
|
||||
if (customerPng == null || driverPng == null) return;
|
||||
widget.onSigned(
|
||||
SignatureResult(
|
||||
customerSignaturePng: customerPng,
|
||||
driverSignaturePng: driverPng,
|
||||
receiptConfirmed: _receiptAccepted,
|
||||
notesAcknowledged: _notesEmpty ? false : _notesAccepted,
|
||||
acknowledgedNoteIds:
|
||||
_notesEmpty ? const [] : _notes.map((n) => n.id).toList(),
|
||||
paymentCollected: widget.requiresCollection && _paymentConfirmed,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignaturePadStep extends StatefulWidget {
|
||||
const _SignaturePadStep({
|
||||
required this.controller,
|
||||
required this.delivery,
|
||||
required this.appBarTitle,
|
||||
required this.buttonLabel,
|
||||
required this.onContinue,
|
||||
});
|
||||
|
||||
final SignatureController controller;
|
||||
final Delivery delivery;
|
||||
final String appBarTitle;
|
||||
final String buttonLabel;
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<_SignaturePadStep> createState() => _SignaturePadStepState();
|
||||
}
|
||||
|
||||
class _SignaturePadStepState extends State<_SignaturePadStep> {
|
||||
bool _isEmpty = true;
|
||||
late final VoidCallback _listener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isEmpty = widget.controller.isEmpty;
|
||||
_listener = () {
|
||||
if (_isEmpty != widget.controller.isEmpty) {
|
||||
setState(() {
|
||||
_isEmpty = widget.controller.isEmpty;
|
||||
});
|
||||
}
|
||||
};
|
||||
widget.controller.addListener(_listener);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_listener);
|
||||
super.dispose();
|
||||
String get _paymentText {
|
||||
final amount = widget.openAmount.toStringAsFixed(2).replaceAll('.', ',');
|
||||
final via = widget.paymentMethodLabel.isEmpty
|
||||
? ''
|
||||
: ' per ${widget.paymentMethodLabel}';
|
||||
return 'Ich bestätige, dass der offene Betrag von $amount €$via '
|
||||
'erhalten bzw. abgerechnet wurde.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedDate = DateFormat("dd.MM.yyyy").format(DateTime.now());
|
||||
final theme = Theme.of(context);
|
||||
final customer = widget.details.customerOf(widget.delivery);
|
||||
final date = DateFormat('dd.MM.yyyy').format(DateTime.now());
|
||||
final isPayment = _stage == _SignStage.payment;
|
||||
final isDriver = _stage == _SignStage.driver;
|
||||
|
||||
final String title;
|
||||
switch (_stage) {
|
||||
case _SignStage.payment:
|
||||
title = 'Zahlung bestätigen';
|
||||
case _SignStage.customer:
|
||||
title = 'Unterschrift des Kunden';
|
||||
case _SignStage.driver:
|
||||
title = 'Unterschrift des Fahrers';
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.appBarTitle)),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(color: Colors.white),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Lieferung an: ${widget.delivery.customer.name}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Signature(
|
||||
controller: widget.controller,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Text(
|
||||
"${widget.delivery.customer.address.city}, den $formattedDate",
|
||||
),
|
||||
],
|
||||
if (isPayment) ...[
|
||||
// Stufe 0 — Fahrer kassiert (Bar/EC) und bestätigt VOR den
|
||||
// Unterschriften.
|
||||
_PaymentDueCard(
|
||||
openAmount: widget.openAmount,
|
||||
methodLabel: widget.paymentMethodLabel,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ConfirmTile(
|
||||
value: _paymentConfirmed,
|
||||
enabled: true,
|
||||
label: _paymentText,
|
||||
onChanged: (v) => setState(() => _paymentConfirmed = v),
|
||||
),
|
||||
] else ...[
|
||||
if (!isDriver) ...[
|
||||
_NotesSection(notes: _notes),
|
||||
const SizedBox(height: 16),
|
||||
_ConfirmTile(
|
||||
value: _notesAccepted,
|
||||
enabled: !_notesEmpty,
|
||||
label: _notesText,
|
||||
onChanged: (v) => setState(() => _notesAccepted = v),
|
||||
),
|
||||
_ConfirmTile(
|
||||
value: _receiptAccepted,
|
||||
enabled: true,
|
||||
label: _receiptText,
|
||||
onChanged: (v) => setState(() => _receiptAccepted = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
'Lieferung an: ${customer?.name ?? '⟨Unbekannter Kunde⟩'}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SignaturePad(
|
||||
controller: isDriver ? _driverController : _customerController,
|
||||
onClear: () =>
|
||||
(isDriver ? _driverController : _customerController)
|
||||
.clear(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${customer?.address.city ?? ''}, den $date',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _stageValid ? _onPrimaryPressed : null,
|
||||
icon: Icon(isDriver ? Icons.check : Icons.arrow_forward),
|
||||
label: Text(isDriver ? 'Abschließen' : 'Weiter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inkasso-Hinweis (Stufe 0) ──────────────────────────────────────────────
|
||||
|
||||
class _PaymentDueCard extends StatelessWidget {
|
||||
const _PaymentDueCard({required this.openAmount, required this.methodLabel});
|
||||
|
||||
final double openAmount;
|
||||
final String methodLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final amount = openAmount.toStringAsFixed(2).replaceAll('.', ',');
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.07),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Offener Betrag',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.payments_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'$amount €',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (methodLabel.isNotEmpty)
|
||||
Chip(
|
||||
label: Text(methodLabel),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Bitte den Betrag bar entgegennehmen oder über das EC-Gerät '
|
||||
'abrechnen und anschließend bestätigen.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 90,
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed: _isEmpty ? null : widget.onContinue,
|
||||
child: Text(widget.buttonLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notizen-Block ────────────────────────────────────────────────────────
|
||||
|
||||
class _NotesSection extends StatelessWidget {
|
||||
const _NotesSection({required this.notes});
|
||||
|
||||
final List<DeliveryNote> notes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Anmerkungen zur Lieferung',
|
||||
style:
|
||||
theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (notes.isEmpty)
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Keine Anmerkungen vorhanden.',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < notes.length; i++) ...[
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
notes[i].imageAttachment != null
|
||||
? (notes[i].imageAttachmentDeleted
|
||||
? Icons.picture_as_pdf_outlined
|
||||
: Icons.photo_outlined)
|
||||
: Icons.event_note_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
notes[i].text?.trim().isNotEmpty == true
|
||||
? notes[i].text!.trim()
|
||||
: (notes[i].imageAttachmentDeleted
|
||||
? 'Bild im Lieferbericht enthalten'
|
||||
: 'Foto-Anhang'),
|
||||
),
|
||||
),
|
||||
if (i < notes.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bestätigungs-Checkbox ──────────────────────────────────────────────────
|
||||
|
||||
class _ConfirmTile extends StatelessWidget {
|
||||
const _ConfirmTile({
|
||||
required this.value,
|
||||
required this.enabled,
|
||||
required this.label,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final bool enabled;
|
||||
final String label;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CheckboxListTile(
|
||||
value: value,
|
||||
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(label, style: const TextStyle(fontSize: 14)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Signatur-Pad ───────────────────────────────────────────────────────────
|
||||
|
||||
class _SignaturePad extends StatelessWidget {
|
||||
const _SignaturePad({required this.controller, required this.onClear});
|
||||
|
||||
final SignatureController controller;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Signature(
|
||||
controller: controller,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: onClear,
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Text('Löschen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,177 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
import '../../../../model/tour.dart';
|
||||
|
||||
class DeliverySummary extends StatefulWidget {
|
||||
const DeliverySummary({super.key, required this.delivery});
|
||||
|
||||
final Delivery delivery;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliverySummaryState();
|
||||
}
|
||||
|
||||
class _DeliverySummaryState extends State<DeliverySummary> {
|
||||
late List<Payment> _paymentMethods;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final tourState = context.read<TourBloc>().state as TourLoaded;
|
||||
_paymentMethods = [...tourState.paymentOptions];
|
||||
|
||||
if (!_paymentMethods.any(
|
||||
(payment) => payment.id == widget.delivery.payment.id,
|
||||
)) {
|
||||
_paymentMethods.add(widget.delivery.payment);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _deliveredArticles() {
|
||||
List<Widget> items =
|
||||
widget.delivery
|
||||
.getDeliveredArticles()
|
||||
.map(
|
||||
(article) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(article.name),
|
||||
subtitle: Text("Artikelnr. ${article.articleNumber}"),
|
||||
trailing: Text(
|
||||
"${article.scannable ? article.getGrossPriceScanned().toStringAsFixed(2) : article.getGrossPrice().toStringAsFixed(2)}€",
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
"${article.scannable ? article.scannedAmount : article.amount}x",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
items.add(
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
child: ListTile(
|
||||
title: const Text(
|
||||
"Gesamtsumme:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: Text(
|
||||
"${widget.delivery.getGrossPrice().toStringAsFixed(2)}€",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _paymentOptions() {
|
||||
List<DropdownMenuEntry> entries =
|
||||
_paymentMethods
|
||||
.map(
|
||||
(payment) => DropdownMenuEntry(
|
||||
value: payment.id,
|
||||
label: "${payment.description} (${payment.shortcode})",
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return DropdownMenu(
|
||||
dropdownMenuEntries: entries,
|
||||
initialSelection: widget.delivery.payment.id,
|
||||
onSelected: (id) {
|
||||
context.read<TourBloc>().add(
|
||||
UpdateSelectedPaymentMethodEvent(
|
||||
deliveryId: widget.delivery.id,
|
||||
payment: _paymentMethods.firstWhere((payment) => payment.id == id),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _paymentDone() {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text(
|
||||
"Bei Bestellung bezahlt:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: Text("${widget.delivery.prepayment.toStringAsFixed(2)}€"),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
"Offener Betrag:",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: Text(
|
||||
"${widget.delivery.getOpenPrice().toStringAsFixed(2)}€",
|
||||
style: TextStyle(fontWeight: FontWeight.w900, color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final insets = EdgeInsets.all(10);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: ListView(
|
||||
children: [
|
||||
Text(
|
||||
"Ausgelieferte Artikel",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
|
||||
Padding(padding: insets, child: _deliveredArticles()),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
"Geleistete Zahlung",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
|
||||
Padding(padding: insets, child: _paymentDone()),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
"Zahlungsmethode",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
|
||||
Padding(padding: insets, child: _paymentOptions()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,149 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class NoteAddDialog extends StatefulWidget {
|
||||
final String delivery;
|
||||
final List<NoteTemplate> templates;
|
||||
|
||||
const NoteAddDialog({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.templates,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _NoteAddDialogState();
|
||||
}
|
||||
|
||||
class _NoteAddDialogState extends State<NoteAddDialog> {
|
||||
final _noteController = TextEditingController();
|
||||
final _noteSelectionController = TextEditingController();
|
||||
late FocusNode _noteFieldFocusNode;
|
||||
bool _isCustomNotesEmpty = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_noteFieldFocusNode = FocusNode();
|
||||
|
||||
_noteController.addListener(() {
|
||||
setState(() {
|
||||
_isCustomNotesEmpty = _noteController.text.isEmpty;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
String content = _noteController.text;
|
||||
|
||||
context.read<NoteBloc>().add(
|
||||
AddNote(note: content, deliveryId: widget.delivery),
|
||||
);
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
// Default Dialog.insetPadding eats 80 dp horizontally on phones, leaving
|
||||
// too little room for two side-by-side buttons on narrow devices like
|
||||
// the Samsung A16F. Shrinking the inset gives back ~64 dp.
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: ListView(
|
||||
//mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Notiz hinzufügen",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10.0, top: 20),
|
||||
child: DropdownMenu(
|
||||
controller: _noteSelectionController,
|
||||
onSelected: (int? value) {
|
||||
setState(() {
|
||||
_noteController.text =
|
||||
widget.templates[value!].content;
|
||||
});
|
||||
},
|
||||
width: double.infinity,
|
||||
label: const Text("Notiz auswählen"),
|
||||
dropdownMenuEntries:
|
||||
widget.templates
|
||||
.mapIndexed(
|
||||
(i, note) =>
|
||||
DropdownMenuEntry(value: i, label: note.title),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 0.0, bottom: 0.0),
|
||||
child: Center(child: Text("oder")),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10.0, bottom: 20.0),
|
||||
child: TextFormField(
|
||||
onTapOutside: (_) { _noteFieldFocusNode.unfocus(); },
|
||||
controller: _noteController,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {_noteFieldFocusNode.unfocus();},
|
||||
focusNode: _noteFieldFocusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Eigene Notiz",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
minLines: 8,
|
||||
maxLines: 10,
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed:
|
||||
_noteSelectionController.text.isNotEmpty ||
|
||||
_noteController.text.isNotEmpty
|
||||
? _onSave
|
||||
: null,
|
||||
child: const Text("Hinzufügen"),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
_noteController.clear();
|
||||
_noteSelectionController.clear();
|
||||
},
|
||||
child: const Text("Zurücksetzen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class NoteEditDialog extends StatefulWidget {
|
||||
final Note note;
|
||||
|
||||
const NoteEditDialog({super.key, required this.note});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _NoteEditDialogState();
|
||||
}
|
||||
|
||||
class _NoteEditDialogState extends State<NoteEditDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _editController;
|
||||
late FocusNode _noteFieldFocusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_noteFieldFocusNode = FocusNode();
|
||||
|
||||
_editController = TextEditingController(text: widget.note.content);
|
||||
}
|
||||
|
||||
void _onEdit(BuildContext context) {
|
||||
context.read<NoteBloc>().add(
|
||||
EditNote(
|
||||
content: _editController.text,
|
||||
noteId: widget.note.id.toString(),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: ListView(
|
||||
children: [
|
||||
Text(
|
||||
"Notiz bearbeiten",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
focusNode: _noteFieldFocusNode,
|
||||
onTapOutside: (_) {
|
||||
_noteFieldFocusNode.unfocus();
|
||||
},
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
_noteFieldFocusNode.unfocus();
|
||||
},
|
||||
decoration: InputDecoration(label: const Text("Notiz")),
|
||||
controller: _editController,
|
||||
minLines: 10,
|
||||
maxLines: 12,
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
_onEdit(context);
|
||||
},
|
||||
child: const Text("Bearbeiten"),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("Abbrechen"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class NoteLoadingFailPage extends StatelessWidget {
|
||||
const NoteLoadingFailPage({super.key, required this.delivery});
|
||||
|
||||
final Delivery delivery;
|
||||
|
||||
void _onRetry(BuildContext context) {
|
||||
context.read<NoteBloc>().add(LoadNote(delivery: delivery));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(50),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 30),
|
||||
child: Text(
|
||||
"Leider ist es beim Laden der Notizen zu einem Fehler gekommen.",
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 30),
|
||||
child: FilledButton(
|
||||
onPressed: () => _onRetry(context),
|
||||
child: Text("Erneut versuchen"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:carousel_slider/carousel_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class NoteImageOverview extends StatefulWidget {
|
||||
final List<ImageNote> images;
|
||||
final String deliveryId;
|
||||
|
||||
const NoteImageOverview({
|
||||
super.key,
|
||||
required this.images,
|
||||
required this.deliveryId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _NoteImageOverviewState();
|
||||
}
|
||||
|
||||
class _NoteImageOverviewState extends State<NoteImageOverview> {
|
||||
int? _imageDeleting;
|
||||
|
||||
void _onRemoveImage(int index) {
|
||||
ImageNote note = widget.images[index];
|
||||
|
||||
context.read<NoteBloc>().add(
|
||||
RemoveImageNote(objectId: note.objectId, deliveryId: widget.deliveryId),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageCarousel() {
|
||||
return CarouselSlider(
|
||||
options: CarouselOptions(
|
||||
height: 300.0,
|
||||
aspectRatio: 2.0,
|
||||
enableInfiniteScroll: false,
|
||||
),
|
||||
items:
|
||||
widget.images.mapIndexed((index, data) {
|
||||
Uint8List bytes = data.data!;
|
||||
|
||||
return Builder(
|
||||
builder: (BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(15.0),
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.fill,
|
||||
width: 1920.0,
|
||||
height: 1090.0,
|
||||
),
|
||||
),
|
||||
_imageDeleting == index
|
||||
? Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(15.0),
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: CircularProgressIndicator(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container(),
|
||||
Positioned(
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
child: IconButton.filled(
|
||||
onPressed:
|
||||
!(_imageDeleting == index)
|
||||
? () {
|
||||
_onRemoveImage(index);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.images.isEmpty
|
||||
? const Center(child: Text("Noch keine Bilder hochgeladen"))
|
||||
: _buildImageCarousel();
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_list_item.dart';
|
||||
|
||||
class NoteList extends StatelessWidget {
|
||||
final List<NoteInformation> notes;
|
||||
final String deliveryId;
|
||||
|
||||
const NoteList({super.key, required this.notes, required this.deliveryId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (notes.isEmpty) {
|
||||
return const Center(child: Text("keine Notizen vorhanden"));
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder:
|
||||
(context, index) => NoteListItem(
|
||||
note: notes[index],
|
||||
deliveryId: deliveryId,
|
||||
index: index,
|
||||
),
|
||||
separatorBuilder: (context, index) => const Divider(height: 0),
|
||||
itemCount: notes.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_edit_dialog.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
|
||||
enum NoteItemAction { noteEdit, noteDelete }
|
||||
|
||||
class NoteListItem extends StatelessWidget {
|
||||
final NoteInformation note;
|
||||
final String deliveryId;
|
||||
final int index;
|
||||
|
||||
const NoteListItem({
|
||||
super.key,
|
||||
required this.note,
|
||||
required this.deliveryId,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
void _onDelete(BuildContext context) {
|
||||
context.read<NoteBloc>().add(RemoveNote(noteId: note.note.id.toString()));
|
||||
}
|
||||
|
||||
Widget? _subtitle(BuildContext context) {
|
||||
String discountArticleId =
|
||||
(context.read<TourBloc>().state as TourLoaded)
|
||||
.tour
|
||||
.discountArticleNumber;
|
||||
|
||||
if (note.article != null &&
|
||||
note.article?.articleNumber == discountArticleId) {
|
||||
return const Text("Begründung der Gutschrift");
|
||||
}
|
||||
|
||||
return note.article != null ? Text(note.article!.name) : null;
|
||||
}
|
||||
|
||||
void _onEdit(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(_) => BlocProvider.value(
|
||||
value: context.read<NoteBloc>(),
|
||||
child: NoteEditDialog(note: note.note),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(0),
|
||||
child: ListTile(
|
||||
title: Text(note.note.content),
|
||||
subtitle: _subtitle(context),
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
leading: CircleAvatar(child: Text("${index + 1}")),
|
||||
trailing: PopupMenuButton<NoteItemAction>(
|
||||
onSelected: (NoteItemAction action) {
|
||||
switch (action) {
|
||||
case NoteItemAction.noteDelete:
|
||||
_onDelete(context);
|
||||
break;
|
||||
case NoteItemAction.noteEdit:
|
||||
_onEdit(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem<NoteItemAction>(
|
||||
value: NoteItemAction.noteEdit,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, color: Colors.blueAccent),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: const Text("Editieren"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<NoteItemAction>(
|
||||
value: NoteItemAction.noteDelete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: Colors.redAccent),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 5),
|
||||
child: const Text("Löschen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_add_dialog.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_image_overview.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_list.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
||||
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
enum NoteAction {
|
||||
addNote,
|
||||
addImage
|
||||
}
|
||||
|
||||
class NoteOverview extends StatefulWidget {
|
||||
final List<NoteInformation> notes;
|
||||
final List<NoteTemplate> templates;
|
||||
final List<ImageNote> images;
|
||||
final String deliveryId;
|
||||
|
||||
const NoteOverview({
|
||||
super.key,
|
||||
required this.notes,
|
||||
required this.deliveryId,
|
||||
required this.templates,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _NoteOverviewState();
|
||||
}
|
||||
|
||||
class _NoteOverviewState extends State<NoteOverview> {
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
Widget _notes() {
|
||||
for (final note in widget.notes) {
|
||||
debugPrint("Note: ${note.note.content}");
|
||||
debugPrint("NOTE Article: ${note.article?.name.toString()}");
|
||||
}
|
||||
|
||||
return NoteList(notes: widget.notes, deliveryId: widget.deliveryId);
|
||||
}
|
||||
|
||||
Widget _images() {
|
||||
return NoteImageOverview(
|
||||
images: widget.images,
|
||||
deliveryId: widget.deliveryId,
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddNote(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return BlocProvider.value(value: context.read<NoteBloc>(), child: NoteAddDialog(
|
||||
delivery: widget.deliveryId,
|
||||
templates: widget.templates,
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddImage(BuildContext context) async {
|
||||
XFile? file = await _imagePicker.pickImage(source: ImageSource.camera);
|
||||
if (file == null) {
|
||||
context.read<OperationBloc>().add(
|
||||
FailOperation(message: "Fehler beim Aufnehmen des Bildes"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<NoteBloc>().add(
|
||||
AddImageNote(file: file, deliveryId: widget.deliveryId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
"Notizen",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
_notes(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10, top: 10),
|
||||
child: Text(
|
||||
"Bilder",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
|
||||
_images(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 25),
|
||||
child: PopupMenuButton<NoteAction>(
|
||||
onSelected: (NoteAction action) {
|
||||
switch (action) {
|
||||
case NoteAction.addNote:
|
||||
_onAddNote(context);
|
||||
break;
|
||||
case NoteAction.addImage:
|
||||
_onAddImage(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem<NoteAction>(
|
||||
value: NoteAction.addNote,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.note_add_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: const Text("Notiz hinzufügen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<NoteAction>(
|
||||
value: NoteAction.addImage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: const Text("Bild hochladen"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_article_management.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_delivery_options.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_info.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/steps/step_summary.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
abstract class IStepFactory {
|
||||
Widget? make(int step, Delivery delivery);
|
||||
}
|
||||
|
||||
class StepFactory extends IStepFactory {
|
||||
@override
|
||||
Widget? make(int step, Delivery delivery) {
|
||||
switch(step) {
|
||||
case 0:
|
||||
return DeliveryStepInfo(delivery: delivery);
|
||||
case 1:
|
||||
return DeliveryStepNote(delivery: delivery);
|
||||
case 2:
|
||||
return DeliveryStepArticleManagement(delivery: delivery);
|
||||
case 3:
|
||||
return DeliveryStepOptions(delivery: delivery);
|
||||
case 4:
|
||||
return DeliveryStepSummary(delivery: delivery);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/article/article_list.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_discount.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class DeliveryStepArticleManagement extends StatefulWidget {
|
||||
final Delivery delivery;
|
||||
|
||||
const DeliveryStepArticleManagement({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepArticleManagement> {
|
||||
Widget _articleOverview() {
|
||||
TourLoaded tour = context.read<TourBloc>().state as TourLoaded;
|
||||
|
||||
return ArticleList(
|
||||
articles:
|
||||
widget.delivery.articles
|
||||
.where(
|
||||
(article) =>
|
||||
article.articleNumber != tour.tour.discountArticleNumber,
|
||||
)
|
||||
.toList(),
|
||||
deliveryId: widget.delivery.id,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _discountView() {
|
||||
return DeliveryDiscount(
|
||||
disabled: false,
|
||||
discount: widget.delivery.discount,
|
||||
deliveryId: widget.delivery.id,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
"Artikel",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
_articleOverview(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
child: Text(
|
||||
"Gutschriften",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
|
||||
_discountView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,395 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/scan_progress.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.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/delivery/bloc/tour_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/widget/discount_editor.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/widget/reason_catalog.dart';
|
||||
import 'package:hl_lieferservice/feature/loading/widget/reason_picker_sheet.dart';
|
||||
|
||||
/// Step 3 — Artikel & Gutschriften.
|
||||
///
|
||||
/// * **Mengen-Gutschrift** (Belegzeile ganz ODER teilweise entfernen) ist
|
||||
/// voll funktional über `RemoveItem`/`UnremoveItem` (mit `quantity`) →
|
||||
/// Backend Audit-Action `remove`/`unremove`. Die `creditedQuantity` des
|
||||
/// Items ist die Wahrheit (vom Server), kein lokaler Draft mehr.
|
||||
/// Regeln (serverseitig erzwungen): scannbare Position muss `done` sein,
|
||||
/// Lieferung muss `active` sein. Die UI spiegelt das als Gate + Hinweis.
|
||||
/// * **Betrags-Gutschrift** (≤ 150 € in 10er-Schritten + Grund) ist
|
||||
/// backend-gestützt über `SetDeliveryCredit`/`RemoveDeliveryCredit` am
|
||||
/// `TourBloc` → `POST /deliveries/{id}/credit`. Wahrheit ist der Server
|
||||
/// (`TourDetails.creditOf`), kein lokaler Draft mehr.
|
||||
class StepArticles extends StatelessWidget {
|
||||
const StepArticles({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
|
||||
// Komponenten direkt darunter eingerückt erscheinen.
|
||||
final items = List<DeliveryItem>.of(delivery.items)
|
||||
..sort((a, b) {
|
||||
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
|
||||
if (byLine != 0) return byLine;
|
||||
final byParent =
|
||||
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
|
||||
if (byParent != 0) return byParent;
|
||||
return (a.komponentenArtikelNr ?? '')
|
||||
.compareTo(b.komponentenArtikelNr ?? '');
|
||||
});
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_SectionHeader(text: 'Artikel'),
|
||||
const SizedBox(height: 8),
|
||||
if (delivery.state != DeliveryState.active) ...[
|
||||
const _LockedHint(
|
||||
text: 'Nur bei aktiver Lieferung änderbar.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (items.isEmpty)
|
||||
const _EmptyHint(text: 'Keine Artikel hinterlegt.')
|
||||
else
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
_ArticleManagementRow(
|
||||
item: items[i],
|
||||
details: details,
|
||||
deliveryId: delivery.id,
|
||||
deliveryActive: delivery.state == DeliveryState.active,
|
||||
),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Gutschriften'),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DiscountEditor(
|
||||
deliveryId: delivery.id,
|
||||
active: delivery.state == DeliveryState.active,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile in der Artikel-Verwaltung. Quelle der Wahrheit ist
|
||||
/// `item.scanProgress.creditedQuantity` (vom Backend) — kein lokaler Draft.
|
||||
/// Zeigt:
|
||||
/// - verbleibende Liefermenge (Soll − Gutschrift)
|
||||
/// - Gutschrift-Button → Mengen-Dialog (1…Restmenge + Grund), gesperrt für
|
||||
/// scannbare, noch nicht gescannte Positionen oder inaktive Lieferung
|
||||
/// - Wiederherstellen-Button, sobald etwas gutgeschrieben ist
|
||||
class _ArticleManagementRow extends StatelessWidget {
|
||||
const _ArticleManagementRow({
|
||||
required this.item,
|
||||
required this.details,
|
||||
required this.deliveryId,
|
||||
required this.deliveryActive,
|
||||
});
|
||||
|
||||
final DeliveryItem item;
|
||||
final TourDetails details;
|
||||
final String deliveryId;
|
||||
final bool deliveryActive;
|
||||
|
||||
Future<void> _openCreditDialog(
|
||||
BuildContext context, {
|
||||
required int remaining,
|
||||
}) async {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final actorCarId = _actorCarId(context);
|
||||
// Identische Auswahl wie im Beladen-/Scan-Screen: Grund-Picker
|
||||
// (Presets + „Anderer Grund") + Mengen-Stepper (Teilmengen-Gutschrift).
|
||||
final result = await showReasonPickerSheet(
|
||||
context: context,
|
||||
title: 'Grund für das Entfernen',
|
||||
presets: ReasonCatalog.itemRemove,
|
||||
confirmLabel: 'Entfernen',
|
||||
maxQuantity: remaining,
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
// Eine Aktion für ganz UND teilweise: das Backend kippt die Zeile auf
|
||||
// `removed`, sobald die volle Menge gutgeschrieben ist.
|
||||
tourBloc.add(RemoveItem(
|
||||
deliveryItemId: item.id,
|
||||
reason: result.reason,
|
||||
actorCarId: actorCarId,
|
||||
quantity: result.quantity,
|
||||
// Gutschrift-Grund zusätzlich als Lieferungs-Notiz festhalten.
|
||||
saveReasonAsNote: true,
|
||||
));
|
||||
}
|
||||
|
||||
void _restoreAll(BuildContext context) {
|
||||
// quantity: null → gesamte Gutschrift zurücknehmen.
|
||||
context.read<TourBloc>().add(UnremoveItem(
|
||||
deliveryItemId: item.id,
|
||||
actorCarId: _actorCarId(context),
|
||||
));
|
||||
}
|
||||
|
||||
/// Holt die aktuelle Auto-ID aus dem CarSelectBloc — gleiche Konvention
|
||||
/// wie der Loading-Flow: das aktiv gewählte Fahrzeug ist der „Akteur"
|
||||
/// des Audit-Events. Falls (defensiv) noch keine Auswahl getroffen ist,
|
||||
/// fallback auf einen Null-UUID-String, damit der Backend-Call nicht
|
||||
/// validation-failt.
|
||||
String _actorCarId(BuildContext context) {
|
||||
final state = context.read<CarSelectBloc>().state;
|
||||
if (state is CarSelectComplete) return state.selectedCar.id;
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final article = details.articleOf(item.articleId);
|
||||
final warehouse = details.warehouseOf(item.warehouseId);
|
||||
|
||||
final required = item.requiredQuantity;
|
||||
final credited = item.scanProgress.creditedQuantity;
|
||||
final remaining = required - credited;
|
||||
final fullyRemoved = item.isRemoved; // Status == removed (voll gutgeschr.)
|
||||
final partiallyCredited = credited > 0 && !fullyRemoved;
|
||||
|
||||
// Gate: scannbare Position muss `done` sein, sonst keine Gutschrift.
|
||||
final scannable = article?.scannable ?? false;
|
||||
final isDone = item.scanProgress.status == ScanStatus.done;
|
||||
final blockedByScan = scannable && !isDone && !fullyRemoved;
|
||||
final canCredit = deliveryActive && !blockedByScan && remaining > 0;
|
||||
|
||||
final Color avatarColor;
|
||||
final String avatarText;
|
||||
if (fullyRemoved) {
|
||||
avatarColor = Colors.red.shade400;
|
||||
avatarText = '0×';
|
||||
} else if (partiallyCredited) {
|
||||
avatarColor = Colors.amber.shade700;
|
||||
avatarText = '$remaining×';
|
||||
} else {
|
||||
avatarColor = theme.colorScheme.primary;
|
||||
avatarText = '$required×';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
||||
contentPadding:
|
||||
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: avatarColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
child: Text(
|
||||
avatarText,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: fullyRemoved ? TextDecoration.lineThrough : null,
|
||||
color: fullyRemoved ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
[
|
||||
article?.articleNumber ?? item.articleId,
|
||||
if (warehouse != null) warehouse.name,
|
||||
if (article?.scannable == false) 'Dienstleistung',
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (fullyRemoved)
|
||||
_StatusLine(
|
||||
text: 'Komplett gutgeschrieben'
|
||||
'${item.scanProgress.heldReason != null ? ' – ${item.scanProgress.heldReason}' : ''}',
|
||||
color: Colors.red.shade400,
|
||||
)
|
||||
else if (partiallyCredited)
|
||||
_StatusLine(
|
||||
text: '$credited von $required gutgeschrieben',
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
if (blockedByScan)
|
||||
_StatusLine(
|
||||
text: 'Erst scannen/verladen — dann Gutschrift möglich',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (credited > 0)
|
||||
IconButton(
|
||||
// Wiederherstellen nur bei aktiver Lieferung — bei
|
||||
// abgeschlossener/abgebrochener Lieferung gesperrt (greift auch
|
||||
// backend-seitig, hier zusätzlich in der UI).
|
||||
tooltip: deliveryActive
|
||||
? 'Gutschrift zurücknehmen'
|
||||
: 'Nur bei aktiver Lieferung',
|
||||
icon: Icon(
|
||||
Icons.restore,
|
||||
color: deliveryActive
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: deliveryActive ? () => _restoreAll(context) : null,
|
||||
),
|
||||
if (!fullyRemoved)
|
||||
IconButton.outlined(
|
||||
tooltip: blockedByScan
|
||||
? 'Erst scannen/verladen'
|
||||
: (!deliveryActive
|
||||
? 'Nur bei aktiver Lieferung'
|
||||
: 'Gutschrift / entfernen'),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
canCredit
|
||||
? Colors.redAccent
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
onPressed: canCredit
|
||||
? () => _openCreditDialog(context, remaining: remaining)
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
color: canCredit
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleine farbige Statuszeile unter dem Artikelnamen.
|
||||
class _StatusLine extends StatelessWidget {
|
||||
const _StatusLine({required this.text, required this.color});
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-Hinweis (analog DiscountEditor): zeigt mit Schloss-Symbol an, dass die
|
||||
/// Artikel-Aktionen (Entfernen / Wiederherstellen) nur bei aktiver Lieferung
|
||||
/// möglich sind.
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_options.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart' as model;
|
||||
|
||||
class DeliveryStepOptions extends StatefulWidget {
|
||||
final model.Delivery delivery;
|
||||
|
||||
const DeliveryStepOptions({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepOptions> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint(
|
||||
"${widget.delivery.options.map((option) => "${option.display}, ${option.value}")}",
|
||||
);
|
||||
return DeliveryOptionsView(
|
||||
options: widget.delivery.options,
|
||||
deliveryId: widget.delivery.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,86 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/note_state.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/model/note.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_fail_page.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/note/note_overview.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
|
||||
class DeliveryStepNote extends StatefulWidget {
|
||||
final Delivery delivery;
|
||||
|
||||
const DeliveryStepNote({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepNote> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<NoteBloc>().add(LoadNote(delivery: widget.delivery));
|
||||
}
|
||||
|
||||
Widget _notesLoading() {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
Widget _blocUndefinedState() {
|
||||
return Center(child: const Text("NoteBloc in einem Fehlerhaften Zustand"));
|
||||
}
|
||||
|
||||
Widget _notesOverview(
|
||||
BuildContext context,
|
||||
List<Note> notes,
|
||||
List<NoteTemplate> templates,
|
||||
List<ImageNote> images,
|
||||
) {
|
||||
List<NoteInformation> hydratedNotes =
|
||||
notes
|
||||
.map(
|
||||
(note) => NoteInformation(
|
||||
note: note,
|
||||
article: widget.delivery.findArticleWithNoteId(
|
||||
note.id.toString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return NoteOverview(
|
||||
notes: hydratedNotes,
|
||||
deliveryId: widget.delivery.id,
|
||||
templates: templates,
|
||||
images: images,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NoteBloc, NoteState>(
|
||||
builder: (context, state) {
|
||||
if (state is NoteLoading) {
|
||||
return _notesLoading();
|
||||
}
|
||||
|
||||
if (state is NoteLoaded) {
|
||||
return _notesOverview(
|
||||
context,
|
||||
state.notes,
|
||||
(state.templates ?? []),
|
||||
(state.images ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is NoteLoadingFailed) {
|
||||
return NoteLoadingFailPage(delivery: widget.delivery);
|
||||
}
|
||||
|
||||
return _blocUndefinedState();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
716
lib/feature/delivery/detail/presentation/steps/step_notes.dart
Normal file
716
lib/feature/delivery/detail/presentation/steps/step_notes.dart
Normal file
@ -0,0 +1,716 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_note.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.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/widget/attachment_image.dart';
|
||||
|
||||
/// Step 2 — Notizen & Fotos.
|
||||
///
|
||||
/// Die UI trennt bewusst in **zwei Sektionen**, weil es zwei
|
||||
/// unterschiedliche Dinge sind:
|
||||
/// * **Notizen** (Text): anlegen / bearbeiten / löschen über den
|
||||
/// `TourBloc` (Backend-Endpoints vorhanden).
|
||||
/// * **Fotos** (Bild-Notizen): `image_picker` → Upload über
|
||||
/// `TourBloc.UploadDeliveryNoteImage`. Das Backend schiebt das Bild nach
|
||||
/// DOCUframe und legt eine Notiz mit der Referenz an. Fotos werden als
|
||||
/// Thumbnail angezeigt (Tap → formatfüllend) und können nur gelöscht,
|
||||
/// nicht inline bearbeitet werden.
|
||||
///
|
||||
/// Datenmodell: beides sind `DeliveryNote`s. Unterschieden wird über
|
||||
/// `imageAttachment != null` (Foto) bzw. `text != null` (Notiz).
|
||||
class StepNotes extends StatelessWidget {
|
||||
const StepNotes({super.key, required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
void _openAddNoteDialog(BuildContext context) {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _NoteEditorDialog(
|
||||
title: 'Notiz hinzufügen',
|
||||
onSubmit: (text) => tourBloc.add(
|
||||
AddDeliveryNote(deliveryId: delivery.id, text: text),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(BuildContext context) async {
|
||||
// Bloc vor dem await greifen — danach kein context-Zugriff über den
|
||||
// async-Gap.
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final picker = ImagePicker();
|
||||
// Bild schon on-device runterskalieren + JPEG-komprimieren: Foto-Notizen
|
||||
// brauchen keine 12-MP-Originale. Spart Upload-/Speicher-/Report-Größe
|
||||
// (ein 4080×3060-Foto ~2,9 MB → ~200–400 KB). 1600 px / Q82 deckt sich mit
|
||||
// dem Backend-Report-Renderer.
|
||||
final file = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 1600,
|
||||
maxHeight: 1600,
|
||||
imageQuality: 82,
|
||||
);
|
||||
if (file == null) return;
|
||||
final bytes = await file.readAsBytes();
|
||||
tourBloc.add(
|
||||
UploadDeliveryNoteImage(
|
||||
deliveryId: delivery.id,
|
||||
filename: file.name,
|
||||
mime: file.mimeType ?? _mimeFromName(file.name),
|
||||
bytes: bytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Grober MIME-Fallback, wenn der Picker keinen Typ liefert (Kamera gibt
|
||||
/// meist JPEG). Reicht für das `Content-Type` des Multipart-Felds.
|
||||
String _mimeFromName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.heic')) return 'image/heic';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notes = details.notesOf(delivery.id);
|
||||
// Notizen & Fotos sind nur bei aktiver Lieferung änderbar. Ist die
|
||||
// Lieferung beendet (abgeschlossen/abgebrochen/pausiert), bleiben sie
|
||||
// sichtbar, aber read-only: kein FAB, keine Aktions-Menüs, kein Löschen.
|
||||
final active = delivery.state == DeliveryState.active;
|
||||
// Sauber in Text-Notizen und Fotos aufteilen — getrennte Sektionen.
|
||||
final textNotes =
|
||||
notes.where((n) => n.imageAttachment == null).toList(growable: false);
|
||||
final photoNotes =
|
||||
notes.where((n) => n.imageAttachment != null).toList(growable: false);
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
children: [
|
||||
if (!active) ...[
|
||||
const _ReadOnlyBanner(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_SectionHeader(text: 'Notizen (${textNotes.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (textNotes.isEmpty)
|
||||
const _EmptyHint(
|
||||
icon: Icons.notes,
|
||||
text: 'Noch keine Notizen erfasst.',
|
||||
)
|
||||
else
|
||||
for (final n in textNotes)
|
||||
_NoteCard(note: n, deliveryId: delivery.id, active: active),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Fotos (${photoNotes.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (photoNotes.isEmpty)
|
||||
const _EmptyHint(
|
||||
icon: Icons.photo_camera_outlined,
|
||||
text: 'Noch keine Fotos aufgenommen.',
|
||||
)
|
||||
else
|
||||
for (final n in photoNotes)
|
||||
_PhotoCard(note: n, deliveryId: delivery.id, active: active),
|
||||
],
|
||||
),
|
||||
// FAB nur bei aktiver Lieferung — sonst ist Hinzufügen gesperrt.
|
||||
if (active)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _AddMenu(
|
||||
onAddNote: () => _openAddNoteDialog(context),
|
||||
onAddImage: () => _pickImage(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hinweis-Balken oben in der Notiz-Sektion, wenn die Lieferung nicht mehr
|
||||
/// aktiv ist — Notizen/Fotos sind dann reine Anzeige.
|
||||
class _ReadOnlyBanner extends StatelessWidget {
|
||||
const _ReadOnlyBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Lieferung beendet — Notizen & Fotos können nicht mehr '
|
||||
'hinzugefügt, geändert oder gelöscht werden.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint({required this.icon, required this.text});
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _NoteAction { edit, delete }
|
||||
|
||||
/// Geteiltes Zeitformat für Notiz- und Foto-Karten.
|
||||
String _formatNoteTime(DateTime t) =>
|
||||
'${t.day.toString().padLeft(2, "0")}.${t.month.toString().padLeft(2, "0")}.${t.year} '
|
||||
'${t.hour.toString().padLeft(2, "0")}:${t.minute.toString().padLeft(2, "0")}';
|
||||
|
||||
/// Geteilter Lösch-Bestätigungsdialog. Wording variiert je nachdem, ob eine
|
||||
/// Text-Notiz oder ein Foto entfernt wird; gefeuert wird in beiden Fällen
|
||||
/// dasselbe `DeleteDeliveryNote`-Event (Foto ist intern auch eine Notiz).
|
||||
Future<void> _confirmDeleteNote(
|
||||
BuildContext context, {
|
||||
required String deliveryId,
|
||||
required String noteId,
|
||||
required bool isPhoto,
|
||||
}) async {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(isPhoto ? 'Foto löschen?' : 'Notiz löschen?'),
|
||||
content: Text(
|
||||
isPhoto
|
||||
? 'Das Foto wird dauerhaft entfernt.'
|
||||
: 'Die Notiz wird dauerhaft entfernt.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Löschen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
tourBloc.add(DeleteDeliveryNote(deliveryId: deliveryId, noteId: noteId));
|
||||
}
|
||||
|
||||
/// Karte einer **Text-Notiz**. Normale Notizen sind bearbeitbar und löschbar.
|
||||
/// **System-verwaltete** Grund-Notizen (Mengen-Gutschrift via
|
||||
/// `creditDeliveryItemId` oder Betrags-Gutschrift via `isAmountCreditNote`)
|
||||
/// dürfen vom Fahrer nicht manuell geändert/gelöscht werden — sie werden
|
||||
/// automatisch mit der jeweiligen Gutschrift angelegt und wieder entfernt.
|
||||
class _NoteCard extends StatelessWidget {
|
||||
const _NoteCard({
|
||||
required this.note,
|
||||
required this.deliveryId,
|
||||
required this.active,
|
||||
});
|
||||
final DeliveryNote note;
|
||||
final String deliveryId;
|
||||
|
||||
/// Nur bei aktiver Lieferung darf bearbeitet/gelöscht werden.
|
||||
final bool active;
|
||||
|
||||
void _openEditDialog(BuildContext context) {
|
||||
final tourBloc = context.read<TourBloc>();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _NoteEditorDialog(
|
||||
title: 'Notiz bearbeiten',
|
||||
initialText: note.text,
|
||||
onSubmit: (text) => tourBloc.add(
|
||||
UpdateDeliveryNote(
|
||||
deliveryId: deliveryId,
|
||||
noteId: note.id,
|
||||
text: text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// System-verwaltete Grund-Notiz: kein manuelles Bearbeiten/Löschen.
|
||||
final isSystemManaged =
|
||||
note.creditDeliveryItemId != null || note.isAmountCreditNote;
|
||||
// Aktions-Menü nur bei aktiver Lieferung UND nicht-System-Notiz.
|
||||
final canEdit = active && !isSystemManaged;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
note.text ?? '',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Personalnr. ${note.authorPersonalnummer} '
|
||||
'· ${_formatNoteTime(note.createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!canEdit)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 6),
|
||||
child: Tooltip(
|
||||
message: isSystemManaged
|
||||
? 'Automatisch verwaltet – wird mit der Gutschrift '
|
||||
'angelegt und beim Zurücknehmen wieder entfernt.'
|
||||
: 'Lieferung beendet – Notiz nicht mehr änderbar.',
|
||||
child: Icon(
|
||||
Icons.lock_outline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
PopupMenuButton<_NoteAction>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Notiz-Aktionen',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _NoteAction.edit:
|
||||
_openEditDialog(context);
|
||||
case _NoteAction.delete:
|
||||
_confirmDeleteNote(
|
||||
context,
|
||||
deliveryId: deliveryId,
|
||||
noteId: note.id,
|
||||
isPhoto: false,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: _NoteAction.edit,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit_outlined),
|
||||
SizedBox(width: 12),
|
||||
Text('Bearbeiten'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _NoteAction.delete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline,
|
||||
color: theme.colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Löschen',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Karte eines **Fotos** — Thumbnail (Tap → formatfüllend) plus Metazeile
|
||||
/// mit Lösch-Button. Kein Inline-Edit (ein Foto bearbeitet man nicht).
|
||||
class _PhotoCard extends StatelessWidget {
|
||||
const _PhotoCard({
|
||||
required this.note,
|
||||
required this.deliveryId,
|
||||
required this.active,
|
||||
});
|
||||
final DeliveryNote note;
|
||||
final String deliveryId;
|
||||
|
||||
/// Nur bei aktiver Lieferung darf das Foto gelöscht werden.
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_NoteImageThumb(
|
||||
attachmentId: note.imageAttachment!,
|
||||
deleted: note.imageAttachmentDeleted,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_camera_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Personalnr. ${note.authorPersonalnummer} '
|
||||
'· ${_formatNoteTime(note.createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
tooltip: 'Foto löschen',
|
||||
onPressed: () => _confirmDeleteNote(
|
||||
context,
|
||||
deliveryId: deliveryId,
|
||||
noteId: note.id,
|
||||
isPhoto: true,
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Tooltip(
|
||||
message: 'Lieferung beendet – Foto nicht mehr löschbar.',
|
||||
child: Icon(
|
||||
Icons.lock_outline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnail einer Bild-Notiz; Tap öffnet das Bild formatfüllend mit
|
||||
/// Zoom/Pan.
|
||||
class _NoteImageThumb extends StatelessWidget {
|
||||
const _NoteImageThumb({required this.attachmentId, this.deleted = false});
|
||||
|
||||
final String attachmentId;
|
||||
|
||||
/// Lokale Bilddatei nach Report-Upload gelöscht → Hinweis statt Vorschau.
|
||||
final bool deleted;
|
||||
|
||||
void _openFull(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 5,
|
||||
child: AttachmentImage(
|
||||
attachmentId: attachmentId,
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
quality: 90,
|
||||
fit: BoxFit.contain,
|
||||
deleted: deleted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Kein eigenes Clipping — die umgebende `_PhotoCard` clippt bereits
|
||||
// (Clip.antiAlias), sonst gäbe es doppelt gerundete Ecken.
|
||||
return GestureDetector(
|
||||
// Gelöschtes Bild → kein Vollbild öffnen (es gibt nichts zu laden).
|
||||
onTap: deleted ? null : () => _openFull(context),
|
||||
child: SizedBox(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
child: AttachmentImage(
|
||||
attachmentId: attachmentId,
|
||||
width: 600,
|
||||
height: 600,
|
||||
deleted: deleted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add-Menu (FAB) ─────────────────────────────────────────────────────
|
||||
|
||||
enum _AddAction { note, image }
|
||||
|
||||
class _AddMenu extends StatelessWidget {
|
||||
const _AddMenu({required this.onAddNote, required this.onAddImage});
|
||||
|
||||
final VoidCallback onAddNote;
|
||||
final VoidCallback onAddImage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<_AddAction>(
|
||||
tooltip: 'Hinzufügen',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _AddAction.note:
|
||||
onAddNote();
|
||||
case _AddAction.image:
|
||||
onAddImage();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: _AddAction.note,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit_note),
|
||||
SizedBox(width: 12),
|
||||
Text('Notiz schreiben'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _AddAction.image,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.camera_alt_outlined),
|
||||
SizedBox(width: 12),
|
||||
Text('Foto aufnehmen'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: null, // PopupMenuButton fängt den Tap
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Hinzufügen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notiz-Dialog (Text) ────────────────────────────────────────────────
|
||||
|
||||
/// Editor-Dialog für Text-Notizen — geteilt zwischen „Hinzufügen" und
|
||||
/// „Bearbeiten". Liefert den getrimmten Text per [onSubmit]; der Aufrufer
|
||||
/// entscheidet, ob daraus ein `AddDeliveryNote` oder `UpdateDeliveryNote`
|
||||
/// wird.
|
||||
class _NoteEditorDialog extends StatefulWidget {
|
||||
const _NoteEditorDialog({
|
||||
required this.title,
|
||||
required this.onSubmit,
|
||||
this.initialText,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final void Function(String text) onSubmit;
|
||||
final String? initialText;
|
||||
|
||||
@override
|
||||
State<_NoteEditorDialog> createState() => _NoteEditorDialogState();
|
||||
}
|
||||
|
||||
class _NoteEditorDialogState extends State<_NoteEditorDialog> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.initialText ?? '');
|
||||
bool _empty = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_empty = _controller.text.trim().isEmpty;
|
||||
_controller.addListener(() {
|
||||
setState(() => _empty = _controller.text.trim().isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
widget.onSubmit(text);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.55,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// TODO(B6): Templates-Dropdown ergänzen, sobald Backend
|
||||
// Notiz-Templates als Stammdaten anbietet.
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notiz',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _empty ? null : _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_service_value.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/service.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.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/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';
|
||||
|
||||
/// Step 4 — Services (früher „Lieferoptionen").
|
||||
///
|
||||
/// Rendert die aktiven Service-Definitionen (`TourDetails.services`,
|
||||
/// admin-konfigurierbar) und lässt den Fahrer sie pro Lieferung auswählen:
|
||||
/// `boolean` → Checkbox, `numeric` → Zahlenfeld mit min/max. Werte landen
|
||||
/// über den `TourBloc` (`SetDeliveryServiceValue`/`RemoveDeliveryServiceValue`)
|
||||
/// im Backend. Setzen nur bei aktiver Lieferung.
|
||||
class StepServices extends StatelessWidget {
|
||||
const StepServices({super.key, required this.delivery, required this.details});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
String _actorCarId(BuildContext context) {
|
||||
final state = context.read<CarSelectBloc>().state;
|
||||
if (state is CarSelectComplete) return state.selectedCar.id;
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final active = delivery.state == DeliveryState.active;
|
||||
|
||||
return BlocBuilder<TourBloc, TourState>(
|
||||
buildWhen: (a, b) {
|
||||
if (a is! TourLoaded || b is! TourLoaded) return true;
|
||||
return a.details.services != b.details.services ||
|
||||
a.details.serviceValuesByDeliveryId[delivery.id] !=
|
||||
b.details.serviceValuesByDeliveryId[delivery.id];
|
||||
},
|
||||
builder: (context, state) {
|
||||
final d = state is TourLoaded ? state.details : details;
|
||||
final services = d.services;
|
||||
|
||||
if (services.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.construction_outlined,
|
||||
size: 56, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text('Keine Services konfiguriert',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ein Administrator kann Services anlegen.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Wie im alten `delivery_options.dart`: zwei Kategorien —
|
||||
// „Auswählbare Optionen" (Checkboxen) und „Zahlenwerte".
|
||||
final bools =
|
||||
services.where((s) => s.kind == ServiceKind.boolean).toList();
|
||||
final numerics =
|
||||
services.where((s) => s.kind == ServiceKind.numeric).toList();
|
||||
|
||||
_ServiceTile tileFor(Service service) => _ServiceTile(
|
||||
service: service,
|
||||
value: d.serviceValueOf(delivery.id, service.id),
|
||||
enabled: active,
|
||||
onSetBool: (v) => context.read<TourBloc>().add(
|
||||
SetDeliveryServiceValue(
|
||||
deliveryId: delivery.id,
|
||||
serviceId: service.id,
|
||||
boolValue: v,
|
||||
actorCarId: _actorCarId(context),
|
||||
),
|
||||
),
|
||||
onSetNumeric: (n) => context.read<TourBloc>().add(
|
||||
SetDeliveryServiceValue(
|
||||
deliveryId: delivery.id,
|
||||
serviceId: service.id,
|
||||
numericValue: n,
|
||||
actorCarId: _actorCarId(context),
|
||||
),
|
||||
),
|
||||
onClear: () => context.read<TourBloc>().add(
|
||||
RemoveDeliveryServiceValue(
|
||||
deliveryId: delivery.id,
|
||||
serviceId: service.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget sectionCard(List<Service> items) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
tileFor(items[i]),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
if (!active)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Nur bei aktiver Lieferung änderbar.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (bools.isNotEmpty) ...[
|
||||
const _CategoryHeader(
|
||||
icon: Icons.check_box_outlined,
|
||||
text: 'Auswählbare Optionen',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
sectionCard(bools),
|
||||
],
|
||||
if (bools.isNotEmpty && numerics.isNotEmpty)
|
||||
const SizedBox(height: 24),
|
||||
if (numerics.isNotEmpty) ...[
|
||||
const _CategoryHeader(
|
||||
icon: Icons.pin_outlined,
|
||||
text: 'Zahlenwerte',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
sectionCard(numerics),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kategorie-Überschrift (Icon + Titel) — trennt Checkboxen von Zahlenwerten.
|
||||
class _CategoryHeader extends StatelessWidget {
|
||||
const _CategoryHeader({required this.icon, required this.text});
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Service-Zeile — Checkbox (boolean) oder Zahlenfeld (numeric).
|
||||
class _ServiceTile extends StatelessWidget {
|
||||
const _ServiceTile({
|
||||
required this.service,
|
||||
required this.value,
|
||||
required this.enabled,
|
||||
required this.onSetBool,
|
||||
required this.onSetNumeric,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final Service service;
|
||||
final DeliveryServiceValue? value;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> onSetBool;
|
||||
final ValueChanged<int> onSetNumeric;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (service.kind) {
|
||||
case ServiceKind.boolean:
|
||||
return CheckboxListTile(
|
||||
value: value?.boolValue ?? false,
|
||||
onChanged: enabled ? (v) => onSetBool(v ?? false) : null,
|
||||
title: Text(service.name),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
dense: true,
|
||||
);
|
||||
case ServiceKind.numeric:
|
||||
return _NumericServiceField(
|
||||
key: ValueKey(service.id),
|
||||
service: service,
|
||||
initial: value?.numericValue,
|
||||
enabled: enabled,
|
||||
onSetNumeric: onSetNumeric,
|
||||
onClear: onClear,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zahlenfeld eines numerischen Service — eigener Controller, persistiert beim
|
||||
/// Verlassen/Submit, klemmt auf [min,max]. Leeres Feld → Wert entfernen.
|
||||
class _NumericServiceField extends StatefulWidget {
|
||||
const _NumericServiceField({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.initial,
|
||||
required this.enabled,
|
||||
required this.onSetNumeric,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final Service service;
|
||||
final int? initial;
|
||||
final bool enabled;
|
||||
final ValueChanged<int> onSetNumeric;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
State<_NumericServiceField> createState() => _NumericServiceFieldState();
|
||||
}
|
||||
|
||||
class _NumericServiceFieldState extends State<_NumericServiceField> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.initial?.toString() ?? '');
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_NumericServiceField old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Server-Stand übernehmen, wenn er sich geändert hat (z. B. nach
|
||||
// Reconcile) und sich vom angezeigten Text unterscheidet.
|
||||
final incoming = widget.initial?.toString() ?? '';
|
||||
if (old.initial != widget.initial && _controller.text != incoming) {
|
||||
_controller.text = incoming;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
widget.onClear();
|
||||
return;
|
||||
}
|
||||
final parsed = int.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
_controller.text = widget.initial?.toString() ?? '';
|
||||
return;
|
||||
}
|
||||
var n = parsed;
|
||||
final min = widget.service.minValue;
|
||||
final max = widget.service.maxValue;
|
||||
if (min != null && n < min) n = min;
|
||||
if (max != null && n > max) n = max;
|
||||
if (n.toString() != _controller.text) {
|
||||
_controller.text = n.toString();
|
||||
}
|
||||
widget.onSetNumeric(n);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = widget.service;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: s.name,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onTapOutside: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
_commit();
|
||||
},
|
||||
onSubmitted: (_) => _commit(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,514 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_summary.dart';
|
||||
import 'package:hl_lieferservice/model/delivery.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_credit.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
|
||||
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_bloc.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_event.dart';
|
||||
import 'package:hl_lieferservice/feature/delivery/detail/bloc/workflow_state.dart';
|
||||
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
|
||||
|
||||
/// Step 5 — Übersicht & Abschluss.
|
||||
///
|
||||
/// Listet alle Artikel mit der **tatsächlich auszuliefernden Menge** auf
|
||||
/// (Original-Soll minus lokaler Partial-Remove-Drafts minus
|
||||
/// Komplett-Removes). Dazu Anzahlung-Anzeige, optionale Gutschrift,
|
||||
/// Zahlungsmethoden-Dropdown.
|
||||
///
|
||||
/// Der „Unterschreiben"-Button lebt in der Bottom-Navigation des
|
||||
/// Page-Wrappers; hier zeigen wir den Resümee-Block, der direkt vor der
|
||||
/// Unterschrift steht.
|
||||
class StepSummary extends StatelessWidget {
|
||||
const StepSummary({
|
||||
super.key,
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
class DeliveryStepSummary extends StatefulWidget {
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
const DeliveryStepSummary({required this.delivery, super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DeliveryStepInfo();
|
||||
}
|
||||
|
||||
class _DeliveryStepInfo extends State<DeliveryStepSummary> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DeliverySummary(delivery: widget.delivery);
|
||||
return BlocBuilder<DeliveryWorkflowBloc, DeliveryWorkflowState>(
|
||||
builder: (context, wfState) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_SectionHeader(text: 'Ausgelieferte Artikel'),
|
||||
const SizedBox(height: 8),
|
||||
_DeliveredItems(
|
||||
delivery: delivery,
|
||||
details: details,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Zahlung'),
|
||||
const SizedBox(height: 8),
|
||||
_PaymentSummary(
|
||||
delivery: delivery,
|
||||
credit: details.creditOf(delivery.id),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeader(text: 'Zahlungsmethode'),
|
||||
const SizedBox(height: 8),
|
||||
_PaymentMethodPicker(
|
||||
delivery: delivery,
|
||||
overrideId: wfState.paymentMethodOverrideId,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _SignHint(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeliveredItems extends StatelessWidget {
|
||||
const _DeliveredItems({
|
||||
required this.delivery,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Innerhalb einer Belegzeile: Oberartikel vor seinen Komponenten, damit
|
||||
// Komponenten direkt darunter eingerückt erscheinen.
|
||||
final items = List<DeliveryItem>.of(delivery.items)
|
||||
..sort((a, b) {
|
||||
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
|
||||
if (byLine != 0) return byLine;
|
||||
final byParent =
|
||||
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
|
||||
if (byParent != 0) return byParent;
|
||||
return (a.komponentenArtikelNr ?? '')
|
||||
.compareTo(b.komponentenArtikelNr ?? '');
|
||||
});
|
||||
if (items.isEmpty) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Keine Artikel hinterlegt.',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
_DeliveredRow(
|
||||
item: items[i],
|
||||
details: details,
|
||||
),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeliveredRow extends StatelessWidget {
|
||||
const _DeliveredRow({
|
||||
required this.item,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
final DeliveryItem item;
|
||||
final TourDetails details;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final article = details.articleOf(item.articleId);
|
||||
// Ausgeliefert = Soll − Gutschrift (vom Backend). Voll gutgeschrieben
|
||||
// (status removed) ⇒ credited == required ⇒ delivered 0.
|
||||
final credited = item.scanProgress.creditedQuantity;
|
||||
final delivered = (item.requiredQuantity - credited).clamp(
|
||||
0,
|
||||
item.requiredQuantity,
|
||||
);
|
||||
|
||||
final Color avatarColor;
|
||||
if (delivered == 0) {
|
||||
avatarColor = Colors.red.shade400;
|
||||
} else if (delivered < item.requiredQuantity) {
|
||||
avatarColor = Colors.amber.shade700;
|
||||
} else {
|
||||
avatarColor = Colors.green.shade600;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
// Komponenten um eine Stufe eingerückt (gehören zum Oberartikel darüber).
|
||||
contentPadding:
|
||||
EdgeInsets.only(left: item.isComponent ? 40 : 16, right: 16),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: avatarColor,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
child: Text(
|
||||
'$delivered×',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'${item.isComponent ? '↳ ' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (delivered < item.requiredQuantity)
|
||||
'von ${item.requiredQuantity} bestellt · Gutschrift: $credited'
|
||||
else
|
||||
'Artikelnr. ${article?.articleNumber ?? item.articleId}',
|
||||
'${item.unitPrice.toStringAsFixed(2)} € / Stück',
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${item.lineTotal.toStringAsFixed(2)} €',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: delivered == 0 ? TextDecoration.lineThrough : null,
|
||||
color: delivered == 0 ? theme.colorScheme.onSurfaceVariant : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentSummary extends StatelessWidget {
|
||||
const _PaymentSummary({required this.delivery, required this.credit});
|
||||
|
||||
final Delivery delivery;
|
||||
final DeliveryCredit? credit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Exakt aus Cent (nicht gerundet) — Gutschrift kann Cent-Beträge haben.
|
||||
final creditEuros = (credit?.amountCents ?? 0) / 100.0;
|
||||
// Warenwert = Σ Stückpreis × ausgelieferte Menge (entfernte/teil-entfernte
|
||||
// Positionen fallen automatisch raus).
|
||||
final warenwert = delivery.items
|
||||
.fold<double>(0, (acc, item) => acc + item.lineTotal);
|
||||
// Offener Betrag = Warenwert − Anzahlung − Gutschrift, nie negativ.
|
||||
final open = (warenwert - delivery.prepaidAmount - creditEuros)
|
||||
.clamp(0.0, double.infinity);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_SummaryRow(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
label: 'Warenwert',
|
||||
valueText: '${warenwert.toStringAsFixed(2)} €',
|
||||
valueColor: theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SummaryRow(
|
||||
icon: Icons.savings_outlined,
|
||||
label: 'Bei Bestellung bezahlt',
|
||||
valueText: '− ${delivery.prepaidAmount.toStringAsFixed(2)} €',
|
||||
valueColor: delivery.prepaidAmount > 0
|
||||
? Colors.green.shade700
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
if (credit != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_SummaryRow(
|
||||
icon: Icons.card_giftcard_outlined,
|
||||
label: 'Gutschrift',
|
||||
valueText: '− ${(credit!.amountCents / 100).toStringAsFixed(2)} €',
|
||||
valueColor: Colors.amber.shade800,
|
||||
subtitle: credit!.reason,
|
||||
),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
_SummaryRow(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: 'Offener Betrag',
|
||||
valueText: '${open.toStringAsFixed(2)} €',
|
||||
valueColor: open > 0
|
||||
? theme.colorScheme.primary
|
||||
: Colors.green.shade700,
|
||||
emphasize: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryRow extends StatelessWidget {
|
||||
const _SummaryRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.valueText,
|
||||
required this.valueColor,
|
||||
this.subtitle,
|
||||
this.emphasize = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String valueText;
|
||||
final Color valueColor;
|
||||
final String? subtitle;
|
||||
|
||||
/// Hebt Label + Wert hervor (für den „Offener Betrag"-Abschluss).
|
||||
final bool emphasize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(icon, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: emphasize
|
||||
? const TextStyle(fontWeight: FontWeight.w700)
|
||||
: null,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
valueText,
|
||||
style: (emphasize
|
||||
? theme.textTheme.titleLarge
|
||||
: theme.textTheme.titleMedium)
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentMethodPicker extends StatelessWidget {
|
||||
const _PaymentMethodPicker({
|
||||
required this.delivery,
|
||||
required this.overrideId,
|
||||
});
|
||||
|
||||
final Delivery delivery;
|
||||
final String? overrideId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PaymentMethodsCubit, PaymentMethodsState>(
|
||||
builder: (context, state) {
|
||||
if (state is PaymentMethodsLoading || state is PaymentMethodsInitial) {
|
||||
return const Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('Zahlungsmethoden laden …'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is PaymentMethodsFailed) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final loaded = state as PaymentMethodsLoaded;
|
||||
// Ausschließlich die Backend-Methoden — keine frontend-seitige
|
||||
// Fabrikation/Hardcodierung. Es werden genau die angezeigt, die im
|
||||
// Backend (Postgres `payment_methods`, aktiv) hinterlegt sind.
|
||||
final methods = loaded.methods;
|
||||
final selectedId = overrideId ?? delivery.paymentMethodId;
|
||||
// Als Dropdown-Value nur setzen, wenn die Methode tatsächlich in der
|
||||
// Backend-Liste ist (sonst würde Flutter asserten). Ist die zugewiesene
|
||||
// Methode zwischenzeitlich deaktiviert/entfernt, bleibt das Feld leer.
|
||||
final selectedValue =
|
||||
methods.any((m) => m.id == selectedId) ? selectedId : null;
|
||||
// Zahlungsmethode nur bei aktiver Lieferung änderbar. Bei
|
||||
// abgeschlossener/abgebrochener/pausierter Lieferung zeigt das
|
||||
// Dropdown den gewählten Stand, ist aber gesperrt.
|
||||
final active = delivery.state == DeliveryState.active;
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selectedValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Zahlungsmethode',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final m in methods)
|
||||
DropdownMenuItem(
|
||||
value: m.id,
|
||||
child: Text(m.name),
|
||||
),
|
||||
],
|
||||
// `null` deaktiviert das Dropdown (Flutter-Konvention).
|
||||
onChanged: active
|
||||
? (newId) {
|
||||
if (newId == null) return;
|
||||
context.read<DeliveryWorkflowBloc>().add(
|
||||
WorkflowOverridePaymentMethod(
|
||||
// Zurück auf die Original-Methode → Override
|
||||
// löschen, damit das Domain-Modell "no
|
||||
// override" kennt.
|
||||
paymentMethodId:
|
||||
newId == delivery.paymentMethodId
|
||||
? null
|
||||
: newId,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (!active) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Lieferung abgeschlossen — Zahlungsmethode nicht '
|
||||
'mehr änderbar.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignHint extends StatelessWidget {
|
||||
const _SignHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.draw_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Mit „Unterschreiben" unten schließt der Kunde den Vorgang ab.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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