first commit
This commit is contained in:
626
app/lib/presentation/pages/scan/scan_page.dart
Normal file
626
app/lib/presentation/pages/scan/scan_page.dart
Normal file
@@ -0,0 +1,626 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user