Compare commits
No commits in common. "e52d3406a6d36e5baa6f47f8a1365cd5f552ce93" and "6ee71775fa1239378723b0bc626399dab67ae00b" have entirely different histories.
e52d3406a6
...
6ee71775fa
|
|
@ -49,10 +49,6 @@ web/version.json
|
|||
web/main.dart.js
|
||||
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
|
||||
windows/flutter/generated_plugin_registrant.cc
|
||||
windows/flutter/generated_plugin_registrant.h
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -25,14 +25,12 @@ The system models military formations from individual soldier positions up to mu
|
|||
## Key Features
|
||||
|
||||
- **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
|
||||
- **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android *(planned)*
|
||||
- **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android
|
||||
- **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
|
||||
- **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments
|
||||
- **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`
|
||||
- **Multi-Format Export** — JSON, Protobuf, and vector PDF organizational chart export
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -245,11 +243,11 @@ dart run dart_code_metrics:metrics analyze lib
|
|||
|
||||
| Phase | Title | Weeks | Status |
|
||||
|---|---|---|---|
|
||||
| Phase 1 | Architecture & Data Foundations | 1 – 4 | ✅ Complete |
|
||||
| Phase 2 | Canvas Graphics Engine & Persistence | 5 – 9 | ✅ Complete |
|
||||
| Phase 3 | Template Library & Import/Export | 10 – 14 | ✅ Complete |
|
||||
| Phase 4 | Reporting & Analytics | 15 – 19 | ✅ Complete |
|
||||
| Phase 5 | UI/CRUD Layer & Web Platform | 20 – 24 | ✅ Complete |
|
||||
| Phase 1 | Architecture & Data Foundations | 1 – 4 | ⬜ Not Started |
|
||||
| Phase 2 | Canvas Graphics Engine & Gesture Subsystem | 5 – 9 | ⬜ Not Started |
|
||||
| Phase 3 | 2026 Doctrinal Templates & Symbology Engine | 10 – 14 | ⬜ Not Started |
|
||||
| Phase 4 | Persistence, Validation Rules, & Export Engine | 15 – 19 | ⬜ Not Started |
|
||||
| Phase 5 | Platform Optimization, Profiling, & Hardening | 20 – 24 | ⬜ Not Started |
|
||||
|
||||
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
|
||||
|
||||
Last updated: 2026-08-09 — Phase 5 UI/CRUD layer merged to `main`
|
||||
Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main`
|
||||
|
||||
---
|
||||
|
||||
## Current Branch
|
||||
|
||||
`main` (Phase 5 UI/CRUD layer merged)
|
||||
`main` (fix/phase-4-analyzer-infos merged)
|
||||
|
||||
## Overall Progress
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ Last updated: 2026-08-09 — Phase 5 UI/CRUD layer merged to `main`
|
|||
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
||||
| 3 | Template Library & Import/Export | ✅ Complete |
|
||||
| 4 | Reporting & Analytics | ✅ Complete |
|
||||
| 5 | UI/CRUD Layer & Web Platform | ✅ Complete |
|
||||
| 5 | Multi-user / Sync | 🔲 Not started |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,132 +204,6 @@ Last updated: 2026-08-09 — Phase 5 UI/CRUD layer 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
|
||||
|
||||
| File | Tests | Status |
|
||||
|
|
@ -353,18 +227,15 @@ Last updated: 2026-08-09 — Phase 5 UI/CRUD layer merged to `main`
|
|||
|
||||
---
|
||||
|
||||
## Known Limitations / Next Steps (Phase 5 Hardening)
|
||||
## Known Limitations / Next Steps (Phase 5)
|
||||
|
||||
- No multi-user or real-time sync capability (Phase 5 hardening)
|
||||
- No multi-user or real-time sync capability (Phase 5)
|
||||
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
||||
- No undo/redo for assignment operations
|
||||
- No search/filter on the canvas itself
|
||||
- `DeltaSynchronizer` uses simulated online/offline; real network transport deferred
|
||||
- `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred
|
||||
- `DeltaSynchronizer` uses simulated online/offline; real network transport deferred to Phase 5
|
||||
- `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred to Phase 5
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -374,7 +245,6 @@ Last updated: 2026-08-09 — Phase 5 UI/CRUD layer merged to `main`
|
|||
lib/
|
||||
├── domain/
|
||||
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
||||
│ │ # UnitNode, PositionSlot, PersonnelRecord, SoldierPosition
|
||||
│ ├── value_objects/ # DutyMos (supports 3-char and 4-char AOC formats)
|
||||
│ ├── repositories/ # Abstract interfaces
|
||||
│ └── services/ # UnitTreeService, AssignmentService,
|
||||
|
|
@ -384,18 +254,12 @@ lib/
|
|||
├── infrastructure/
|
||||
│ ├── codecs/ # UnitTreeCodec (JSON), ProtobufCodec (binary),
|
||||
│ │ # PdfChartExporter (PDF org chart)
|
||||
│ ├── database/ # Drift schema (v2), DAOs, generated code
|
||||
│ │ # Tables: Units, PositionSlots, Personnel,
|
||||
│ │ # SoldierPositions, Templates
|
||||
│ ├── database/ # Drift schema, DAOs, generated code
|
||||
│ └── repositories/ # Drift-backed implementations
|
||||
│ # DriftSoldierPositionsRepository (new in Phase 5)
|
||||
├── application/
|
||||
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
||||
│ # TemplateCubit, ValidationCubit
|
||||
└── presentation/
|
||||
├── screens/
|
||||
│ ├── home_screen.dart # Full CRUD UI + export dialog
|
||||
│ └── dialogs/ # AddEditPersonnelDialog, AddEditSlotDialog,
|
||||
│ # AddEditUnitDialog (all new in Phase 5)
|
||||
├── screens/ # HomeScreen (with PDF/binary export + validation UI)
|
||||
└── widgets/ # Canvas, cards, tiles
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
|
|
@ -52,16 +52,15 @@ This guide covers the technical architecture, implementation details, and contri
|
|||
|---|---|
|
||||
| UI Framework | Flutter 3.x (Dart 3.x) |
|
||||
| Rendering | CustomPainter + InteractiveViewer |
|
||||
| State Management | BLoC (flutter_bloc) |
|
||||
| Drag-and-Drop | Flutter DragTarget / Draggable |
|
||||
| Local Database | Drift v2 (SQLite / IndexedDB) — schema v2 |
|
||||
| Web DB Worker | drift_worker.js + sqlite3.wasm |
|
||||
| Encryption | SQLCipher *(planned)* |
|
||||
| Symbology (Web) | milsymbol.js via dart:js_interop *(planned)* |
|
||||
| Symbology (Native) | path_drawing + SVG assets + LRU cache *(planned)* |
|
||||
| Background Compute | Dart Isolates (IsolateValidationRunner) |
|
||||
| Serialization | JSON (UnitTreeCodec) + Binary (ProtobufCodec) |
|
||||
| PDF Export | Pure-Dart PDF 1.4 (PdfChartExporter) |
|
||||
| State Management | BLoC + Signals |
|
||||
| Drag-and-Drop | super_drag_and_drop |
|
||||
| Local Database | Drift (SQLite / IndexedDB) |
|
||||
| Encryption | SQLCipher |
|
||||
| Symbology (Web) | milsymbol.js via dart:js_interop |
|
||||
| Symbology (Native) | path_drawing + SVG assets + LRU cache |
|
||||
| Background Compute | Dart Isolates |
|
||||
| Serialization | JSON + Protobuf |
|
||||
| PDF Export | pdf package |
|
||||
| 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.
|
||||
|
||||
**Current Version:** Pre-release (Phases 1–5 complete — UI/CRUD Layer & Web Platform)
|
||||
**Current Version:** Pre-release (Phase 1 — Architecture & Data Foundations)
|
||||
|
||||
**Project Repository:** [git.kellsupport.com/uto_manager](https://git.kellsupport.com/uto_manager)
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@
|
|||
|
||||
| Milestone | Target | Deliverable |
|
||||
|---|---|---|
|
||||
| M1 — End of Week 4 | 2026-08-07 ✅ | Compilable multi-platform skeleton + Drift DB + domain entities |
|
||||
| M2 — End of Week 9 | 2026-08-08 ✅ | Functional canvas engine with drag-and-drop on Windows and Android |
|
||||
| M3 — End of Week 14 | 2026-08-09 ✅ | All BCT templates instantiable + MTOE validation engine |
|
||||
| M4 — End of Week 19 | 2026-08-09 ✅ | Full persistence + real-time MTOE validation + JSON/PDF/binary export |
|
||||
| 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 |
|
||||
| M1 — End of Week 4 | TBD | Compilable multi-platform skeleton + Drift DB + CI pipeline |
|
||||
| M2 — End of Week 9 | TBD | 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 |
|
||||
| M4 — End of Week 19 | TBD | Full persistence + real-time MTOE validation + JSON/PDF export |
|
||||
| M5 — End of Week 24 | TBD | Signed release packages for Windows, Web, and Android |
|
||||
|
|
|
|||
|
|
@ -2,111 +2,58 @@
|
|||
|
||||
## Status: 🔵 In Development
|
||||
|
||||
**Target:** End of hardening phase
|
||||
**Target:** End of Week 24
|
||||
|
||||
---
|
||||
|
||||
## Implemented Features (Phases 1–5)
|
||||
## Planned Features
|
||||
|
||||
### Core Platform
|
||||
|
||||
- Cross-platform Flutter application targeting Windows x64, Web (WASM), and Android
|
||||
- GPU-accelerated `CustomPainter` canvas engine with viewport culling
|
||||
- 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
|
||||
- 60 FPS rendering at 5,000+ nodes (Windows), 2,000+ (Web), 1,500+ (Android)
|
||||
|
||||
### Organizational Modeling
|
||||
|
||||
- Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord`, `SoldierPosition`
|
||||
- Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord`
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- `InteractiveViewer` pan/zoom org chart
|
||||
- Drag-and-drop soldier assignment (drag from personnel panel → drop on position slot card)
|
||||
- Drag-and-drop unit re-parenting with doctrinal validation highlights
|
||||
- Validation node indicators: red border for critical findings, amber for warnings
|
||||
- Reingold-Tilford hierarchical tree layout algorithm
|
||||
- Quadtree spatial index for hit-testing and drag proximity queries
|
||||
- Cross-platform drag-and-drop re-parenting with doctrinal validation highlights
|
||||
- MIL-STD-2525D tactical symbology rendering
|
||||
|
||||
### MTOE Validation Engine
|
||||
### Persistence & Validation
|
||||
|
||||
- 7 validation rules (V001–V007): MOS mismatch, rank mismatch, vacant key leader, missing 68W medic, missing 13F FSO, structural hierarchy violation, manning below 70%
|
||||
- Background Dart Isolate execution via `IsolateValidationRunner`
|
||||
- Findings displayed in inspector panel (per-unit, color-coded by severity) and status bar
|
||||
|
||||
### 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)
|
||||
- Drift/SQLite offline-first persistence with IndexedDB on Web
|
||||
- Real-time MTOE validation engine (MOS mismatch, rank mismatch, vacant leadership, structural violations)
|
||||
- Delta synchronization for contested network environments
|
||||
- SQLCipher data-at-rest encryption
|
||||
|
||||
### Export
|
||||
|
||||
- **JSON export/import** — full unit tree with schema versioning and round-trip fidelity
|
||||
- **Binary export (`.utob`)** — `ProtobufCodec` with 12-byte UTOB header + zlib-compressed JSON
|
||||
- **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
|
||||
- JSON export/import with doctrinal validation
|
||||
- Protobuf binary export
|
||||
- Vector PDF organizational chart export
|
||||
|
||||
---
|
||||
### Platform-Specific
|
||||
|
||||
## Planned Features (Hardening Phase)
|
||||
|
||||
### 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
|
||||
- Windows: multi-window, high-DPI scaling, `.msi` installer, `.uto` file association
|
||||
- Web: PWA offline capability, IndexedDB quota management
|
||||
- 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
|
||||
|
||||
- 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
|
||||
*To be documented upon release.*
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Instructions
|
||||
|
||||
*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;
|
||||
final q = filterQuery.toLowerCase();
|
||||
return unassigned.where((p) {
|
||||
return p.displayLabel.toLowerCase().contains(q) ||
|
||||
return p.name.toLowerCase().contains(q) ||
|
||||
p.primaryMos.code.toLowerCase().contains(q) ||
|
||||
p.payGrade.payGrade.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
|
|
@ -187,35 +187,6 @@ 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
|
||||
Future<void> close() {
|
||||
_allSubscription?.cancel();
|
||||
|
|
|
|||
|
|
@ -142,14 +142,8 @@ class UnitTreeCubit extends Cubit<UnitTreeState> {
|
|||
final roots = treeService.buildTree(_units, _slots);
|
||||
final currentSelection =
|
||||
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
|
||||
emit(
|
||||
UnitTreeLoaded(
|
||||
units: _units,
|
||||
slots: _slots,
|
||||
roots: roots,
|
||||
selectedUnitId: currentSelection,
|
||||
),
|
||||
);
|
||||
emit(UnitTreeLoaded(
|
||||
units: _units, slots: _slots, roots: roots, selectedUnitId: currentSelection,),);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -212,42 +206,6 @@ 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).
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -36,17 +36,13 @@ enum ReadinessState {
|
|||
///
|
||||
/// Contains identifying information, MOS qualifications, pay grade,
|
||||
/// and current readiness state.
|
||||
///
|
||||
/// [name] is optional. When omitted the soldier is identified by
|
||||
/// [positionTitle] (e.g. "Team Leader", "Rifleman").
|
||||
class PersonnelRecord extends Equatable {
|
||||
const PersonnelRecord({
|
||||
required this.soldierId,
|
||||
required this.name,
|
||||
required this.primaryMos,
|
||||
required this.payGrade,
|
||||
required this.readinessState,
|
||||
this.name,
|
||||
this.positionTitle,
|
||||
this.qualifications = const [],
|
||||
this.assignedSlotId,
|
||||
});
|
||||
|
|
@ -54,13 +50,8 @@ class PersonnelRecord extends Equatable {
|
|||
/// Unique identifier for this soldier (UUID v4).
|
||||
final String soldierId;
|
||||
|
||||
/// The soldier's full name (Last, First MI format), or null if unnamed.
|
||||
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 full name (Last, First MI format).
|
||||
final String name;
|
||||
|
||||
/// The soldier's primary Military Occupational Specialty.
|
||||
final DutyMos primaryMos;
|
||||
|
|
@ -77,15 +68,6 @@ class PersonnelRecord extends Equatable {
|
|||
/// The ID of the position slot this soldier is assigned to, or null.
|
||||
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.
|
||||
bool get isAssigned => assignedSlotId != null;
|
||||
|
||||
|
|
@ -99,9 +81,6 @@ class PersonnelRecord extends Equatable {
|
|||
PersonnelRecord copyWith({
|
||||
String? soldierId,
|
||||
String? name,
|
||||
bool clearName = false,
|
||||
String? positionTitle,
|
||||
bool clearPositionTitle = false,
|
||||
DutyMos? primaryMos,
|
||||
AuthorizedRank? payGrade,
|
||||
List<String>? qualifications,
|
||||
|
|
@ -111,8 +90,7 @@ class PersonnelRecord extends Equatable {
|
|||
}) {
|
||||
return PersonnelRecord(
|
||||
soldierId: soldierId ?? this.soldierId,
|
||||
name: clearName ? null : (name ?? this.name),
|
||||
positionTitle: clearPositionTitle ? null : (positionTitle ?? this.positionTitle),
|
||||
name: name ?? this.name,
|
||||
primaryMos: primaryMos ?? this.primaryMos,
|
||||
payGrade: payGrade ?? this.payGrade,
|
||||
qualifications: qualifications ?? this.qualifications,
|
||||
|
|
@ -125,7 +103,6 @@ class PersonnelRecord extends Equatable {
|
|||
List<Object?> get props => [
|
||||
soldierId,
|
||||
name,
|
||||
positionTitle,
|
||||
primaryMos,
|
||||
payGrade,
|
||||
qualifications,
|
||||
|
|
@ -134,7 +111,7 @@ class PersonnelRecord extends Equatable {
|
|||
];
|
||||
|
||||
@override
|
||||
String toString() => 'PersonnelRecord(id: $soldierId, label: $displayLabel, '
|
||||
String toString() => 'PersonnelRecord(id: $soldierId, name: $name, '
|
||||
'mos: ${primaryMos.code}, grade: ${payGrade.payGrade}, '
|
||||
'readiness: ${readinessState.displayName})';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
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,9 +74,6 @@ class DutyMos {
|
|||
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].
|
||||
static Set<String> codesForBranch(DoctrinalBranch branch) {
|
||||
return _branchMosMap[branch] ?? {};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.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/soldier_positions_dao.dart';
|
||||
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
|
||||
import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart';
|
||||
|
||||
|
|
@ -42,8 +45,7 @@ class PositionSlots extends Table {
|
|||
/// Drift table for soldier personnel records.
|
||||
class Personnel extends Table {
|
||||
TextColumn get soldierId => text().named('soldier_id')();
|
||||
TextColumn get name => text().nullable()();
|
||||
TextColumn get positionTitle => text().named('position_title').nullable()();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get primaryMos => text().named('primary_mos')();
|
||||
TextColumn get payGrade => text().named('pay_grade')();
|
||||
TextColumn get qualifications => text()(); // JSON-encoded list
|
||||
|
|
@ -54,16 +56,6 @@ class Personnel extends Table {
|
|||
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.
|
||||
class Templates extends Table {
|
||||
TextColumn get templateId => text().named('template_id')();
|
||||
|
|
@ -83,11 +75,11 @@ class Templates extends Table {
|
|||
|
||||
/// The main Drift database for the UTO Manager application.
|
||||
///
|
||||
/// Manages all core tables: units, position_slots, personnel, soldier_positions, templates.
|
||||
/// Manages all four core tables: units, position_slots, personnel, templates.
|
||||
/// Supports SQLite on Windows/Android and IndexedDB on Web via drift_web.
|
||||
@DriftDatabase(
|
||||
tables: [Units, PositionSlots, Personnel, SoldierPositions, Templates],
|
||||
daos: [UnitDao, PersonnelDao, PositionSlotDao, SoldierPositionsDao, TemplateDao],
|
||||
tables: [Units, PositionSlots, Personnel, Templates],
|
||||
daos: [UnitDao, PersonnelDao, PositionSlotDao, TemplateDao],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
/// Creates the production database backed by SQLite on disk.
|
||||
|
|
@ -97,7 +89,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -105,25 +97,16 @@ class AppDatabase extends _$AppDatabase {
|
|||
await m.createAll();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
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);
|
||||
}
|
||||
// Future migration steps added here as schemaVersion increases.
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens a platform-adaptive database connection.
|
||||
///
|
||||
/// Uses SQLite via FFI on Windows/Android and WASM/IndexedDB on Web.
|
||||
QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
name: 'uto_manager',
|
||||
web: DriftWebOptions(
|
||||
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
|
||||
driftWorker: Uri.parse('drift_worker.js'),
|
||||
),
|
||||
);
|
||||
/// Opens the SQLite database connection for native platforms.
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, 'uto_manager.sqlite'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
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,7 +26,6 @@ class DriftPersonnelRepository implements PersonnelRepository {
|
|||
return domain.PersonnelRecord(
|
||||
soldierId: row.soldierId,
|
||||
name: row.name,
|
||||
positionTitle: row.positionTitle,
|
||||
primaryMos: DutyMos.unvalidated(row.primaryMos),
|
||||
payGrade: AuthorizedRank.values.firstWhere((r) => r.name == row.payGrade),
|
||||
qualifications: (jsonDecode(row.qualifications) as List<dynamic>).cast<String>(),
|
||||
|
|
@ -40,8 +39,7 @@ class DriftPersonnelRepository implements PersonnelRepository {
|
|||
PersonnelCompanion _toCompanion(domain.PersonnelRecord record) {
|
||||
return PersonnelCompanion.insert(
|
||||
soldierId: record.soldierId,
|
||||
name: Value(record.name),
|
||||
positionTitle: Value(record.positionTitle),
|
||||
name: record.name,
|
||||
primaryMos: record.primaryMos.code,
|
||||
payGrade: record.payGrade.name,
|
||||
qualifications: jsonEncode(record.qualifications),
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
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,7 +9,6 @@ import 'package:uto_manager/domain/services/unit_tree_service.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_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/presentation/screens/home_screen.dart';
|
||||
|
||||
|
|
@ -33,7 +32,6 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
|||
late final DriftUnitRepository _unitRepo;
|
||||
late final DriftPersonnelRepository _personnelRepo;
|
||||
late final DriftPositionSlotRepository _slotRepo;
|
||||
late final DriftSoldierPositionsRepository _positionsRepo;
|
||||
late final UnitTreeService _treeService;
|
||||
late final AssignmentService _assignmentService;
|
||||
|
||||
|
|
@ -44,7 +42,6 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
|||
_unitRepo = DriftUnitRepository(dao: _db.unitDao, db: _db);
|
||||
_personnelRepo = DriftPersonnelRepository(dao: _db.personnelDao);
|
||||
_slotRepo = DriftPositionSlotRepository(dao: _db.positionSlotDao);
|
||||
_positionsRepo = DriftSoldierPositionsRepository(dao: _db.soldierPositionsDao);
|
||||
_treeService = const UnitTreeService();
|
||||
_assignmentService = AssignmentService(treeService: _treeService);
|
||||
}
|
||||
|
|
@ -94,7 +91,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
|||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: HomeScreen(positionsRepository: _positionsRepo),
|
||||
home: const HomeScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,404 +0,0 @@
|
|||
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!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
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,5 +1,3 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
|
||||
|
|
@ -7,15 +5,10 @@ import 'package:uto_manager/application/blocs/unit_tree_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/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/infrastructure/codecs/pdf_chart_exporter.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/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/position_slot_card.dart';
|
||||
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
||||
|
|
@ -27,9 +20,7 @@ import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
|||
/// - Center: Interactive org chart canvas
|
||||
/// - Right panel: Unit inspector (selected unit's position slots + validation)
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({required this.positionsRepository, super.key});
|
||||
|
||||
final DriftSoldierPositionsRepository positionsRepository;
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -57,22 +48,6 @@ class HomeScreen extends StatelessWidget {
|
|||
backgroundColor: const Color(0xFF1B3A6B),
|
||||
foregroundColor: Colors.white,
|
||||
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
|
||||
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||||
builder: (context, treeState) {
|
||||
|
|
@ -126,7 +101,7 @@ class HomeScreen extends StatelessWidget {
|
|||
const VerticalDivider(width: 1),
|
||||
// Right panel: Unit inspector
|
||||
SizedBox(
|
||||
width: 300,
|
||||
width: 280,
|
||||
child: _buildInspectorPanel(context),
|
||||
),
|
||||
],
|
||||
|
|
@ -154,28 +129,13 @@ class HomeScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'PERSONNEL ROSTER',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'PERSONNEL ROSTER',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
BlocBuilder<PersonnelCubit, PersonnelState>(
|
||||
|
|
@ -205,40 +165,10 @@ class HomeScreen extends StatelessWidget {
|
|||
}
|
||||
if (state is PersonnelLoaded) {
|
||||
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) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No matches',
|
||||
'No unassigned soldiers',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -247,34 +177,13 @@ class HomeScreen extends StatelessWidget {
|
|||
}
|
||||
return ListView.builder(
|
||||
itemCount: soldiers.length,
|
||||
itemBuilder: (context, i) => PersonnelListTile(
|
||||
record: soldiers[i],
|
||||
onTap: () => _showEditPersonnelDialog(context, soldiers[i]),
|
||||
),
|
||||
itemBuilder: (context, i) => PersonnelListTile(record: soldiers[i]),
|
||||
);
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -323,29 +232,12 @@ class HomeScreen extends StatelessWidget {
|
|||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
'Select a unit on the canvas to inspect its position slots.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -362,61 +254,20 @@ class HomeScreen extends StatelessWidget {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Inspector header with edit/delete actions
|
||||
// Inspector header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'UNIT INSPECTOR',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'UNIT INSPECTOR',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
|
|
@ -459,68 +310,14 @@ 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
|
||||
Expanded(
|
||||
child: slots.isEmpty
|
||||
? Center(
|
||||
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',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
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),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
'No position slots defined',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -536,8 +333,6 @@ class HomeScreen extends StatelessWidget {
|
|||
assignedSoldier: soldier,
|
||||
onUnassign: slot.isFilled ? () => _unassign(context, slot) : null,
|
||||
onAcceptDrop: (soldier) => _assign(context, soldier, slot),
|
||||
onEdit: () => _showEditSlotDialog(context, slot),
|
||||
onDelete: () => _confirmDeleteSlot(context, slot),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -607,18 +402,6 @@ class HomeScreen extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
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
|
||||
BlocBuilder<ValidationCubit, ValidationState>(
|
||||
builder: (context, valState) {
|
||||
|
|
@ -700,7 +483,7 @@ class HomeScreen extends StatelessWidget {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignment Actions
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _assign(BuildContext context, PersonnelRecord soldier, PositionSlot slot) {
|
||||
|
|
@ -711,172 +494,6 @@ class HomeScreen extends StatelessWidget {
|
|||
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) {
|
||||
final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded
|
||||
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
|
||||
|
|
@ -1011,10 +628,7 @@ class _ExportDialogState extends State<_ExportDialog> {
|
|||
color: Colors.red.shade900.withAlpha(80),
|
||||
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,23 +5,15 @@ import 'package:uto_manager/presentation/widgets/position_slot_card.dart' show P
|
|||
/// A draggable list tile representing a soldier in the personnel panel.
|
||||
///
|
||||
/// 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 {
|
||||
const PersonnelListTile({
|
||||
required this.record,
|
||||
super.key,
|
||||
required this.record, super.key,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final PersonnelRecord record;
|
||||
|
||||
/// Called when the tile is tapped (opens edit dialog).
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Called when the user selects Delete from the context menu.
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -48,7 +40,7 @@ class PersonnelListTile extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
record.displayLabel,
|
||||
record.name,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
|
|
@ -92,7 +84,7 @@ class PersonnelListTile extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
title: Text(
|
||||
record.displayLabel,
|
||||
record.name,
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ class PositionSlotCard extends StatelessWidget {
|
|||
this.assignedSoldier,
|
||||
this.onUnassign,
|
||||
this.onAcceptDrop,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final PositionSlot slot;
|
||||
|
|
@ -22,12 +20,6 @@ class PositionSlotCard extends StatelessWidget {
|
|||
final VoidCallback? onUnassign;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -75,48 +67,21 @@ class PositionSlotCard extends StatelessWidget {
|
|||
fontWeight: slot.isVacant ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
trailing: _buildTrailing(theme),
|
||||
trailing: slot.isFilled && onUnassign != null
|
||||
? 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) {
|
||||
return Container(
|
||||
width: 40,
|
||||
|
|
|
|||
|
|
@ -642,27 +642,7 @@ This document breaks the master development plan into five structured phases spa
|
|||
|
||||
---
|
||||
|
||||
## 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.
|
||||
## Phase 5: Platform Optimization, Profiling, & Hardening
|
||||
|
||||
### Weeks 20 – 24
|
||||
|
||||
|
|
|
|||
48
pubspec.lock
48
pubspec.lock
|
|
@ -5,18 +5,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "96.0.0"
|
||||
version: "100.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "13.0.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -125,10 +125,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
version: "0.5.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -181,34 +181,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
version: "3.1.9"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||
sha256: "3a3f1f6f905037d7426e4c445854139fd6a3d592135f7c96d7931682b73d16f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
version: "2.34.3"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||
sha256: "735aad3c34215805c66bd518c8812fa4f83b5534b2c13c4092c970c04a7e9983"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_flutter
|
||||
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
version: "2.34.5"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -435,6 +427,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.2"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -628,10 +628,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
version: "3.5.1"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -644,10 +644,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||
sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.43.1"
|
||||
version: "0.45.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ environment:
|
|||
dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
drift: ^2.20.3
|
||||
drift_flutter: ^0.2.8
|
||||
equatable: ^2.0.5
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -11,13 +11,11 @@ import 'package:uto_manager/domain/services/unit_tree_service.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_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/presentation/screens/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late DriftSoldierPositionsRepository positionsRepo;
|
||||
late UnitTreeCubit unitTreeCubit;
|
||||
late PersonnelCubit personnelCubit;
|
||||
late TemplateCubit templateCubit;
|
||||
|
|
@ -30,7 +28,6 @@ void main() {
|
|||
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
|
||||
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
|
||||
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
|
||||
positionsRepo = DriftSoldierPositionsRepository(dao: db.soldierPositionsDao);
|
||||
const assignmentService = AssignmentService(treeService: treeService);
|
||||
|
||||
// Create cubits but do NOT call startWatching() — avoids live streams.
|
||||
|
|
@ -69,7 +66,7 @@ void main() {
|
|||
BlocProvider<TemplateCubit>.value(value: templateCubit),
|
||||
BlocProvider<ValidationCubit>.value(value: validationCubit),
|
||||
],
|
||||
child: MaterialApp(home: HomeScreen(positionsRepository: positionsRepo)),
|
||||
child: const MaterialApp(home: HomeScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import 'package:drift/wasm.dart';
|
||||
|
||||
void main() {
|
||||
WasmDatabase.workerMainForOpen();
|
||||
}
|
||||
14475
web/drift_worker.js
14475
web/drift_worker.js
File diff suppressed because one or more lines are too long
BIN
web/sqlite3.wasm
BIN
web/sqlite3.wasm
Binary file not shown.
Loading…
Reference in New Issue