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

30
app/.metadata Normal file
View File

@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "8b872868494e429d94fa06dca855c306438b22c0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: android
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

89
app/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,89 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "🚀 HHA - Android Emulator (Debug)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "debug",
"deviceId": "emulator-5554",
"preLaunchTask": "prelaunch: check deps",
"args": [
"--enable-software-rendering",
"--no-enable-impeller"
],
"console": "debugConsole",
"toolArgs": [
"--hot"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart"
},
{
"name": "🚀 HHA - Android Emulator (Verbose)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "debug",
"deviceId": "emulator-5554",
"preLaunchTask": "flutter: pub get",
"args": [
"--enable-software-rendering",
"--no-enable-impeller",
"--verbose"
],
"console": "terminal",
"cwd": "${workspaceFolder}"
},
{
"name": "📱 HHA - Android Device (Debug)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "debug",
"preLaunchTask": "prelaunch: check deps",
"console": "debugConsole",
"cwd": "${workspaceFolder}"
},
{
"name": "🌐 HHA - Chrome (Debug)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "debug",
"deviceId": "chrome",
"preLaunchTask": "prelaunch: check deps",
"console": "debugConsole",
"cwd": "${workspaceFolder}"
},
{
"name": "📊 HHA - Profile Mode",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "profile",
"preLaunchTask": "prelaunch: check deps",
"console": "debugConsole",
"cwd": "${workspaceFolder}"
},
{
"name": "⚡ HHA - Release Mode",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "release",
"preLaunchTask": "prelaunch: check deps",
"console": "debugConsole",
"cwd": "${workspaceFolder}"
},
{
"name": "🔌 HHA - Attach to Running App",
"type": "dart",
"request": "attach",
"program": "lib/main.dart",
"console": "debugConsole",
"cwd": "${workspaceFolder}"
}
]
}

38
app/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "flutter: pub get",
"type": "shell",
"command": "flutter",
"args": ["pub", "get"],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "prelaunch: check deps",
"type": "shell",
"command": "flutter",
"args": ["pub", "get"],
"options": {
"cwd": "${workspaceFolder}"
},
"presentation": {
"echo": false,
"reveal": "silent",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
}
]
}

381
app/ANALYSE.md Normal file
View File

@@ -0,0 +1,381 @@
# HHA Lua Workflow Analyse
## Überblick
Die analysierten Lua-Skripte implementieren einen umfangreichen Logistik-Workflow für die Hamburger Hochbahn (HHA). Die App verwaltet den Transport und die Zustandsänderungen verschiedener Objekte zwischen verschiedenen Stationen.
## Architektur der Lua-App
### Dateistruktur
- `hha_mainscript.lua` - Initialisierung und Barcode-Handling
- `hha_main.lua` - Hauptlogik mit UI und State-Machine
- `hha_db_migration_V0_initial.lua` - Datenbank-Schema
### UI-Framework
Die App verwendet ein proprietäres UI-Framework (ASUI) mit folgenden Komponenten:
- ASUIStackLayout - Layout-Container
- ASUILabel - Textanzeige
- ASUIButton - Schaltflächen
- ASUIScrollView - Scrollbare Bereiche
- ASUIPerfListViewM - Performante Listen
- ASUIBoxView / ASUIRelativeLayout - Container
## Objekt-Typen
### 1. Geldkassetten (GK)
- **Typ-ID**: 3
- **Codes**: MEKxxx, BEKxxx
- **Subtypen**: meka, mekb, mekc, mekd, beka, bekb, bekc, bekd
- **Workflow**: Lager → Fahrzeug → Station → Fahrscheinautomat → Geldinstitut
### 2. HP Patronen (HP)
- **Typ-ID**: 4
- **Codes**: HOPxxx, H1Pxxx, H2Pxxx, H3Pxxx
- **Subtypen**: hp1a, hp1b, hp1c, hp2a, hp2b, hp2c, hp3a, hp3b, hp3c
- **Workflow**: Lager → Fahrzeug → Fahrscheinautomat
### 3. Fahrkartenrollen (FR)
- **Typ-ID**: 7
- **Codes**: FRxxx
- **Subtypen**: fra
- **Workflow**: Lager → Fahrzeug → Fahrscheinautomat
### 4. Safebags (SB)
- **Typ-ID**: 5
- **Workflow**: Versorgungsstelle → Geldinstitut
### 5. Abfallbehälter (ABS)
- **Typ-ID**: 9
- **Workflow**: Versorgungsstelle → Dienststelle
### 6. Container (CNTR)
- **Typ-ID**: ?
- **Subtypen**: cntra (Geldinstitut), cntrb (Dienststelle)
- **Workflow**: Sammlung mehrerer Objekte
## Zustände (States)
### Hauptzustände
```
unknown → to_delivery → delivery → station → in_fa
┌───────────────────────┼───────────────────────┐
↓ ↓ ↓
ret_gi ret_fail ret_ds
↓ ↓ ↓
ret_gi_fzg ret_fail_fzg ret_ds_fzg
↓ ↓ ↓
fin_gi ret_fail_stk ret_ds_stk
↓ ↓
fin_ds_fail fin_ds
```
### Zustandsbeschreibungen
| State | Beschreibung | Farbe |
|-------|-------------|-------|
| `unknown` | Unbekannt/Neu | Weiß |
| `to_delivery` | Zum Fahrzeug | Grau |
| `delivery` | Im Fahrzeug | Grau |
| `station` | An Station | Gelb |
| `in_fa` | Im Fahrscheinautomat | Grün |
| `in_vs` | In Versorgungsstelle | Gelb |
| `ret_fail` | Fehlerhaft zur Dienststelle | Rot |
| `ret_fail_fzg` | Fehlerhaft im Fahrzeug | Rot |
| `ret_fail_stk` | Fehlerhaft in Dienststelle | Rot |
| `ret_gi` | Zum Geldinstitut | Hellblau |
| `ret_gi_fzg` | Zum Geldinstitut (Fzg) | Hellblau |
| `ret_gi_stk` | Zum Geldinstitut (Stk) | Hellblau |
| `fin_gi` | Abgeschlossen im Geldinstitut | Blau |
| `ret_ds` | Zur Dienststelle | Hellblau |
| `ret_ds_fzg` | Zur Dienststelle (Fzg) | Hellblau |
| `ret_ds_stk` | Zur Dienststelle (Stk) | Hellblau |
| `ret_ds_err` | Fehlerhaft zur Dienststelle | Hellblau |
| `fin_ds` | Abgeschlossen in Dienststelle | Blau |
| `fin_ds_fail` | Fehlerhaft abgeschlossen | Blau |
| `fin_ds_err` | Fehlerhaft abgeschlossen | Blau |
| `hdl` | Handel | Grau |
| `ret_ds_empty` | Leer zur Dienststelle | Hellblau |
## Tour-Stationen
### Stationstypen
| Type | Beschreibung | Icon |
|------|-------------|------|
| `stock_start` | Lager - Beladung | Lager |
| `stock` | Lager | Lager |
| `stock_end` | Lager - Rückgabe | Lager |
| `start` | Dienststelle - Start | Dienststelle |
| `end` | Dienststelle - Ende | Dienststelle |
| `st` | Haltestelle | Haltestelle |
| `hls` | Hochbahnstation | HLS |
| `fsa` | Fahrscheinautomat | FSA |
| `vs` | Versorgungsstelle | VS |
| `gi` | Geldinstitut | Bank |
| `veh_start` | Fahrzeug - Beladung | Fahrzeug |
| `veh` | Fahrzeug | Fahrzeug |
| `veh_bulk` | Fahrzeug - Massenladung | Fahrzeug |
| `veh_vs` | Fahrzeug - Versorgung | Fahrzeug |
| `veh_end` | Fahrzeug - Entladung | Fahrzeug |
### Seiten (Pages)
Jede Tour hat mehrere Seiten, die bestimmte Aktionen definieren:
- **Page-ID**: Eindeutige Identifikation
- **Code**: Barcode zum Aktivieren der Seite
- **Label**: Anzeigename
- **Pickup**: Objekte, die aufgenommen werden sollen
- **Swap**: Objekte, die ausgetauscht werden sollen
## State Machines
### 1. Stock Start State Machine
```
unknown → to_delivery (Auf Fahrzeug laden)
fin_gi_tmp → ret_gi (Rückgabe an Geldinstitut)
```
### 2. Vehicle Start State Machine
```
to_delivery → delivery (Bestätigung Ladung)
ret_gi → ret_gi_fzg (Übernahme vom Geldinstitut)
```
### 3. Vehicle State Machine
```
delivery → station (An Haltestelle ausgeben)
ret_fail → ret_fail_fzg (Fehlerhafte übernehmen)
ret_gi → ret_gi_fzg (Geldinstitut-Objekte übernehmen)
ret_ds → ret_ds_fzg (Dienststellen-Objekte übernehmen)
station → delivery (Rücknahme)
in_fa → hdl (Handel)
```
### 4. FSA (Fahrscheinautomat) State Machine
#### Geldkassette (GK)
```
station → in_fa (Einbau)
in_fa → ret_fail (Fehlerhaft ausbauen)
unknown → ret_gi (Neue unbekannte Kassette)
```
#### HP Patrone
```
station → in_fa (Einbau)
in_fa → station (Ausbau)
unknown → ret_ds (Neue unbekannte Patrone)
```
#### Fahrkartenrolle
```
station → in_fa (Einbau)
in_fa → station (Ausbau)
```
### 5. Versorgungsstelle (VS) State Machine
```
Container Type A (cntra):
- SB (Safebag) in_vs → ret_gi
Container Type B (cntrb):
- ABS (Abfall) in_vs → ret_ds
Container Barcode:
- cntra → Container A aktivieren
- cntrb → Container B aktivieren
```
### 6. Geldinstitut (GI) State Machine
```
ret_gi_fzg → fin_gi (Übergabe an Bank)
unknown → ret_ds_empty (Leere Kassette)
```
### 7. Vehicle End State Machine
```
ret_fail_fzg → ret_fail_stk
ret_ds_fzg → ret_ds_stk
delivery → ret_ds_stk
ret_ds_empty → ret_ds_stk
ret_gi_fzg → ret_gi_stk
```
### 8. Stock End State Machine
```
ret_fail_stk → fin_ds_fail
ret_ds_stk → fin_ds
ret_ds_err → fin_ds_err
ret_gi_stk → fin_gi_tmp
```
## API-Endpunkte
### Initialisierung
```
GET /hha?init={"update":<version>, "imei":<imei>}
```
### Status-Update
```
GET /hha?set_obj_state={"obj":[...], "imei":<imei>}
```
### Tour abschließen
```
GET /hha?fin_tour={"id":<tour_id>, "time":<timestamp>, "imei":<imei>}
```
### Neues Objekt
```
GET /hha?new_obj={"type":<type>, "man":true, "code":<code>, ...}
```
### Quittung
```
GET /hha?new_receipt={"data":<barcode>, "time":<timestamp>, ...}
```
### Neuer Safebag
```
GET /hha?new_safebag={"code":<code>, "time":<timestamp>, ...}
```
## Datenbank-Schema
### Tabellen
#### tour
- id, job_id, tour_id, version, state, type, sort
- loc_id, loc_code, loc_code2, rem, menu, modified, del_code
#### object
- id, object_id, type, version, loc_id, code, rem, state, subtype
- origin, manual, last_modified
#### location
- id, location_id, version, name, street, num, zip, city, lat, long, rem
#### page
- id, tour_id, page_number, page_id, type, code, label
#### page_swap_count / page_pickup_count
- id, tour_id, page_id, object_type, object_count
#### sendqueue
- id, url (Base64-kodiert)
#### receipt
- id, code
#### container_objects
- id, container_id, type, object_id
## Besonderheiten
### Counter-Labels
Die App zeigt Zähler für verschiedene Objekttypen an:
- **MEK**: Münzgeldkassette
- **BEK**: Bargeldentnahmekassette
- **H1, H2, H3**: HP Patronen
- **P**: Fahrkartenrolle
- **SB**: Safebag
- **ABS**: Abfallbehälter
### Beladezähler
- SST: Sonder-Sonder-Tour
- CR: Collection-Route
- HADAG: Fährverbindung
### Quittungen
Für Geldkassetten müssen Quittungen gescannt werden:
- 2D-Barcode enthält: FA-#, GK-#, Betrag, Datum, Zeit
- Keine Quittung = Warnhinweis
### Manueller Modus
- Neue Objekte können manuell angelegt werden
- Server-Validierung des Barcodes
- Automatische Typ-Erkennung über Präfix
### Container-Logik
- Container werden über Barcode aktiviert
- Mehrere Objekte können einem Container zugeordnet werden
- Container-Abschluss überträgt Status auf alle enthaltenen Objekte
## Offline-Verhalten
1. **Daten werden lokal gespeichert** (SQLite)
2. **API-Aufrufe werden in Queue eingereiht**
3. **Automatische Synchronisation** bei Verbindung
4. **Konfliktauflösung** über Versionsnummern
## Synchronisation
### Trigger
- App-Start
- Manueller Refresh
- Intervall (60 Sekunden)
- Tour-Abschluss
### Daten
- Metadaten (Objekttypen)
- Standorte
- Objekte
- Jobs & Touren
- Page-Counts
## Sicherheit
- Geräte-Authentifizierung über IMEI
- Keine Passwörter im Code
- HTTPS für API-Kommunikation
- Lokale Daten verschlüsselt (SQLite)
## Flutter-Implementierung
### Architektur-Mapping
| Lua | Flutter |
|-----|---------|
| ASUIStackLayout | Column/Row |
| ASUILabel | Text |
| ASUIButton | ElevatedButton |
| ASUIScrollView | SingleChildScrollView |
| database:Query | Repository Pattern |
| BarcodeScanned | MobileScanner |
| State Machine | BLoC Pattern |
### UI-Vergleich
| Lua-UI | Flutter-UI |
|--------|-----------|
| Hintergrund #220220220 | Colors.grey.shade200 |
| HHA Rot | Color(0xFFE3001B) |
| Grüner Header #188230165 | Color(0xFFBCA6A5) |
| Status-Farben | ObjectStateInfo.getColorForState |
### State-Management
Die komplexen Lua-State-Machines wurden in BLoC-Pattern überführt:
- `ScanBloc` verwaltet Barcode-Scanning
- `TourBloc` verwaltet Touren und Synchronisation
- Zustandsübergänge sind explizit und testbar
### Vorteile der Flutter-Implementierung
1. **Type Safety**: Dart's Typisierung verhindert Laufzeitfehler
2. **Hot Reload**: Schnellere Entwicklung
3. **Moderne UI**: Material Design 3, Animationen
4. **Wartbarkeit**: Klare Architektur, Trennung von Concerns
5. **Testbarkeit**: Unit-Tests für Business-Logik
6. **Offline-First**: SQFlite mit Repository-Pattern
7. **State Management**: BLoC für vorhersagbare Zustände
## Fazit
Die Lua-App implementiert einen ausgereiften, komplexen Workflow für die Logistik der Hamburger Hochbahn. Die Flutter-Neuimplementierung behält die komplette fachliche Logik bei, verbessert jedoch:
- Architektur und Wartbarkeit
- UI/UX mit modernem Design
- Offline-Fähigkeiten
- Testbarkeit und Qualität
- Zukunftssicherheit und Erweiterbarkeit

196
app/README.md Normal file
View File

@@ -0,0 +1,196 @@
# HHA Logistics - Flutter App
Diese Flutter-Anwendung liegt im Monorepo unter `app/`.
Das zugehoerige Spring Boot + Vaadin Backend liegt daneben unter `../backend/`.
Eine moderne, optisch ansprechende Flutter-App für die Hamburger Hochbahn (HHA) zur Verwaltung von Logistik-Prozessen.
## Features
### Kernfunktionen
- **Tour-Management**: Übersichtliche Darstellung aller Touren und Stationen
- **Barcode-Scanning**: Schnelles Erfassen von Objekten per Kamera
- **State-Management**: Automatische Zustandsübergänge für Objekte
- **Offline-Unterstützung**: Lokale Datenspeicherung mit Synchronisation
- **Echtzeit-Sync**: Automatische Aktualisierung der Daten
### Objekttypen
- **Geldkassetten** (MEK, BEK)
- **HP Patronen** (H1, H2, H3)
- **Fahrkartenrollen**
- **Safebags** (Wertsachenbehälter)
- **Container**
### Stationstypen
- Lager (Beladung/Rückgabe)
- Dienststellen
- Haltestellen
- Fahrscheinautomaten
- Versorgungsstellen
- Geldinstitute
## Technischer Stack
### Architektur
- **Clean Architecture**: Trennung von Domain, Data und Presentation Layer
- **BLoC Pattern**: State Management mit flutter_bloc
- **Repository Pattern**: Abstraktion der Datenquellen
### UI/UX
- **Material Design 3**: Modernes, konsistentes Design
- **Flutter Animate**: Flüssige Animationen
- **Phosphor Icons**: Hochwertige Icons
- **Google Fonts**: Inter als Hausschrift
### Technologien
- **Mobile Scanner**: Barcode-Scanning
- **SQFlite**: Lokale Datenbank
- **Dio**: HTTP-Client für API-Kommunikation
- **Shared Preferences**: Lokale Einstellungen
## Projektstruktur
```
app/
├── lib/
│ ├── core/
│ ├── data/
│ ├── domain/
│ └── presentation/
├── android/
├── build/
├── pubspec.yaml
└── run_app.sh
```
## Installation
### Voraussetzungen
- Flutter SDK >= 3.0.0
- Dart SDK >= 3.0.0
- Android Studio / Xcode
### Setup
1. Repository klonen:
```bash
git clone <repository-url>
cd hha_logistics
cd app
```
2. Dependencies installieren:
```bash
flutter pub get
```
3. Code-Generierung ausführen:
```bash
flutter pub run build_runner build
```
4. App starten:
```bash
flutter run
```
## Konfiguration
### API-Endpunkt
Die API-URL wird in `lib/core/constants/app_constants.dart` konfiguriert:
```dart
static const String baseUrl = 'https://hha-app1.assecutor.de/hha';
```
### Berechtigungen
#### Android (android/app/src/main/AndroidManifest.xml)
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
```
#### iOS (ios/Runner/Info.plist)
```xml
<key>NSCameraUsageDescription</key>
<string>Kamera wird für Barcode-Scanning benötigt</string>
```
## Fachlicher Workflow
### 1. Login & Datensynchronisation
- Anmeldung mit Geräte-IMEI
- Automatischer Datenabgleich mit Server
- Lokale Speicherung für Offline-Nutzung
### 2. Tour-Übersicht
- Liste aller Stationen der aktuellen Tour
- Fortschrittsanzeige
- Unterscheidung zwischen offenen und erledigten Stationen
### 3. Station-Arbeit
- Barcode-Scanning zur Identifikation
- Automatische State-Machine für Zustandsübergänge
- Manuelle Barcode-Eingabe möglich
### 4. State-Machine
Die App implementiert folgende Zustandsübergänge:
- `unknown``to_delivery``delivery``station``in_fa``ret_gi``fin_gi`
- Fehlerfälle: `ret_fail``ret_fail_fzg``ret_fail_stk`
- Dienststelle: `ret_ds``ret_ds_fzg``fin_ds`
### 5. Bestandsführung
- Echtzeit-Anzeige aller Objekte
- Filterung nach Status und Typ
- Quittungsverwaltung für Geldkassetten
## Design-System
### Farben
- **Primary**: HHA Rot (#E3001B)
- **Success**: Grün (#4CAF50)
- **Warning**: Orange (#FF9800)
- **Error**: Rot (#E3001B)
### State Colors
- `delivery`: Grau (#B3B3B3)
- `station`: Gelb (#FFDD00)
- `in_fa`: Grün (#9CDA7A)
- `ret_fail`: Rot (#FF9081)
- `ret_ds`: Hellblau (#AFE0ED)
- `fin_ds`: Blau (#29B7FB)
## Entwicklung
### Code-Generierung
Nach Änderungen an Modellen:
```bash
flutter pub run build_runner build --delete-conflicting-outputs
```
### Tests ausführen
```bash
flutter test
```
### Release Build
```bash
# Android
flutter build apk --release
flutter build appbundle --release
# iOS
flutter build ios --release
```
## Lizenz
Copyright © 2024 Hamburger Hochbahn AG
## Support
Bei Fragen oder Problemen wenden Sie sich an:
- IT-Support: support@hha.de
- Entwicklung: dev@hha.de

22
app/analysis_options.yaml Normal file
View File

@@ -0,0 +1,22 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
avoid_print: true
prefer_single_quotes: true
prefer_const_constructors: true
prefer_const_literals_to_create_immutables: true
prefer_final_fields: true
prefer_final_locals: true
avoid_unnecessary_containers: true
avoid_redundant_argument_values: true
use_super_parameters: true
use_build_context_synchronously: true
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "**/generated_plugin_registrant.dart"
errors:
invalid_annotation_target: ignore

14
app/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.hha_logistics"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.hha_logistics"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="hha_logistics"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.example.hha_logistics
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

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

1111
app/pubspec.lock Normal file

File diff suppressed because it is too large Load Diff

75
app/pubspec.yaml Normal file
View File

@@ -0,0 +1,75 @@
name: hha_logistics
version: 1.0.0+1
publish_to: none
description: HHA Logistics - Moderne Flutter App für die Hamburger Hochbahn
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# State Management
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Navigation
go_router: ^12.1.1
# Local Storage
sqflite: ^2.3.0
path_provider: ^2.1.1
shared_preferences: ^2.2.2
# Networking
dio: ^5.4.0
connectivity_plus: ^5.0.2
# Barcode Scanning
mobile_scanner: ^3.5.5
# UI Components
shimmer: ^3.0.0
flutter_slidable: ^3.0.1
badges: ^3.1.2
flutter_animate: ^4.3.0
lottie: ^2.7.0
# Icons & Fonts
cupertino_icons: ^1.0.6
google_fonts: ^6.1.0
flutter_svg: ^2.0.9
phosphor_flutter: ^2.0.1
# Utils
intl: ^0.20.2
uuid: ^4.2.1
logger: ^2.0.2
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dartz: ^0.10.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
build_runner: ^2.4.7
freezed: ^2.4.5
json_serializable: ^6.7.1
flutter_launcher_icons: ^0.13.1
flutter:
uses-material-design: true
# assets:
# - assets/icons/
# - assets/animations/
# - assets/images/
flutter_launcher_icons:
android: "launcher_icon"
ios: true
image_path: "assets/icons/app_icon.png"
min_sdk_android: 21

91
app/run_app.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/bin/bash
# Flutter App Start-Skript mit sichtbarem Build-Status
# Usage: ./run_app.sh [device_id]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Farben für Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} HHA Logistics - Flutter App Start ${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Device ID aus Parameter oder Default
DEVICE_ID=${1:-"emulator-5554"}
# Prüfe ob Flutter installiert ist
if ! command -v flutter &> /dev/null; then
echo -e "${RED}❌ Flutter wurde nicht gefunden!${NC}"
echo "Bitte Flutter installieren: https://flutter.dev/docs/get-started/install"
exit 1
fi
echo -e "${BLUE}📱 Verfügbare Geräte:${NC}"
flutter devices
echo ""
# Prüfe ob gewähltes Gerät existiert
echo -e "${BLUE}🔍 Prüfe Gerät: $DEVICE_ID${NC}"
if ! flutter devices | grep -q "$DEVICE_ID"; then
echo -e "${YELLOW}⚠️ Gerät '$DEVICE_ID' nicht gefunden!${NC}"
echo -e "${YELLOW} Versuche verfügbare Geräte zu finden...${NC}"
echo ""
# Suche nach Android Emulator
AVAILABLE_EMULATOR=$(flutter devices | grep "emulator" | head -1 | awk '{print $2}')
if [ -n "$AVAILABLE_EMULATOR" ]; then
echo -e "${GREEN}✅ Emulator gefunden: $AVAILABLE_EMULATOR${NC}"
DEVICE_ID=$AVAILABLE_EMULATOR
else
echo -e "${RED}❌ Kein Emulator gefunden!${NC}"
echo ""
echo "Verfügbare Emulatoren:"
flutter emulators
echo ""
echo -e "${YELLOW}Starte Emulator...${NC}"
flutter emulators --launch Pixel_8_Pro_API_29 2>/dev/null || true
sleep 5
fi
fi
echo ""
echo -e "${BLUE}📦 Installiere Dependencies...${NC}"
flutter pub get
echo ""
echo -e "${BLUE}🔨 Starte Build...${NC}"
echo -e "${YELLOW} Das kann einige Minuten dauern beim ersten Mal!${NC}"
echo ""
# Zeige Build-Fortschritt mit verbose
flutter run -d "$DEVICE_ID" --debug --verbose 2>&1 | while IFS= read -r line; do
# Filtere wichtige Build-Schritte
if [[ $line == *"Building APK"* ]] || [[ $line == *"Compiling"* ]] || [[ $line == *"Installing"* ]] || [[ $line == *"Launching"* ]]; then
echo -e "${GREEN}🔄 $line${NC}"
elif [[ $line == *"error"* ]] || [[ $line == *"Error"* ]] || [[ $line == *"FAILED"* ]]; then
echo -e "${RED}$line${NC}"
elif [[ $line == *"Syncing"* ]] || [[ $line == *"Reloaded"* ]]; then
echo -e "${BLUE}💫 $line${NC}"
else
# Normale Ausgabe nur bei verbose-Modus relevant
if [[ $line == *"[+"* ]] || [[ $line == *"lib/"* ]]; then
echo "$line"
fi
fi
done
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} App beendet ${NC}"
echo -e "${GREEN}========================================${NC}"