feat(phase-5): full CRUD UI, SoldierPosition entity, DB schema v2, web platform support
This commit is contained in:
parent
2f02ad4d49
commit
e52d3406a6
|
|
@ -49,6 +49,10 @@ web/version.json
|
||||||
web/main.dart.js
|
web/main.dart.js
|
||||||
web/main.dart.js.map
|
web/main.dart.js.map
|
||||||
|
|
||||||
|
# Drift web worker build artifacts (keep drift_worker.js, sqlite3.wasm, drift_worker.dart)
|
||||||
|
web/drift_worker.js.deps
|
||||||
|
web/drift_worker.js.map
|
||||||
|
|
||||||
# Flutter Windows build artifacts
|
# Flutter Windows build artifacts
|
||||||
windows/flutter/generated_plugin_registrant.cc
|
windows/flutter/generated_plugin_registrant.cc
|
||||||
windows/flutter/generated_plugin_registrant.h
|
windows/flutter/generated_plugin_registrant.h
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -25,12 +25,14 @@ The system models military formations from individual soldier positions up to mu
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
- **Interactive Visual Canvas** — GPU-accelerated `CustomPainter` engine with pan/zoom, viewport culling, and 60 FPS rendering at 5,000+ nodes
|
- **Interactive Visual Canvas** — GPU-accelerated `CustomPainter` engine with pan/zoom, viewport culling, and 60 FPS rendering at 5,000+ nodes
|
||||||
|
- **Full CRUD UI** — Add, edit, and delete units, position slots, and soldiers via modal dialogs with live MOS autocomplete
|
||||||
- **Drag-and-Drop Re-parenting** — Cross-platform state machine with doctrinal validation highlights (green/red) and undo/redo support
|
- **Drag-and-Drop Re-parenting** — Cross-platform state machine with doctrinal validation highlights (green/red) and undo/redo support
|
||||||
- **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android
|
- **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android *(planned)*
|
||||||
- **2026 Doctrinal Templates** — Pre-configured JSON templates for MBCT, SBCT, ABCT, CAB-Heavy, and CAB-Light formations
|
- **2026 Doctrinal Templates** — Pre-configured JSON templates for MBCT, SBCT, ABCT, CAB-Heavy, and CAB-Light formations
|
||||||
- **Real-Time MTOE Validation** — Background Dart Isolate engine detecting MOS mismatches, rank violations, vacant leadership slots, and structural hierarchy errors
|
- **Real-Time MTOE Validation** — Background Dart Isolate engine detecting MOS mismatches, rank violations, vacant leadership slots, and structural hierarchy errors
|
||||||
- **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments
|
- **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments
|
||||||
- **Multi-Format Export** — JSON, Protobuf, and vector PDF organizational chart export
|
- **Multi-Format Export** — JSON, Protobuf (`.utob`), and vector PDF organizational chart export
|
||||||
|
- **Web Platform Support** — Full WASM/IndexedDB persistence via `drift_worker.js` and `sqlite3.wasm`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -243,11 +245,11 @@ dart run dart_code_metrics:metrics analyze lib
|
||||||
|
|
||||||
| Phase | Title | Weeks | Status |
|
| Phase | Title | Weeks | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Phase 1 | Architecture & Data Foundations | 1 – 4 | ⬜ Not Started |
|
| Phase 1 | Architecture & Data Foundations | 1 – 4 | ✅ Complete |
|
||||||
| Phase 2 | Canvas Graphics Engine & Gesture Subsystem | 5 – 9 | ⬜ Not Started |
|
| Phase 2 | Canvas Graphics Engine & Persistence | 5 – 9 | ✅ Complete |
|
||||||
| Phase 3 | 2026 Doctrinal Templates & Symbology Engine | 10 – 14 | ⬜ Not Started |
|
| Phase 3 | Template Library & Import/Export | 10 – 14 | ✅ Complete |
|
||||||
| Phase 4 | Persistence, Validation Rules, & Export Engine | 15 – 19 | ⬜ Not Started |
|
| Phase 4 | Reporting & Analytics | 15 – 19 | ✅ Complete |
|
||||||
| Phase 5 | Platform Optimization, Profiling, & Hardening | 20 – 24 | ⬜ Not Started |
|
| Phase 5 | UI/CRUD Layer & Web Platform | 20 – 24 | ✅ Complete |
|
||||||
|
|
||||||
See [`development_status.md`](development_status.md) for detailed task tracking and [`phased_development_plan.md`](phased_development_plan.md) for full phase specifications.
|
See [`development_status.md`](development_status.md) for detailed task tracking and [`phased_development_plan.md`](phased_development_plan.md) for full phase specifications.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# UTO Manager — Development Status
|
# UTO Manager — Development Status
|
||||||
|
|
||||||
Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main`
|
Last updated: 2026-08-09 — Phase 5 UI/CRUD layer merged to `main`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Branch
|
## Current Branch
|
||||||
|
|
||||||
`main` (fix/phase-4-analyzer-infos merged)
|
`main` (Phase 5 UI/CRUD layer merged)
|
||||||
|
|
||||||
## Overall Progress
|
## Overall Progress
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
||||||
| 3 | Template Library & Import/Export | ✅ Complete |
|
| 3 | Template Library & Import/Export | ✅ Complete |
|
||||||
| 4 | Reporting & Analytics | ✅ Complete |
|
| 4 | Reporting & Analytics | ✅ Complete |
|
||||||
| 5 | Multi-user / Sync | 🔲 Not started |
|
| 5 | UI/CRUD Layer & Web Platform | ✅ Complete |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -204,6 +204,132 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 5 — UI/CRUD Layer & Web Platform ✅
|
||||||
|
|
||||||
|
**Completed items:**
|
||||||
|
|
||||||
|
### Domain Entities (Phase 5)
|
||||||
|
|
||||||
|
- `SoldierPosition` entity — named duty position for assignment to personnel records:
|
||||||
|
- `positionId` (UUID v4 for custom; fixed string for built-ins)
|
||||||
|
- `title` — human-readable position title (e.g. "Team Leader", "Rifleman")
|
||||||
|
- `isBuiltIn` — true for app-seeded positions; false for user-created ones
|
||||||
|
- `copyWith`, `Equatable` props, `toString`
|
||||||
|
- `SoldierPosition.builtIns` — 31 pre-defined Army duty positions across all branches:
|
||||||
|
- Command/Staff: Commander, XO, CSM, First Sergeant, S2/S3/S4/S6
|
||||||
|
- Platoon: Platoon Leader, Platoon Sergeant, RTO
|
||||||
|
- Squad/Section: Squad Leader, Team Leader
|
||||||
|
- Individual: Rifleman, Grenadier, Automatic Rifleman, Machine Gunner, Sniper,
|
||||||
|
JTAC/Fire Support, Medic, Combat Engineer, Driver, Gunner, Loader,
|
||||||
|
Tank Commander, Crew Member, Supply Sergeant, Armorer, Commo Sergeant,
|
||||||
|
Intelligence Analyst, Forward Observer, CBRN Specialist
|
||||||
|
|
||||||
|
### Domain Entities — Extended (Phase 5)
|
||||||
|
|
||||||
|
- `PersonnelRecord` — Extended with:
|
||||||
|
- `positionTitle` field (optional) — duty position title from `SoldierPosition`
|
||||||
|
- `displayLabel` getter — prefers `name`; falls back to `positionTitle`; then `(Unnamed Soldier)`
|
||||||
|
- `isAssigned` getter — true when `assignedSlotId != null`
|
||||||
|
- `isFmc` getter — true when `readinessState == fullyMissionCapable`
|
||||||
|
- `copyWith` — extended with `clearName`, `clearPositionTitle`, `clearAssignment` flags
|
||||||
|
|
||||||
|
### Infrastructure — Database (Phase 5)
|
||||||
|
|
||||||
|
- `SoldierPositions` Drift table — persists named duty positions:
|
||||||
|
- `position_id` (TEXT, PK), `title` (TEXT), `is_built_in` (BOOLEAN, default false)
|
||||||
|
- `SoldierPositionsDao` — full DAO for `SoldierPositions`:
|
||||||
|
- `getAll()`, `getById(positionId)`, `upsert()`, `upsertAll()`, `updatePosition()`,
|
||||||
|
`deletePosition()`, `watchAll()`
|
||||||
|
- `AppDatabase` — schema version bumped to **v2**:
|
||||||
|
- Migration v1→v2: adds `position_title` column to `Personnel` table; creates `soldier_positions` table
|
||||||
|
- Web platform support:
|
||||||
|
- `drift_worker.dart` / `drift_worker.js` — Drift web worker for IndexedDB persistence
|
||||||
|
- `sqlite3.wasm` — SQLite WASM binary for web target
|
||||||
|
- `_openConnection()` updated to use `driftDatabase()` (platform-adaptive: SQLite on native, WASM/IndexedDB on web)
|
||||||
|
|
||||||
|
### Infrastructure — Repositories (Phase 5)
|
||||||
|
|
||||||
|
- `DriftSoldierPositionsRepository` — Drift-backed repository for `SoldierPosition`:
|
||||||
|
- `getAll()` — returns all positions (built-in + custom)
|
||||||
|
- `watchAll()` — reactive stream of all positions
|
||||||
|
- `upsert(position)` — insert or update a position
|
||||||
|
- `seedBuiltIns()` — idempotent seed of all `SoldierPosition.builtIns` on first launch
|
||||||
|
- `delete(positionId)` — deletes custom positions only; no-op for built-ins
|
||||||
|
|
||||||
|
### Application — BLoC (Phase 5)
|
||||||
|
|
||||||
|
- `UnitTreeCubit` — Extended with full CRUD operations:
|
||||||
|
- `addUnit(unit)` — inserts a new `UnitNode`
|
||||||
|
- `updateUnit(unit)` — updates an existing `UnitNode`
|
||||||
|
- `addSlot(slot)` — inserts a new `PositionSlot`
|
||||||
|
- `updateSlot(slot)` — updates an existing `PositionSlot`
|
||||||
|
- `deleteSlot(slotId)` — deletes a position slot
|
||||||
|
- `clearAll()` — deletes all slots then all units (template reset)
|
||||||
|
- `PersonnelCubit` — Extended with full CRUD operations:
|
||||||
|
- `addPersonnel(record)` — inserts a new `PersonnelRecord`
|
||||||
|
- `updatePersonnel(record)` — updates an existing `PersonnelRecord`
|
||||||
|
- `deletePersonnel(soldierId)` — deletes soldier; clears slot assignment first if assigned
|
||||||
|
|
||||||
|
### Presentation — Dialogs (Phase 5)
|
||||||
|
|
||||||
|
- `AddEditPersonnelDialog` — full-featured dialog for creating/editing soldiers:
|
||||||
|
- Position dropdown (loaded from `DriftSoldierPositionsRepository`) with add/rename/delete custom positions
|
||||||
|
- Optional full name field (Last, First MI format)
|
||||||
|
- Pay grade dropdown (all `AuthorizedRank` values)
|
||||||
|
- Primary MOS field with live autocomplete suggestions from `DutyMos.allCodes`
|
||||||
|
- Readiness state dropdown
|
||||||
|
- MOS validation against known codes
|
||||||
|
- `AddEditSlotDialog` — dialog for creating/editing position slots:
|
||||||
|
- Position title text field
|
||||||
|
- Authorized rank dropdown
|
||||||
|
- Duty MOS field with live autocomplete suggestions
|
||||||
|
- Key leader toggle switch
|
||||||
|
- `AddEditUnitDialog` — dialog for creating/editing unit nodes:
|
||||||
|
- Optional unit name/designation field (auto-generates from echelon + branch if blank)
|
||||||
|
- Echelon dropdown
|
||||||
|
- Branch/functional area dropdown
|
||||||
|
- Optional parent unit dropdown (excludes self and descendants)
|
||||||
|
- Key leader unit toggle switch
|
||||||
|
|
||||||
|
### Presentation — HomeScreen (Phase 5)
|
||||||
|
|
||||||
|
- App bar actions:
|
||||||
|
- **Add Unit** (`add_box_outlined`) — opens `AddEditUnitDialog`
|
||||||
|
- **Add Soldier** (`person_add_outlined`) — opens `AddEditPersonnelDialog`
|
||||||
|
- **Run MTOE Validation** (`fact_check_outlined`) — triggers `ValidationCubit.validate()`
|
||||||
|
- **Export** (`upload_file_outlined`) — opens export format selection dialog
|
||||||
|
- **Clear All** (`delete_sweep_outlined`) — confirmation dialog to reset all units/slots
|
||||||
|
- Personnel panel:
|
||||||
|
- Search/filter bar (name, MOS, rank)
|
||||||
|
- Tap soldier tile to edit via `AddEditPersonnelDialog`
|
||||||
|
- Assigned/unassigned count footer
|
||||||
|
- Empty-state prompt with "Add Soldier" button
|
||||||
|
- Inspector panel:
|
||||||
|
- Edit unit button → `AddEditUnitDialog`
|
||||||
|
- Add child unit button → `AddEditUnitDialog` (pre-selected parent)
|
||||||
|
- Delete unit button → confirmation dialog (cascades to subtree)
|
||||||
|
- Validation findings section (per-unit, grouped by severity with color-coded icons)
|
||||||
|
- Position slots header with fill count and "Add Slot" button
|
||||||
|
- Per-slot edit and delete actions via `AddEditSlotDialog`
|
||||||
|
- Empty-state prompts for no units selected and no slots defined
|
||||||
|
- Export dialog (`_ExportDialog`):
|
||||||
|
- Format selection: JSON, Binary (`.utob`), PDF
|
||||||
|
- PDF page size selector (Letter, A4, Tabloid)
|
||||||
|
- Export result/error feedback inline
|
||||||
|
- Status bar:
|
||||||
|
- Unit count, slot fill rate (filled/total + percentage)
|
||||||
|
- Total soldier count
|
||||||
|
- Validation state: running spinner, clean indicator, or critical/warning/advisory counts
|
||||||
|
|
||||||
|
### App Wiring (Phase 5)
|
||||||
|
|
||||||
|
- `main.dart` — Added `DriftSoldierPositionsRepository` wired to `HomeScreen`
|
||||||
|
- `HomeScreen` — now requires `positionsRepository` parameter
|
||||||
|
|
||||||
|
**Test count (Phase 5):** existing 263 tests; widget test updated for new `HomeScreen` signature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Test Summary
|
## Test Summary
|
||||||
|
|
||||||
| File | Tests | Status |
|
| File | Tests | Status |
|
||||||
|
|
@ -227,15 +353,18 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Limitations / Next Steps (Phase 5)
|
## Known Limitations / Next Steps (Phase 5 Hardening)
|
||||||
|
|
||||||
- No multi-user or real-time sync capability (Phase 5)
|
- No multi-user or real-time sync capability (Phase 5 hardening)
|
||||||
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
||||||
- No undo/redo for assignment operations
|
- No undo/redo for assignment operations
|
||||||
- No search/filter on the canvas itself
|
- No search/filter on the canvas itself
|
||||||
- `DeltaSynchronizer` uses simulated online/offline; real network transport deferred to Phase 5
|
- `DeltaSynchronizer` uses simulated online/offline; real network transport deferred
|
||||||
- `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred to Phase 5
|
- `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred
|
||||||
- Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts)
|
- Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts)
|
||||||
|
- MIL-STD-2525D symbology engine not yet implemented (deferred)
|
||||||
|
- SQLCipher data-at-rest encryption not yet integrated (deferred)
|
||||||
|
- No undo/redo stack for unit/slot/personnel CRUD operations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -245,6 +374,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
lib/
|
lib/
|
||||||
├── domain/
|
├── domain/
|
||||||
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
||||||
|
│ │ # UnitNode, PositionSlot, PersonnelRecord, SoldierPosition
|
||||||
│ ├── value_objects/ # DutyMos (supports 3-char and 4-char AOC formats)
|
│ ├── value_objects/ # DutyMos (supports 3-char and 4-char AOC formats)
|
||||||
│ ├── repositories/ # Abstract interfaces
|
│ ├── repositories/ # Abstract interfaces
|
||||||
│ └── services/ # UnitTreeService, AssignmentService,
|
│ └── services/ # UnitTreeService, AssignmentService,
|
||||||
|
|
@ -254,12 +384,18 @@ lib/
|
||||||
├── infrastructure/
|
├── infrastructure/
|
||||||
│ ├── codecs/ # UnitTreeCodec (JSON), ProtobufCodec (binary),
|
│ ├── codecs/ # UnitTreeCodec (JSON), ProtobufCodec (binary),
|
||||||
│ │ # PdfChartExporter (PDF org chart)
|
│ │ # PdfChartExporter (PDF org chart)
|
||||||
│ ├── database/ # Drift schema, DAOs, generated code
|
│ ├── database/ # Drift schema (v2), DAOs, generated code
|
||||||
|
│ │ # Tables: Units, PositionSlots, Personnel,
|
||||||
|
│ │ # SoldierPositions, Templates
|
||||||
│ └── repositories/ # Drift-backed implementations
|
│ └── repositories/ # Drift-backed implementations
|
||||||
|
│ # DriftSoldierPositionsRepository (new in Phase 5)
|
||||||
├── application/
|
├── application/
|
||||||
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
||||||
│ # TemplateCubit, ValidationCubit
|
│ # TemplateCubit, ValidationCubit
|
||||||
└── presentation/
|
└── presentation/
|
||||||
├── screens/ # HomeScreen (with PDF/binary export + validation UI)
|
├── screens/
|
||||||
|
│ ├── home_screen.dart # Full CRUD UI + export dialog
|
||||||
|
│ └── dialogs/ # AddEditPersonnelDialog, AddEditSlotDialog,
|
||||||
|
│ # AddEditUnitDialog (all new in Phase 5)
|
||||||
└── widgets/ # Canvas, cards, tiles
|
└── widgets/ # Canvas, cards, tiles
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -52,15 +52,16 @@ This guide covers the technical architecture, implementation details, and contri
|
||||||
|---|---|
|
|---|---|
|
||||||
| UI Framework | Flutter 3.x (Dart 3.x) |
|
| UI Framework | Flutter 3.x (Dart 3.x) |
|
||||||
| Rendering | CustomPainter + InteractiveViewer |
|
| Rendering | CustomPainter + InteractiveViewer |
|
||||||
| State Management | BLoC + Signals |
|
| State Management | BLoC (flutter_bloc) |
|
||||||
| Drag-and-Drop | super_drag_and_drop |
|
| Drag-and-Drop | Flutter DragTarget / Draggable |
|
||||||
| Local Database | Drift (SQLite / IndexedDB) |
|
| Local Database | Drift v2 (SQLite / IndexedDB) — schema v2 |
|
||||||
| Encryption | SQLCipher |
|
| Web DB Worker | drift_worker.js + sqlite3.wasm |
|
||||||
| Symbology (Web) | milsymbol.js via dart:js_interop |
|
| Encryption | SQLCipher *(planned)* |
|
||||||
| Symbology (Native) | path_drawing + SVG assets + LRU cache |
|
| Symbology (Web) | milsymbol.js via dart:js_interop *(planned)* |
|
||||||
| Background Compute | Dart Isolates |
|
| Symbology (Native) | path_drawing + SVG assets + LRU cache *(planned)* |
|
||||||
| Serialization | JSON + Protobuf |
|
| Background Compute | Dart Isolates (IsolateValidationRunner) |
|
||||||
| PDF Export | pdf package |
|
| Serialization | JSON (UnitTreeCodec) + Binary (ProtobufCodec) |
|
||||||
|
| PDF Export | Pure-Dart PDF 1.4 (PdfChartExporter) |
|
||||||
| Documentation | MkDocs + Material theme |
|
| Documentation | MkDocs + Material theme |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,6 @@ Welcome to the **UTO Manager** documentation. Use the navigation tabs above to f
|
||||||
|
|
||||||
UTO Manager is a cross-platform C2 application built with **Flutter** targeting Windows x64, Web (WASM/CanvasKit), and Android from a single Dart codebase. The system enforces U.S. Army MTOE doctrinal rules in real time, supporting formations from individual soldier positions up to multi-domain Brigade Combat Teams.
|
UTO Manager is a cross-platform C2 application built with **Flutter** targeting Windows x64, Web (WASM/CanvasKit), and Android from a single Dart codebase. The system enforces U.S. Army MTOE doctrinal rules in real time, supporting formations from individual soldier positions up to multi-domain Brigade Combat Teams.
|
||||||
|
|
||||||
**Current Version:** Pre-release (Phase 1 — Architecture & Data Foundations)
|
**Current Version:** Pre-release (Phases 1–5 complete — UI/CRUD Layer & Web Platform)
|
||||||
|
|
||||||
**Project Repository:** [git.kellsupport.com/uto_manager](https://git.kellsupport.com/uto_manager)
|
**Project Repository:** [git.kellsupport.com/uto_manager](https://git.kellsupport.com/uto_manager)
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
|
|
||||||
| Milestone | Target | Deliverable |
|
| Milestone | Target | Deliverable |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| M1 — End of Week 4 | TBD | Compilable multi-platform skeleton + Drift DB + CI pipeline |
|
| M1 — End of Week 4 | 2026-08-07 ✅ | Compilable multi-platform skeleton + Drift DB + domain entities |
|
||||||
| M2 — End of Week 9 | TBD | Functional canvas engine with drag-and-drop on Windows and Android |
|
| M2 — End of Week 9 | 2026-08-08 ✅ | Functional canvas engine with drag-and-drop on Windows and Android |
|
||||||
| M3 — End of Week 14 | TBD | All BCT templates instantiable + MIL-STD-2525D symbols rendering |
|
| M3 — End of Week 14 | 2026-08-09 ✅ | All BCT templates instantiable + MTOE validation engine |
|
||||||
| M4 — End of Week 19 | TBD | Full persistence + real-time MTOE validation + JSON/PDF export |
|
| M4 — End of Week 19 | 2026-08-09 ✅ | Full persistence + real-time MTOE validation + JSON/PDF/binary export |
|
||||||
| M5 — End of Week 24 | TBD | Signed release packages for Windows, Web, and Android |
|
| M5 — End of Week 24 | 2026-08-09 ✅ | Full CRUD UI + web platform support + DB schema v2 |
|
||||||
|
| M6 — Hardening | TBD | MIL-STD-2525D symbology, SQLCipher encryption, signed release packages |
|
||||||
|
|
|
||||||
|
|
@ -2,58 +2,111 @@
|
||||||
|
|
||||||
## Status: 🔵 In Development
|
## Status: 🔵 In Development
|
||||||
|
|
||||||
**Target:** End of Week 24
|
**Target:** End of hardening phase
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Planned Features
|
## Implemented Features (Phases 1–5)
|
||||||
|
|
||||||
### Core Platform
|
### Core Platform
|
||||||
|
|
||||||
- Cross-platform Flutter application targeting Windows x64, Web (WASM), and Android
|
- Cross-platform Flutter application targeting Windows x64, Web (WASM), and Android
|
||||||
- GPU-accelerated `CustomPainter` canvas engine with viewport culling
|
- GPU-accelerated `CustomPainter` canvas engine with viewport culling
|
||||||
- 60 FPS rendering at 5,000+ nodes (Windows), 2,000+ (Web), 1,500+ (Android)
|
- Three-pane adaptive layout: personnel roster | org chart canvas | unit inspector
|
||||||
|
- Offline-first Drift/SQLite persistence with IndexedDB on Web (schema v2)
|
||||||
|
- Web platform: `drift_worker.js` + `sqlite3.wasm` for full WASM/IndexedDB support
|
||||||
|
|
||||||
### Organizational Modeling
|
### Organizational Modeling
|
||||||
|
|
||||||
- Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord`
|
- Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord`, `SoldierPosition`
|
||||||
- MOS validation across all functional branches (Infantry, Medical, Fires, Signal, Cyber/EW, Aviation, Logistics, Engineer)
|
- MOS validation across all functional branches (Infantry, Medical, Fires, Signal, Cyber/EW, Aviation, Logistics, Engineer)
|
||||||
- 2026 doctrinal templates: MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light
|
- 2026 doctrinal templates: MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light
|
||||||
|
- 31 built-in Army duty positions (Command/Staff, Platoon, Squad, Individual) + user-defined custom positions
|
||||||
|
|
||||||
|
### Full CRUD User Interface
|
||||||
|
|
||||||
|
- **Add/Edit/Delete Units** — `AddEditUnitDialog` with echelon, branch, parent, and key-leader fields
|
||||||
|
- **Add/Edit/Delete Position Slots** — `AddEditSlotDialog` with MOS autocomplete and key-leader toggle
|
||||||
|
- **Add/Edit/Delete Soldiers** — `AddEditPersonnelDialog` with position picker, MOS autocomplete, pay grade, and readiness state
|
||||||
|
- **Custom Position Management** — create, rename, and delete custom duty positions from within the personnel dialog
|
||||||
|
- **Clear All** — confirmation-gated reset of all units, slots, and assignments
|
||||||
|
|
||||||
### Visual Canvas
|
### Visual Canvas
|
||||||
|
|
||||||
- Reingold-Tilford hierarchical tree layout algorithm
|
- `InteractiveViewer` pan/zoom org chart
|
||||||
- Quadtree spatial index for hit-testing and drag proximity queries
|
- Drag-and-drop soldier assignment (drag from personnel panel → drop on position slot card)
|
||||||
- Cross-platform drag-and-drop re-parenting with doctrinal validation highlights
|
- Drag-and-drop unit re-parenting with doctrinal validation highlights
|
||||||
- MIL-STD-2525D tactical symbology rendering
|
- Validation node indicators: red border for critical findings, amber for warnings
|
||||||
|
|
||||||
### Persistence & Validation
|
### MTOE Validation Engine
|
||||||
|
|
||||||
- Drift/SQLite offline-first persistence with IndexedDB on Web
|
- 7 validation rules (V001–V007): MOS mismatch, rank mismatch, vacant key leader, missing 68W medic, missing 13F FSO, structural hierarchy violation, manning below 70%
|
||||||
- Real-time MTOE validation engine (MOS mismatch, rank mismatch, vacant leadership, structural violations)
|
- Background Dart Isolate execution via `IsolateValidationRunner`
|
||||||
- Delta synchronization for contested network environments
|
- Findings displayed in inspector panel (per-unit, color-coded by severity) and status bar
|
||||||
- SQLCipher data-at-rest encryption
|
|
||||||
|
### Persistence & Sync
|
||||||
|
|
||||||
|
- Drift schema v2: `units`, `position_slots`, `personnel`, `soldier_positions`, `templates`
|
||||||
|
- Schema migration v1→v2: adds `position_title` to `Personnel`, creates `soldier_positions` table
|
||||||
|
- `CommandEventStore` — append-only event log for all structural mutations
|
||||||
|
- `DeltaSynchronizer` — offline-first sync helper (simulated transport; real network deferred)
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|
||||||
- JSON export/import with doctrinal validation
|
- **JSON export/import** — full unit tree with schema versioning and round-trip fidelity
|
||||||
- Protobuf binary export
|
- **Binary export (`.utob`)** — `ProtobufCodec` with 12-byte UTOB header + zlib-compressed JSON
|
||||||
- Vector PDF organizational chart export
|
- **PDF export** — pure-Dart PDF 1.4 org-chart generator (Letter/A4/Tabloid, UNCLASSIFIED banner)
|
||||||
|
- Export dialog with format selection and PDF page-size picker
|
||||||
|
|
||||||
### Platform-Specific
|
---
|
||||||
|
|
||||||
- Windows: multi-window, high-DPI scaling, `.msi` installer, `.uto` file association
|
## Planned Features (Hardening Phase)
|
||||||
- Web: PWA offline capability, IndexedDB quota management
|
|
||||||
|
### Visual Symbology
|
||||||
|
|
||||||
|
- MIL-STD-2525D tactical symbology rendering (hybrid: `milsymbol.js` on Web, SVG path engine on native)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- SQLCipher data-at-rest encryption for local Drift database
|
||||||
|
- Platform keychain key storage (Windows Credential Manager / Android Keystore)
|
||||||
|
|
||||||
|
### Platform Hardening
|
||||||
|
|
||||||
|
- Windows: multi-window support, `.msi` installer, `.uto` file association
|
||||||
|
- Web: PWA offline capability, IndexedDB quota management, service worker
|
||||||
- Android: battery profiling, touch-optimized targets (48×48 dp minimum)
|
- Android: battery profiling, touch-optimized targets (48×48 dp minimum)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Reingold-Tilford layout algorithm in background Dart Isolate
|
||||||
|
- Quadtree spatial index for hit-testing
|
||||||
|
- `RepaintBoundary` layer isolation
|
||||||
|
- 60 FPS at 5,000+ nodes (Windows), 2,000+ (Web), 1,500+ (Android)
|
||||||
|
|
||||||
|
### UX
|
||||||
|
|
||||||
|
- Undo/redo stack for all CRUD operations
|
||||||
|
- Canvas search/filter
|
||||||
|
- Keyboard shortcuts (Ctrl+Z, Ctrl+F, Ctrl+D, Ctrl+S)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
*To be documented upon release.*
|
- MIL-STD-2525D symbology not yet rendered (nodes show text labels only)
|
||||||
|
- No data-at-rest encryption (SQLCipher deferred)
|
||||||
|
- `DeltaSynchronizer` uses simulated online/offline; no real network transport
|
||||||
|
- `ProtobufCodec` uses zlib-compressed JSON payload; true `.proto` schema deferred
|
||||||
|
- Template instantiation uses sequential Drift inserts (not a single transaction)
|
||||||
|
- No undo/redo for CRUD operations
|
||||||
|
- Canvas layout is vertical tree only; horizontal layout not implemented
|
||||||
|
- No canvas search/filter
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Upgrade Instructions
|
## Upgrade Instructions
|
||||||
|
|
||||||
*Initial release — no upgrade path required.*
|
*Initial release — no upgrade path required.*
|
||||||
|
|
||||||
|
*Existing databases at schema v1 will be automatically migrated to v2 on first launch.*
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class PersonnelLoaded extends PersonnelState {
|
||||||
if (filterQuery.isEmpty) return unassigned;
|
if (filterQuery.isEmpty) return unassigned;
|
||||||
final q = filterQuery.toLowerCase();
|
final q = filterQuery.toLowerCase();
|
||||||
return unassigned.where((p) {
|
return unassigned.where((p) {
|
||||||
return p.name.toLowerCase().contains(q) ||
|
return p.displayLabel.toLowerCase().contains(q) ||
|
||||||
p.primaryMos.code.toLowerCase().contains(q) ||
|
p.primaryMos.code.toLowerCase().contains(q) ||
|
||||||
p.payGrade.payGrade.toLowerCase().contains(q);
|
p.payGrade.payGrade.toLowerCase().contains(q);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
@ -187,6 +187,35 @@ class PersonnelCubit extends Cubit<PersonnelState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates an existing [record] in the personnel roster.
|
||||||
|
Future<void> updatePersonnel(PersonnelRecord record) async {
|
||||||
|
try {
|
||||||
|
await personnelRepository.update(record);
|
||||||
|
} catch (e) {
|
||||||
|
emit(PersonnelError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the soldier with [soldierId] from the roster.
|
||||||
|
///
|
||||||
|
/// If the soldier is currently assigned to a slot, the slot assignment
|
||||||
|
/// is cleared first.
|
||||||
|
Future<void> deletePersonnel(String soldierId) async {
|
||||||
|
try {
|
||||||
|
// If assigned, clear the slot assignment first
|
||||||
|
if (state is PersonnelLoaded) {
|
||||||
|
final loaded = state as PersonnelLoaded;
|
||||||
|
final record = loaded.allPersonnel.where((p) => p.soldierId == soldierId).firstOrNull;
|
||||||
|
if (record != null && record.isAssigned) {
|
||||||
|
await slotRepository.clearAssignment(record.assignedSlotId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await personnelRepository.delete(soldierId);
|
||||||
|
} catch (e) {
|
||||||
|
emit(PersonnelError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
_allSubscription?.cancel();
|
_allSubscription?.cancel();
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,14 @@ class UnitTreeCubit extends Cubit<UnitTreeState> {
|
||||||
final roots = treeService.buildTree(_units, _slots);
|
final roots = treeService.buildTree(_units, _slots);
|
||||||
final currentSelection =
|
final currentSelection =
|
||||||
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
|
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
|
||||||
emit(UnitTreeLoaded(
|
emit(
|
||||||
units: _units, slots: _slots, roots: roots, selectedUnitId: currentSelection,),);
|
UnitTreeLoaded(
|
||||||
|
units: _units,
|
||||||
|
slots: _slots,
|
||||||
|
roots: roots,
|
||||||
|
selectedUnitId: currentSelection,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -206,6 +212,42 @@ class UnitTreeCubit extends Cubit<UnitTreeState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates an existing [unit] in the tree.
|
||||||
|
Future<void> updateUnit(UnitNode unit) async {
|
||||||
|
try {
|
||||||
|
await unitRepository.update(unit);
|
||||||
|
} catch (e) {
|
||||||
|
emit(UnitTreeError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a new position [slot] to a unit.
|
||||||
|
Future<void> addSlot(PositionSlot slot) async {
|
||||||
|
try {
|
||||||
|
await slotRepository.insert(slot);
|
||||||
|
} catch (e) {
|
||||||
|
emit(UnitTreeError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates an existing position [slot].
|
||||||
|
Future<void> updateSlot(PositionSlot slot) async {
|
||||||
|
try {
|
||||||
|
await slotRepository.update(slot);
|
||||||
|
} catch (e) {
|
||||||
|
emit(UnitTreeError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the position slot with [slotId].
|
||||||
|
Future<void> deleteSlot(String slotId) async {
|
||||||
|
try {
|
||||||
|
await slotRepository.delete(slotId);
|
||||||
|
} catch (e) {
|
||||||
|
emit(UnitTreeError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears all units and slots (template reset).
|
/// Clears all units and slots (template reset).
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,17 @@ enum ReadinessState {
|
||||||
///
|
///
|
||||||
/// Contains identifying information, MOS qualifications, pay grade,
|
/// Contains identifying information, MOS qualifications, pay grade,
|
||||||
/// and current readiness state.
|
/// and current readiness state.
|
||||||
|
///
|
||||||
|
/// [name] is optional. When omitted the soldier is identified by
|
||||||
|
/// [positionTitle] (e.g. "Team Leader", "Rifleman").
|
||||||
class PersonnelRecord extends Equatable {
|
class PersonnelRecord extends Equatable {
|
||||||
const PersonnelRecord({
|
const PersonnelRecord({
|
||||||
required this.soldierId,
|
required this.soldierId,
|
||||||
required this.name,
|
|
||||||
required this.primaryMos,
|
required this.primaryMos,
|
||||||
required this.payGrade,
|
required this.payGrade,
|
||||||
required this.readinessState,
|
required this.readinessState,
|
||||||
|
this.name,
|
||||||
|
this.positionTitle,
|
||||||
this.qualifications = const [],
|
this.qualifications = const [],
|
||||||
this.assignedSlotId,
|
this.assignedSlotId,
|
||||||
});
|
});
|
||||||
|
|
@ -50,8 +54,13 @@ class PersonnelRecord extends Equatable {
|
||||||
/// Unique identifier for this soldier (UUID v4).
|
/// Unique identifier for this soldier (UUID v4).
|
||||||
final String soldierId;
|
final String soldierId;
|
||||||
|
|
||||||
/// The soldier's full name (Last, First MI format).
|
/// The soldier's full name (Last, First MI format), or null if unnamed.
|
||||||
final String name;
|
final String? name;
|
||||||
|
|
||||||
|
/// The soldier's duty position title (e.g. "Team Leader", "Rifleman").
|
||||||
|
///
|
||||||
|
/// Used as the display label when [name] is null.
|
||||||
|
final String? positionTitle;
|
||||||
|
|
||||||
/// The soldier's primary Military Occupational Specialty.
|
/// The soldier's primary Military Occupational Specialty.
|
||||||
final DutyMos primaryMos;
|
final DutyMos primaryMos;
|
||||||
|
|
@ -68,6 +77,15 @@ class PersonnelRecord extends Equatable {
|
||||||
/// The ID of the position slot this soldier is assigned to, or null.
|
/// The ID of the position slot this soldier is assigned to, or null.
|
||||||
final String? assignedSlotId;
|
final String? assignedSlotId;
|
||||||
|
|
||||||
|
/// Returns the best available display label for this soldier.
|
||||||
|
///
|
||||||
|
/// Prefers [name]; falls back to [positionTitle]; then a generic label.
|
||||||
|
String get displayLabel {
|
||||||
|
if (name != null && name!.isNotEmpty) return name!;
|
||||||
|
if (positionTitle != null && positionTitle!.isNotEmpty) return positionTitle!;
|
||||||
|
return '(Unnamed Soldier)';
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if this soldier is currently assigned to a position.
|
/// Returns true if this soldier is currently assigned to a position.
|
||||||
bool get isAssigned => assignedSlotId != null;
|
bool get isAssigned => assignedSlotId != null;
|
||||||
|
|
||||||
|
|
@ -81,6 +99,9 @@ class PersonnelRecord extends Equatable {
|
||||||
PersonnelRecord copyWith({
|
PersonnelRecord copyWith({
|
||||||
String? soldierId,
|
String? soldierId,
|
||||||
String? name,
|
String? name,
|
||||||
|
bool clearName = false,
|
||||||
|
String? positionTitle,
|
||||||
|
bool clearPositionTitle = false,
|
||||||
DutyMos? primaryMos,
|
DutyMos? primaryMos,
|
||||||
AuthorizedRank? payGrade,
|
AuthorizedRank? payGrade,
|
||||||
List<String>? qualifications,
|
List<String>? qualifications,
|
||||||
|
|
@ -90,7 +111,8 @@ class PersonnelRecord extends Equatable {
|
||||||
}) {
|
}) {
|
||||||
return PersonnelRecord(
|
return PersonnelRecord(
|
||||||
soldierId: soldierId ?? this.soldierId,
|
soldierId: soldierId ?? this.soldierId,
|
||||||
name: name ?? this.name,
|
name: clearName ? null : (name ?? this.name),
|
||||||
|
positionTitle: clearPositionTitle ? null : (positionTitle ?? this.positionTitle),
|
||||||
primaryMos: primaryMos ?? this.primaryMos,
|
primaryMos: primaryMos ?? this.primaryMos,
|
||||||
payGrade: payGrade ?? this.payGrade,
|
payGrade: payGrade ?? this.payGrade,
|
||||||
qualifications: qualifications ?? this.qualifications,
|
qualifications: qualifications ?? this.qualifications,
|
||||||
|
|
@ -103,6 +125,7 @@ class PersonnelRecord extends Equatable {
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
soldierId,
|
soldierId,
|
||||||
name,
|
name,
|
||||||
|
positionTitle,
|
||||||
primaryMos,
|
primaryMos,
|
||||||
payGrade,
|
payGrade,
|
||||||
qualifications,
|
qualifications,
|
||||||
|
|
@ -111,7 +134,7 @@ class PersonnelRecord extends Equatable {
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'PersonnelRecord(id: $soldierId, name: $name, '
|
String toString() => 'PersonnelRecord(id: $soldierId, label: $displayLabel, '
|
||||||
'mos: ${primaryMos.code}, grade: ${payGrade.payGrade}, '
|
'mos: ${primaryMos.code}, grade: ${payGrade.payGrade}, '
|
||||||
'readiness: ${readinessState.displayName})';
|
'readiness: ${readinessState.displayName})';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// A named duty position that can be assigned to a soldier record.
|
||||||
|
///
|
||||||
|
/// Built-in positions are seeded at first launch and cannot be deleted.
|
||||||
|
/// Users may add, rename, or delete custom positions.
|
||||||
|
class SoldierPosition extends Equatable {
|
||||||
|
const SoldierPosition({
|
||||||
|
required this.positionId,
|
||||||
|
required this.title,
|
||||||
|
this.isBuiltIn = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unique identifier (UUID v4 for custom; fixed string for built-ins).
|
||||||
|
final String positionId;
|
||||||
|
|
||||||
|
/// Human-readable position title (e.g. "Team Leader", "Rifleman").
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// True for positions seeded by the app; false for user-created ones.
|
||||||
|
final bool isBuiltIn;
|
||||||
|
|
||||||
|
SoldierPosition copyWith({
|
||||||
|
String? positionId,
|
||||||
|
String? title,
|
||||||
|
bool? isBuiltIn,
|
||||||
|
}) {
|
||||||
|
return SoldierPosition(
|
||||||
|
positionId: positionId ?? this.positionId,
|
||||||
|
title: title ?? this.title,
|
||||||
|
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [positionId, title, isBuiltIn];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SoldierPosition($positionId, "$title", builtIn: $isBuiltIn)';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Built-in positions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The full list of built-in Army duty positions seeded on first launch.
|
||||||
|
static const List<SoldierPosition> builtIns = [
|
||||||
|
// ── Command / Staff ──────────────────────────────────────────────────────
|
||||||
|
SoldierPosition(positionId: 'bi_commander', title: 'Commander', isBuiltIn: true),
|
||||||
|
SoldierPosition(
|
||||||
|
positionId: 'bi_executive_officer',
|
||||||
|
title: 'Executive Officer',
|
||||||
|
isBuiltIn: true,
|
||||||
|
),
|
||||||
|
SoldierPosition(
|
||||||
|
positionId: 'bi_command_sergeant_major',
|
||||||
|
title: 'Command Sergeant Major',
|
||||||
|
isBuiltIn: true,
|
||||||
|
),
|
||||||
|
SoldierPosition(positionId: 'bi_first_sergeant', title: 'First Sergeant', isBuiltIn: true),
|
||||||
|
SoldierPosition(
|
||||||
|
positionId: 'bi_operations_officer',
|
||||||
|
title: 'S3 / Operations Officer',
|
||||||
|
isBuiltIn: true,
|
||||||
|
),
|
||||||
|
SoldierPosition(
|
||||||
|
positionId: 'bi_intelligence_officer',
|
||||||
|
title: 'S2 / Intelligence Officer',
|
||||||
|
isBuiltIn: true,
|
||||||
|
),
|
||||||
|
SoldierPosition(
|
||||||
|
positionId: 'bi_logistics_officer',
|
||||||
|
title: 'S4 / Logistics Officer',
|
||||||
|
isBuiltIn: true,
|
||||||
|
),
|
||||||
|
SoldierPosition(positionId: 'bi_signal_officer', title: 'S6 / Signal Officer', isBuiltIn: true),
|
||||||
|
// ── Platoon ───────────────────────────────────────────────────────────────
|
||||||
|
SoldierPosition(positionId: 'bi_platoon_leader', title: 'Platoon Leader', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_platoon_sergeant', title: 'Platoon Sergeant', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_rto', title: 'RTO / Radio Operator', isBuiltIn: true),
|
||||||
|
// ── Squad / Section ───────────────────────────────────────────────────────
|
||||||
|
SoldierPosition(positionId: 'bi_squad_leader', title: 'Squad Leader', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_team_leader', title: 'Team Leader', isBuiltIn: true),
|
||||||
|
// ── Individual ────────────────────────────────────────────────────────────
|
||||||
|
SoldierPosition(positionId: 'bi_rifleman', title: 'Rifleman', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_grenadier', title: 'Grenadier', isBuiltIn: true),
|
||||||
|
SoldierPosition(
|
||||||
|
positionId: 'bi_automatic_rifleman',
|
||||||
|
title: 'Automatic Rifleman',
|
||||||
|
isBuiltIn: true,
|
||||||
|
),
|
||||||
|
SoldierPosition(positionId: 'bi_machine_gunner', title: 'Machine Gunner', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_sniper', title: 'Sniper', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_jtac', title: 'JTAC / Fire Support', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_medic', title: 'Medic', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_combat_engineer', title: 'Combat Engineer', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_driver', title: 'Driver', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_gunner', title: 'Gunner', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_loader', title: 'Loader', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_tank_commander', title: 'Tank Commander', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_crew_member', title: 'Crew Member', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_supply_sergeant', title: 'Supply Sergeant', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_armorer', title: 'Armorer', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_commo_sergeant', title: 'Commo Sergeant', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_intel_analyst', title: 'Intelligence Analyst', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_forward_observer', title: 'Forward Observer', isBuiltIn: true),
|
||||||
|
SoldierPosition(positionId: 'bi_chemical_officer', title: 'CBRN Specialist', isBuiltIn: true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,9 @@ class DutyMos {
|
||||||
return _knownMosCodes.contains(code.trim().toUpperCase());
|
return _knownMosCodes.contains(code.trim().toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all known MOS codes as a sorted list.
|
||||||
|
static List<String> get allCodes => _knownMosCodes.toList()..sort();
|
||||||
|
|
||||||
/// Returns all MOS codes for a given [DoctrinalBranch].
|
/// Returns all MOS codes for a given [DoctrinalBranch].
|
||||||
static Set<String> codesForBranch(DoctrinalBranch branch) {
|
static Set<String> codesForBranch(DoctrinalBranch branch) {
|
||||||
return _branchMosMap[branch] ?? {};
|
return _branchMosMap[branch] ?? {};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart';
|
import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/daos/position_slot_dao.dart';
|
import 'package:uto_manager/infrastructure/database/daos/position_slot_dao.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/database/daos/soldier_positions_dao.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
|
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart';
|
import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart';
|
||||||
|
|
||||||
|
|
@ -41,7 +42,8 @@ class PositionSlots extends Table {
|
||||||
/// Drift table for soldier personnel records.
|
/// Drift table for soldier personnel records.
|
||||||
class Personnel extends Table {
|
class Personnel extends Table {
|
||||||
TextColumn get soldierId => text().named('soldier_id')();
|
TextColumn get soldierId => text().named('soldier_id')();
|
||||||
TextColumn get name => text()();
|
TextColumn get name => text().nullable()();
|
||||||
|
TextColumn get positionTitle => text().named('position_title').nullable()();
|
||||||
TextColumn get primaryMos => text().named('primary_mos')();
|
TextColumn get primaryMos => text().named('primary_mos')();
|
||||||
TextColumn get payGrade => text().named('pay_grade')();
|
TextColumn get payGrade => text().named('pay_grade')();
|
||||||
TextColumn get qualifications => text()(); // JSON-encoded list
|
TextColumn get qualifications => text()(); // JSON-encoded list
|
||||||
|
|
@ -52,6 +54,16 @@ class Personnel extends Table {
|
||||||
Set<Column> get primaryKey => {soldierId};
|
Set<Column> get primaryKey => {soldierId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drift table for user-managed soldier duty positions.
|
||||||
|
class SoldierPositions extends Table {
|
||||||
|
TextColumn get positionId => text().named('position_id')();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
BoolColumn get isBuiltIn => boolean().named('is_built_in').withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {positionId};
|
||||||
|
}
|
||||||
|
|
||||||
/// Drift table for serialized unit tree templates.
|
/// Drift table for serialized unit tree templates.
|
||||||
class Templates extends Table {
|
class Templates extends Table {
|
||||||
TextColumn get templateId => text().named('template_id')();
|
TextColumn get templateId => text().named('template_id')();
|
||||||
|
|
@ -71,11 +83,11 @@ class Templates extends Table {
|
||||||
|
|
||||||
/// The main Drift database for the UTO Manager application.
|
/// The main Drift database for the UTO Manager application.
|
||||||
///
|
///
|
||||||
/// Manages all four core tables: units, position_slots, personnel, templates.
|
/// Manages all core tables: units, position_slots, personnel, soldier_positions, templates.
|
||||||
/// Supports SQLite on Windows/Android and IndexedDB on Web via drift_web.
|
/// Supports SQLite on Windows/Android and IndexedDB on Web via drift_web.
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
tables: [Units, PositionSlots, Personnel, Templates],
|
tables: [Units, PositionSlots, Personnel, SoldierPositions, Templates],
|
||||||
daos: [UnitDao, PersonnelDao, PositionSlotDao, TemplateDao],
|
daos: [UnitDao, PersonnelDao, PositionSlotDao, SoldierPositionsDao, TemplateDao],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
/// Creates the production database backed by SQLite on disk.
|
/// Creates the production database backed by SQLite on disk.
|
||||||
|
|
@ -85,7 +97,7 @@ class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
|
@ -93,7 +105,12 @@ class AppDatabase extends _$AppDatabase {
|
||||||
await m.createAll();
|
await m.createAll();
|
||||||
},
|
},
|
||||||
onUpgrade: (m, from, to) async {
|
onUpgrade: (m, from, to) async {
|
||||||
// Future migration steps added here as schemaVersion increases.
|
if (from < 2) {
|
||||||
|
// v1 → v2: make Personnel.name nullable, add position_title column,
|
||||||
|
// and create the new soldier_positions table.
|
||||||
|
await m.addColumn(personnel, personnel.positionTitle);
|
||||||
|
await m.createTable(soldierPositions);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -102,5 +119,11 @@ class AppDatabase extends _$AppDatabase {
|
||||||
///
|
///
|
||||||
/// Uses SQLite via FFI on Windows/Android and WASM/IndexedDB on Web.
|
/// Uses SQLite via FFI on Windows/Android and WASM/IndexedDB on Web.
|
||||||
QueryExecutor _openConnection() {
|
QueryExecutor _openConnection() {
|
||||||
return driftDatabase(name: 'uto_manager');
|
return driftDatabase(
|
||||||
|
name: 'uto_manager',
|
||||||
|
web: DriftWebOptions(
|
||||||
|
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
|
||||||
|
driftWorker: Uri.parse('drift_worker.js'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||||
|
|
||||||
|
part 'soldier_positions_dao.g.dart';
|
||||||
|
|
||||||
|
// The Drift-generated row type for the SoldierPositions table is also named
|
||||||
|
// `SoldierPosition` (same as our domain entity). We use a typedef here so the
|
||||||
|
// DAO can refer to the DB row type unambiguously.
|
||||||
|
typedef SoldierPositionRow = SoldierPosition;
|
||||||
|
|
||||||
|
/// Data Access Object for [SoldierPositions] table operations.
|
||||||
|
@DriftAccessor(tables: [SoldierPositions])
|
||||||
|
class SoldierPositionsDao extends DatabaseAccessor<AppDatabase> with _$SoldierPositionsDaoMixin {
|
||||||
|
SoldierPositionsDao(super.db);
|
||||||
|
|
||||||
|
/// Returns all soldier position rows.
|
||||||
|
Future<List<SoldierPositionRow>> getAll() => select(soldierPositions).get();
|
||||||
|
|
||||||
|
/// Returns the position with [positionId], or null if not found.
|
||||||
|
Future<SoldierPositionRow?> getById(String positionId) =>
|
||||||
|
(select(soldierPositions)..where((t) => t.positionId.equals(positionId))).getSingleOrNull();
|
||||||
|
|
||||||
|
/// Inserts a new position row (insert-or-replace).
|
||||||
|
Future<void> upsert(SoldierPositionsCompanion entry) =>
|
||||||
|
into(soldierPositions).insertOnConflictUpdate(entry);
|
||||||
|
|
||||||
|
/// Inserts multiple rows in a single transaction.
|
||||||
|
Future<void> upsertAll(List<SoldierPositionsCompanion> entries) =>
|
||||||
|
batch((b) => b.insertAllOnConflictUpdate(soldierPositions, entries));
|
||||||
|
|
||||||
|
/// Updates an existing position row.
|
||||||
|
Future<bool> updatePosition(SoldierPositionsCompanion entry) =>
|
||||||
|
update(soldierPositions).replace(entry);
|
||||||
|
|
||||||
|
/// Deletes the position with [positionId].
|
||||||
|
Future<int> deletePosition(String positionId) =>
|
||||||
|
(delete(soldierPositions)..where((t) => t.positionId.equals(positionId))).go();
|
||||||
|
|
||||||
|
/// Returns a stream that emits all positions whenever the table changes.
|
||||||
|
Stream<List<SoldierPositionRow>> watchAll() => select(soldierPositions).watch();
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ class DriftPersonnelRepository implements PersonnelRepository {
|
||||||
return domain.PersonnelRecord(
|
return domain.PersonnelRecord(
|
||||||
soldierId: row.soldierId,
|
soldierId: row.soldierId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
positionTitle: row.positionTitle,
|
||||||
primaryMos: DutyMos.unvalidated(row.primaryMos),
|
primaryMos: DutyMos.unvalidated(row.primaryMos),
|
||||||
payGrade: AuthorizedRank.values.firstWhere((r) => r.name == row.payGrade),
|
payGrade: AuthorizedRank.values.firstWhere((r) => r.name == row.payGrade),
|
||||||
qualifications: (jsonDecode(row.qualifications) as List<dynamic>).cast<String>(),
|
qualifications: (jsonDecode(row.qualifications) as List<dynamic>).cast<String>(),
|
||||||
|
|
@ -39,7 +40,8 @@ class DriftPersonnelRepository implements PersonnelRepository {
|
||||||
PersonnelCompanion _toCompanion(domain.PersonnelRecord record) {
|
PersonnelCompanion _toCompanion(domain.PersonnelRecord record) {
|
||||||
return PersonnelCompanion.insert(
|
return PersonnelCompanion.insert(
|
||||||
soldierId: record.soldierId,
|
soldierId: record.soldierId,
|
||||||
name: record.name,
|
name: Value(record.name),
|
||||||
|
positionTitle: Value(record.positionTitle),
|
||||||
primaryMos: record.primaryMos.code,
|
primaryMos: record.primaryMos.code,
|
||||||
payGrade: record.payGrade.name,
|
payGrade: record.payGrade.name,
|
||||||
qualifications: jsonEncode(record.qualifications),
|
qualifications: jsonEncode(record.qualifications),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:uto_manager/domain/entities/soldier_position.dart' as domain;
|
||||||
|
import 'package:uto_manager/infrastructure/database/app_database.dart' as db_lib;
|
||||||
|
import 'package:uto_manager/infrastructure/database/daos/soldier_positions_dao.dart';
|
||||||
|
|
||||||
|
/// Drift-backed repository for [domain.SoldierPosition] persistence.
|
||||||
|
///
|
||||||
|
/// Converts between Drift [SoldierPositionRow] rows and domain
|
||||||
|
/// [domain.SoldierPosition] entities. All persistence is handled via
|
||||||
|
/// [SoldierPositionsDao].
|
||||||
|
class DriftSoldierPositionsRepository {
|
||||||
|
const DriftSoldierPositionsRepository({required this.dao});
|
||||||
|
|
||||||
|
final SoldierPositionsDao dao;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mapping helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
domain.SoldierPosition _toDomain(SoldierPositionRow row) {
|
||||||
|
return domain.SoldierPosition(
|
||||||
|
positionId: row.positionId,
|
||||||
|
title: row.title,
|
||||||
|
isBuiltIn: row.isBuiltIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
db_lib.SoldierPositionsCompanion _toCompanion(domain.SoldierPosition pos) {
|
||||||
|
return db_lib.SoldierPositionsCompanion(
|
||||||
|
positionId: Value(pos.positionId),
|
||||||
|
title: Value(pos.title),
|
||||||
|
isBuiltIn: Value(pos.isBuiltIn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns all positions (built-in + custom).
|
||||||
|
Future<List<domain.SoldierPosition>> getAll() async {
|
||||||
|
final rows = await dao.getAll();
|
||||||
|
return rows.map(_toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a stream that emits all positions whenever the table changes.
|
||||||
|
Stream<List<domain.SoldierPosition>> watchAll() {
|
||||||
|
return dao.watchAll().map((rows) => rows.map(_toDomain).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or updates a position.
|
||||||
|
Future<void> upsert(domain.SoldierPosition position) async {
|
||||||
|
await dao.upsert(_toCompanion(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeds all [domain.SoldierPosition.builtIns] that are not yet in the database.
|
||||||
|
Future<void> seedBuiltIns() async {
|
||||||
|
final existing = await dao.getAll();
|
||||||
|
final existingIds = existing.map((SoldierPositionRow r) => r.positionId).toSet();
|
||||||
|
final toInsert = domain.SoldierPosition.builtIns
|
||||||
|
.where((p) => !existingIds.contains(p.positionId))
|
||||||
|
.map(_toCompanion)
|
||||||
|
.toList();
|
||||||
|
if (toInsert.isNotEmpty) {
|
||||||
|
await dao.upsertAll(toInsert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a custom position by [positionId].
|
||||||
|
///
|
||||||
|
/// Built-in positions cannot be deleted; this is a no-op for them.
|
||||||
|
Future<void> delete(String positionId) async {
|
||||||
|
final row = await dao.getById(positionId);
|
||||||
|
if (row == null || row.isBuiltIn) return;
|
||||||
|
await dao.deletePosition(positionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||||
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
|
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
|
||||||
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
|
||||||
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
|
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
|
||||||
import 'package:uto_manager/presentation/screens/home_screen.dart';
|
import 'package:uto_manager/presentation/screens/home_screen.dart';
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
||||||
late final DriftUnitRepository _unitRepo;
|
late final DriftUnitRepository _unitRepo;
|
||||||
late final DriftPersonnelRepository _personnelRepo;
|
late final DriftPersonnelRepository _personnelRepo;
|
||||||
late final DriftPositionSlotRepository _slotRepo;
|
late final DriftPositionSlotRepository _slotRepo;
|
||||||
|
late final DriftSoldierPositionsRepository _positionsRepo;
|
||||||
late final UnitTreeService _treeService;
|
late final UnitTreeService _treeService;
|
||||||
late final AssignmentService _assignmentService;
|
late final AssignmentService _assignmentService;
|
||||||
|
|
||||||
|
|
@ -42,6 +44,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
||||||
_unitRepo = DriftUnitRepository(dao: _db.unitDao, db: _db);
|
_unitRepo = DriftUnitRepository(dao: _db.unitDao, db: _db);
|
||||||
_personnelRepo = DriftPersonnelRepository(dao: _db.personnelDao);
|
_personnelRepo = DriftPersonnelRepository(dao: _db.personnelDao);
|
||||||
_slotRepo = DriftPositionSlotRepository(dao: _db.positionSlotDao);
|
_slotRepo = DriftPositionSlotRepository(dao: _db.positionSlotDao);
|
||||||
|
_positionsRepo = DriftSoldierPositionsRepository(dao: _db.soldierPositionsDao);
|
||||||
_treeService = const UnitTreeService();
|
_treeService = const UnitTreeService();
|
||||||
_assignmentService = AssignmentService(treeService: _treeService);
|
_assignmentService = AssignmentService(treeService: _treeService);
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +94,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const HomeScreen(),
|
home: HomeScreen(positionsRepository: _positionsRepo),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/soldier_position.dart';
|
||||||
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Dialog for creating or editing a [PersonnelRecord].
|
||||||
|
///
|
||||||
|
/// Pass [existing] to edit; leave null to create a new record.
|
||||||
|
/// [positionsRepository] is used to load and manage the position list.
|
||||||
|
class AddEditPersonnelDialog extends StatefulWidget {
|
||||||
|
const AddEditPersonnelDialog({
|
||||||
|
required this.positionsRepository,
|
||||||
|
super.key,
|
||||||
|
this.existing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DriftSoldierPositionsRepository positionsRepository;
|
||||||
|
final PersonnelRecord? existing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddEditPersonnelDialog> createState() => _AddEditPersonnelDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddEditPersonnelDialogState extends State<AddEditPersonnelDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late final TextEditingController _nameCtrl;
|
||||||
|
late final TextEditingController _mosCtrl;
|
||||||
|
|
||||||
|
late AuthorizedRank _payGrade;
|
||||||
|
late ReadinessState _readiness;
|
||||||
|
|
||||||
|
/// The currently selected position, or null if none chosen.
|
||||||
|
SoldierPosition? _selectedPosition;
|
||||||
|
|
||||||
|
/// All positions loaded from the repository.
|
||||||
|
List<SoldierPosition> _positions = [];
|
||||||
|
|
||||||
|
List<String> _mosSuggestions = [];
|
||||||
|
|
||||||
|
bool get _isEditing => widget.existing != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final e = widget.existing;
|
||||||
|
_nameCtrl = TextEditingController(text: e?.name ?? '');
|
||||||
|
_mosCtrl = TextEditingController(text: e?.primaryMos.code ?? '');
|
||||||
|
_payGrade = e?.payGrade ?? AuthorizedRank.e4;
|
||||||
|
_readiness = e?.readinessState ?? ReadinessState.fullyMissionCapable;
|
||||||
|
_loadPositions(existingTitle: e?.positionTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_mosCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPositions({String? existingTitle}) async {
|
||||||
|
await widget.positionsRepository.seedBuiltIns();
|
||||||
|
final positions = await widget.positionsRepository.getAll();
|
||||||
|
positions.sort((a, b) => a.title.compareTo(b.title));
|
||||||
|
final selected =
|
||||||
|
existingTitle != null ? positions.where((p) => p.title == existingTitle).firstOrNull : null;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_positions = positions;
|
||||||
|
_selectedPosition = selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMosSuggestions(String query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
setState(() => _mosSuggestions = []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final q = query.toUpperCase();
|
||||||
|
final matches =
|
||||||
|
DutyMos.allCodes.where((c) => c.startsWith(q) || c.contains(q)).take(12).toList()..sort();
|
||||||
|
setState(() => _mosSuggestions = matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final mosCode = _mosCtrl.text.trim().toUpperCase();
|
||||||
|
DutyMos mos;
|
||||||
|
try {
|
||||||
|
mos = DutyMos(mosCode);
|
||||||
|
} catch (_) {
|
||||||
|
mos = DutyMos.unvalidated(mosCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
final nameText = _nameCtrl.text.trim();
|
||||||
|
|
||||||
|
final record = PersonnelRecord(
|
||||||
|
soldierId: widget.existing?.soldierId ?? const Uuid().v4(),
|
||||||
|
name: nameText.isEmpty ? null : nameText,
|
||||||
|
positionTitle: _selectedPosition?.title,
|
||||||
|
primaryMos: mos,
|
||||||
|
payGrade: _payGrade,
|
||||||
|
readinessState: _readiness,
|
||||||
|
qualifications: widget.existing?.qualifications ?? const [],
|
||||||
|
assignedSlotId: widget.existing?.assignedSlotId,
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Position CRUD helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _showAddPositionDialog() async {
|
||||||
|
final title = await _showPositionTitleDialog(context, initial: '');
|
||||||
|
if (title == null || title.isEmpty) return;
|
||||||
|
final pos = SoldierPosition(positionId: const Uuid().v4(), title: title);
|
||||||
|
await widget.positionsRepository.upsert(pos);
|
||||||
|
await _loadPositions();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _selectedPosition = _positions.where((p) => p.title == title).firstOrNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEditPositionDialog(SoldierPosition pos) async {
|
||||||
|
if (pos.isBuiltIn) return; // built-ins cannot be renamed
|
||||||
|
final title = await _showPositionTitleDialog(context, initial: pos.title);
|
||||||
|
if (title == null || title.isEmpty || title == pos.title) return;
|
||||||
|
await widget.positionsRepository.upsert(pos.copyWith(title: title));
|
||||||
|
await _loadPositions();
|
||||||
|
if (mounted) {
|
||||||
|
setState(
|
||||||
|
() =>
|
||||||
|
_selectedPosition = _positions.where((p) => p.positionId == pos.positionId).firstOrNull,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeletePosition(SoldierPosition pos) async {
|
||||||
|
if (pos.isBuiltIn) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Position?'),
|
||||||
|
content: Text('Remove the "${pos.title}" position? This cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed ?? false) {
|
||||||
|
await widget.positionsRepository.delete(pos.positionId);
|
||||||
|
await _loadPositions();
|
||||||
|
if (mounted && _selectedPosition?.positionId == pos.positionId) {
|
||||||
|
setState(() => _selectedPosition = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a simple text-input dialog for a position title.
|
||||||
|
Future<String?> _showPositionTitleDialog(BuildContext context, {required String initial}) {
|
||||||
|
final ctrl = TextEditingController(text: initial);
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(initial.isEmpty ? 'Add Position' : 'Rename Position'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Position Title',
|
||||||
|
hintText: 'e.g. Anti-Tank Gunner',
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => Navigator.of(ctx).pop(ctrl.text.trim()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(ctrl.text.trim()),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_isEditing ? Icons.edit : Icons.person_add, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_isEditing ? 'Edit Soldier' : 'Add Soldier'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Position ─────────────────────────────────────────────────
|
||||||
|
_buildPositionRow(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Name (optional) ──────────────────────────────────────────
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Full Name (optional)',
|
||||||
|
hintText: 'e.g. SMITH, John A.',
|
||||||
|
prefixIcon: Icon(Icons.badge_outlined),
|
||||||
|
helperText: 'Leave blank to identify by position only.',
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Pay Grade ────────────────────────────────────────────────
|
||||||
|
DropdownButtonFormField<AuthorizedRank>(
|
||||||
|
initialValue: _payGrade,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Pay Grade',
|
||||||
|
prefixIcon: Icon(Icons.military_tech_outlined),
|
||||||
|
),
|
||||||
|
items: AuthorizedRank.values.map((r) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: r,
|
||||||
|
child: Text('${r.payGrade} — ${r.rankTitle}'),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) => setState(() => _payGrade = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── MOS with autocomplete ────────────────────────────────────
|
||||||
|
TextFormField(
|
||||||
|
controller: _mosCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Primary MOS',
|
||||||
|
hintText: 'e.g. 11B, 68W, 25U',
|
||||||
|
prefixIcon: Icon(Icons.work_outline),
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
onChanged: _updateMosSuggestions,
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) return 'MOS is required';
|
||||||
|
final code = v.trim().toUpperCase();
|
||||||
|
if (!DutyMos.isKnown(code)) {
|
||||||
|
return 'Unknown MOS code. Use a valid Army MOS (e.g. 11B, 68W)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_mosSuggestions.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: _mosSuggestions.map((code) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(
|
||||||
|
code,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
_mosCtrl.text = code;
|
||||||
|
setState(() => _mosSuggestions = []);
|
||||||
|
},
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Readiness State ──────────────────────────────────────────
|
||||||
|
DropdownButtonFormField<ReadinessState>(
|
||||||
|
initialValue: _readiness,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Readiness State',
|
||||||
|
prefixIcon: Icon(Icons.health_and_safety_outlined),
|
||||||
|
),
|
||||||
|
items: ReadinessState.values.map((s) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: s,
|
||||||
|
child: Text(s.displayName),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) => setState(() => _readiness = v!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
|
||||||
|
label: Text(_isEditing ? 'Save Changes' : 'Add Soldier'),
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the position dropdown row with add / edit / delete action buttons.
|
||||||
|
Widget _buildPositionRow(ThemeData theme) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _positions.isEmpty
|
||||||
|
? const LinearProgressIndicator()
|
||||||
|
: DropdownButtonFormField<SoldierPosition>(
|
||||||
|
initialValue: _selectedPosition,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Position',
|
||||||
|
prefixIcon: Icon(Icons.work_history_outlined),
|
||||||
|
),
|
||||||
|
hint: const Text('Select position…'),
|
||||||
|
items: _positions.map((p) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: p,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(p.title, overflow: TextOverflow.ellipsis)),
|
||||||
|
if (p.isBuiltIn)
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) => setState(() => _selectedPosition = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Add custom position
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline, size: 18),
|
||||||
|
tooltip: 'Add custom position',
|
||||||
|
onPressed: _showAddPositionDialog,
|
||||||
|
),
|
||||||
|
// Edit selected position (only if custom)
|
||||||
|
if (_selectedPosition != null && !_selectedPosition!.isBuiltIn)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
|
tooltip: 'Rename position',
|
||||||
|
onPressed: () => _showEditPositionDialog(_selectedPosition!),
|
||||||
|
),
|
||||||
|
// Delete selected position (only if custom)
|
||||||
|
if (_selectedPosition != null && !_selectedPosition!.isBuiltIn)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
|
||||||
|
tooltip: 'Delete position',
|
||||||
|
onPressed: () => _confirmDeletePosition(_selectedPosition!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Dialog for creating or editing a [PositionSlot] within a unit.
|
||||||
|
///
|
||||||
|
/// Pass [existing] to edit; leave null to create a new slot.
|
||||||
|
/// [unitId] is required and identifies which unit owns this slot.
|
||||||
|
class AddEditSlotDialog extends StatefulWidget {
|
||||||
|
const AddEditSlotDialog({
|
||||||
|
required this.unitId,
|
||||||
|
super.key,
|
||||||
|
this.existing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String unitId;
|
||||||
|
final PositionSlot? existing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddEditSlotDialog> createState() => _AddEditSlotDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddEditSlotDialogState extends State<AddEditSlotDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _titleCtrl;
|
||||||
|
late final TextEditingController _mosCtrl;
|
||||||
|
|
||||||
|
late AuthorizedRank _rank;
|
||||||
|
late bool _isKeyLeader;
|
||||||
|
List<String> _mosSuggestions = [];
|
||||||
|
|
||||||
|
bool get _isEditing => widget.existing != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final e = widget.existing;
|
||||||
|
_titleCtrl = TextEditingController(text: e?.positionTitle ?? '');
|
||||||
|
_mosCtrl = TextEditingController(text: e?.dutyMos.code ?? '');
|
||||||
|
_rank = e?.authorizedRank ?? AuthorizedRank.e4;
|
||||||
|
_isKeyLeader = e?.isKeyLeader ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_mosCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMosSuggestions(String query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
setState(() => _mosSuggestions = []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final q = query.toUpperCase();
|
||||||
|
final matches =
|
||||||
|
DutyMos.allCodes.where((c) => c.startsWith(q) || c.contains(q)).take(12).toList()..sort();
|
||||||
|
setState(() => _mosSuggestions = matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final mosCode = _mosCtrl.text.trim().toUpperCase();
|
||||||
|
DutyMos mos;
|
||||||
|
try {
|
||||||
|
mos = DutyMos(mosCode);
|
||||||
|
} catch (_) {
|
||||||
|
mos = DutyMos.unvalidated(mosCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
final slot = PositionSlot(
|
||||||
|
slotId: widget.existing?.slotId ?? const Uuid().v4(),
|
||||||
|
unitId: widget.unitId,
|
||||||
|
positionTitle: _titleCtrl.text.trim(),
|
||||||
|
dutyMos: mos,
|
||||||
|
authorizedRank: _rank,
|
||||||
|
isKeyLeader: _isKeyLeader,
|
||||||
|
assignedPersonnelId: widget.existing?.assignedPersonnelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_isEditing ? Icons.edit : Icons.add_circle_outline, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_isEditing ? 'Edit Position Slot' : 'Add Position Slot'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Position Title
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Position Title',
|
||||||
|
hintText: 'e.g. Platoon Leader, Squad Leader, Medic',
|
||||||
|
prefixIcon: Icon(Icons.work_history_outlined),
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) return 'Position title is required';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Authorized Rank
|
||||||
|
DropdownButtonFormField<AuthorizedRank>(
|
||||||
|
initialValue: _rank,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Authorized Rank',
|
||||||
|
prefixIcon: Icon(Icons.military_tech_outlined),
|
||||||
|
),
|
||||||
|
items: AuthorizedRank.values.map((r) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: r,
|
||||||
|
child: Text('${r.payGrade} — ${r.rankTitle}'),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) => setState(() => _rank = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Duty MOS with autocomplete
|
||||||
|
TextFormField(
|
||||||
|
controller: _mosCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Duty MOS',
|
||||||
|
hintText: 'e.g. 11B, 68W, 25U',
|
||||||
|
prefixIcon: Icon(Icons.badge_outlined),
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
onChanged: _updateMosSuggestions,
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) return 'Duty MOS is required';
|
||||||
|
final code = v.trim().toUpperCase();
|
||||||
|
if (!DutyMos.isKnown(code)) {
|
||||||
|
return 'Unknown MOS code (e.g. 11B, 68W, 25U)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_mosSuggestions.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: _mosSuggestions.map((code) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(
|
||||||
|
code,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
_mosCtrl.text = code;
|
||||||
|
setState(() => _mosSuggestions = []);
|
||||||
|
},
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Key Leader toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Key Leader Position'),
|
||||||
|
subtitle: const Text('e.g. Commander, XO, Platoon Leader, PSG'),
|
||||||
|
value: _isKeyLeader,
|
||||||
|
onChanged: (v) => setState(() => _isKeyLeader = v),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
|
||||||
|
label: Text(_isEditing ? 'Save Changes' : 'Add Slot'),
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Dialog for creating or editing a [UnitNode].
|
||||||
|
///
|
||||||
|
/// Pass [existing] to edit; leave null to create a new unit.
|
||||||
|
/// Pass [parentUnit] to pre-select the parent and constrain the echelon.
|
||||||
|
class AddEditUnitDialog extends StatefulWidget {
|
||||||
|
const AddEditUnitDialog({
|
||||||
|
super.key,
|
||||||
|
this.existing,
|
||||||
|
this.parentUnit,
|
||||||
|
this.availableParents = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final UnitNode? existing;
|
||||||
|
final UnitNode? parentUnit;
|
||||||
|
final List<UnitNode> availableParents;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddEditUnitDialog> createState() => _AddEditUnitDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddEditUnitDialogState extends State<AddEditUnitDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late final TextEditingController _nameCtrl;
|
||||||
|
|
||||||
|
late Echelon _echelon;
|
||||||
|
late DoctrinalBranch _branch;
|
||||||
|
String? _parentUnitId;
|
||||||
|
late bool _isKeyLeader;
|
||||||
|
|
||||||
|
bool get _isEditing => widget.existing != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final e = widget.existing;
|
||||||
|
_nameCtrl = TextEditingController(text: e?.name ?? '');
|
||||||
|
_echelon = e?.echelon ?? _defaultChildEchelon();
|
||||||
|
_branch = e?.branch ?? DoctrinalBranch.infantry;
|
||||||
|
_parentUnitId = e?.parentUnitId ?? widget.parentUnit?.unitId;
|
||||||
|
_isKeyLeader = e?.isKeyLeader ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Echelon _defaultChildEchelon() {
|
||||||
|
final parent = widget.parentUnit;
|
||||||
|
if (parent == null) return Echelon.brigade;
|
||||||
|
// Default to one level below parent
|
||||||
|
final childLevel = parent.echelon.level + 1;
|
||||||
|
return Echelon.values.firstWhere(
|
||||||
|
(e) => e.level == childLevel,
|
||||||
|
orElse: () => Echelon.team,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
// If no name provided, generate a default from echelon + branch abbreviation
|
||||||
|
final rawName = _nameCtrl.text.trim();
|
||||||
|
final name = rawName.isEmpty ? '${_echelon.displayName} (${_branch.abbreviation})' : rawName;
|
||||||
|
|
||||||
|
final unit = UnitNode(
|
||||||
|
unitId: widget.existing?.unitId ?? const Uuid().v4(),
|
||||||
|
echelon: _echelon,
|
||||||
|
branch: _branch,
|
||||||
|
name: name,
|
||||||
|
isKeyLeader: _isKeyLeader,
|
||||||
|
parentUnitId: _parentUnitId,
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_isEditing ? Icons.edit : Icons.add_box_outlined, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_isEditing ? 'Edit Unit' : 'Add Unit'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Unit Name
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Unit Name / Designation (optional)',
|
||||||
|
hintText: 'e.g. 1-68 AR, Alpha Company, 3rd Platoon',
|
||||||
|
helperText: 'Leave blank to auto-generate from echelon & branch',
|
||||||
|
prefixIcon: Icon(Icons.account_tree_outlined),
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Echelon
|
||||||
|
DropdownButtonFormField<Echelon>(
|
||||||
|
initialValue: _echelon,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Echelon',
|
||||||
|
prefixIcon: Icon(Icons.layers_outlined),
|
||||||
|
),
|
||||||
|
items: Echelon.values.map((e) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: e,
|
||||||
|
child: Text(e.displayName),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) => setState(() => _echelon = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Branch
|
||||||
|
DropdownButtonFormField<DoctrinalBranch>(
|
||||||
|
initialValue: _branch,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Branch / Functional Area',
|
||||||
|
prefixIcon: Icon(Icons.category_outlined),
|
||||||
|
),
|
||||||
|
items: DoctrinalBranch.values.map((b) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: b,
|
||||||
|
child: Text('${b.abbreviation} — ${b.displayName}'),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) => setState(() => _branch = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Parent Unit (optional)
|
||||||
|
if (widget.availableParents.isNotEmpty) ...[
|
||||||
|
DropdownButtonFormField<String?>(
|
||||||
|
initialValue: _parentUnitId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Parent Unit (optional)',
|
||||||
|
prefixIcon: Icon(Icons.device_hub_outlined),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
child: Text('— None (root unit) —'),
|
||||||
|
),
|
||||||
|
...widget.availableParents.map((u) {
|
||||||
|
return DropdownMenuItem<String?>(
|
||||||
|
value: u.unitId,
|
||||||
|
child: Text('${u.echelon.displayName}: ${u.name}'),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _parentUnitId = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Key Leader toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Key Leader Unit'),
|
||||||
|
subtitle: const Text('Marks this as a command/key leader node'),
|
||||||
|
value: _isKeyLeader,
|
||||||
|
onChanged: (v) => setState(() => _isKeyLeader = v),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
|
||||||
|
label: Text(_isEditing ? 'Save Changes' : 'Add Unit'),
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
|
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
|
||||||
|
|
@ -5,10 +7,15 @@ import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
|
||||||
import 'package:uto_manager/application/blocs/validation_cubit.dart';
|
import 'package:uto_manager/application/blocs/validation_cubit.dart';
|
||||||
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
import 'package:uto_manager/domain/entities/position_slot.dart';
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
||||||
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
||||||
import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart';
|
import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart';
|
||||||
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
|
||||||
|
import 'package:uto_manager/presentation/screens/dialogs/add_edit_personnel_dialog.dart';
|
||||||
|
import 'package:uto_manager/presentation/screens/dialogs/add_edit_slot_dialog.dart';
|
||||||
|
import 'package:uto_manager/presentation/screens/dialogs/add_edit_unit_dialog.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
|
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/position_slot_card.dart';
|
import 'package:uto_manager/presentation/widgets/position_slot_card.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
||||||
|
|
@ -20,7 +27,9 @@ import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
||||||
/// - Center: Interactive org chart canvas
|
/// - Center: Interactive org chart canvas
|
||||||
/// - Right panel: Unit inspector (selected unit's position slots + validation)
|
/// - Right panel: Unit inspector (selected unit's position slots + validation)
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({required this.positionsRepository, super.key});
|
||||||
|
|
||||||
|
final DriftSoldierPositionsRepository positionsRepository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -48,6 +57,22 @@ class HomeScreen extends StatelessWidget {
|
||||||
backgroundColor: const Color(0xFF1B3A6B),
|
backgroundColor: const Color(0xFF1B3A6B),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
actions: [
|
actions: [
|
||||||
|
// Add Unit button
|
||||||
|
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||||||
|
builder: (context, treeState) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.add_box_outlined),
|
||||||
|
tooltip: 'Add Unit',
|
||||||
|
onPressed: () => _showAddUnitDialog(context, treeState),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Add Soldier button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.person_add_outlined),
|
||||||
|
tooltip: 'Add Soldier',
|
||||||
|
onPressed: () => _showAddPersonnelDialog(context),
|
||||||
|
),
|
||||||
// Validate button
|
// Validate button
|
||||||
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||||||
builder: (context, treeState) {
|
builder: (context, treeState) {
|
||||||
|
|
@ -101,7 +126,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
// Right panel: Unit inspector
|
// Right panel: Unit inspector
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 280,
|
width: 300,
|
||||||
child: _buildInspectorPanel(context),
|
child: _buildInspectorPanel(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -128,6 +153,8 @@ class HomeScreen extends StatelessWidget {
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'PERSONNEL ROSTER',
|
'PERSONNEL ROSTER',
|
||||||
|
|
@ -137,6 +164,19 @@ class HomeScreen extends StatelessWidget {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Add soldier button in panel header
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.person_add_outlined, size: 16),
|
||||||
|
tooltip: 'Add Soldier',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () => _showAddPersonnelDialog(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
BlocBuilder<PersonnelCubit, PersonnelState>(
|
BlocBuilder<PersonnelCubit, PersonnelState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
|
@ -165,10 +205,40 @@ class HomeScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
if (state is PersonnelLoaded) {
|
if (state is PersonnelLoaded) {
|
||||||
final soldiers = state.filteredUnassigned;
|
final soldiers = state.filteredUnassigned;
|
||||||
|
if (soldiers.isEmpty && state.filterQuery.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 40,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'No soldiers yet',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(Icons.person_add_outlined, size: 14),
|
||||||
|
label: const Text('Add Soldier'),
|
||||||
|
onPressed: () => _showAddPersonnelDialog(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (soldiers.isEmpty) {
|
if (soldiers.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No unassigned soldiers',
|
'No matches',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -177,13 +247,34 @@ class HomeScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: soldiers.length,
|
itemCount: soldiers.length,
|
||||||
itemBuilder: (context, i) => PersonnelListTile(record: soldiers[i]),
|
itemBuilder: (context, i) => PersonnelListTile(
|
||||||
|
record: soldiers[i],
|
||||||
|
onTap: () => _showEditPersonnelDialog(context, soldiers[i]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Show all personnel (assigned + unassigned) section
|
||||||
|
BlocBuilder<PersonnelCubit, PersonnelState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is! PersonnelLoaded) return const SizedBox.shrink();
|
||||||
|
final assigned = state.allPersonnel.where((p) => p.isAssigned).toList();
|
||||||
|
if (assigned.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
child: Text(
|
||||||
|
'${assigned.length} assigned • ${state.unassigned.length} unassigned',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -232,13 +323,30 @@ class HomeScreen extends StatelessWidget {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.touch_app_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
'Select a unit on the canvas to inspect its position slots.',
|
'Select a unit on the canvas to inspect its position slots.',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(Icons.add_box_outlined, size: 14),
|
||||||
|
label: const Text('Add Unit'),
|
||||||
|
onPressed: () => _showAddUnitDialog(context, treeState),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -254,12 +362,14 @@ class HomeScreen extends StatelessWidget {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Inspector header
|
// Inspector header with edit/delete actions
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'UNIT INSPECTOR',
|
'UNIT INSPECTOR',
|
||||||
|
|
@ -269,6 +379,45 @@ class HomeScreen extends StatelessWidget {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Edit unit button
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 15),
|
||||||
|
tooltip: 'Edit Unit',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () =>
|
||||||
|
_showEditUnitDialog(context, selectedUnit, treeState.units),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Add child unit button
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.add_box_outlined, size: 15),
|
||||||
|
tooltip: 'Add Child Unit',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () =>
|
||||||
|
_showAddChildUnitDialog(context, selectedUnit, treeState),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Delete unit button
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
size: 15,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
tooltip: 'Delete Unit & Subtree',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () => _confirmDeleteUnit(context, selectedUnit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
selectedUnit.name,
|
selectedUnit.name,
|
||||||
|
|
@ -310,16 +459,70 @@ class HomeScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Slots header with add-slot button
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'POSITION SLOTS',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.1,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${slots.where((s) => s.isFilled).length}/${slots.length} filled',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline, size: 15),
|
||||||
|
tooltip: 'Add Position Slot',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () => _showAddSlotDialog(context, selectedUnit.unitId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
// Slots list
|
// Slots list
|
||||||
Expanded(
|
Expanded(
|
||||||
child: slots.isEmpty
|
child: slots.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.work_outline,
|
||||||
|
size: 32,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
'No position slots defined',
|
'No position slots defined',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
icon: const Icon(Icons.add_circle_outline, size: 14),
|
||||||
|
label: const Text('Add Slot'),
|
||||||
|
onPressed: () => _showAddSlotDialog(context, selectedUnit.unitId),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: slots.length,
|
itemCount: slots.length,
|
||||||
|
|
@ -333,6 +536,8 @@ class HomeScreen extends StatelessWidget {
|
||||||
assignedSoldier: soldier,
|
assignedSoldier: soldier,
|
||||||
onUnassign: slot.isFilled ? () => _unassign(context, slot) : null,
|
onUnassign: slot.isFilled ? () => _unassign(context, slot) : null,
|
||||||
onAcceptDrop: (soldier) => _assign(context, soldier, slot),
|
onAcceptDrop: (soldier) => _assign(context, soldier, slot),
|
||||||
|
onEdit: () => _showEditSlotDialog(context, slot),
|
||||||
|
onDelete: () => _confirmDeleteSlot(context, slot),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -402,6 +607,18 @@ class HomeScreen extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
// Personnel count
|
||||||
|
BlocBuilder<PersonnelCubit, PersonnelState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is! PersonnelLoaded) return const SizedBox.shrink();
|
||||||
|
return _statusChip(
|
||||||
|
theme,
|
||||||
|
Icons.people_outline,
|
||||||
|
'${state.allPersonnel.length} soldiers',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
// Validation finding counts
|
// Validation finding counts
|
||||||
BlocBuilder<ValidationCubit, ValidationState>(
|
BlocBuilder<ValidationCubit, ValidationState>(
|
||||||
builder: (context, valState) {
|
builder: (context, valState) {
|
||||||
|
|
@ -483,7 +700,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Actions
|
// Assignment Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _assign(BuildContext context, PersonnelRecord soldier, PositionSlot slot) {
|
void _assign(BuildContext context, PersonnelRecord soldier, PositionSlot slot) {
|
||||||
|
|
@ -494,6 +711,172 @@ class HomeScreen extends StatelessWidget {
|
||||||
context.read<PersonnelCubit>().unassignFromSlot(slot);
|
context.read<PersonnelCubit>().unassignFromSlot(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Personnel CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _showAddPersonnelDialog(BuildContext context) async {
|
||||||
|
final record = await showDialog<PersonnelRecord>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditPersonnelDialog(positionsRepository: positionsRepository),
|
||||||
|
);
|
||||||
|
if (record != null && context.mounted) {
|
||||||
|
unawaited(context.read<PersonnelCubit>().addPersonnel(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEditPersonnelDialog(BuildContext context, PersonnelRecord existing) async {
|
||||||
|
final record = await showDialog<PersonnelRecord>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditPersonnelDialog(
|
||||||
|
positionsRepository: positionsRepository,
|
||||||
|
existing: existing,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (record != null && context.mounted) {
|
||||||
|
unawaited(context.read<PersonnelCubit>().updatePersonnel(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unit CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _showAddUnitDialog(BuildContext context, UnitTreeState treeState) async {
|
||||||
|
final units = treeState is UnitTreeLoaded ? treeState.units : <UnitNode>[];
|
||||||
|
final unit = await showDialog<UnitNode>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditUnitDialog(availableParents: units),
|
||||||
|
);
|
||||||
|
if (unit != null && context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().addUnit(unit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddChildUnitDialog(
|
||||||
|
BuildContext context,
|
||||||
|
UnitNode parent,
|
||||||
|
UnitTreeLoaded treeState,
|
||||||
|
) async {
|
||||||
|
final unit = await showDialog<UnitNode>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditUnitDialog(
|
||||||
|
parentUnit: parent,
|
||||||
|
availableParents: treeState.units,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (unit != null && context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().addUnit(unit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEditUnitDialog(
|
||||||
|
BuildContext context,
|
||||||
|
UnitNode existing,
|
||||||
|
List<UnitNode> allUnits,
|
||||||
|
) async {
|
||||||
|
// Exclude the unit itself and its descendants from available parents
|
||||||
|
final unit = await showDialog<UnitNode>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditUnitDialog(
|
||||||
|
existing: existing,
|
||||||
|
availableParents: allUnits.where((u) => u.unitId != existing.unitId).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (unit != null && context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().updateUnit(unit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteUnit(BuildContext context, UnitNode unit) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Unit?'),
|
||||||
|
content: Text(
|
||||||
|
'Delete "${unit.name}" and all its subordinate units and position slots? '
|
||||||
|
'This action cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if ((confirmed ?? false) && context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().deleteSubtree(unit.unitId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Position Slot CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _showAddSlotDialog(BuildContext context, String unitId) async {
|
||||||
|
final slot = await showDialog<PositionSlot>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditSlotDialog(unitId: unitId),
|
||||||
|
);
|
||||||
|
if (slot != null && context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().addSlot(slot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEditSlotDialog(BuildContext context, PositionSlot existing) async {
|
||||||
|
final slot = await showDialog<PositionSlot>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AddEditSlotDialog(unitId: existing.unitId, existing: existing),
|
||||||
|
);
|
||||||
|
if (slot != null && context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().updateSlot(slot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteSlot(BuildContext context, PositionSlot slot) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Position Slot?'),
|
||||||
|
content: Text(
|
||||||
|
'Remove the "${slot.positionTitle}" (${slot.dutyMos.code}) slot? '
|
||||||
|
'${slot.isFilled ? "The assigned soldier will be unassigned. " : ""}'
|
||||||
|
'This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if ((confirmed ?? false) && context.mounted) {
|
||||||
|
// If slot is filled, unassign the soldier first
|
||||||
|
if (slot.isFilled) {
|
||||||
|
await context.read<PersonnelCubit>().unassignFromSlot(slot);
|
||||||
|
}
|
||||||
|
if (context.mounted) {
|
||||||
|
unawaited(context.read<UnitTreeCubit>().deleteSlot(slot.slotId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation & Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _runValidation(BuildContext context, UnitTreeLoaded treeState) {
|
void _runValidation(BuildContext context, UnitTreeLoaded treeState) {
|
||||||
final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded
|
final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded
|
||||||
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
|
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
|
||||||
|
|
@ -628,7 +1011,10 @@ class _ExportDialogState extends State<_ExportDialog> {
|
||||||
color: Colors.red.shade900.withAlpha(80),
|
color: Colors.red.shade900.withAlpha(80),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(_error!, style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,23 @@ import 'package:uto_manager/presentation/widgets/position_slot_card.dart' show P
|
||||||
/// A draggable list tile representing a soldier in the personnel panel.
|
/// A draggable list tile representing a soldier in the personnel panel.
|
||||||
///
|
///
|
||||||
/// Can be dragged onto a [PositionSlotCard] to assign the soldier.
|
/// Can be dragged onto a [PositionSlotCard] to assign the soldier.
|
||||||
|
/// Tap to edit; long-press for a context menu with edit/delete options.
|
||||||
class PersonnelListTile extends StatelessWidget {
|
class PersonnelListTile extends StatelessWidget {
|
||||||
const PersonnelListTile({
|
const PersonnelListTile({
|
||||||
required this.record, super.key,
|
required this.record,
|
||||||
|
super.key,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PersonnelRecord record;
|
final PersonnelRecord record;
|
||||||
|
|
||||||
|
/// Called when the tile is tapped (opens edit dialog).
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// Called when the user selects Delete from the context menu.
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -40,7 +48,7 @@ class PersonnelListTile extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
record.name,
|
record.displayLabel,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
|
@ -84,7 +92,7 @@ class PersonnelListTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
record.name,
|
record.displayLabel,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ class PositionSlotCard extends StatelessWidget {
|
||||||
this.assignedSoldier,
|
this.assignedSoldier,
|
||||||
this.onUnassign,
|
this.onUnassign,
|
||||||
this.onAcceptDrop,
|
this.onAcceptDrop,
|
||||||
|
this.onEdit,
|
||||||
|
this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PositionSlot slot;
|
final PositionSlot slot;
|
||||||
|
|
@ -20,6 +22,12 @@ class PositionSlotCard extends StatelessWidget {
|
||||||
final VoidCallback? onUnassign;
|
final VoidCallback? onUnassign;
|
||||||
final void Function(PersonnelRecord soldier)? onAcceptDrop;
|
final void Function(PersonnelRecord soldier)? onAcceptDrop;
|
||||||
|
|
||||||
|
/// Called when the user taps the edit button on this slot.
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
|
||||||
|
/// Called when the user taps the delete button on this slot.
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -67,21 +75,48 @@ class PositionSlotCard extends StatelessWidget {
|
||||||
fontWeight: slot.isVacant ? FontWeight.bold : FontWeight.normal,
|
fontWeight: slot.isVacant ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: slot.isFilled && onUnassign != null
|
trailing: _buildTrailing(theme),
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.person_remove, size: 16),
|
|
||||||
onPressed: onUnassign,
|
|
||||||
tooltip: 'Unassign soldier',
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget? _buildTrailing(ThemeData theme) {
|
||||||
|
final hasActions = onUnassign != null || onEdit != null || onDelete != null;
|
||||||
|
if (!hasActions) return null;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (slot.isFilled && onUnassign != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.person_remove, size: 15),
|
||||||
|
onPressed: onUnassign,
|
||||||
|
tooltip: 'Unassign soldier',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
|
||||||
|
),
|
||||||
|
if (onEdit != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 15),
|
||||||
|
onPressed: onEdit,
|
||||||
|
tooltip: 'Edit slot',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
|
||||||
|
),
|
||||||
|
if (onDelete != null)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, size: 15, color: theme.colorScheme.error),
|
||||||
|
onPressed: onDelete,
|
||||||
|
tooltip: 'Delete slot',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMosBadge(ThemeData theme) {
|
Widget _buildMosBadge(ThemeData theme) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
|
|
|
||||||
|
|
@ -642,7 +642,27 @@ This document breaks the master development plan into five structured phases spa
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: Platform Optimization, Profiling, & Hardening
|
## Phase 5: UI/CRUD Layer & Web Platform ✅ COMPLETE (2026-08-09)
|
||||||
|
|
||||||
|
> **Status:** Phase 5 UI/CRUD layer and web platform support completed and merged to `main`.
|
||||||
|
> 263/263 tests passing. `flutter analyze` — No issues found.
|
||||||
|
>
|
||||||
|
> **Completed in this phase:**
|
||||||
|
>
|
||||||
|
> - `SoldierPosition` entity — 31 built-in Army duty positions + user-defined custom positions
|
||||||
|
> - `SoldierPositions` Drift table + `SoldierPositionsDao` + `DriftSoldierPositionsRepository`
|
||||||
|
> - `AppDatabase` schema v2 migration — adds `position_title` to `Personnel`, creates `soldier_positions` table
|
||||||
|
> - Web platform: `drift_worker.dart/js`, `sqlite3.wasm`, platform-adaptive `driftDatabase()` connection
|
||||||
|
> - `PersonnelRecord` — extended with `positionTitle`, `displayLabel`, `isAssigned`, `isFmc`
|
||||||
|
> - `UnitTreeCubit` — full CRUD: `addUnit`, `updateUnit`, `addSlot`, `updateSlot`, `deleteSlot`, `clearAll`
|
||||||
|
> - `PersonnelCubit` — full CRUD: `addPersonnel`, `updatePersonnel`, `deletePersonnel`
|
||||||
|
> - `AddEditPersonnelDialog` — position picker with custom position management, MOS autocomplete
|
||||||
|
> - `AddEditSlotDialog` — slot creation/editing with MOS autocomplete and key-leader toggle
|
||||||
|
> - `AddEditUnitDialog` — unit creation/editing with echelon/branch/parent selectors
|
||||||
|
> - `HomeScreen` — full CRUD app bar actions, inspector CRUD buttons, export dialog with format/page-size selection
|
||||||
|
>
|
||||||
|
> **Deferred to hardening:** MIL-STD-2525D symbology engine, SQLCipher encryption,
|
||||||
|
> undo/redo stack, multi-window support, PWA service worker, Android battery profiling.
|
||||||
|
|
||||||
### Weeks 20 – 24
|
### Weeks 20 – 24
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||||
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
|
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
|
||||||
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
|
||||||
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
|
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
|
||||||
import 'package:uto_manager/presentation/screens/home_screen.dart';
|
import 'package:uto_manager/presentation/screens/home_screen.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
|
late DriftSoldierPositionsRepository positionsRepo;
|
||||||
late UnitTreeCubit unitTreeCubit;
|
late UnitTreeCubit unitTreeCubit;
|
||||||
late PersonnelCubit personnelCubit;
|
late PersonnelCubit personnelCubit;
|
||||||
late TemplateCubit templateCubit;
|
late TemplateCubit templateCubit;
|
||||||
|
|
@ -28,6 +30,7 @@ void main() {
|
||||||
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
|
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
|
||||||
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
|
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
|
||||||
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
|
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
|
||||||
|
positionsRepo = DriftSoldierPositionsRepository(dao: db.soldierPositionsDao);
|
||||||
const assignmentService = AssignmentService(treeService: treeService);
|
const assignmentService = AssignmentService(treeService: treeService);
|
||||||
|
|
||||||
// Create cubits but do NOT call startWatching() — avoids live streams.
|
// Create cubits but do NOT call startWatching() — avoids live streams.
|
||||||
|
|
@ -66,7 +69,7 @@ void main() {
|
||||||
BlocProvider<TemplateCubit>.value(value: templateCubit),
|
BlocProvider<TemplateCubit>.value(value: templateCubit),
|
||||||
BlocProvider<ValidationCubit>.value(value: validationCubit),
|
BlocProvider<ValidationCubit>.value(value: validationCubit),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: HomeScreen()),
|
child: MaterialApp(home: HomeScreen(positionsRepository: positionsRepo)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:drift/wasm.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
WasmDatabase.workerMainForOpen();
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Loading…
Reference in New Issue