diff --git a/development_status.md b/development_status.md index db1cd3b..67a1539 100644 --- a/development_status.md +++ b/development_status.md @@ -1,106 +1,128 @@ # UTO Manager — Development Status -**Last Updated:** 2026-08-07 -**Current Branch:** `feature/phase-1-foundations` → merged to `main` -**Active Phase:** Phase 1 — Architecture & Data Foundations ✅ COMPLETE +_Last updated: 2026-08-09_ --- -## Phase 1 — Architecture & Data Foundations ✅ COMPLETE +## Current Branch +`main` (feature/phase-2-canvas-engine merged) -### Completed Items +## Overall Progress -| Item | Status | Notes | -|------|--------|-------| -| Flutter project initialization | ✅ | Multi-platform: Windows, Web (WASM), Android | -| Clean architecture folder structure | ✅ | domain / infrastructure / presentation layers | -| `pubspec.yaml` configuration | ✅ | Drift, Equatable, flutter_bloc, uuid, meta, path_provider | -| `analysis_options.yaml` lint rules | ✅ | very_good_analysis base + project overrides | -| `Echelon` entity | ✅ | Brigade→Team hierarchy, `isValidParent`, `isValidChild`, `level` | -| `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 | Title | Status | +|-------|-------|--------| +| 1 | Domain Foundation | ✅ Complete | +| 2 | Canvas Engine & Persistence | ✅ Complete | +| 3 | Template Library & Import/Export | 🔲 Not started | +| 4 | Reporting & Analytics | 🔲 Not started | +| 5 | Multi-user / Sync | 🔲 Not started | --- -## 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 | -|------|----------|-------| -| `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 | +**Test count (Phase 1):** 60 tests --- -## Phase 3 — Reporting & Export (PLANNED) +## Phase 2 — Canvas Engine & Persistence ✅ -| Item | Priority | Notes | -|------|----------|-------| -| UTO report generation | High | PDF/HTML export of unit task organization | -| Readiness dashboard | High | Aggregate FMC/PMC/NMC statistics by echelon | -| Personnel roster export | Medium | CSV export of assigned/unassigned soldiers | -| Template library | Medium | Save/load/share UTO templates | -| Multi-BCT support | Low | Support multiple simultaneous BCT configurations | -| Web deployment | Low | Deploy WASM build to internal web server | +**Completed items:** + +### Domain Services +- `UnitTreeService` — `buildTree`, `getSubtree`, `getAncestors`, `validateReparent`, + `validateAssignment`, `calculateFillRate`, `getVacantKeyLeaderSlots`, `getMosMismatches` +- `AssignmentService` — `assign`, `unassign`, `aggregateReadiness` with `ReadinessColor` + +### 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 | -|-------|----------|-----------| -| `lines_longer_than_80_chars` disabled | Low | Dart formatter produces lines > 80 chars for chained method calls; rule disabled in `analysis_options.yaml` | -| Drift DAOs not yet implemented | Medium | Phase 2 work item — abstract interfaces defined, concrete implementations pending | -| No integration tests | Medium | Phase 2 will add integration tests for repository implementations | -| `sqlite3_flutter_libs` EOL warning | Low | Will upgrade when `drift` supports newer version | +| File | Tests | Status | +|------|-------|--------| +| `authorized_rank_test.dart` | 16 | ✅ | +| `echelon_test.dart` | 17 | ✅ | +| `personnel_record_test.dart` | 13 | ✅ | +| `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 -94 tests | 0 failures | 0 skipped -Coverage: domain layer ~95%+ (entities, value objects) +lib/ +├── domain/ +│ ├── 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 | diff --git a/lib/application/blocs/personnel_cubit.dart b/lib/application/blocs/personnel_cubit.dart new file mode 100644 index 0000000..b5457d5 --- /dev/null +++ b/lib/application/blocs/personnel_cubit.dart @@ -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 get props => []; +} + +class PersonnelLoading extends PersonnelState { + const PersonnelLoading(); + + @override + List get props => []; +} + +class PersonnelLoaded extends PersonnelState { + const PersonnelLoaded({ + required this.allPersonnel, + required this.unassigned, + this.filterQuery = '', + }); + + /// All personnel records. + final List allPersonnel; + + /// Unassigned soldiers (available for assignment). + final List unassigned; + + /// Current search/filter query string. + final String filterQuery; + + /// Returns [unassigned] filtered by [filterQuery]. + List 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? allPersonnel, + List? unassigned, + String? filterQuery, + }) { + return PersonnelLoaded( + allPersonnel: allPersonnel ?? this.allPersonnel, + unassigned: unassigned ?? this.unassigned, + filterQuery: filterQuery ?? this.filterQuery, + ); + } + + @override + List get props => [allPersonnel, unassigned, filterQuery]; +} + +class PersonnelError extends PersonnelState { + const PersonnelError(this.message); + + final String message; + + @override + List get props => [message]; +} + +// --------------------------------------------------------------------------- +// Cubit +// --------------------------------------------------------------------------- + +/// Manages personnel state, including loading, filtering, and assignment. +class PersonnelCubit extends Cubit { + PersonnelCubit({ + required this.personnelRepository, + required this.slotRepository, + required this.assignmentService, + }) : super(const PersonnelInitial()); + + final PersonnelRepository personnelRepository; + final DriftPositionSlotRepository slotRepository; + final AssignmentService assignmentService; + + StreamSubscription>? _allSubscription; + StreamSubscription>? _unassignedSubscription; + + List _all = []; + List _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 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 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 addPersonnel(PersonnelRecord record) async { + try { + await personnelRepository.insert(record); + } catch (e) { + emit(PersonnelError(e.toString())); + } + } + + @override + Future close() { + _allSubscription?.cancel(); + _unassignedSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/application/blocs/template_cubit.dart b/lib/application/blocs/template_cubit.dart new file mode 100644 index 0000000..381387b --- /dev/null +++ b/lib/application/blocs/template_cubit.dart @@ -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 get props => []; +} + +class TemplateLoading extends TemplateState { + const TemplateLoading(); + + @override + List get props => []; +} + +class TemplateLoaded extends TemplateState { + const TemplateLoaded(this.templates); + + final List