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

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
**/build/
**/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/app/android/app/debug
/app/android/app/profile
/app/android/app/release
# Java / Maven / Vaadin
**/target/
**/node_modules/
backend/.vaadin/

47
.vscode/check_emulator.sh vendored Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Skript zum Prüfen ob Emulator läuft
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
echo "🔍 Prüfe auf Android Emulator..."
# Prüfe ob Emulator läuft
DEVICE=$(flutter devices 2>/dev/null | grep "emulator" | head -1)
if [ -n "$DEVICE" ]; then
echo -e "${GREEN}✅ Emulator läuft:${NC}"
echo " $DEVICE"
exit 0
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 &
echo ""
echo "Warte auf Emulator-Start (ca. 30-60 Sekunden)..."
# Warte bis Emulator verfügbar ist
for i in {1..30}; do
sleep 2
DEVICE=$(flutter devices 2>/dev/null | grep "emulator" | head -1)
if [ -n "$DEVICE" ]; then
echo -e "${GREEN}✅ Emulator bereit!${NC}"
echo " $DEVICE"
exit 0
fi
echo -n "."
done
echo ""
echo -e "${YELLOW}⚠️ Emulator startet noch...${NC}"
echo " Bitte warten und dann nochmal versuchen."
exit 1
fi

26
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"recommendations": [
// Flutter & Dart
"Dart-Code.flutter",
"Dart-Code.dart-code",
// UI-Entwicklung
"eamodio.gitlens",
"usernamehw.errorlens",
"PKief.material-icon-theme",
// Produktivität
"formulahendry.auto-rename-tag",
"streetsidesoftware.code-spell-checker",
"wmaurer.change-case",
"Tyriar.sort-lines",
// Flutter Tools
"alexisvt.flutter-snippets",
"Nash.awesome-flutter-snippets",
"miquelddg.dart-barrel-file-generator"
],
"unwantedRecommendations": [
"vscjava.vscode-java-pack"
]
}

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

60
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,60 @@
{
// Dart & Flutter
"dart.flutterSdkPath": null,
"dart.openDevTools": "flutter",
"dart.previewFlutterUiGuides": true,
"dart.previewFlutterUiGuidesCustomTracking": true,
"dart.showInspectorNotificationsForWidgetErrors": true,
"dart.hotReloadOnSave": "always",
"dart.flutterHotReloadOnSave": "always",
"dart.debugExternalPackageLibraries": false,
"dart.debugSdkLibraries": false,
// Formatierung
"editor.formatOnSave": true,
"editor.formatOnType": true,
"editor.rulers": [80, 120],
"editor.wordWrap": "wordWrapColumn",
"editor.wordWrapColumn": 120,
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
// Dart-spezifisch
"[dart]": {
"editor.formatOnSave": true,
"editor.formatOnType": true,
"editor.rulers": [80],
"editor.selectionHighlight": false,
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets",
"editor.wordBasedSuggestions": "off"
},
// File Explorer
"files.exclude": {
"**/.dart_tool": true,
"**/.packages": true,
"**/.pub": true,
"**/build": true,
"**/*.freezed.dart": true,
"**/*.g.dart": true
},
// Linting
"dart.analysisExcludedFolders": [
"app/build",
"app/.dart_tool",
"backend/target"
],
// Emulator
"dart.flutterRunAdditionalArgs": [
"--enable-software-rendering"
],
// Testing
"dart.testAdditionalArgs": [
"--concurrency=4"
]
}

177
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,177 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "flutter: clean",
"type": "shell",
"command": "flutter",
"args": ["clean"],
"options": {
"cwd": "${workspaceFolder}/app"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "flutter: pub get",
"type": "shell",
"command": "flutter",
"args": ["pub", "get"],
"options": {
"cwd": "${workspaceFolder}/app"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "check: emulator",
"type": "shell",
"command": "${workspaceFolder}/.vscode/check_emulator.sh",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"clear": true
},
"problemMatcher": []
},
{
"label": "flutter: build debug",
"type": "shell",
"command": "flutter",
"args": [
"build",
"apk",
"--debug",
"-t",
"lib/main.dart"
],
"options": {
"cwd": "${workspaceFolder}/app"
},
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": true
},
"problemMatcher": {
"pattern": {
"regexp": "."
}
}
},
{
"label": "flutter: run (terminal)",
"type": "shell",
"command": "flutter",
"args": [
"run",
"-d",
"emulator-5554",
"--debug"
],
"options": {
"cwd": "${workspaceFolder}/app"
},
"group": "build",
"isBackground": true,
"dependsOn": ["check: emulator", "flutter: pub get"],
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": false,
"clear": true
},
"problemMatcher": {
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^\\[\\+\\]",
"endsPattern": "^\\[.*\\]\\s*Synced.*"
}
}
},
{
"label": "flutter: devices",
"type": "shell",
"command": "flutter",
"args": ["devices"],
"options": {
"cwd": "${workspaceFolder}/app"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"clear": true
},
"problemMatcher": []
},
{
"label": "flutter: doctor",
"type": "shell",
"command": "flutter",
"args": ["doctor", "-v"],
"options": {
"cwd": "${workspaceFolder}/app"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"clear": true
},
"problemMatcher": []
},
{
"label": "prelaunch: check deps",
"type": "shell",
"command": "flutter",
"args": ["pub", "get"],
"options": {
"cwd": "${workspaceFolder}/app"
},
"presentation": {
"echo": false,
"reveal": "silent",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
}
]
}

45
README.md Normal file
View File

@@ -0,0 +1,45 @@
# HHA Repository
Dieses Repository ist jetzt als Monorepo fuer zwei getrennte Anwendungen aufgebaut:
- `app/` enthaelt die Flutter-App
- `backend/` enthaelt das Spring Boot + Vaadin Backoffice
## Struktur
```text
HHA/
|- app/
| |- lib/
| |- android/
| |- pubspec.yaml
| `- README.md
`- backend/
|- src/main/java/
|- src/main/resources/
|- pom.xml
`- mvnw
```
## Start
Flutter-App:
```bash
cd app
flutter pub get
flutter run
```
Backend:
```bash
cd backend
./mvnw spring-boot:run
```
## Hinweise
- Flutter-spezifische Quellen, Build-Dateien und Artefakte liegen unter `app/`.
- Das Backend bleibt als eigenstaendiges Maven-Modul unter `backend/`.
- Weitere Details zur mobilen App stehen in `app/README.md`.

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

34
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
node_modules
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

295
backend/mvnw vendored Executable file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
backend/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

88
backend/pom.xml Normal file
View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.3</version>
<relativePath/>
</parent>
<groupId>de.assecutor.hha</groupId>
<artifactId>hha-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>HHA Backend</name>
<description>Spring Boot and Vaadin backoffice module for the HHA repository.</description>
<properties>
<java.version>21</java.version>
<vaadin.version>25.0.7</vaadin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>${vaadin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<id>build-frontend</id>
<goals>
<goal>build-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1 @@
export declare const applyCss: (target: Node) => void;

View File

@@ -0,0 +1,706 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
/// <reference lib="es2018" />
import { Flow as _Flow } from 'Frontend/generated/jar-resources/Flow.js';
import React, { useCallback, useEffect, useReducer, useRef, useState, type ReactNode } from 'react';
import { matchRoutes, useBlocker, useLocation, useNavigate, type NavigateOptions, useHref } from 'react-router';
import { createPortal } from 'react-dom';
const flow = new _Flow({
imports: () => import('Frontend/generated/flow/generated-flow-imports.js')
});
const router = {
render() {
return Promise.resolve();
}
};
const flowReact : { active: boolean } = {
active: false,
}
// ClickHandler for vaadin-router-go event is copied from vaadin/router click.js
// @ts-ignore
function getAnchorOrigin(anchor) {
// IE11: on HTTP and HTTPS the default port is not included into
// window.location.origin, so won't include it here either.
const port = anchor.port;
const protocol = anchor.protocol;
const defaultHttp = protocol === 'http:' && port === '80';
const defaultHttps = protocol === 'https:' && port === '443';
const host =
defaultHttp || defaultHttps
? anchor.hostname // does not include the port number (e.g. www.example.org)
: anchor.host; // does include the port number (e.g. www.example.org:80)
return `${protocol}//${host}`;
}
function normalizeURL(url: URL): void | string {
// ignore click if baseURI does not match the document (external)
if (!url.href.startsWith(document.baseURI)) {
return;
}
// Normalize path against baseURI
return '/' + url.href.slice(document.baseURI.length);
}
function extractURL(event: MouseEvent): void | URL {
// ignore the click if the default action is prevented
if (event.defaultPrevented) {
return;
}
// ignore the click if not with the primary mouse button
if (event.button !== 0) {
return;
}
// ignore the click if a modifier key is pressed
if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// find the <a> element that the click is at (or within)
let maybeAnchor = event.target;
const path = event.composedPath
? event.composedPath()
: // @ts-ignore
event.path || [];
// example to check: `for...of` loop here throws the "Not yet implemented" error
for (let i = 0; i < path.length; i++) {
const target = path[i];
if (target.nodeName && target.nodeName.toLowerCase() === 'a') {
maybeAnchor = target;
break;
}
}
// @ts-ignore
while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') {
// @ts-ignore
maybeAnchor = maybeAnchor.parentNode;
}
// ignore the click if not at an <a> element
// @ts-ignore
if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') {
return;
}
const anchor = maybeAnchor as HTMLAnchorElement;
// ignore the click if the <a> element has a non-default target
if (anchor.target && anchor.target.toLowerCase() !== '_self') {
return;
}
// ignore the click if the <a> element has the 'download' attribute
if (anchor.hasAttribute('download')) {
return;
}
// ignore the click if the <a> element has the 'router-ignore' attribute
if (anchor.hasAttribute('router-ignore')) {
return;
}
// ignore the click if the target URL is a fragment on the current page
if (anchor.pathname === window.location.pathname && anchor.hash !== '') {
// @ts-ignore
window.location.hash = anchor.hash;
return;
}
// ignore the click if the target is external to the app
// In IE11 HTMLAnchorElement does not have the `origin` property
// @ts-ignore
const origin = anchor.origin || getAnchorOrigin(anchor);
if (origin !== window.location.origin) {
return;
}
return new URL(anchor.href, anchor.baseURI);
}
function extractPath(event: MouseEvent): void | string {
const url = extractURL(event);
if (!url) {
return;
}
return normalizeURL(url);
}
export const registerGlobalClickHandler = () => {
window.addEventListener('click', (event: MouseEvent) => {
if (flowReact.active) {
return;
}
const url = extractURL(event);
if (!url) {
return;
}
// ignore click if baseURI does not match the document (external)
if (!url.href.startsWith(document.baseURI)) {
return;
}
if (event && event.preventDefault) {
event.preventDefault();
}
// Normalize path against baseURI
const path = url.pathname + url.search + url.hash;
const state = {...window.history.state}
if (state.idx !== undefined) {
state.idx = state.idx + 1;
}
window.history.pushState(state, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}, { capture: false });
};
/**
* Fire 'vaadin-navigated' event to inform components of navigation.
* @param pathname pathname of navigation
* @param search search of navigation
*/
function fireNavigated(pathname: string, search: string) {
setTimeout(() => {
window.dispatchEvent(
new CustomEvent('vaadin-navigated', {
detail: {
pathname,
search
}
})
);
// @ts-ignore
delete window.Vaadin.Flow.navigation;
});
}
function postpone() {}
const prevent = () => postpone;
type RouterContainer = Awaited<ReturnType<(typeof flow.serverSideRoutes)[0]['action']>>;
type PortalEntry = {
readonly children: ReactNode;
readonly domNode: HTMLElement;
};
type FlowPortalProps = React.PropsWithChildren<
Readonly<{
domNode: HTMLElement;
onRemove(): void;
}>
>;
function FlowPortal({ children, domNode, onRemove }: FlowPortalProps) {
useEffect(() => {
domNode.addEventListener(
'flow-portal-remove',
(event: Event) => {
event.preventDefault();
onRemove();
},
{ once: true }
);
}, []);
return createPortal(children, domNode);
}
const ADD_FLOW_PORTAL = 'ADD_FLOW_PORTAL';
type AddFlowPortalAction = Readonly<{
type: typeof ADD_FLOW_PORTAL;
portal: React.ReactElement<FlowPortalProps>;
}>;
function addFlowPortal(portal: React.ReactElement<FlowPortalProps>): AddFlowPortalAction {
return {
type: ADD_FLOW_PORTAL,
portal
};
}
const REMOVE_FLOW_PORTAL = 'REMOVE_FLOW_PORTAL';
type RemoveFlowPortalAction = Readonly<{
type: typeof REMOVE_FLOW_PORTAL;
key: string;
}>;
function removeFlowPortal(key: string): RemoveFlowPortalAction {
return {
type: REMOVE_FLOW_PORTAL,
key
};
}
function flowPortalsReducer(
portals: readonly React.ReactElement<FlowPortalProps>[],
action: AddFlowPortalAction | RemoveFlowPortalAction
) {
switch (action.type) {
case ADD_FLOW_PORTAL:
return [...portals, action.portal];
case REMOVE_FLOW_PORTAL:
return portals.filter(({ key }) => key !== action.key);
default:
return portals;
}
}
type NavigateOpts = {
to: string;
callback: boolean;
opts?: NavigateOptions;
};
type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => void;
let navigateInProgress = false;
/**
* A hook providing the `navigate(path: string, opts?: NavigateOptions)` function
* with React Router API that has more consistent history updates. Uses internal
* queue for processing navigate calls.
*/
function useQueuedNavigate(
waitReference: React.MutableRefObject<Promise<void> | undefined>,
navigated: React.MutableRefObject<boolean>
): NavigateFn {
const navigate = useNavigate();
const navigateQueue = useRef<NavigateOpts[]>([]).current;
const [navigateQueueLength, setNavigateQueueLength] = useState(0);
const dequeueNavigation = useCallback(() => {
if (navigateInProgress) {
dequeueNavigationAfterCurrentTask();
return;
}
const navigateArgs = navigateQueue.shift();
if (navigateArgs === undefined) {
// Empty queue, do nothing.
return;
}
const blockingNavigate = async () => {
if (waitReference.current) {
await waitReference.current;
waitReference.current = undefined;
}
navigated.current = !navigateArgs.callback;
navigateInProgress = true;
navigate(navigateArgs.to, navigateArgs.opts);
setNavigateQueueLength(navigateQueue.length);
};
blockingNavigate();
}, [navigate, setNavigateQueueLength]);
const dequeueNavigationAfterCurrentTask = useCallback(() => {
setTimeout(dequeueNavigation, 0);
}, [dequeueNavigation]);
const enqueueNavigation = useCallback(
(to: string, callback: boolean, opts?: NavigateOptions) => {
navigateQueue.push({ to: to, callback: callback, opts: opts });
setNavigateQueueLength(navigateQueue.length);
if (navigateQueue.length === 1) {
// The first navigation can be started right after any pending sync
// jobs, which could add more navigations to the queue.
dequeueNavigationAfterCurrentTask();
}
},
[setNavigateQueueLength, dequeueNavigationAfterCurrentTask]
);
useEffect(
() => () => {
// The Flow component has rendered, but history might not be
// updated yet, as React Router does it asynchronously.
// Use microtask callback for history consistency.
dequeueNavigationAfterCurrentTask();
},
[navigateQueueLength, dequeueNavigationAfterCurrentTask]
);
return enqueueNavigation;
}
const flowNavigation = () => {
// @ts-ignore
window.Vaadin.Flow.navigation = true;
};
function Flow() {
const ref = useRef<HTMLOutputElement>(null);
const navigate = useNavigate();
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
navigated.current =
navigated.current ||
(nextLocation.pathname === currentLocation.pathname &&
nextLocation.search === currentLocation.search &&
nextLocation.hash === currentLocation.hash);
return true;
});
const location = useLocation();
const navigated = useRef<boolean>(false);
const blockerHandled = useRef<boolean>(false);
const fromAnchor = useRef<boolean>(false);
const containerRef = useRef<RouterContainer | undefined>(undefined);
const roundTrip = useRef<Promise<void> | undefined>(undefined);
const queuedNavigate = useQueuedNavigate(roundTrip, navigated);
const basename = useHref('/');
// portalsReducer function is used as state outside the Flow component.
const [portals, dispatchPortalAction] = useReducer(flowPortalsReducer, []);
const addPortalEventHandler = useCallback(
(event: CustomEvent<PortalEntry>) => {
event.preventDefault();
const key = Math.random().toString(36).slice(2);
dispatchPortalAction(
addFlowPortal(
<FlowPortal
key={key}
domNode={event.detail.domNode}
onRemove={() => dispatchPortalAction(removeFlowPortal(key))}
>
{event.detail.children}
</FlowPortal>
)
);
},
[dispatchPortalAction]
);
const navigateEventHandler = useCallback(
(event: MouseEvent) => {
const path = extractPath(event);
if (!path) {
return;
}
if (event && event.preventDefault) {
event.preventDefault();
}
navigated.current = false;
// When navigation is triggered by click on a link, fromAnchor is set to true
// in order to get a server round-trip even when navigating to the same URL again
fromAnchor.current = true;
// @ts-ignore
window.Vaadin.Flow.navigation = true;
navigate(path);
// Dispatch close event for overlay drawer on click navigation.
window.dispatchEvent(new CustomEvent('close-overlay-drawer'));
},
[navigate]
);
const vaadinRouterGoEventHandler = useCallback(
(event: CustomEvent<URL>) => {
const url = event.detail;
const path = normalizeURL(url);
if (!path) {
return;
}
event.preventDefault();
navigate(path);
},
[navigate]
);
const vaadinNavigateEventHandler = useCallback(
(event: CustomEvent<{ state: unknown; url: string; replace?: boolean; callback: boolean }>) => {
// @ts-ignore
window.Vaadin.Flow.navigation = true;
// clean base uri away if for instance redirected to http://localhost/path/user?id=10
// else the whole http... will be appended to the url see #19580
const path = event.detail.url.startsWith(document.baseURI)
? '/' + event.detail.url.slice(document.baseURI.length)
: '/' + event.detail.url;
fromAnchor.current = false;
queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace });
},
[navigate]
);
const redirect = useCallback(
(path: string) => {
return () => {
navigate(path, { replace: true });
};
},
[navigate]
);
useEffect(() => {
// @ts-ignore
window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler);
// @ts-ignore
window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler);
return () => {
// @ts-ignore
window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler);
// @ts-ignore
window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler);
};
}, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]);
useEffect(() => {
// @ts-ignore
window.addEventListener("popstate", flowNavigation);
window.addEventListener('click', navigateEventHandler);
flowReact.active = true;
return () => {
containerRef.current?.parentNode?.removeChild(containerRef.current);
containerRef.current?.removeEventListener('flow-portal-add', addPortalEventHandler as EventListener);
containerRef.current = undefined;
// @ts-ignore
window.removeEventListener("popstate", flowNavigation);
window.removeEventListener('click', navigateEventHandler);
flowReact.active = false;
};
}, []);
useEffect(() => {
if (blocker.state === 'blocked') {
if (blockerHandled.current) {
// Blocker is handled and the new navigation
// gets queued to be executed after the current handling ends.
const { pathname, state } = blocker.location;
// Clear base name to not get /baseName/basename/path
const pathNoBase = pathname.substring(basename.length);
// path should always start with / else react-router will append to current url
queuedNavigate(pathNoBase.startsWith('/') ? pathNoBase : '/' + pathNoBase, true, {
state: state,
replace: true
});
return;
}
blockerHandled.current = true;
let blockingPromise: any;
roundTrip.current = new Promise<void>(
(resolve, reject) => (blockingPromise = { resolve: resolve, reject: reject })
);
// Release blocker handling after promise is fulfilled
roundTrip.current.then(
() => (blockerHandled.current = false),
() => (blockerHandled.current = false)
);
// Proceed to the blocked location, unless the navigation originates from a click on a link.
// In that case continue with function execution and perform a server round-trip
if (navigated.current && !fromAnchor.current) {
blocker.proceed();
blockingPromise.resolve();
navigateInProgress = false;
return;
}
fromAnchor.current = false;
const { pathname, search } = blocker.location;
const routes = ((window as any)?.Vaadin?.routesConfig || []) as any[];
let matched = matchRoutes(Array.from(routes), pathname);
// Navigation between server routes
// @ts-ignore
if (matched && matched.filter((path) => path.route?.element?.type?.name === Flow.name).length != 0) {
containerRef.current?.onBeforeEnter?.call(
containerRef?.current,
{ pathname, search },
{
prevent() {
blocker.reset();
blockingPromise.resolve();
navigateInProgress = false;
navigated.current = false;
},
redirect,
continue() {
blocker.proceed();
blockingPromise.resolve();
navigateInProgress = false;
}
},
router
);
navigated.current = true;
} else {
// For covering the 'server -> client' use case
Promise.resolve(
containerRef.current?.onBeforeLeave?.call(
containerRef?.current,
{
pathname,
search
},
{ prevent },
router
)
).then((cmd: unknown) => {
if (cmd === postpone && containerRef.current) {
// postponed navigation: expose existing blocker to Flow
containerRef.current.serverConnected = (cancel) => {
if (cancel) {
blocker.reset();
} else {
blocker.proceed();
}
blockingPromise.resolve();
navigateInProgress = false;
};
} else {
// permitted navigation: proceed with the blocker
blocker.proceed();
blockingPromise.resolve();
navigateInProgress = false;
}
});
}
}
}, [blocker.state, blocker.location]);
useEffect(() => {
if (blocker.state === 'blocked') {
return;
}
if (navigated.current) {
navigated.current = false;
fireNavigated(location.pathname, location.search);
return;
}
flow.serverSideRoutes[0]
.action({ pathname: location.pathname, search: location.search })
.then((container) => {
const outlet = ref.current?.parentNode;
if (outlet && outlet !== container.parentNode) {
outlet.append(container);
container.addEventListener('flow-portal-add', addPortalEventHandler as EventListener);
containerRef.current = container;
}
return container.onBeforeEnter?.call(
container,
{ pathname: location.pathname, search: location.search },
{
prevent,
redirect,
continue() {
fireNavigated(location.pathname, location.search);
}
},
router
);
})
.then((result: unknown) => {
if (typeof result === 'function') {
result();
}
});
}, [location]);
return (
<>
<output ref={ref} style={{ display: 'none' }} />
{portals}
</>
);
}
Flow.type = 'FlowContainer'; // This is for copilot to recognize this
export const serverSideRoutes = [{ path: '/*', element: <Flow /> }];
/**
* Load the script for an exported WebComponent with the given tag
*
* @param tag name of the exported web-component to load
*
* @returns Promise(resolve, reject) that is fulfilled on script load
*/
export const loadComponentScript = (tag: String): Promise<void> => {
return new Promise((resolve, reject) => {
useEffect(() => {
const script = document.createElement('script');
script.src = `/web-component/${tag}.js`;
script.onload = function () {
resolve();
};
script.onerror = function (err) {
reject(err);
};
document.head.appendChild(script);
return () => {
document.head.removeChild(script);
};
}, []);
});
};
interface Properties {
[key: string]: string;
}
/**
* Load WebComponent script and create a React element for the WebComponent.
*
* @param tag custom web-component tag name.
* @param props optional Properties object to create element attributes with
* @param onload optional callback to be called for script onload
* @param onerror optional callback for error loading the script
*/
export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err: any) => void) => {
loadComponentScript(tag).then(
() => onload?.(),
(err) => {
if (onerror) {
onerror(err);
} else {
console.error(`Failed to load script for ${tag}.`, err);
}
}
);
if (props) {
return React.createElement(tag, props);
}
return React.createElement(tag);
};
export default Flow;
// @ts-ignore
if (import.meta.hot) {
// @ts-ignore
import.meta.hot.accept((newModule) => {
// A hot module replace for Flow.tsx happens when any JS/TS imported through @JsModule
// or similar is updated because this updates generated-flow-imports.js and that in turn
// is imported by this file. We have no means of hot replacing those files, e.g. some
// custom lit element so we need to reload the page. */
if (newModule) {
window.location.reload();
}
});
}

View File

@@ -0,0 +1,329 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
import { createRoot, Root } from 'react-dom/client';
import { createElement, type Dispatch, type ReactElement, type ReactNode, useEffect, useReducer } from 'react';
type FlowStateKeyChangedAction<K extends string, V> = Readonly<{
type: 'stateKeyChanged';
key: K;
value: V;
}>;
type FlowStateReducerAction = FlowStateKeyChangedAction<string, unknown>;
function stateReducer<S extends Readonly<Record<string, unknown>>>(state: S, action: FlowStateReducerAction): S {
switch (action.type) {
case 'stateKeyChanged':
const { value } = action;
return {
...state,
key: value
} as S;
default:
return state;
}
}
type DispatchEvent<T> = T extends undefined ? () => boolean : (value: T) => boolean;
const emptyAction: Dispatch<unknown> = () => {};
/**
* An object with APIs exposed for using in the {@link ReactAdapterElement#render}
* implementation.
*/
export type RenderHooks = {
/**
* A hook API for using stateful JS properties of the Web Component from
* the React `render()`.
*
* @typeParam T - Type of the state value
*
* @param key - Web Component property name, which is used for two-way
* value propagation from the server and back.
* @param initialValue - Fallback initial value (optional). Only applies if
* the Java component constructor does not invoke `setState`.
* @returns A tuple with two values:
* 1. The current state.
* 2. The `set` function for changing the state and triggering render
* @protected
*/
readonly useState: ReactAdapterElement['useState'];
/**
* A hook helper to simplify dispatching a `CustomEvent` on the Web
* Component from React.
*
* @typeParam T - The type for `event.detail` value (optional).
*
* @param type - The `CustomEvent` type string.
* @param options - The settings for the `CustomEvent`.
* @returns The `dispatch` function. The function parameters change
* depending on the `T` generic type:
* - For `undefined` type (default), has no parameters.
* - For other types, has one parameter for the `event.detail` value of that type.
* @protected
*/
readonly useCustomEvent: ReactAdapterElement['useCustomEvent'];
/**
* A hook helper to generate the content element with name attribute to bind
* the server-side Flow element for this component.
*
* This is used together with {@link ReactAdapterComponent::getContentElement}
* to have server-side component attach to the correct client element.
*
* Usage as follows:
*
* const content = hooks.useContent('content');
* return <>
* {content}
* </>;
*
* Note! Not adding the 'content' element into the dom will have the
* server throw a IllegalStateException for element with tag name not found.
*
* @param name - The name attribute of the element
*/
readonly useContent: ReactAdapterElement['useContent'];
};
interface ReadyCallbackFunction {
(): void;
}
/**
* A base class for Web Components that render using React. Enables creating
* adapters for integrating React components with Flow. Intended for use with
* `ReactAdapterComponent` Flow Java class.
*/
export abstract class ReactAdapterElement extends HTMLElement {
#root: Root | undefined = undefined;
#rootRendered: boolean = false;
#rendering: ReactNode | undefined = undefined;
#state: Record<string, unknown> = Object.create(null);
#stateSetters = new Map<string, Dispatch<unknown>>();
#customEvents = new Map<string, DispatchEvent<unknown>>();
#dispatchFlowState: Dispatch<FlowStateReducerAction> = emptyAction;
#readyCallback = new Map<string, ReadyCallbackFunction>();
readonly #renderHooks: RenderHooks;
readonly #Wrapper: () => ReactElement | null;
#unmounting?: Promise<void>;
constructor() {
super();
this.#renderHooks = {
useState: this.useState.bind(this),
useCustomEvent: this.useCustomEvent.bind(this),
useContent: this.useContent.bind(this)
};
this.#Wrapper = this.#renderWrapper.bind(this);
this.#markAsUsed();
}
public async connectedCallback() {
this.#rendering = createElement(this.#Wrapper);
const createNewRoot = this.dispatchEvent(
new CustomEvent('flow-portal-add', {
bubbles: true,
cancelable: true,
composed: true,
detail: {
children: this.#rendering,
domNode: this
}
})
);
if (!createNewRoot || this.#root) {
return;
}
await this.#unmounting;
this.#root = createRoot(this);
this.#maybeRenderRoot();
this.#root.render(this.#rendering);
}
/**
* Add a callback for specified element identifier to be called when
* react element is ready.
* <p>
* For internal use only. May be renamed or removed in a future release.
*
* @param id element identifier that callback is for
* @param readyCallback callback method to be informed on element ready state
* @internal
*/
public addReadyCallback(id: string, readyCallback: ReadyCallbackFunction) {
this.#readyCallback.set(id, readyCallback);
}
public async disconnectedCallback() {
if (!this.#root) {
this.dispatchEvent(
new CustomEvent('flow-portal-remove', {
bubbles: true,
cancelable: true,
composed: true,
detail: {
children: this.#rendering,
domNode: this
}
})
);
} else {
this.#unmounting = Promise.resolve();
await this.#unmounting;
this.#root.unmount();
this.#root = undefined;
}
this.#rootRendered = false;
this.#rendering = undefined;
}
/**
* A hook API for using stateful JS properties of the Web Component from
* the React `render()`.
*
* @typeParam T - Type of the state value
*
* @param key - Web Component property name, which is used for two-way
* value propagation from the server and back.
* @param initialValue - Fallback initial value (optional). Only applies if
* the Java component constructor does not invoke `setState`.
* @returns A tuple with two values:
* 1. The current state.
* 2. The `set` function for changing the state and triggering render
* @protected
*/
protected useState<T>(key: string, initialValue?: T): [value: T, setValue: Dispatch<T>] {
if (this.#stateSetters.has(key)) {
return [this.#state[key] as T, this.#stateSetters.get(key)!];
}
const value = ((this as Record<string, unknown>)[key] as T) ?? initialValue!;
this.#state[key] = value;
Object.defineProperty(this, key, {
enumerable: true,
get(): T {
return this.#state[key];
},
set(nextValue: T) {
this.#state[key] = nextValue;
this.#dispatchFlowState({ type: 'stateKeyChanged', key, value });
}
});
const dispatchChangedEvent = this.useCustomEvent<{ value: T }>(`${key}-changed`, { detail: { value } });
const setValue = (value: T) => {
this.#state[key] = value;
dispatchChangedEvent({ value });
this.#dispatchFlowState({ type: 'stateKeyChanged', key, value });
};
this.#stateSetters.set(key, setValue as Dispatch<unknown>);
return [value, setValue];
}
/**
* A hook helper to simplify dispatching a `CustomEvent` on the Web
* Component from React.
*
* @typeParam T - The type for `event.detail` value (optional).
*
* @param type - The `CustomEvent` type string.
* @param options - The settings for the `CustomEvent`.
* @returns The `dispatch` function. The function parameters change
* depending on the `T` generic type:
* - For `undefined` type (default), has no parameters.
* - For other types, has one parameter for the `event.detail` value of that type.
* @protected
*/
protected useCustomEvent<T = undefined>(type: string, options: CustomEventInit<T> = {}): DispatchEvent<T> {
if (!this.#customEvents.has(type)) {
const dispatch = ((detail?: T) => {
const eventInitDict =
detail === undefined
? options
: {
...options,
detail
};
const event = new CustomEvent(type, eventInitDict);
return this.dispatchEvent(event);
}) as DispatchEvent<T>;
this.#customEvents.set(type, dispatch as DispatchEvent<unknown>);
return dispatch;
}
return this.#customEvents.get(type)! as DispatchEvent<T>;
}
/**
* The Web Component render function. To be implemented by users with React.
*
* @param hooks - the adapter APIs exposed for the implementation.
* @protected
*/
protected abstract render(hooks: RenderHooks): ReactElement | null;
/**
* Prepare content container for Flow to bind server Element to.
*
* @param name container name attribute matching server name attribute
* @protected
*/
protected useContent(name: string): ReactElement | null {
useEffect(() => {
this.#readyCallback.get(name)?.();
}, []);
return createElement('flow-content-container', { name, style: { display: 'contents' } });
}
#maybeRenderRoot() {
if (this.#rootRendered || !this.#root) {
return;
}
this.#root.render(createElement(this.#Wrapper));
this.#rootRendered = true;
}
#renderWrapper(): ReactElement | null {
const [state, dispatchFlowState] = useReducer(stateReducer, this.#state);
this.#state = state;
this.#dispatchFlowState = dispatchFlowState;
return this.render(this.#renderHooks);
}
#markAsUsed(): void {
// @ts-ignore
let vaadinObject = window.Vaadin || {};
// @ts-ignore
if (vaadinObject.developmentMode) {
vaadinObject.registrations = vaadinObject.registrations || [];
vaadinObject.registrations.push({
is: 'ReactAdapterElement',
version: '25.0.8'
});
}
}
}

View File

@@ -0,0 +1,10 @@
import '@vaadin/app-layout/src/vaadin-app-layout.js';
import '@vaadin/vertical-layout/src/vaadin-vertical-layout.js';
import '@vaadin/app-layout/src/vaadin-drawer-toggle.js';
import '@vaadin/button/src/vaadin-button.js';
import '@vaadin/tooltip/src/vaadin-tooltip.js';
import 'Frontend/generated/jar-resources/disableOnClickFunctions.js';
import '@vaadin/side-nav/src/vaadin-side-nav.js';
import '@vaadin/side-nav/src/vaadin-side-nav-item.js';
import '@vaadin/icons/vaadin-iconset.js';
import '@vaadin/icon/src/vaadin-icon.js';

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,29 @@
import '@vaadin/app-layout/src/vaadin-app-layout.js';
import '@vaadin/vertical-layout/src/vaadin-vertical-layout.js';
import '@vaadin/app-layout/src/vaadin-drawer-toggle.js';
import '@vaadin/button/src/vaadin-button.js';
import '@vaadin/tooltip/src/vaadin-tooltip.js';
import 'Frontend/generated/jar-resources/disableOnClickFunctions.js';
import '@vaadin/side-nav/src/vaadin-side-nav.js';
import '@vaadin/side-nav/src/vaadin-side-nav-item.js';
import '@vaadin/icons/vaadin-iconset.js';
import '@vaadin/icon/src/vaadin-icon.js';
import '@vaadin/common-frontend/ConnectionIndicator.js';
import 'Frontend/generated/jar-resources/ReactRouterOutletElement.tsx';
const loadOnDemand = (key) => {
const pending = [];
if (key === '1e3e1195126ded8f48ea9283d0ff579a0216bfc273c30bdac8e5152f029351ee') {
pending.push(import('./chunks/chunk-bb2f082c2d2806895673c8e73955a0121cc631902992dbe3083e4b276ee78c5f.js'));
}
return Promise.all(pending);
}
window.Vaadin = window.Vaadin || {};
window.Vaadin.Flow = window.Vaadin.Flow || {};
window.Vaadin.Flow.loadOnDemand = loadOnDemand;
window.Vaadin.Flow.resetFocus = () => {
let ae=document.activeElement;
while(ae&&ae.shadowRoot) ae = ae.shadowRoot.activeElement;
return !ae || ae.blur() || ae.focus() || true;
}

View File

@@ -0,0 +1,30 @@
import { injectGlobalWebcomponentCss } from 'Frontend/generated/jar-resources/theme-util.js';
import '@vaadin/app-layout/src/vaadin-app-layout.js';
import '@vaadin/vertical-layout/src/vaadin-vertical-layout.js';
import '@vaadin/app-layout/src/vaadin-drawer-toggle.js';
import '@vaadin/button/src/vaadin-button.js';
import '@vaadin/tooltip/src/vaadin-tooltip.js';
import 'Frontend/generated/jar-resources/disableOnClickFunctions.js';
import '@vaadin/side-nav/src/vaadin-side-nav.js';
import '@vaadin/side-nav/src/vaadin-side-nav-item.js';
import '@vaadin/icons/vaadin-iconset.js';
import '@vaadin/icon/src/vaadin-icon.js';
import '@vaadin/common-frontend/ConnectionIndicator.js';
import 'Frontend/generated/jar-resources/ReactRouterOutletElement.tsx';
const loadOnDemand = (key) => {
const pending = [];
if (key === '1e3e1195126ded8f48ea9283d0ff579a0216bfc273c30bdac8e5152f029351ee') {
pending.push(import('./chunks/chunk-bb2f082c2d2806895673c8e73955a0121cc631902992dbe3083e4b276ee78c5f.js'));
}
return Promise.all(pending);
}
window.Vaadin = window.Vaadin || {};
window.Vaadin.Flow = window.Vaadin.Flow || {};
window.Vaadin.Flow.loadOnDemand = loadOnDemand;
window.Vaadin.Flow.resetFocus = () => {
let ae=document.activeElement;
while(ae&&ae.shadowRoot) ae = ae.shadowRoot.activeElement;
return !ae || ae.blur() || ae.focus() || true;
}

View File

@@ -0,0 +1,26 @@
/******************************************************************************
* This file is auto-generated by Vaadin.
* If you want to customize the entry point, you can copy this file or create
* your own `index.tsx` in your frontend directory.
* By default, the `index.tsx` file should be in `./frontend/` folder.
*
* NOTE:
* - You need to restart the dev-server after adding the new `index.tsx` file.
* After that, all modifications to `index.tsx` are recompiled automatically.
* - `index.js` is also supported if you don't want to use TypeScript.
******************************************************************************/
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from 'Frontend/generated/routes.js';
function App() {
return <RouterProvider router={router} />;
}
const outlet = document.getElementById('outlet')!;
let root = (outlet as any)._root ?? createRoot(outlet);
(outlet as any)._root = root;
root.render(createElement(App));

View File

@@ -0,0 +1,78 @@
export interface FlowConfig {
imports?: () => Promise<any>;
}
interface AppConfig {
productionMode: boolean;
appId: string;
uidl: any;
}
interface AppInitResponse {
appConfig: AppConfig;
pushScript?: string;
}
interface Router {
render: (ctx: NavigationParameters, shouldUpdateHistory: boolean) => Promise<void>;
}
interface HTMLRouterContainer extends HTMLElement {
onBeforeEnter?: (ctx: NavigationParameters, cmd: PreventAndRedirectCommands, router: Router) => void | Promise<any>;
onBeforeLeave?: (ctx: NavigationParameters, cmd: PreventCommands, router: Router) => void | Promise<any>;
serverConnected?: (cancel: boolean, url?: NavigationParameters) => void;
serverPaused?: () => void;
}
interface FlowRoute {
action: (params: NavigationParameters) => Promise<HTMLRouterContainer>;
path: string;
}
export interface NavigationParameters {
pathname: string;
search?: string;
}
export interface PreventCommands {
prevent: () => any;
continue?: () => any;
}
export interface PreventAndRedirectCommands extends PreventCommands {
redirect: (route: string) => any;
}
/**
* Client API for flow UI operations.
*/
export declare class Flow {
config: FlowConfig;
response?: AppInitResponse;
pathname: string;
container: HTMLRouterContainer;
private isActive;
private baseRegex;
private appShellTitle;
private navigation;
constructor(config?: FlowConfig);
/**
* Return a `route` object for vaadin-router in an one-element array.
*
* The `FlowRoute` object `path` property handles any route,
* and the `action` returns the flow container without updating the content,
* delaying the actual Flow server call to the `onBeforeEnter` phase.
*
* This is a specific API for its use with `vaadin-router`.
*/
get serverSideRoutes(): [FlowRoute];
loadingStarted(): void;
loadingFinished(): void;
private get action();
private flowLeave;
private flowNavigate;
private getFlowRoutePath;
private getFlowRouteQuery;
private flowInit;
private loadScript;
private findNonce;
private injectAppIdScript;
private flowInitClient;
private flowInitUi;
private collectBrowserDetails;
private addConnectionIndicator;
private offlineStubAction;
private isFlowClientLoaded;
}
export {};

View File

@@ -0,0 +1,497 @@
import { ConnectionIndicator, ConnectionState } from '@vaadin/common-frontend';
class FlowUiInitializationError extends Error {
}
// flow uses body for keeping references
const flowRoot = window.document.body;
const $wnd = window;
const ROOT_NODE_ID = 1; // See StateTree.java
function getClients() {
return Object.keys($wnd.Vaadin.Flow.clients)
.filter((key) => key !== 'TypeScript')
.map((id) => $wnd.Vaadin.Flow.clients[id]);
}
function sendEvent(eventName, data) {
getClients().forEach((client) => client.sendEventMessage(ROOT_NODE_ID, eventName, data));
}
// In the future could be replaced with RegExp.escape()
function escapeRegExp(pattern) {
return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Client API for flow UI operations.
*/
export class Flow {
config;
response = undefined;
pathname = '';
container;
// flag used to inform Testbench whether a server route is in progress
isActive = false;
baseRegex = /^\//;
appShellTitle;
navigation = '';
constructor(config) {
// Set window.name early so @PreserveOnRefresh can use it to identify the browser tab
// Only set if not already set to preserve any existing value
if (!window.name) {
window.name = `v-${Math.random()}`;
}
flowRoot.$ = flowRoot.$ || [];
this.config = config || {};
// TB checks for the existence of window.Vaadin.Flow in order
// to consider that TB needs to wait for `initFlow()`.
$wnd.Vaadin = $wnd.Vaadin || {};
$wnd.Vaadin.Flow = $wnd.Vaadin.Flow || {};
$wnd.Vaadin.Flow.clients = {
TypeScript: {
isActive: () => this.isActive
}
};
// Set browser details collection function as global for use by refresh()
$wnd.Vaadin.Flow.getBrowserDetailsParameters = this.collectBrowserDetails.bind(this);
// Regular expression used to remove the app-context
const elm = document.head.querySelector('base');
this.baseRegex = new RegExp(`^${
// IE11 does not support document.baseURI
escapeRegExp(decodeURIComponent((document.baseURI || (elm && elm.href) || '/').replace(/^https?:\/\/[^/]+/i, '')))}`);
this.appShellTitle = document.title;
// Put a vaadin-connection-indicator in the dom
this.addConnectionIndicator();
}
/**
* Return a `route` object for vaadin-router in an one-element array.
*
* The `FlowRoute` object `path` property handles any route,
* and the `action` returns the flow container without updating the content,
* delaying the actual Flow server call to the `onBeforeEnter` phase.
*
* This is a specific API for its use with `vaadin-router`.
*/
get serverSideRoutes() {
return [
{
path: '(.*)',
action: this.action
}
];
}
loadingStarted() {
// Make Testbench know that server request is in progress
this.isActive = true;
$wnd.Vaadin.connectionState.loadingStarted();
}
loadingFinished() {
// Make Testbench know that server request has finished
this.isActive = false;
$wnd.Vaadin.connectionState.loadingFinished();
if ($wnd.Vaadin.listener) {
// Listeners registered, do not register again.
return;
}
$wnd.Vaadin.listener = {};
// Listen for click on router-links -> 'link' navigation trigger
// and on <a> nodes -> 'client' navigation trigger.
// Use capture phase to detect prevented / stopped events.
document.addEventListener('click', (_e) => {
if (_e.target) {
if (_e.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute('router-link'))) {
this.navigation = 'link';
}
else if (_e.composedPath().some((node) => node.nodeName === 'A')) {
this.navigation = 'client';
}
}
}, {
capture: true
});
}
get action() {
// Return a function which is bound to the flow instance, thus we can use
// the syntax `...serverSideRoutes` in vaadin-router.
return async (params) => {
// Store last action pathname so as we can check it in events
this.pathname = params.pathname;
if ($wnd.Vaadin.connectionState.online) {
try {
await this.flowInit();
}
catch (error) {
if (error instanceof FlowUiInitializationError) {
// error initializing Flow: assume connection lost
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTION_LOST;
return this.offlineStubAction();
}
else {
throw error;
}
}
}
else {
// insert an offline stub
return this.offlineStubAction();
}
// When an action happens, navigation will be resolved `onBeforeEnter`
this.container.onBeforeEnter = (ctx, cmd) => this.flowNavigate(ctx, cmd);
// For covering the 'server -> client' use case
this.container.onBeforeLeave = (ctx, cmd) => this.flowLeave(ctx, cmd);
return this.container;
};
}
// Send a remote call to `JavaScriptBootstrapUI` to check
// whether navigation has to be cancelled.
async flowLeave(ctx, cmd) {
// server -> server, viewing offline stub, or browser is offline
const { connectionState } = $wnd.Vaadin;
if (this.pathname === ctx.pathname || !this.isFlowClientLoaded() || connectionState.offline) {
return Promise.resolve({});
}
// 'server -> client'
return new Promise((resolve) => {
this.loadingStarted();
// The callback to run from server side to cancel navigation
this.container.serverConnected = (cancel) => {
resolve(cmd && cancel ? cmd.prevent() : cmd?.continue?.());
this.loadingFinished();
};
// Call server side to check whether we can leave the view
sendEvent('ui-leave-navigation', { route: this.getFlowRoutePath(ctx), query: this.getFlowRouteQuery(ctx) });
});
}
// Send the remote call to `JavaScriptBootstrapUI` to render the flow
// route specified by the context
async flowNavigate(ctx, cmd) {
if (this.response) {
return new Promise((resolve) => {
this.loadingStarted();
// The callback to run from server side once the view is ready
this.container.serverConnected = (cancel, redirectContext) => {
if (cmd && cancel) {
resolve(cmd.prevent());
}
else if (cmd && cmd.redirect && redirectContext) {
resolve(cmd.redirect(redirectContext.pathname));
}
else {
cmd?.continue?.();
this.container.style.display = '';
resolve(this.container);
}
this.loadingFinished();
};
this.container.serverPaused = () => {
this.loadingFinished();
};
// Call server side to navigate to the given route
sendEvent('ui-navigate', {
route: this.getFlowRoutePath(ctx),
query: this.getFlowRouteQuery(ctx),
appShellTitle: this.appShellTitle,
historyState: history.state,
trigger: this.navigation
});
// Default to history navigation trigger.
// Link and client cases are handled by click listener in loadingFinished().
this.navigation = 'history';
});
}
else {
// No server response => offline or erroneous connection
return Promise.resolve(this.container);
}
}
getFlowRoutePath(context) {
return decodeURIComponent(context.pathname).replace(this.baseRegex, '');
}
getFlowRouteQuery(context) {
return (context.search && context.search.substring(1)) || '';
}
// import flow client modules and initialize UI in server side.
async flowInit() {
// Do not start flow twice
if (!this.isFlowClientLoaded()) {
$wnd.Vaadin.Flow.nonce = this.findNonce();
// show flow progress indicator
this.loadingStarted();
// Initialize server side UI
this.response = await this.flowInitUi();
const { pushScript, appConfig } = this.response;
if (typeof pushScript === 'string') {
await this.loadScript(pushScript);
}
const { appId } = appConfig;
// we use a custom tag for the flow app container
// This must be created before bootstrapMod.init is called as that call
// can handle a UIDL from the server, which relies on the container being available
const tag = `flow-container-${appId.toLowerCase()}`;
const serverCreatedContainer = document.querySelector(tag);
if (serverCreatedContainer) {
this.container = serverCreatedContainer;
}
else {
this.container = document.createElement(tag);
this.container.id = appId;
}
flowRoot.$[appId] = this.container;
// Load bootstrap script with server side parameters
const bootstrapMod = await import('./FlowBootstrap');
bootstrapMod.init(this.response);
// Load custom modules defined by user
if (typeof this.config.imports === 'function') {
this.injectAppIdScript(appId);
await this.config.imports();
}
// Load flow-client module
const clientMod = await import('./FlowClient');
await this.flowInitClient(clientMod);
// hide flow progress indicator
this.loadingFinished();
}
// It might be that components created from server expect that their content has been rendered.
// Appending eagerly the container we avoid these kind of errors.
// Note that the client router will move this container to the outlet if the navigation succeed
if (this.container && !this.container.isConnected) {
this.container.style.display = 'none';
document.body.appendChild(this.container);
}
return this.response;
}
async loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = () => resolve();
script.onerror = reject;
script.src = url;
const { nonce } = $wnd.Vaadin.Flow;
if (nonce !== undefined) {
script.setAttribute('nonce', nonce);
}
document.body.appendChild(script);
});
}
findNonce() {
let nonce;
const scriptTags = document.head.getElementsByTagName('script');
for (const scriptTag of scriptTags) {
if (scriptTag.nonce) {
nonce = scriptTag.nonce;
break;
}
}
return nonce;
}
injectAppIdScript(appId) {
const appIdWithoutHashCode = appId.substring(0, appId.lastIndexOf('-'));
const scriptAppId = document.createElement('script');
scriptAppId.type = 'module';
scriptAppId.setAttribute('data-app-id', appIdWithoutHashCode);
const { nonce } = $wnd.Vaadin.Flow;
if (nonce !== undefined) {
scriptAppId.setAttribute('nonce', nonce);
}
document.body.append(scriptAppId);
}
// After the flow-client javascript module has been loaded, this initializes flow UI
// in the browser.
async flowInitClient(clientMod) {
clientMod.init();
// client init is async, we need to loop until initialized
return new Promise((resolve) => {
const intervalId = setInterval(() => {
// client `isActive() == true` while initializing or processing
const initializing = getClients().reduce((prev, client) => prev || client.isActive(), false);
if (!initializing) {
clearInterval(intervalId);
resolve();
}
}, 5);
});
}
// Returns the `appConfig` object
async flowInitUi() {
// appConfig was sent in the index.html request
const initial = $wnd.Vaadin && $wnd.Vaadin.TypeScript && $wnd.Vaadin.TypeScript.initial;
if (initial) {
$wnd.Vaadin.TypeScript.initial = undefined;
return Promise.resolve(initial);
}
// send a request to the `JavaScriptBootstrapHandler`
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const httpRequest = xhr;
// Collect browser details to send with init request as JSON
const browserDetails = this.collectBrowserDetails();
const browserDetailsParam = browserDetails
? `&v-browserDetails=${encodeURIComponent(JSON.stringify(browserDetails))}`
: '';
const requestPath = `?v-r=init&location=${encodeURIComponent(this.getFlowRoutePath(location))}&query=${encodeURIComponent(this.getFlowRouteQuery(location))}${browserDetailsParam}`;
httpRequest.open('GET', requestPath);
httpRequest.onerror = () => reject(new FlowUiInitializationError(`Invalid server response when initializing Flow UI.
${httpRequest.status}
${httpRequest.responseText}`));
httpRequest.onload = () => {
const contentType = httpRequest.getResponseHeader('content-type');
if (contentType && contentType.indexOf('application/json') !== -1) {
resolve(JSON.parse(httpRequest.responseText));
}
else {
httpRequest.onerror();
}
};
httpRequest.send();
});
}
// Collects browser details parameters
collectBrowserDetails() {
const params = {};
/* Screen height and width */
params['v-sh'] = $wnd.screen.height;
params['v-sw'] = $wnd.screen.width;
/* Browser window dimensions */
params['v-wh'] = $wnd.innerHeight;
params['v-ww'] = $wnd.innerWidth;
/* Body element dimensions */
params['v-bh'] = $wnd.document.body.clientHeight;
params['v-bw'] = $wnd.document.body.clientWidth;
/* Current time */
const date = new Date();
params['v-curdate'] = date.getTime();
/* Current timezone offset (including DST shift) */
const tzo1 = date.getTimezoneOffset();
/* Compare the current tz offset with the first offset from the end
of the year that differs --- if less that, we are in DST, otherwise
we are in normal time */
let dstDiff = 0;
let rawTzo = tzo1;
for (let m = 12; m > 0; m -= 1) {
date.setUTCMonth(m);
const tzo2 = date.getTimezoneOffset();
if (tzo1 !== tzo2) {
dstDiff = tzo1 > tzo2 ? tzo1 - tzo2 : tzo2 - tzo1;
rawTzo = tzo1 > tzo2 ? tzo1 : tzo2;
break;
}
}
/* Time zone offset */
params['v-tzo'] = tzo1;
/* DST difference */
params['v-dstd'] = dstDiff;
/* Time zone offset without DST */
params['v-rtzo'] = rawTzo;
/* DST in effect? */
params['v-dston'] = tzo1 !== rawTzo;
/* Time zone id (if available) */
try {
params['v-tzid'] = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
catch (err) {
params['v-tzid'] = '';
}
/* Window name */
if ($wnd.name) {
params['v-wn'] = $wnd.name;
}
/* Detect touch device support */
let supportsTouch = false;
try {
$wnd.document.createEvent('TouchEvent');
supportsTouch = true;
}
catch (e) {
/* Chrome and IE10 touch detection */
supportsTouch = 'ontouchstart' in $wnd || typeof $wnd.navigator.msMaxTouchPoints !== 'undefined';
}
params['v-td'] = supportsTouch;
/* Device Pixel Ratio */
params['v-pr'] = $wnd.devicePixelRatio;
if ($wnd.navigator.platform) {
params['v-np'] = $wnd.navigator.platform;
}
/* Color scheme from CSS color-scheme property */
const colorScheme = getComputedStyle(document.documentElement).colorScheme.trim();
// "normal" is the default value and means no color scheme is set
params['v-cs'] = colorScheme && colorScheme !== 'normal' ? colorScheme : '';
/* Theme name - detect which theme is in use */
const computedStyle = getComputedStyle(document.documentElement);
let themeName = '';
if (computedStyle.getPropertyValue('--vaadin-lumo-theme').trim()) {
themeName = 'lumo';
}
else if (computedStyle.getPropertyValue('--vaadin-aura-theme').trim()) {
themeName = 'aura';
}
params['v-tn'] = themeName;
/* Stringify each value (they are parsed on the server side) */
const stringParams = {};
Object.keys(params).forEach((key) => {
const value = params[key];
if (typeof value !== 'undefined') {
stringParams[key] = value.toString();
}
});
return stringParams;
}
// Create shared connection state store and connection indicator
addConnectionIndicator() {
// add connection indicator to DOM
ConnectionIndicator.create();
// Listen to browser online/offline events and update the loading indicator accordingly.
// Note: if flow-client is loaded, it instead handles the state transitions.
$wnd.addEventListener('online', () => {
if (!this.isFlowClientLoaded()) {
// Send an HTTP HEAD request for sw.js to verify server reachability.
// We do not expect sw.js to be cached, so the request goes to the
// server rather than being served from local cache.
// Require network-level failure to revert the state to CONNECTION_LOST
// (HTTP error code is ok since it still verifies server's presence).
$wnd.Vaadin.connectionState.state = ConnectionState.RECONNECTING;
const http = new XMLHttpRequest();
http.open('HEAD', 'sw.js');
http.onload = () => {
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTED;
};
http.onerror = () => {
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTION_LOST;
};
// Postpone request to reduce potential net::ERR_INTERNET_DISCONNECTED
// errors that sometimes occurs even if browser says it is online
setTimeout(() => http.send(), 50);
}
});
$wnd.addEventListener('offline', () => {
if (!this.isFlowClientLoaded()) {
$wnd.Vaadin.connectionState.state = ConnectionState.CONNECTION_LOST;
}
});
}
async offlineStubAction() {
const offlineStub = document.createElement('iframe');
const offlineStubPath = './offline-stub.html';
offlineStub.setAttribute('src', offlineStubPath);
offlineStub.setAttribute('style', 'width: 100%; height: 100%; border: 0');
this.response = undefined;
let onlineListener;
const removeOfflineStubAndOnlineListener = () => {
if (onlineListener !== undefined) {
$wnd.Vaadin.connectionState.removeStateChangeListener(onlineListener);
onlineListener = undefined;
}
};
offlineStub.onBeforeEnter = (ctx, _cmds, router) => {
onlineListener = () => {
if ($wnd.Vaadin.connectionState.online) {
removeOfflineStubAndOnlineListener();
router.render(ctx, false);
}
};
$wnd.Vaadin.connectionState.addStateChangeListener(onlineListener);
};
offlineStub.onBeforeLeave = (_ctx, _cmds, _router) => {
removeOfflineStubAndOnlineListener();
};
return offlineStub;
}
isFlowClientLoaded() {
return this.response !== undefined;
}
}
//# sourceMappingURL=Flow.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export const init: (appInitResponse: any) => void;

View File

@@ -0,0 +1,201 @@
/* This is a copy of the regular `BootstrapHandler.js` in the flow-server
module, but with the following modifications:
- The main function is exported as an ES module for lazy initialization.
- Application configuration is passed as a parameter instead of using
replacement placeholders as in the regular bootstrapping.
- It reuses `Vaadin.Flow.clients` if exists.
- Fixed lint errors.
*/
const init = function (appInitResponse) {
window.Vaadin = window.Vaadin || {};
window.Vaadin.Flow = window.Vaadin.Flow || {};
var apps = {};
var widgetsets = {};
var log;
if (typeof window.console === undefined || !window.location.search.match(/[&?]debug(&|$)/)) {
/* If no console.log present, just use a no-op */
log = function () {};
} else if (typeof window.console.log === 'function') {
/* If it's a function, use it with apply */
log = function () {
window.console.log.apply(window.console, arguments);
};
} else {
/* In IE, its a native function for which apply is not defined, but it works
without a proper 'this' reference */
log = window.console.log;
}
var isInitializedInDom = function (appId) {
var appDiv = document.getElementById(appId);
if (!appDiv) {
return false;
}
for (var i = 0; i < appDiv.childElementCount; i++) {
var className = appDiv.childNodes[i].className;
/* If the app div contains a child with the class
'v-app-loading' we have only received the HTML
but not yet started the widget set
(UIConnector removes the v-app-loading div). */
if (className && className.indexOf('v-app-loading') != -1) {
return false;
}
}
return true;
};
/*
* Needed for Testbench compatibility, but prevents any Vaadin 7 app from
* bootstrapping unless the legacy vaadinBootstrap.js file is loaded before
* this script.
*/
window.Vaadin = window.Vaadin || {};
window.Vaadin.Flow = window.Vaadin.Flow || {};
/*
* Needed for wrapping custom javascript functionality in the components (i.e. connectors)
*/
window.Vaadin.Flow.tryCatchWrapper = function (originalFunction, component) {
return function () {
try {
// eslint-disable-next-line
const result = originalFunction.apply(this, arguments);
return result;
} catch (error) {
console.error(
`There seems to be an error in ${component}:
${error.message}
Please submit an issue to https://github.com/vaadin/flow-components/issues/new/choose`
);
}
};
};
if (!window.Vaadin.Flow.initApplication) {
window.Vaadin.Flow.clients = window.Vaadin.Flow.clients || {};
window.Vaadin.Flow.initApplication = function (appId, config) {
var testbenchId = appId.replace(/-\d+$/, '');
if (apps[appId]) {
if (
window.Vaadin &&
window.Vaadin.Flow &&
window.Vaadin.Flow.clients &&
window.Vaadin.Flow.clients[testbenchId] &&
window.Vaadin.Flow.clients[testbenchId].initializing
) {
throw new Error('Application ' + appId + ' is already being initialized');
}
if (isInitializedInDom(appId)) {
if (appInitResponse.appConfig.productionMode) {
throw new Error('Application ' + appId + ' already initialized');
}
// Remove old contents for Flow
var appDiv = document.getElementById(appId);
for (var i = 0; i < appDiv.childElementCount; i++) {
appDiv.childNodes[i].remove();
}
// For devMode reset app config and restart widgetset as client
// is up and running after hrm update.
const getConfig = function (name) {
return config[name];
};
/* Export public data */
const app = {
getConfig: getConfig
};
apps[appId] = app;
if (widgetsets['client'].callback) {
log('Starting from bootstrap', appId);
widgetsets['client'].callback(appId);
} else {
log('Setting pending startup', appId);
widgetsets['client'].pendingApps.push(appId);
}
return apps[appId];
}
}
log('init application', appId, config);
window.Vaadin.Flow.clients[testbenchId] = {
isActive: function () {
return true;
},
initializing: true,
productionMode: mode
};
var getConfig = function (name) {
var value = config[name];
return value;
};
/* Export public data */
var app = {
getConfig: getConfig
};
apps[appId] = app;
var widgetset = 'client';
widgetsets[widgetset] = {
pendingApps: []
};
if (widgetsets[widgetset].callback) {
log('Starting from bootstrap', appId);
widgetsets[widgetset].callback(appId);
} else {
log('Setting pending startup', appId);
widgetsets[widgetset].pendingApps.push(appId);
}
return app;
};
window.Vaadin.Flow.getAppIds = function () {
var ids = [];
for (var id in apps) {
if (Object.prototype.hasOwnProperty.call(apps, id)) {
ids.push(id);
}
}
return ids;
};
window.Vaadin.Flow.getApp = function (appId) {
return apps[appId];
};
window.Vaadin.Flow.registerWidgetset = function (widgetset, callback) {
log('Widgetset registered', widgetset);
var ws = widgetsets[widgetset];
if (ws && ws.pendingApps) {
ws.callback = callback;
for (var i = 0; i < ws.pendingApps.length; i++) {
var appId = ws.pendingApps[i];
log('Starting from register widgetset', appId);
callback(appId);
}
ws.pendingApps = null;
}
};
}
log('Flow bootstrap loaded');
if (appInitResponse.appConfig.productionMode && typeof window.__gwtStatsEvent != 'function') {
window.Vaadin.Flow.gwtStatsEvents = [];
window.__gwtStatsEvent = function (event) {
window.Vaadin.Flow.gwtStatsEvents.push(event);
return true;
};
}
var config = appInitResponse.appConfig;
var mode = appInitResponse.appConfig.productionMode;
window.Vaadin.Flow.initApplication(config.appId, config);
};
export { init };

View File

@@ -0,0 +1 @@
export const init: () => void;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
import { Outlet } from 'react-router';
import { ReactAdapterElement } from "Frontend/generated/flow/ReactAdapter.js";
import React from "react";
class ReactRouterOutletElement extends ReactAdapterElement {
public async connectedCallback() {
await super.connectedCallback();
this.style.display = 'contents';
}
protected render(): React.ReactElement | null {
return <Outlet />;
}
}
customElements.define('react-router-outlet', ReactRouterOutletElement);

View File

@@ -0,0 +1,284 @@
import { Debouncer } from '@vaadin/component-base/src/debounce.js';
import { timeOut } from '@vaadin/component-base/src/async.js';
import { ComboBoxPlaceholder } from '@vaadin/combo-box/src/vaadin-combo-box-placeholder.js';
window.Vaadin.Flow.comboBoxConnector = {};
window.Vaadin.Flow.comboBoxConnector.initLazy = (comboBox) => {
// Check whether the connector was already initialized for the ComboBox
if (comboBox.$connector) {
return;
}
comboBox.$connector = {};
// holds pageIndex -> callback pairs of subsequent indexes (current active range)
const pageCallbacks = {};
let cache = {};
let lastFilter = '';
const placeHolder = new window.Vaadin.ComboBoxPlaceholder();
const serverFacade = (() => {
// Private variables
let lastFilterSentToServer = '';
let dataCommunicatorResetNeeded = false;
// Public methods
const needsDataCommunicatorReset = () => (dataCommunicatorResetNeeded = true);
const getLastFilterSentToServer = () => lastFilterSentToServer;
const requestData = (startIndex, endIndex, params) => {
const count = endIndex - startIndex;
const filter = params.filter;
comboBox.$server.setViewportRange(startIndex, count, filter);
lastFilterSentToServer = filter;
if (dataCommunicatorResetNeeded) {
comboBox.$server.resetDataCommunicator();
dataCommunicatorResetNeeded = false;
}
};
return {
needsDataCommunicatorReset,
getLastFilterSentToServer,
requestData
};
})();
const clearPageCallbacks = (pages = Object.keys(pageCallbacks)) => {
// Flush and empty the existing requests
pages.forEach((page) => {
pageCallbacks[page]([], comboBox.size);
delete pageCallbacks[page];
// Empty the comboBox's internal cache without invoking observers by filling
// the filteredItems array with placeholders (comboBox will request for data when it
// encounters a placeholder)
const pageStart = parseInt(page) * comboBox.pageSize;
const pageEnd = pageStart + comboBox.pageSize;
const end = Math.min(pageEnd, comboBox.filteredItems.length);
for (let i = pageStart; i < end; i++) {
comboBox.filteredItems[i] = placeHolder;
}
});
};
comboBox.dataProvider = function (params, callback) {
if (params.pageSize != comboBox.pageSize) {
throw 'Invalid pageSize';
}
if (comboBox._clientSideFilter) {
// For clientside filter we first make sure we have all data which we also
// filter based on comboBox.filter. While later we only filter clientside data.
if (cache[0]) {
performClientSideFilter(cache[0], params.filter, callback);
return;
} else {
// If client side filter is enabled then we need to first ask all data
// and filter it on client side, otherwise next time when user will
// input another filter, eg. continue to type, the local cache will be only
// what was received for the first filter, which may not be the whole
// data from server (keep in mind that client side filter is enabled only
// when the items count does not exceed one page).
params.filter = '';
}
}
const filterChanged = params.filter !== lastFilter;
if (filterChanged) {
cache = {};
lastFilter = params.filter;
comboBox._filterDebouncer = Debouncer.debounce(comboBox._filterDebouncer, timeOut.after(500), () => {
if (serverFacade.getLastFilterSentToServer() === params.filter) {
// Fixes the case when the filter changes
// to something else and back to the original value
// within debounce timeout, and the
// DataCommunicator thinks it doesn't need to send data
serverFacade.needsDataCommunicatorReset();
}
if (params.filter !== lastFilter) {
throw new Error("Expected params.filter to be '" + lastFilter + "' but was '" + params.filter + "'");
}
// Remove the debouncer before clearing page callbacks.
// This makes sure that they are executed.
comboBox._filterDebouncer = undefined;
// Call the method again after debounce.
clearPageCallbacks();
comboBox.dataProvider(params, callback);
});
return;
}
// Postpone the execution of new callbacks if there is an active debouncer.
// They will be executed when the page callbacks are cleared within the debouncer.
if (comboBox._filterDebouncer) {
pageCallbacks[params.page] = callback;
return;
}
if (cache[params.page]) {
// This may happen after skipping pages by scrolling fast
commitPage(params.page, callback);
} else {
pageCallbacks[params.page] = callback;
const maxRangeCount = Math.max(params.pageSize * 2, 500); // Max item count in active range
const activePages = Object.keys(pageCallbacks).map((page) => parseInt(page));
const rangeMin = Math.min(...activePages);
const rangeMax = Math.max(...activePages);
if (activePages.length * params.pageSize > maxRangeCount) {
if (params.page === rangeMin) {
clearPageCallbacks([String(rangeMax)]);
} else {
clearPageCallbacks([String(rangeMin)]);
}
comboBox.dataProvider(params, callback);
} else if (rangeMax - rangeMin + 1 !== activePages.length) {
// Wasn't a sequential page index, clear the cache so combo-box will request for new pages
clearPageCallbacks();
} else {
// The requested page was sequential, extend the requested range
const startIndex = params.pageSize * rangeMin;
const endIndex = params.pageSize * (rangeMax + 1);
serverFacade.requestData(startIndex, endIndex, params);
}
}
};
comboBox.$connector.clear = (start, length) => {
const firstPageToClear = Math.floor(start / comboBox.pageSize);
const numberOfPagesToClear = Math.ceil(length / comboBox.pageSize);
for (let i = firstPageToClear; i < firstPageToClear + numberOfPagesToClear; i++) {
delete cache[i];
}
};
comboBox.$connector.filter = (item, filter) => {
filter = filter ? filter.toString().toLowerCase() : '';
return comboBox._getItemLabel(item, comboBox.itemLabelPath).toString().toLowerCase().indexOf(filter) > -1;
};
comboBox.$connector.set = (index, items, filter) => {
if (filter != serverFacade.getLastFilterSentToServer()) {
return;
}
if (index % comboBox.pageSize != 0) {
throw 'Got new data to index ' + index + ' which is not aligned with the page size of ' + comboBox.pageSize;
}
if (index === 0 && items.length === 0 && pageCallbacks[0]) {
// Makes sure that the dataProvider callback is called even when server
// returns empty data set (no items match the filter).
cache[0] = [];
return;
}
const firstPageToSet = index / comboBox.pageSize;
const updatedPageCount = Math.ceil(items.length / comboBox.pageSize);
for (let i = 0; i < updatedPageCount; i++) {
let page = firstPageToSet + i;
let slice = items.slice(i * comboBox.pageSize, (i + 1) * comboBox.pageSize);
cache[page] = slice;
}
};
comboBox.$connector.updateData = (items) => {
const itemsMap = new Map(items.map((item) => [item.key, item]));
comboBox.filteredItems = comboBox.filteredItems.map((item) => {
return itemsMap.get(item.key) || item;
});
};
comboBox.$connector.updateSize = function (newSize) {
if (!comboBox._clientSideFilter) {
// FIXME: It may be that this size set is unnecessary, since when
// providing data to combobox via callback we may use data's size.
// However, if this size reflect the whole data size, including
// data not fetched yet into client side, and combobox expect it
// to be set as such, the at least, we don't need it in case the
// filter is clientSide only, since it'll increase the height of
// the popup at only at first user filter to this size, while the
// filtered items count are less.
comboBox.size = newSize;
}
};
comboBox.$connector.reset = function () {
// Cancel pending requests, as clearCache below will set the combo
// in a state where it will always request new data, regardless
// what is in the cache already.
if (comboBox._filterDebouncer) {
comboBox._filterDebouncer.cancel();
comboBox._filterDebouncer = undefined;
}
clearPageCallbacks();
cache = {};
comboBox.clearCache();
};
comboBox.$connector.confirm = function (id, filter) {
if (filter != serverFacade.getLastFilterSentToServer()) {
return;
}
// We're done applying changes from this batch, resolve pending
// callbacks
let activePages = Object.getOwnPropertyNames(pageCallbacks);
for (let i = 0; i < activePages.length; i++) {
let page = activePages[i];
if (cache[page]) {
commitPage(page, pageCallbacks[page]);
}
}
// Let server know we're done
comboBox.$server.confirmUpdate(id);
};
const commitPage = function (page, callback) {
let data = cache[page];
if (comboBox._clientSideFilter) {
performClientSideFilter(data, comboBox.filter, callback);
} else {
// Remove the data if server-side filtering, but keep it for client-side
// filtering
delete cache[page];
// FIXME: It may be that we ought to provide data.length instead of
// comboBox.size and remove updateSize function.
callback(data, comboBox.size);
}
};
// Perform filter on client side (here) using the items from specified page
// and submitting the filtered items to specified callback.
// The filter used is the one from combobox, not the lastFilter stored since
// that may not reflect user's input.
const performClientSideFilter = function (page, filter, callback) {
let filteredItems = page;
if (filter) {
filteredItems = page.filter((item) => comboBox.$connector.filter(item, filter));
}
callback(filteredItems, filteredItems.length);
};
// Prevent setting the custom value as the 'value'-prop automatically
comboBox.addEventListener('custom-value-set', (e) => e.preventDefault());
comboBox.itemClassNameGenerator = function (item) {
return item.className || '';
};
};
window.Vaadin.ComboBoxPlaceholder = ComboBoxPlaceholder;

View File

@@ -0,0 +1,122 @@
function getContainer(appId, nodeId) {
try {
return window.Vaadin.Flow.clients[appId].getByNodeId(nodeId);
} catch (error) {
console.error('Could not get node %s from app %s', nodeId, appId);
console.error(error);
}
}
/**
* Initializes the connector for a context menu element.
*
* @param {HTMLElement} contextMenu
* @param {string} appId
*/
function initLazy(contextMenu, appId) {
if (contextMenu.$connector) {
return;
}
contextMenu.$connector = {
/**
* Generates and assigns the items to the context menu.
*
* @param {number} nodeId
*/
generateItems(nodeId) {
const items = generateItemsTree(appId, nodeId);
contextMenu.items = items;
}
};
}
/**
* Generates an items tree compatible with the context-menu web component
* by traversing the given Flow DOM tree of context menu item nodes
* whose root node is identified by the `nodeId` argument.
*
* The app id is required to access the store of Flow DOM nodes.
*
* @param {string} appId
* @param {number} nodeId
*/
function generateItemsTree(appId, nodeId) {
const container = getContainer(appId, nodeId);
if (!container) {
return;
}
return Array.from(container.children).map((child) => {
const item = {
component: child,
checked: child._checked,
keepOpen: child._keepOpen,
className: child.className,
theme: child.__theme
};
// Do not hardcode tag name to allow `vaadin-menu-bar-item`
if (child._hasVaadinItemMixin && child._containerNodeId) {
item.children = generateItemsTree(appId, child._containerNodeId);
}
child._item = item;
return item;
});
}
/**
* Sets the checked state for a context menu item.
*
* This method is supposed to be called when the context menu item is closed,
* so there is no need for triggering a re-render eagarly.
*
* @param {HTMLElement} component
* @param {boolean} checked
*/
function setChecked(component, checked) {
if (component._item) {
component._item.checked = checked;
// Set the attribute in the connector to show the checkmark
// without having to re-render the whole menu while opened.
if (component._item.keepOpen) {
component.toggleAttribute('menu-item-checked', checked);
}
}
}
/**
* Sets the keep open state for a context menu item.
*
* @param {HTMLElement} component
* @param {boolean} keepOpen
*/
function setKeepOpen(component, keepOpen) {
if (component._item) {
component._item.keepOpen = keepOpen;
}
}
/**
* Sets the theme for a context menu item.
*
* This method is supposed to be called when the context menu item is closed,
* so there is no need for triggering a re-render eagarly.
*
* @param {HTMLElement} component
* @param {string | undefined | null} theme
*/
function setTheme(component, theme) {
if (component._item) {
component._item.theme = theme;
}
}
window.Vaadin.Flow.contextMenuConnector = {
initLazy,
generateItemsTree,
setChecked,
setKeepOpen,
setTheme
};

View File

@@ -0,0 +1,62 @@
import * as Gestures from '@vaadin/component-base/src/gestures.js';
function init(target) {
if (target.$contextMenuTargetConnector) {
return;
}
target.$contextMenuTargetConnector = {
openOnHandler(e) {
// used by Grid to prevent context menu on selection column click
if (target.preventContextMenu && target.preventContextMenu(e)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.$contextMenuTargetConnector.openEvent = e;
let detail = {};
if (target.getContextMenuBeforeOpenDetail) {
detail = target.getContextMenuBeforeOpenDetail(e);
}
target.dispatchEvent(
new CustomEvent('vaadin-context-menu-before-open', {
detail: detail
})
);
},
updateOpenOn(eventType) {
this.removeListener();
this.openOnEventType = eventType;
customElements.whenDefined('vaadin-context-menu').then(() => {
if (Gestures.gestures[eventType]) {
Gestures.addListener(target, eventType, this.openOnHandler);
} else {
target.addEventListener(eventType, this.openOnHandler);
}
});
},
removeListener() {
if (this.openOnEventType) {
if (Gestures.gestures[this.openOnEventType]) {
Gestures.removeListener(target, this.openOnEventType, this.openOnHandler);
} else {
target.removeEventListener(this.openOnEventType, this.openOnHandler);
}
}
},
openMenu(contextMenu) {
contextMenu.open(this.openEvent);
},
removeConnector() {
this.removeListener();
target.$contextMenuTargetConnector = undefined;
}
};
}
window.Vaadin.Flow.contextMenuTargetConnector = { init };

View File

@@ -0,0 +1 @@
// Full cdn version: 25.0.7-undefined

View File

@@ -0,0 +1,3 @@
export { registerImporter, createChildrenDefinitions } from './copilot/figma-public/figma-api';
export type { FigmaNode, ImportMetadata } from './copilot/figma-public/figma-api';
export type { ComponentDefinition, ComponentDefinitionProperties } from './copilot/shared/flow-utils';

View File

@@ -0,0 +1,5 @@
import { aB as a, aA as i } from "./copilot/copilot-BvIxHaRg.js";
export {
a as createChildrenDefinitions,
i as registerImporter
};

View File

@@ -0,0 +1,36 @@
import { M as t, w as n, j as a, ay as i, b as o } from "./copilot-BvIxHaRg.js";
class l extends t {
constructor() {
super(...arguments), this.eventBusRemovers = [], this.messageHandlers = {}, this.handleESC = (e) => {
const s = n.getPanelByTag(this.tagName);
!a.active && s && !s.individual || e.key === "Escape" && i(this);
};
}
createRenderRoot() {
return this;
}
onEventBus(e, s) {
this.eventBusRemovers.push(o.on(e, s));
}
connectedCallback() {
super.connectedCallback(), this.addESCListener();
}
disconnectedCallback() {
super.disconnectedCallback(), this.eventBusRemovers.forEach((e) => e()), this.removeESCListener();
}
addESCListener() {
document.addEventListener("keydown", this.handleESC);
}
removeESCListener() {
document.removeEventListener("keydown", this.handleESC);
}
onCommand(e, s) {
this.messageHandlers[e] = s;
}
handleMessage(e) {
return this.messageHandlers[e.command] ? (this.messageHandlers[e.command].call(this, e), !0) : !1;
}
}
export {
l as B
};

Some files were not shown because too many files have changed in this diff Show More