Compare commits

...

3 Commits

31 changed files with 17231 additions and 926 deletions

590
.gitignore vendored
View File

@ -1,293 +1,297 @@
# ============================================================================= # =============================================================================
# UTO Manager — Flutter / Dart / VSCode / MkDocs .gitignore # UTO Manager — Flutter / Dart / VSCode / MkDocs .gitignore
# U.S. Army Unit Task Organization Enterprise System # U.S. Army Unit Task Organization Enterprise System
# ============================================================================= # =============================================================================
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Flutter / Dart # Flutter / Dart
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Dart tool cache # Dart tool cache
.dart_tool/ .dart_tool/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
# Flutter build outputs # Flutter build outputs
build/ build/
*.apk *.apk
*.aab *.aab
*.ipa *.ipa
*.app *.app
*.exe *.exe
*.msix *.msix
*.msi *.msi
# Dart generated files # Dart generated files
*.g.dart *.g.dart
*.freezed.dart *.freezed.dart
*.gr.dart *.gr.dart
*.mocks.dart *.mocks.dart
# Drift / SQLite generated files # Drift / SQLite generated files
*.drift.dart *.drift.dart
*.g.dart *.g.dart
# Protobuf generated files # Protobuf generated files
*.pb.dart *.pb.dart
*.pbenum.dart *.pbenum.dart
*.pbgrpc.dart *.pbgrpc.dart
*.pbjson.dart *.pbjson.dart
# pubspec lock (keep for reproducible builds — comment out if desired) # pubspec lock (keep for reproducible builds — comment out if desired)
# pubspec.lock # pubspec.lock
# Flutter web build # Flutter web build
web/flutter_service_worker.js web/flutter_service_worker.js
web/flutter.js web/flutter.js
web/flutter_bootstrap.js web/flutter_bootstrap.js
web/version.json web/version.json
web/main.dart.js web/main.dart.js
web/main.dart.js.map web/main.dart.js.map
# Flutter Windows build artifacts # Drift web worker build artifacts (keep drift_worker.js, sqlite3.wasm, drift_worker.dart)
windows/flutter/generated_plugin_registrant.cc web/drift_worker.js.deps
windows/flutter/generated_plugin_registrant.h web/drift_worker.js.map
windows/flutter/generated_plugins.cmake
windows/flutter/ephemeral/ # Flutter Windows build artifacts
windows/flutter/generated_plugin_registrant.cc
# Flutter Android build artifacts windows/flutter/generated_plugin_registrant.h
android/.gradle/ windows/flutter/generated_plugins.cmake
android/local.properties windows/flutter/ephemeral/
android/key.properties
android/app/google-services.json # Flutter Android build artifacts
android/app/debug.keystore android/.gradle/
android/app/release.keystore android/local.properties
android/app/upload-keystore.jks android/key.properties
android/captures/ android/app/google-services.json
android/gradlew android/app/debug.keystore
android/gradlew.bat android/app/release.keystore
android/gradle/ android/app/upload-keystore.jks
android/captures/
# Flutter iOS (future target) android/gradlew
ios/Flutter/App.framework android/gradlew.bat
ios/Flutter/Flutter.framework android/gradle/
ios/Flutter/Flutter.podspec
ios/Flutter/Generated.xcconfig # Flutter iOS (future target)
ios/Flutter/ephemeral/ ios/Flutter/App.framework
ios/Flutter/app.flx ios/Flutter/Flutter.framework
ios/Flutter/app.zip ios/Flutter/Flutter.podspec
ios/Flutter/flutter_assets/ ios/Flutter/Generated.xcconfig
ios/ServiceDefinitions.json ios/Flutter/ephemeral/
ios/Runner/GeneratedPluginRegistrant.* ios/Flutter/app.flx
ios/Pods/ ios/Flutter/app.zip
ios/.symlinks/ ios/Flutter/flutter_assets/
ios/Flutter/flutter_export_environment.sh ios/ServiceDefinitions.json
ios/Runner/GeneratedPluginRegistrant.*
# Dart analysis cache ios/Pods/
.dart_tool/ ios/.symlinks/
analysis_options.yaml.bak ios/Flutter/flutter_export_environment.sh
# Coverage reports # Dart analysis cache
coverage/ .dart_tool/
lcov.info analysis_options.yaml.bak
*.lcov
# Coverage reports
# Dart pub cache (local) coverage/
.pub-cache/ lcov.info
.pub/ *.lcov
# Flutter DevTools # Dart pub cache (local)
.devtools .pub-cache/
.pub/
# -----------------------------------------------------------------------------
# Visual Studio Code # Flutter DevTools
# ----------------------------------------------------------------------------- .devtools
# VSCode workspace settings (keep .vscode/settings.json if team-shared) # -----------------------------------------------------------------------------
.vscode/* # Visual Studio Code
!.vscode/settings.json # -----------------------------------------------------------------------------
!.vscode/tasks.json
!.vscode/launch.json # VSCode workspace settings (keep .vscode/settings.json if team-shared)
!.vscode/extensions.json .vscode/*
!.vscode/*.code-snippets !.vscode/settings.json
!.vscode/tasks.json
# VSCode local history !.vscode/launch.json
.history/ !.vscode/extensions.json
*.vsix !.vscode/*.code-snippets
# VSCode test results # VSCode local history
.vscode-test/ .history/
*.vsix
# -----------------------------------------------------------------------------
# MkDocs / Python Documentation # VSCode test results
# ----------------------------------------------------------------------------- .vscode-test/
# MkDocs build output # -----------------------------------------------------------------------------
site/ # MkDocs / Python Documentation
# -----------------------------------------------------------------------------
# Python virtual environments
.venv/ # MkDocs build output
venv/ site/
env/
ENV/ # Python virtual environments
.env/ .venv/
venv/
# Python cache env/
__pycache__/ ENV/
*.py[cod] .env/
*$py.class
*.pyo # Python cache
*.pyd __pycache__/
.Python *.py[cod]
*$py.class
# Python package build artifacts *.pyo
dist/ *.pyd
*.egg-info/ .Python
*.egg
MANIFEST # Python package build artifacts
dist/
# pip *.egg-info/
pip-log.txt *.egg
pip-delete-this-directory.txt MANIFEST
# MkDocs cache # pip
.cache/ pip-log.txt
.mkdocs_cache/ pip-delete-this-directory.txt
# Jupyter Notebooks (if used for documentation) # MkDocs cache
.ipynb_checkpoints/ .cache/
.mkdocs_cache/
# -----------------------------------------------------------------------------
# Operating System # Jupyter Notebooks (if used for documentation)
# ----------------------------------------------------------------------------- .ipynb_checkpoints/
# Windows # -----------------------------------------------------------------------------
Thumbs.db # Operating System
Thumbs.db:encryptable # -----------------------------------------------------------------------------
ehthumbs.db
ehthumbs_vista.db # Windows
Desktop.ini Thumbs.db
$RECYCLE.BIN/ Thumbs.db:encryptable
*.lnk ehthumbs.db
*.stackdump ehthumbs_vista.db
Desktop.ini
# macOS $RECYCLE.BIN/
.DS_Store *.lnk
.AppleDouble *.stackdump
.LSOverride
._* # macOS
.DocumentRevisions-V100 .DS_Store
.fseventsd .AppleDouble
.Spotlight-V100 .LSOverride
.TemporaryItems ._*
.Trashes .DocumentRevisions-V100
.VolumeIcon.icns .fseventsd
.com.apple.timemachine.donotpresent .Spotlight-V100
.TemporaryItems
# Linux .Trashes
*~ .VolumeIcon.icns
.fuse_hidden* .com.apple.timemachine.donotpresent
.directory
.Trash-* # Linux
.nfs* *~
.fuse_hidden*
# ----------------------------------------------------------------------------- .directory
# Security & Secrets .Trash-*
# ----------------------------------------------------------------------------- .nfs*
# Environment variable files # -----------------------------------------------------------------------------
.env # Security & Secrets
.env.* # -----------------------------------------------------------------------------
!.env.example
*.env # Environment variable files
.env
# Key files and certificates .env.*
*.pem !.env.example
*.key *.env
*.p12
*.pfx # Key files and certificates
*.jks *.pem
*.keystore *.key
google-services.json *.p12
GoogleService-Info.plist *.pfx
*.jks
# SQLCipher / database encryption keys *.keystore
*.db-key google-services.json
*.sqlite-key GoogleService-Info.plist
# Firebase # SQLCipher / database encryption keys
firebase.json *.db-key
.firebaserc *.sqlite-key
firebase-debug.log
# Firebase
# ----------------------------------------------------------------------------- firebase.json
# CI/CD & Build Tools .firebaserc
# ----------------------------------------------------------------------------- firebase-debug.log
# GitHub Actions local runner cache # -----------------------------------------------------------------------------
.act/ # CI/CD & Build Tools
# -----------------------------------------------------------------------------
# Gradle
.gradle/ # GitHub Actions local runner cache
gradle-app.setting .act/
!gradle-wrapper.jar
!gradle-wrapper.properties # Gradle
.gradle/
# CMake (Windows native build) gradle-app.setting
CMakeCache.txt !gradle-wrapper.jar
CMakeFiles/ !gradle-wrapper.properties
CMakeScripts/
cmake_install.cmake # CMake (Windows native build)
install_manifest.txt CMakeCache.txt
compile_commands.json CMakeFiles/
CTestTestfile.cmake CMakeScripts/
_deps/ cmake_install.cmake
install_manifest.txt
# Ninja build files compile_commands.json
*.ninja CTestTestfile.cmake
*.ninja_deps _deps/
*.ninja_log
# Ninja build files
# ----------------------------------------------------------------------------- *.ninja
# Logs & Temporary Files *.ninja_deps
# ----------------------------------------------------------------------------- *.ninja_log
# Log files # -----------------------------------------------------------------------------
*.log # Logs & Temporary Files
logs/ # -----------------------------------------------------------------------------
npm-debug.log*
yarn-debug.log* # Log files
yarn-error.log* *.log
logs/
# Temporary files npm-debug.log*
*.tmp yarn-debug.log*
*.temp yarn-error.log*
*.swp
*.swo # Temporary files
*~ *.tmp
.~lock.* *.temp
*.swp
# Test output *.swo
test-results/ *~
test_results/ .~lock.*
junit.xml
# Test output
# ----------------------------------------------------------------------------- test-results/
# Project-Specific test_results/
# ----------------------------------------------------------------------------- junit.xml
# UTO project save files (user data — not source controlled) # -----------------------------------------------------------------------------
*.uto # Project-Specific
# -----------------------------------------------------------------------------
# Exported organizational charts (generated artifacts)
exports/ # UTO project save files (user data — not source controlled)
*.uto
# Local MOS dictionary overrides (use committed version)
local_mos_override.json # Exported organizational charts (generated artifacts)
exports/
# Performance benchmark raw data
benchmarks/raw/ # Local MOS dictionary overrides (use committed version)
local_mos_override.json
# DevTools snapshots
*.devtools_snapshot # Performance benchmark raw data
*.heapsnapshot benchmarks/raw/
# DevTools snapshots
*.devtools_snapshot
*.heapsnapshot

574
README.md
View File

@ -1,286 +1,288 @@
# UTO Manager # UTO Manager
## Flutter-Based Military Unit Task Organization Enterprise System ## Flutter-Based Military Unit Task Organization Enterprise System
### U.S. Army 2026 Doctrine — Cross-Platform Command & Control Application ### U.S. Army 2026 Doctrine — Cross-Platform Command & Control Application
--- ---
[![Flutter](https://img.shields.io/badge/Flutter-3.x-02569B?logo=flutter)](https://flutter.dev) [![Flutter](https://img.shields.io/badge/Flutter-3.x-02569B?logo=flutter)](https://flutter.dev)
[![Dart](https://img.shields.io/badge/Dart-3.x-0175C2?logo=dart)](https://dart.dev) [![Dart](https://img.shields.io/badge/Dart-3.x-0175C2?logo=dart)](https://dart.dev)
[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Web%20%7C%20Android-brightgreen)](https://flutter.dev/multi-platform) [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Web%20%7C%20Android-brightgreen)](https://flutter.dev/multi-platform)
[![License](https://img.shields.io/badge/License-Proprietary-red)](LICENSE) [![License](https://img.shields.io/badge/License-Proprietary-red)](LICENSE)
[![Docs](https://img.shields.io/badge/Docs-MkDocs-526CFE?logo=materialformkdocs)](https://www.mkdocs.org) [![Docs](https://img.shields.io/badge/Docs-MkDocs-526CFE?logo=materialformkdocs)](https://www.mkdocs.org)
--- ---
## Overview ## Overview
**UTO Manager** is a cross-platform command and control (C2) application for creating, visualizing, and validating U.S. Army unit task organizations (UTOs) in accordance with 2026 Army doctrine. Built with Flutter, the application targets **Windows x64**, **Web (WASM/CanvasKit)**, and **Android** from a single Dart codebase. **UTO Manager** is a cross-platform command and control (C2) application for creating, visualizing, and validating U.S. Army unit task organizations (UTOs) in accordance with 2026 Army doctrine. Built with Flutter, the application targets **Windows x64**, **Web (WASM/CanvasKit)**, and **Android** from a single Dart codebase.
The system models military formations from individual soldier positions up to multi-domain Brigade Combat Teams, enforcing Modified Table of Organization and Equipment (MTOE) doctrinal rules in real time. The system models military formations from individual soldier positions up to multi-domain Brigade Combat Teams, enforcing Modified Table of Organization and Equipment (MTOE) doctrinal rules in real time.
--- ---
## 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
- **Drag-and-Drop Re-parenting** — Cross-platform state machine with doctrinal validation highlights (green/red) and undo/redo support - **Full CRUD UI** — Add, edit, and delete units, position slots, and soldiers via modal dialogs with live MOS autocomplete
- **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android - **Drag-and-Drop Re-parenting** — Cross-platform state machine with doctrinal validation highlights (green/red) and undo/redo support
- **2026 Doctrinal Templates** — Pre-configured JSON templates for MBCT, SBCT, ABCT, CAB-Heavy, and CAB-Light formations - **MIL-STD-2525D Symbology** — Hybrid rendering pipeline: `milsymbol.js` on Web, native SVG path engine on Windows/Android *(planned)*
- **Real-Time MTOE Validation** — Background Dart Isolate engine detecting MOS mismatches, rank violations, vacant leadership slots, and structural hierarchy errors - **2026 Doctrinal Templates** — Pre-configured JSON templates for MBCT, SBCT, ABCT, CAB-Heavy, and CAB-Light formations
- **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments - **Real-Time MTOE Validation** — Background Dart Isolate engine detecting MOS mismatches, rank violations, vacant leadership slots, and structural hierarchy errors
- **Multi-Format Export** — JSON, Protobuf, and vector PDF organizational chart export - **Offline-First Architecture** — Drift/SQLite persistence with delta synchronization for contested network environments
- **Multi-Format Export** — JSON, Protobuf (`.utob`), and vector PDF organizational chart export
--- - **Web Platform Support** — Full WASM/IndexedDB persistence via `drift_worker.js` and `sqlite3.wasm`
## Platform Targets ---
| Platform | Deployment | Performance Target | ## Platform Targets
|---|---|---|
| Windows x64 | Native `.msi` / `.exe` | 60 FPS @ 5,000+ nodes | | Platform | Deployment | Performance Target |
| Web (WASM) | WebAssembly + CanvasKit | 60 FPS @ 2,000+ nodes | |---|---|---|
| Android | Native `.apk` / `.aab` | 60 FPS @ 1,500+ nodes | | Windows x64 | Native `.msi` / `.exe` | 60 FPS @ 5,000+ nodes |
| Web (WASM) | WebAssembly + CanvasKit | 60 FPS @ 2,000+ nodes |
--- | Android | Native `.apk` / `.aab` | 60 FPS @ 1,500+ nodes |
## Architecture ---
```text ## Architecture
[Presentation Layer: CustomPainter / InteractiveViewer / Responsive Shell]
│ ```text
▼ [Presentation Layer: CustomPainter / InteractiveViewer / Responsive Shell]
[Application State Layer: BLoC / Signals / Super Drag-and-Drop Pipeline] │
│ ▼
▼ [Application State Layer: BLoC / Signals / Super Drag-and-Drop Pipeline]
[Domain Layer: MTOE Doctrinal Rules Engine / Structural Validation] │
│ ▼
▼ [Domain Layer: MTOE Doctrinal Rules Engine / Structural Validation]
[Infrastructure Layer: Drift SQLite / IndexedDB / Protobuf Codecs] │
``` ▼
[Infrastructure Layer: Drift SQLite / IndexedDB / Protobuf Codecs]
### Project Structure ```
```text ### Project Structure
lib/
├── domain/ # Entities, value objects, repository interfaces ```text
│ ├── entities/ # UnitNode, PositionSlot, PersonnelRecord lib/
│ ├── enums/ # Echelon, DoctrinalBranch, AuthorizedRank ├── domain/ # Entities, value objects, repository interfaces
│ └── repositories/ # Abstract repository contracts │ ├── entities/ # UnitNode, PositionSlot, PersonnelRecord
├── application/ # BLoC / use cases / state management │ ├── enums/ # Echelon, DoctrinalBranch, AuthorizedRank
│ ├── blocs/ # State management BLoCs │ └── repositories/ # Abstract repository contracts
│ └── use_cases/ # Domain use case implementations ├── application/ # BLoC / use cases / state management
├── infrastructure/ # Drift DB, IndexedDB adapters, Protobuf codecs │ ├── blocs/ # State management BLoCs
│ ├── database/ # Drift table definitions and DAOs │ └── use_cases/ # Domain use case implementations
│ ├── codecs/ # JSON and Protobuf serialization ├── infrastructure/ # Drift DB, IndexedDB adapters, Protobuf codecs
│ └── sync/ # Delta synchronization engine │ ├── database/ # Drift table definitions and DAOs
└── presentation/ # Widgets, painters, responsive shell │ ├── codecs/ # JSON and Protobuf serialization
├── canvas/ # CustomPainter, InteractiveViewer, viewport │ └── sync/ # Delta synchronization engine
├── layout/ # Reingold-Tilford algorithm, Quadtree index └── presentation/ # Widgets, painters, responsive shell
├── symbology/ # MIL-STD-2525D rendering pipeline ├── canvas/ # CustomPainter, InteractiveViewer, viewport
└── shell/ # Three-pane UI, responsive layouts ├── layout/ # Reingold-Tilford algorithm, Quadtree index
``` ├── symbology/ # MIL-STD-2525D rendering pipeline
└── shell/ # Three-pane UI, responsive layouts
--- ```
## Getting Started ---
### Prerequisites ## Getting Started
- [Flutter SDK](https://docs.flutter.dev/get-started/install) 3.x or later ### Prerequisites
- [Dart SDK](https://dart.dev/get-dart) 3.x (included with Flutter)
- [Visual Studio 2022](https://visualstudio.microsoft.com/) with **Desktop development with C++** workload (Windows target) - [Flutter SDK](https://docs.flutter.dev/get-started/install) 3.x or later
- [Android Studio](https://developer.android.com/studio) with Android SDK (Android target) - [Dart SDK](https://dart.dev/get-dart) 3.x (included with Flutter)
- [Python 3.9+](https://www.python.org/) with `pip` (MkDocs documentation) - [Visual Studio 2022](https://visualstudio.microsoft.com/) with **Desktop development with C++** workload (Windows target)
- [Git](https://git-scm.com/) - [Android Studio](https://developer.android.com/studio) with Android SDK (Android target)
- [Python 3.9+](https://www.python.org/) with `pip` (MkDocs documentation)
### Clone the Repository - [Git](https://git-scm.com/)
```bash ### Clone the Repository
git clone https://git.kellsupport.com/uto_manager.git
cd uto_manager ```bash
``` git clone https://git.kellsupport.com/uto_manager.git
cd uto_manager
### Install Flutter Dependencies ```
```bash ### Install Flutter Dependencies
flutter pub get
``` ```bash
flutter pub get
### Run the Application ```
```bash ### Run the Application
# Windows desktop
flutter run -d windows ```bash
# Windows desktop
# Web (Chrome) flutter run -d windows
flutter run -d chrome
# Web (Chrome)
# Web (WASM mode) flutter run -d chrome
flutter run -d chrome --wasm
# Web (WASM mode)
# Android (connected device or emulator) flutter run -d chrome --wasm
flutter run -d android
``` # Android (connected device or emulator)
flutter run -d android
### Run Tests ```
```bash ### Run Tests
# All tests
flutter test ```bash
# All tests
# With coverage flutter test
flutter test --coverage
# With coverage
# Static analysis flutter test --coverage
flutter analyze
``` # Static analysis
flutter analyze
### Build Release Packages ```
```bash ### Build Release Packages
# Windows
flutter build windows --release ```bash
# Windows
# Web (WASM) flutter build windows --release
flutter build web --wasm --release
# Web (WASM)
# Android APK flutter build web --wasm --release
flutter build apk --release
# Android APK
# Android App Bundle (Google Play) flutter build apk --release
flutter build appbundle --release
``` # Android App Bundle (Google Play)
flutter build appbundle --release
--- ```
## Documentation ---
Project documentation is built with [MkDocs](https://www.mkdocs.org/) using the [Material theme](https://squidfunk.github.io/mkdocs-material/). ## Documentation
### Install Documentation Dependencies Project documentation is built with [MkDocs](https://www.mkdocs.org/) using the [Material theme](https://squidfunk.github.io/mkdocs-material/).
```bash ### Install Documentation Dependencies
pip install -r docs/requirements.txt
``` ```bash
pip install -r docs/requirements.txt
### Serve Documentation Locally ```
```bash ### Serve Documentation Locally
mkdocs serve
``` ```bash
mkdocs serve
Then open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. ```
### Build Documentation Site Then open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser.
```bash ### Build Documentation Site
mkdocs build
``` ```bash
mkdocs build
Output is written to the `site/` directory (excluded from version control). ```
--- Output is written to the `site/` directory (excluded from version control).
## Development Workflow ---
### Branch Strategy ## Development Workflow
| Branch | Purpose | ### Branch Strategy
|---|---|
| `main` | Production-ready releases only | | Branch | Purpose |
| `develop` | Integration branch for completed features | |---|---|
| `feature/*` | Individual feature development | | `main` | Production-ready releases only |
| `fix/*` | Bug fixes | | `develop` | Integration branch for completed features |
| `release/*` | Release preparation and stabilization | | `feature/*` | Individual feature development |
| `fix/*` | Bug fixes |
### Commit Convention | `release/*` | Release preparation and stabilization |
This project follows [Conventional Commits](https://www.conventionalcommits.org/): ### Commit Convention
```text This project follows [Conventional Commits](https://www.conventionalcommits.org/):
feat(canvas): implement viewport culling via inverse matrix transform
fix(validation): correct MOS mismatch detection for 68W slots ```text
docs(readme): update platform prerequisites feat(canvas): implement viewport culling via inverse matrix transform
chore(deps): upgrade drift to 2.x fix(validation): correct MOS mismatch detection for 68W slots
``` docs(readme): update platform prerequisites
chore(deps): upgrade drift to 2.x
### Code Quality ```
```bash ### Code Quality
# Format code
dart format . ```bash
# Format code
# Analyze code dart format .
flutter analyze
# Analyze code
# Run linter flutter analyze
dart run dart_code_metrics:metrics analyze lib
``` # Run linter
dart run dart_code_metrics:metrics analyze lib
--- ```
## Technology Stack ---
| Layer | Technology | Purpose | ## Technology Stack
|---|---|---|
| UI Framework | Flutter (Dart) | Single codebase for Windows, Web, Android | | Layer | Technology | Purpose |
| Rendering | CustomPainter + InteractiveViewer | GPU-direct drawing; bypasses widget tree overhead | |---|---|---|
| State Management | BLoC + Signals | Reactive atomic state | | UI Framework | Flutter (Dart) | Single codebase for Windows, Web, Android |
| Drag-and-Drop | super_drag_and_drop | Cross-platform payload integrity | | Rendering | CustomPainter + InteractiveViewer | GPU-direct drawing; bypasses widget tree overhead |
| Local Database | Drift (SQLite / IndexedDB) | Type-safe reactive ORM; offline-first | | State Management | BLoC + Signals | Reactive atomic state |
| Encryption | SQLCipher | Data-at-rest protection | | Drag-and-Drop | super_drag_and_drop | Cross-platform payload integrity |
| Symbology (Web) | milsymbol.js via dart:js_interop | Full MIL-STD-2525D compliance | | Local Database | Drift (SQLite / IndexedDB) | Type-safe reactive ORM; offline-first |
| Symbology (Native) | path_drawing + SVG assets + LRU cache | Zero external runtime dependencies | | Encryption | SQLCipher | Data-at-rest protection |
| Background Compute | Dart Isolates | Offload layout, validation, export | | Symbology (Web) | milsymbol.js via dart:js_interop | Full MIL-STD-2525D compliance |
| Serialization | JSON + Protobuf | Human-readable + compact binary export | | Symbology (Native) | path_drawing + SVG assets + LRU cache | Zero external runtime dependencies |
| PDF Export | pdf package | Vector-quality organizational chart output | | Background Compute | Dart Isolates | Offload layout, validation, export |
| Documentation | MkDocs + Material theme | User and developer documentation | | Serialization | JSON + Protobuf | Human-readable + compact binary export |
| PDF Export | pdf package | Vector-quality organizational chart output |
--- | Documentation | MkDocs + Material theme | User and developer documentation |
## Development Phases ---
| Phase | Title | Weeks | Status | ## Development Phases
|---|---|---|---|
| Phase 1 | Architecture & Data Foundations | 1 – 4 | ⬜ Not Started | | Phase | Title | Weeks | Status |
| Phase 2 | Canvas Graphics Engine & Gesture Subsystem | 5 – 9 | ⬜ Not Started | |---|---|---|---|
| Phase 3 | 2026 Doctrinal Templates & Symbology Engine | 10 – 14 | ⬜ Not Started | | Phase 1 | Architecture & Data Foundations | 1 – 4 | ✅ Complete |
| Phase 4 | Persistence, Validation Rules, & Export Engine | 15 – 19 | ⬜ Not Started | | Phase 2 | Canvas Graphics Engine & Persistence | 5 – 9 | ✅ Complete |
| Phase 5 | Platform Optimization, Profiling, & Hardening | 20 – 24 | ⬜ Not Started | | Phase 3 | Template Library & Import/Export | 10 – 14 | ✅ Complete |
| Phase 4 | Reporting & Analytics | 15 – 19 | ✅ 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. | 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.
## Contributing ---
1. Fork the repository and create a `feature/*` branch from `develop` ## Contributing
2. Implement your changes with appropriate unit tests
3. Ensure `flutter analyze` passes with zero violations 1. Fork the repository and create a `feature/*` branch from `develop`
4. Ensure `flutter test --coverage` meets the 80% minimum threshold 2. Implement your changes with appropriate unit tests
5. Submit a pull request targeting the `develop` branch 3. Ensure `flutter analyze` passes with zero violations
6. Pull requests require passing CI on all three platform targets before merge 4. Ensure `flutter test --coverage` meets the 80% minimum threshold
5. Submit a pull request targeting the `develop` branch
--- 6. Pull requests require passing CI on all three platform targets before merge
## Security ---
This application is designed for use with U.S. Army organizational data. All local data is encrypted at rest using SQLCipher. Encryption keys are stored in platform keychains (Windows Credential Manager / Android Keystore). ## Security
**Do not commit sensitive data, encryption keys, or classified information to this repository.** This application is designed for use with U.S. Army organizational data. All local data is encrypted at rest using SQLCipher. Encryption keys are stored in platform keychains (Windows Credential Manager / Android Keystore).
--- **Do not commit sensitive data, encryption keys, or classified information to this repository.**
## License ---
Proprietary — All rights reserved. See [LICENSE](LICENSE) for details. ## License
--- Proprietary — All rights reserved. See [LICENSE](LICENSE) for details.
## Related Documents ---
- [`master_development_plan.md`](master_development_plan.md) — Full architectural specification ## Related Documents
- [`phased_development_plan.md`](phased_development_plan.md) — Phase-by-phase implementation plan
- [`development_status.md`](development_status.md) — Sprint tracking and task status - [`master_development_plan.md`](master_development_plan.md) — Full architectural specification
- [Documentation Site](http://127.0.0.1:8000) — MkDocs user and developer documentation - [`phased_development_plan.md`](phased_development_plan.md) — Phase-by-phase implementation plan
- [`development_status.md`](development_status.md) — Sprint tracking and task status
- [Documentation Site](http://127.0.0.1:8000) — MkDocs user and developer documentation

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

@ -1,89 +1,90 @@
# Developer Guide # Developer Guide
## UTO Manager — Engineering & Architecture Reference ## UTO Manager — Engineering & Architecture Reference
--- ---
This guide covers the technical architecture, implementation details, and contribution guidelines for engineers building and extending the UTO Manager platform. This guide covers the technical architecture, implementation details, and contribution guidelines for engineers building and extending the UTO Manager platform.
--- ---
## In This Guide ## In This Guide
| Section | Description | | Section | Description |
|---|---| |---|---|
| [Architecture Overview](architecture/overview.md) | Layered clean architecture and system design | | [Architecture Overview](architecture/overview.md) | Layered clean architecture and system design |
| [Domain Model](domain/entities.md) | Core Dart entities, enums, and repository contracts | | [Domain Model](domain/entities.md) | Core Dart entities, enums, and repository contracts |
| [Canvas Engine](canvas/custom-painter.md) | CustomPainter rendering, viewport culling, layout algorithm | | [Canvas Engine](canvas/custom-painter.md) | CustomPainter rendering, viewport culling, layout algorithm |
| [Symbology Engine](symbology/mil-std-2525d.md) | MIL-STD-2525D hybrid rendering pipeline | | [Symbology Engine](symbology/mil-std-2525d.md) | MIL-STD-2525D hybrid rendering pipeline |
| [Database & Persistence](database/drift-schema.md) | Drift schema, DAOs, migrations, offline-first | | [Database & Persistence](database/drift-schema.md) | Drift schema, DAOs, migrations, offline-first |
| [Validation Engine](validation/mtoe-engine.md) | Real-time MTOE validation in background Isolates | | [Validation Engine](validation/mtoe-engine.md) | Real-time MTOE validation in background Isolates |
| [Export Engine](export/json-codec.md) | JSON, Protobuf, and PDF export modules | | [Export Engine](export/json-codec.md) | JSON, Protobuf, and PDF export modules |
| [Performance](performance/repaint-boundaries.md) | RepaintBoundary isolation, Dart Isolates, benchmarks | | [Performance](performance/repaint-boundaries.md) | RepaintBoundary isolation, Dart Isolates, benchmarks |
| [CI/CD](cicd/pipeline.md) | Multi-platform build pipeline and test gates | | [CI/CD](cicd/pipeline.md) | Multi-platform build pipeline and test gates |
| [Contributing](contributing/workflow.md) | Branch strategy, commit conventions, code standards | | [Contributing](contributing/workflow.md) | Branch strategy, commit conventions, code standards |
--- ---
## Architecture at a Glance ## Architecture at a Glance
```text ```text
[Presentation Layer] [Presentation Layer]
CustomPainter / InteractiveViewer / Responsive Shell CustomPainter / InteractiveViewer / Responsive Shell
│ │
▼ ▼
[Application State Layer] [Application State Layer]
BLoC / Signals / Drag-and-Drop Pipeline BLoC / Signals / Drag-and-Drop Pipeline
│ │
▼ ▼
[Domain Layer] [Domain Layer]
MTOE Rules Engine / Structural Validation / Entities MTOE Rules Engine / Structural Validation / Entities
│ │
▼ ▼
[Infrastructure Layer] [Infrastructure Layer]
Drift SQLite / IndexedDB / Protobuf Codecs / Delta Sync Drift SQLite / IndexedDB / Protobuf Codecs / Delta Sync
``` ```
--- ---
## Technology Stack ## Technology Stack
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| 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) |
| Documentation | MkDocs + Material theme | | PDF Export | Pure-Dart PDF 1.4 (PdfChartExporter) |
| Documentation | MkDocs + Material theme |
---
---
## Development Setup
## Development Setup
```bash
# Clone the repository ```bash
git clone https://git.kellsupport.com/uto_manager.git # Clone the repository
cd uto_manager git clone https://git.kellsupport.com/uto_manager.git
cd uto_manager
# Install Flutter dependencies
flutter pub get # Install Flutter dependencies
flutter pub get
# Run static analysis
flutter analyze # Run static analysis
flutter analyze
# Run all tests with coverage
flutter test --coverage # Run all tests with coverage
flutter test --coverage
# Install documentation dependencies
pip install -r docs/requirements.txt # Install documentation dependencies
pip install -r docs/requirements.txt
# Serve documentation locally
mkdocs serve # Serve documentation locally
``` mkdocs serve
```

View File

@ -1,72 +1,72 @@
# UTO Manager Documentation # UTO Manager Documentation
## Flutter-Based Military Unit Task Organization Enterprise System ## Flutter-Based Military Unit Task Organization Enterprise System
### U.S. Army 2026 Doctrine — Cross-Platform Command & Control Application ### U.S. Army 2026 Doctrine — Cross-Platform Command & Control Application
--- ---
Welcome to the **UTO Manager** documentation. Use the navigation tabs above to find what you need. Welcome to the **UTO Manager** documentation. Use the navigation tabs above to find what you need.
--- ---
## Documentation Sections ## Documentation Sections
<div class="grid cards" markdown> <div class="grid cards" markdown>
- :material-book-open-variant:{ .lg .middle } **User Guide** - :material-book-open-variant:{ .lg .middle } **User Guide**
--- ---
Step-by-step instructions for planners and operators using UTO Manager to build, visualize, and validate unit task organizations. Step-by-step instructions for planners and operators using UTO Manager to build, visualize, and validate unit task organizations.
[:octicons-arrow-right-24: User Guide](user-guide/index.md) [:octicons-arrow-right-24: User Guide](user-guide/index.md)
- :material-code-braces:{ .lg .middle } **Developer Guide** - :material-code-braces:{ .lg .middle } **Developer Guide**
--- ---
Architecture deep-dives, API references, and contribution guidelines for engineers building and extending the platform. Architecture deep-dives, API references, and contribution guidelines for engineers building and extending the platform.
[:octicons-arrow-right-24: Developer Guide](developer-guide/index.md) [:octicons-arrow-right-24: Developer Guide](developer-guide/index.md)
- :material-shield-star:{ .lg .middle } **Doctrinal Reference** - :material-shield-star:{ .lg .middle } **Doctrinal Reference**
--- ---
U.S. Army 2026 force structure specifications, MOS reference tables, and MIL-STD-2525D symbology reference. U.S. Army 2026 force structure specifications, MOS reference tables, and MIL-STD-2525D symbology reference.
[:octicons-arrow-right-24: Doctrinal Reference](doctrinal-reference/index.md) [:octicons-arrow-right-24: Doctrinal Reference](doctrinal-reference/index.md)
- :material-note-text:{ .lg .middle } **Release Notes** - :material-note-text:{ .lg .middle } **Release Notes**
--- ---
Version history, feature summaries, known limitations, and upgrade instructions. Version history, feature summaries, known limitations, and upgrade instructions.
[:octicons-arrow-right-24: Release Notes](release-notes/index.md) [:octicons-arrow-right-24: Release Notes](release-notes/index.md)
</div> </div>
--- ---
## Quick Links ## Quick Links
| Resource | Description | | Resource | Description |
|---|---| |---|---|
| [Installation](user-guide/getting-started/installation.md) | Set up UTO Manager on Windows, Web, or Android | | [Installation](user-guide/getting-started/installation.md) | Set up UTO Manager on Windows, Web, or Android |
| [Quick Start](user-guide/getting-started/quick-start.md) | Create your first unit task organization in 5 minutes | | [Quick Start](user-guide/getting-started/quick-start.md) | Create your first unit task organization in 5 minutes |
| [MBCT Template](user-guide/templates/mbct.md) | Mobile Brigade Combat Team template guide | | [MBCT Template](user-guide/templates/mbct.md) | Mobile Brigade Combat Team template guide |
| [Architecture Overview](developer-guide/architecture/overview.md) | Clean architecture and layered design | | [Architecture Overview](developer-guide/architecture/overview.md) | Clean architecture and layered design |
| [MOS Reference](doctrinal-reference/mos/infantry.md) | Military Occupational Specialty reference tables | | [MOS Reference](doctrinal-reference/mos/infantry.md) | Military Occupational Specialty reference tables |
| [MIL-STD-2525D](doctrinal-reference/symbology/overview.md) | Tactical symbology reference | | [MIL-STD-2525D](doctrinal-reference/symbology/overview.md) | Tactical symbology reference |
--- ---
## About This Project ## About This Project
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

@ -1,21 +1,22 @@
# Release Notes # Release Notes
## UTO Manager Version History ## UTO Manager Version History
--- ---
| Version | Date | Status | | Version | Date | Status |
|---|---|---| |---|---|---|
| [v1.0.0](v1.0.0.md) | TBD | 🔵 In Development | | [v1.0.0](v1.0.0.md) | TBD | 🔵 In Development |
--- ---
## Roadmap ## Roadmap
| 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

@ -1,59 +1,112 @@
# v1.0.0 — Initial Release # v1.0.0 — Initial Release
## 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)
### Organizational Modeling - Web platform: `drift_worker.js` + `sqlite3.wasm` for full WASM/IndexedDB support
- Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord` ### Organizational Modeling
- 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 - Complete domain model: `UnitNode`, `PositionSlot`, `PersonnelRecord`, `SoldierPosition`
- MOS validation across all functional branches (Infantry, Medical, Fires, Signal, Cyber/EW, Aviation, Logistics, Engineer)
### Visual Canvas - 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
- Reingold-Tilford hierarchical tree layout algorithm
- Quadtree spatial index for hit-testing and drag proximity queries ### Full CRUD User Interface
- Cross-platform drag-and-drop re-parenting with doctrinal validation highlights
- MIL-STD-2525D tactical symbology rendering - **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
### Persistence & Validation - **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
- Drift/SQLite offline-first persistence with IndexedDB on Web - **Clear All** — confirmation-gated reset of all units, slots, and assignments
- Real-time MTOE validation engine (MOS mismatch, rank mismatch, vacant leadership, structural violations)
- Delta synchronization for contested network environments ### Visual Canvas
- SQLCipher data-at-rest encryption
- `InteractiveViewer` pan/zoom org chart
### Export - Drag-and-drop soldier assignment (drag from personnel panel → drop on position slot card)
- Drag-and-drop unit re-parenting with doctrinal validation highlights
- JSON export/import with doctrinal validation - Validation node indicators: red border for critical findings, amber for warnings
- Protobuf binary export
- Vector PDF organizational chart export ### MTOE Validation Engine
### Platform-Specific - 7 validation rules (V001–V007): MOS mismatch, rank mismatch, vacant key leader, missing 68W medic, missing 13F FSO, structural hierarchy violation, manning below 70%
- Background Dart Isolate execution via `IsolateValidationRunner`
- Windows: multi-window, high-DPI scaling, `.msi` installer, `.uto` file association - Findings displayed in inspector panel (per-unit, color-coded by severity) and status bar
- Web: PWA offline capability, IndexedDB quota management
- Android: battery profiling, touch-optimized targets (48×48 dp minimum) ### 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
## Known Limitations - `CommandEventStore` — append-only event log for all structural mutations
- `DeltaSynchronizer` — offline-first sync helper (simulated transport; real network deferred)
*To be documented upon release.*
### Export
---
- **JSON export/import** — full unit tree with schema versioning and round-trip fidelity
## Upgrade Instructions - **Binary export (`.utob`)** — `ProtobufCodec` with 12-byte UTOB header + zlib-compressed JSON
- **PDF export** — pure-Dart PDF 1.4 org-chart generator (Letter/A4/Tabloid, UNCLASSIFIED banner)
*Initial release — no upgrade path required.* - Export dialog with format selection and PDF page-size picker
---
## Planned Features (Hardening Phase)
### Visual Symbology
- MIL-STD-2525D tactical symbology rendering (hybrid: `milsymbol.js` on Web, SVG path engine on native)
### Security
- SQLCipher data-at-rest encryption for local Drift database
- Platform keychain key storage (Windows Credential Manager / Android Keystore)
### Platform Hardening
- Windows: multi-window support, `.msi` installer, `.uto` file association
- Web: PWA offline capability, IndexedDB quota management, service worker
- Android: battery profiling, touch-optimized targets (48×48 dp minimum)
### Performance
- Reingold-Tilford layout algorithm in background Dart Isolate
- Quadtree spatial index for hit-testing
- `RepaintBoundary` layer isolation
- 60 FPS at 5,000+ nodes (Windows), 2,000+ (Web), 1,500+ (Android)
### UX
- Undo/redo stack for all CRUD operations
- Canvas search/filter
- Keyboard shortcuts (Ctrl+Z, Ctrl+F, Ctrl+D, Ctrl+S)
---
## Known Limitations
- MIL-STD-2525D symbology not yet rendered (nodes show text labels only)
- No data-at-rest encryption (SQLCipher deferred)
- `DeltaSynchronizer` uses simulated online/offline; no real network transport
- `ProtobufCodec` uses zlib-compressed JSON payload; true `.proto` schema deferred
- Template instantiation uses sequential Drift inserts (not a single transaction)
- No undo/redo for CRUD operations
- Canvas layout is vertical tree only; horizontal layout not implemented
- No canvas search/filter
---
## Upgrade Instructions
*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

@ -1,11 +1,8 @@
import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart'; import 'package:uto_manager/infrastructure/database/daos/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';
@ -45,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
@ -56,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')();
@ -75,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.
@ -89,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(
@ -97,16 +105,25 @@ 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);
}
}, },
); );
} }
/// Opens the SQLite database connection for native platforms. /// Opens a platform-adaptive database connection.
LazyDatabase _openConnection() { ///
return LazyDatabase(() async { /// Uses SQLite via FFI on Windows/Android and WASM/IndexedDB on Web.
final dbFolder = await getApplicationDocumentsDirectory(); QueryExecutor _openConnection() {
final file = File(p.join(dbFolder.path, 'uto_manager.sqlite')); return driftDatabase(
return NativeDatabase.createInBackground(file); 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

@ -5,18 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "100.0.0" version: "96.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.0.0" version: "10.2.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -125,10 +125,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cli_util name: cli_util
sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab" sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.2" version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -181,26 +181,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.9" version: "3.1.7"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift name: drift
sha256: "3a3f1f6f905037d7426e4c445854139fd6a3d592135f7c96d7931682b73d16f4" sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.34.3" version: "2.31.0"
drift_dev: drift_dev:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: drift_dev name: drift_dev
sha256: "735aad3c34215805c66bd518c8812fa4f83b5534b2c13c4092c970c04a7e9983" sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.34.5" version: "2.31.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
url: "https://pub.dev"
source: hosted
version: "0.2.8"
equatable: equatable:
dependency: "direct main" dependency: "direct main"
description: description:
@ -427,14 +435,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.19.2"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -628,10 +628,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlite3 name: sqlite3
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.5.1" version: "2.9.4"
sqlite3_flutter_libs: sqlite3_flutter_libs:
dependency: "direct main" dependency: "direct main"
description: description:
@ -644,10 +644,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlparser name: sqlparser
sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.45.0" version: "0.43.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:

View File

@ -10,6 +10,7 @@ environment:
dependencies: dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
drift: ^2.20.3 drift: ^2.20.3
drift_flutter: ^0.2.8
equatable: ^2.0.5 equatable: ^2.0.5
flutter: flutter:
sdk: flutter sdk: flutter

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.