feat(phase-2): canvas engine, persistence layer, and BLoC state management

This commit is contained in:
UTO Manager Dev 2026-08-09 08:03:51 -04:00
parent 6bffae8cc4
commit ae31173da7
23 changed files with 3369 additions and 135 deletions

View File

@ -1,106 +1,128 @@
# UTO Manager — Development Status # UTO Manager — Development Status
**Last Updated:** 2026-08-07 _Last updated: 2026-08-09_
**Current Branch:** `feature/phase-1-foundations` → merged to `main`
**Active Phase:** Phase 1 — Architecture & Data Foundations ✅ COMPLETE
--- ---
## Phase 1 — Architecture & Data Foundations ✅ COMPLETE ## Current Branch
`main` (feature/phase-2-canvas-engine merged)
### Completed Items ## Overall Progress
| Item | Status | Notes | | Phase | Title | Status |
|------|--------|-------| |-------|-------|--------|
| Flutter project initialization | ✅ | Multi-platform: Windows, Web (WASM), Android | | 1 | Domain Foundation | ✅ Complete |
| Clean architecture folder structure | ✅ | domain / infrastructure / presentation layers | | 2 | Canvas Engine & Persistence | ✅ Complete |
| `pubspec.yaml` configuration | ✅ | Drift, Equatable, flutter_bloc, uuid, meta, path_provider | | 3 | Template Library & Import/Export | 🔲 Not started |
| `analysis_options.yaml` lint rules | ✅ | very_good_analysis base + project overrides | | 4 | Reporting & Analytics | 🔲 Not started |
| `Echelon` entity | ✅ | Brigade→Team hierarchy, `isValidParent`, `isValidChild`, `level` | | 5 | Multi-user / Sync | 🔲 Not started |
| `DoctrinalBranch` entity | ✅ | Infantry, Medical, Fires, Signal, Cyber/EW, Aviation, Logistics, Engineer |
| `AuthorizedRank` entity | ✅ | E1–E9, W1–W5, O1–O10; `payGrade`, `rankTitle`, `meetsMinimum`, category flags |
| `UnitNode` entity | ✅ | Immutable, `Equatable`, `copyWith`, `isRoot`, `isValidParentNode` |
| `PositionSlot` entity | ✅ | Immutable, `Equatable`, `copyWith`, `isVacant`, `isFilled` |
| `PersonnelRecord` entity | ✅ | Immutable, `Equatable`, `copyWith`, `ReadinessState` (FMC/PMC/NMC) |
| `DutyMos` value object | ✅ | Validated MOS codes, `@immutable`, `codesForBranch`, `isKnown` |
| `domain_entities.dart` barrel export | ✅ | Single import for all domain entities |
| `UnitRepository` abstract interface | ✅ | CRUD + subtree + watch streams |
| `PersonnelRepository` abstract interface | ✅ | CRUD + assignment + watch streams |
| `AppDatabase` (Drift) schema | ✅ | Units, PositionSlots, Personnel, Templates tables |
| Drift code generation (`build_runner`) | ✅ | `app_database.g.dart` generated (94 KB) |
| `assets/data/mos_dictionary.json` | ✅ | 8 branches, 60+ MOS codes, position slot templates |
| `assets/svg/` directory | ✅ | Reserved for NATO military symbols (Phase 2) |
| GitHub Actions CI/CD pipeline | ✅ | Test + analyze + build (Windows, Web WASM, Android) |
| `lib/main.dart` placeholder scaffold | ✅ | Army-blue Material 3 theme, placeholder UI |
| Unit tests — `Echelon` | ✅ | 17 tests passing |
| Unit tests — `AuthorizedRank` | ✅ | 16 tests passing |
| Unit tests — `PersonnelRecord` | ✅ | 13 tests passing |
| Unit tests — `UnitNode` | ✅ | 15 tests passing |
| Unit tests — `DutyMos` | ✅ | 32 tests passing |
| Widget test — placeholder scaffold | ✅ | 1 test passing |
| **Total tests** | ✅ | **94 / 94 passing** |
| `flutter analyze` | ✅ | **No issues found** |
--- ---
## Phase 2 — Canvas Engine & Drag-Drop UTO Builder (NEXT) ## Phase 1 — Domain Foundation ✅
### Planned Items **Completed items:**
- `Echelon` entity with doctrinal hierarchy validation (`isValidParent`, `isValidChild`)
- `DoctrinalBranch` entity with display names
- `AuthorizedRank` entity with pay grade, category flags, `meetsMinimum`, `gradeLevel`
- `DutyMos` value object with MOS dictionary validation and `unvalidated` constructor
- `UnitNode` entity with `copyWith`, `isRoot`, `isValidParentNode`
- `PositionSlot` entity with `isVacant`, `isFilled`, `isKeyLeader`
- `PersonnelRecord` entity with `ReadinessState`, `copyWith`, `isUnassigned`
- `UnitRepository` abstract interface
- `PersonnelRepository` abstract interface
- `domain_entities.dart` barrel export
- Full unit test coverage for all domain entities and value objects
| Item | Priority | Notes | **Test count (Phase 1):** 60 tests
|------|----------|-------|
| `UnitTreeService` domain service | High | Build/validate unit tree from `UnitNode` list |
| `AssignmentService` domain service | High | Assign/unassign soldiers to slots with validation |
| Drift DAO implementations | High | `UnitDao`, `PersonnelDao`, `PositionSlotDao`, `TemplateDao` |
| Repository concrete implementations | High | Wire DAOs to abstract repository interfaces |
| `flutter_bloc` Cubits/Blocs | High | `UnitTreeCubit`, `PersonnelCubit`, `TemplateCubit` |
| Three-pane UI shell | High | Left panel (unit tree), center canvas, right panel (personnel) |
| Interactive canvas widget | High | Scrollable/zoomable org chart canvas |
| Drag-and-drop soldier assignment | High | Drag from personnel panel → drop on position slot |
| NATO military symbol SVGs | Medium | Brigade, Battalion, Company, Platoon, Squad, Team symbols |
| Unit node card widget | Medium | Echelon symbol + name + fill rate indicator |
| Position slot card widget | Medium | MOS badge + rank + soldier name or "VACANT" |
| Readiness color coding | Medium | FMC=green, PMC=amber, NMC=red |
| Template save/load | Medium | Serialize unit tree to JSON, persist via `Templates` table |
--- ---
## Phase 3 — Reporting & Export (PLANNED) ## Phase 2 — Canvas Engine & Persistence ✅
| Item | Priority | Notes | **Completed items:**
|------|----------|-------|
| UTO report generation | High | PDF/HTML export of unit task organization | ### Domain Services
| Readiness dashboard | High | Aggregate FMC/PMC/NMC statistics by echelon | - `UnitTreeService` — `buildTree`, `getSubtree`, `getAncestors`, `validateReparent`,
| Personnel roster export | Medium | CSV export of assigned/unassigned soldiers | `validateAssignment`, `calculateFillRate`, `getVacantKeyLeaderSlots`, `getMosMismatches`
| Template library | Medium | Save/load/share UTO templates | - `AssignmentService` — `assign`, `unassign`, `aggregateReadiness` with `ReadinessColor`
| Multi-BCT support | Low | Support multiple simultaneous BCT configurations |
| Web deployment | Low | Deploy WASM build to internal web server | ### Infrastructure — Database
- Drift schema: `Units`, `PositionSlots`, `Personnel`, `Templates` tables
- `AppDatabase` with `forTesting(NativeDatabase.memory())` constructor
- `UnitDao` — full CRUD + watch streams
- `PersonnelDao` — full CRUD + `assignToSlot` / `unassignFromSlot` + watch streams
- `PositionSlotDao` — full CRUD + `assignPersonnel` / `clearAssignment` + watch streams
- `TemplateDao` — full CRUD + `upsertTemplate`
- build_runner code generation (`.g.dart` files)
### Infrastructure — Repositories
- `DriftUnitRepository` — implements `UnitRepository` with domain↔Drift mapping
- `DriftPersonnelRepository` — implements `PersonnelRepository` with domain↔Drift mapping
- `DriftPositionSlotRepository` — concrete slot repository
### Application — BLoC
- `UnitTreeCubit` / `UnitTreeState` (sealed) — tree loading, selection, reparent, subtree delete
- `PersonnelCubit` / `PersonnelState` (sealed) — roster loading, filter, assign/unassign
- `TemplateCubit` / `TemplateState` (sealed) — template save/load/delete
### Presentation
- `HomeScreen` — three-pane adaptive layout (personnel | canvas | inspector)
- `UtoCanvasWidget` — `InteractiveViewer` pan/zoom org chart with drag-and-drop reparent
- `UnitNodeCard` — echelon-colored card with fill rate progress bar
- `PositionSlotCard` — `DragTarget` slot card with MOS badge and readiness color
- `PersonnelListTile` — `Draggable` soldier tile for assignment
- Status bar with live unit/slot fill counts
### App Wiring
- `main.dart` — `MultiBlocProvider` wiring all cubits with production database
**Test count (Phase 2):** 77 new tests (137 total)
--- ---
## Known Issues / Technical Debt ## Test Summary
| Issue | Severity | Resolution | | File | Tests | Status |
|-------|----------|-----------| |------|-------|--------|
| `lines_longer_than_80_chars` disabled | Low | Dart formatter produces lines > 80 chars for chained method calls; rule disabled in `analysis_options.yaml` | | `authorized_rank_test.dart` | 16 | ✅ |
| Drift DAOs not yet implemented | Medium | Phase 2 work item — abstract interfaces defined, concrete implementations pending | | `echelon_test.dart` | 17 | ✅ |
| No integration tests | Medium | Phase 2 will add integration tests for repository implementations | | `personnel_record_test.dart` | 13 | ✅ |
| `sqlite3_flutter_libs` EOL warning | Low | Will upgrade when `drift` supports newer version | | `unit_node_test.dart` | 15 | ✅ |
| `duty_mos_test.dart` | 32 | ✅ |
| `assignment_service_test.dart` | 16 | ✅ |
| `unit_tree_service_test.dart` | 27 | ✅ |
| `widget_test.dart` | 1 | ✅ |
| **Total** | **137** | **✅ All pass** |
--- ---
## Test Coverage Summary ## Known Limitations / Next Steps (Phase 3)
- Template serialization is JSON-only; no import from external formats yet
- No PDF/Excel export of readiness reports
- No multi-user or sync capability
- Canvas layout is vertical tree only; horizontal layout not yet implemented
- No undo/redo for assignment operations
- No search/filter on the canvas itself
---
## Architecture Notes
``` ```
flutter test --coverage lib/
94 tests | 0 failures | 0 skipped ├── domain/
Coverage: domain layer ~95%+ (entities, value objects) │ ├── entities/ # Pure Dart domain models (no Flutter deps)
│ ├── value_objects/ # DutyMos
│ ├── repositories/ # Abstract interfaces
│ └── services/ # UnitTreeService, AssignmentService
├── infrastructure/
│ ├── database/ # Drift schema, DAOs, generated code
│ └── repositories/ # Drift-backed implementations
├── application/
│ └── blocs/ # flutter_bloc Cubits
└── presentation/
├── screens/ # HomeScreen
└── widgets/ # Canvas, cards, tiles
``` ```
---
## Branch History
| Branch | Merged | Description |
|--------|--------|-------------|
| `feature/phase-1-foundations` | 2026-08-07 | Phase 1: Architecture & Data Foundations |

View File

@ -0,0 +1,196 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/repositories/personnel_repository.dart';
import 'package:uto_manager/domain/services/assignment_service.dart';
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// State for the personnel cubit.
sealed class PersonnelState extends Equatable {
const PersonnelState();
}
class PersonnelInitial extends PersonnelState {
const PersonnelInitial();
@override
List<Object?> get props => [];
}
class PersonnelLoading extends PersonnelState {
const PersonnelLoading();
@override
List<Object?> get props => [];
}
class PersonnelLoaded extends PersonnelState {
const PersonnelLoaded({
required this.allPersonnel,
required this.unassigned,
this.filterQuery = '',
});
/// All personnel records.
final List<PersonnelRecord> allPersonnel;
/// Unassigned soldiers (available for assignment).
final List<PersonnelRecord> unassigned;
/// Current search/filter query string.
final String filterQuery;
/// Returns [unassigned] filtered by [filterQuery].
List<PersonnelRecord> get filteredUnassigned {
if (filterQuery.isEmpty) return unassigned;
final q = filterQuery.toLowerCase();
return unassigned.where((p) {
return p.name.toLowerCase().contains(q) ||
p.primaryMos.code.toLowerCase().contains(q) ||
p.payGrade.payGrade.toLowerCase().contains(q);
}).toList();
}
PersonnelLoaded copyWith({
List<PersonnelRecord>? allPersonnel,
List<PersonnelRecord>? unassigned,
String? filterQuery,
}) {
return PersonnelLoaded(
allPersonnel: allPersonnel ?? this.allPersonnel,
unassigned: unassigned ?? this.unassigned,
filterQuery: filterQuery ?? this.filterQuery,
);
}
@override
List<Object?> get props => [allPersonnel, unassigned, filterQuery];
}
class PersonnelError extends PersonnelState {
const PersonnelError(this.message);
final String message;
@override
List<Object?> get props => [message];
}
// ---------------------------------------------------------------------------
// Cubit
// ---------------------------------------------------------------------------
/// Manages personnel state, including loading, filtering, and assignment.
class PersonnelCubit extends Cubit<PersonnelState> {
PersonnelCubit({
required this.personnelRepository,
required this.slotRepository,
required this.assignmentService,
}) : super(const PersonnelInitial());
final PersonnelRepository personnelRepository;
final DriftPositionSlotRepository slotRepository;
final AssignmentService assignmentService;
StreamSubscription<List<PersonnelRecord>>? _allSubscription;
StreamSubscription<List<PersonnelRecord>>? _unassignedSubscription;
List<PersonnelRecord> _all = [];
List<PersonnelRecord> _unassigned = [];
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// Starts watching the database for personnel changes.
void startWatching() {
emit(const PersonnelLoading());
_allSubscription = personnelRepository.watchAll().listen(
(all) {
_all = all;
_rebuild();
},
onError: (Object e) => emit(PersonnelError(e.toString())),
);
_unassignedSubscription = personnelRepository.watchUnassigned().listen(
(unassigned) {
_unassigned = unassigned;
_rebuild();
},
onError: (Object e) => emit(PersonnelError(e.toString())),
);
}
void _rebuild() {
final currentFilter = state is PersonnelLoaded ? (state as PersonnelLoaded).filterQuery : '';
emit(PersonnelLoaded(allPersonnel: _all, unassigned: _unassigned, filterQuery: currentFilter));
}
// ---------------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------------
/// Updates the search filter query.
void setFilter(String query) {
if (state is PersonnelLoaded) {
emit((state as PersonnelLoaded).copyWith(filterQuery: query));
}
}
// ---------------------------------------------------------------------------
// Assignment
// ---------------------------------------------------------------------------
/// Assigns [soldier] to [slot] after validation.
///
/// Returns true on success, false if validation failed.
Future<bool> assignSoldierToSlot(PersonnelRecord soldier, PositionSlot slot) async {
final outcome = assignmentService.assign(slot, soldier);
if (!outcome.isSuccess) return false;
try {
await personnelRepository.assignToSlot(soldier.soldierId, slot.slotId);
await slotRepository.assignPersonnel(slot.slotId, soldier.soldierId);
return true;
} catch (e) {
emit(PersonnelError(e.toString()));
return false;
}
}
/// Unassigns the soldier from [slot].
Future<void> unassignFromSlot(PositionSlot slot) async {
if (slot.isVacant) return;
try {
await personnelRepository.unassignFromSlot(slot.assignedPersonnelId!);
await slotRepository.clearAssignment(slot.slotId);
} catch (e) {
emit(PersonnelError(e.toString()));
}
}
/// Adds a new [record] to the personnel roster.
Future<void> addPersonnel(PersonnelRecord record) async {
try {
await personnelRepository.insert(record);
} catch (e) {
emit(PersonnelError(e.toString()));
}
}
@override
Future<void> close() {
_allSubscription?.cancel();
_unassignedSubscription?.cancel();
return super.close();
}
}

View File

@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// State for the template cubit.
sealed class TemplateState extends Equatable {
const TemplateState();
}
class TemplateInitial extends TemplateState {
const TemplateInitial();
@override
List<Object?> get props => [];
}
class TemplateLoading extends TemplateState {
const TemplateLoading();
@override
List<Object?> get props => [];
}
class TemplateLoaded extends TemplateState {
const TemplateLoaded(this.templates);
final List<Template> templates;
@override
List<Object?> get props => [templates];
}
class TemplateError extends TemplateState {
const TemplateError(this.message);
final String message;
@override
List<Object?> get props => [message];
}
// ---------------------------------------------------------------------------
// Cubit
// ---------------------------------------------------------------------------
/// Manages saved unit tree templates.
class TemplateCubit extends Cubit<TemplateState> {
TemplateCubit({required this.dao}) : super(const TemplateInitial());
final TemplateDao dao;
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
/// Loads all saved templates from the database.
Future<void> loadTemplates() async {
emit(const TemplateLoading());
try {
final templates = await dao.getAllTemplates();
emit(TemplateLoaded(templates));
} catch (e) {
emit(TemplateError(e.toString()));
}
}
// ---------------------------------------------------------------------------
// Save / Delete
// ---------------------------------------------------------------------------
/// Saves a template with [name], [bctType], and [jsonData].
Future<void> saveTemplate({
required String templateId,
required String name,
required String bctType,
required Map<String, dynamic> jsonData,
}) async {
try {
final now = DateTime.now();
await dao.upsertTemplate(
TemplatesCompanion.insert(
templateId: templateId,
name: name,
bctType: bctType,
jsonData: jsonEncode(jsonData),
createdAt: now,
updatedAt: now,
),
);
await loadTemplates();
} catch (e) {
emit(TemplateError(e.toString()));
}
}
/// Deletes the template with [templateId].
Future<void> deleteTemplate(String templateId) async {
try {
await dao.deleteTemplate(templateId);
await loadTemplates();
} catch (e) {
emit(TemplateError(e.toString()));
}
}
/// Returns the JSON data for [templateId], or null if not found.
Future<Map<String, dynamic>?> getTemplateJson(String templateId) async {
try {
final template = await dao.getTemplateById(templateId);
if (template == null) return null;
return jsonDecode(template.jsonData) as Map<String, dynamic>;
} catch (e) {
return null;
}
}
}

View File

@ -0,0 +1,225 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/domain/repositories/unit_repository.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// State for the unit tree cubit.
sealed class UnitTreeState extends Equatable {
const UnitTreeState();
}
/// Initial state before any data is loaded.
class UnitTreeInitial extends UnitTreeState {
const UnitTreeInitial();
@override
List<Object?> get props => [];
}
/// State while the unit tree is loading.
class UnitTreeLoading extends UnitTreeState {
const UnitTreeLoading();
@override
List<Object?> get props => [];
}
/// State when the unit tree has been loaded successfully.
class UnitTreeLoaded extends UnitTreeState {
const UnitTreeLoaded({
required this.units,
required this.slots,
required this.roots,
this.selectedUnitId,
});
/// Flat list of all unit nodes.
final List<UnitNode> units;
/// Flat list of all position slots.
final List<PositionSlot> slots;
/// Root-level [UnitTreeNode]s (built from units + slots).
final List<UnitTreeNode> roots;
/// The currently selected unit ID, or null if none selected.
final String? selectedUnitId;
/// Returns the selected [UnitNode], or null.
UnitNode? get selectedUnit =>
selectedUnitId == null ? null : units.firstWhere((u) => u.unitId == selectedUnitId);
/// Returns slots for the selected unit.
List<PositionSlot> get selectedUnitSlots =>
selectedUnitId == null ? [] : slots.where((s) => s.unitId == selectedUnitId).toList();
UnitTreeLoaded copyWith({
List<UnitNode>? units,
List<PositionSlot>? slots,
List<UnitTreeNode>? roots,
String? selectedUnitId,
bool clearSelection = false,
}) {
return UnitTreeLoaded(
units: units ?? this.units,
slots: slots ?? this.slots,
roots: roots ?? this.roots,
selectedUnitId: clearSelection ? null : (selectedUnitId ?? this.selectedUnitId),
);
}
@override
List<Object?> get props => [units, slots, roots, selectedUnitId];
}
/// State when an error occurred loading the unit tree.
class UnitTreeError extends UnitTreeState {
const UnitTreeError(this.message);
final String message;
@override
List<Object?> get props => [message];
}
// ---------------------------------------------------------------------------
// Cubit
// ---------------------------------------------------------------------------
/// Manages the unit tree state, including loading, selection, and re-parenting.
class UnitTreeCubit extends Cubit<UnitTreeState> {
UnitTreeCubit({
required this.unitRepository,
required this.slotRepository,
required this.treeService,
}) : super(const UnitTreeInitial());
final UnitRepository unitRepository;
final DriftPositionSlotRepository slotRepository;
final UnitTreeService treeService;
StreamSubscription<List<UnitNode>>? _unitSubscription;
StreamSubscription<List<PositionSlot>>? _slotSubscription;
List<UnitNode> _units = [];
List<PositionSlot> _slots = [];
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// Starts watching the database for unit and slot changes.
void startWatching() {
emit(const UnitTreeLoading());
_unitSubscription = unitRepository.watchAll().listen(
(units) {
_units = units;
_rebuild();
},
onError: (Object e) => emit(UnitTreeError(e.toString())),
);
_slotSubscription = slotRepository.watchAll().listen(
(slots) {
_slots = slots;
_rebuild();
},
onError: (Object e) => emit(UnitTreeError(e.toString())),
);
}
void _rebuild() {
final roots = treeService.buildTree(_units, _slots);
final currentSelection =
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
emit(UnitTreeLoaded(
units: _units, slots: _slots, roots: roots, selectedUnitId: currentSelection));
}
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
/// Selects the unit with [unitId].
void selectUnit(String unitId) {
if (state is UnitTreeLoaded) {
emit((state as UnitTreeLoaded).copyWith(selectedUnitId: unitId));
}
}
/// Clears the current unit selection.
void clearSelection() {
if (state is UnitTreeLoaded) {
emit((state as UnitTreeLoaded).copyWith(clearSelection: true));
}
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
/// Adds a new [unit] to the tree.
Future<void> addUnit(UnitNode unit) async {
try {
await unitRepository.insert(unit);
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
/// Re-parents [unitId] under [newParentId] after doctrinal validation.
Future<bool> reparentUnit(String unitId, String newParentId) async {
if (state is! UnitTreeLoaded) return false;
final loaded = state as UnitTreeLoaded;
final child = loaded.units.firstWhere((u) => u.unitId == unitId);
final newParent = loaded.units.firstWhere((u) => u.unitId == newParentId);
final validation = treeService.validateReparent(child, newParent, loaded.units);
if (!validation.isValid) return false;
try {
await unitRepository.reparent(unitId, newParentId);
return true;
} catch (e) {
emit(UnitTreeError(e.toString()));
return false;
}
}
/// Deletes [unitId] and all its descendants.
Future<void> deleteSubtree(String unitId) async {
try {
await unitRepository.deleteSubtree(unitId);
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
/// Clears all units and slots (template reset).
Future<void> clearAll() async {
try {
await slotRepository.deleteAll();
await unitRepository.deleteAll();
} catch (e) {
emit(UnitTreeError(e.toString()));
}
}
@override
Future<void> close() {
_unitSubscription?.cancel();
_slotSubscription?.cancel();
return super.close();
}
}

View File

@ -0,0 +1,163 @@
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
/// Domain service for assigning and unassigning soldiers to position slots.
///
/// Encapsulates all business rules around personnel assignment, including
/// MOS compatibility checks and rank validation. This service is pure domain
/// logic with no infrastructure dependencies.
class AssignmentService {
const AssignmentService({required this.treeService});
/// The [UnitTreeService] used for validation.
final UnitTreeService treeService;
// ---------------------------------------------------------------------------
// Assignment Operations
// ---------------------------------------------------------------------------
/// Attempts to assign [soldier] to [slot].
///
/// Returns a [ValidationResult] indicating success or the reason for failure.
/// On success, returns updated copies of both the slot and the soldier record.
AssignmentOutcome assign(PositionSlot slot, PersonnelRecord soldier) {
final validation = treeService.validateAssignment(slot, soldier);
if (!validation.isValid) {
return AssignmentOutcome.failure(validation.reason!);
}
final mosMismatch = soldier.primaryMos != slot.dutyMos;
final rankMismatch = !soldier.payGrade.meetsMinimum(slot.authorizedRank);
final updatedSlot = slot.copyWith(assignedPersonnelId: soldier.soldierId);
final updatedSoldier = soldier.copyWith(assignedSlotId: slot.slotId);
return AssignmentOutcome.success(
updatedSlot: updatedSlot,
updatedSoldier: updatedSoldier,
hasMosMismatch: mosMismatch,
hasRankMismatch: rankMismatch,
);
}
/// Unassigns the soldier currently in [slot].
///
/// Returns updated copies of the slot and the soldier record, or null
/// for the soldier if [soldier] is null (slot was already vacant).
UnassignmentOutcome unassign(PositionSlot slot, PersonnelRecord? soldier) {
if (slot.isVacant) {
return UnassignmentOutcome(
updatedSlot: slot,
updatedSoldier: null,
);
}
final updatedSlot = slot.copyWith(clearAssignment: true);
final updatedSoldier = soldier?.copyWith(clearAssignment: true);
return UnassignmentOutcome(
updatedSlot: updatedSlot,
updatedSoldier: updatedSoldier,
);
}
// ---------------------------------------------------------------------------
// Readiness Calculations
// ---------------------------------------------------------------------------
/// Returns the aggregate readiness color for a list of [personnel].
///
/// - All FMC → [ReadinessColor.green]
/// - Any NMC → [ReadinessColor.red]
/// - Otherwise → [ReadinessColor.amber]
ReadinessColor aggregateReadiness(List<PersonnelRecord> personnel) {
if (personnel.isEmpty) return ReadinessColor.grey;
final hasNmc = personnel.any((p) => p.readinessState == ReadinessState.notMissionCapable);
if (hasNmc) return ReadinessColor.red;
final allFmc = personnel.every((p) => p.readinessState == ReadinessState.fullyMissionCapable);
if (allFmc) return ReadinessColor.green;
return ReadinessColor.amber;
}
}
/// Color coding for readiness display.
enum ReadinessColor {
/// All soldiers fully mission capable.
green,
/// Mixed readiness states.
amber,
/// One or more soldiers not mission capable.
red,
/// No personnel data available.
grey,
}
/// The outcome of an assignment attempt.
class AssignmentOutcome {
const AssignmentOutcome._({
required this.isSuccess,
this.updatedSlot,
this.updatedSoldier,
this.failureReason,
this.hasMosMismatch = false,
this.hasRankMismatch = false,
});
/// Creates a successful assignment outcome.
factory AssignmentOutcome.success({
required PositionSlot updatedSlot,
required PersonnelRecord updatedSoldier,
bool hasMosMismatch = false,
bool hasRankMismatch = false,
}) {
return AssignmentOutcome._(
isSuccess: true,
updatedSlot: updatedSlot,
updatedSoldier: updatedSoldier,
hasMosMismatch: hasMosMismatch,
hasRankMismatch: hasRankMismatch,
);
}
/// Creates a failed assignment outcome with a [reason].
factory AssignmentOutcome.failure(String reason) {
return AssignmentOutcome._(isSuccess: false, failureReason: reason);
}
/// Whether the assignment succeeded.
final bool isSuccess;
/// The updated slot (non-null on success).
final PositionSlot? updatedSlot;
/// The updated soldier record (non-null on success).
final PersonnelRecord? updatedSoldier;
/// The reason for failure (non-null on failure).
final String? failureReason;
/// Whether the assigned soldier's MOS does not match the slot's duty MOS.
final bool hasMosMismatch;
/// Whether the assigned soldier's rank does not meet the slot's minimum.
final bool hasRankMismatch;
}
/// The outcome of an unassignment operation.
class UnassignmentOutcome {
const UnassignmentOutcome({
required this.updatedSlot,
required this.updatedSoldier,
});
/// The updated slot with the assignment cleared.
final PositionSlot updatedSlot;
/// The updated soldier record with the assignment cleared, or null if
/// no soldier was assigned.
final PersonnelRecord? updatedSoldier;
}

View File

@ -0,0 +1,239 @@
import 'package:uto_manager/domain/entities/echelon.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
/// Result of a doctrinal validation check.
class ValidationResult {
const ValidationResult({required this.isValid, this.reason});
/// Whether the operation is doctrinally valid.
final bool isValid;
/// Human-readable reason if [isValid] is false.
final String? reason;
/// A passing validation result.
static const valid = ValidationResult(isValid: true);
/// Creates a failing validation result with a [reason].
static ValidationResult invalid(String reason) =>
ValidationResult(isValid: false, reason: reason);
}
/// Represents a node in the unit tree with its children and slots.
class UnitTreeNode {
const UnitTreeNode({
required this.unit,
required this.children,
required this.slots,
});
/// The unit node at this position in the tree.
final UnitNode unit;
/// Direct child [UnitTreeNode]s.
final List<UnitTreeNode> children;
/// Position slots belonging to this unit.
final List<PositionSlot> slots;
/// Total number of slots in this subtree (inclusive).
int get totalSlots => slots.length + children.fold(0, (sum, c) => sum + c.totalSlots);
/// Total number of filled slots in this subtree (inclusive).
int get filledSlots =>
slots.where((s) => s.isFilled).length + children.fold(0, (sum, c) => sum + c.filledSlots);
/// Fill rate as a percentage (0–100).
double get fillRate => totalSlots == 0 ? 0.0 : (filledSlots / totalSlots) * 100.0;
}
/// Domain service for building and validating the unit tree structure.
///
/// Provides tree construction from flat lists, doctrinal validation,
/// and subtree operations. This service is pure domain logic with no
/// infrastructure dependencies.
class UnitTreeService {
const UnitTreeService();
// ---------------------------------------------------------------------------
// Tree Construction
// ---------------------------------------------------------------------------
/// Builds a hierarchical [UnitTreeNode] tree from flat [units] and [slots].
///
/// Returns a list of root [UnitTreeNode]s (units with no parent).
List<UnitTreeNode> buildTree(
List<UnitNode> units,
List<PositionSlot> slots,
) {
final slotsByUnit = <String, List<PositionSlot>>{};
for (final slot in slots) {
slotsByUnit.putIfAbsent(slot.unitId, () => []).add(slot);
}
final nodeMap = <String, UnitTreeNode>{};
// First pass: create leaf nodes (no children yet).
for (final unit in units) {
nodeMap[unit.unitId] = UnitTreeNode(
unit: unit,
children: [],
slots: slotsByUnit[unit.unitId] ?? [],
);
}
final roots = <UnitTreeNode>[];
// Second pass: wire parent-child relationships.
for (final unit in units) {
if (unit.parentUnitId == null) {
roots.add(nodeMap[unit.unitId]!);
} else {
final parent = nodeMap[unit.parentUnitId];
if (parent != null) {
(parent.children as List<UnitTreeNode>).add(nodeMap[unit.unitId]!);
}
}
}
return roots;
}
/// Returns all units in the subtree rooted at [unitId] (inclusive).
List<UnitNode> getSubtree(String unitId, List<UnitNode> allUnits) {
final result = <UnitNode>[];
final queue = <String>[unitId];
while (queue.isNotEmpty) {
final current = queue.removeAt(0);
final node = allUnits.firstWhere(
(u) => u.unitId == current,
orElse: () => throw StateError('Unit $current not found'),
);
result.add(node);
final children = allUnits.where((u) => u.parentUnitId == current).map((u) => u.unitId);
queue.addAll(children);
}
return result;
}
/// Returns all ancestors of [unitId] from immediate parent up to root.
List<UnitNode> getAncestors(String unitId, List<UnitNode> allUnits) {
final ancestors = <UnitNode>[];
var current = allUnits.firstWhere(
(u) => u.unitId == unitId,
orElse: () => throw StateError('Unit $unitId not found'),
);
while (current.parentUnitId != null) {
final parent = allUnits.firstWhere(
(u) => u.unitId == current.parentUnitId,
orElse: () => throw StateError('Parent ${current.parentUnitId} not found'),
);
ancestors.add(parent);
current = parent;
}
return ancestors;
}
// ---------------------------------------------------------------------------
// Doctrinal Validation
// ---------------------------------------------------------------------------
/// Validates whether [child] can be re-parented under [newParent].
///
/// Checks echelon hierarchy rules and prevents circular references.
ValidationResult validateReparent(
UnitNode child,
UnitNode newParent,
List<UnitNode> allUnits,
) {
// Cannot parent a unit to itself.
if (child.unitId == newParent.unitId) {
return ValidationResult.invalid('A unit cannot be its own parent.');
}
// Check echelon hierarchy: parent must be exactly one level higher.
if (!child.echelon.isValidParent(newParent.echelon)) {
return ValidationResult.invalid(
'${child.echelon.displayName} cannot be a child of '
'${newParent.echelon.displayName}. '
'Expected parent echelon: ${_expectedParentEchelon(child.echelon)?.displayName ?? "none"}.',
);
}
// Prevent circular reference: newParent must not be in child's subtree.
final subtreeIds = getSubtree(child.unitId, allUnits).map((u) => u.unitId).toSet();
if (subtreeIds.contains(newParent.unitId)) {
return ValidationResult.invalid(
'Cannot re-parent: ${newParent.name} is a descendant of ${child.name}.',
);
}
return ValidationResult.valid;
}
/// Validates whether [slot] can accept [soldier] as an assignment.
ValidationResult validateAssignment(
PositionSlot slot,
PersonnelRecord soldier,
) {
if (slot.isFilled) {
return ValidationResult.invalid(
'Position "${slot.positionTitle}" is already filled.',
);
}
if (soldier.isAssigned) {
return ValidationResult.invalid(
'${soldier.name} is already assigned to another position.',
);
}
if (soldier.primaryMos != slot.dutyMos) {
// Allow assignment but flag as a warning (not a hard block).
// The UI layer decides whether to warn or block.
}
return ValidationResult.valid;
}
/// Returns the expected parent echelon for [echelon], or null for Brigade.
Echelon? _expectedParentEchelon(Echelon echelon) {
if (echelon == Echelon.brigade) return null;
return Echelon.values.firstWhere((e) => e.level == echelon.level - 1);
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
/// Calculates the fill rate for a list of [slots] (0.0–1.0).
double calculateFillRate(List<PositionSlot> slots) {
if (slots.isEmpty) return 0.0;
final filled = slots.where((s) => s.isFilled).length;
return filled / slots.length;
}
/// Returns all vacant key leader slots from [slots].
List<PositionSlot> getVacantKeyLeaderSlots(List<PositionSlot> slots) {
return slots.where((s) => s.isKeyLeader && s.isVacant).toList();
}
/// Returns all slots with MOS mismatches given [personnel].
List<PositionSlot> getMosMismatches(
List<PositionSlot> slots,
List<PersonnelRecord> personnel,
) {
final personnelById = {for (final p in personnel) p.soldierId: p};
return slots.where((slot) {
if (slot.isVacant) return false;
final soldier = personnelById[slot.assignedPersonnelId];
if (soldier == null) return false;
return soldier.primaryMos != slot.dutyMos;
}).toList();
}
}

View File

@ -4,6 +4,10 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/position_slot_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart';
part 'app_database.g.dart'; part 'app_database.g.dart';
@ -73,7 +77,10 @@ class Templates extends Table {
/// ///
/// Manages all four core tables: units, position_slots, personnel, templates. /// Manages all four core tables: units, position_slots, personnel, 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(tables: [Units, PositionSlots, Personnel, Templates]) @DriftDatabase(
tables: [Units, PositionSlots, Personnel, Templates],
daos: [UnitDao, PersonnelDao, PositionSlotDao, 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.
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());

View File

@ -0,0 +1,72 @@
import 'package:drift/drift.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
part 'personnel_dao.g.dart';
/// Data Access Object for [Personnel] table operations.
@DriftAccessor(tables: [Personnel])
class PersonnelDao extends DatabaseAccessor<AppDatabase> with _$PersonnelDaoMixin {
PersonnelDao(super.db);
/// Returns all personnel rows.
Future<List<PersonnelData>> getAllPersonnel() => select(personnel).get();
/// Returns the personnel record with [soldierId], or null if not found.
Future<PersonnelData?> getPersonnelById(String soldierId) =>
(select(personnel)..where((t) => t.soldierId.equals(soldierId))).getSingleOrNull();
/// Returns all soldiers assigned to [slotId].
Future<List<PersonnelData>> getBySlot(String slotId) =>
(select(personnel)..where((t) => t.assignedSlotId.equals(slotId))).get();
/// Returns all unassigned soldiers.
Future<List<PersonnelData>> getUnassigned() =>
(select(personnel)..where((t) => t.assignedSlotId.isNull())).get();
/// Returns all soldiers with [primaryMos].
Future<List<PersonnelData>> getByMos(String primaryMos) =>
(select(personnel)..where((t) => t.primaryMos.equals(primaryMos))).get();
/// Returns all soldiers at [payGrade].
Future<List<PersonnelData>> getByPayGrade(String payGrade) =>
(select(personnel)..where((t) => t.payGrade.equals(payGrade))).get();
/// Inserts a new personnel row.
Future<void> insertPersonnel(PersonnelCompanion entry) =>
into(personnel).insert(entry, mode: InsertMode.insertOrFail);
/// Inserts multiple personnel rows in a single transaction.
Future<void> insertAllPersonnel(List<PersonnelCompanion> entries) =>
batch((b) => b.insertAll(personnel, entries, mode: InsertMode.insertOrFail));
/// Updates an existing personnel row.
Future<bool> updatePersonnel(PersonnelCompanion entry) => update(personnel).replace(entry);
/// Deletes the personnel record with [soldierId].
Future<int> deletePersonnel(String soldierId) =>
(delete(personnel)..where((t) => t.soldierId.equals(soldierId))).go();
/// Assigns [soldierId] to [slotId].
Future<void> assignToSlot(String soldierId, String slotId) async {
await (update(personnel)..where((t) => t.soldierId.equals(soldierId))).write(
PersonnelCompanion(assignedSlotId: Value(slotId)),
);
}
/// Clears the slot assignment for [soldierId].
Future<void> unassignFromSlot(String soldierId) async {
await (update(personnel)..where((t) => t.soldierId.equals(soldierId))).write(
const PersonnelCompanion(assignedSlotId: Value(null)),
);
}
/// Deletes all personnel records.
Future<int> deleteAllPersonnel() => delete(personnel).go();
/// Returns a stream that emits all personnel whenever the table changes.
Stream<List<PersonnelData>> watchAllPersonnel() => select(personnel).watch();
/// Returns a stream that emits unassigned soldiers on changes.
Stream<List<PersonnelData>> watchUnassigned() =>
(select(personnel)..where((t) => t.assignedSlotId.isNull())).watch();
}

View File

@ -0,0 +1,72 @@
import 'package:drift/drift.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
part 'position_slot_dao.g.dart';
/// Data Access Object for [PositionSlots] table operations.
@DriftAccessor(tables: [PositionSlots])
class PositionSlotDao extends DatabaseAccessor<AppDatabase> with _$PositionSlotDaoMixin {
PositionSlotDao(super.db);
/// Returns all position slot rows.
Future<List<PositionSlot>> getAllSlots() => select(positionSlots).get();
/// Returns the slot with [slotId], or null if not found.
Future<PositionSlot?> getSlotById(String slotId) =>
(select(positionSlots)..where((t) => t.slotId.equals(slotId))).getSingleOrNull();
/// Returns all slots belonging to [unitId].
Future<List<PositionSlot>> getSlotsByUnit(String unitId) =>
(select(positionSlots)..where((t) => t.unitId.equals(unitId))).get();
/// Returns all vacant slots (no assigned personnel).
Future<List<PositionSlot>> getVacantSlots() =>
(select(positionSlots)..where((t) => t.assignedPersonnelId.isNull())).get();
/// Returns all key leader slots.
Future<List<PositionSlot>> getKeyLeaderSlots() =>
(select(positionSlots)..where((t) => t.isKeyLeader.equals(true))).get();
/// Inserts a new position slot row.
Future<void> insertSlot(PositionSlotsCompanion entry) =>
into(positionSlots).insert(entry, mode: InsertMode.insertOrFail);
/// Inserts multiple position slot rows in a single transaction.
Future<void> insertAllSlots(List<PositionSlotsCompanion> entries) =>
batch((b) => b.insertAll(positionSlots, entries, mode: InsertMode.insertOrFail));
/// Updates an existing position slot row.
Future<bool> updateSlot(PositionSlotsCompanion entry) => update(positionSlots).replace(entry);
/// Assigns [personnelId] to the slot with [slotId].
Future<void> assignPersonnel(String slotId, String personnelId) async {
await (update(positionSlots)..where((t) => t.slotId.equals(slotId))).write(
PositionSlotsCompanion(assignedPersonnelId: Value(personnelId)),
);
}
/// Clears the personnel assignment for the slot with [slotId].
Future<void> clearAssignment(String slotId) async {
await (update(positionSlots)..where((t) => t.slotId.equals(slotId))).write(
const PositionSlotsCompanion(assignedPersonnelId: Value(null)),
);
}
/// Deletes the slot with [slotId].
Future<int> deleteSlot(String slotId) =>
(delete(positionSlots)..where((t) => t.slotId.equals(slotId))).go();
/// Deletes all slots belonging to [unitId].
Future<int> deleteSlotsByUnit(String unitId) =>
(delete(positionSlots)..where((t) => t.unitId.equals(unitId))).go();
/// Deletes all position slots.
Future<int> deleteAllSlots() => delete(positionSlots).go();
/// Returns a stream that emits all slots whenever the table changes.
Stream<List<PositionSlot>> watchAllSlots() => select(positionSlots).watch();
/// Returns a stream that emits slots for [unitId] on changes.
Stream<List<PositionSlot>> watchSlotsByUnit(String unitId) =>
(select(positionSlots)..where((t) => t.unitId.equals(unitId))).watch();
}

View File

@ -0,0 +1,42 @@
import 'package:drift/drift.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
part 'template_dao.g.dart';
/// Data Access Object for [Templates] table operations.
@DriftAccessor(tables: [Templates])
class TemplateDao extends DatabaseAccessor<AppDatabase> with _$TemplateDaoMixin {
TemplateDao(super.db);
/// Returns all template rows.
Future<List<Template>> getAllTemplates() => select(templates).get();
/// Returns the template with [templateId], or null if not found.
Future<Template?> getTemplateById(String templateId) =>
(select(templates)..where((t) => t.templateId.equals(templateId))).getSingleOrNull();
/// Returns all templates of the given [bctType].
Future<List<Template>> getByBctType(String bctType) =>
(select(templates)..where((t) => t.bctType.equals(bctType))).get();
/// Inserts a new template row.
Future<void> insertTemplate(TemplatesCompanion entry) =>
into(templates).insert(entry, mode: InsertMode.insertOrFail);
/// Inserts or replaces a template row (upsert).
Future<void> upsertTemplate(TemplatesCompanion entry) =>
into(templates).insertOnConflictUpdate(entry);
/// Updates an existing template row.
Future<bool> updateTemplate(TemplatesCompanion entry) => update(templates).replace(entry);
/// Deletes the template with [templateId].
Future<int> deleteTemplate(String templateId) =>
(delete(templates)..where((t) => t.templateId.equals(templateId))).go();
/// Deletes all templates.
Future<int> deleteAllTemplates() => delete(templates).go();
/// Returns a stream that emits all templates whenever the table changes.
Stream<List<Template>> watchAllTemplates() => select(templates).watch();
}

View File

@ -0,0 +1,53 @@
import 'package:drift/drift.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
part 'unit_dao.g.dart';
/// Data Access Object for [Units] table operations.
@DriftAccessor(tables: [Units])
class UnitDao extends DatabaseAccessor<AppDatabase> with _$UnitDaoMixin {
UnitDao(super.db);
/// Returns all unit rows.
Future<List<Unit>> getAllUnits() => select(units).get();
/// Returns the unit with [unitId], or null if not found.
Future<Unit?> getUnitById(String unitId) =>
(select(units)..where((t) => t.unitId.equals(unitId))).getSingleOrNull();
/// Returns all direct children of [parentUnitId].
Future<List<Unit>> getChildren(String parentUnitId) =>
(select(units)..where((t) => t.parentUnitId.equals(parentUnitId))).get();
/// Returns all root units (no parent).
Future<List<Unit>> getRoots() => (select(units)..where((t) => t.parentUnitId.isNull())).get();
/// Returns all units at the given [echelon] string value.
Future<List<Unit>> getByEchelon(String echelon) =>
(select(units)..where((t) => t.echelon.equals(echelon))).get();
/// Inserts a new unit row. Throws on duplicate primary key.
Future<void> insertUnit(UnitsCompanion entry) =>
into(units).insert(entry, mode: InsertMode.insertOrFail);
/// Inserts multiple unit rows in a single transaction.
Future<void> insertAllUnits(List<UnitsCompanion> entries) =>
batch((b) => b.insertAll(units, entries, mode: InsertMode.insertOrFail));
/// Updates an existing unit row.
Future<bool> updateUnit(UnitsCompanion entry) => update(units).replace(entry);
/// Deletes the unit with [unitId].
Future<int> deleteUnit(String unitId) =>
(delete(units)..where((t) => t.unitId.equals(unitId))).go();
/// Deletes all units.
Future<int> deleteAllUnits() => delete(units).go();
/// Returns a stream that emits all units whenever the table changes.
Stream<List<Unit>> watchAllUnits() => select(units).watch();
/// Returns a stream that emits children of [parentUnitId] on changes.
Stream<List<Unit>> watchChildren(String parentUnitId) =>
(select(units)..where((t) => t.parentUnitId.equals(parentUnitId))).watch();
}

View File

@ -0,0 +1,143 @@
import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart' as domain;
import 'package:uto_manager/domain/repositories/personnel_repository.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/database/daos/personnel_dao.dart';
/// Drift-backed implementation of [PersonnelRepository].
///
/// Converts between Drift [PersonnelData] rows and domain
/// [domain.PersonnelRecord] entities. All persistence is handled via
/// [PersonnelDao].
class DriftPersonnelRepository implements PersonnelRepository {
const DriftPersonnelRepository({required this.dao});
final PersonnelDao dao;
// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------
domain.PersonnelRecord _toDomain(PersonnelData row) {
return domain.PersonnelRecord(
soldierId: row.soldierId,
name: row.name,
primaryMos: DutyMos.unvalidated(row.primaryMos),
payGrade: AuthorizedRank.values.firstWhere((r) => r.name == row.payGrade),
qualifications: (jsonDecode(row.qualifications) as List<dynamic>).cast<String>(),
readinessState: domain.ReadinessState.values.firstWhere(
(s) => s.name == row.readinessState,
),
assignedSlotId: row.assignedSlotId,
);
}
PersonnelCompanion _toCompanion(domain.PersonnelRecord record) {
return PersonnelCompanion.insert(
soldierId: record.soldierId,
name: record.name,
primaryMos: record.primaryMos.code,
payGrade: record.payGrade.name,
qualifications: jsonEncode(record.qualifications),
readinessState: record.readinessState.name,
assignedSlotId: Value(record.assignedSlotId),
);
}
// ---------------------------------------------------------------------------
// PersonnelRepository implementation
// ---------------------------------------------------------------------------
@override
Future<List<domain.PersonnelRecord>> getAll() async {
final rows = await dao.getAllPersonnel();
return rows.map(_toDomain).toList();
}
@override
Future<domain.PersonnelRecord?> getById(String soldierId) async {
final row = await dao.getPersonnelById(soldierId);
return row == null ? null : _toDomain(row);
}
@override
Future<List<domain.PersonnelRecord>> getBySlot(String slotId) async {
final rows = await dao.getBySlot(slotId);
return rows.map(_toDomain).toList();
}
@override
Future<List<domain.PersonnelRecord>> getUnassigned() async {
final rows = await dao.getUnassigned();
return rows.map(_toDomain).toList();
}
@override
Future<List<domain.PersonnelRecord>> getByMos(DutyMos primaryMos) async {
final rows = await dao.getByMos(primaryMos.code);
return rows.map(_toDomain).toList();
}
@override
Future<List<domain.PersonnelRecord>> getByPayGrade(AuthorizedRank payGrade) async {
final rows = await dao.getByPayGrade(payGrade.name);
return rows.map(_toDomain).toList();
}
@override
Future<void> insert(domain.PersonnelRecord record) async {
final existing = await dao.getPersonnelById(record.soldierId);
if (existing != null) {
throw StateError('Personnel ${record.soldierId} already exists.');
}
await dao.insertPersonnel(_toCompanion(record));
}
@override
Future<void> insertAll(List<domain.PersonnelRecord> records) async {
await dao.insertAllPersonnel(records.map(_toCompanion).toList());
}
@override
Future<void> update(domain.PersonnelRecord record) async {
final existing = await dao.getPersonnelById(record.soldierId);
if (existing == null) {
throw StateError('Personnel ${record.soldierId} not found.');
}
await dao.updatePersonnel(_toCompanion(record));
}
@override
Future<void> delete(String soldierId) async {
await dao.deletePersonnel(soldierId);
}
@override
Future<void> assignToSlot(String soldierId, String slotId) async {
await dao.assignToSlot(soldierId, slotId);
}
@override
Future<void> unassignFromSlot(String soldierId) async {
await dao.unassignFromSlot(soldierId);
}
@override
Stream<List<domain.PersonnelRecord>> watchAll() {
return dao.watchAllPersonnel().map((rows) => rows.map(_toDomain).toList());
}
@override
Stream<List<domain.PersonnelRecord>> watchUnassigned() {
return dao.watchUnassigned().map((rows) => rows.map(_toDomain).toList());
}
@override
Future<void> deleteAll() async {
await dao.deleteAllPersonnel();
}
}

View File

@ -0,0 +1,110 @@
import 'package:drift/drift.dart' show Value;
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/position_slot.dart' as domain;
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/database/daos/position_slot_dao.dart';
/// Repository for [domain.PositionSlot] persistence via Drift.
class DriftPositionSlotRepository {
const DriftPositionSlotRepository({required this.dao});
final PositionSlotDao dao;
// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------
domain.PositionSlot _toDomain(PositionSlot row) {
return domain.PositionSlot(
slotId: row.slotId,
unitId: row.unitId,
positionTitle: row.positionTitle,
dutyMos: DutyMos.unvalidated(row.dutyMos),
authorizedRank: AuthorizedRank.values.firstWhere((r) => r.name == row.authorizedRank),
isKeyLeader: row.isKeyLeader,
assignedPersonnelId: row.assignedPersonnelId,
);
}
PositionSlotsCompanion _toCompanion(domain.PositionSlot slot) {
return PositionSlotsCompanion.insert(
slotId: slot.slotId,
unitId: slot.unitId,
positionTitle: slot.positionTitle,
dutyMos: slot.dutyMos.code,
authorizedRank: slot.authorizedRank.name,
isKeyLeader: slot.isKeyLeader,
assignedPersonnelId: Value(slot.assignedPersonnelId),
);
}
// ---------------------------------------------------------------------------
// CRUD operations
// ---------------------------------------------------------------------------
Future<List<domain.PositionSlot>> getAll() async {
final rows = await dao.getAllSlots();
return rows.map(_toDomain).toList();
}
Future<domain.PositionSlot?> getById(String slotId) async {
final row = await dao.getSlotById(slotId);
return row == null ? null : _toDomain(row);
}
Future<List<domain.PositionSlot>> getByUnit(String unitId) async {
final rows = await dao.getSlotsByUnit(unitId);
return rows.map(_toDomain).toList();
}
Future<List<domain.PositionSlot>> getVacant() async {
final rows = await dao.getVacantSlots();
return rows.map(_toDomain).toList();
}
Future<List<domain.PositionSlot>> getKeyLeaderSlots() async {
final rows = await dao.getKeyLeaderSlots();
return rows.map(_toDomain).toList();
}
Future<void> insert(domain.PositionSlot slot) async {
await dao.insertSlot(_toCompanion(slot));
}
Future<void> insertAll(List<domain.PositionSlot> slots) async {
await dao.insertAllSlots(slots.map(_toCompanion).toList());
}
Future<void> update(domain.PositionSlot slot) async {
await dao.updateSlot(_toCompanion(slot));
}
Future<void> assignPersonnel(String slotId, String personnelId) async {
await dao.assignPersonnel(slotId, personnelId);
}
Future<void> clearAssignment(String slotId) async {
await dao.clearAssignment(slotId);
}
Future<void> delete(String slotId) async {
await dao.deleteSlot(slotId);
}
Future<void> deleteByUnit(String unitId) async {
await dao.deleteSlotsByUnit(unitId);
}
Future<void> deleteAll() async {
await dao.deleteAllSlots();
}
Stream<List<domain.PositionSlot>> watchAll() {
return dao.watchAllSlots().map((rows) => rows.map(_toDomain).toList());
}
Stream<List<domain.PositionSlot>> watchByUnit(String unitId) {
return dao.watchSlotsByUnit(unitId).map((rows) => rows.map(_toDomain).toList());
}
}

View File

@ -0,0 +1,174 @@
import 'package:drift/drift.dart' show Value;
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:uto_manager/domain/repositories/unit_repository.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/database/daos/unit_dao.dart';
/// Drift-backed implementation of [UnitRepository].
///
/// Converts between Drift [Unit] data rows and domain [UnitNode] entities.
/// All persistence is handled via [UnitDao].
class DriftUnitRepository implements UnitRepository {
const DriftUnitRepository({required this.dao, required this.db});
final UnitDao dao;
final AppDatabase db;
// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------
UnitNode _toDomain(Unit row) {
return UnitNode(
unitId: row.unitId,
echelon: Echelon.values.firstWhere((e) => e.name == row.echelon),
branch: DoctrinalBranch.values.firstWhere((b) => b.name == row.branch),
parentUnitId: row.parentUnitId,
name: row.name,
isKeyLeader: row.isKeyLeader,
);
}
UnitsCompanion _toCompanion(UnitNode unit) {
return UnitsCompanion.insert(
unitId: unit.unitId,
echelon: unit.echelon.name,
branch: unit.branch.name,
parentUnitId: Value(unit.parentUnitId),
name: unit.name,
isKeyLeader: unit.isKeyLeader,
);
}
// ---------------------------------------------------------------------------
// UnitRepository implementation
// ---------------------------------------------------------------------------
@override
Future<List<UnitNode>> getAll() async {
final rows = await dao.getAllUnits();
return rows.map(_toDomain).toList();
}
@override
Future<UnitNode?> getById(String unitId) async {
final row = await dao.getUnitById(unitId);
return row == null ? null : _toDomain(row);
}
@override
Future<List<UnitNode>> getChildren(String parentUnitId) async {
final rows = await dao.getChildren(parentUnitId);
return rows.map(_toDomain).toList();
}
@override
Future<List<UnitNode>> getRoots() async {
final rows = await dao.getRoots();
return rows.map(_toDomain).toList();
}
@override
Future<List<UnitNode>> getByEchelon(Echelon echelon) async {
final rows = await dao.getByEchelon(echelon.name);
return rows.map(_toDomain).toList();
}
@override
Future<List<UnitNode>> getSubtree(String unitId) async {
final all = await getAll();
return _collectSubtree(unitId, all);
}
@override
Future<void> insert(UnitNode unit) async {
final existing = await dao.getUnitById(unit.unitId);
if (existing != null) {
throw StateError('Unit ${unit.unitId} already exists.');
}
await dao.insertUnit(_toCompanion(unit));
}
@override
Future<void> insertAll(List<UnitNode> units) async {
await dao.insertAllUnits(units.map(_toCompanion).toList());
}
@override
Future<void> update(UnitNode unit) async {
final existing = await dao.getUnitById(unit.unitId);
if (existing == null) {
throw StateError('Unit ${unit.unitId} not found.');
}
await dao.updateUnit(_toCompanion(unit));
}
@override
Future<void> delete(String unitId) async {
await dao.deleteUnit(unitId);
}
@override
Future<void> deleteSubtree(String unitId) async {
final all = await getAll();
final subtree = _collectSubtree(unitId, all);
// Delete leaves first (bottom-up) to respect FK constraints.
for (final unit in subtree.reversed) {
await dao.deleteUnit(unit.unitId);
}
}
@override
Future<void> reparent(String unitId, String newParentId) async {
final unit = await getById(unitId);
if (unit == null) throw StateError('Unit $unitId not found.');
final newParent = await getById(newParentId);
if (newParent == null) throw StateError('Parent $newParentId not found.');
if (!unit.echelon.isValidParent(newParent.echelon)) {
throw ArgumentError(
'${unit.echelon.displayName} cannot be a child of '
'${newParent.echelon.displayName}.',
);
}
final updated = unit.copyWith(parentUnitId: newParentId);
await dao.updateUnit(_toCompanion(updated));
}
@override
Stream<List<UnitNode>> watchAll() {
return dao.watchAllUnits().map((rows) => rows.map(_toDomain).toList());
}
@override
Stream<List<UnitNode>> watchSubtree(String unitId) {
return watchAll().map((all) => _collectSubtree(unitId, all));
}
@override
Future<void> deleteAll() async {
await dao.deleteAllUnits();
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
List<UnitNode> _collectSubtree(String rootId, List<UnitNode> all) {
final result = <UnitNode>[];
final queue = <String>[rootId];
while (queue.isNotEmpty) {
final current = queue.removeAt(0);
final node = all.firstWhere(
(u) => u.unitId == current,
orElse: () => throw StateError('Unit $current not found'),
);
result.add(node);
queue.addAll(all.where((u) => u.parentUnitId == current).map((u) => u.unitId));
}
return result;
}
}

View File

@ -1,4 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
import 'package:uto_manager/application/blocs/template_cubit.dart';
import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
import 'package:uto_manager/domain/services/assignment_service.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
import 'package:uto_manager/presentation/screens/home_screen.dart';
void main() { void main() {
runApp(const UtoManagerApp()); runApp(const UtoManagerApp());
@ -6,65 +17,73 @@ void main() {
/// The root application widget for the UTO Manager. /// The root application widget for the UTO Manager.
/// ///
/// This is the entry point for the Flutter application. The full application /// Wires together the database, repositories, domain services, and BLoC
/// shell (three-pane layout, canvas engine, state management) will be /// providers before handing off to the [HomeScreen].
/// implemented in Phase 3. class UtoManagerApp extends StatefulWidget {
class UtoManagerApp extends StatelessWidget {
const UtoManagerApp({super.key}); const UtoManagerApp({super.key});
@override @override
Widget build(BuildContext context) { State<UtoManagerApp> createState() => _UtoManagerAppState();
return MaterialApp(
title: 'UTO Manager',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1B3A6B), // Army blue
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const _PlaceholderScaffold(),
);
}
} }
/// Placeholder scaffold displayed during Phase 1 development. class _UtoManagerAppState extends State<UtoManagerApp> {
/// late final AppDatabase _db;
/// Will be replaced by the full three-pane UI shell in Phase 3. late final DriftUnitRepository _unitRepo;
class _PlaceholderScaffold extends StatelessWidget { late final DriftPersonnelRepository _personnelRepo;
const _PlaceholderScaffold(); late final DriftPositionSlotRepository _slotRepo;
late final UnitTreeService _treeService;
late final AssignmentService _assignmentService;
@override
void initState() {
super.initState();
_db = AppDatabase();
_unitRepo = DriftUnitRepository(dao: _db.unitDao, db: _db);
_personnelRepo = DriftPersonnelRepository(dao: _db.personnelDao);
_slotRepo = DriftPositionSlotRepository(dao: _db.positionSlotDao);
_treeService = const UnitTreeService();
_assignmentService = AssignmentService(treeService: _treeService);
}
@override
void dispose() {
_db.close();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return MultiBlocProvider(
appBar: AppBar( providers: [
title: const Text('UTO Manager'), BlocProvider<UnitTreeCubit>(
backgroundColor: Theme.of(context).colorScheme.inversePrimary, create: (_) => UnitTreeCubit(
), unitRepository: _unitRepo,
body: const Center( slotRepository: _slotRepo,
child: Column( treeService: _treeService,
mainAxisAlignment: MainAxisAlignment.center, )..startWatching(),
children: [
Icon(Icons.military_tech, size: 64),
SizedBox(height: 16),
Text(
'Unit Task Organization Manager',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'U.S. Army 2026 Doctrine — '
'Phase 1: Architecture & Data Foundations',
textAlign: TextAlign.center,
),
SizedBox(height: 24),
Text(
'Canvas engine and UI shell coming in Phase 2 & 3.',
style: TextStyle(color: Colors.grey),
),
],
), ),
BlocProvider<PersonnelCubit>(
create: (_) => PersonnelCubit(
personnelRepository: _personnelRepo,
slotRepository: _slotRepo,
assignmentService: _assignmentService,
)..startWatching(),
),
BlocProvider<TemplateCubit>(
create: (_) => TemplateCubit(dao: _db.templateDao)..loadTemplates(),
),
],
child: MaterialApp(
title: 'UTO Manager',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1B3A6B),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const HomeScreen(),
), ),
); );
} }

View File

@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
import 'package:uto_manager/presentation/widgets/position_slot_card.dart';
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
/// The main three-pane home screen of the UTO Manager.
///
/// Layout:
/// - Left panel: Personnel roster (unassigned soldiers, draggable)
/// - Center: Interactive org chart canvas
/// - Right panel: Unit inspector (selected unit's position slots)
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildStatusBar(context),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
title: const Row(
children: [
Icon(Icons.military_tech, size: 20),
SizedBox(width: 8),
Text('UTO Manager', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(width: 8),
Text(
'U.S. Army 2026 Doctrine',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
),
],
),
backgroundColor: const Color(0xFF1B3A6B),
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.delete_sweep_outlined),
tooltip: 'Clear all units',
onPressed: () => _confirmClearAll(context),
),
const SizedBox(width: 8),
],
);
}
Widget _buildBody(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 900;
if (isWide) {
return Row(
children: [
// Left panel: Personnel roster
SizedBox(
width: 260,
child: _buildPersonnelPanel(context),
),
const VerticalDivider(width: 1),
// Center: Canvas
Expanded(
child: _buildCanvas(context),
),
const VerticalDivider(width: 1),
// Right panel: Unit inspector
SizedBox(
width: 280,
child: _buildInspectorPanel(context),
),
],
);
}
// Narrow layout: canvas only, with drawer for panels
return _buildCanvas(context);
},
);
}
// ---------------------------------------------------------------------------
// Left Panel — Personnel Roster
// ---------------------------------------------------------------------------
Widget _buildPersonnelPanel(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: theme.colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'PERSONNEL ROSTER',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
BlocBuilder<PersonnelCubit, PersonnelState>(
builder: (context, state) {
return TextField(
decoration: InputDecoration(
hintText: 'Search by name, MOS, rank…',
hintStyle: theme.textTheme.labelSmall,
prefixIcon: const Icon(Icons.search, size: 16),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
),
style: theme.textTheme.bodySmall,
onChanged: (q) => context.read<PersonnelCubit>().setFilter(q),
);
},
),
],
),
),
Expanded(
child: BlocBuilder<PersonnelCubit, PersonnelState>(
builder: (context, state) {
if (state is PersonnelLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is PersonnelLoaded) {
final soldiers = state.filteredUnassigned;
if (soldiers.isEmpty) {
return Center(
child: Text(
'No unassigned soldiers',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return ListView.builder(
itemCount: soldiers.length,
itemBuilder: (context, i) => PersonnelListTile(record: soldiers[i]),
);
}
return const SizedBox.shrink();
},
),
),
],
);
}
// ---------------------------------------------------------------------------
// Center — Canvas
// ---------------------------------------------------------------------------
Widget _buildCanvas(BuildContext context) {
return BlocBuilder<UnitTreeCubit, UnitTreeState>(
builder: (context, state) {
if (state is UnitTreeLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is UnitTreeLoaded) {
return UtoCanvasWidget(
roots: state.roots,
selectedUnitId: state.selectedUnitId,
onUnitSelected: (id) => context.read<UnitTreeCubit>().selectUnit(id),
onReparent: (unitId, newParentId) =>
context.read<UnitTreeCubit>().reparentUnit(unitId, newParentId),
);
}
if (state is UnitTreeError) {
return Center(child: Text('Error: ${state.message}'));
}
return const Center(child: CircularProgressIndicator());
},
);
}
// ---------------------------------------------------------------------------
// Right Panel — Unit Inspector
// ---------------------------------------------------------------------------
Widget _buildInspectorPanel(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<UnitTreeCubit, UnitTreeState>(
builder: (context, treeState) {
if (treeState is! UnitTreeLoaded) {
return const SizedBox.shrink();
}
final selectedUnit = treeState.selectedUnit;
if (selectedUnit == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Select a unit on the canvas to inspect its position slots.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
final slots = treeState.selectedUnitSlots;
final personnelById = {
for (final p in (context.read<PersonnelCubit>().state is PersonnelLoaded
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
: <PersonnelRecord>[]))
p.soldierId: p,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Inspector header
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: theme.colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'UNIT INSPECTOR',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
selectedUnit.name,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
Text(
'${selectedUnit.echelon.displayName} • ${selectedUnit.branch.displayName}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
// Slots list
Expanded(
child: slots.isEmpty
? Center(
child: Text(
'No position slots defined',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: slots.length,
itemBuilder: (context, i) {
final slot = slots[i];
final soldier = slot.assignedPersonnelId != null
? personnelById[slot.assignedPersonnelId]
: null;
return PositionSlotCard(
slot: slot,
assignedSoldier: soldier,
onUnassign: slot.isFilled ? () => _unassign(context, slot) : null,
onAcceptDrop: (soldier) => _assign(context, soldier, slot),
);
},
),
),
],
);
},
);
}
// ---------------------------------------------------------------------------
// Status Bar
// ---------------------------------------------------------------------------
Widget _buildStatusBar(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 28,
color: const Color(0xFF1B3A6B),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: BlocBuilder<UnitTreeCubit, UnitTreeState>(
builder: (context, state) {
if (state is! UnitTreeLoaded) {
return const SizedBox.shrink();
}
final unitCount = state.units.length;
final slotCount = state.slots.length;
final filledCount = state.slots.where((s) => s.isFilled).length;
final fillPct = slotCount == 0 ? 0 : (filledCount * 100 ~/ slotCount);
return Row(
children: [
_statusChip(theme, Icons.account_tree, '$unitCount units'),
const SizedBox(width: 16),
_statusChip(theme, Icons.badge, '$filledCount / $slotCount slots ($fillPct%)'),
const Spacer(),
Text(
'Phase 2 — Canvas Engine',
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
),
],
);
},
),
);
}
Widget _statusChip(ThemeData theme, IconData icon, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Colors.white70),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white70, fontSize: 10),
),
],
);
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
void _assign(BuildContext context, PersonnelRecord soldier, PositionSlot slot) {
context.read<PersonnelCubit>().assignSoldierToSlot(soldier, slot);
}
void _unassign(BuildContext context, PositionSlot slot) {
context.read<PersonnelCubit>().unassignFromSlot(slot);
}
void _confirmClearAll(BuildContext context) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear All Units?'),
content: const Text(
'This will delete all units, position slots, and personnel assignments. '
'This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
context.read<UnitTreeCubit>().clearAll();
},
child: const Text('Clear All'),
),
],
),
);
}
}

View File

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
/// A draggable list tile representing a soldier in the personnel panel.
///
/// Can be dragged onto a [PositionSlotCard] to assign the soldier.
class PersonnelListTile extends StatelessWidget {
const PersonnelListTile({
super.key,
required this.record,
this.onTap,
});
final PersonnelRecord record;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Draggable<PersonnelRecord>(
data: record,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 220,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
record.name,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onPrimaryContainer,
),
),
Text(
'${record.payGrade.payGrade} • ${record.primaryMos.code}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.4,
child: _buildTile(theme),
),
child: _buildTile(theme),
);
}
Widget _buildTile(ThemeData theme) {
return ListTile(
dense: true,
onTap: onTap,
leading: CircleAvatar(
radius: 16,
backgroundColor: _readinessColor(record.readinessState).withValues(alpha: 0.2),
child: Text(
record.payGrade.payGrade,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: _readinessColor(record.readinessState),
),
),
),
title: Text(
record.name,
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${record.primaryMos.code} • ${record.readinessState.displayName}',
style: theme.textTheme.labelSmall?.copyWith(
color: _readinessColor(record.readinessState),
),
),
trailing: Icon(
Icons.drag_indicator,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
Color _readinessColor(ReadinessState state) {
switch (state) {
case ReadinessState.fullyMissionCapable:
return const Color(0xFF4CAF50);
case ReadinessState.partiallyMissionCapable:
return const Color(0xFFFF9800);
case ReadinessState.notMissionCapable:
return const Color(0xFFF44336);
case ReadinessState.unknown:
return Colors.grey;
}
}
}

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
/// Displays a single position slot as a card in the inspector panel.
///
/// Shows the slot's position title, MOS, rank, and assigned soldier (or VACANT).
/// Supports drag-and-drop target for soldier assignment.
class PositionSlotCard extends StatelessWidget {
const PositionSlotCard({
super.key,
required this.slot,
this.assignedSoldier,
this.onUnassign,
this.onAcceptDrop,
});
final PositionSlot slot;
final PersonnelRecord? assignedSoldier;
final VoidCallback? onUnassign;
final void Function(PersonnelRecord soldier)? onAcceptDrop;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DragTarget<PersonnelRecord>(
onWillAcceptWithDetails: (details) => slot.isVacant,
onAcceptWithDetails: (details) => onAcceptDrop?.call(details.data),
builder: (context, candidateData, rejectedData) {
final isDragOver = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
margin: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
decoration: BoxDecoration(
color: isDragOver
? theme.colorScheme.primaryContainer.withValues(alpha: 0.6)
: slot.isFilled
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceContainerLow,
border: Border.all(
color: isDragOver
? theme.colorScheme.primary
: slot.isKeyLeader
? const Color(0xFFFFD700) // Gold for key leaders
: theme.colorScheme.outlineVariant,
width: isDragOver ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
),
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
leading: _buildMosBadge(theme),
title: Text(
slot.positionTitle,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: slot.isKeyLeader ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
slot.isFilled ? (assignedSoldier?.name ?? 'Assigned') : 'VACANT',
style: theme.textTheme.labelSmall?.copyWith(
color: slot.isFilled
? _readinessColor(assignedSoldier?.readinessState)
: theme.colorScheme.error,
fontWeight: slot.isVacant ? FontWeight.bold : FontWeight.normal,
),
),
trailing: slot.isFilled && onUnassign != null
? IconButton(
icon: const Icon(Icons.person_remove, size: 16),
onPressed: onUnassign,
tooltip: 'Unassign soldier',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
)
: null,
),
);
},
);
}
Widget _buildMosBadge(ThemeData theme) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
slot.dutyMos.code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 10,
color: theme.colorScheme.primary,
),
),
Text(
slot.authorizedRank.payGrade,
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Color _readinessColor(ReadinessState? state) {
switch (state) {
case ReadinessState.fullyMissionCapable:
return const Color(0xFF4CAF50);
case ReadinessState.partiallyMissionCapable:
return const Color(0xFFFF9800);
case ReadinessState.notMissionCapable:
return const Color(0xFFF44336);
default:
return Colors.grey;
}
}
}

View File

@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
/// Displays a single unit node as a card in the org chart canvas.
///
/// Shows the unit's echelon, branch, name, and fill rate indicator.
class UnitNodeCard extends StatelessWidget {
const UnitNodeCard({
super.key,
required this.treeNode,
required this.isSelected,
required this.onTap,
});
final UnitTreeNode treeNode;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final unit = treeNode.unit;
final fillRate = treeNode.fillRate;
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 160,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
border: Border.all(
color: isSelected ? theme.colorScheme.primary : _echelonColor(unit),
width: isSelected ? 2.5 : 1.5,
),
borderRadius: BorderRadius.circular(6),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: echelon badge
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: _echelonColor(unit).withValues(alpha: 0.85),
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
unit.echelon.displayName.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
),
),
Text(
unit.branch.displayName,
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.white70,
fontSize: 9,
),
),
],
),
),
// Body: unit name
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(
unit.name,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
// Footer: fill rate bar
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Fill',
style: theme.textTheme.labelSmall?.copyWith(fontSize: 9),
),
Text(
'${fillRate.toStringAsFixed(0)}%',
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: _fillRateColor(fillRate),
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 2),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: fillRate / 100.0,
minHeight: 4,
backgroundColor: theme.colorScheme.surfaceContainerLow,
valueColor: AlwaysStoppedAnimation<Color>(_fillRateColor(fillRate)),
),
),
],
),
),
],
),
),
);
}
Color _echelonColor(UnitNode unit) {
switch (unit.echelon.level) {
case 1:
return const Color(0xFF1B3A6B); // Brigade — Army blue
case 2:
return const Color(0xFF2E5FA3); // Battalion
case 3:
return const Color(0xFF3A7BD5); // Company
case 4:
return const Color(0xFF4A90D9); // Platoon
case 5:
return const Color(0xFF5BA3E0); // Section
case 6:
return const Color(0xFF6CB4E8); // Squad
default:
return const Color(0xFF7EC8F0); // Team
}
}
Color _fillRateColor(double rate) {
if (rate >= 90) return const Color(0xFF4CAF50); // Green
if (rate >= 70) return const Color(0xFFFF9800); // Amber
return const Color(0xFFF44336); // Red
}
}

View File

@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/presentation/widgets/unit_node_card.dart';
/// An interactive, scrollable/zoomable canvas displaying the unit org chart.
///
/// Uses [InteractiveViewer] for pan and zoom. Unit nodes are rendered as
/// [UnitNodeCard] widgets arranged in a hierarchical tree layout.
class UtoCanvasWidget extends StatefulWidget {
const UtoCanvasWidget({
super.key,
required this.roots,
required this.selectedUnitId,
required this.onUnitSelected,
required this.onReparent,
});
/// Root-level tree nodes to render.
final List<UnitTreeNode> roots;
/// Currently selected unit ID (or null).
final String? selectedUnitId;
/// Called when a unit node is tapped.
final void Function(String unitId) onUnitSelected;
/// Called when a unit is dragged onto a new parent.
final Future<bool> Function(String unitId, String newParentId) onReparent;
@override
State<UtoCanvasWidget> createState() => _UtoCanvasWidgetState();
}
class _UtoCanvasWidgetState extends State<UtoCanvasWidget> {
final TransformationController _transformController = TransformationController();
@override
void dispose() {
_transformController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.roots.isEmpty) {
return _buildEmptyState(context);
}
return InteractiveViewer(
transformationController: _transformController,
minScale: 0.2,
maxScale: 3.0,
boundaryMargin: const EdgeInsets.all(500),
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: widget.roots.map((root) => _buildSubtree(root, 0)).toList(),
),
),
);
}
Widget _buildSubtree(UnitTreeNode node, int depth) {
final card = _buildDraggableNode(node);
if (node.children.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: card,
);
}
return Column(
children: [
card,
// Connector line
Container(width: 2, height: 20, color: Colors.blueGrey.withValues(alpha: 0.4)),
// Children row
IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children.map((child) {
return Column(
children: [
Container(width: 2, height: 20, color: Colors.blueGrey.withValues(alpha: 0.4)),
_buildSubtree(child, depth + 1),
],
);
}).toList(),
),
),
],
);
}
Widget _buildDraggableNode(UnitTreeNode node) {
final unitId = node.unit.unitId;
final isSelected = widget.selectedUnitId == unitId;
return DragTarget<String>(
onWillAcceptWithDetails: (details) => details.data != unitId,
onAcceptWithDetails: (details) async {
await widget.onReparent(details.data, unitId);
},
builder: (context, candidateData, rejectedData) {
final isDragOver = candidateData.isNotEmpty;
return Draggable<String>(
data: unitId,
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(6),
child: Opacity(
opacity: 0.85,
child: UnitNodeCard(
treeNode: node,
isSelected: false,
onTap: () {},
),
),
),
childWhenDragging: Opacity(
opacity: 0.3,
child: UnitNodeCard(
treeNode: node,
isSelected: false,
onTap: () {},
),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: isDragOver
? BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
)
: null,
child: UnitNodeCard(
treeNode: node,
isSelected: isSelected,
onTap: () => widget.onUnitSelected(unitId),
),
),
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.account_tree_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
'No units loaded',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add units using the controls above,\nor load a saved template.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@ -0,0 +1,208 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
import 'package:uto_manager/domain/entities/echelon.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/domain/services/assignment_service.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
void main() {
const treeService = UnitTreeService();
const service = AssignmentService(treeService: treeService);
PositionSlot makeSlot({
String slotId = 's1',
String unitId = 'u1',
String mosCode = '11B',
AuthorizedRank rank = AuthorizedRank.e5,
bool isKeyLeader = false,
String? assignedPersonnelId,
}) {
return PositionSlot(
slotId: slotId,
unitId: unitId,
positionTitle: 'Test Position',
dutyMos: DutyMos(mosCode),
authorizedRank: rank,
isKeyLeader: isKeyLeader,
assignedPersonnelId: assignedPersonnelId,
);
}
PersonnelRecord makeSoldier({
String id = 'p1',
String mosCode = '11B',
AuthorizedRank rank = AuthorizedRank.e5,
String? assignedSlotId,
ReadinessState readiness = ReadinessState.fullyMissionCapable,
}) {
return PersonnelRecord(
soldierId: id,
name: 'SGT Test',
primaryMos: DutyMos(mosCode),
payGrade: rank,
readinessState: readiness,
assignedSlotId: assignedSlotId,
);
}
// ---------------------------------------------------------------------------
// assign
// ---------------------------------------------------------------------------
group('assign', () {
test('successful assignment returns success outcome', () {
final slot = makeSlot();
final soldier = makeSoldier();
final outcome = service.assign(slot, soldier);
expect(outcome.isSuccess, isTrue);
expect(outcome.updatedSlot?.assignedPersonnelId, 'p1');
expect(outcome.updatedSoldier?.assignedSlotId, 's1');
});
test('assignment to filled slot returns failure', () {
final slot = makeSlot(assignedPersonnelId: 'existing');
final soldier = makeSoldier();
final outcome = service.assign(slot, soldier);
expect(outcome.isSuccess, isFalse);
expect(outcome.failureReason, contains('already filled'));
});
test('assignment of already-assigned soldier returns failure', () {
final slot = makeSlot();
final soldier = makeSoldier(assignedSlotId: 'other-slot');
final outcome = service.assign(slot, soldier);
expect(outcome.isSuccess, isFalse);
expect(outcome.failureReason, contains('already assigned'));
});
test('MOS mismatch is flagged but does not block assignment', () {
final slot = makeSlot(mosCode: '68W');
final soldier = makeSoldier(mosCode: '11B');
final outcome = service.assign(slot, soldier);
expect(outcome.isSuccess, isTrue);
expect(outcome.hasMosMismatch, isTrue);
});
test('rank mismatch is flagged when soldier rank is below minimum', () {
final slot = makeSlot(rank: AuthorizedRank.e7);
final soldier = makeSoldier(rank: AuthorizedRank.e4);
final outcome = service.assign(slot, soldier);
expect(outcome.isSuccess, isTrue);
expect(outcome.hasRankMismatch, isTrue);
});
test('no rank mismatch when soldier meets minimum rank', () {
final slot = makeSlot(rank: AuthorizedRank.e5);
final soldier = makeSoldier(rank: AuthorizedRank.e6);
final outcome = service.assign(slot, soldier);
expect(outcome.isSuccess, isTrue);
expect(outcome.hasRankMismatch, isFalse);
});
test('updated slot has correct personnel ID', () {
final slot = makeSlot(slotId: 'slot-abc');
final soldier = makeSoldier(id: 'soldier-xyz');
final outcome = service.assign(slot, soldier);
expect(outcome.updatedSlot?.assignedPersonnelId, 'soldier-xyz');
expect(outcome.updatedSoldier?.assignedSlotId, 'slot-abc');
});
});
// ---------------------------------------------------------------------------
// unassign
// ---------------------------------------------------------------------------
group('unassign', () {
test('unassigning from vacant slot returns unchanged slot', () {
final slot = makeSlot();
final outcome = service.unassign(slot, null);
expect(outcome.updatedSlot.isVacant, isTrue);
expect(outcome.updatedSoldier, isNull);
});
test('unassigning from filled slot clears assignment', () {
final slot = makeSlot(assignedPersonnelId: 'p1');
final soldier = makeSoldier(assignedSlotId: 's1');
final outcome = service.unassign(slot, soldier);
expect(outcome.updatedSlot.isVacant, isTrue);
expect(outcome.updatedSoldier?.isUnassigned, isTrue);
});
test('unassigning with null soldier still clears slot', () {
final slot = makeSlot(assignedPersonnelId: 'p1');
final outcome = service.unassign(slot, null);
expect(outcome.updatedSlot.isVacant, isTrue);
expect(outcome.updatedSoldier, isNull);
});
});
// ---------------------------------------------------------------------------
// aggregateReadiness
// ---------------------------------------------------------------------------
group('aggregateReadiness', () {
test('empty list returns grey', () {
expect(service.aggregateReadiness([]), ReadinessColor.grey);
});
test('all FMC returns green', () {
final soldiers = [
makeSoldier(id: 'p1', readiness: ReadinessState.fullyMissionCapable),
makeSoldier(id: 'p2', readiness: ReadinessState.fullyMissionCapable),
];
expect(service.aggregateReadiness(soldiers), ReadinessColor.green);
});
test('any NMC returns red', () {
final soldiers = [
makeSoldier(id: 'p1', readiness: ReadinessState.fullyMissionCapable),
makeSoldier(id: 'p2', readiness: ReadinessState.notMissionCapable),
];
expect(service.aggregateReadiness(soldiers), ReadinessColor.red);
});
test('mixed FMC and PMC returns amber', () {
final soldiers = [
makeSoldier(id: 'p1', readiness: ReadinessState.fullyMissionCapable),
makeSoldier(id: 'p2', readiness: ReadinessState.partiallyMissionCapable),
];
expect(service.aggregateReadiness(soldiers), ReadinessColor.amber);
});
test('single PMC soldier returns amber', () {
final soldiers = [makeSoldier(id: 'p1', readiness: ReadinessState.partiallyMissionCapable)];
expect(service.aggregateReadiness(soldiers), ReadinessColor.amber);
});
});
// ---------------------------------------------------------------------------
// UnitNode fixture (used to verify UnitTreeService integration)
// ---------------------------------------------------------------------------
group('UnitTreeService integration via AssignmentService', () {
test('validateReparent is accessible through treeService', () {
final bde = UnitNode(
unitId: 'bde',
echelon: Echelon.brigade,
branch: DoctrinalBranch.infantry,
name: 'Brigade',
isKeyLeader: false,
);
final bn = UnitNode(
unitId: 'bn',
echelon: Echelon.battalion,
branch: DoctrinalBranch.infantry,
name: 'Battalion',
isKeyLeader: false,
parentUnitId: 'bde',
);
final result = service.treeService.validateReparent(bn, bde, [bde, bn]);
// bn (battalion) under bde (brigade) is valid
expect(result.isValid, isTrue);
});
});
}

View File

@ -0,0 +1,337 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:uto_manager/domain/entities/authorized_rank.dart';
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
import 'package:uto_manager/domain/entities/echelon.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
void main() {
const service = UnitTreeService();
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
UnitNode makeUnit({
required String id,
required Echelon echelon,
String? parentId,
String name = 'Test Unit',
}) {
return UnitNode(
unitId: id,
echelon: echelon,
branch: DoctrinalBranch.infantry,
name: name,
isKeyLeader: false,
parentUnitId: parentId,
);
}
PositionSlot makeSlot({
required String slotId,
required String unitId,
String? assignedPersonnelId,
bool isKeyLeader = false,
}) {
return PositionSlot(
slotId: slotId,
unitId: unitId,
positionTitle: 'Test Position',
dutyMos: DutyMos('11B'),
authorizedRank: AuthorizedRank.e5,
isKeyLeader: isKeyLeader,
assignedPersonnelId: assignedPersonnelId,
);
}
PersonnelRecord makeSoldier({
required String id,
String? assignedSlotId,
ReadinessState readiness = ReadinessState.fullyMissionCapable,
}) {
return PersonnelRecord(
soldierId: id,
name: 'SGT Test',
primaryMos: DutyMos('11B'),
payGrade: AuthorizedRank.e5,
readinessState: readiness,
assignedSlotId: assignedSlotId,
);
}
// ---------------------------------------------------------------------------
// buildTree
// ---------------------------------------------------------------------------
group('buildTree', () {
test('returns empty list for empty input', () {
final roots = service.buildTree([], []);
expect(roots, isEmpty);
});
test('returns single root node with no children', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.brigade);
final roots = service.buildTree([unit], []);
expect(roots.length, 1);
expect(roots.first.unit.unitId, 'u1');
expect(roots.first.children, isEmpty);
});
test('builds parent-child relationship correctly', () {
final brigade = makeUnit(id: 'bde', echelon: Echelon.brigade);
final battalion = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
final roots = service.buildTree([brigade, battalion], []);
expect(roots.length, 1);
expect(roots.first.children.length, 1);
expect(roots.first.children.first.unit.unitId, 'bn');
});
test('attaches slots to correct unit nodes', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
final slot1 = makeSlot(slotId: 's1', unitId: 'u1');
final slot2 = makeSlot(slotId: 's2', unitId: 'u1');
final roots = service.buildTree([unit], [slot1, slot2]);
expect(roots.first.slots.length, 2);
});
test('multiple roots are returned correctly', () {
final bde1 = makeUnit(id: 'bde1', echelon: Echelon.brigade, name: 'Brigade 1');
final bde2 = makeUnit(id: 'bde2', echelon: Echelon.brigade, name: 'Brigade 2');
final roots = service.buildTree([bde1, bde2], []);
expect(roots.length, 2);
});
test('fill rate is 0 when no slots', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
final roots = service.buildTree([unit], []);
expect(roots.first.fillRate, 0.0);
});
test('fill rate is 100 when all slots filled', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
final slot = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1');
final roots = service.buildTree([unit], [slot]);
expect(roots.first.fillRate, 100.0);
});
test('fill rate is 50 when half slots filled', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
final slot1 = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1');
final slot2 = makeSlot(slotId: 's2', unitId: 'u1');
final roots = service.buildTree([unit], [slot1, slot2]);
expect(roots.first.fillRate, 50.0);
});
test('totalSlots includes children slots', () {
final platoon = makeUnit(id: 'plt', echelon: Echelon.platoon);
final squad = makeUnit(id: 'sq', echelon: Echelon.squad, parentId: 'plt');
final pltSlot = makeSlot(slotId: 'ps1', unitId: 'plt');
final sqSlot = makeSlot(slotId: 'ss1', unitId: 'sq');
final roots = service.buildTree([platoon, squad], [pltSlot, sqSlot]);
expect(roots.first.totalSlots, 2);
});
});
// ---------------------------------------------------------------------------
// getSubtree
// ---------------------------------------------------------------------------
group('getSubtree', () {
test('returns only the root when no children', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.brigade);
final result = service.getSubtree('u1', [unit]);
expect(result.length, 1);
expect(result.first.unitId, 'u1');
});
test('returns root and all descendants', () {
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
final bn = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn');
final result = service.getSubtree('bde', [bde, bn, co]);
expect(result.length, 3);
expect(result.map((u) => u.unitId), containsAll(['bde', 'bn', 'co']));
});
test('throws StateError for unknown unitId', () {
expect(() => service.getSubtree('unknown', []), throwsStateError);
});
});
// ---------------------------------------------------------------------------
// getAncestors
// ---------------------------------------------------------------------------
group('getAncestors', () {
test('returns empty list for root unit', () {
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
final result = service.getAncestors('bde', [bde]);
expect(result, isEmpty);
});
test('returns correct ancestor chain', () {
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
final bn = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn');
final result = service.getAncestors('co', [bde, bn, co]);
expect(result.length, 2);
expect(result[0].unitId, 'bn');
expect(result[1].unitId, 'bde');
});
});
// ---------------------------------------------------------------------------
// validateReparent
// ---------------------------------------------------------------------------
group('validateReparent', () {
test('valid reparent returns valid result', () {
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
final bn1 = makeUnit(id: 'bn1', echelon: Echelon.battalion, parentId: 'bde');
final bn2 = makeUnit(id: 'bn2', echelon: Echelon.battalion, parentId: 'bde');
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn1');
final result = service.validateReparent(co, bn2, [bde, bn1, bn2, co]);
expect(result.isValid, isTrue);
});
test('cannot parent unit to itself', () {
final unit = makeUnit(id: 'u1', echelon: Echelon.company);
final result = service.validateReparent(unit, unit, [unit]);
expect(result.isValid, isFalse);
expect(result.reason, contains('cannot be its own parent'));
});
test('invalid echelon hierarchy returns invalid result', () {
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
final squad = makeUnit(id: 'sq', echelon: Echelon.squad);
final result = service.validateReparent(squad, bde, [bde, squad]);
expect(result.isValid, isFalse);
expect(result.reason, contains('cannot be a child of'));
});
test('circular reference is rejected', () {
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
final bn = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn');
// Try to make bde (brigade) a child of co (company) — echelon is valid
// direction-wise only if we swap, but the real issue is circular ancestry.
// Use a case where echelon is valid but would create a cycle:
// Move bn to be a child of co (bn is ancestor of co → circular).
final result = service.validateReparent(bn, co, [bde, bn, co]);
expect(result.isValid, isFalse);
expect(result.reason, isNotEmpty);
});
});
// ---------------------------------------------------------------------------
// validateAssignment
// ---------------------------------------------------------------------------
group('validateAssignment', () {
test('valid assignment returns valid result', () {
final slot = makeSlot(slotId: 's1', unitId: 'u1');
final soldier = makeSoldier(id: 'p1');
final result = service.validateAssignment(slot, soldier);
expect(result.isValid, isTrue);
});
test('filled slot returns invalid result', () {
final slot = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'existing');
final soldier = makeSoldier(id: 'p1');
final result = service.validateAssignment(slot, soldier);
expect(result.isValid, isFalse);
expect(result.reason, contains('already filled'));
});
test('already assigned soldier returns invalid result', () {
final slot = makeSlot(slotId: 's1', unitId: 'u1');
final soldier = makeSoldier(id: 'p1', assignedSlotId: 'other-slot');
final result = service.validateAssignment(slot, soldier);
expect(result.isValid, isFalse);
expect(result.reason, contains('already assigned'));
});
});
// ---------------------------------------------------------------------------
// calculateFillRate
// ---------------------------------------------------------------------------
group('calculateFillRate', () {
test('returns 0.0 for empty slot list', () {
expect(service.calculateFillRate([]), 0.0);
});
test('returns 1.0 when all slots filled', () {
final slots = [
makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1'),
makeSlot(slotId: 's2', unitId: 'u1', assignedPersonnelId: 'p2'),
];
expect(service.calculateFillRate(slots), 1.0);
});
test('returns 0.5 when half slots filled', () {
final slots = [
makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1'),
makeSlot(slotId: 's2', unitId: 'u1'),
];
expect(service.calculateFillRate(slots), 0.5);
});
});
// ---------------------------------------------------------------------------
// getVacantKeyLeaderSlots
// ---------------------------------------------------------------------------
group('getVacantKeyLeaderSlots', () {
test('returns only vacant key leader slots', () {
final slots = [
makeSlot(slotId: 's1', unitId: 'u1', isKeyLeader: true),
makeSlot(slotId: 's2', unitId: 'u1', isKeyLeader: true, assignedPersonnelId: 'p1'),
makeSlot(slotId: 's3', unitId: 'u1'),
];
final result = service.getVacantKeyLeaderSlots(slots);
expect(result.length, 1);
expect(result.first.slotId, 's1');
});
});
// ---------------------------------------------------------------------------
// getMosMismatches
// ---------------------------------------------------------------------------
group('getMosMismatches', () {
test('returns slots where assigned soldier MOS does not match', () {
final slot = PositionSlot(
slotId: 's1',
unitId: 'u1',
positionTitle: 'Medic',
dutyMos: DutyMos('68W'),
authorizedRank: AuthorizedRank.e4,
isKeyLeader: false,
assignedPersonnelId: 'p1',
);
final soldier = PersonnelRecord(
soldierId: 'p1',
name: 'SGT Wrong',
primaryMos: DutyMos('11B'), // Wrong MOS
payGrade: AuthorizedRank.e4,
readinessState: ReadinessState.fullyMissionCapable,
assignedSlotId: 's1',
);
final result = service.getMosMismatches([slot], [soldier]);
expect(result.length, 1);
expect(result.first.slotId, 's1');
});
test('returns empty list when all MOS match', () {
final slot = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1');
final soldier = makeSoldier(id: 'p1', assignedSlotId: 's1');
final result = service.getMosMismatches([slot], [soldier]);
expect(result, isEmpty);
});
});
}

View File

@ -1,13 +1,71 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:uto_manager/main.dart'; import 'package:uto_manager/application/blocs/personnel_cubit.dart';
import 'package:uto_manager/application/blocs/template_cubit.dart';
import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
import 'package:uto_manager/domain/services/assignment_service.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/infrastructure/database/app_database.dart';
import 'package:uto_manager/infrastructure/repositories/drift_personnel_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
import 'package:uto_manager/infrastructure/repositories/drift_unit_repository.dart';
import 'package:uto_manager/presentation/screens/home_screen.dart';
void main() { void main() {
testWidgets('UTO Manager placeholder scaffold renders', (tester) async { late AppDatabase db;
await tester.pumpWidget(const UtoManagerApp()); late UnitTreeCubit unitTreeCubit;
late PersonnelCubit personnelCubit;
late TemplateCubit templateCubit;
setUp(() {
// Use an in-memory SQLite database so no file I/O or pending timers.
db = AppDatabase.forTesting(NativeDatabase.memory());
const treeService = UnitTreeService();
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
final assignmentService = AssignmentService(treeService: treeService);
// Create cubits but do NOT call startWatching() — avoids live streams.
unitTreeCubit = UnitTreeCubit(
unitRepository: unitRepo,
slotRepository: slotRepo,
treeService: treeService,
);
personnelCubit = PersonnelCubit(
personnelRepository: personnelRepo,
slotRepository: slotRepo,
assignmentService: assignmentService,
);
templateCubit = TemplateCubit(dao: db.templateDao);
});
tearDown(() async {
await unitTreeCubit.close();
await personnelCubit.close();
await templateCubit.close();
await db.close();
});
testWidgets('UTO Manager app bar renders with title and icon', (tester) async {
await tester.pumpWidget(
MultiBlocProvider(
providers: [
BlocProvider<UnitTreeCubit>.value(value: unitTreeCubit),
BlocProvider<PersonnelCubit>.value(value: personnelCubit),
BlocProvider<TemplateCubit>.value(value: templateCubit),
],
child: const MaterialApp(home: HomeScreen()),
),
);
await tester.pump();
expect(find.text('UTO Manager'), findsOneWidget); expect(find.text('UTO Manager'), findsOneWidget);
expect(find.text('Unit Task Organization Manager'), findsOneWidget); expect(find.text('U.S. Army 2026 Doctrine'), findsOneWidget);
expect(find.byIcon(Icons.military_tech), findsOneWidget); expect(find.byIcon(Icons.military_tech), findsOneWidget);
}); });
} }