first commit

This commit is contained in:
2026-03-24 15:03:35 +01:00
commit cdba16ebe8
162 changed files with 194406 additions and 0 deletions

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

View File

@@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../domain/entities/logistic_object.dart';
import '../../../domain/entities/tour.dart';
class ScanResultSheet extends StatelessWidget {
final LogisticObject object;
final String suggestedState;
final Tour? tour;
final String? containerInfo;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const ScanResultSheet({
super.key,
required this.object,
required this.suggestedState,
this.tour,
this.containerInfo,
required this.onConfirm,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final currentStateColor = ObjectStateInfo.getColorForState(object.state);
final suggestedStateColor = ObjectStateInfo.getColorForState(suggestedState);
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 24),
// Success Icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.green.shade400,
Colors.green.shade600,
],
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.green.withValues(alpha: 77),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 40,
),
).animate().scale(duration: 300.ms, curve: Curves.elasticOut),
const SizedBox(height: 24),
// Title
Text(
'Objekt gefunden',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// Object Info Card
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
elevation: 0,
color: Colors.grey.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildInfoRow(
context,
'Code',
object.code,
Icons.qr_code,
),
const Divider(height: 24),
_buildInfoRow(
context,
'Typ',
object.typeName ?? object.subtype.toUpperCase(),
Icons.inventory_2,
),
const Divider(height: 24),
_buildStateRow(
context,
'Aktueller Status',
object.state,
currentStateColor,
),
if (containerInfo != null) ...[
const Divider(height: 24),
_buildInfoRow(
context,
'Container',
containerInfo!,
Icons.inventory_2,
),
],
],
),
),
),
),
const SizedBox(height: 24),
// State Transition
Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
suggestedStateColor.withValues(alpha: 26),
suggestedStateColor.withValues(alpha: 13),
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: suggestedStateColor.withValues(alpha: 77)),
),
child: Column(
children: [
Text(
'Status wird geändert zu:',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: suggestedStateColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
ObjectStateInfo.getDisplayName(suggestedState),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: suggestedStateColor,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// Action Buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onCancel,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Abbrechen'),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: onConfirm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Bestätigen'),
),
),
],
),
),
const SizedBox(height: 32),
],
),
);
}
Widget _buildInfoRow(BuildContext context, String label, String value, IconData icon) {
final theme = Theme.of(context);
return Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 26),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 20,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
value,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
Widget _buildStateRow(BuildContext context, String label, String state, Color color) {
final theme = Theme.of(context);
return Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.info,
size: 20,
color: color,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(20),
),
child: Text(
ObjectStateInfo.getDisplayName(state),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// FSA Page - Fahrscheinautomat
/// Entspricht Lua: ShowFsaScreen + CreateLoadingFsaView
class FsaPage extends StatefulWidget {
final Tour tour;
const FsaPage({
super.key,
required this.tour,
});
@override
State<FsaPage> createState() => _FsaPageState();
}
class _FsaPageState extends State<FsaPage> {
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Fahrscheinautomat'),
const Text(
'Objekt-Einbuchung',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header
_buildHeader(context),
// FSA-spezifische Info
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildFsaInfo(context),
_buildObjectTypes(context),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.confirmation_number,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fahrscheinautomat',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'FSA',
style: Theme.of(context).textTheme.bodyMedium,
),
if (widget.tour.remark != null)
Text(
widget.tour.remark!,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
);
}
Widget _buildFsaInfo(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: const Column(
children: [
Text(
'Gültige Objekte:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
'• GK (Geldkassette): station → in_fa → ret_fail\n'
'• HP (Hauptkasse): station ↔ in_fa (Wechsel)\n'
'• FR (Fahrkartenrolle): station ↔ in_fa',
style: TextStyle(fontSize: 14),
),
],
),
);
}
Widget _buildObjectTypes(BuildContext context) {
final objectTypes = [
_ObjectTypeInfo('Geldkassette (GK)', 'MEK, BEK', Icons.money, Colors.green),
_ObjectTypeInfo('Hauptkasse (HP)', 'H1, H2, H3', Icons.print, Colors.blue),
_ObjectTypeInfo('Fahrkartenrolle (FR)', 'P', Icons.receipt, Colors.orange),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Objekttypen',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...objectTypes.map((type) => _buildObjectTypeCard(context, type)),
],
),
);
}
Widget _buildObjectTypeCard(BuildContext context, _ObjectTypeInfo type) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: type.color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(8),
),
child: Icon(type.icon, color: type.color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
type.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
Text(
type.subtypes,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}
class _ObjectTypeInfo {
final String name;
final String subtypes;
final IconData icon;
final Color color;
_ObjectTypeInfo(this.name, this.subtypes, this.icon, this.color);
}

View File

@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// GI Page - Geldinstitut
/// Entspricht Lua: ShowGiScreen + CreateLoadingGiView
class GiPage extends StatefulWidget {
final Tour tour;
const GiPage({
super.key,
required this.tour,
});
@override
State<GiPage> createState() => _GiPageState();
}
class _GiPageState extends State<GiPage> {
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Geldinstitut'),
const Text(
'Übergabe',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header
_buildHeader(context),
// GI-spezifische Info
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildGiInfo(context),
_buildExpectedObjects(context),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.account_balance,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Geldinstitut',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'Bank',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildGiInfo(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.shade200),
),
child: const Column(
children: [
Text(
'Erwartete Objekte:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
'• GK mit Status "ret_gi_fzg" → "fin_gi"\n'
'• SB (Safebag) mit Status "ret_gi_fzg" → "fin_gi"\n'
'• Leere Kassetten: "unknown" → "ret_ds_empty"',
style: TextStyle(fontSize: 14),
),
],
),
);
}
Widget _buildExpectedObjects(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Zu übergebende Objekte',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Geldkassetten
_buildObjectStatusCard(
context,
'Geldkassetten (GK)',
'ret_gi_fzg → fin_gi',
Icons.money,
Colors.green,
),
// Safebags
_buildObjectStatusCard(
context,
'Safebags (SB)',
'ret_gi_fzg → fin_gi',
Icons.shopping_bag,
Colors.blue,
),
// Leere Kassetten
_buildObjectStatusCard(
context,
'Leere Kassetten',
'unknown → ret_ds_empty',
Icons.remove_circle_outline,
Colors.orange,
),
],
),
);
}
Widget _buildObjectStatusCard(
BuildContext context,
String title,
String transition,
IconData icon,
Color color,
) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
Text(
transition,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}

View File

@@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// Stock End Page - Lager Rückgabe
/// Entspricht Lua: ShowStockEndScreen + CreateLoadingStockEndView
class StockEndPage extends StatefulWidget {
final Tour tour;
const StockEndPage({
super.key,
required this.tour,
});
@override
State<StockEndPage> createState() => _StockEndPageState();
}
class _StockEndPageState extends State<StockEndPage> {
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Lager'),
const Text(
'Rückgabe',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header
_buildHeader(context),
// Rückgabe-Übersicht
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildReturnInfo(context),
_buildObjectSummary(context),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.warehouse,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lager Rückgabe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'Hauptlager',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildReturnInfo(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.shade200),
),
child: const Column(
children: [
Text(
'Rückgabe-Status:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
'Abschluss der Tour - Objekte werden finalisiert:\n'
'• ret_fail_stk → fin_ds_fail\n'
'• ret_ds_stk → fin_ds\n'
'• ret_gi_stk → fin_gi_tmp',
style: TextStyle(fontSize: 14),
),
],
),
);
}
Widget _buildObjectSummary(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Objekte zur Rückgabe',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Fehlkassetten
_buildObjectStatusCard(
context,
'Fehlkassetten',
'ret_fail_stk → fin_ds_fail',
Icons.error,
Colors.red,
),
// DS-Normal
_buildObjectStatusCard(
context,
'Zur Dienststelle',
'ret_ds_stk → fin_ds',
Icons.account_balance,
Colors.blue,
),
// DS-Fehler
_buildObjectStatusCard(
context,
'Fehlerhafte DS',
'ret_ds_err → fin_ds_err',
Icons.warning,
Colors.orange,
),
// GI
_buildObjectStatusCard(
context,
'Zum Geldinstitut',
'ret_gi_stk → fin_gi_tmp',
Icons.account_balance_wallet,
Colors.green,
),
],
),
);
}
Widget _buildObjectStatusCard(
BuildContext context,
String title,
String transition,
IconData icon,
Color color,
) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
Text(
transition,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}

View File

@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../../domain/entities/counter.dart';
import '../../../core/constants/app_constants.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../../widgets/counter_grid.dart';
import '../../widgets/recent_scans_list.dart';
import '../scan/scan_page.dart';
/// Stock Start Page - Lager Beladung
/// Entspricht Lua: ShowStockStartScreen + CreateLoadingStockStartView
class StockStartPage extends StatefulWidget {
final Tour tour;
const StockStartPage({
super.key,
required this.tour,
});
@override
State<StockStartPage> createState() => _StockStartPageState();
}
class _StockStartPageState extends State<StockStartPage> {
@override
void initState() {
super.initState();
// Initialize scan bloc with tour
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Lager'),
Text(
'Beladung',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header mit Lager-Icon
_buildHeader(context),
// Zähler-Übersicht (wie in Lua CreateLoadingStockStartView)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
// Bestand Fzg (aktueller Bestand im Fahrzeug)
_buildCounterSection(
context,
title: 'Bestand Fzg',
counters: const [
CounterItem('MEK', 0),
CounterItem('BEK', 0),
CounterItem('H1', 0),
CounterItem('H2', 0),
CounterItem('H3', 0),
CounterItem('P', 0),
],
backgroundColor: const Color(0xFFA4D4F0), // Lua-Farbe
),
// Beladezähler (Soll-Zahlen)
_buildCounterSection(
context,
title: 'Beladezähler',
counters: const [
CounterItem('MEK', 0),
CounterItem('BEK', 0),
CounterItem('H1', 0),
CounterItem('H2', 0),
CounterItem('H3', 0),
CounterItem('P', 0),
],
backgroundColor: Colors.grey.shade200,
),
// HADAG
_buildCounterSection(
context,
title: 'HADAG',
counters: const [
CounterItem('', null),
CounterItem('BEK-B', 0),
CounterItem('H1-B', 0),
CounterItem('H2-B', 0),
CounterItem('', null),
CounterItem('', null),
],
backgroundColor: Colors.grey.shade200,
),
// SST (Schnellbahn)
_buildCounterSection(
context,
title: 'SST',
counters: const [
CounterItem('MEK-SST', 0),
CounterItem('BEK-SST', 0),
CounterItem('H1-SST', 0),
CounterItem('H2-SST', 0),
CounterItem('', null),
CounterItem('', null),
],
backgroundColor: Colors.grey.shade200,
),
// CR (CityRail)
_buildCounterSection(
context,
title: 'CR',
counters: const [
CounterItem('MEK-CR', 0),
CounterItem('BEK-CR', 0),
CounterItem('', null),
CounterItem('', null),
CounterItem('', null),
CounterItem('', null),
],
backgroundColor: Colors.grey.shade200,
),
// Zuletzt gescannte Objekte
const Padding(
padding: EdgeInsets.all(16),
child: RecentScansList(),
),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0), // Hellblau wie in Lua
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.warehouse,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lager Beladung',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'Hauptlager',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildCounterSection(
BuildContext context, {
required String title,
required List<CounterItem> counters,
required Color backgroundColor,
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
// Titel
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
// Zähler-Grid
Container(
padding: const EdgeInsets.all(8),
child: Row(
children: counters.map((counter) {
return Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: counter.value == null ? Colors.transparent : Colors.white,
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
Text(
counter.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: counter.value == null ? Colors.transparent : Colors.black87,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (counter.value != null) ...[
const SizedBox(height: 4),
Text(
'${counter.value}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
],
),
),
);
}).toList(),
),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 10),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text(
'Barcode scannen',
style: TextStyle(fontSize: 16),
),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}
/// Hilfsklasse für Zähler-Darstellung
class CounterItem {
final String label;
final int? value;
const CounterItem(this.label, this.value);
}

View File

@@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// Veh End Page - Fahrzeug Rückgabe
/// Entspricht Lua: ShowVehEndScreen + CreateLoadingVehEndView
class VehEndPage extends StatefulWidget {
final Tour tour;
const VehEndPage({
super.key,
required this.tour,
});
@override
State<VehEndPage> createState() => _VehEndPageState();
}
class _VehEndPageState extends State<VehEndPage> {
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Fahrzeugende'),
const Text(
'Rückgabe',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header
_buildHeader(context),
// Rückgabe-Übersicht
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildReturnInfo(context),
_buildObjectSummary(context),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.local_shipping,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fahrzeug Rückgabe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'Dienststelle',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildReturnInfo(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.shade200),
),
child: const Column(
children: [
Text(
'Rückgabe-Status:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
'Alle Objekte im Fahrzeug werden zurückgebucht:\n'
'• ret_fail_fzg → ret_fail_stk\n'
'• ret_ds_fzg → ret_ds_stk\n'
'• ret_gi_fzg → ret_gi_stk',
style: TextStyle(fontSize: 14),
),
],
),
);
}
Widget _buildObjectSummary(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Objekte im Fahrzeug',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Fehlkassetten
_buildObjectStatusCard(
context,
'Fehlkassetten',
'ret_fail_fzg → ret_fail_stk',
Icons.error,
Colors.red,
),
// DS-Objekte
_buildObjectStatusCard(
context,
'Zur Dienststelle',
'ret_ds_fzg → ret_ds_stk',
Icons.account_balance,
Colors.blue,
),
// GI-Objekte
_buildObjectStatusCard(
context,
'Zum Geldinstitut',
'ret_gi_fzg → ret_gi_stk',
Icons.account_balance_wallet,
Colors.green,
),
// Noch im Fahrzeug
_buildObjectStatusCard(
context,
'Noch im Fahrzeug (Rest)',
'delivery → ret_ds_stk',
Icons.local_shipping,
Colors.orange,
),
],
),
);
}
Widget _buildObjectStatusCard(
BuildContext context,
String title,
String transition,
IconData icon,
Color color,
) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
Text(
transition,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}

View File

@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// Veh Page - Station (Haltestelle)
/// Entspricht Lua: ShowVehScreen + CreateLoadingVehView
class VehPage extends StatefulWidget {
final Tour tour;
const VehPage({
super.key,
required this.tour,
});
@override
State<VehPage> createState() => _VehPageState();
}
class _VehPageState extends State<VehPage> {
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Station'),
const Text(
'Objekt-Wechsel',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header mit Stations-Info
_buildHeader(context),
// Zähler-Bereiche
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
// Wechselzähler (Leer/Voll)
_buildSwapCounters(context),
// Abholzähler
_buildPickupCounters(context),
const SizedBox(height: 16),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.train,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.tour.locationName ?? 'Haltestelle',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (widget.tour.remark != null)
Text(
widget.tour.remark!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.black87,
),
),
],
),
),
],
),
);
}
Widget _buildSwapCounters(BuildContext context) {
return Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
),
child: const Text(
'Wechselzähler',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
// Leer/Voll/HADAG Reihen
_buildCounterRow('Leer', [0, 0, 0, 0, 0, 0]),
const Divider(height: 1),
_buildCounterRow('Voll', [0, 0, 0, 0, 0, 0]),
const Divider(height: 1),
_buildCounterRow('HADAG', [null, 0, 0, 0, null, null]),
],
),
);
}
Widget _buildPickupCounters(BuildContext context) {
return Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
),
child: const Text(
'Abholzähler',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
_buildCounterRow('Abholung', [0, 0, 0, 0, 0, 0]),
],
),
);
}
Widget _buildCounterRow(String label, List<int?> values) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Row(
children: values.map((value) {
return Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: value == null ? Colors.transparent : Colors.white,
borderRadius: BorderRadius.circular(4),
),
child: value != null
? Text(
'$value',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
)
: null,
),
);
}).toList(),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}

View File

@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// Veh Start Page - Fahrzeug Beladung
/// Entspricht Lua: ShowVehStartScreen + CreateLoadingVehStartView
class VehStartPage extends StatefulWidget {
final Tour tour;
const VehStartPage({
super.key,
required this.tour,
});
@override
State<VehStartPage> createState() => _VehStartPageState();
}
class _VehStartPageState extends State<VehStartPage> {
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Dienststelle'),
const Text(
'Fahrzeug Beladung',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header
_buildHeader(context),
// Beladezähler (wie in Lua CreateLoadingVehStartView)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildCounterSection(
context,
title: 'Beladezähler',
counters: const [
CounterItem('MEK', 0),
CounterItem('BEK', 0),
CounterItem('H1', 0),
CounterItem('H2', 0),
CounterItem('H3', 0),
CounterItem('P', 0),
],
),
// Zuletzt gescannt
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Scanne Objekte zum Beladen',
style: TextStyle(
color: Colors.grey,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.local_shipping,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fahrzeug Beladung',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'Dienststelle',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildCounterSection(
BuildContext context, {
required String title,
required List<CounterItem> counters,
}) {
return Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Container(
padding: const EdgeInsets.all(8),
child: Row(
children: counters.map((counter) {
return Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
Text(
counter.label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'${counter.value}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}).toList(),
),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _openScanner(context),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}
class CounterItem {
final String label;
final int value;
const CounterItem(this.label, this.value);
}

View File

@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../blocs/scan/scan_bloc.dart';
import '../scan/scan_page.dart';
/// VS Page - Verwahrungsstelle
/// Entspricht Lua: ShowVsScreen + CreateLoadingVsView
/// Spezial: Container-Handling für SB (Safebag) und ABS (Abfallbehälter)
class VsPage extends StatefulWidget {
final Tour tour;
const VsPage({
super.key,
required this.tour,
});
@override
State<VsPage> createState() => _VsPageState();
}
class _VsPageState extends State<VsPage> {
String? selectedContainerId;
String? selectedContainerType; // 'a' = GI, 'b' = DS
@override
void initState() {
super.initState();
context.read<ScanBloc>().add(InitializeScan(widget.tour));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.tour.locationName ?? 'Verwahrungsstelle'),
const Text(
'Container-Annahme',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: () => _openScanner(context),
),
],
),
body: Column(
children: [
// Header
_buildHeader(context),
// Container-Auswahl
_buildContainerSelection(context),
// Aktueller Container Status
if (selectedContainerId != null)
_buildContainerStatus(context),
const Spacer(),
// Scan Button
_buildScanButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: const Color(0xFFA4D4F0),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.inventory_2,
size: 48,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Verwahrungsstelle',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
widget.tour.locationName ?? 'VS',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildContainerSelection(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Container auswählen',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Container A - Geldinstitut
_buildContainerOption(
context,
id: 'CONT_A',
type: 'a',
title: 'Container A',
subtitle: 'Für Geldinstitut (GI)',
icon: Icons.account_balance,
color: Colors.blue,
),
const SizedBox(height: 8),
// Container B - Dienststelle
_buildContainerOption(
context,
id: 'CONT_B',
type: 'b',
title: 'Container B',
subtitle: 'Für Dienststelle (DS)',
icon: Icons.business,
color: Colors.orange,
),
],
),
);
}
Widget _buildContainerOption(
BuildContext context, {
required String id,
required String type,
required String title,
required String subtitle,
required IconData icon,
required Color color,
}) {
final isSelected = selectedContainerId == id;
return InkWell(
onTap: () {
setState(() {
selectedContainerId = id;
selectedContainerType = type;
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? color.withValues(alpha: 20) : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? color : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
if (isSelected)
Icon(Icons.check_circle, color: color),
],
),
),
);
}
Widget _buildContainerStatus(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: selectedContainerType == 'a'
? Colors.blue.shade50
: Colors.orange.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selectedContainerType == 'a'
? Colors.blue
: Colors.orange,
),
),
child: Column(
children: [
Text(
'Ausgewählt: ${selectedContainerType == 'a' ? 'Container A (GI)' : 'Container B (DS)'}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: selectedContainerType == 'a'
? Colors.blue
: Colors.orange,
),
),
const SizedBox(height: 8),
const Text(
'Scannen Sie jetzt SB (Safebag) oder ABS (Abfallbehälter)',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
],
),
);
}
Widget _buildScanButton(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (selectedContainerId == null)
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
'Bitte zuerst einen Container auswählen',
style: TextStyle(
color: Colors.orange,
fontSize: 13,
),
),
),
ElevatedButton.icon(
onPressed: selectedContainerId != null
? () => _openScanner(context)
: null,
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Barcode scannen'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
),
),
],
),
);
}
void _openScanner(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ScanPage(tour: widget.tour),
),
);
}
}

View File

@@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/tour/tour_bloc.dart';
import '../../widgets/loading_indicator.dart';
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoading) {
return const LoadingIndicator();
}
if (state is ToursLoaded) {
return CustomScrollView(
slivers: [
// App Bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Guten Morgen,',
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 4),
Text(
'Fahrer',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 26),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.person,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
Text(
'Tour vom ${DateTime.now().day}.${DateTime.now().month}.${DateTime.now().year}',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
),
),
// Quick Stats
SliverToBoxAdapter(
child: _buildQuickStats(context, state),
),
// Section Title
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Schnellzugriff',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
TextButton(
onPressed: () {
// Navigate to full tours list
},
child: const Text('Alle anzeigen'),
),
],
),
),
),
// Quick Actions Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24),
sliver: SliverGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
children: [
_buildQuickActionCard(
context,
'Nächste Station',
Icons.location_on,
Colors.orange,
'${state.tours.where((t) => t.state == 0).length} offen',
() {},
),
_buildQuickActionCard(
context,
'Scan',
Icons.qr_code_scanner,
Colors.green,
'Barcode scannen',
() {},
),
_buildQuickActionCard(
context,
'Bestand',
Icons.warehouse,
Colors.blue,
'Objekte anzeigen',
() {},
),
_buildQuickActionCard(
context,
'Sync',
Icons.sync,
Colors.purple,
state.isSyncing ? 'Synchronisiert...' : 'Daten aktualisieren',
() {
context.read<TourBloc>().add(const SyncData());
},
),
],
),
),
// Recent Activity
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
child: Text(
'Letzte Aktivitäten',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _buildActivityItem(
context,
'Geldkassette gescannt',
'Station: Hauptbahnhof Nord',
'10:23 Uhr',
Icons.qr_code_scanner,
Colors.green,
);
},
childCount: 3,
),
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 100)),
],
);
}
return const Center(child: Text('Willkommen bei HHA Logistics'));
},
),
),
);
}
Widget _buildQuickStats(BuildContext context, ToursLoaded state) {
final theme = Theme.of(context);
final completionPercentage = state.completionPercentage;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(alpha: 204),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 77),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 51),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.route,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tour-Fortschritt',
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white.withValues(alpha: 204),
),
),
const SizedBox(height: 4),
Text(
'${state.completedCount} / ${state.totalCount} Stationen',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: completionPercentage / 100,
backgroundColor: Colors.white.withValues(alpha: 51),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
minHeight: 10,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${completionPercentage.toStringAsFixed(0)}% abgeschlossen',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 230),
),
),
if (state.isSyncing)
Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white.withValues(alpha: 204),
),
),
),
const SizedBox(width: 8),
Text(
'Sync...',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 230),
),
),
],
),
],
),
],
),
);
}
Widget _buildQuickActionCard(
BuildContext context,
String title,
IconData icon,
Color color,
String subtitle,
VoidCallback onTap,
) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: Colors.grey.shade200),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
icon,
color: color,
size: 28,
),
),
const SizedBox(height: 16),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
),
),
);
}
Widget _buildActivityItem(
BuildContext context,
String title,
String subtitle,
String time,
IconData icon,
Color color,
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withValues(alpha: 26),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 22,
),
),
title: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
trailing: Text(
time,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade500,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,327 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/tour.dart';
import '../../../core/constants/app_constants.dart';
import '../../blocs/tour/tour_bloc.dart';
import '../../widgets/tour_list_item.dart';
import '../../widgets/loading_indicator.dart';
import '../../widgets/error_view.dart';
import '../tour_types/stock_start_page.dart';
import '../tour_types/veh_start_page.dart';
import '../tour_types/veh_page.dart';
import '../tour_types/fsa_page.dart';
import '../tour_types/vs_page.dart';
import '../tour_types/gi_page.dart';
import '../tour_types/veh_end_page.dart';
import '../tour_types/stock_end_page.dart';
import '../scan/scan_page.dart';
class ToursPage extends StatelessWidget {
const ToursPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoading) {
return const LoadingIndicator(message: 'Touren werden geladen...');
}
if (state is TourError) {
return ErrorView(
message: state.message,
onRetry: () => context.read<TourBloc>().add(const RefreshTours()),
);
}
if (state is ToursLoaded) {
return _ToursListView(state: state);
}
return const LoadingIndicator();
},
),
);
}
}
class _ToursListView extends StatelessWidget {
final ToursLoaded state;
const _ToursListView({required this.state});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CustomScrollView(
slivers: [
// Header mit Fortschritt
SliverToBoxAdapter(
child: _buildHeader(context),
),
// Offene Touren
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(
Icons.location_on,
color: theme.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Offene Stationen',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 26),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.tours.length}',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final tour = state.tours[index];
return TourListItem(
tour: tour,
onTap: () => _onTourSelected(context, tour),
);
},
childCount: state.tours.length,
),
),
// Erledigte Touren (falls aktiviert)
if (state.showCompleted && state.completedTours.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 20,
),
const SizedBox(width: 8),
Text(
'Erledigte Stationen',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 26),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.completedTours.length}',
style: theme.textTheme.labelMedium?.copyWith(
color: Colors.green,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final tour = state.completedTours[index];
return TourListItem(
tour: tour,
onTap: () => _onTourSelected(context, tour),
isCompleted: true,
);
},
childCount: state.completedTours.length,
),
),
],
const SliverPadding(padding: EdgeInsets.only(bottom: 100)),
],
);
}
Widget _buildHeader(BuildContext context) {
final theme = Theme.of(context);
final completionPercentage = state.completionPercentage;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(alpha: 204),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 77),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 51),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.route,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tagesübersicht',
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white.withValues(alpha: 204),
),
),
const SizedBox(height: 4),
Text(
'${state.completedCount} / ${state.totalCount} Stationen',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 20),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: completionPercentage / 100,
backgroundColor: Colors.white.withValues(alpha: 51),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
minHeight: 8,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${completionPercentage.toStringAsFixed(0)}% abgeschlossen',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 230),
),
),
if (state.isSyncing)
Row(
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white.withValues(alpha: 204),
),
),
),
const SizedBox(width: 6),
Text(
'Sync...',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 230),
),
),
],
),
],
),
],
),
);
}
void _onTourSelected(BuildContext context, Tour tour) {
context.read<TourBloc>().add(SelectTour(tour));
// Navigation zur tour-spezifischen Page
final page = _getPageForTourType(tour);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => page),
);
}
Widget _getPageForTourType(Tour tour) {
switch (tour.type) {
case TourTypes.stockStart:
return StockStartPage(tour: tour);
case TourTypes.vehStart:
return VehStartPage(tour: tour);
case TourTypes.veh:
return VehPage(tour: tour);
case TourTypes.fsa:
return FsaPage(tour: tour);
case TourTypes.vs:
return VsPage(tour: tour);
case TourTypes.gi:
return GiPage(tour: tour);
case TourTypes.vehEnd:
return VehEndPage(tour: tour);
case TourTypes.stockEnd:
return StockEndPage(tour: tour);
case TourTypes.stock:
// Stock (HADAG) uses similar UI to stock_start
return StockStartPage(tour: tour);
default:
// Fallback to generic scan page for unknown types
return ScanPage(tour: tour);
}
}
}