Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -1,172 +0,0 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:rxdart/rxdart.dart';
import '../../../../model/delivery.dart';
import 'note_event.dart';
import 'note_state.dart';
import 'package:hl_lieferservice/feature/delivery/detail/repository/note_repository.dart';
class NoteBloc extends Bloc<NoteEvent, NoteState> {
final NoteRepository repository;
final OperationBloc opBloc;
final AuthBloc authBloc;
final String deliveryId;
StreamSubscription? _combinedSubscription;
NoteBloc({
required this.repository,
required this.opBloc,
required this.authBloc,
required this.deliveryId,
}) : super(NoteInitial()) {
_combinedSubscription = CombineLatestStream.combine3(
repository.notes,
repository.images,
repository.templates,
(note, image, templates) {
if (note == null || image == null || templates == null) {
return null;
}
return {"note": note, "image": image, "templates": templates};
},
)
.where((data) => data != null)
.listen(
(data) => add(
DataUpdated(
images: data!["image"] as List<ImageNote>,
notes: data["note"] as List<Note>,
templates: data["templates"] as List<NoteTemplate>,
),
),
);
on<LoadNote>(_load);
on<AddNote>(_add);
on<EditNote>(_edit);
on<RemoveNote>(_remove);
on<AddImageNote>(_upload);
on<RemoveImageNote>(_removeImage);
on<ResetNotes>(_reset);
on<DataUpdated>(_dataUpdated);
}
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
Future<void> _dataUpdated(DataUpdated event, Emitter<NoteState> emit) async {
emit(
NoteLoaded(
notes: event.notes,
images: event.images,
templates: event.templates,
),
);
}
Future<void> _reset(ResetNotes event, Emitter<NoteState> emit) async {
emit.call(NoteInitial());
}
Future<void> _removeImage(
RemoveImageNote event,
Emitter<NoteState> emit,
) async {
opBloc.add(StartOperation());
try {
await repository.deleteImage(event.deliveryId, event.objectId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Bildes: $e $st");
_handleError(e, "Fehler beim Löschen des Bildes");
}
}
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
Uint8List imageBytes = await event.file.readAsBytes();
await repository.addImage(event.deliveryId, imageBytes);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Bildes");
}
}
Future<void> _load(LoadNote event, Emitter<NoteState> emit) async {
if (state is NoteLoaded || state is NoteLoading) {
return;
}
emit.call(NoteLoading());
try {
await repository.loadNotes(event.delivery.id);
await repository.loadTemplates();
} catch (e, st) {
debugPrint("Fehler beim Herunterladen der Notizen: $e $st");
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden."));
emit.call(NoteLoadingFailed());
}
}
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.addNote(event.deliveryId, event.note);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
_handleError(e, "Fehler beim Hinzufügen der Notiz");
}
}
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.editNote(event.noteId, event.content);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Editieren der Notiz: $e $st");
_handleError(e, "Fehler beim Editieren der Notiz");
}
}
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
opBloc.add(StartOperation());
try {
await repository.deleteNote(event.noteId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen der Notiz: $e $st");
_handleError(e, "Notiz konnte nicht gelöscht werden");
}
}
}

View File

@ -1,75 +0,0 @@
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:image_picker/image_picker.dart';
abstract class NoteEvent {}
class LoadNote extends NoteEvent {
LoadNote({required this.delivery});
final Delivery delivery;
}
class ResetNotes extends NoteEvent {}
class AddNote extends NoteEvent {
AddNote({required this.note, required this.deliveryId});
final String note;
final String deliveryId;
}
class AddNoteOffline extends NoteEvent {
AddNoteOffline({required this.note, required this.deliveryId, required this.noteId});
final String note;
final String noteId;
final String deliveryId;
}
class RemoveNote extends NoteEvent {
RemoveNote({required this.noteId});
final String noteId;
}
class EditNote extends NoteEvent {
EditNote({required this.content, required this.noteId});
final String noteId;
final String content;
}
class AddImageNote extends NoteEvent {
AddImageNote({required this.file, required this.deliveryId});
final XFile file;
final String deliveryId;
}
class RemoveImageNote extends NoteEvent {
RemoveImageNote({required this.objectId, required this.deliveryId});
final String objectId;
final String deliveryId;
}
class NotesUpdated extends NoteEvent {
final List<Note> notes;
NotesUpdated({required this.notes});
}
class ImageUpdated extends NoteEvent {
final List<ImageNote> images;
ImageUpdated({required this.images});
}
class DataUpdated extends NoteEvent {
final List<ImageNote> images;
final List<Note> notes;
final List<NoteTemplate> templates;
DataUpdated({required this.images, required this.notes, required this.templates});
}

View File

@ -1,41 +0,0 @@
import 'package:hl_lieferservice/model/delivery.dart';
abstract class NoteState {}
class NoteInitial extends NoteState {}
class NoteLoading extends NoteState {}
class NoteLoadingFailed extends NoteState {}
class NoteLoadedBase extends NoteState {
NoteLoadedBase({
required this.notes,
});
List<Note> notes;
}
class NoteLoaded extends NoteLoadedBase {
NoteLoaded({
this.templates,
this.images,
required super.notes,
});
List<NoteTemplate>? templates;
List<ImageNote>? images;
NoteLoaded copyWith({
List<Note>? notes,
List<NoteTemplate>? templates,
List<ImageNote>? images,
}) {
return NoteLoaded(
notes: notes ?? this.notes,
templates: templates ?? this.templates,
images: images ?? this.images,
);
}
}

View File

@ -0,0 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'workflow_event.dart';
import 'workflow_state.dart';
/// Bloc, der den Detail-Workflow einer einzelnen Lieferung trägt.
///
/// Pro `DeliveryDetail`-Page-Push neu instanziert (per `BlocProvider` direkt
/// in der Page), Lifetime endet beim Pop. Strukturelle Persistenzen
/// (Notizen speichern, Artikel entfernen, Complete) gehen NICHT durch
/// diesen Bloc, sondern direkt am globalen `TourBloc` — der Workflow-Bloc
/// trägt nur Step-State + lokale Drafts.
class DeliveryWorkflowBloc
extends Bloc<DeliveryWorkflowEvent, DeliveryWorkflowState> {
DeliveryWorkflowBloc({required String deliveryId})
: super(DeliveryWorkflowState.initial(deliveryId)) {
on<WorkflowGoToStep>((e, emit) => emit(state.copyWith(step: e.step)));
on<WorkflowNextStep>((e, emit) {
final next = state.step.index + 1;
if (next < WorkflowStep.values.length) {
emit(state.copyWith(step: WorkflowStep.values[next]));
}
});
on<WorkflowPreviousStep>((e, emit) {
final prev = state.step.index - 1;
if (prev >= 0) {
emit(state.copyWith(step: WorkflowStep.values[prev]));
}
});
on<WorkflowAddPendingImage>((e, emit) {
final next = [
...state.pendingImageNotes,
PendingImageNote(file: e.file, pickedAt: DateTime.now()),
];
emit(state.copyWith(pendingImageNotes: next));
});
on<WorkflowRemovePendingImage>((e, emit) {
if (e.index < 0 || e.index >= state.pendingImageNotes.length) return;
final next = [...state.pendingImageNotes]..removeAt(e.index);
emit(state.copyWith(pendingImageNotes: next));
});
on<WorkflowOverridePaymentMethod>((e, emit) => emit(
state.copyWith(paymentMethodOverrideId: e.paymentMethodId),
));
}
}

View File

@ -0,0 +1,43 @@
import 'package:image_picker/image_picker.dart';
import 'workflow_state.dart';
/// Events des Detail-Workflow-Blocs. Strukturelle Aktionen (Notiz speichern,
/// Artikel entfernen, Lieferung abschließen) dispatchen NICHT hier, sondern
/// am `TourBloc` — der Workflow-Bloc kümmert sich nur um Step-Navigation
/// und Drafts.
sealed class DeliveryWorkflowEvent {
const DeliveryWorkflowEvent();
}
class WorkflowGoToStep extends DeliveryWorkflowEvent {
const WorkflowGoToStep(this.step);
final WorkflowStep step;
}
class WorkflowNextStep extends DeliveryWorkflowEvent {
const WorkflowNextStep();
}
class WorkflowPreviousStep extends DeliveryWorkflowEvent {
const WorkflowPreviousStep();
}
// ─── Bild-Notiz-Drafts ──────────────────────────────────────────────────
class WorkflowAddPendingImage extends DeliveryWorkflowEvent {
const WorkflowAddPendingImage(this.file);
final XFile file;
}
class WorkflowRemovePendingImage extends DeliveryWorkflowEvent {
const WorkflowRemovePendingImage(this.index);
final int index;
}
// ─── Payment-Auswahl ────────────────────────────────────────────────────
class WorkflowOverridePaymentMethod extends DeliveryWorkflowEvent {
const WorkflowOverridePaymentMethod({required this.paymentMethodId});
final String? paymentMethodId;
}

View File

@ -0,0 +1,87 @@
import 'package:image_picker/image_picker.dart';
/// Die 5 Steps der Auslieferungs-Detail-Page. Reihenfolge ≙ Index.
enum WorkflowStep {
info,
notes,
articles,
services,
summary,
}
extension WorkflowStepX on WorkflowStep {
String get displayName => switch (this) {
WorkflowStep.info => 'Info',
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel & Gutschriften',
WorkflowStep.services => 'Services',
WorkflowStep.summary => 'Übersicht',
};
/// Kurze Bezeichnung für den Header-Step (Platz ist eng auf Mobilgeräten).
String get shortName => switch (this) {
WorkflowStep.info => 'Info',
WorkflowStep.notes => 'Notizen',
WorkflowStep.articles => 'Artikel',
WorkflowStep.services => 'Services',
WorkflowStep.summary => 'Übersicht',
};
}
/// Eine im Workflow geparkte Bild-Notiz, die noch nicht hochgeladen werden
/// kann — wartet auf den Foto-Upload-Endpoint.
class PendingImageNote {
const PendingImageNote({required this.file, required this.pickedAt});
/// Das vom `image_picker` zurückgegebene File-Handle.
final XFile file;
final DateTime pickedAt;
}
/// State des Detail-Workflows. Ein State, ein Bloc — der Step-Wechsel,
/// die Drafts und die Payment-Auswahl liegen alle hier. So sieht jede
/// Step-Page denselben kohärenten Zustand und ein Step kann Daten aus
/// einem anderen lesen (z. B. Summary liest Article-Drafts).
class DeliveryWorkflowState {
const DeliveryWorkflowState({
required this.deliveryId,
required this.step,
required this.pendingImageNotes,
required this.paymentMethodOverrideId,
});
factory DeliveryWorkflowState.initial(String deliveryId) =>
DeliveryWorkflowState(
deliveryId: deliveryId,
step: WorkflowStep.info,
pendingImageNotes: const [],
paymentMethodOverrideId: null,
);
final String deliveryId;
final WorkflowStep step;
/// Lokal gehaltene Bild-Notizen — solange kein Upload-Endpoint da ist.
final List<PendingImageNote> pendingImageNotes;
/// Wenn der Fahrer im Summary die Zahlungsmethode überschreibt, landet
/// die neue Id hier. `null` = Methode der Lieferung bleibt.
final String? paymentMethodOverrideId;
DeliveryWorkflowState copyWith({
WorkflowStep? step,
List<PendingImageNote>? pendingImageNotes,
Object? paymentMethodOverrideId = _sentinel,
}) {
return DeliveryWorkflowState(
deliveryId: deliveryId,
step: step ?? this.step,
pendingImageNotes: pendingImageNotes ?? this.pendingImageNotes,
paymentMethodOverrideId: identical(paymentMethodOverrideId, _sentinel)
? this.paymentMethodOverrideId
: paymentMethodOverrideId as String?,
);
}
}
const Object _sentinel = Object();

View File

@ -1 +0,0 @@
class NoteImageAddException implements Exception {}

View File

@ -1,10 +0,0 @@
import 'package:hl_lieferservice/model/article.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class NoteInformation {
NoteInformation({required this.note, this.article});
Note note;
Article? article;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}
}

View File

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

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

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

View File

@ -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

View File

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

View 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 → ~200400 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'),
),
],
),
],
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

@ -1,57 +0,0 @@
import 'dart:typed_data';
import 'package:hl_lieferservice/dto/discount_add_response.dart';
import 'package:hl_lieferservice/dto/discount_remove_response.dart';
import 'package:hl_lieferservice/dto/discount_update_response.dart';
import 'package:hl_lieferservice/feature/delivery/detail/repository/note_repository.dart';
import 'package:hl_lieferservice/feature/delivery/detail/service/notes_service.dart';
import 'package:hl_lieferservice/feature/delivery/service/tour_service.dart';
import 'package:hl_lieferservice/model/delivery.dart';
class DeliveryRepository {
DeliveryRepository({required this.service});
TourService service;
Future<String?> unscan(String articleId, int newAmount, String reason) async {
return await service.unscanArticle(articleId, newAmount, reason);
}
Future<void> resetScan(String articleId) async {
return await service.resetScannedArticleAmount(articleId);
}
Future<void> uploadDriverSignature(String deliveryId, Uint8List signature) async {
NoteRepository noteRepository = NoteRepository(service: NoteService());
await noteRepository.addNamedImage(deliveryId, signature, "delivery_${deliveryId}_signature_driver.jpg");
}
Future<void> uploadCustomerSignature(String deliveryId, Uint8List signature) async {
NoteRepository noteRepository = NoteRepository(service: NoteService());
await noteRepository.addNamedImage(deliveryId, signature, "delivery_${deliveryId}_signature_customer.jpg");
}
Future<DiscountAddResponseDTO> addDiscount(
String deliveryId,
String reason,
int value,
) {
return service.addDiscount(deliveryId, value, reason);
}
Future<DiscountRemoveResponseDTO> removeDiscount(String deliveryId) {
return service.removeDiscount(deliveryId);
}
Future<DiscountUpdateResponseDTO> updateDiscount(
String deliveryId,
String? reason,
int? value,
) {
return service.updateDiscount(deliveryId, reason, value);
}
Future<void> updateDelivery(Delivery delivery) {
return service.updateDelivery(delivery);
}
}

View File

@ -1,96 +0,0 @@
import 'dart:typed_data';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/feature/delivery/detail/service/notes_service.dart';
import 'package:rxdart/rxdart.dart';
class NoteRepository {
final NoteService service;
final _notesStream = BehaviorSubject<List<Note>?>.seeded(null);
final _imageNoteStream = BehaviorSubject<List<ImageNote>?>.seeded(null);
final _noteTemplateStream = BehaviorSubject<List<NoteTemplate>?>.seeded(null);
Stream<List<Note>?> get notes => _notesStream.stream;
Stream<List<ImageNote>?> get images => _imageNoteStream.stream;
Stream<List<NoteTemplate>?> get templates => _noteTemplateStream.stream;
List<Note> get _currentNotes => _notesStream.value ?? [];
List<ImageNote> get _currentImages => _imageNoteStream.value ?? [];
NoteRepository({required this.service});
Future<void> addNote(String deliveryId, String content) async {
final note = await service.addNote(content, int.parse(deliveryId));
_currentNotes.add(note);
_notesStream.add(_currentNotes);
}
Future<void> editNote(String noteId, String content) async {
final newNote = Note(content: content, id: int.parse(noteId));
await service.editNote(newNote);
final currentNotes = _notesStream.value;
final index = _currentNotes.indexWhere((note) => note.id == int.parse(noteId));
if (index != -1) {
_currentNotes[index] = newNote;
_notesStream.add(currentNotes);
}
}
Future<void> deleteNote(String noteId) async {
await service.deleteNote(int.parse(noteId));
final currentNotes = _notesStream.value;
final index = _currentNotes.indexWhere((note) => note.id == int.parse(noteId));
_currentNotes.removeAt(index);
_notesStream.add(currentNotes);
}
Future<void> loadNotes(String deliveryId) async {
var (notes, images) = await service.getNotes(deliveryId);
_notesStream.add(notes);
_imageNoteStream.add(images);
}
Future<void> loadTemplates() async {
_noteTemplateStream.add(await service.getNoteTemplates());
}
Future<void> addImage(String deliveryId, Uint8List bytes) async {
final fileName =
"delivery_note_${deliveryId}_${DateTime.timestamp().microsecondsSinceEpoch}.jpg";
String objectId = await service.uploadImage(
deliveryId,
fileName,
bytes,
"image/png",
);
_currentImages.add(ImageNote.make(objectId, fileName, bytes));
_imageNoteStream.add(_currentImages);
}
Future<void> addNamedImage(String deliveryId, Uint8List bytes, String filename) async {
String objectId = await service.uploadImage(
deliveryId,
filename,
bytes,
"image/png",
);
_currentImages.add(ImageNote.make(objectId, filename, bytes));
_imageNoteStream.add(_currentImages);
}
Future<void> deleteImage(String deliveryId, String objectId) async {
await service.removeImage(objectId);
final index = _currentImages.indexWhere((imageNote) => imageNote.objectId == objectId);
_currentImages.removeAt(index);
_imageNoteStream.add(_currentImages);
}
}

View File

@ -1,327 +0,0 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:hl_lieferservice/dto/note_get_response.dart';
import 'package:hl_lieferservice/feature/delivery/detail/exceptions.dart';
import 'package:hl_lieferservice/services/erpframe.dart';
import 'package:flutter/cupertino.dart';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import '../../../../dto/basic_response.dart';
import '../../../../dto/note_add_response.dart';
import '../../../../dto/note_template_response.dart';
import '../../../../model/delivery.dart';
import '../../../../util.dart';
import '../../../authentication/exceptions.dart';
class NoteService {
Future<void> deleteNote(int noteId) async {
try {
var response = await http.post(
urlBuilder("_web_deleteNote"),
headers: getSessionOrThrow(),
body: {"id": noteId.toString()},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint("NOTE DELETE: ${response.body}");
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE DELETING NOTE $noteId");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<void> editNote(Note newNote) async {
try {
var response = await http.post(
urlBuilder("_web_editNote"),
headers: getSessionOrThrow(),
body: {"id": newNote.id.toString(), "note": newNote.content},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE EDITING NOTE ${newNote.id}");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<List<NoteTemplate>> getNoteTemplates() async {
try {
var response = await http.post(
urlBuilder("_web_getNoteTemplates"),
headers: getSessionOrThrow(),
body: {},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
NoteTemplateResponseDTO responseDto = NoteTemplateResponseDTO.fromJson(
responseJson,
);
if (responseDto.succeeded == true) {
return responseDto.notes.map(NoteTemplate.fromDTO).toList();
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE GETTING NOTE TEMPLATES");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<(List<Note>, List<ImageNote>)> getNotes(String deliveryId) async {
try {
var response = await http.post(
urlBuilder("_web_getNotes"),
headers: getSessionOrThrow(),
body: {"delivery_id": deliveryId},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
NoteGetResponseDTO responseDto = NoteGetResponseDTO.fromJson(
responseJson,
);
if (responseDto.succeeded == true) {
List<ImageNote> imageNotes =
responseDto.images
.map((imageNoteDto) => ImageNote.fromDTO(imageNoteDto))
.toList();
final images = await downloadImages(imageNotes.map((note) => note.url).toList());
for (var (index, note) in imageNotes.indexed) {
note.data = await images[index];
}
return (
responseDto.notes
.map((noteDto) => Note.fromDto(noteDto))
.toList(),
imageNotes
);
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE GETTING NOTES");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<Note> addNote(String note, int deliveryId) async {
try {
var response = await http.post(
urlBuilder("_web_addNote"),
headers: getSessionOrThrow(),
body: {"receipt_id": deliveryId.toString(), "note": note},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint(responseJson.toString());
NoteAddResponseDTO responseDto = NoteAddResponseDTO.fromJson(
responseJson,
);
if (responseDto.succeeded == true) {
return Note.fromDto(responseDto.note!);
} else {
debugPrint("ERROR: ${responseDto.message}");
throw responseDto.message;
}
} catch (e) {
rethrow;
}
}
Future<String> uploadImage(
String deliveryId,
String filename,
Uint8List bytes,
String? mimeType,
) async {
try {
var config = getConfig();
var basePath = "${config.backendUrl}/v1/uploadFile";
var response = await http.get(
Uri.parse(basePath),
headers: getSessionOrThrow(),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> jsonResponse = jsonDecode(response.body);
debugPrint("GET UPLOADID : ${response.body}");
if (!jsonResponse.containsKey("data")) {
debugPrint("No data structure in uploadFile request");
debugPrint("RAW RESPONSE: ${response.body}");
throw NoteImageAddException();
}
Map<String, dynamic> data = jsonResponse["data"];
if (!data.containsKey("uploadId")) {
debugPrint("No data.uploadId structure in uploadFile request");
debugPrint("RAW RESPONSE: ${response.body}");
throw NoteImageAddException();
}
String uploadId = data["uploadId"];
http.MultipartRequest request = http.MultipartRequest(
"POST",
Uri.parse("$basePath/$uploadId"),
);
HashMap<String, String> header = HashMap();
header["Content-Type"] = "multipart/form-data";
header.addAll(getSessionOrThrow());
request.headers.addAll(header);
request.files.add(
http.MultipartFile.fromBytes(
"file",
bytes,
filename: filename,
contentType: MediaType.parse(mimeType ?? "application/octet-stream"),
),
);
http.Response fileUploadResponse = await http.Response.fromStream(
await request.send(),
);
Map<String, dynamic> fileUploadResponseJson = jsonDecode(
fileUploadResponse.body,
);
debugPrint("UPLOAD IMAGE RESPONSE: ${fileUploadResponse.body}");
if (fileUploadResponseJson["status"]["internalStatus"] != "0") {
debugPrint("Failed to upload image");
debugPrint("RAW: ${fileUploadResponseJson.toString()}");
throw NoteImageAddException();
}
var fileCommitResponse = await http.patch(
Uri.parse("$basePath/$uploadId"),
headers: getSessionOrThrow(),
);
debugPrint("FILE COMMIT BODY: ${fileCommitResponse.body}");
var fileCommitResponseJson = jsonDecode(fileCommitResponse.body);
return fileCommitResponseJson["data"]["~ObjectID"];
} catch (e, st) {
debugPrint("An error occured:");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<List<Future<Uint8List>>> downloadImages(List<String> urls) async {
try {
LocalDocuFrameConfiguration config = getConfig();
return urls.map((url) async {
final response = await http.get(
Uri.parse("${config.backendUrl}$url"),
headers: getSessionOrThrow(),
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
return response.bodyBytes;
}).toList();
} catch (e, st) {
debugPrint("An error occured:");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<void> removeImage(String oid) async {
try {
var response = await http.post(
urlBuilder("_web_removeImage"),
headers: getSessionOrThrow(),
body: {"oid": oid},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
debugPrint(oid);
debugPrint(responseJson.toString());
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
debugPrint("ERROR: ${responseDto.message}");
throw responseDto.message;
}
} catch (e, st) {
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
}