feat(phase-5): full CRUD UI, SoldierPosition entity, DB schema v2, web platform support

This commit is contained in:
UTO Manager Dev 2026-08-09 11:50:40 -04:00
parent 2f02ad4d49
commit e52d3406a6
29 changed files with 17201 additions and 891 deletions

4
.gitignore vendored
View File

@ -49,6 +49,10 @@ web/version.json
web/main.dart.js web/main.dart.js
web/main.dart.js.map web/main.dart.js.map
# Drift web worker build artifacts (keep drift_worker.js, sqlite3.wasm, drift_worker.dart)
web/drift_worker.js.deps
web/drift_worker.js.map
# Flutter Windows build artifacts # Flutter Windows build artifacts
windows/flutter/generated_plugin_registrant.cc windows/flutter/generated_plugin_registrant.cc
windows/flutter/generated_plugin_registrant.h windows/flutter/generated_plugin_registrant.h

View File

@ -25,12 +25,14 @@ The system models military formations from individual soldier positions up to mu
## Key Features ## Key Features
- **Interactive Visual Canvas** — GPU-accelerated `CustomPainter` engine with pan/zoom, viewport culling, and 60 FPS rendering at 5,000+ nodes - **Interactive Visual Canvas** — GPU-accelerated `CustomPainter` engine with pan/zoom, viewport culling, and 60 FPS rendering at 5,000+ nodes
- **Full CRUD UI** — Add, edit, and delete units, position slots, and soldiers via modal dialogs with live MOS autocomplete
- **Drag-and-Drop Re-parenting** — Cross-platform state machine with doctrinal validation highlights (green/red) and undo/redo support - **Drag-and-Drop Re-parenting** — Cross-platform state machine with doctrinal validation highlights (green/red) and undo/redo support
- **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android - **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android *(planned)*
- **2026 Doctrinal Templates** — Pre-configured JSON templates for MBCT, SBCT, ABCT, CAB-Heavy, and CAB-Light formations - **2026 Doctrinal Templates** — Pre-configured JSON templates for MBCT, SBCT, ABCT, CAB-Heavy, and CAB-Light formations
- **Real-Time MTOE Validation** — Background Dart Isolate engine detecting MOS mismatches, rank violations, vacant leadership slots, and structural hierarchy errors - **Real-Time MTOE Validation** — Background Dart Isolate engine detecting MOS mismatches, rank violations, vacant leadership slots, and structural hierarchy errors
- **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments - **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments
- **Multi-Format Export** — JSON, Protobuf, and vector PDF organizational chart export - **Multi-Format Export** — JSON, Protobuf (`.utob`), and vector PDF organizational chart export
- **Web Platform Support** — Full WASM/IndexedDB persistence via `drift_worker.js` and `sqlite3.wasm`
--- ---
@ -243,11 +245,11 @@ dart run dart_code_metrics:metrics analyze lib
| Phase | Title | Weeks | Status | | Phase | Title | Weeks | Status |
|---|---|---|---| |---|---|---|---|
| Phase 1 | Architecture & Data Foundations | 1 – 4 | ⬜ Not Started | | Phase 1 | Architecture & Data Foundations | 1 – 4 | ✅ Complete |
| Phase 2 | Canvas Graphics Engine & Gesture Subsystem | 5 – 9 | ⬜ Not Started | | Phase 2 | Canvas Graphics Engine & Persistence | 5 – 9 | ✅ Complete |
| Phase 3 | 2026 Doctrinal Templates & Symbology Engine | 10 – 14 | ⬜ Not Started | | Phase 3 | Template Library & Import/Export | 10 – 14 | ✅ Complete |
| Phase 4 | Persistence, Validation Rules, & Export Engine | 15 – 19 | ⬜ Not Started | | Phase 4 | Reporting & Analytics | 15 – 19 | ✅ Complete |
| Phase 5 | Platform Optimization, Profiling, & Hardening | 20 – 24 | ⬜ Not Started | | Phase 5 | UI/CRUD Layer & Web Platform | 20 – 24 | ✅ Complete |
See [`development_status.md`](development_status.md) for detailed task tracking and [`phased_development_plan.md`](phased_development_plan.md) for full phase specifications. See [`development_status.md`](development_status.md) for detailed task tracking and [`phased_development_plan.md`](phased_development_plan.md) for full phase specifications.

View File

@ -1,12 +1,12 @@
# UTO Manager — Development Status # UTO Manager — Development Status
Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main` Last updated: 2026-08-09 — Phase 5 UI/CRUD layer merged to `main`
--- ---
## Current Branch ## Current Branch
`main` (fix/phase-4-analyzer-infos merged) `main` (Phase 5 UI/CRUD layer merged)
## Overall Progress ## Overall Progress
@ -16,7 +16,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
| 2 | Canvas Engine & Persistence | ✅ Complete | | 2 | Canvas Engine & Persistence | ✅ Complete |
| 3 | Template Library & Import/Export | ✅ Complete | | 3 | Template Library & Import/Export | ✅ Complete |
| 4 | Reporting & Analytics | ✅ Complete | | 4 | Reporting & Analytics | ✅ Complete |
| 5 | Multi-user / Sync | 🔲 Not started | | 5 | UI/CRUD Layer & Web Platform | ✅ Complete |
--- ---
@ -204,6 +204,132 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
--- ---
## Phase 5 — UI/CRUD Layer & Web Platform ✅
**Completed items:**
### Domain Entities (Phase 5)
- `SoldierPosition` entity — named duty position for assignment to personnel records:
- `positionId` (UUID v4 for custom; fixed string for built-ins)
- `title` — human-readable position title (e.g. "Team Leader", "Rifleman")
- `isBuiltIn` — true for app-seeded positions; false for user-created ones
- `copyWith`, `Equatable` props, `toString`
- `SoldierPosition.builtIns` — 31 pre-defined Army duty positions across all branches:
- Command/Staff: Commander, XO, CSM, First Sergeant, S2/S3/S4/S6
- Platoon: Platoon Leader, Platoon Sergeant, RTO
- Squad/Section: Squad Leader, Team Leader
- Individual: Rifleman, Grenadier, Automatic Rifleman, Machine Gunner, Sniper,
JTAC/Fire Support, Medic, Combat Engineer, Driver, Gunner, Loader,
Tank Commander, Crew Member, Supply Sergeant, Armorer, Commo Sergeant,
Intelligence Analyst, Forward Observer, CBRN Specialist
### Domain Entities — Extended (Phase 5)
- `PersonnelRecord` — Extended with:
- `positionTitle` field (optional) — duty position title from `SoldierPosition`
- `displayLabel` getter — prefers `name`; falls back to `positionTitle`; then `(Unnamed Soldier)`
- `isAssigned` getter — true when `assignedSlotId != null`
- `isFmc` getter — true when `readinessState == fullyMissionCapable`
- `copyWith` — extended with `clearName`, `clearPositionTitle`, `clearAssignment` flags
### Infrastructure — Database (Phase 5)
- `SoldierPositions` Drift table — persists named duty positions:
- `position_id` (TEXT, PK), `title` (TEXT), `is_built_in` (BOOLEAN, default false)
- `SoldierPositionsDao` — full DAO for `SoldierPositions`:
- `getAll()`, `getById(positionId)`, `upsert()`, `upsertAll()`, `updatePosition()`,
`deletePosition()`, `watchAll()`
- `AppDatabase` — schema version bumped to **v2**:
- Migration v1→v2: adds `position_title` column to `Personnel` table; creates `soldier_positions` table
- Web platform support:
- `drift_worker.dart` / `drift_worker.js` — Drift web worker for IndexedDB persistence
- `sqlite3.wasm` — SQLite WASM binary for web target
- `_openConnection()` updated to use `driftDatabase()` (platform-adaptive: SQLite on native, WASM/IndexedDB on web)
### Infrastructure — Repositories (Phase 5)
- `DriftSoldierPositionsRepository` — Drift-backed repository for `SoldierPosition`:
- `getAll()` — returns all positions (built-in + custom)
- `watchAll()` — reactive stream of all positions
- `upsert(position)` — insert or update a position
- `seedBuiltIns()` — idempotent seed of all `SoldierPosition.builtIns` on first launch
- `delete(positionId)` — deletes custom positions only; no-op for built-ins
### Application — BLoC (Phase 5)
- `UnitTreeCubit` — Extended with full CRUD operations:
- `addUnit(unit)` — inserts a new `UnitNode`
- `updateUnit(unit)` — updates an existing `UnitNode`
- `addSlot(slot)` — inserts a new `PositionSlot`
- `updateSlot(slot)` — updates an existing `PositionSlot`
- `deleteSlot(slotId)` — deletes a position slot
- `clearAll()` — deletes all slots then all units (template reset)
- `PersonnelCubit` — Extended with full CRUD operations:
- `addPersonnel(record)` — inserts a new `PersonnelRecord`
- `updatePersonnel(record)` — updates an existing `PersonnelRecord`
- `deletePersonnel(soldierId)` — deletes soldier; clears slot assignment first if assigned
### Presentation — Dialogs (Phase 5)
- `AddEditPersonnelDialog` — full-featured dialog for creating/editing soldiers:
- Position dropdown (loaded from `DriftSoldierPositionsRepository`) with add/rename/delete custom positions
- Optional full name field (Last, First MI format)
- Pay grade dropdown (all `AuthorizedRank` values)
- Primary MOS field with live autocomplete suggestions from `DutyMos.allCodes`
- Readiness state dropdown
- MOS validation against known codes
- `AddEditSlotDialog` — dialog for creating/editing position slots:
- Position title text field
- Authorized rank dropdown
- Duty MOS field with live autocomplete suggestions
- Key leader toggle switch
- `AddEditUnitDialog` — dialog for creating/editing unit nodes:
- Optional unit name/designation field (auto-generates from echelon + branch if blank)
- Echelon dropdown
- Branch/functional area dropdown
- Optional parent unit dropdown (excludes self and descendants)
- Key leader unit toggle switch
### Presentation — HomeScreen (Phase 5)
- App bar actions:
- **Add Unit** (`add_box_outlined`) — opens `AddEditUnitDialog`
- **Add Soldier** (`person_add_outlined`) — opens `AddEditPersonnelDialog`
- **Run MTOE Validation** (`fact_check_outlined`) — triggers `ValidationCubit.validate()`
- **Export** (`upload_file_outlined`) — opens export format selection dialog
- **Clear All** (`delete_sweep_outlined`) — confirmation dialog to reset all units/slots
- Personnel panel:
- Search/filter bar (name, MOS, rank)
- Tap soldier tile to edit via `AddEditPersonnelDialog`
- Assigned/unassigned count footer
- Empty-state prompt with "Add Soldier" button
- Inspector panel:
- Edit unit button → `AddEditUnitDialog`
- Add child unit button → `AddEditUnitDialog` (pre-selected parent)
- Delete unit button → confirmation dialog (cascades to subtree)
- Validation findings section (per-unit, grouped by severity with color-coded icons)
- Position slots header with fill count and "Add Slot" button
- Per-slot edit and delete actions via `AddEditSlotDialog`
- Empty-state prompts for no units selected and no slots defined
- Export dialog (`_ExportDialog`):
- Format selection: JSON, Binary (`.utob`), PDF
- PDF page size selector (Letter, A4, Tabloid)
- Export result/error feedback inline
- Status bar:
- Unit count, slot fill rate (filled/total + percentage)
- Total soldier count
- Validation state: running spinner, clean indicator, or critical/warning/advisory counts
### App Wiring (Phase 5)
- `main.dart` — Added `DriftSoldierPositionsRepository` wired to `HomeScreen`
- `HomeScreen` — now requires `positionsRepository` parameter
**Test count (Phase 5):** existing 263 tests; widget test updated for new `HomeScreen` signature
---
## Test Summary ## Test Summary
| File | Tests | Status | | File | Tests | Status |
@ -227,15 +353,18 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
--- ---
## Known Limitations / Next Steps (Phase 5) ## Known Limitations / Next Steps (Phase 5 Hardening)
- No multi-user or real-time sync capability (Phase 5) - No multi-user or real-time sync capability (Phase 5 hardening)
- Canvas layout is vertical tree only; horizontal layout not yet implemented - Canvas layout is vertical tree only; horizontal layout not yet implemented
- No undo/redo for assignment operations - No undo/redo for assignment operations
- No search/filter on the canvas itself - No search/filter on the canvas itself
- `DeltaSynchronizer` uses simulated online/offline; real network transport deferred to Phase 5 - `DeltaSynchronizer` uses simulated online/offline; real network transport deferred
- `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred to Phase 5 - `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred
- Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts) - Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts)
- MIL-STD-2525D symbology engine not yet implemented (deferred)
- SQLCipher data-at-rest encryption not yet integrated (deferred)
- No undo/redo stack for unit/slot/personnel CRUD operations
--- ---
@ -245,6 +374,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
lib/ lib/
├── domain/ ├── domain/
│ ├── entities/ # Pure Dart domain models (no Flutter deps) │ ├── entities/ # Pure Dart domain models (no Flutter deps)
│ │ # UnitNode, PositionSlot, PersonnelRecord, SoldierPosition
│ ├── value_objects/ # DutyMos (supports 3-char and 4-char AOC formats) │ ├── value_objects/ # DutyMos (supports 3-char and 4-char AOC formats)
│ ├── repositories/ # Abstract interfaces │ ├── repositories/ # Abstract interfaces
│ └── services/ # UnitTreeService, AssignmentService, │ └── services/ # UnitTreeService, AssignmentService,
@ -254,12 +384,18 @@ lib/
├── infrastructure/ ├── infrastructure/
│ ├── codecs/ # UnitTreeCodec (JSON), ProtobufCodec (binary), │ ├── codecs/ # UnitTreeCodec (JSON), ProtobufCodec (binary),
│ │ # PdfChartExporter (PDF org chart) │ │ # PdfChartExporter (PDF org chart)
│ ├── database/ # Drift schema, DAOs, generated code │ ├── database/ # Drift schema (v2), DAOs, generated code
│ │ # Tables: Units, PositionSlots, Personnel,
│ │ # SoldierPositions, Templates
│ └── repositories/ # Drift-backed implementations │ └── repositories/ # Drift-backed implementations
│ # DriftSoldierPositionsRepository (new in Phase 5)
├── application/ ├── application/
│ └── blocs/ # UnitTreeCubit, PersonnelCubit, │ └── blocs/ # UnitTreeCubit, PersonnelCubit,
│ # TemplateCubit, ValidationCubit │ # TemplateCubit, ValidationCubit
└── presentation/ └── presentation/
├── screens/ # HomeScreen (with PDF/binary export + validation UI) ├── screens/
│ ├── home_screen.dart # Full CRUD UI + export dialog
│ └── dialogs/ # AddEditPersonnelDialog, AddEditSlotDialog,
│ # AddEditUnitDialog (all new in Phase 5)
└── widgets/ # Canvas, cards, tiles └── widgets/ # Canvas, cards, tiles
``` ```

3
devtools_options.yaml Normal file
View File

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

View File

@ -52,15 +52,16 @@ This guide covers the technical architecture, implementation details, and contri
|---|---| |---|---|
| UI Framework | Flutter 3.x (Dart 3.x) | | UI Framework | Flutter 3.x (Dart 3.x) |
| Rendering | CustomPainter + InteractiveViewer | | Rendering | CustomPainter + InteractiveViewer |
| State Management | BLoC + Signals | | State Management | BLoC (flutter_bloc) |
| Drag-and-Drop | super_drag_and_drop | | Drag-and-Drop | Flutter DragTarget / Draggable |
| Local Database | Drift (SQLite / IndexedDB) | | Local Database | Drift v2 (SQLite / IndexedDB) — schema v2 |
| Encryption | SQLCipher | | Web DB Worker | drift_worker.js + sqlite3.wasm |
| Symbology (Web) | milsymbol.js via dart:js_interop | | Encryption | SQLCipher *(planned)* |
| Symbology (Native) | path_drawing + SVG assets + LRU cache | | Symbology (Web) | milsymbol.js via dart:js_interop *(planned)* |
| Background Compute | Dart Isolates | | Symbology (Native) | path_drawing + SVG assets + LRU cache *(planned)* |
| Serialization | JSON + Protobuf | | Background Compute | Dart Isolates (IsolateValidationRunner) |
| PDF Export | pdf package | | Serialization | JSON (UnitTreeCodec) + Binary (ProtobufCodec) |
| PDF Export | Pure-Dart PDF 1.4 (PdfChartExporter) |
| Documentation | MkDocs + Material theme | | Documentation | MkDocs + Material theme |
--- ---

View File

@ -67,6 +67,6 @@ Welcome to the **UTO Manager** documentation. Use the navigation tabs above to f
UTO Manager is a cross-platform C2 application built with **Flutter** targeting Windows x64, Web (WASM/CanvasKit), and Android from a single Dart codebase. The system enforces U.S. Army MTOE doctrinal rules in real time, supporting formations from individual soldier positions up to multi-domain Brigade Combat Teams. UTO Manager is a cross-platform C2 application built with **Flutter** targeting Windows x64, Web (WASM/CanvasKit), and Android from a single Dart codebase. The system enforces U.S. Army MTOE doctrinal rules in real time, supporting formations from individual soldier positions up to multi-domain Brigade Combat Teams.
**Current Version:** Pre-release (Phase 1 — Architecture & Data Foundations) **Current Version:** Pre-release (Phases 1–5 complete — UI/CRUD Layer & Web Platform)
**Project Repository:** [git.kellsupport.com/uto_manager](https://git.kellsupport.com/uto_manager) **Project Repository:** [git.kellsupport.com/uto_manager](https://git.kellsupport.com/uto_manager)

View File

@ -14,8 +14,9 @@
| Milestone | Target | Deliverable | | Milestone | Target | Deliverable |
|---|---|---| |---|---|---|
| M1 — End of Week 4 | TBD | Compilable multi-platform skeleton + Drift DB + CI pipeline | | M1 — End of Week 4 | 2026-08-07 ✅ | Compilable multi-platform skeleton + Drift DB + domain entities |
| M2 — End of Week 9 | TBD | Functional canvas engine with drag-and-drop on Windows and Android | | M2 — End of Week 9 | 2026-08-08 ✅ | Functional canvas engine with drag-and-drop on Windows and Android |
| M3 — End of Week 14 | TBD | All BCT templates instantiable + MIL-STD-2525D symbols rendering | | M3 — End of Week 14 | 2026-08-09 ✅ | All BCT templates instantiable + MTOE validation engine |
| M4 — End of Week 19 | TBD | Full persistence + real-time MTOE validation + JSON/PDF export | | M4 — End of Week 19 | 2026-08-09 ✅ | Full persistence + real-time MTOE validation + JSON/PDF/binary export |
| M5 — End of Week 24 | TBD | Signed release packages for Windows, Web, and Android | | M5 — End of Week 24 | 2026-08-09 ✅ | Full CRUD UI + web platform support + DB schema v2 |
| M6 — Hardening | TBD | MIL-STD-2525D symbology, SQLCipher encryption, signed release packages |

View File

@ -2,58 +2,111 @@
## Status: 🔵 In Development ## Status: 🔵 In Development
**Target:** End of Week 24 **Target:** End of hardening phase
--- ---
## Planned Features ## Implemented Features (Phases 1–5)
### Core Platform ### Core Platform
- Cross-platform Flutter application targeting Windows x64, Web (WASM), and Android - Cross-platform Flutter application targeting Windows x64, Web (WASM), and Android
- GPU-accelerated `CustomPainter` canvas engine with viewport culling - GPU-accelerated `CustomPainter` canvas engine with viewport culling
- 60 FPS rendering at 5,000+ nodes (Windows), 2,000+ (Web), 1,500+ (Android) - Three-pane adaptive layout: personnel roster | org chart canvas | unit inspector
- Offline-first Drift/SQLite persistence with IndexedDB on Web (schema v2)
- Web platform: `drift_worker.js` + `sqlite3.wasm` for full WASM/IndexedDB support
### Organizational Modeling ### Organizational Modeling
- Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord` - Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord`, `SoldierPosition`
- MOS validation across all functional branches (Infantry, Medical, Fires, Signal, Cyber/EW, Aviation, Logistics, Engineer) - MOS validation across all functional branches (Infantry, Medical, Fires, Signal, Cyber/EW, Aviation, Logistics, Engineer)
- 2026 doctrinal templates: MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light - 2026 doctrinal templates: MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light
- 31 built-in Army duty positions (Command/Staff, Platoon, Squad, Individual) + user-defined custom positions
### Full CRUD User Interface
- **Add/Edit/Delete Units** — `AddEditUnitDialog` with echelon, branch, parent, and key-leader fields
- **Add/Edit/Delete Position Slots** — `AddEditSlotDialog` with MOS autocomplete and key-leader toggle
- **Add/Edit/Delete Soldiers** — `AddEditPersonnelDialog` with position picker, MOS autocomplete, pay grade, and readiness state
- **Custom Position Management** — create, rename, and delete custom duty positions from within the personnel dialog
- **Clear All** — confirmation-gated reset of all units, slots, and assignments
### Visual Canvas ### Visual Canvas
- Reingold-Tilford hierarchical tree layout algorithm - `InteractiveViewer` pan/zoom org chart
- Quadtree spatial index for hit-testing and drag proximity queries - Drag-and-drop soldier assignment (drag from personnel panel → drop on position slot card)
- Cross-platform drag-and-drop re-parenting with doctrinal validation highlights - Drag-and-drop unit re-parenting with doctrinal validation highlights
- MIL-STD-2525D tactical symbology rendering - Validation node indicators: red border for critical findings, amber for warnings
### Persistence & Validation ### MTOE Validation Engine
- Drift/SQLite offline-first persistence with IndexedDB on Web - 7 validation rules (V001–V007): MOS mismatch, rank mismatch, vacant key leader, missing 68W medic, missing 13F FSO, structural hierarchy violation, manning below 70%
- Real-time MTOE validation engine (MOS mismatch, rank mismatch, vacant leadership, structural violations) - Background Dart Isolate execution via `IsolateValidationRunner`
- Delta synchronization for contested network environments - Findings displayed in inspector panel (per-unit, color-coded by severity) and status bar
- SQLCipher data-at-rest encryption
### Persistence & Sync
- Drift schema v2: `units`, `position_slots`, `personnel`, `soldier_positions`, `templates`
- Schema migration v1→v2: adds `position_title` to `Personnel`, creates `soldier_positions` table
- `CommandEventStore` — append-only event log for all structural mutations
- `DeltaSynchronizer` — offline-first sync helper (simulated transport; real network deferred)
### Export ### Export
- JSON export/import with doctrinal validation - **JSON export/import** — full unit tree with schema versioning and round-trip fidelity
- Protobuf binary export - **Binary export (`.utob`)** — `ProtobufCodec` with 12-byte UTOB header + zlib-compressed JSON
- Vector PDF organizational chart export - **PDF export** — pure-Dart PDF 1.4 org-chart generator (Letter/A4/Tabloid, UNCLASSIFIED banner)
- Export dialog with format selection and PDF page-size picker
### Platform-Specific ---
- Windows: multi-window, high-DPI scaling, `.msi` installer, `.uto` file association ## Planned Features (Hardening Phase)
- Web: PWA offline capability, IndexedDB quota management
### Visual Symbology
- MIL-STD-2525D tactical symbology rendering (hybrid: `milsymbol.js` on Web, SVG path engine on native)
### Security
- SQLCipher data-at-rest encryption for local Drift database
- Platform keychain key storage (Windows Credential Manager / Android Keystore)
### Platform Hardening
- Windows: multi-window support, `.msi` installer, `.uto` file association
- Web: PWA offline capability, IndexedDB quota management, service worker
- Android: battery profiling, touch-optimized targets (48×48 dp minimum) - Android: battery profiling, touch-optimized targets (48×48 dp minimum)
### Performance
- Reingold-Tilford layout algorithm in background Dart Isolate
- Quadtree spatial index for hit-testing
- `RepaintBoundary` layer isolation
- 60 FPS at 5,000+ nodes (Windows), 2,000+ (Web), 1,500+ (Android)
### UX
- Undo/redo stack for all CRUD operations
- Canvas search/filter
- Keyboard shortcuts (Ctrl+Z, Ctrl+F, Ctrl+D, Ctrl+S)
--- ---
## Known Limitations ## Known Limitations
*To be documented upon release.* - MIL-STD-2525D symbology not yet rendered (nodes show text labels only)
- No data-at-rest encryption (SQLCipher deferred)
- `DeltaSynchronizer` uses simulated online/offline; no real network transport
- `ProtobufCodec` uses zlib-compressed JSON payload; true `.proto` schema deferred
- Template instantiation uses sequential Drift inserts (not a single transaction)
- No undo/redo for CRUD operations
- Canvas layout is vertical tree only; horizontal layout not implemented
- No canvas search/filter
--- ---
## Upgrade Instructions ## Upgrade Instructions
*Initial release — no upgrade path required.* *Initial release — no upgrade path required.*
*Existing databases at schema v1 will be automatically migrated to v2 on first launch.*

View File

@ -52,7 +52,7 @@ class PersonnelLoaded extends PersonnelState {
if (filterQuery.isEmpty) return unassigned; if (filterQuery.isEmpty) return unassigned;
final q = filterQuery.toLowerCase(); final q = filterQuery.toLowerCase();
return unassigned.where((p) { return unassigned.where((p) {
return p.name.toLowerCase().contains(q) || return p.displayLabel.toLowerCase().contains(q) ||
p.primaryMos.code.toLowerCase().contains(q) || p.primaryMos.code.toLowerCase().contains(q) ||
p.payGrade.payGrade.toLowerCase().contains(q); p.payGrade.payGrade.toLowerCase().contains(q);
}).toList(); }).toList();
@ -187,6 +187,35 @@ class PersonnelCubit extends Cubit<PersonnelState> {
} }
} }
/// Updates an existing [record] in the personnel roster.
Future<void> updatePersonnel(PersonnelRecord record) async {
try {
await personnelRepository.update(record);
} catch (e) {
emit(PersonnelError(e.toString()));
}
}
/// Deletes the soldier with [soldierId] from the roster.
///
/// If the soldier is currently assigned to a slot, the slot assignment
/// is cleared first.
Future<void> deletePersonnel(String soldierId) async {
try {
// If assigned, clear the slot assignment first
if (state is PersonnelLoaded) {
final loaded = state as PersonnelLoaded;
final record = loaded.allPersonnel.where((p) => p.soldierId == soldierId).firstOrNull;
if (record != null && record.isAssigned) {
await slotRepository.clearAssignment(record.assignedSlotId!);
}
}
await personnelRepository.delete(soldierId);
} catch (e) {
emit(PersonnelError(e.toString()));
}
}
@override @override
Future<void> close() { Future<void> close() {
_allSubscription?.cancel(); _allSubscription?.cancel();

View File

@ -142,8 +142,14 @@ class UnitTreeCubit extends Cubit<UnitTreeState> {
final roots = treeService.buildTree(_units, _slots); final roots = treeService.buildTree(_units, _slots);
final currentSelection = final currentSelection =
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null; state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
emit(UnitTreeLoaded( emit(
units: _units, slots: _slots, roots: roots, selectedUnitId: currentSelection,),); UnitTreeLoaded(
units: _units,
slots: _slots,
roots: roots,
selectedUnitId: currentSelection,
),
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -206,6 +212,42 @@ class UnitTreeCubit extends Cubit<UnitTreeState> {
} }
} }
/// Updates an existing [unit] in the tree.
Future<void> updateUnit(UnitNode unit) async {
try {
await unitRepository.update(unit);
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
/// Adds a new position [slot] to a unit.
Future<void> addSlot(PositionSlot slot) async {
try {
await slotRepository.insert(slot);
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
/// Updates an existing position [slot].
Future<void> updateSlot(PositionSlot slot) async {
try {
await slotRepository.update(slot);
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
/// Deletes the position slot with [slotId].
Future<void> deleteSlot(String slotId) async {
try {
await slotRepository.delete(slotId);
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
/// Clears all units and slots (template reset). /// Clears all units and slots (template reset).
Future<void> clearAll() async { Future<void> clearAll() async {
try { try {

View File

@ -36,13 +36,17 @@ enum ReadinessState {
/// ///
/// Contains identifying information, MOS qualifications, pay grade, /// Contains identifying information, MOS qualifications, pay grade,
/// and current readiness state. /// and current readiness state.
///
/// [name] is optional. When omitted the soldier is identified by
/// [positionTitle] (e.g. "Team Leader", "Rifleman").
class PersonnelRecord extends Equatable { class PersonnelRecord extends Equatable {
const PersonnelRecord({ const PersonnelRecord({
required this.soldierId, required this.soldierId,
required this.name,
required this.primaryMos, required this.primaryMos,
required this.payGrade, required this.payGrade,
required this.readinessState, required this.readinessState,
this.name,
this.positionTitle,
this.qualifications = const [], this.qualifications = const [],
this.assignedSlotId, this.assignedSlotId,
}); });
@ -50,8 +54,13 @@ class PersonnelRecord extends Equatable {
/// Unique identifier for this soldier (UUID v4). /// Unique identifier for this soldier (UUID v4).
final String soldierId; final String soldierId;
/// The soldier's full name (Last, First MI format). /// The soldier's full name (Last, First MI format), or null if unnamed.
final String name; final String? name;
/// The soldier's duty position title (e.g. "Team Leader", "Rifleman").
///
/// Used as the display label when [name] is null.
final String? positionTitle;
/// The soldier's primary Military Occupational Specialty. /// The soldier's primary Military Occupational Specialty.
final DutyMos primaryMos; final DutyMos primaryMos;
@ -68,6 +77,15 @@ class PersonnelRecord extends Equatable {
/// The ID of the position slot this soldier is assigned to, or null. /// The ID of the position slot this soldier is assigned to, or null.
final String? assignedSlotId; final String? assignedSlotId;
/// Returns the best available display label for this soldier.
///
/// Prefers [name]; falls back to [positionTitle]; then a generic label.
String get displayLabel {
if (name != null && name!.isNotEmpty) return name!;
if (positionTitle != null && positionTitle!.isNotEmpty) return positionTitle!;
return '(Unnamed Soldier)';
}
/// Returns true if this soldier is currently assigned to a position. /// Returns true if this soldier is currently assigned to a position.
bool get isAssigned => assignedSlotId != null; bool get isAssigned => assignedSlotId != null;
@ -81,6 +99,9 @@ class PersonnelRecord extends Equatable {
PersonnelRecord copyWith({ PersonnelRecord copyWith({
String? soldierId, String? soldierId,
String? name, String? name,
bool clearName = false,
String? positionTitle,
bool clearPositionTitle = false,
DutyMos? primaryMos, DutyMos? primaryMos,
AuthorizedRank? payGrade, AuthorizedRank? payGrade,
List<String>? qualifications, List<String>? qualifications,
@ -90,7 +111,8 @@ class PersonnelRecord extends Equatable {
}) { }) {
return PersonnelRecord( return PersonnelRecord(
soldierId: soldierId ?? this.soldierId, soldierId: soldierId ?? this.soldierId,
name: name ?? this.name, name: clearName ? null : (name ?? this.name),
positionTitle: clearPositionTitle ? null : (positionTitle ?? this.positionTitle),
primaryMos: primaryMos ?? this.primaryMos, primaryMos: primaryMos ?? this.primaryMos,
payGrade: payGrade ?? this.payGrade, payGrade: payGrade ?? this.payGrade,
qualifications: qualifications ?? this.qualifications, qualifications: qualifications ?? this.qualifications,
@ -103,6 +125,7 @@ class PersonnelRecord extends Equatable {
List<Object?> get props => [ List<Object?> get props => [
soldierId, soldierId,
name, name,
positionTitle,
primaryMos, primaryMos,
payGrade, payGrade,
qualifications, qualifications,
@ -111,7 +134,7 @@ class PersonnelRecord extends Equatable {
]; ];
@override @override
String toString() => 'PersonnelRecord(id: $soldierId, name: $name, ' String toString() => 'PersonnelRecord(id: $soldierId, label: $displayLabel, '
'mos: ${primaryMos.code}, grade: ${payGrade.payGrade}, ' 'mos: ${primaryMos.code}, grade: ${payGrade.payGrade}, '
'readiness: ${readinessState.displayName})'; 'readiness: ${readinessState.displayName})';
} }

View File

@ -0,0 +1,108 @@
import 'package:equatable/equatable.dart';
/// A named duty position that can be assigned to a soldier record.
///
/// Built-in positions are seeded at first launch and cannot be deleted.
/// Users may add, rename, or delete custom positions.
class SoldierPosition extends Equatable {
const SoldierPosition({
required this.positionId,
required this.title,
this.isBuiltIn = false,
});
/// Unique identifier (UUID v4 for custom; fixed string for built-ins).
final String positionId;
/// Human-readable position title (e.g. "Team Leader", "Rifleman").
final String title;
/// True for positions seeded by the app; false for user-created ones.
final bool isBuiltIn;
SoldierPosition copyWith({
String? positionId,
String? title,
bool? isBuiltIn,
}) {
return SoldierPosition(
positionId: positionId ?? this.positionId,
title: title ?? this.title,
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
);
}
@override
List<Object?> get props => [positionId, title, isBuiltIn];
@override
String toString() => 'SoldierPosition($positionId, "$title", builtIn: $isBuiltIn)';
// ---------------------------------------------------------------------------
// Built-in positions
// ---------------------------------------------------------------------------
/// The full list of built-in Army duty positions seeded on first launch.
static const List<SoldierPosition> builtIns = [
// ── Command / Staff ──────────────────────────────────────────────────────
SoldierPosition(positionId: 'bi_commander', title: 'Commander', isBuiltIn: true),
SoldierPosition(
positionId: 'bi_executive_officer',
title: 'Executive Officer',
isBuiltIn: true,
),
SoldierPosition(
positionId: 'bi_command_sergeant_major',
title: 'Command Sergeant Major',
isBuiltIn: true,
),
SoldierPosition(positionId: 'bi_first_sergeant', title: 'First Sergeant', isBuiltIn: true),
SoldierPosition(
positionId: 'bi_operations_officer',
title: 'S3 / Operations Officer',
isBuiltIn: true,
),
SoldierPosition(
positionId: 'bi_intelligence_officer',
title: 'S2 / Intelligence Officer',
isBuiltIn: true,
),
SoldierPosition(
positionId: 'bi_logistics_officer',
title: 'S4 / Logistics Officer',
isBuiltIn: true,
),
SoldierPosition(positionId: 'bi_signal_officer', title: 'S6 / Signal Officer', isBuiltIn: true),
// ── Platoon ───────────────────────────────────────────────────────────────
SoldierPosition(positionId: 'bi_platoon_leader', title: 'Platoon Leader', isBuiltIn: true),
SoldierPosition(positionId: 'bi_platoon_sergeant', title: 'Platoon Sergeant', isBuiltIn: true),
SoldierPosition(positionId: 'bi_rto', title: 'RTO / Radio Operator', isBuiltIn: true),
// ── Squad / Section ───────────────────────────────────────────────────────
SoldierPosition(positionId: 'bi_squad_leader', title: 'Squad Leader', isBuiltIn: true),
SoldierPosition(positionId: 'bi_team_leader', title: 'Team Leader', isBuiltIn: true),
// ── Individual ────────────────────────────────────────────────────────────
SoldierPosition(positionId: 'bi_rifleman', title: 'Rifleman', isBuiltIn: true),
SoldierPosition(positionId: 'bi_grenadier', title: 'Grenadier', isBuiltIn: true),
SoldierPosition(
positionId: 'bi_automatic_rifleman',
title: 'Automatic Rifleman',
isBuiltIn: true,
),
SoldierPosition(positionId: 'bi_machine_gunner', title: 'Machine Gunner', isBuiltIn: true),
SoldierPosition(positionId: 'bi_sniper', title: 'Sniper', isBuiltIn: true),
SoldierPosition(positionId: 'bi_jtac', title: 'JTAC / Fire Support', isBuiltIn: true),
SoldierPosition(positionId: 'bi_medic', title: 'Medic', isBuiltIn: true),
SoldierPosition(positionId: 'bi_combat_engineer', title: 'Combat Engineer', isBuiltIn: true),
SoldierPosition(positionId: 'bi_driver', title: 'Driver', isBuiltIn: true),
SoldierPosition(positionId: 'bi_gunner', title: 'Gunner', isBuiltIn: true),
SoldierPosition(positionId: 'bi_loader', title: 'Loader', isBuiltIn: true),
SoldierPosition(positionId: 'bi_tank_commander', title: 'Tank Commander', isBuiltIn: true),
SoldierPosition(positionId: 'bi_crew_member', title: 'Crew Member', isBuiltIn: true),
SoldierPosition(positionId: 'bi_supply_sergeant', title: 'Supply Sergeant', isBuiltIn: true),
SoldierPosition(positionId: 'bi_armorer', title: 'Armorer', isBuiltIn: true),
SoldierPosition(positionId: 'bi_commo_sergeant', title: 'Commo Sergeant', isBuiltIn: true),
SoldierPosition(positionId: 'bi_intel_analyst', title: 'Intelligence Analyst', isBuiltIn: true),
SoldierPosition(positionId: 'bi_forward_observer', title: 'Forward Observer', isBuiltIn: true),
SoldierPosition(positionId: 'bi_chemical_officer', title: 'CBRN Specialist', isBuiltIn: true),
];
}

View File

@ -74,6 +74,9 @@ class DutyMos {
return _knownMosCodes.contains(code.trim().toUpperCase()); return _knownMosCodes.contains(code.trim().toUpperCase());
} }
/// Returns all known MOS codes as a sorted list.
static List<String> get allCodes => _knownMosCodes.toList()..sort();
/// Returns all MOS codes for a given [DoctrinalBranch]. /// Returns all MOS codes for a given [DoctrinalBranch].
static Set<String> codesForBranch(DoctrinalBranch branch) { static Set<String> codesForBranch(DoctrinalBranch branch) {
return _branchMosMap[branch] ?? {}; return _branchMosMap[branch] ?? {};

View File

@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart'; import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/position_slot_dao.dart'; import 'package:uto_manager/infrastructure/database/daos/position_slot_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/soldier_positions_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart'; import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart'; import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart';
@ -41,7 +42,8 @@ class PositionSlots extends Table {
/// Drift table for soldier personnel records. /// Drift table for soldier personnel records.
class Personnel extends Table { class Personnel extends Table {
TextColumn get soldierId => text().named('soldier_id')(); TextColumn get soldierId => text().named('soldier_id')();
TextColumn get name => text()(); TextColumn get name => text().nullable()();
TextColumn get positionTitle => text().named('position_title').nullable()();
TextColumn get primaryMos => text().named('primary_mos')(); TextColumn get primaryMos => text().named('primary_mos')();
TextColumn get payGrade => text().named('pay_grade')(); TextColumn get payGrade => text().named('pay_grade')();
TextColumn get qualifications => text()(); // JSON-encoded list TextColumn get qualifications => text()(); // JSON-encoded list
@ -52,6 +54,16 @@ class Personnel extends Table {
Set<Column> get primaryKey => {soldierId}; Set<Column> get primaryKey => {soldierId};
} }
/// Drift table for user-managed soldier duty positions.
class SoldierPositions extends Table {
TextColumn get positionId => text().named('position_id')();
TextColumn get title => text()();
BoolColumn get isBuiltIn => boolean().named('is_built_in').withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {positionId};
}
/// Drift table for serialized unit tree templates. /// Drift table for serialized unit tree templates.
class Templates extends Table { class Templates extends Table {
TextColumn get templateId => text().named('template_id')(); TextColumn get templateId => text().named('template_id')();
@ -71,11 +83,11 @@ class Templates extends Table {
/// The main Drift database for the UTO Manager application. /// The main Drift database for the UTO Manager application.
/// ///
/// Manages all four core tables: units, position_slots, personnel, templates. /// Manages all core tables: units, position_slots, personnel, soldier_positions, templates.
/// Supports SQLite on Windows/Android and IndexedDB on Web via drift_web. /// Supports SQLite on Windows/Android and IndexedDB on Web via drift_web.
@DriftDatabase( @DriftDatabase(
tables: [Units, PositionSlots, Personnel, Templates], tables: [Units, PositionSlots, Personnel, SoldierPositions, Templates],
daos: [UnitDao, PersonnelDao, PositionSlotDao, TemplateDao], daos: [UnitDao, PersonnelDao, PositionSlotDao, SoldierPositionsDao, TemplateDao],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
/// Creates the production database backed by SQLite on disk. /// Creates the production database backed by SQLite on disk.
@ -85,7 +97,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor); AppDatabase.forTesting(super.executor);
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -93,7 +105,12 @@ class AppDatabase extends _$AppDatabase {
await m.createAll(); await m.createAll();
}, },
onUpgrade: (m, from, to) async { onUpgrade: (m, from, to) async {
// Future migration steps added here as schemaVersion increases. if (from < 2) {
// v1 → v2: make Personnel.name nullable, add position_title column,
// and create the new soldier_positions table.
await m.addColumn(personnel, personnel.positionTitle);
await m.createTable(soldierPositions);
}
}, },
); );
} }
@ -102,5 +119,11 @@ class AppDatabase extends _$AppDatabase {
/// ///
/// Uses SQLite via FFI on Windows/Android and WASM/IndexedDB on Web. /// Uses SQLite via FFI on Windows/Android and WASM/IndexedDB on Web.
QueryExecutor _openConnection() { QueryExecutor _openConnection() {
return driftDatabase(name: 'uto_manager'); return driftDatabase(
name: 'uto_manager',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.js'),
),
);
} }

View File

@ -0,0 +1,41 @@
import 'package:drift/drift.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
part 'soldier_positions_dao.g.dart';
// The Drift-generated row type for the SoldierPositions table is also named
// `SoldierPosition` (same as our domain entity). We use a typedef here so the
// DAO can refer to the DB row type unambiguously.
typedef SoldierPositionRow = SoldierPosition;
/// Data Access Object for [SoldierPositions] table operations.
@DriftAccessor(tables: [SoldierPositions])
class SoldierPositionsDao extends DatabaseAccessor<AppDatabase> with _$SoldierPositionsDaoMixin {
SoldierPositionsDao(super.db);
/// Returns all soldier position rows.
Future<List<SoldierPositionRow>> getAll() => select(soldierPositions).get();
/// Returns the position with [positionId], or null if not found.
Future<SoldierPositionRow?> getById(String positionId) =>
(select(soldierPositions)..where((t) => t.positionId.equals(positionId))).getSingleOrNull();
/// Inserts a new position row (insert-or-replace).
Future<void> upsert(SoldierPositionsCompanion entry) =>
into(soldierPositions).insertOnConflictUpdate(entry);
/// Inserts multiple rows in a single transaction.
Future<void> upsertAll(List<SoldierPositionsCompanion> entries) =>
batch((b) => b.insertAllOnConflictUpdate(soldierPositions, entries));
/// Updates an existing position row.
Future<bool> updatePosition(SoldierPositionsCompanion entry) =>
update(soldierPositions).replace(entry);
/// Deletes the position with [positionId].
Future<int> deletePosition(String positionId) =>
(delete(soldierPositions)..where((t) => t.positionId.equals(positionId))).go();
/// Returns a stream that emits all positions whenever the table changes.
Stream<List<SoldierPositionRow>> watchAll() => select(soldierPositions).watch();
}

View File

@ -26,6 +26,7 @@ class DriftPersonnelRepository implements PersonnelRepository {
return domain.PersonnelRecord( return domain.PersonnelRecord(
soldierId: row.soldierId, soldierId: row.soldierId,
name: row.name, name: row.name,
positionTitle: row.positionTitle,
primaryMos: DutyMos.unvalidated(row.primaryMos), primaryMos: DutyMos.unvalidated(row.primaryMos),
payGrade: AuthorizedRank.values.firstWhere((r) => r.name == row.payGrade), payGrade: AuthorizedRank.values.firstWhere((r) => r.name == row.payGrade),
qualifications: (jsonDecode(row.qualifications) as List<dynamic>).cast<String>(), qualifications: (jsonDecode(row.qualifications) as List<dynamic>).cast<String>(),
@ -39,7 +40,8 @@ class DriftPersonnelRepository implements PersonnelRepository {
PersonnelCompanion _toCompanion(domain.PersonnelRecord record) { PersonnelCompanion _toCompanion(domain.PersonnelRecord record) {
return PersonnelCompanion.insert( return PersonnelCompanion.insert(
soldierId: record.soldierId, soldierId: record.soldierId,
name: record.name, name: Value(record.name),
positionTitle: Value(record.positionTitle),
primaryMos: record.primaryMos.code, primaryMos: record.primaryMos.code,
payGrade: record.payGrade.name, payGrade: record.payGrade.name,
qualifications: jsonEncode(record.qualifications), qualifications: jsonEncode(record.qualifications),

View File

@ -0,0 +1,77 @@
import 'package:drift/drift.dart' show Value;
import 'package:uto_manager/domain/entities/soldier_position.dart' as domain;
import 'package:uto_manager/infrastructure/database/app_database.dart' as db_lib;
import 'package:uto_manager/infrastructure/database/daos/soldier_positions_dao.dart';
/// Drift-backed repository for [domain.SoldierPosition] persistence.
///
/// Converts between Drift [SoldierPositionRow] rows and domain
/// [domain.SoldierPosition] entities. All persistence is handled via
/// [SoldierPositionsDao].
class DriftSoldierPositionsRepository {
const DriftSoldierPositionsRepository({required this.dao});
final SoldierPositionsDao dao;
// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------
domain.SoldierPosition _toDomain(SoldierPositionRow row) {
return domain.SoldierPosition(
positionId: row.positionId,
title: row.title,
isBuiltIn: row.isBuiltIn,
);
}
db_lib.SoldierPositionsCompanion _toCompanion(domain.SoldierPosition pos) {
return db_lib.SoldierPositionsCompanion(
positionId: Value(pos.positionId),
title: Value(pos.title),
isBuiltIn: Value(pos.isBuiltIn),
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Returns all positions (built-in + custom).
Future<List<domain.SoldierPosition>> getAll() async {
final rows = await dao.getAll();
return rows.map(_toDomain).toList();
}
/// Returns a stream that emits all positions whenever the table changes.
Stream<List<domain.SoldierPosition>> watchAll() {
return dao.watchAll().map((rows) => rows.map(_toDomain).toList());
}
/// Inserts or updates a position.
Future<void> upsert(domain.SoldierPosition position) async {
await dao.upsert(_toCompanion(position));
}
/// Seeds all [domain.SoldierPosition.builtIns] that are not yet in the database.
Future<void> seedBuiltIns() async {
final existing = await dao.getAll();
final existingIds = existing.map((SoldierPositionRow r) => r.positionId).toSet();
final toInsert = domain.SoldierPosition.builtIns
.where((p) => !existingIds.contains(p.positionId))
.map(_toCompanion)
.toList();
if (toInsert.isNotEmpty) {
await dao.upsertAll(toInsert);
}
}
/// Deletes a custom position by [positionId].
///
/// Built-in positions cannot be deleted; this is a no-op for them.
Future<void> delete(String positionId) async {
final row = await dao.getById(positionId);
if (row == null || row.isBuiltIn) return;
await dao.deletePosition(positionId);
}
}

View File

@ -9,6 +9,7 @@ import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart'; import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart'; import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart'; import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart'; import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
import 'package:uto_manager/presentation/screens/home_screen.dart'; import 'package:uto_manager/presentation/screens/home_screen.dart';
@ -32,6 +33,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
late final DriftUnitRepository _unitRepo; late final DriftUnitRepository _unitRepo;
late final DriftPersonnelRepository _personnelRepo; late final DriftPersonnelRepository _personnelRepo;
late final DriftPositionSlotRepository _slotRepo; late final DriftPositionSlotRepository _slotRepo;
late final DriftSoldierPositionsRepository _positionsRepo;
late final UnitTreeService _treeService; late final UnitTreeService _treeService;
late final AssignmentService _assignmentService; late final AssignmentService _assignmentService;
@ -42,6 +44,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
_unitRepo = DriftUnitRepository(dao: _db.unitDao, db: _db); _unitRepo = DriftUnitRepository(dao: _db.unitDao, db: _db);
_personnelRepo = DriftPersonnelRepository(dao: _db.personnelDao); _personnelRepo = DriftPersonnelRepository(dao: _db.personnelDao);
_slotRepo = DriftPositionSlotRepository(dao: _db.positionSlotDao); _slotRepo = DriftPositionSlotRepository(dao: _db.positionSlotDao);
_positionsRepo = DriftSoldierPositionsRepository(dao: _db.soldierPositionsDao);
_treeService = const UnitTreeService(); _treeService = const UnitTreeService();
_assignmentService = AssignmentService(treeService: _treeService); _assignmentService = AssignmentService(treeService: _treeService);
} }
@ -91,7 +94,7 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
), ),
useMaterial3: true, useMaterial3: true,
), ),
home: const HomeScreen(), home: HomeScreen(positionsRepository: _positionsRepo),
), ),
); );
} }

View File

@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/soldier_position.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
import 'package:uuid/uuid.dart';
/// Dialog for creating or editing a [PersonnelRecord].
///
/// Pass [existing] to edit; leave null to create a new record.
/// [positionsRepository] is used to load and manage the position list.
class AddEditPersonnelDialog extends StatefulWidget {
const AddEditPersonnelDialog({
required this.positionsRepository,
super.key,
this.existing,
});
final DriftSoldierPositionsRepository positionsRepository;
final PersonnelRecord? existing;
@override
State<AddEditPersonnelDialog> createState() => _AddEditPersonnelDialogState();
}
class _AddEditPersonnelDialogState extends State<AddEditPersonnelDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _mosCtrl;
late AuthorizedRank _payGrade;
late ReadinessState _readiness;
/// The currently selected position, or null if none chosen.
SoldierPosition? _selectedPosition;
/// All positions loaded from the repository.
List<SoldierPosition> _positions = [];
List<String> _mosSuggestions = [];
bool get _isEditing => widget.existing != null;
@override
void initState() {
super.initState();
final e = widget.existing;
_nameCtrl = TextEditingController(text: e?.name ?? '');
_mosCtrl = TextEditingController(text: e?.primaryMos.code ?? '');
_payGrade = e?.payGrade ?? AuthorizedRank.e4;
_readiness = e?.readinessState ?? ReadinessState.fullyMissionCapable;
_loadPositions(existingTitle: e?.positionTitle);
}
@override
void dispose() {
_nameCtrl.dispose();
_mosCtrl.dispose();
super.dispose();
}
Future<void> _loadPositions({String? existingTitle}) async {
await widget.positionsRepository.seedBuiltIns();
final positions = await widget.positionsRepository.getAll();
positions.sort((a, b) => a.title.compareTo(b.title));
final selected =
existingTitle != null ? positions.where((p) => p.title == existingTitle).firstOrNull : null;
if (mounted) {
setState(() {
_positions = positions;
_selectedPosition = selected;
});
}
}
void _updateMosSuggestions(String query) {
if (query.isEmpty) {
setState(() => _mosSuggestions = []);
return;
}
final q = query.toUpperCase();
final matches =
DutyMos.allCodes.where((c) => c.startsWith(q) || c.contains(q)).take(12).toList()..sort();
setState(() => _mosSuggestions = matches);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final mosCode = _mosCtrl.text.trim().toUpperCase();
DutyMos mos;
try {
mos = DutyMos(mosCode);
} catch (_) {
mos = DutyMos.unvalidated(mosCode);
}
final nameText = _nameCtrl.text.trim();
final record = PersonnelRecord(
soldierId: widget.existing?.soldierId ?? const Uuid().v4(),
name: nameText.isEmpty ? null : nameText,
positionTitle: _selectedPosition?.title,
primaryMos: mos,
payGrade: _payGrade,
readinessState: _readiness,
qualifications: widget.existing?.qualifications ?? const [],
assignedSlotId: widget.existing?.assignedSlotId,
);
Navigator.of(context).pop(record);
}
// ---------------------------------------------------------------------------
// Position CRUD helpers
// ---------------------------------------------------------------------------
Future<void> _showAddPositionDialog() async {
final title = await _showPositionTitleDialog(context, initial: '');
if (title == null || title.isEmpty) return;
final pos = SoldierPosition(positionId: const Uuid().v4(), title: title);
await widget.positionsRepository.upsert(pos);
await _loadPositions();
if (mounted) {
setState(() => _selectedPosition = _positions.where((p) => p.title == title).firstOrNull);
}
}
Future<void> _showEditPositionDialog(SoldierPosition pos) async {
if (pos.isBuiltIn) return; // built-ins cannot be renamed
final title = await _showPositionTitleDialog(context, initial: pos.title);
if (title == null || title.isEmpty || title == pos.title) return;
await widget.positionsRepository.upsert(pos.copyWith(title: title));
await _loadPositions();
if (mounted) {
setState(
() =>
_selectedPosition = _positions.where((p) => p.positionId == pos.positionId).firstOrNull,
);
}
}
Future<void> _confirmDeletePosition(SoldierPosition pos) async {
if (pos.isBuiltIn) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Position?'),
content: Text('Remove the "${pos.title}" position? This cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed ?? false) {
await widget.positionsRepository.delete(pos.positionId);
await _loadPositions();
if (mounted && _selectedPosition?.positionId == pos.positionId) {
setState(() => _selectedPosition = null);
}
}
}
/// Shows a simple text-input dialog for a position title.
Future<String?> _showPositionTitleDialog(BuildContext context, {required String initial}) {
final ctrl = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(initial.isEmpty ? 'Add Position' : 'Rename Position'),
content: TextField(
controller: ctrl,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Position Title',
hintText: 'e.g. Anti-Tank Gunner',
),
onSubmitted: (_) => Navigator.of(ctx).pop(ctrl.text.trim()),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(ctrl.text.trim()),
child: const Text('Save'),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Row(
children: [
Icon(_isEditing ? Icons.edit : Icons.person_add, size: 20),
const SizedBox(width: 8),
Text(_isEditing ? 'Edit Soldier' : 'Add Soldier'),
],
),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Position ─────────────────────────────────────────────────
_buildPositionRow(theme),
const SizedBox(height: 12),
// ── Name (optional) ──────────────────────────────────────────
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Full Name (optional)',
hintText: 'e.g. SMITH, John A.',
prefixIcon: Icon(Icons.badge_outlined),
helperText: 'Leave blank to identify by position only.',
),
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 12),
// ── Pay Grade ────────────────────────────────────────────────
DropdownButtonFormField<AuthorizedRank>(
initialValue: _payGrade,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Pay Grade',
prefixIcon: Icon(Icons.military_tech_outlined),
),
items: AuthorizedRank.values.map((r) {
return DropdownMenuItem(
value: r,
child: Text('${r.payGrade} — ${r.rankTitle}'),
);
}).toList(),
onChanged: (v) => setState(() => _payGrade = v!),
),
const SizedBox(height: 12),
// ── MOS with autocomplete ────────────────────────────────────
TextFormField(
controller: _mosCtrl,
decoration: const InputDecoration(
labelText: 'Primary MOS',
hintText: 'e.g. 11B, 68W, 25U',
prefixIcon: Icon(Icons.work_outline),
),
textCapitalization: TextCapitalization.characters,
onChanged: _updateMosSuggestions,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'MOS is required';
final code = v.trim().toUpperCase();
if (!DutyMos.isKnown(code)) {
return 'Unknown MOS code. Use a valid Army MOS (e.g. 11B, 68W)';
}
return null;
},
),
if (_mosSuggestions.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _mosSuggestions.map((code) {
return ActionChip(
label: Text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
onPressed: () {
_mosCtrl.text = code;
setState(() => _mosSuggestions = []);
},
padding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
);
}).toList(),
),
),
],
const SizedBox(height: 12),
// ── Readiness State ──────────────────────────────────────────
DropdownButtonFormField<ReadinessState>(
initialValue: _readiness,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Readiness State',
prefixIcon: Icon(Icons.health_and_safety_outlined),
),
items: ReadinessState.values.map((s) {
return DropdownMenuItem(
value: s,
child: Text(s.displayName),
);
}).toList(),
onChanged: (v) => setState(() => _readiness = v!),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
label: Text(_isEditing ? 'Save Changes' : 'Add Soldier'),
onPressed: _submit,
),
],
);
}
/// Builds the position dropdown row with add / edit / delete action buttons.
Widget _buildPositionRow(ThemeData theme) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: _positions.isEmpty
? const LinearProgressIndicator()
: DropdownButtonFormField<SoldierPosition>(
initialValue: _selectedPosition,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Position',
prefixIcon: Icon(Icons.work_history_outlined),
),
hint: const Text('Select position…'),
items: _positions.map((p) {
return DropdownMenuItem(
value: p,
child: Row(
children: [
Expanded(child: Text(p.title, overflow: TextOverflow.ellipsis)),
if (p.isBuiltIn)
Icon(
Icons.lock_outline,
size: 12,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
],
),
);
}).toList(),
onChanged: (v) => setState(() => _selectedPosition = v),
),
),
// Add custom position
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 18),
tooltip: 'Add custom position',
onPressed: _showAddPositionDialog,
),
// Edit selected position (only if custom)
if (_selectedPosition != null && !_selectedPosition!.isBuiltIn)
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Rename position',
onPressed: () => _showEditPositionDialog(_selectedPosition!),
),
// Delete selected position (only if custom)
if (_selectedPosition != null && !_selectedPosition!.isBuiltIn)
IconButton(
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
tooltip: 'Delete position',
onPressed: () => _confirmDeletePosition(_selectedPosition!),
),
],
);
}
}

View File

@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
import 'package:uuid/uuid.dart';
/// Dialog for creating or editing a [PositionSlot] within a unit.
///
/// Pass [existing] to edit; leave null to create a new slot.
/// [unitId] is required and identifies which unit owns this slot.
class AddEditSlotDialog extends StatefulWidget {
const AddEditSlotDialog({
required this.unitId,
super.key,
this.existing,
});
final String unitId;
final PositionSlot? existing;
@override
State<AddEditSlotDialog> createState() => _AddEditSlotDialogState();
}
class _AddEditSlotDialogState extends State<AddEditSlotDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _titleCtrl;
late final TextEditingController _mosCtrl;
late AuthorizedRank _rank;
late bool _isKeyLeader;
List<String> _mosSuggestions = [];
bool get _isEditing => widget.existing != null;
@override
void initState() {
super.initState();
final e = widget.existing;
_titleCtrl = TextEditingController(text: e?.positionTitle ?? '');
_mosCtrl = TextEditingController(text: e?.dutyMos.code ?? '');
_rank = e?.authorizedRank ?? AuthorizedRank.e4;
_isKeyLeader = e?.isKeyLeader ?? false;
}
@override
void dispose() {
_titleCtrl.dispose();
_mosCtrl.dispose();
super.dispose();
}
void _updateMosSuggestions(String query) {
if (query.isEmpty) {
setState(() => _mosSuggestions = []);
return;
}
final q = query.toUpperCase();
final matches =
DutyMos.allCodes.where((c) => c.startsWith(q) || c.contains(q)).take(12).toList()..sort();
setState(() => _mosSuggestions = matches);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final mosCode = _mosCtrl.text.trim().toUpperCase();
DutyMos mos;
try {
mos = DutyMos(mosCode);
} catch (_) {
mos = DutyMos.unvalidated(mosCode);
}
final slot = PositionSlot(
slotId: widget.existing?.slotId ?? const Uuid().v4(),
unitId: widget.unitId,
positionTitle: _titleCtrl.text.trim(),
dutyMos: mos,
authorizedRank: _rank,
isKeyLeader: _isKeyLeader,
assignedPersonnelId: widget.existing?.assignedPersonnelId,
);
Navigator.of(context).pop(slot);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Row(
children: [
Icon(_isEditing ? Icons.edit : Icons.add_circle_outline, size: 20),
const SizedBox(width: 8),
Text(_isEditing ? 'Edit Position Slot' : 'Add Position Slot'),
],
),
content: SizedBox(
width: 400,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Position Title
TextFormField(
controller: _titleCtrl,
decoration: const InputDecoration(
labelText: 'Position Title',
hintText: 'e.g. Platoon Leader, Squad Leader, Medic',
prefixIcon: Icon(Icons.work_history_outlined),
),
textCapitalization: TextCapitalization.words,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Position title is required';
return null;
},
),
const SizedBox(height: 12),
// Authorized Rank
DropdownButtonFormField<AuthorizedRank>(
initialValue: _rank,
decoration: const InputDecoration(
labelText: 'Authorized Rank',
prefixIcon: Icon(Icons.military_tech_outlined),
),
items: AuthorizedRank.values.map((r) {
return DropdownMenuItem(
value: r,
child: Text('${r.payGrade} — ${r.rankTitle}'),
);
}).toList(),
onChanged: (v) => setState(() => _rank = v!),
),
const SizedBox(height: 12),
// Duty MOS with autocomplete
TextFormField(
controller: _mosCtrl,
decoration: const InputDecoration(
labelText: 'Duty MOS',
hintText: 'e.g. 11B, 68W, 25U',
prefixIcon: Icon(Icons.badge_outlined),
),
textCapitalization: TextCapitalization.characters,
onChanged: _updateMosSuggestions,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Duty MOS is required';
final code = v.trim().toUpperCase();
if (!DutyMos.isKnown(code)) {
return 'Unknown MOS code (e.g. 11B, 68W, 25U)';
}
return null;
},
),
if (_mosSuggestions.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _mosSuggestions.map((code) {
return ActionChip(
label: Text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
onPressed: () {
_mosCtrl.text = code;
setState(() => _mosSuggestions = []);
},
padding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: VisualDensity.compact,
);
}).toList(),
),
),
],
const SizedBox(height: 8),
// Key Leader toggle
SwitchListTile(
title: const Text('Key Leader Position'),
subtitle: const Text('e.g. Commander, XO, Platoon Leader, PSG'),
value: _isKeyLeader,
onChanged: (v) => setState(() => _isKeyLeader = v),
contentPadding: EdgeInsets.zero,
dense: true,
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
label: Text(_isEditing ? 'Save Changes' : 'Add Slot'),
onPressed: _submit,
),
],
);
}
}

View File

@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
import 'package:uto_manager/domain/entities/echelon.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uuid/uuid.dart';
/// Dialog for creating or editing a [UnitNode].
///
/// Pass [existing] to edit; leave null to create a new unit.
/// Pass [parentUnit] to pre-select the parent and constrain the echelon.
class AddEditUnitDialog extends StatefulWidget {
const AddEditUnitDialog({
super.key,
this.existing,
this.parentUnit,
this.availableParents = const [],
});
final UnitNode? existing;
final UnitNode? parentUnit;
final List<UnitNode> availableParents;
@override
State<AddEditUnitDialog> createState() => _AddEditUnitDialogState();
}
class _AddEditUnitDialogState extends State<AddEditUnitDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late Echelon _echelon;
late DoctrinalBranch _branch;
String? _parentUnitId;
late bool _isKeyLeader;
bool get _isEditing => widget.existing != null;
@override
void initState() {
super.initState();
final e = widget.existing;
_nameCtrl = TextEditingController(text: e?.name ?? '');
_echelon = e?.echelon ?? _defaultChildEchelon();
_branch = e?.branch ?? DoctrinalBranch.infantry;
_parentUnitId = e?.parentUnitId ?? widget.parentUnit?.unitId;
_isKeyLeader = e?.isKeyLeader ?? false;
}
Echelon _defaultChildEchelon() {
final parent = widget.parentUnit;
if (parent == null) return Echelon.brigade;
// Default to one level below parent
final childLevel = parent.echelon.level + 1;
return Echelon.values.firstWhere(
(e) => e.level == childLevel,
orElse: () => Echelon.team,
);
}
@override
void dispose() {
_nameCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
// If no name provided, generate a default from echelon + branch abbreviation
final rawName = _nameCtrl.text.trim();
final name = rawName.isEmpty ? '${_echelon.displayName} (${_branch.abbreviation})' : rawName;
final unit = UnitNode(
unitId: widget.existing?.unitId ?? const Uuid().v4(),
echelon: _echelon,
branch: _branch,
name: name,
isKeyLeader: _isKeyLeader,
parentUnitId: _parentUnitId,
);
Navigator.of(context).pop(unit);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(_isEditing ? Icons.edit : Icons.add_box_outlined, size: 20),
const SizedBox(width: 8),
Text(_isEditing ? 'Edit Unit' : 'Add Unit'),
],
),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Unit Name
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Unit Name / Designation (optional)',
hintText: 'e.g. 1-68 AR, Alpha Company, 3rd Platoon',
helperText: 'Leave blank to auto-generate from echelon & branch',
prefixIcon: Icon(Icons.account_tree_outlined),
),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 12),
// Echelon
DropdownButtonFormField<Echelon>(
initialValue: _echelon,
decoration: const InputDecoration(
labelText: 'Echelon',
prefixIcon: Icon(Icons.layers_outlined),
),
items: Echelon.values.map((e) {
return DropdownMenuItem(
value: e,
child: Text(e.displayName),
);
}).toList(),
onChanged: (v) => setState(() => _echelon = v!),
),
const SizedBox(height: 12),
// Branch
DropdownButtonFormField<DoctrinalBranch>(
initialValue: _branch,
decoration: const InputDecoration(
labelText: 'Branch / Functional Area',
prefixIcon: Icon(Icons.category_outlined),
),
items: DoctrinalBranch.values.map((b) {
return DropdownMenuItem(
value: b,
child: Text('${b.abbreviation} — ${b.displayName}'),
);
}).toList(),
onChanged: (v) => setState(() => _branch = v!),
),
const SizedBox(height: 12),
// Parent Unit (optional)
if (widget.availableParents.isNotEmpty) ...[
DropdownButtonFormField<String?>(
initialValue: _parentUnitId,
decoration: const InputDecoration(
labelText: 'Parent Unit (optional)',
prefixIcon: Icon(Icons.device_hub_outlined),
),
items: [
const DropdownMenuItem<String?>(
child: Text('— None (root unit) —'),
),
...widget.availableParents.map((u) {
return DropdownMenuItem<String?>(
value: u.unitId,
child: Text('${u.echelon.displayName}: ${u.name}'),
);
}),
],
onChanged: (v) => setState(() => _parentUnitId = v),
),
const SizedBox(height: 12),
],
// Key Leader toggle
SwitchListTile(
title: const Text('Key Leader Unit'),
subtitle: const Text('Marks this as a command/key leader node'),
value: _isKeyLeader,
onChanged: (v) => setState(() => _isKeyLeader = v),
contentPadding: EdgeInsets.zero,
dense: true,
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
icon: Icon(_isEditing ? Icons.save : Icons.add, size: 16),
label: Text(_isEditing ? 'Save Changes' : 'Add Unit'),
onPressed: _submit,
),
],
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uto_manager/application/blocs/personnel_cubit.dart'; import 'package:uto_manager/application/blocs/personnel_cubit.dart';
@ -5,10 +7,15 @@ import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
import 'package:uto_manager/application/blocs/validation_cubit.dart'; import 'package:uto_manager/application/blocs/validation_cubit.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart'; import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart'; import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart'; import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart'; import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart'; import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart';
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart'; import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
import 'package:uto_manager/presentation/screens/dialogs/add_edit_personnel_dialog.dart';
import 'package:uto_manager/presentation/screens/dialogs/add_edit_slot_dialog.dart';
import 'package:uto_manager/presentation/screens/dialogs/add_edit_unit_dialog.dart';
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart'; import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
import 'package:uto_manager/presentation/widgets/position_slot_card.dart'; import 'package:uto_manager/presentation/widgets/position_slot_card.dart';
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart'; import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
@ -20,7 +27,9 @@ import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
/// - Center: Interactive org chart canvas /// - Center: Interactive org chart canvas
/// - Right panel: Unit inspector (selected unit's position slots + validation) /// - Right panel: Unit inspector (selected unit's position slots + validation)
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({required this.positionsRepository, super.key});
final DriftSoldierPositionsRepository positionsRepository;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -48,6 +57,22 @@ class HomeScreen extends StatelessWidget {
backgroundColor: const Color(0xFF1B3A6B), backgroundColor: const Color(0xFF1B3A6B),
foregroundColor: Colors.white, foregroundColor: Colors.white,
actions: [ actions: [
// Add Unit button
BlocBuilder<UnitTreeCubit, UnitTreeState>(
builder: (context, treeState) {
return IconButton(
icon: const Icon(Icons.add_box_outlined),
tooltip: 'Add Unit',
onPressed: () => _showAddUnitDialog(context, treeState),
);
},
),
// Add Soldier button
IconButton(
icon: const Icon(Icons.person_add_outlined),
tooltip: 'Add Soldier',
onPressed: () => _showAddPersonnelDialog(context),
),
// Validate button // Validate button
BlocBuilder<UnitTreeCubit, UnitTreeState>( BlocBuilder<UnitTreeCubit, UnitTreeState>(
builder: (context, treeState) { builder: (context, treeState) {
@ -101,7 +126,7 @@ class HomeScreen extends StatelessWidget {
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
// Right panel: Unit inspector // Right panel: Unit inspector
SizedBox( SizedBox(
width: 280, width: 300,
child: _buildInspectorPanel(context), child: _buildInspectorPanel(context),
), ),
], ],
@ -129,13 +154,28 @@ class HomeScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
'PERSONNEL ROSTER', children: [
style: theme.textTheme.labelSmall?.copyWith( Text(
fontWeight: FontWeight.bold, 'PERSONNEL ROSTER',
letterSpacing: 1.2, style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, 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),
),
),
],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
BlocBuilder<PersonnelCubit, PersonnelState>( BlocBuilder<PersonnelCubit, PersonnelState>(
@ -165,10 +205,40 @@ class HomeScreen extends StatelessWidget {
} }
if (state is PersonnelLoaded) { if (state is PersonnelLoaded) {
final soldiers = state.filteredUnassigned; final soldiers = state.filteredUnassigned;
if (soldiers.isEmpty && state.filterQuery.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_outline,
size: 40,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 8),
Text(
'No soldiers yet',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
icon: const Icon(Icons.person_add_outlined, size: 14),
label: const Text('Add Soldier'),
onPressed: () => _showAddPersonnelDialog(context),
),
],
),
),
);
}
if (soldiers.isEmpty) { if (soldiers.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'No unassigned soldiers', 'No matches',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -177,13 +247,34 @@ class HomeScreen extends StatelessWidget {
} }
return ListView.builder( return ListView.builder(
itemCount: soldiers.length, itemCount: soldiers.length,
itemBuilder: (context, i) => PersonnelListTile(record: soldiers[i]), itemBuilder: (context, i) => PersonnelListTile(
record: soldiers[i],
onTap: () => _showEditPersonnelDialog(context, soldiers[i]),
),
); );
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
), ),
), ),
// Show all personnel (assigned + unassigned) section
BlocBuilder<PersonnelCubit, PersonnelState>(
builder: (context, state) {
if (state is! PersonnelLoaded) return const SizedBox.shrink();
final assigned = state.allPersonnel.where((p) => p.isAssigned).toList();
if (assigned.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: theme.colorScheme.surfaceContainerLow,
child: Text(
'${assigned.length} assigned • ${state.unassigned.length} unassigned',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
},
),
], ],
); );
} }
@ -232,12 +323,29 @@ class HomeScreen extends StatelessWidget {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Column(
'Select a unit on the canvas to inspect its position slots.', mainAxisSize: MainAxisSize.min,
style: theme.textTheme.bodySmall?.copyWith( children: [
color: theme.colorScheme.onSurfaceVariant, Icon(
), Icons.touch_app_outlined,
textAlign: TextAlign.center, 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),
),
],
), ),
), ),
); );
@ -254,20 +362,61 @@ class HomeScreen extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Inspector header // Inspector header with edit/delete actions
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
'UNIT INSPECTOR', children: [
style: theme.textTheme.labelSmall?.copyWith( Text(
fontWeight: FontWeight.bold, 'UNIT INSPECTOR',
letterSpacing: 1.2, style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, 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),
),
),
],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@ -310,14 +459,68 @@ class HomeScreen extends StatelessWidget {
); );
}, },
), ),
// Slots header with add-slot button
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: theme.colorScheme.surfaceContainerLow,
child: Row(
children: [
Text(
'POSITION SLOTS',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
'${slots.where((s) => s.isFilled).length}/${slots.length} filled',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
SizedBox(
height: 24,
child: IconButton(
icon: const Icon(Icons.add_circle_outline, size: 15),
tooltip: 'Add Position Slot',
padding: EdgeInsets.zero,
onPressed: () => _showAddSlotDialog(context, selectedUnit.unitId),
),
),
],
),
),
// Slots list // Slots list
Expanded( Expanded(
child: slots.isEmpty child: slots.isEmpty
? Center( ? Center(
child: Text( child: Padding(
'No position slots defined', padding: const EdgeInsets.all(16),
style: theme.textTheme.bodySmall?.copyWith( child: Column(
color: theme.colorScheme.onSurfaceVariant, 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),
),
],
), ),
), ),
) )
@ -333,6 +536,8 @@ class HomeScreen extends StatelessWidget {
assignedSoldier: soldier, assignedSoldier: soldier,
onUnassign: slot.isFilled ? () => _unassign(context, slot) : null, onUnassign: slot.isFilled ? () => _unassign(context, slot) : null,
onAcceptDrop: (soldier) => _assign(context, soldier, slot), onAcceptDrop: (soldier) => _assign(context, soldier, slot),
onEdit: () => _showEditSlotDialog(context, slot),
onDelete: () => _confirmDeleteSlot(context, slot),
); );
}, },
), ),
@ -402,6 +607,18 @@ class HomeScreen extends StatelessWidget {
}, },
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// Personnel count
BlocBuilder<PersonnelCubit, PersonnelState>(
builder: (context, state) {
if (state is! PersonnelLoaded) return const SizedBox.shrink();
return _statusChip(
theme,
Icons.people_outline,
'${state.allPersonnel.length} soldiers',
);
},
),
const SizedBox(width: 16),
// Validation finding counts // Validation finding counts
BlocBuilder<ValidationCubit, ValidationState>( BlocBuilder<ValidationCubit, ValidationState>(
builder: (context, valState) { builder: (context, valState) {
@ -483,7 +700,7 @@ class HomeScreen extends StatelessWidget {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Actions // Assignment Actions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void _assign(BuildContext context, PersonnelRecord soldier, PositionSlot slot) { void _assign(BuildContext context, PersonnelRecord soldier, PositionSlot slot) {
@ -494,6 +711,172 @@ class HomeScreen extends StatelessWidget {
context.read<PersonnelCubit>().unassignFromSlot(slot); context.read<PersonnelCubit>().unassignFromSlot(slot);
} }
// ---------------------------------------------------------------------------
// Personnel CRUD
// ---------------------------------------------------------------------------
Future<void> _showAddPersonnelDialog(BuildContext context) async {
final record = await showDialog<PersonnelRecord>(
context: context,
builder: (_) => AddEditPersonnelDialog(positionsRepository: positionsRepository),
);
if (record != null && context.mounted) {
unawaited(context.read<PersonnelCubit>().addPersonnel(record));
}
}
Future<void> _showEditPersonnelDialog(BuildContext context, PersonnelRecord existing) async {
final record = await showDialog<PersonnelRecord>(
context: context,
builder: (_) => AddEditPersonnelDialog(
positionsRepository: positionsRepository,
existing: existing,
),
);
if (record != null && context.mounted) {
unawaited(context.read<PersonnelCubit>().updatePersonnel(record));
}
}
// ---------------------------------------------------------------------------
// Unit CRUD
// ---------------------------------------------------------------------------
Future<void> _showAddUnitDialog(BuildContext context, UnitTreeState treeState) async {
final units = treeState is UnitTreeLoaded ? treeState.units : <UnitNode>[];
final unit = await showDialog<UnitNode>(
context: context,
builder: (_) => AddEditUnitDialog(availableParents: units),
);
if (unit != null && context.mounted) {
unawaited(context.read<UnitTreeCubit>().addUnit(unit));
}
}
Future<void> _showAddChildUnitDialog(
BuildContext context,
UnitNode parent,
UnitTreeLoaded treeState,
) async {
final unit = await showDialog<UnitNode>(
context: context,
builder: (_) => AddEditUnitDialog(
parentUnit: parent,
availableParents: treeState.units,
),
);
if (unit != null && context.mounted) {
unawaited(context.read<UnitTreeCubit>().addUnit(unit));
}
}
Future<void> _showEditUnitDialog(
BuildContext context,
UnitNode existing,
List<UnitNode> allUnits,
) async {
// Exclude the unit itself and its descendants from available parents
final unit = await showDialog<UnitNode>(
context: context,
builder: (_) => AddEditUnitDialog(
existing: existing,
availableParents: allUnits.where((u) => u.unitId != existing.unitId).toList(),
),
);
if (unit != null && context.mounted) {
unawaited(context.read<UnitTreeCubit>().updateUnit(unit));
}
}
Future<void> _confirmDeleteUnit(BuildContext context, UnitNode unit) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Unit?'),
content: Text(
'Delete "${unit.name}" and all its subordinate units and position slots? '
'This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if ((confirmed ?? false) && context.mounted) {
unawaited(context.read<UnitTreeCubit>().deleteSubtree(unit.unitId));
}
}
// ---------------------------------------------------------------------------
// Position Slot CRUD
// ---------------------------------------------------------------------------
Future<void> _showAddSlotDialog(BuildContext context, String unitId) async {
final slot = await showDialog<PositionSlot>(
context: context,
builder: (_) => AddEditSlotDialog(unitId: unitId),
);
if (slot != null && context.mounted) {
unawaited(context.read<UnitTreeCubit>().addSlot(slot));
}
}
Future<void> _showEditSlotDialog(BuildContext context, PositionSlot existing) async {
final slot = await showDialog<PositionSlot>(
context: context,
builder: (_) => AddEditSlotDialog(unitId: existing.unitId, existing: existing),
);
if (slot != null && context.mounted) {
unawaited(context.read<UnitTreeCubit>().updateSlot(slot));
}
}
Future<void> _confirmDeleteSlot(BuildContext context, PositionSlot slot) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Position Slot?'),
content: Text(
'Remove the "${slot.positionTitle}" (${slot.dutyMos.code}) slot? '
'${slot.isFilled ? "The assigned soldier will be unassigned. " : ""}'
'This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if ((confirmed ?? false) && context.mounted) {
// If slot is filled, unassign the soldier first
if (slot.isFilled) {
await context.read<PersonnelCubit>().unassignFromSlot(slot);
}
if (context.mounted) {
unawaited(context.read<UnitTreeCubit>().deleteSlot(slot.slotId));
}
}
}
// ---------------------------------------------------------------------------
// Validation & Export
// ---------------------------------------------------------------------------
void _runValidation(BuildContext context, UnitTreeLoaded treeState) { void _runValidation(BuildContext context, UnitTreeLoaded treeState) {
final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel ? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
@ -628,7 +1011,10 @@ class _ExportDialogState extends State<_ExportDialog> {
color: Colors.red.shade900.withAlpha(80), color: Colors.red.shade900.withAlpha(80),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text(_error!, style: const TextStyle(fontSize: 11, color: Colors.redAccent)), child: Text(
_error!,
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
),
), ),
], ],
], ],

View File

@ -5,15 +5,23 @@ import 'package:uto_manager/presentation/widgets/position_slot_card.dart' show P
/// A draggable list tile representing a soldier in the personnel panel. /// A draggable list tile representing a soldier in the personnel panel.
/// ///
/// Can be dragged onto a [PositionSlotCard] to assign the soldier. /// Can be dragged onto a [PositionSlotCard] to assign the soldier.
/// Tap to edit; long-press for a context menu with edit/delete options.
class PersonnelListTile extends StatelessWidget { class PersonnelListTile extends StatelessWidget {
const PersonnelListTile({ const PersonnelListTile({
required this.record, super.key, required this.record,
super.key,
this.onTap, this.onTap,
this.onDelete,
}); });
final PersonnelRecord record; final PersonnelRecord record;
/// Called when the tile is tapped (opens edit dialog).
final VoidCallback? onTap; final VoidCallback? onTap;
/// Called when the user selects Delete from the context menu.
final VoidCallback? onDelete;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -40,7 +48,7 @@ class PersonnelListTile extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
record.name, record.displayLabel,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.colorScheme.onPrimaryContainer, color: theme.colorScheme.onPrimaryContainer,
@ -84,7 +92,7 @@ class PersonnelListTile extends StatelessWidget {
), ),
), ),
title: Text( title: Text(
record.name, record.displayLabel,
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
), ),
subtitle: Text( subtitle: Text(

View File

@ -13,6 +13,8 @@ class PositionSlotCard extends StatelessWidget {
this.assignedSoldier, this.assignedSoldier,
this.onUnassign, this.onUnassign,
this.onAcceptDrop, this.onAcceptDrop,
this.onEdit,
this.onDelete,
}); });
final PositionSlot slot; final PositionSlot slot;
@ -20,6 +22,12 @@ class PositionSlotCard extends StatelessWidget {
final VoidCallback? onUnassign; final VoidCallback? onUnassign;
final void Function(PersonnelRecord soldier)? onAcceptDrop; final void Function(PersonnelRecord soldier)? onAcceptDrop;
/// Called when the user taps the edit button on this slot.
final VoidCallback? onEdit;
/// Called when the user taps the delete button on this slot.
final VoidCallback? onDelete;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -67,21 +75,48 @@ class PositionSlotCard extends StatelessWidget {
fontWeight: slot.isVacant ? FontWeight.bold : FontWeight.normal, fontWeight: slot.isVacant ? FontWeight.bold : FontWeight.normal,
), ),
), ),
trailing: slot.isFilled && onUnassign != null trailing: _buildTrailing(theme),
? IconButton(
icon: const Icon(Icons.person_remove, size: 16),
onPressed: onUnassign,
tooltip: 'Unassign soldier',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
)
: null,
), ),
); );
}, },
); );
} }
Widget? _buildTrailing(ThemeData theme) {
final hasActions = onUnassign != null || onEdit != null || onDelete != null;
if (!hasActions) return null;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (slot.isFilled && onUnassign != null)
IconButton(
icon: const Icon(Icons.person_remove, size: 15),
onPressed: onUnassign,
tooltip: 'Unassign soldier',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
),
if (onEdit != null)
IconButton(
icon: const Icon(Icons.edit_outlined, size: 15),
onPressed: onEdit,
tooltip: 'Edit slot',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
),
if (onDelete != null)
IconButton(
icon: Icon(Icons.delete_outline, size: 15, color: theme.colorScheme.error),
onPressed: onDelete,
tooltip: 'Delete slot',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 26, minHeight: 26),
),
],
);
}
Widget _buildMosBadge(ThemeData theme) { Widget _buildMosBadge(ThemeData theme) {
return Container( return Container(
width: 40, width: 40,

View File

@ -642,7 +642,27 @@ This document breaks the master development plan into five structured phases spa
--- ---
## Phase 5: Platform Optimization, Profiling, & Hardening ## Phase 5: UI/CRUD Layer & Web Platform ✅ COMPLETE (2026-08-09)
> **Status:** Phase 5 UI/CRUD layer and web platform support completed and merged to `main`.
> 263/263 tests passing. `flutter analyze` — No issues found.
>
> **Completed in this phase:**
>
> - `SoldierPosition` entity — 31 built-in Army duty positions + user-defined custom positions
> - `SoldierPositions` Drift table + `SoldierPositionsDao` + `DriftSoldierPositionsRepository`
> - `AppDatabase` schema v2 migration — adds `position_title` to `Personnel`, creates `soldier_positions` table
> - Web platform: `drift_worker.dart/js`, `sqlite3.wasm`, platform-adaptive `driftDatabase()` connection
> - `PersonnelRecord` — extended with `positionTitle`, `displayLabel`, `isAssigned`, `isFmc`
> - `UnitTreeCubit` — full CRUD: `addUnit`, `updateUnit`, `addSlot`, `updateSlot`, `deleteSlot`, `clearAll`
> - `PersonnelCubit` — full CRUD: `addPersonnel`, `updatePersonnel`, `deletePersonnel`
> - `AddEditPersonnelDialog` — position picker with custom position management, MOS autocomplete
> - `AddEditSlotDialog` — slot creation/editing with MOS autocomplete and key-leader toggle
> - `AddEditUnitDialog` — unit creation/editing with echelon/branch/parent selectors
> - `HomeScreen` — full CRUD app bar actions, inspector CRUD buttons, export dialog with format/page-size selection
>
> **Deferred to hardening:** MIL-STD-2525D symbology engine, SQLCipher encryption,
> undo/redo stack, multi-window support, PWA service worker, Android battery profiling.
### Weeks 20 – 24 ### Weeks 20 – 24

View File

@ -11,11 +11,13 @@ import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart'; import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart'; import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart'; import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart'; import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
import 'package:uto_manager/presentation/screens/home_screen.dart'; import 'package:uto_manager/presentation/screens/home_screen.dart';
void main() { void main() {
late AppDatabase db; late AppDatabase db;
late DriftSoldierPositionsRepository positionsRepo;
late UnitTreeCubit unitTreeCubit; late UnitTreeCubit unitTreeCubit;
late PersonnelCubit personnelCubit; late PersonnelCubit personnelCubit;
late TemplateCubit templateCubit; late TemplateCubit templateCubit;
@ -28,6 +30,7 @@ void main() {
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db); final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao); final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao); final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
positionsRepo = DriftSoldierPositionsRepository(dao: db.soldierPositionsDao);
const assignmentService = AssignmentService(treeService: treeService); const assignmentService = AssignmentService(treeService: treeService);
// Create cubits but do NOT call startWatching() — avoids live streams. // Create cubits but do NOT call startWatching() — avoids live streams.
@ -66,7 +69,7 @@ void main() {
BlocProvider<TemplateCubit>.value(value: templateCubit), BlocProvider<TemplateCubit>.value(value: templateCubit),
BlocProvider<ValidationCubit>.value(value: validationCubit), BlocProvider<ValidationCubit>.value(value: validationCubit),
], ],
child: const MaterialApp(home: HomeScreen()), child: MaterialApp(home: HomeScreen(positionsRepository: positionsRepo)),
), ),
); );

5
web/drift_worker.dart Normal file
View File

@ -0,0 +1,5 @@
import 'package:drift/wasm.dart';
void main() {
WasmDatabase.workerMainForOpen();
}

14475
web/drift_worker.js Normal file

File diff suppressed because one or more lines are too long

BIN
web/sqlite3.wasm Normal file

Binary file not shown.