Compare commits
No commits in common. "fb73a6eb7ab95f389e75c2dec2a23488ab4d84f4" and "6bffae8cc46d51b9e9beb8e6635da7e03819194f" have entirely different histories.
fb73a6eb7a
...
6bffae8cc4
|
|
@ -1,128 +1,106 @@
|
||||||
# UTO Manager — Development Status
|
# UTO Manager — Development Status
|
||||||
|
|
||||||
_Last updated: 2026-08-09_
|
**Last Updated:** 2026-08-07
|
||||||
|
**Current Branch:** `feature/phase-1-foundations` → merged to `main`
|
||||||
|
**Active Phase:** Phase 1 — Architecture & Data Foundations ✅ COMPLETE
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Branch
|
## Phase 1 — Architecture & Data Foundations ✅ COMPLETE
|
||||||
`main` (feature/phase-2-canvas-engine merged)
|
|
||||||
|
|
||||||
## Overall Progress
|
### Completed Items
|
||||||
|
|
||||||
| Phase | Title | Status |
|
| Item | Status | Notes |
|
||||||
|-------|-------|--------|
|
|------|--------|-------|
|
||||||
| 1 | Domain Foundation | ✅ Complete |
|
| Flutter project initialization | ✅ | Multi-platform: Windows, Web (WASM), Android |
|
||||||
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
| Clean architecture folder structure | ✅ | domain / infrastructure / presentation layers |
|
||||||
| 3 | Template Library & Import/Export | 🔲 Not started |
|
| `pubspec.yaml` configuration | ✅ | Drift, Equatable, flutter_bloc, uuid, meta, path_provider |
|
||||||
| 4 | Reporting & Analytics | 🔲 Not started |
|
| `analysis_options.yaml` lint rules | ✅ | very_good_analysis base + project overrides |
|
||||||
| 5 | Multi-user / Sync | 🔲 Not started |
|
| `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 1 — Domain Foundation ✅
|
## Phase 2 — Canvas Engine & Drag-Drop UTO Builder (NEXT)
|
||||||
|
|
||||||
**Completed items:**
|
### Planned 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
|
|
||||||
|
|
||||||
**Test count (Phase 1):** 60 tests
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Canvas Engine & Persistence ✅
|
## Phase 3 — Reporting & Export (PLANNED)
|
||||||
|
|
||||||
**Completed items:**
|
| Item | Priority | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
### Domain Services
|
| UTO report generation | High | PDF/HTML export of unit task organization |
|
||||||
- `UnitTreeService` — `buildTree`, `getSubtree`, `getAncestors`, `validateReparent`,
|
| Readiness dashboard | High | Aggregate FMC/PMC/NMC statistics by echelon |
|
||||||
`validateAssignment`, `calculateFillRate`, `getVacantKeyLeaderSlots`, `getMosMismatches`
|
| Personnel roster export | Medium | CSV export of assigned/unassigned soldiers |
|
||||||
- `AssignmentService` — `assign`, `unassign`, `aggregateReadiness` with `ReadinessColor`
|
| Template library | Medium | Save/load/share UTO templates |
|
||||||
|
| Multi-BCT support | Low | Support multiple simultaneous BCT configurations |
|
||||||
### Infrastructure — Database
|
| Web deployment | Low | Deploy WASM build to internal web server |
|
||||||
- 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test Summary
|
## Known Issues / Technical Debt
|
||||||
|
|
||||||
| File | Tests | Status |
|
| Issue | Severity | Resolution |
|
||||||
|------|-------|--------|
|
|-------|----------|-----------|
|
||||||
| `authorized_rank_test.dart` | 16 | ✅ |
|
| `lines_longer_than_80_chars` disabled | Low | Dart formatter produces lines > 80 chars for chained method calls; rule disabled in `analysis_options.yaml` |
|
||||||
| `echelon_test.dart` | 17 | ✅ |
|
| Drift DAOs not yet implemented | Medium | Phase 2 work item — abstract interfaces defined, concrete implementations pending |
|
||||||
| `personnel_record_test.dart` | 13 | ✅ |
|
| No integration tests | Medium | Phase 2 will add integration tests for repository implementations |
|
||||||
| `unit_node_test.dart` | 15 | ✅ |
|
| `sqlite3_flutter_libs` EOL warning | Low | Will upgrade when `drift` supports newer version |
|
||||||
| `duty_mos_test.dart` | 32 | ✅ |
|
|
||||||
| `assignment_service_test.dart` | 16 | ✅ |
|
|
||||||
| `unit_tree_service_test.dart` | 27 | ✅ |
|
|
||||||
| `widget_test.dart` | 1 | ✅ |
|
|
||||||
| **Total** | **137** | **✅ All pass** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Limitations / Next Steps (Phase 3)
|
## Test Coverage Summary
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
flutter test --coverage
|
||||||
├── domain/
|
94 tests | 0 failures | 0 skipped
|
||||||
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
Coverage: domain layer ~95%+ (entities, value objects)
|
||||||
│ ├── 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 |
|
||||||
|
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,10 +4,6 @@ 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';
|
||||||
|
|
||||||
|
|
@ -77,10 +73,7 @@ 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(
|
@DriftDatabase(tables: [Units, PositionSlots, Personnel, Templates])
|
||||||
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());
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
lib/main.dart
119
lib/main.dart
|
|
@ -1,15 +1,4 @@
|
||||||
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());
|
||||||
|
|
@ -17,73 +6,65 @@ void main() {
|
||||||
|
|
||||||
/// The root application widget for the UTO Manager.
|
/// The root application widget for the UTO Manager.
|
||||||
///
|
///
|
||||||
/// Wires together the database, repositories, domain services, and BLoC
|
/// This is the entry point for the Flutter application. The full application
|
||||||
/// providers before handing off to the [HomeScreen].
|
/// shell (three-pane layout, canvas engine, state management) will be
|
||||||
class UtoManagerApp extends StatefulWidget {
|
/// implemented in Phase 3.
|
||||||
|
class UtoManagerApp extends StatelessWidget {
|
||||||
const UtoManagerApp({super.key});
|
const UtoManagerApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UtoManagerApp> createState() => _UtoManagerAppState();
|
Widget build(BuildContext context) {
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UtoManagerAppState extends State<UtoManagerApp> {
|
/// Placeholder scaffold displayed during Phase 1 development.
|
||||||
late final AppDatabase _db;
|
///
|
||||||
late final DriftUnitRepository _unitRepo;
|
/// Will be replaced by the full three-pane UI shell in Phase 3.
|
||||||
late final DriftPersonnelRepository _personnelRepo;
|
class _PlaceholderScaffold extends StatelessWidget {
|
||||||
late final DriftPositionSlotRepository _slotRepo;
|
const _PlaceholderScaffold();
|
||||||
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 MultiBlocProvider(
|
return Scaffold(
|
||||||
providers: [
|
appBar: AppBar(
|
||||||
BlocProvider<UnitTreeCubit>(
|
title: const Text('UTO Manager'),
|
||||||
create: (_) => UnitTreeCubit(
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
unitRepository: _unitRepo,
|
),
|
||||||
slotRepository: _slotRepo,
|
body: const Center(
|
||||||
treeService: _treeService,
|
child: Column(
|
||||||
)..startWatching(),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
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(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +1,13 @@
|
||||||
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/application/blocs/personnel_cubit.dart';
|
import 'package:uto_manager/main.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() {
|
||||||
late AppDatabase db;
|
testWidgets('UTO Manager placeholder scaffold renders', (tester) async {
|
||||||
late UnitTreeCubit unitTreeCubit;
|
await tester.pumpWidget(const UtoManagerApp());
|
||||||
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('U.S. Army 2026 Doctrine'), findsOneWidget);
|
expect(find.text('Unit Task Organization Manager'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.military_tech), findsOneWidget);
|
expect(find.byIcon(Icons.military_tech), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue