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,132 @@
class AppConstants {
// API
static const String baseUrl = 'https://hha-app1.assecutor.de/hha';
static const int connectionTimeout = 30000;
static const int receiveTimeout = 30000;
// Database
static const String databaseName = 'hha_logistics.db';
static const int databaseVersion = 1;
// App Info
static const String appName = 'HHA Logistics';
static const String appVersion = '2.0.0';
// Sync
static const Duration syncInterval = Duration(minutes: 1);
static const Duration locationUpdateInterval = Duration(seconds: 30);
}
class ObjectStates {
static const String unknown = 'unknown';
static const String toDelivery = 'to_delivery';
static const String delivery = 'delivery';
static const String station = 'station';
static const String inFA = 'in_fa';
static const String inVS = 'in_vs';
static const String retFail = 'ret_fail';
static const String retFailFzg = 'ret_fail_fzg';
static const String retFailStk = 'ret_fail_stk';
static const String retGI = 'ret_gi';
static const String retGIFzg = 'ret_gi_fzg';
static const String retGIStk = 'ret_gi_stk';
static const String retDS = 'ret_ds';
static const String retDSFzg = 'ret_ds_fzg';
static const String retDSStk = 'ret_ds_stk';
static const String retDSErr = 'ret_ds_err';
static const String retDSEmpty = 'ret_ds_empty';
static const String finDS = 'fin_ds';
static const String finGI = 'fin_gi';
static const String finDSFail = 'fin_ds_fail';
static const String finDSErr = 'fin_ds_err';
static const String hdl = 'hdl';
static const String stkHadag = 'stk_hadag';
static const String finGITmp = 'fin_gi_tmp';
static const String retcGI = 'retc_gi';
static const String retcDS = 'retc_ds';
static const String retDSFix = 'ret_ds_fix';
static const String retFixStk = 'ret_fix_stk';
static const String finFix = 'fin_fix';
static const String trig = 'trig';
}
class TourTypes {
static const String stockStart = 'stock_start';
static const String stock = 'stock';
static const String start = 'start';
static const String station = 'st';
static const String hls = 'hls';
static const String vs = 'vs';
static const String stockEnd = 'stock_end';
static const String end = 'end';
static const String fsa = 'fsa';
static const String gi = 'gi';
static const String veh = 'veh';
static const String vehStart = 'veh_start';
static const String vehBulk = 'veh_bulk';
static const String vehVs = 'veh_vs';
static const String vehEnd = 'veh_end';
static const String menu = 'me';
}
class ObjectTypes {
static const String gk = 'gk'; // Geldkassette
static const String hp = 'hp'; // HP Patronen
static const String fr = 'fr'; // Fahrkartenrolle
static const String sb = 'sb'; // Safebag
static const String abs = 'abs'; // Abfallbehälter
static const String cntr = 'cntr'; // Container
}
class ObjectSubtypes {
// Geldkassetten
static const String meka = 'meka';
static const String mekb = 'mekb';
static const String mekc = 'mekc';
static const String mekd = 'mekd';
static const String beka = 'beka';
static const String bekb = 'bekb';
static const String bekc = 'bekc';
static const String bekd = 'bekd';
// HP Patronen
static const String hp1a = 'hp1a';
static const String hp1b = 'hp1b';
static const String hp1c = 'hp1c';
static const String hp2a = 'hp2a';
static const String hp2b = 'hp2b';
static const String hp2c = 'hp2c';
static const String hp3a = 'hp3a';
static const String hp3b = 'hp3b';
static const String hp3c = 'hp3c';
// Fahrkartenrollen
static const String fra = 'fra';
// Container
static const String cntra = 'cntra';
static const String cntrb = 'cntrb';
}
class CounterLabels {
static const Map<String, String> labels = {
'meka': 'MEK',
'beka': 'BEK',
'hp1a': 'H1',
'hp2a': 'H2',
'hp3a': 'H3',
'fra': 'P',
'sb': 'SB',
'abs': 'ABS',
'mekb': 'MEK-B',
'bekb': 'BEK-B',
'hp1b': 'H1-B',
'hp2b': 'H2-B',
'mekc': 'MEK-SST',
'bekc': 'BEK-SST',
'hp1c': 'H1-SST',
'hp2c': 'H2-SST',
'mekd': 'MEK-CR',
'bekd': 'BEK-CR',
};
}

View File

@@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
final String message;
final String? code;
const Failure({
required this.message,
this.code,
});
@override
List<Object?> get props => [message, code];
}
class ServerFailure extends Failure {
const ServerFailure({
required super.message,
super.code,
});
}
class CacheFailure extends Failure {
const CacheFailure({
required super.message,
super.code,
});
}
class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
super.code,
});
}
class ValidationFailure extends Failure {
const ValidationFailure({
required super.message,
super.code,
});
}
class NotFoundFailure extends Failure {
const NotFoundFailure({
required super.message,
super.code,
});
}
class UnauthorizedFailure extends Failure {
const UnauthorizedFailure({
required super.message,
super.code,
});
}
class BarcodeFailure extends Failure {
const BarcodeFailure({
required super.message,
super.code,
});
}
class SyncFailure extends Failure {
const SyncFailure({
required super.message,
super.code,
});
}

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
// Brand Colors - HHA Corporate Colors
static const Color hhaRed = Color(0xFFE3001B);
static const Color hhaDarkRed = Color(0xFFB30015);
static const Color hhaLightRed = Color(0xFFFF4D5E);
// State Colors
static const Color stateDelivery = Color(0xFFB3B3B3);
static const Color stateStation = Color(0xFFFFDD00);
static const Color stateInFA = Color(0xFF9CDA7A);
static const Color stateRetFail = Color(0xFFFF9081);
static const Color stateRetDS = Color(0xFFAFE0ED);
static const Color stateRetGI = Color(0xFFAFE0ED);
static const Color stateFinDS = Color(0xFF29B7FB);
static const Color stateFinGI = Color(0xFF25BAFC);
static const Color stateInVS = Color(0xFFFAE14B);
// Functional Colors
static const Color success = Color(0xFF4CAF50);
static const Color warning = Color(0xFFFF9800);
static const Color error = Color(0xFFE3001B);
static const Color info = Color(0xFF2196F3);
// Neutral Colors
static const Color white = Color(0xFFFFFFFF);
static const Color background = Color(0xFFF5F5F5);
static const Color surface = Color(0xFFFFFFFF);
static const Color cardBackground = Color(0xFFFAFAFA);
static const Color divider = Color(0xFFE0E0E0);
// Text Colors
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
static const Color textTertiary = Color(0xFF9E9E9E);
static const Color textOnDark = Color(0xFFFFFFFF);
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: hhaRed,
primaryContainer: hhaLightRed,
onPrimaryContainer: white,
secondary: Color(0xFF2196F3),
onSurface: textPrimary,
surfaceContainerHighest: background,
error: error,
),
scaffoldBackgroundColor: background,
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: hhaRed,
foregroundColor: white,
titleTextStyle: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: white,
),
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
),
cardTheme: CardThemeData(
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 26),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
color: surface,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: hhaRed,
foregroundColor: white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: hhaRed,
side: const BorderSide(color: hhaRed, width: 2),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: hhaRed,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: hhaRed,
foregroundColor: white,
elevation: 4,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: divider),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: divider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: hhaRed, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: error, width: 2),
),
labelStyle: GoogleFonts.inter(
fontSize: 14,
color: textSecondary,
),
hintStyle: GoogleFonts.inter(
fontSize: 14,
color: textTertiary,
),
),
chipTheme: ChipThemeData(
backgroundColor: background,
selectedColor: hhaRed.withValues(alpha: 26),
labelStyle: GoogleFonts.inter(fontSize: 12),
secondaryLabelStyle: GoogleFonts.inter(
fontSize: 12,
color: hhaRed,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
listTileTheme: ListTileThemeData(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
dividerTheme: const DividerThemeData(
color: divider,
thickness: 1,
space: 1,
),
textTheme: _textTheme,
fontFamily: GoogleFonts.inter().fontFamily,
);
}
static TextTheme get _textTheme {
return TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.bold,
color: textPrimary,
),
displayMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.bold,
color: textPrimary,
),
displaySmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.bold,
color: textPrimary,
),
headlineLarge: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w600,
color: textPrimary,
),
headlineMedium: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimary,
),
headlineSmall: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: textPrimary,
),
titleLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimary,
),
titleMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textPrimary,
),
titleSmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: textSecondary,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.normal,
color: textPrimary,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.normal,
color: textPrimary,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.normal,
color: textSecondary,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: textPrimary,
),
labelMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
),
labelSmall: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
color: textTertiary,
),
);
}
}
extension ColorExtension on Color {
Color darken([double amount = .1]) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(this);
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
return hslDark.toColor();
}
Color lighten([double amount = .1]) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(this);
final hslLight = hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0));
return hslLight.toColor();
}
}

View File

@@ -0,0 +1,163 @@
import '../../domain/entities/logistic_object.dart';
class LogisticObjectModel extends LogisticObject {
const LogisticObjectModel({
required super.id,
required super.objectId,
required super.type,
required super.version,
super.locationId,
required super.code,
super.remark,
required super.state,
required super.subtype,
super.origin,
super.isManual,
super.lastModified,
super.typeName,
super.typeMnemonic,
});
factory LogisticObjectModel.fromJson(Map<String, dynamic> json) {
return LogisticObjectModel(
id: json['id'] ?? 0,
objectId: json['object_id'] ?? json['id'] ?? 0,
type: json['type'] ?? 0,
version: json['version'] ?? json['ver'] ?? 0,
locationId: json['loc_id'],
code: json['code'] ?? '',
remark: json['rem'],
state: json['state'] ?? 'unknown',
subtype: json['subtype'] ?? json['type']?.toString() ?? '',
origin: json['origin'],
isManual: json['manual'] == '1' || json['manual'] == true,
lastModified: json['last_modified'] != null
? DateTime.parse(json['last_modified'])
: null,
);
}
factory LogisticObjectModel.fromMap(Map<String, dynamic> map) {
return LogisticObjectModel(
id: map['id'] ?? 0,
objectId: map['object_id'] ?? 0,
type: map['type'] ?? 0,
version: map['version'] ?? 0,
locationId: map['loc_id'],
code: map['code'] ?? '',
remark: map['rem'],
state: map['state'] ?? 'unknown',
subtype: map['subtype'] ?? '',
origin: map['origin'],
isManual: map['manual'] == '1' || map['manual'] == 1,
lastModified: map['last_modified'] != null
? DateTime.tryParse(map['last_modified'])
: null,
typeName: map['type_name'],
typeMnemonic: map['type_mnemonic'],
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'object_id': objectId,
'type': type,
'version': version,
'loc_id': locationId,
'code': code,
'rem': remark,
'state': state,
'subtype': subtype,
'origin': origin,
'manual': isManual ? 1 : 0,
'last_modified': lastModified?.toIso8601String(),
};
}
LogisticObjectModel copyWithModel({
int? id,
int? objectId,
int? type,
int? version,
int? locationId,
String? code,
String? remark,
String? state,
String? subtype,
String? origin,
bool? isManual,
DateTime? lastModified,
String? typeName,
String? typeMnemonic,
}) {
return LogisticObjectModel(
id: id ?? this.id,
objectId: objectId ?? this.objectId,
type: type ?? this.type,
version: version ?? this.version,
locationId: locationId ?? this.locationId,
code: code ?? this.code,
remark: remark ?? this.remark,
state: state ?? this.state,
subtype: subtype ?? this.subtype,
origin: origin ?? this.origin,
isManual: isManual ?? this.isManual,
lastModified: lastModified ?? this.lastModified,
typeName: typeName ?? this.typeName,
typeMnemonic: typeMnemonic ?? this.typeMnemonic,
);
}
}
class ObjectMetadataModel extends ObjectMetadata {
const ObjectMetadataModel({
required super.id,
required super.type,
required super.version,
required super.mnemonic,
required super.name,
required super.prefix,
required super.subtype,
required super.counterText,
});
factory ObjectMetadataModel.fromJson(Map<String, dynamic> json) {
return ObjectMetadataModel(
id: json['id'] ?? 0,
type: json['type'] ?? 0,
version: json['version'] ?? json['ver'] ?? 0,
mnemonic: json['mnemonic'] ?? '',
name: json['name'] ?? '',
prefix: json['pre'] ?? '',
subtype: json['subtype'] ?? '',
counterText: json['counter_text'] ?? '',
);
}
factory ObjectMetadataModel.fromMap(Map<String, dynamic> map) {
return ObjectMetadataModel(
id: map['id'] ?? 0,
type: map['type'] ?? 0,
version: map['version'] ?? 0,
mnemonic: map['mnemonic'] ?? '',
name: map['name'] ?? '',
prefix: map['prefix'] ?? '',
subtype: map['subtype'] ?? '',
counterText: map['counter_text'] ?? '',
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'type': type,
'version': version,
'mnemonic': mnemonic,
'name': name,
'prefix': prefix,
'subtype': subtype,
'counter_text': counterText,
};
}
}

View File

@@ -0,0 +1,194 @@
import '../../domain/entities/tour.dart';
class TourModel extends Tour {
const TourModel({
required super.id,
required super.jobId,
required super.tourId,
required super.version,
required super.state,
required super.type,
required super.sort,
required super.locationId,
required super.locationCode,
super.locationCode2,
super.remark,
super.menuText,
required super.modified,
super.deliveryCode,
super.locationName,
super.isCompleted,
super.pages,
});
factory TourModel.fromJson(Map<String, dynamic> json) {
return TourModel(
id: json['id'] ?? 0,
jobId: json['job_id'] ?? 0,
tourId: json['tour_id'] ?? 0,
version: json['version'] ?? 0,
state: json['state'] ?? 0,
type: json['type'] ?? '',
sort: json['sort'] ?? 0,
locationId: json['loc_id'] ?? 0,
locationCode: json['loc_code'] ?? '',
locationCode2: json['loc_code_2'],
remark: json['rem'],
menuText: json['menu'],
modified: json['modified'] ?? 0,
deliveryCode: json['del_code'],
locationName: json['location_name'],
isCompleted: json['state'] == 1,
pages: (json['pages'] as List?)
?.map((p) => TourPageModel.fromJson(p))
.toList() ??
[],
);
}
factory TourModel.fromMap(Map<String, dynamic> map) {
return TourModel(
id: map['id'] ?? 0,
jobId: map['job_id'] ?? 0,
tourId: map['tour_id'] ?? 0,
version: map['version'] ?? 0,
state: map['state'] ?? 0,
type: map['type'] ?? '',
sort: map['sort'] ?? 0,
locationId: map['loc_id'] ?? 0,
locationCode: map['loc_code'] ?? '',
locationCode2: map['loc_code2'],
remark: map['rem'],
menuText: map['menu'],
modified: map['modified'] ?? 0,
deliveryCode: map['del_code'],
locationName: map['location_name'],
isCompleted: map['state'] == 1,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'job_id': jobId,
'tour_id': tourId,
'version': version,
'state': state,
'type': type,
'sort': sort,
'loc_id': locationId,
'loc_code': locationCode,
'loc_code2': locationCode2,
'rem': remark,
'menu': menuText,
'modified': modified,
'del_code': deliveryCode,
};
}
TourModel copyWithModel({
int? id,
int? jobId,
int? tourId,
int? version,
int? state,
String? type,
int? sort,
int? locationId,
String? locationCode,
String? locationCode2,
String? remark,
String? menuText,
int? modified,
String? deliveryCode,
String? locationName,
bool? isCompleted,
List<TourPage>? pages,
}) {
return TourModel(
id: id ?? this.id,
jobId: jobId ?? this.jobId,
tourId: tourId ?? this.tourId,
version: version ?? this.version,
state: state ?? this.state,
type: type ?? this.type,
sort: sort ?? this.sort,
locationId: locationId ?? this.locationId,
locationCode: locationCode ?? this.locationCode,
locationCode2: locationCode2 ?? this.locationCode2,
remark: remark ?? this.remark,
menuText: menuText ?? this.menuText,
modified: modified ?? this.modified,
deliveryCode: deliveryCode ?? this.deliveryCode,
locationName: locationName ?? this.locationName,
isCompleted: isCompleted ?? this.isCompleted,
pages: pages ?? this.pages,
);
}
}
class TourPageModel extends TourPage {
const TourPageModel({
required super.id,
required super.tourId,
required super.pageNumber,
required super.pageId,
required super.type,
super.code,
super.label,
super.pickupCounts,
super.swapCounts,
});
factory TourPageModel.fromJson(Map<String, dynamic> json) {
Map<String, int> pickupCounts = {};
Map<String, int> swapCounts = {};
if (json['pickup'] != null && json['pickup']['cnt'] != null) {
final cnt = json['pickup']['cnt'] as Map<String, dynamic>;
pickupCounts = cnt.map((key, value) => MapEntry(key, value as int));
}
if (json['swap'] != null && json['swap']['cnt'] != null) {
final cnt = json['swap']['cnt'] as Map<String, dynamic>;
swapCounts = cnt.map((key, value) => MapEntry(key, value as int));
}
return TourPageModel(
id: json['id'] ?? 0,
tourId: json['tour_id'] ?? 0,
pageNumber: json['page_number'] ?? 0,
pageId: json['page_id'] ?? '',
type: json['type'] ?? '',
code: json['code'],
label: json['lbl'],
pickupCounts: pickupCounts,
swapCounts: swapCounts,
);
}
factory TourPageModel.fromMap(Map<String, dynamic> map) {
return TourPageModel(
id: map['id'] ?? 0,
tourId: map['tour_id'] ?? 0,
pageNumber: map['page_number'] ?? 0,
pageId: map['page_id'] ?? '',
type: map['type'] ?? '',
code: map['code'],
label: map['label'],
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'tour_id': tourId,
'page_number': pageNumber,
'page_id': pageId,
'type': type,
'code': code,
'label': label,
};
}
}

View File

@@ -0,0 +1,199 @@
import 'package:equatable/equatable.dart';
/// Repräsentiert einen Zählerstand für einen Objekttyp
/// Entspricht der Lua-Logik in CreateLoadingStockStartView etc.
class ObjectCounter extends Equatable {
final String objectType; // z.B. 'meka', 'beka', 'hp1a', etc.
final String label; // z.B. 'MEK', 'BEK', 'H1'
final int currentCount; // Aktueller Bestand (z.B. im Fahrzeug)
final int targetCount; // Soll-Zahl (z.B. Beladezähler)
final int? alternateCount; // Alternative Zählung (z.B. HADAG, CR, SST)
const ObjectCounter({
required this.objectType,
required this.label,
required this.currentCount,
required this.targetCount,
this.alternateCount,
});
bool get isComplete => currentCount >= targetCount;
bool get isOver => currentCount > targetCount;
int get difference => targetCount - currentCount;
@override
List<Object?> get props => [objectType, label, currentCount, targetCount, alternateCount];
}
/// Gruppen von Zählern für verschiedene Ansichten
class CounterGroup extends Equatable {
final String title;
final List<ObjectCounter> counters;
const CounterGroup({
required this.title,
required this.counters,
});
@override
List<Object?> get props => [title, counters];
}
/// Zähler-Übersicht für eine komplette Tour/Page
class CounterOverview extends Equatable {
final int tourId;
final String? pageId;
final List<CounterGroup> groups;
final DateTime? lastUpdated;
const CounterOverview({
required this.tourId,
this.pageId,
required this.groups,
this.lastUpdated,
});
/// Standard-Gruppen für StockStart (Lager Beladung)
/// Entspricht Lua: CreateLoadingStockStartView
factory CounterOverview.stockStart({
required int tourId,
required List<ObjectCounter> vehicleStock, // Bestand Fzg
required List<ObjectCounter> loadingCounters, // Beladezähler
required List<ObjectCounter> hadagCounters, // HADAG
required List<ObjectCounter> sstCounters, // SST
required List<ObjectCounter> crCounters, // CR
}) {
return CounterOverview(
tourId: tourId,
groups: [
CounterGroup(title: 'Bestand Fzg', counters: vehicleStock),
CounterGroup(title: 'Beladezähler', counters: loadingCounters),
CounterGroup(title: 'HADAG', counters: hadagCounters),
CounterGroup(title: 'SST', counters: sstCounters),
CounterGroup(title: 'CR', counters: crCounters),
],
);
}
/// Standard-Gruppen für VehStart
/// Entspricht Lua: CreateLoadingVehStartView
factory CounterOverview.vehStart({
required int tourId,
required List<ObjectCounter> loadingCounters,
}) {
return CounterOverview(
tourId: tourId,
groups: [
CounterGroup(title: 'Beladezähler', counters: loadingCounters),
],
);
}
/// Standard-Gruppen für Veh (Station)
/// Entspricht Lua: CreateLoadingVehView
factory CounterOverview.veh({
required int tourId,
String? pageId,
required List<ObjectCounter> swapCounters, // Wechselzähler
required List<ObjectCounter> pickupCounters, // Abholzähler
}) {
return CounterOverview(
tourId: tourId,
pageId: pageId,
groups: [
CounterGroup(title: 'Wechsel', counters: swapCounters),
CounterGroup(title: 'Abholung', counters: pickupCounters),
],
);
}
@override
List<Object?> get props => [tourId, pageId, groups, lastUpdated];
}
/// Pickup/Abhol-Zähler aus der Datenbank
/// Entspricht Lua: page_pickup_count Tabelle
class PickupCount extends Equatable {
final int tourId;
final String pageId;
final String objectType;
final int count;
const PickupCount({
required this.tourId,
required this.pageId,
required this.objectType,
required this.count,
});
@override
List<Object?> get props => [tourId, pageId, objectType, count];
}
/// Swap/Wechsel-Zähler aus der Datenbank
/// Entspricht Lua: page_swap_count Tabelle
class SwapCount extends Equatable {
final int tourId;
final String pageId;
final String objectType;
final int count;
const SwapCount({
required this.tourId,
required this.pageId,
required this.objectType,
required this.count,
});
@override
List<Object?> get props => [tourId, pageId, objectType, count];
}
/// Container-Information für VS/Verwahrungsstelle
/// Entspricht Lua: ContainerId, ContainerType in vsStateMachine
class ContainerInfo extends Equatable {
final String containerId;
final String containerType; // 'a' = Geldinstitut, 'b' = Dienststelle
final String? subtype; // 'cntra' oder 'cntrb'
final int? objectCount; // Anzahl Objekte im Container
const ContainerInfo({
required this.containerId,
required this.containerType,
this.subtype,
this.objectCount,
});
bool get isForGI => containerType == 'a';
bool get isForDS => containerType == 'b';
String get displayName {
if (isForGI) return 'Container Geldinstitut';
if (isForDS) return 'Container Dienststelle';
return 'Container $containerId';
}
@override
List<Object?> get props => [containerId, containerType, subtype, objectCount];
}
/// Letzte gescannte Objekte für die Anzeige
/// Entspricht Lua: Die Liste in ShowStockStartScreen etc.
class RecentScan extends Equatable {
final String objectCode;
final String objectName;
final String state;
final DateTime scanTime;
final String? imageName;
const RecentScan({
required this.objectCode,
required this.objectName,
required this.state,
required this.scanTime,
this.imageName,
});
@override
List<Object?> get props => [objectCode, objectName, state, scanTime, imageName];
}

View File

@@ -0,0 +1,89 @@
import 'package:equatable/equatable.dart';
class Location extends Equatable {
final int id;
final int locationId;
final int version;
final String name;
final String? street;
final String? number;
final String? zip;
final String? city;
final double? latitude;
final double? longitude;
final String? remark;
const Location({
required this.id,
required this.locationId,
required this.version,
required this.name,
this.street,
this.number,
this.zip,
this.city,
this.latitude,
this.longitude,
this.remark,
});
Location copyWith({
int? id,
int? locationId,
int? version,
String? name,
String? street,
String? number,
String? zip,
String? city,
double? latitude,
double? longitude,
String? remark,
}) {
return Location(
id: id ?? this.id,
locationId: locationId ?? this.locationId,
version: version ?? this.version,
name: name ?? this.name,
street: street ?? this.street,
number: number ?? this.number,
zip: zip ?? this.zip,
city: city ?? this.city,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
remark: remark ?? this.remark,
);
}
String get fullAddress {
final parts = <String>[];
if (street != null && street!.isNotEmpty) {
parts.add(street!);
if (number != null && number!.isNotEmpty) {
parts.add(number!);
}
}
if (zip != null && zip!.isNotEmpty) {
parts.add(zip!);
}
if (city != null && city!.isNotEmpty) {
parts.add(city!);
}
return parts.join(', ');
}
@override
List<Object?> get props => [
id,
locationId,
version,
name,
street,
number,
zip,
city,
latitude,
longitude,
remark,
];
}

View File

@@ -0,0 +1,250 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
class LogisticObject extends Equatable {
final int id;
final int objectId;
final int type;
final int version;
final int? locationId;
final String code;
final String? remark;
final String state;
final String subtype;
final String? origin;
final bool isManual;
final DateTime? lastModified;
final String? typeName;
final String? typeMnemonic;
const LogisticObject({
required this.id,
required this.objectId,
required this.type,
required this.version,
this.locationId,
required this.code,
this.remark,
required this.state,
required this.subtype,
this.origin,
this.isManual = false,
this.lastModified,
this.typeName,
this.typeMnemonic,
});
LogisticObject copyWith({
int? id,
int? objectId,
int? type,
int? version,
int? locationId,
String? code,
String? remark,
String? state,
String? subtype,
String? origin,
bool? isManual,
DateTime? lastModified,
String? typeName,
String? typeMnemonic,
}) {
return LogisticObject(
id: id ?? this.id,
objectId: objectId ?? this.objectId,
type: type ?? this.type,
version: version ?? this.version,
locationId: locationId ?? this.locationId,
code: code ?? this.code,
remark: remark ?? this.remark,
state: state ?? this.state,
subtype: subtype ?? this.subtype,
origin: origin ?? this.origin,
isManual: isManual ?? this.isManual,
lastModified: lastModified ?? this.lastModified,
typeName: typeName ?? this.typeName,
typeMnemonic: typeMnemonic ?? this.typeMnemonic,
);
}
@override
List<Object?> get props => [
id,
objectId,
type,
version,
locationId,
code,
remark,
state,
subtype,
origin,
isManual,
lastModified,
typeName,
typeMnemonic,
];
}
class ObjectMetadata extends Equatable {
final int id;
final int type;
final int version;
final String mnemonic;
final String name;
final String prefix;
final String subtype;
final String counterText;
const ObjectMetadata({
required this.id,
required this.type,
required this.version,
required this.mnemonic,
required this.name,
required this.prefix,
required this.subtype,
required this.counterText,
});
@override
List<Object?> get props => [
id,
type,
version,
mnemonic,
name,
prefix,
subtype,
counterText,
];
}
class ObjectStateInfo {
final String state;
final String displayName;
final int colorValue;
final String iconName;
const ObjectStateInfo({
required this.state,
required this.displayName,
required this.colorValue,
required this.iconName,
});
static const Map<String, ObjectStateInfo> stateInfos = {
'unknown': ObjectStateInfo(
state: 'unknown',
displayName: 'Unbekannt',
colorValue: 0xFFFFFFFF,
iconName: 'help',
),
'delivery': ObjectStateInfo(
state: 'delivery',
displayName: 'Im Fahrzeug',
colorValue: 0xFFB3B3B3,
iconName: 'local_shipping',
),
'to_delivery': ObjectStateInfo(
state: 'to_delivery',
displayName: 'Zum Fahrzeug',
colorValue: 0xFFB3B3B3,
iconName: 'local_shipping_outlined',
),
'station': ObjectStateInfo(
state: 'station',
displayName: 'An Station',
colorValue: 0xFFFFDD00,
iconName: 'location_on',
),
'in_fa': ObjectStateInfo(
state: 'in_fa',
displayName: 'Im Fahrscheinautomat',
colorValue: 0xFF9CDA7A,
iconName: 'confirmation_number',
),
'in_vs': ObjectStateInfo(
state: 'in_vs',
displayName: 'In Versorgungsstelle',
colorValue: 0xFFFAE14B,
iconName: 'inventory',
),
'ret_fail': ObjectStateInfo(
state: 'ret_fail',
displayName: 'Fehler - zur Dienststelle',
colorValue: 0xFFFF9081,
iconName: 'error',
),
'ret_fail_fzg': ObjectStateInfo(
state: 'ret_fail_fzg',
displayName: 'Fehler - im Fahrzeug',
colorValue: 0xFFFF9081,
iconName: 'error_outline',
),
'ret_ds': ObjectStateInfo(
state: 'ret_ds',
displayName: 'Zur Dienststelle',
colorValue: 0xFFAFE0ED,
iconName: 'account_balance',
),
'ret_ds_fzg': ObjectStateInfo(
state: 'ret_ds_fzg',
displayName: 'Zur Dienststelle (Fzg)',
colorValue: 0xFFAFE0ED,
iconName: 'account_balance_outlined',
),
'ret_gi': ObjectStateInfo(
state: 'ret_gi',
displayName: 'Zum Geldinstitut',
colorValue: 0xFFAFE0ED,
iconName: 'account_balance_wallet',
),
'ret_gi_fzg': ObjectStateInfo(
state: 'ret_gi_fzg',
displayName: 'Zum Geldinstitut (Fzg)',
colorValue: 0xFFAFE0ED,
iconName: 'account_balance_wallet_outlined',
),
'fin_ds': ObjectStateInfo(
state: 'fin_ds',
displayName: 'In Dienststelle',
colorValue: 0xFF29B7FB,
iconName: 'check_circle',
),
'fin_gi': ObjectStateInfo(
state: 'fin_gi',
displayName: 'In Geldinstitut',
colorValue: 0xFF25BAFC,
iconName: 'check_circle_outline',
),
'hdl': ObjectStateInfo(
state: 'hdl',
displayName: 'Handel',
colorValue: 0xFF9E9E9E,
iconName: 'shopping_cart',
),
'ret_ds_empty': ObjectStateInfo(
state: 'ret_ds_empty',
displayName: 'Leer - zur Dienststelle',
colorValue: 0xFFAFE0ED,
iconName: 'remove_circle_outline',
),
};
static Color getColorForState(String state) {
final info = stateInfos[state];
return info != null ? Color(info.colorValue) : const Color(0xFFFFFFFF);
}
static String getDisplayName(String state) {
final info = stateInfos[state];
return info?.displayName ?? 'Unbekannt';
}
static String getIconName(String state) {
final info = stateInfos[state];
return info?.iconName ?? 'help';
}
}

View File

@@ -0,0 +1,163 @@
import 'package:equatable/equatable.dart';
class Tour extends Equatable {
final int id;
final int jobId;
final int tourId;
final int version;
final int state; // 0 = offen, 1 = erledigt, 2 = abgeschlossen
final String type;
final int sort;
final int locationId;
final String locationCode;
final String? locationCode2;
final String? remark;
final String? menuText;
final int modified;
final String? deliveryCode;
final String? locationName;
final bool isCompleted;
final List<TourPage> pages;
const Tour({
required this.id,
required this.jobId,
required this.tourId,
required this.version,
required this.state,
required this.type,
required this.sort,
required this.locationId,
required this.locationCode,
this.locationCode2,
this.remark,
this.menuText,
required this.modified,
this.deliveryCode,
this.locationName,
this.isCompleted = false,
this.pages = const [],
});
Tour copyWith({
int? id,
int? jobId,
int? tourId,
int? version,
int? state,
String? type,
int? sort,
int? locationId,
String? locationCode,
String? locationCode2,
String? remark,
String? menuText,
int? modified,
String? deliveryCode,
String? locationName,
bool? isCompleted,
List<TourPage>? pages,
}) {
return Tour(
id: id ?? this.id,
jobId: jobId ?? this.jobId,
tourId: tourId ?? this.tourId,
version: version ?? this.version,
state: state ?? this.state,
type: type ?? this.type,
sort: sort ?? this.sort,
locationId: locationId ?? this.locationId,
locationCode: locationCode ?? this.locationCode,
locationCode2: locationCode2 ?? this.locationCode2,
remark: remark ?? this.remark,
menuText: menuText ?? this.menuText,
modified: modified ?? this.modified,
deliveryCode: deliveryCode ?? this.deliveryCode,
locationName: locationName ?? this.locationName,
isCompleted: isCompleted ?? this.isCompleted,
pages: pages ?? this.pages,
);
}
@override
List<Object?> get props => [
id,
jobId,
tourId,
version,
state,
type,
sort,
locationId,
locationCode,
locationCode2,
remark,
menuText,
modified,
deliveryCode,
locationName,
isCompleted,
pages,
];
}
class TourPage extends Equatable {
final int id;
final int tourId;
final int pageNumber;
final String pageId;
final String type;
final String? code;
final String? label;
final Map<String, int> pickupCounts;
final Map<String, int> swapCounts;
const TourPage({
required this.id,
required this.tourId,
required this.pageNumber,
required this.pageId,
required this.type,
this.code,
this.label,
this.pickupCounts = const {},
this.swapCounts = const {},
});
TourPage copyWith({
int? id,
int? tourId,
int? pageNumber,
String? pageId,
String? type,
String? code,
String? label,
Map<String, int>? pickupCounts,
Map<String, int>? swapCounts,
}) {
return TourPage(
id: id ?? this.id,
tourId: tourId ?? this.tourId,
pageNumber: pageNumber ?? this.pageNumber,
pageId: pageId ?? this.pageId,
type: type ?? this.type,
code: code ?? this.code,
label: label ?? this.label,
pickupCounts: pickupCounts ?? this.pickupCounts,
swapCounts: swapCounts ?? this.swapCounts,
);
}
@override
List<Object?> get props => [
id,
tourId,
pageNumber,
pageId,
type,
code,
label,
pickupCounts,
swapCounts,
];
}

View File

@@ -0,0 +1,109 @@
import 'package:dartz/dartz.dart';
import '../entities/tour.dart';
import '../entities/logistic_object.dart';
import '../entities/location.dart';
import '../entities/counter.dart';
import '../../core/errors/failures.dart';
abstract class TourRepository {
// Touren
Future<Either<Failure, List<Tour>>> getTours();
Future<Either<Failure, Tour>> getTourById(int tourId);
Future<Either<Failure, void>> updateTourState(int tourId, int state);
Future<Either<Failure, void>> completeTour(int tourId);
// Sync
Future<Either<Failure, void>> syncData();
Future<Either<Failure, bool>> checkForUpdates();
// Objects
Future<Either<Failure, List<LogisticObject>>> getObjectsByTour(int tourId);
Future<Either<Failure, List<LogisticObject>>> getObjectsByState(String state);
Future<Either<Failure, LogisticObject?>> getObjectByBarcode(String barcode);
Future<Either<Failure, void>> updateObjectState(
int objectId,
String newState, {
int? locationId,
int? refType,
int? refId,
String? containerCode,
});
Future<Either<Failure, void>> createObject({
required int type,
required String code,
bool isManual = true,
});
// Locations
Future<Either<Failure, List<Location>>> getLocations();
Future<Either<Failure, Location>> getLocationById(int locationId);
// Metadata
Future<Either<Failure, List<ObjectMetadata>>> getObjectMetadata();
// Statistics
Future<Either<Failure, TourStatistics>> getTourStatistics(int tourId);
Future<Either<Failure, ObjectStatistics>> getObjectStatistics();
// Counter operations (spezifisch für Lua-kompatible Ansichten)
Future<Either<Failure, CounterOverview>> getCounterOverview(
int tourId,
String tourType, {
String? pageId,
});
Future<Either<Failure, List<PickupCount>>> getPickupCounts(int tourId, String pageId);
Future<Either<Failure, List<SwapCount>>> getSwapCounts(int tourId, String pageId);
// Container operations
Future<Either<Failure, List<ContainerInfo>>> getOpenContainers();
Future<Either<Failure, void>> addObjectToContainer(
String containerId,
String containerType,
int objectId,
);
Future<Either<Failure, void>> closeContainer(
String containerId,
String containerType,
);
// Recent scans
Future<Either<Failure, List<RecentScan>>> getRecentScans(
int tourId, {
int limit = 10,
});
}
class TourStatistics {
final int totalObjects;
final int completedObjects;
final int pendingObjects;
final Map<String, int> objectsByState;
final Map<String, int> objectsByType;
final double completionPercentage;
const TourStatistics({
required this.totalObjects,
required this.completedObjects,
required this.pendingObjects,
required this.objectsByState,
required this.objectsByType,
required this.completionPercentage,
});
}
class ObjectStatistics {
final Map<String, int> byState;
final Map<String, int> byType;
final Map<String, int> byLocation;
final List<LogisticObject> recentObjects;
final int totalCount;
const ObjectStatistics({
required this.byState,
required this.byType,
required this.byLocation,
required this.recentObjects,
required this.totalCount,
});
}

424
app/lib/main.dart Normal file
View File

@@ -0,0 +1,424 @@
import 'package:dartz/dartz.dart' hide State;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/theme/app_theme.dart';
import 'domain/entities/tour.dart';
import 'domain/entities/logistic_object.dart';
import 'domain/entities/location.dart';
import 'domain/entities/counter.dart';
import 'domain/repositories/tour_repository.dart';
import 'core/errors/failures.dart';
import 'presentation/blocs/tour/tour_bloc.dart';
import 'presentation/blocs/scan/scan_bloc.dart';
import 'presentation/pages/tours/tours_page.dart';
import 'presentation/pages/tours/dashboard_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Set preferred orientations
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Set system UI overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
runApp(const HHAApp());
}
class HHAApp extends StatelessWidget {
const HHAApp({super.key});
@override
Widget build(BuildContext context) {
final repository = MockTourRepository();
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => TourBloc(
repository: repository,
)..add(const LoadTours()),
),
BlocProvider(
create: (context) => ScanBloc(
repository: repository,
),
),
],
child: MaterialApp(
title: 'HHA Logistics',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
home: const MainNavigationPage(),
),
);
}
}
class MainNavigationPage extends StatefulWidget {
const MainNavigationPage({super.key});
@override
State<MainNavigationPage> createState() => _MainNavigationPageState();
}
class _MainNavigationPageState extends State<MainNavigationPage> {
int _currentIndex = 0;
final List<Widget> _pages = const [
DashboardPage(),
ToursPage(),
InventoryPage(),
SettingsPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Touren',
),
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: 'Bestand',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Einstellungen',
),
],
),
);
}
}
// Mock Repository for demonstration
class MockTourRepository implements TourRepository {
@override
Future<Either<Failure, List<Tour>>> getTours() async {
await Future.delayed(const Duration(seconds: 1));
return const Right([
Tour(
id: 1,
jobId: 1,
tourId: 1,
version: 1,
state: 0,
type: 'stock_start',
sort: 1,
locationId: 1,
locationCode: 'LAGER001',
locationName: 'Hauptlager Wandsbek',
modified: 0,
),
Tour(
id: 2,
jobId: 1,
tourId: 2,
version: 1,
state: 0,
type: 'veh_start',
sort: 2,
locationId: 2,
locationCode: 'DST001',
locationName: 'Dienststelle Hammerbrook',
modified: 0,
),
Tour(
id: 3,
jobId: 1,
tourId: 3,
version: 1,
state: 0,
type: 'st',
sort: 3,
locationId: 3,
locationCode: 'HALT001',
locationName: 'Hauptbahnhof Nord',
remark: '4 Fahrscheinautomaten',
modified: 0,
),
Tour(
id: 4,
jobId: 1,
tourId: 4,
version: 1,
state: 1,
type: 'st',
sort: 4,
locationId: 4,
locationCode: 'HALT002',
locationName: 'Jungfernstieg',
remark: '2 Fahrscheinautomaten',
modified: 0,
),
Tour(
id: 5,
jobId: 1,
tourId: 5,
version: 1,
state: 0,
type: 'gi',
sort: 5,
locationId: 5,
locationCode: 'BANK001',
locationName: 'Geldinstitut Mitte',
modified: 0,
),
]);
}
@override
Future<Either<Failure, Tour>> getTourById(int tourId) async {
final tours = await getTours();
return tours.fold(
(failure) => Left(failure),
(tourList) {
final tour = tourList.firstWhere(
(t) => t.tourId == tourId,
orElse: () => throw Exception('Tour not found'),
);
return Right(tour);
},
);
}
@override
Future<Either<Failure, void>> updateTourState(int tourId, int state) async {
return const Right(null);
}
@override
Future<Either<Failure, void>> completeTour(int tourId) async {
return const Right(null);
}
@override
Future<Either<Failure, void>> syncData() async {
await Future.delayed(const Duration(seconds: 1));
return const Right(null);
}
@override
Future<Either<Failure, bool>> checkForUpdates() async => const Right(false);
@override
Future<Either<Failure, List<LogisticObject>>> getObjectsByTour(int tourId) async => const Right([]);
@override
Future<Either<Failure, List<LogisticObject>>> getObjectsByState(String state) async => const Right([]);
@override
Future<Either<Failure, LogisticObject?>> getObjectByBarcode(String barcode) async => const Right(null);
@override
Future<Either<Failure, void>> updateObjectState(
int objectId,
String newState, {
int? locationId,
int? refType,
int? refId,
String? containerCode,
}) async => const Right(null);
@override
Future<Either<Failure, void>> createObject({
required int type,
required String code,
bool isManual = true,
}) async => const Right(null);
@override
Future<Either<Failure, List<Location>>> getLocations() async => const Right([]);
@override
Future<Either<Failure, Location>> getLocationById(int locationId) async {
return const Left(NotFoundFailure(message: 'Location not found'));
}
@override
Future<Either<Failure, List<ObjectMetadata>>> getObjectMetadata() async => const Right([]);
@override
Future<Either<Failure, TourStatistics>> getTourStatistics(int tourId) async {
return const Right(TourStatistics(
totalObjects: 10,
completedObjects: 5,
pendingObjects: 5,
objectsByState: {'delivery': 3, 'station': 2},
objectsByType: {'meka': 3, 'beka': 2},
completionPercentage: 50.0,
));
}
@override
Future<Either<Failure, ObjectStatistics>> getObjectStatistics() async {
return const Right(ObjectStatistics(
byState: {},
byType: {},
byLocation: {},
recentObjects: [],
totalCount: 0,
));
}
@override
Future<Either<Failure, CounterOverview>> getCounterOverview(
int tourId,
String tourType, {
String? pageId,
}) async {
return Right(CounterOverview(
tourId: tourId,
pageId: pageId,
groups: [],
));
}
@override
Future<Either<Failure, List<PickupCount>>> getPickupCounts(
int tourId,
String pageId,
) async => const Right([]);
@override
Future<Either<Failure, List<SwapCount>>> getSwapCounts(
int tourId,
String pageId,
) async => const Right([]);
@override
Future<Either<Failure, List<ContainerInfo>>> getOpenContainers() async => const Right([]);
@override
Future<Either<Failure, void>> addObjectToContainer(
String containerId,
String containerType,
int objectId,
) async => const Right(null);
@override
Future<Either<Failure, void>> closeContainer(
String containerId,
String containerType,
) async => const Right(null);
@override
Future<Either<Failure, List<RecentScan>>> getRecentScans(
int tourId, {
int limit = 10,
}) async => const Right([]);
}
// Placeholder pages
class InventoryPage extends StatelessWidget {
const InventoryPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Bestand'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2,
size: 80,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Bestandsübersicht',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Hier wird der aktuelle Bestand angezeigt',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
),
);
}
}
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Einstellungen'),
),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Daten synchronisieren'),
subtitle: const Text('Letzte Synchronisation: Gerade eben'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// Trigger sync
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.visibility),
title: const Text('Erledigte Stationen anzeigen'),
trailing: Switch(
value: true,
onChanged: (value) {},
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.info),
title: const Text('Über'),
subtitle: const Text('Version 2.0.0'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
],
),
);
}
}

View File

@@ -0,0 +1,916 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../../domain/entities/logistic_object.dart';
import '../../../domain/entities/tour.dart';
import '../../../domain/repositories/tour_repository.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/errors/failures.dart';
part 'scan_event.dart';
part 'scan_state.dart';
class ScanBloc extends Bloc<ScanEvent, ScanState> {
final TourRepository repository;
Tour? currentTour;
String? currentPageId;
String? scannedFsaId;
// Container handling for VS state machine
String? containerId;
String? containerType; // 'a' or 'b'
ScanBloc({required this.repository}) : super(ScanInitial()) {
on<InitializeScan>(_onInitializeScan);
on<ProcessBarcode>(_onProcessBarcode);
on<ValidateBarcode>(_onValidateBarcode);
on<UpdateObjectState>(_onUpdateObjectState);
on<ResetScan>(_onResetScan);
on<CreateUnknownObject>(_onCreateUnknownObject);
}
Future<void> _onInitializeScan(InitializeScan event, Emitter<ScanState> emit) async {
currentTour = event.tour;
containerId = null;
containerType = null;
emit(ScanReady(tour: event.tour));
}
Future<void> _onProcessBarcode(ProcessBarcode event, Emitter<ScanState> emit) async {
emit(ScanProcessing(barcode: event.barcode));
final barcode = event.barcode.trim();
// Prüfe auf spezielle Barcodes (Seiten-Codes)
if (currentTour != null) {
final pageInfo = _findPageForBarcode(barcode);
if (pageInfo != null) {
emit(ScanPageDetected(
pageId: pageInfo['pageId']!,
label: pageInfo['label']!,
tour: currentTour!,
));
return;
}
}
// Suche Objekt nach Barcode
final result = await repository.getObjectByBarcode(barcode);
result.fold(
(failure) => emit(ScanError(message: _mapFailureToMessage(failure))),
(object) {
if (object != null) {
_processScannedObject(object, barcode, emit);
} else {
// Unbekanntes Objekt - prüfe auf gültiges Präfix
final prefix = barcode.length >= 3 ? barcode.substring(0, 3) : '';
if (_isValidPrefix(prefix)) {
emit(ScanUnknownObject(
barcode: barcode,
prefix: prefix,
tour: currentTour,
));
} else {
emit(ScanError(message: 'Unbekannter Barcode: $barcode'));
}
}
},
);
}
void _processScannedObject(LogisticObject object, String barcode, Emitter<ScanState> emit) {
if (currentTour == null) {
emit(const ScanError(message: 'Keine Tour ausgewählt'));
return;
}
final tourType = currentTour!.type;
// Dispatch to appropriate state machine based on tour type
switch (tourType) {
case TourTypes.stockStart:
_stockStartStateMachine(object, emit);
break;
case TourTypes.vehStart:
_vehStartStateMachine(object, emit);
break;
case TourTypes.vehBulk:
_vehBulkStateMachine(object, emit);
break;
case TourTypes.veh:
_vehStateMachine(object, emit);
break;
case TourTypes.fsa:
_fsaStateMachine(object, emit);
break;
case TourTypes.vs:
_vsStateMachine(object, emit);
break;
case TourTypes.vehVs:
_vehVsStateMachine(object, emit);
break;
case TourTypes.gi:
_giStateMachine(object, emit);
break;
case TourTypes.vehEnd:
_vehEndStateMachine(object, emit);
break;
case TourTypes.stockEnd:
_stockEndStateMachine(object, emit);
break;
case TourTypes.stock:
_stockStateMachine(object, emit);
break;
default:
// Fallback to simple state machine for unknown tour types
final nextState = _determineNextState(object);
emit(ScanObjectDetected(
object: object,
suggestedState: nextState,
tour: currentTour,
));
}
}
// ============================================================================
// STATE MACHINE: stockStart (Lager Beladung)
// ============================================================================
void _stockStartStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final currentState = object.state;
if (currentState == ObjectStates.unknown) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.toDelivery,
tour: currentTour,
));
} else if (currentState == ObjectStates.finGITmp) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retGI,
tour: currentTour,
));
} else {
emit(ScanError(
message: 'Fehler: Ungültiger Barcode für Lager Beladung',
));
}
}
// ============================================================================
// STATE MACHINE: vehStart (Fahrzeug Start)
// ============================================================================
void _vehStartStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final currentState = object.state;
if (currentState == ObjectStates.toDelivery) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.delivery,
tour: currentTour,
));
} else if (currentState == ObjectStates.retGI) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retGIFzg,
tour: currentTour,
));
} else {
emit(const ScanError(message: 'Fehler: Ungültiger Barcode'));
}
}
// ============================================================================
// STATE MACHINE: vehBulk (Fahrzeug Bulk)
// ============================================================================
void _vehBulkStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final objectType = object.type;
final currentState = object.state;
// Type 9 = special bulk handling
if (objectType == 9) {
// Bulk update all objects with state to_delivery
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.delivery,
tour: currentTour,
));
} else {
if (currentState == ObjectStates.toDelivery) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.delivery,
tour: currentTour,
));
} else if (currentState == ObjectStates.retGI) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retGIFzg,
tour: currentTour,
));
} else {
emit(const ScanError(message: 'Fehler: Ungültiger Barcode'));
}
}
}
// ============================================================================
// STATE MACHINE: veh (Fahrzeug - Stationen)
// ============================================================================
void _vehStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final currentState = object.state;
switch (currentState) {
case ObjectStates.delivery:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.station,
tour: currentTour,
));
break;
case ObjectStates.retFail:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retFailFzg,
tour: currentTour,
));
break;
case ObjectStates.retGI:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retGIFzg,
tour: currentTour,
));
break;
case ObjectStates.retDS:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDSFzg,
tour: currentTour,
));
break;
case ObjectStates.station:
// Reverse transition: back to delivery
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.delivery,
tour: currentTour,
));
break;
case ObjectStates.inFA:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.hdl,
tour: currentTour,
));
break;
default:
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
// ============================================================================
// STATE MACHINE: fsa (Fahrscheinautomat)
// ============================================================================
void _fsaStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final objectType = object.type;
final currentState = object.state;
final subtype = object.subtype.toLowerCase();
// GK = Geldkassette (type 1)
if (_isTypeGK(objectType, subtype)) {
_fsaGKStateMachine(object, currentState, emit);
}
// HP = Hauptkasse/Druckerpatronen (type 2)
else if (_isTypeHP(objectType, subtype)) {
_fsaHPStateMachine(object, currentState, emit);
}
// FR = Fahrkartenrolle (type 5)
else if (_isTypeFR(objectType, subtype)) {
_fsaFRStateMachine(object, currentState, emit);
}
else {
emit(const ScanError(message: 'Fehler: Falscher Typ für FSA'));
}
}
void _fsaGKStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
switch (currentState) {
case ObjectStates.station:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.inFA,
tour: currentTour,
));
break;
case ObjectStates.inFA:
// Special handling: Fehlkassette logic
emit(ScanFehlKassetteDetected(
object: object,
suggestedState: ObjectStates.retFail,
tour: currentTour,
));
break;
case ObjectStates.unknown:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retGI,
tour: currentTour,
originBarcode: object.code,
));
break;
default:
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
void _fsaHPStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
switch (currentState) {
case ObjectStates.station:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.inFA,
tour: currentTour,
));
break;
case ObjectStates.inFA:
// Bidirectional: back to station
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.station,
tour: currentTour,
));
break;
case ObjectStates.unknown:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDS,
tour: currentTour,
originBarcode: object.code,
));
break;
default:
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
void _fsaFRStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
switch (currentState) {
case ObjectStates.station:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.inFA,
tour: currentTour,
));
break;
case ObjectStates.inFA:
// Bidirectional: back to station
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.station,
tour: currentTour,
));
break;
default:
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
// ============================================================================
// STATE MACHINE: vs (Versorgungsstelle)
// ============================================================================
void _vsStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final objectType = object.type;
final currentState = object.state;
final subtype = object.subtype.toLowerCase();
// SB = Safebag
if (_isTypeSB(objectType, subtype)) {
_vsSBStateMachine(object, currentState, emit);
}
// ABS = Abfallbehälter
else if (_isTypeABS(objectType, subtype)) {
_vsABSStateMachine(object, currentState, emit);
}
// CNTR = Container
else if (_isTypeCNTR(objectType, subtype)) {
_vsCNTRStateMachine(object, currentState, subtype, emit);
}
else {
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
void _vsSBStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
if (containerId == null) {
emit(const ScanError(message: 'Fehler: Falscher Zustand - Container nicht ausgewählt'));
return;
}
if (containerType == 'a') {
if (currentState == ObjectStates.inVS) {
emit(ScanContainerObjectDetected(
object: object,
suggestedState: ObjectStates.retGI,
tour: currentTour,
containerId: containerId!,
containerType: containerType!,
));
} else {
emit(const ScanError(message: 'Fehler: Falscher Zustand'));
}
} else {
emit(const ScanError(message: 'Fehler: Falscher Zustand'));
}
}
void _vsABSStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
if (containerId == null) {
emit(const ScanError(message: 'Fehler: Falscher Zustand - Container nicht ausgewählt'));
return;
}
if (containerType == 'a') {
emit(const ScanError(message: 'Fehler: Falscher Zustand'));
} else {
if (currentState == ObjectStates.inVS) {
emit(ScanContainerObjectDetected(
object: object,
suggestedState: ObjectStates.retDS,
tour: currentTour,
containerId: containerId!,
containerType: containerType!,
));
} else {
emit(const ScanError(message: 'Fehler: Falscher Zustand'));
}
}
}
void _vsCNTRStateMachine(LogisticObject object, String currentState, String subtype, Emitter<ScanState> emit) {
containerId = object.code;
if (subtype == 'cntra') {
containerType = 'a';
emit(ScanContainerDetected(
object: object,
suggestedState: ObjectStates.retcGI,
tour: currentTour,
containerId: containerId!,
containerType: containerType!,
));
} else if (subtype == 'cntrb') {
containerType = 'b';
emit(ScanContainerDetected(
object: object,
suggestedState: ObjectStates.retcDS,
tour: currentTour,
containerId: containerId!,
containerType: containerType!,
));
} else {
emit(const ScanError(message: 'Fehler: Unbekannter Container-Typ'));
}
}
// ============================================================================
// STATE MACHINE: vehVs (Fahrzeug VS)
// ============================================================================
void _vehVsStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final objectType = object.type;
final currentState = object.state;
final subtype = object.subtype.toLowerCase();
// SB and ABS not allowed in vehVs
if (_isTypeSB(objectType, subtype) || _isTypeABS(objectType, subtype)) {
emit(const ScanError(message: 'Fehler: Falscher Status'));
return;
}
// CNTR = Container
if (_isTypeCNTR(objectType, subtype)) {
_vehVsCNTRStateMachine(object, currentState, subtype, emit);
} else {
emit(const ScanError(message: 'Fehler: Falscher Typ'));
}
}
void _vehVsCNTRStateMachine(LogisticObject object, String currentState, String subtype, Emitter<ScanState> emit) {
if (subtype == 'cntra') {
if (currentState == ObjectStates.retcGI) {
// Update all container objects and clear container
emit(ScanContainerCloseDetected(
object: object,
suggestedState: ObjectStates.unknown,
targetStateForObjects: ObjectStates.retGIFzg,
tour: currentTour,
containerType: 'a',
));
containerId = null;
containerType = null;
} else {
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
} else if (subtype == 'cntrb') {
if (currentState == ObjectStates.retcDS) {
// Update all container objects and clear container
emit(ScanContainerCloseDetected(
object: object,
suggestedState: ObjectStates.unknown,
targetStateForObjects: ObjectStates.retDSFzg,
tour: currentTour,
containerType: 'b',
));
containerId = null;
containerType = null;
} else {
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
}
// ============================================================================
// STATE MACHINE: gi (Geldinstitut)
// ============================================================================
void _giStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final objectType = object.type;
final currentState = object.state;
final subtype = object.subtype.toLowerCase();
// GK = Geldkassette
if (_isTypeGK(objectType, subtype)) {
_giGKStateMachine(object, currentState, emit);
}
// SB = Safebag
else if (_isTypeSB(objectType, subtype)) {
_giSBStateMachine(object, currentState, emit);
}
else {
emit(const ScanError(message: 'Fehler: Falscher Typ'));
}
}
void _giGKStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
switch (currentState) {
case ObjectStates.retGIFzg:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.finGI,
tour: currentTour,
));
break;
case ObjectStates.unknown:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDSEmpty,
tour: currentTour,
));
break;
case ObjectStates.delivery:
emit(const ScanError(message: 'Fehler: Falscher Status'));
break;
default:
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
void _giSBStateMachine(LogisticObject object, String currentState, Emitter<ScanState> emit) {
if (currentState == ObjectStates.retGIFzg) {
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.finGI,
tour: currentTour,
));
} else {
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
// ============================================================================
// STATE MACHINE: vehEnd (Fahrzeug Ende)
// ============================================================================
void _vehEndStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final currentState = object.state;
switch (currentState) {
case ObjectStates.retFailFzg:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retFailStk,
tour: currentTour,
));
break;
case ObjectStates.retDSFzg:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDSStk,
tour: currentTour,
));
break;
case ObjectStates.delivery:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDSStk,
tour: currentTour,
));
break;
case ObjectStates.retDSEmpty:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDSStk,
tour: currentTour,
));
break;
case ObjectStates.retGIFzg:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retGIStk,
tour: currentTour,
));
break;
default:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.retDSErr,
tour: currentTour,
));
}
}
// ============================================================================
// STATE MACHINE: stockEnd (Lager Ende)
// ============================================================================
void _stockEndStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final currentState = object.state;
switch (currentState) {
case ObjectStates.retFailStk:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.finDSFail,
tour: currentTour,
));
break;
case ObjectStates.retDSStk:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.finDS,
tour: currentTour,
));
break;
case ObjectStates.retDSErr:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.finDSErr,
tour: currentTour,
));
break;
case ObjectStates.retGIStk:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.finGITmp,
tour: currentTour,
));
break;
default:
emit(const ScanError(message: 'Fehler: Falscher Status'));
}
}
// ============================================================================
// STATE MACHINE: stock (Lager - HADAG)
// ============================================================================
void _stockStateMachine(LogisticObject object, Emitter<ScanState> emit) {
final objectType = object.type;
final currentState = object.state;
final subtype = object.subtype.toLowerCase();
// Only HP and GK allowed
if (!_isTypeHP(objectType, subtype) && !_isTypeGK(objectType, subtype)) {
emit(const ScanError(message: 'Fehler: Falscher Typ'));
return;
}
switch (currentState) {
case ObjectStates.unknown:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.station,
tour: currentTour,
));
break;
case ObjectStates.stkHadag:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.station,
tour: currentTour,
));
break;
case ObjectStates.station:
emit(ScanObjectDetected(
object: object,
suggestedState: ObjectStates.stkHadag,
tour: currentTour,
));
break;
default:
emit(ScanUnknownObject(
barcode: object.code,
prefix: object.code.length >= 3 ? object.code.substring(0, 3) : '',
tour: currentTour,
));
}
}
// ============================================================================
// Helper methods for type checking
// ============================================================================
bool _isTypeGK(int type, String subtype) {
// GK = Geldkassette (type 1, subtypes meka, mekb, mekc, mekd, beka, bekb, bekc, bekd)
return type == 1 || subtype.startsWith('mek') || subtype.startsWith('bek');
}
bool _isTypeHP(int type, String subtype) {
// HP = Hauptkasse/Druckerpatronen (type 2, subtypes hp1a, hp1b, etc.)
return type == 2 || subtype.startsWith('hp');
}
bool _isTypeFR(int type, String subtype) {
// FR = Fahrkartenrolle (type 5, subtype fra)
return type == 5 || subtype.startsWith('fr');
}
bool _isTypeSB(int type, String subtype) {
// SB = Safebag (type 6)
return type == 6 || subtype == 'sb';
}
bool _isTypeABS(int type, String subtype) {
// ABS = Abfallbehälter (type 7)
return type == 7 || subtype == 'abs';
}
bool _isTypeCNTR(int type, String subtype) {
// CNTR = Container (type 8)
return type == 8 || subtype.startsWith('cntr');
}
// ============================================================================
// Legacy simple state machine (fallback)
// ============================================================================
String _determineNextState(LogisticObject object) {
switch (object.state) {
case ObjectStates.unknown:
return ObjectStates.toDelivery;
case ObjectStates.toDelivery:
return ObjectStates.delivery;
case ObjectStates.delivery:
return ObjectStates.station;
case ObjectStates.station:
return ObjectStates.inFA;
case ObjectStates.inFA:
return ObjectStates.retGI;
case ObjectStates.retGI:
return ObjectStates.retGIFzg;
case ObjectStates.retGIFzg:
return ObjectStates.finGI;
case ObjectStates.retFail:
return ObjectStates.retFailFzg;
case ObjectStates.retFailFzg:
return ObjectStates.retFailStk;
case ObjectStates.retDS:
return ObjectStates.retDSFzg;
case ObjectStates.retDSFzg:
return ObjectStates.finDS;
default:
return object.state;
}
}
// ============================================================================
// Event handlers
// ============================================================================
Future<void> _onValidateBarcode(ValidateBarcode event, Emitter<ScanState> emit) async {
if (event.barcode.isEmpty) {
emit(const ScanValidationError(message: 'Barcode darf nicht leer sein'));
return;
}
if (event.barcode.length < 6) {
emit(const ScanValidationError(message: 'Barcode zu kurz'));
return;
}
add(ProcessBarcode(barcode: event.barcode));
}
Future<void> _onUpdateObjectState(UpdateObjectState event, Emitter<ScanState> emit) async {
emit(ScanProcessing(barcode: event.object.code));
final result = await repository.updateObjectState(
event.object.objectId,
event.newState,
locationId: currentTour?.locationId,
refType: currentTour != null ? _getTourTypeCode(currentTour!.type) : null,
refId: currentTour?.tourId,
containerCode: event.containerCode,
);
result.fold(
(failure) => emit(ScanError(message: _mapFailureToMessage(failure))),
(_) {
emit(ScanObjectUpdated(
object: event.object.copyWith(state: event.newState),
previousState: event.object.state,
newState: event.newState,
tour: currentTour,
));
},
);
}
Future<void> _onCreateUnknownObject(CreateUnknownObject event, Emitter<ScanState> emit) async {
emit(ScanProcessing(barcode: event.barcode));
final result = await repository.createObject(
type: event.type,
code: event.barcode,
isManual: true,
);
result.fold(
(failure) => emit(ScanError(message: _mapFailureToMessage(failure))),
(_) {
emit(ScanObjectCreated(barcode: event.barcode));
},
);
}
void _onResetScan(ResetScan event, Emitter<ScanState> emit) {
if (currentTour != null) {
emit(ScanReady(tour: currentTour!));
} else {
emit(ScanInitial());
}
}
// ============================================================================
// Helper methods
// ============================================================================
Map<String, String>? _findPageForBarcode(String barcode) {
if (currentTour == null) return null;
for (final page in currentTour!.pages) {
if (page.code == barcode) {
return {
'pageId': page.pageId,
'label': page.label ?? page.pageId,
};
}
if (page.pageId.toLowerCase().startsWith('fsa') && page.type.isNotEmpty) {
// FSA page detected
}
}
return null;
}
bool _isValidPrefix(String prefix) {
final validPrefixes = ['MEK', 'BEK', 'HOP', 'H1P', 'H2P', 'H3P', 'FR', 'SB', 'ABS', 'FZG'];
return validPrefixes.any((p) => prefix.toUpperCase().startsWith(p));
}
int? _getTourTypeCode(String type) {
switch (type) {
case TourTypes.stockStart:
return 1;
case TourTypes.vehStart:
return 2;
case TourTypes.veh:
return 3;
case TourTypes.fsa:
return 4;
case TourTypes.vs:
return 5;
case TourTypes.gi:
return 6;
case TourTypes.vehEnd:
return 7;
case TourTypes.stockEnd:
return 8;
default:
return null;
}
}
String _mapFailureToMessage(Failure failure) {
return switch (failure) {
ServerFailure _ => 'Serverfehler: ${failure.message}',
NetworkFailure _ => 'Netzwerkfehler. Bitte überprüfen Sie Ihre Internetverbindung.',
NotFoundFailure _ => 'Objekt nicht gefunden',
BarcodeFailure _ => 'Barcode-Fehler: ${failure.message}',
_ => 'Ein Fehler ist aufgetreten: ${failure.message}',
};
}
}

View File

@@ -0,0 +1,69 @@
part of 'scan_bloc.dart';
abstract class ScanEvent extends Equatable {
const ScanEvent();
@override
List<Object?> get props => [];
}
class InitializeScan extends ScanEvent {
final Tour tour;
const InitializeScan(this.tour);
@override
List<Object?> get props => [tour];
}
class ProcessBarcode extends ScanEvent {
final String barcode;
const ProcessBarcode({required this.barcode});
@override
List<Object?> get props => [barcode];
}
class ValidateBarcode extends ScanEvent {
final String barcode;
const ValidateBarcode({required this.barcode});
@override
List<Object?> get props => [barcode];
}
class UpdateObjectState extends ScanEvent {
final LogisticObject object;
final String newState;
final int? locationId;
final String? containerCode;
const UpdateObjectState({
required this.object,
required this.newState,
this.locationId,
this.containerCode,
});
@override
List<Object?> get props => [object, newState, locationId, containerCode];
}
class ResetScan extends ScanEvent {
const ResetScan();
}
class CreateUnknownObject extends ScanEvent {
final String barcode;
final int type;
const CreateUnknownObject({
required this.barcode,
required this.type,
});
@override
List<Object?> get props => [barcode, type];
}

View File

@@ -0,0 +1,195 @@
part of 'scan_bloc.dart';
abstract class ScanState extends Equatable {
const ScanState();
@override
List<Object?> get props => [];
}
class ScanInitial extends ScanState {}
class ScanReady extends ScanState {
final Tour tour;
const ScanReady({required this.tour});
@override
List<Object?> get props => [tour];
}
class ScanProcessing extends ScanState {
final String barcode;
const ScanProcessing({required this.barcode});
@override
List<Object?> get props => [barcode];
}
class ScanObjectDetected extends ScanState {
final LogisticObject object;
final String suggestedState;
final Tour? tour;
final String? originBarcode;
const ScanObjectDetected({
required this.object,
required this.suggestedState,
this.tour,
this.originBarcode,
});
@override
List<Object?> get props => [object, suggestedState, tour, originBarcode];
}
// Special state for Fehlkassette (error cassette) detection
class ScanFehlKassetteDetected extends ScanState {
final LogisticObject object;
final String suggestedState;
final Tour? tour;
const ScanFehlKassetteDetected({
required this.object,
required this.suggestedState,
this.tour,
});
@override
List<Object?> get props => [object, suggestedState, tour];
}
// Special state for container object detection (VS state machine)
class ScanContainerObjectDetected extends ScanState {
final LogisticObject object;
final String suggestedState;
final Tour? tour;
final String containerId;
final String containerType;
const ScanContainerObjectDetected({
required this.object,
required this.suggestedState,
this.tour,
required this.containerId,
required this.containerType,
});
@override
List<Object?> get props => [object, suggestedState, tour, containerId, containerType];
}
// Special state for container detection
class ScanContainerDetected extends ScanState {
final LogisticObject object;
final String suggestedState;
final Tour? tour;
final String containerId;
final String containerType;
const ScanContainerDetected({
required this.object,
required this.suggestedState,
this.tour,
required this.containerId,
required this.containerType,
});
@override
List<Object?> get props => [object, suggestedState, tour, containerId, containerType];
}
// Special state for container close detection (vehVs state machine)
class ScanContainerCloseDetected extends ScanState {
final LogisticObject object;
final String suggestedState;
final String targetStateForObjects;
final Tour? tour;
final String containerType;
const ScanContainerCloseDetected({
required this.object,
required this.suggestedState,
required this.targetStateForObjects,
this.tour,
required this.containerType,
});
@override
List<Object?> get props => [object, suggestedState, targetStateForObjects, tour, containerType];
}
class ScanUnknownObject extends ScanState {
final String barcode;
final String prefix;
final Tour? tour;
const ScanUnknownObject({
required this.barcode,
required this.prefix,
this.tour,
});
@override
List<Object?> get props => [barcode, prefix, tour];
}
class ScanPageDetected extends ScanState {
final String pageId;
final String label;
final Tour tour;
const ScanPageDetected({
required this.pageId,
required this.label,
required this.tour,
});
@override
List<Object?> get props => [pageId, label, tour];
}
class ScanObjectUpdated extends ScanState {
final LogisticObject object;
final String previousState;
final String newState;
final Tour? tour;
const ScanObjectUpdated({
required this.object,
required this.previousState,
required this.newState,
this.tour,
});
@override
List<Object?> get props => [object, previousState, newState, tour];
}
class ScanObjectCreated extends ScanState {
final String barcode;
const ScanObjectCreated({required this.barcode});
@override
List<Object?> get props => [barcode];
}
class ScanError extends ScanState {
final String message;
const ScanError({required this.message});
@override
List<Object?> get props => [message];
}
class ScanValidationError extends ScanState {
final String message;
const ScanValidationError({required this.message});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../../domain/entities/tour.dart';
import '../../../domain/entities/logistic_object.dart';
import '../../../domain/repositories/tour_repository.dart';
import '../../../core/errors/failures.dart';
part 'tour_event.dart';
part 'tour_state.dart';
class TourBloc extends Bloc<TourEvent, TourState> {
final TourRepository repository;
TourBloc({required this.repository}) : super(TourInitial()) {
on<LoadTours>(_onLoadTours);
on<SelectTour>(_onSelectTour);
on<CompleteTour>(_onCompleteTour);
on<SyncData>(_onSyncData);
on<RefreshTours>(_onRefreshTours);
on<LoadTourDetails>(_onLoadTourDetails);
}
Future<void> _onLoadTours(LoadTours event, Emitter<TourState> emit) async {
emit(TourLoading());
final result = await repository.getTours();
result.fold(
(failure) => emit(TourError(message: _mapFailureToMessage(failure))),
(tours) {
final openTours = tours.where((t) => t.state < 2).toList();
final completedTours = tours.where((t) => t.state == 1).toList();
emit(ToursLoaded(
tours: openTours,
completedTours: completedTours,
allTours: tours,
));
},
);
}
Future<void> _onSelectTour(SelectTour event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is ToursLoaded) {
emit(currentState.copyWith(selectedTour: event.tour));
}
}
Future<void> _onCompleteTour(CompleteTour event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is ToursLoaded) {
emit(TourLoading());
final result = await repository.completeTour(event.tourId);
result.fold(
(failure) => emit(TourError(message: _mapFailureToMessage(failure))),
(_) => add(const LoadTours()),
);
}
}
Future<void> _onSyncData(SyncData event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is ToursLoaded) {
emit(currentState.copyWith(isSyncing: true));
final result = await repository.syncData();
result.fold(
(failure) {
emit(currentState.copyWith(isSyncing: false));
emit(SyncError(message: _mapFailureToMessage(failure)));
},
(_) {
emit(currentState.copyWith(isSyncing: false));
add(const LoadTours());
},
);
}
}
Future<void> _onRefreshTours(RefreshTours event, Emitter<TourState> emit) async {
add(const LoadTours());
}
Future<void> _onLoadTourDetails(LoadTourDetails event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is ToursLoaded) {
emit(currentState.copyWith(isLoadingDetails: true));
final objectsResult = await repository.getObjectsByTour(event.tourId);
final statsResult = await repository.getTourStatistics(event.tourId);
objectsResult.fold(
(failure) => emit(TourError(message: _mapFailureToMessage(failure))),
(objects) {
statsResult.fold(
(failure) => emit(TourError(message: _mapFailureToMessage(failure))),
(statistics) {
emit(currentState.copyWith(
selectedTourObjects: objects,
selectedTourStats: statistics,
isLoadingDetails: false,
));
},
);
},
);
}
}
String _mapFailureToMessage(Failure failure) {
return switch (failure) {
ServerFailure _ => 'Serverfehler: ${failure.message}',
NetworkFailure _ => 'Netzwerkfehler. Bitte überprüfen Sie Ihre Internetverbindung.',
CacheFailure _ => 'Cachefehler: ${failure.message}',
NotFoundFailure _ => 'Daten nicht gefunden',
UnauthorizedFailure _ => 'Nicht autorisiert. Bitte melden Sie sich erneut an.',
_ => 'Ein unerwarteter Fehler ist aufgetreten',
};
}
}

View File

@@ -0,0 +1,56 @@
part of 'tour_bloc.dart';
abstract class TourEvent extends Equatable {
const TourEvent();
@override
List<Object?> get props => [];
}
class LoadTours extends TourEvent {
const LoadTours();
}
class SelectTour extends TourEvent {
final Tour tour;
const SelectTour(this.tour);
@override
List<Object?> get props => [tour];
}
class CompleteTour extends TourEvent {
final int tourId;
const CompleteTour(this.tourId);
@override
List<Object?> get props => [tourId];
}
class SyncData extends TourEvent {
const SyncData();
}
class RefreshTours extends TourEvent {
const RefreshTours();
}
class LoadTourDetails extends TourEvent {
final int tourId;
const LoadTourDetails(this.tourId);
@override
List<Object?> get props => [tourId];
}
class UpdateShowCompleted extends TourEvent {
final bool showCompleted;
const UpdateShowCompleted(this.showCompleted);
@override
List<Object?> get props => [showCompleted];
}

View File

@@ -0,0 +1,95 @@
part of 'tour_bloc.dart';
abstract class TourState extends Equatable {
const TourState();
@override
List<Object?> get props => [];
}
class TourInitial extends TourState {}
class TourLoading extends TourState {}
class ToursLoaded extends TourState {
final List<Tour> tours;
final List<Tour> completedTours;
final List<Tour> allTours;
final Tour? selectedTour;
final List<LogisticObject>? selectedTourObjects;
final TourStatistics? selectedTourStats;
final bool isSyncing;
final bool isLoadingDetails;
final bool showCompleted;
const ToursLoaded({
required this.tours,
this.completedTours = const [],
required this.allTours,
this.selectedTour,
this.selectedTourObjects,
this.selectedTourStats,
this.isSyncing = false,
this.isLoadingDetails = false,
this.showCompleted = true,
});
ToursLoaded copyWith({
List<Tour>? tours,
List<Tour>? completedTours,
List<Tour>? allTours,
Tour? selectedTour,
List<LogisticObject>? selectedTourObjects,
TourStatistics? selectedTourStats,
bool? isSyncing,
bool? isLoadingDetails,
bool? showCompleted,
}) {
return ToursLoaded(
tours: tours ?? this.tours,
completedTours: completedTours ?? this.completedTours,
allTours: allTours ?? this.allTours,
selectedTour: selectedTour ?? this.selectedTour,
selectedTourObjects: selectedTourObjects ?? this.selectedTourObjects,
selectedTourStats: selectedTourStats ?? this.selectedTourStats,
isSyncing: isSyncing ?? this.isSyncing,
isLoadingDetails: isLoadingDetails ?? this.isLoadingDetails,
showCompleted: showCompleted ?? this.showCompleted,
);
}
int get completedCount => completedTours.length;
int get totalCount => allTours.length;
double get completionPercentage => totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
@override
List<Object?> get props => [
tours,
completedTours,
allTours,
selectedTour,
selectedTourObjects,
selectedTourStats,
isSyncing,
isLoadingDetails,
showCompleted,
];
}
class TourError extends TourState {
final String message;
const TourError({required this.message});
@override
List<Object?> get props => [message];
}
class SyncError extends TourState {
final String message;
const SyncError({required this.message});
@override
List<Object?> get props => [message];
}

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

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../../domain/entities/counter.dart';
/// Zähler-Grid wie in Lua CreateLoadingStockStartView etc.
class CounterGrid extends StatelessWidget {
final List<CounterGroup> groups;
final bool showDifferences;
const CounterGrid({
super.key,
required this.groups,
this.showDifferences = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: groups.map((group) => _buildGroup(context, group)).toList(),
);
}
Widget _buildGroup(BuildContext context, CounterGroup group) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getGroupColor(group.title),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
// Titel
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
group.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
// Zähler
Container(
padding: const EdgeInsets.all(8),
child: Row(
children: group.counters.map((counter) {
return Expanded(
child: _buildCounterCell(context, counter),
);
}).toList(),
),
),
],
),
);
}
Widget _buildCounterCell(BuildContext context, ObjectCounter counter) {
final color = counter.isOver
? Colors.red.shade100
: counter.isComplete
? Colors.green.shade100
: Colors.white;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
Text(
counter.label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${counter.currentCount}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: counter.isOver ? Colors.red : Colors.black87,
),
),
if (showDifferences && counter.targetCount > 0) ...[
const SizedBox(height: 2),
Text(
'${counter.difference > 0 ? "+" : ""}${counter.difference}',
style: TextStyle(
fontSize: 12,
color: counter.difference == 0
? Colors.green
: counter.difference > 0
? Colors.orange
: Colors.red,
),
),
],
],
),
);
}
Color _getGroupColor(String title) {
switch (title.toLowerCase()) {
case 'bestand fzg':
return const Color(0xFFA4D4F0); // Hellblau wie in Lua
case 'beladezähler':
case 'hadag':
case 'sst':
case 'cr':
return Colors.grey.shade200;
default:
return Colors.grey.shade200;
}
}
}
/// Vereinfachte Zähler-Anzeige für eine Zeile
class CounterRow extends StatelessWidget {
final String label;
final int count;
final Color? backgroundColor;
final VoidCallback? onTap;
const CounterRow({
super.key,
required this.label,
required this.count,
this.backgroundColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor ?? Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
'$count',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
class ErrorView extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
final String? retryText;
final IconData? icon;
const ErrorView({
super.key,
required this.message,
this.onRetry,
this.retryText,
this.icon,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 26),
shape: BoxShape.circle,
),
child: Icon(
icon ?? Icons.error_outline,
size: 48,
color: theme.colorScheme.error,
),
),
const SizedBox(height: 24),
Text(
'Oops!',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 179),
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(retryText ?? 'Erneut versuchen'),
),
],
],
),
),
);
}
}
class EmptyStateView extends StatelessWidget {
final String title;
final String? subtitle;
final IconData icon;
final VoidCallback? onAction;
final String? actionText;
const EmptyStateView({
super.key,
required this.title,
this.subtitle,
this.icon = Icons.inbox,
this.onAction,
this.actionText,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 48,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 24),
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 153),
),
textAlign: TextAlign.center,
),
],
if (onAction != null && actionText != null) ...[
const SizedBox(height: 24),
ElevatedButton(
onPressed: onAction,
child: Text(actionText!),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
class LoadingIndicator extends StatelessWidget {
final String? message;
final bool showAnimation;
const LoadingIndicator({
super.key,
this.message,
this.showAnimation = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (showAnimation) ...[
// Optional: Lottie Animation für Loading
SizedBox(
width: 120,
height: 120,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
),
),
] else ...[
SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
),
),
],
if (message != null) ...[
const SizedBox(height: 24),
Text(
message!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 179),
),
textAlign: TextAlign.center,
),
],
],
),
);
}
}
class SkeletonLoading extends StatelessWidget {
final int itemCount;
final EdgeInsets padding;
const SkeletonLoading({
super.key,
this.itemCount = 5,
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
});
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: padding,
itemCount: itemCount,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _SkeletonCard(),
);
},
);
}
}
class _SkeletonCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(14),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 120,
height: 12,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import '../../domain/entities/counter.dart';
/// Zeigt zuletzt gescannte Objekte an
/// Entspricht Lua: Die Liste in ShowStockStartScreen etc.
class RecentScansList extends StatelessWidget {
final List<RecentScan>? scans;
const RecentScansList({
super.key,
this.scans,
});
@override
Widget build(BuildContext context) {
// Demo-Daten falls keine vorhanden
final demoScans = scans ?? const [
// RecentScan(
// objectCode: 'MEKA123456',
// objectName: 'MEK A',
// state: 'to_delivery',
// scanTime: '',
// ),
];
if (demoScans.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'Noch keine Objekte gescannt',
style: TextStyle(
color: Colors.grey,
fontStyle: FontStyle.italic,
),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Zuletzt gescannt',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
...demoScans.map((scan) => _buildScanItem(context, scan)),
],
);
}
Widget _buildScanItem(BuildContext context, RecentScan scan) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
// Objekt-Icon
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getIconForObject(scan.objectName),
color: Colors.blue,
),
),
const SizedBox(width: 12),
// Objekt-Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
scan.objectName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
Text(
'#${scan.objectCode}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
// Status
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getColorForState(scan.state),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_getShortStateName(scan.state),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
IconData _getIconForObject(String objectName) {
final name = objectName.toLowerCase();
if (name.contains('mek')) return Icons.money;
if (name.contains('bek')) return Icons.money_off;
if (name.contains('hp')) return Icons.print;
if (name.contains('fr')) return Icons.receipt;
if (name.contains('sb')) return Icons.shopping_bag;
if (name.contains('abs')) return Icons.delete;
return Icons.inventory_2;
}
Color _getColorForState(String state) {
switch (state) {
case 'to_delivery':
return Colors.grey.shade300;
case 'delivery':
return Colors.grey.shade400;
case 'station':
return const Color(0xFFFFDD00);
case 'in_fa':
return const Color(0xFF9CDA7A);
case 'ret_fail':
return const Color(0xFFFF9081);
default:
return Colors.grey.shade200;
}
}
String _getShortStateName(String state) {
switch (state) {
case 'to_delivery':
return 'Zum Fzg';
case 'delivery':
return 'Im Fzg';
case 'station':
return 'Station';
case 'in_fa':
return 'Im FA';
case 'ret_fail':
return 'Fehler';
default:
return state;
}
}
}

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../domain/entities/tour.dart';
import '../../core/constants/app_constants.dart';
class TourListItem extends StatelessWidget {
final Tour tour;
final VoidCallback onTap;
final bool isCompleted;
const TourListItem({
super.key,
required this.tour,
required this.onTap,
this.isCompleted = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconData = _getIconForTourType(tour.type);
final color = _getColorForTourType(tour.type);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Card(
elevation: isCompleted ? 0 : 2,
shadowColor: Colors.black.withValues(alpha: 26),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: isCompleted
? BorderSide(color: Colors.grey.shade300)
: BorderSide.none,
),
child: InkWell(
onTap: isCompleted ? null : onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Icon Container
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color,
color.withValues(alpha: 204),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 77),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Icon(
iconData,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tour.locationName ?? 'Station ${tour.locationId}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isCompleted ? Colors.grey : null,
decoration: isCompleted ? TextDecoration.lineThrough : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_getTypeLabel(tour.type),
style: theme.textTheme.bodySmall?.copyWith(
color: color,
fontWeight: FontWeight.w500,
),
),
if (tour.remark != null && tour.remark!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
tour.remark!,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Status Indicator
if (isCompleted)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 26),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.green,
size: 20,
),
)
else
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.chevron_right,
color: Colors.grey.shade600,
size: 24,
),
),
],
),
),
),
),
).animate().fadeIn(duration: 300.ms).slideX(begin: 0.1, end: 0);
}
IconData _getIconForTourType(String type) {
switch (type) {
case TourTypes.stockStart:
return Icons.warehouse;
case TourTypes.stockEnd:
return Icons.archive;
case TourTypes.start:
return Icons.business;
case TourTypes.end:
return Icons.flag;
case TourTypes.station:
return Icons.directions_bus;
case TourTypes.hls:
return Icons.train;
case TourTypes.fsa:
return Icons.confirmation_number;
case TourTypes.vs:
return Icons.store;
case TourTypes.gi:
return Icons.account_balance;
case TourTypes.veh:
case TourTypes.vehStart:
case TourTypes.vehEnd:
return Icons.local_shipping;
default:
return Icons.location_on;
}
}
Color _getColorForTourType(String type) {
switch (type) {
case TourTypes.stockStart:
case TourTypes.stockEnd:
return const Color(0xFF2196F3);
case TourTypes.start:
case TourTypes.end:
return const Color(0xFF4CAF50);
case TourTypes.station:
return const Color(0xFFFF9800);
case TourTypes.hls:
return const Color(0xFF9C27B0);
case TourTypes.fsa:
return const Color(0xFFE91E63);
case TourTypes.vs:
return const Color(0xFF00BCD4);
case TourTypes.gi:
return const Color(0xFF3F51B5);
case TourTypes.veh:
case TourTypes.vehStart:
case TourTypes.vehEnd:
return const Color(0xFF795548);
default:
return const Color(0xFF607D8B);
}
}
String _getTypeLabel(String type) {
switch (type) {
case TourTypes.stockStart:
return 'Lager - Beladung';
case TourTypes.stockEnd:
return 'Lager - Rückgabe';
case TourTypes.start:
return 'Dienststelle';
case TourTypes.end:
return 'Tour Ende';
case TourTypes.station:
return 'Haltestelle';
case TourTypes.hls:
return 'Hochbahnstation';
case TourTypes.fsa:
return 'Fahrscheinautomat';
case TourTypes.vs:
return 'Versorgungsstelle';
case TourTypes.gi:
return 'Geldinstitut';
case TourTypes.veh:
return 'Fahrzeug';
case TourTypes.vehStart:
return 'Fahrzeug - Beladung';
case TourTypes.vehEnd:
return 'Fahrzeug - Entladung';
default:
return type;
}
}
}