Files
HHA/app/lib/presentation/pages/scan/scan_page.dart
2026-03-24 15:03:35 +01:00

627 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import 'scan_result_sheet.dart';
class ScanPage extends StatefulWidget {
final Tour tour;
const ScanPage({
super.key,
required this.tour,
});
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin {
late MobileScannerController controller;
bool isFlashOn = false;
bool isManualEntry = false;
final TextEditingController barcodeController = TextEditingController();
@override
void initState() {
super.initState();
controller = MobileScannerController();
// Initialize scan bloc with tour
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
void dispose() {
controller.dispose();
barcodeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black.withValues(alpha: 128),
elevation: 0,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.tour.locationName ?? 'Station',
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
Text(
_getTypeLabel(widget.tour.type),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
),
),
],
),
actions: [
IconButton(
onPressed: () {
setState(() {
isFlashOn = !isFlashOn;
controller.toggleTorch();
});
},
icon: Icon(
isFlashOn
? Icons.flashlight_on
: Icons.flashlight_off,
color: Colors.white,
),
),
IconButton(
onPressed: () {
controller.switchCamera();
},
icon: const Icon(
Icons.flip_camera_android,
color: Colors.white,
),
),
],
),
body: BlocListener<ScanBloc, ScanState>(
listener: (context, state) {
if (state is ScanObjectDetected) {
_showScanResult(context, state);
} else if (state is ScanFehlKassetteDetected) {
_showFehlKassetteDialog(context, state);
} else if (state is ScanContainerObjectDetected) {
_showContainerObjectResult(context, state);
} else if (state is ScanContainerDetected) {
_showContainerDetectedSnackBar(context, state);
} else if (state is ScanContainerCloseDetected) {
_showContainerCloseDialog(context, state);
} else if (state is ScanUnknownObject) {
_showUnknownObjectDialog(context, state);
} else if (state is ScanPageDetected) {
_showPageDetectedSnackBar(context, state);
} else if (state is ScanObjectUpdated) {
_showSuccessSnackBar(context, state);
} else if (state is ScanObjectCreated) {
_showObjectCreatedSnackBar(context, state);
} else if (state is ScanError) {
_showErrorSnackBar(context, state.message);
}
},
child: Stack(
fit: StackFit.expand,
children: [
// Camera Preview
MobileScanner(
controller: controller,
onDetect: (capture) {
final barcodes = capture.barcodes;
if (barcodes.isNotEmpty && barcodes.first.rawValue != null) {
HapticFeedback.mediumImpact();
context.read<ScanBloc>().add(
ProcessBarcode(barcode: barcodes.first.rawValue!),
);
}
},
),
// Scan Overlay
CustomPaint(
size: Size.infinite,
painter: ScanOverlayPainter(),
),
// Bottom Controls
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 230),
Colors.transparent,
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Barcode in den Rahmen halten',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Manual Entry Button
ElevatedButton.icon(
onPressed: _showManualEntryDialog,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
icon: const Icon(Icons.keyboard),
label: const Text('Manuelle Eingabe'),
),
],
),
],
),
),
),
// Loading Overlay
BlocBuilder<ScanBloc, ScanState>(
builder: (context, state) {
if (state is ScanProcessing) {
return Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
);
}
return const SizedBox.shrink();
},
),
],
),
),
);
}
void _showScanResult(BuildContext context, ScanObjectDetected state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ScanResultSheet(
object: state.object,
suggestedState: state.suggestedState,
tour: state.tour,
onConfirm: () {
context.read<ScanBloc>().add(UpdateObjectState(
object: state.object,
newState: state.suggestedState,
));
Navigator.pop(context);
},
onCancel: () {
Navigator.pop(context);
},
),
);
}
void _showUnknownObjectDialog(BuildContext context, ScanUnknownObject state) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Unbekanntes Objekt'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Barcode: ${state.barcode}'),
const SizedBox(height: 8),
const Text(
'Dieses Objekt ist nicht im System vorhanden. '
'Möchten Sie es neu anlegen?',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// TODO: Navigate to create object page
},
child: const Text('Anlegen'),
),
],
),
);
}
void _showPageDetectedSnackBar(BuildContext context, ScanPageDetected state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Seite erkannt: ${state.label}'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {},
),
),
);
}
void _showSuccessSnackBar(BuildContext context, ScanObjectUpdated state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Status aktualisiert: ${state.object.code}'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
void _showFehlKassetteDialog(BuildContext context, ScanFehlKassetteDetected state) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Fehlkassette'),
content: Text(
'Die Kassette ${state.object.code} wird als Fehlkassette markiert. '
'Der Status wird auf "Fehler - zur Dienststelle" geändert.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
context.read<ScanBloc>().add(UpdateObjectState(
object: state.object,
newState: state.suggestedState,
));
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Bestätigen'),
),
],
),
);
}
void _showContainerObjectResult(BuildContext context, ScanContainerObjectDetected state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ScanResultSheet(
object: state.object,
suggestedState: state.suggestedState,
tour: state.tour,
containerInfo: 'Container: ${state.containerId} (${state.containerType == 'a' ? 'Geldinstitut' : 'Dienststelle'})',
onConfirm: () {
context.read<ScanBloc>().add(UpdateObjectState(
object: state.object,
newState: state.suggestedState,
containerCode: state.containerId,
));
Navigator.pop(context);
},
onCancel: () {
Navigator.pop(context);
},
),
);
}
void _showContainerDetectedSnackBar(BuildContext context, ScanContainerDetected state) {
final containerTypeLabel = state.containerType == 'a' ? 'Geldinstitut' : 'Dienststelle';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Container erkannt: ${state.containerId} ($containerTypeLabel)'),
backgroundColor: Colors.blue,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
context.read<ScanBloc>().add(UpdateObjectState(
object: state.object,
newState: state.suggestedState,
));
},
),
),
);
}
void _showContainerCloseDialog(BuildContext context, ScanContainerCloseDetected state) {
final containerTypeLabel = state.containerType == 'a' ? 'Geldinstitut' : 'Dienststelle';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Container schließen'),
content: Text(
'Container ${state.object.code} ($containerTypeLabel) wird geschlossen. '
'Alle enthaltenen Objekte werden auf den entsprechenden Status aktualisiert.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
context.read<ScanBloc>().add(UpdateObjectState(
object: state.object,
newState: state.suggestedState,
));
Navigator.pop(context);
},
child: const Text('Schließen'),
),
],
),
);
}
void _showObjectCreatedSnackBar(BuildContext context, ScanObjectCreated state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Objekt erstellt: ${state.barcode}'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
void _showManualEntryDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Barcode manuell eingeben'),
content: TextField(
controller: barcodeController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Barcode eingeben',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.characters,
),
actions: [
TextButton(
onPressed: () {
barcodeController.clear();
Navigator.pop(context);
},
child: const Text('Abbrechen'),
),
ElevatedButton(
onPressed: () {
if (barcodeController.text.isNotEmpty) {
context.read<ScanBloc>().add(
ProcessBarcode(barcode: barcodeController.text),
);
barcodeController.clear();
Navigator.pop(context);
}
},
child: const Text('Suchen'),
),
],
),
);
}
String _getTypeLabel(String type) {
switch (type) {
case 'stock_start':
return 'Lager - Beladung';
case 'stock_end':
return 'Lager - Rückgabe';
case 'start':
return 'Dienststelle';
case 'st':
return 'Haltestelle';
case 'hls':
return 'Hochbahnstation';
case 'fsa':
return 'Fahrscheinautomat';
case 'vs':
return 'Versorgungsstelle';
case 'gi':
return 'Geldinstitut';
default:
return type;
}
}
}
class ScanOverlayPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black.withValues(alpha: 128)
..style = PaintingStyle.fill;
final scanAreaSize = size.width * 0.7;
final scanAreaLeft = (size.width - scanAreaSize) / 2;
final scanAreaTop = (size.height - scanAreaSize) / 2;
// Draw dark overlay
final path = Path()
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final cutout = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(scanAreaLeft, scanAreaTop, scanAreaSize, scanAreaSize),
const Radius.circular(20),
));
final overlayPath = Path.combine(
PathOperation.difference,
path,
cutout,
);
canvas.drawPath(overlayPath, paint);
// Draw corner markers
final markerPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4;
final cornerLength = scanAreaSize * 0.15;
const cornerRadius = 20.0;
// Top-left corner
_drawCorner(
canvas,
Offset(scanAreaLeft, scanAreaTop),
cornerLength,
markerPaint,
true,
true,
cornerRadius,
);
// Top-right corner
_drawCorner(
canvas,
Offset(scanAreaLeft + scanAreaSize, scanAreaTop),
cornerLength,
markerPaint,
false,
true,
cornerRadius,
);
// Bottom-left corner
_drawCorner(
canvas,
Offset(scanAreaLeft, scanAreaTop + scanAreaSize),
cornerLength,
markerPaint,
true,
false,
cornerRadius,
);
// Bottom-right corner
_drawCorner(
canvas,
Offset(scanAreaLeft + scanAreaSize, scanAreaTop + scanAreaSize),
cornerLength,
markerPaint,
false,
false,
cornerRadius,
);
}
void _drawCorner(
Canvas canvas,
Offset position,
double length,
Paint paint,
bool isLeft,
bool isTop,
double radius,
) {
final path = Path();
if (isLeft && isTop) {
path.moveTo(position.dx + length, position.dy);
path.lineTo(position.dx + radius, position.dy);
path.arcToPoint(
Offset(position.dx, position.dy + radius),
radius: Radius.circular(radius),
clockwise: false,
);
path.lineTo(position.dx, position.dy + length);
} else if (!isLeft && isTop) {
path.moveTo(position.dx - length, position.dy);
path.lineTo(position.dx - radius, position.dy);
path.arcToPoint(
Offset(position.dx, position.dy + radius),
radius: Radius.circular(radius),
);
path.lineTo(position.dx, position.dy + length);
} else if (isLeft && !isTop) {
path.moveTo(position.dx + length, position.dy);
path.lineTo(position.dx + radius, position.dy);
path.arcToPoint(
Offset(position.dx, position.dy - radius),
radius: Radius.circular(radius),
);
path.lineTo(position.dx, position.dy - length);
} else {
path.moveTo(position.dx - length, position.dy);
path.lineTo(position.dx - radius, position.dy);
path.arcToPoint(
Offset(position.dx, position.dy - radius),
radius: Radius.circular(radius),
clockwise: false,
);
path.lineTo(position.dx, position.dy - length);
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}