1136 lines
41 KiB
Dart
1136 lines
41 KiB
Dart
import 'dart:async';
|
||
|
||
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/application/blocs/validation_cubit.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/mtoe_validation_engine.dart';
|
||
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
||
import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart';
|
||
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||
import 'package:uto_manager/infrastructure/repositories/drift_soldier_positions_repository.dart';
|
||
import 'package:uto_manager/presentation/screens/dialogs/add_edit_personnel_dialog.dart';
|
||
import 'package:uto_manager/presentation/screens/dialogs/add_edit_slot_dialog.dart';
|
||
import 'package:uto_manager/presentation/screens/dialogs/add_edit_unit_dialog.dart';
|
||
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
|
||
import 'package:uto_manager/presentation/widgets/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 + validation)
|
||
class HomeScreen extends StatelessWidget {
|
||
const HomeScreen({required this.positionsRepository, super.key});
|
||
|
||
final DriftSoldierPositionsRepository positionsRepository;
|
||
|
||
@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: [
|
||
// Add Unit button
|
||
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||
builder: (context, treeState) {
|
||
return IconButton(
|
||
icon: const Icon(Icons.add_box_outlined),
|
||
tooltip: 'Add Unit',
|
||
onPressed: () => _showAddUnitDialog(context, treeState),
|
||
);
|
||
},
|
||
),
|
||
// Add Soldier button
|
||
IconButton(
|
||
icon: const Icon(Icons.person_add_outlined),
|
||
tooltip: 'Add Soldier',
|
||
onPressed: () => _showAddPersonnelDialog(context),
|
||
),
|
||
// Validate button
|
||
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||
builder: (context, treeState) {
|
||
if (treeState is! UnitTreeLoaded) return const SizedBox.shrink();
|
||
return IconButton(
|
||
icon: const Icon(Icons.fact_check_outlined),
|
||
tooltip: 'Run MTOE Validation',
|
||
onPressed: () => _runValidation(context, treeState),
|
||
);
|
||
},
|
||
),
|
||
// Export button
|
||
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||
builder: (context, treeState) {
|
||
if (treeState is! UnitTreeLoaded) return const SizedBox.shrink();
|
||
return IconButton(
|
||
icon: const Icon(Icons.upload_file_outlined),
|
||
tooltip: 'Export',
|
||
onPressed: () => _showExportDialog(context, treeState),
|
||
);
|
||
},
|
||
),
|
||
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: 300,
|
||
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: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'PERSONNEL ROSTER',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: 1.2,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
// Add soldier button in panel header
|
||
SizedBox(
|
||
height: 24,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.person_add_outlined, size: 16),
|
||
tooltip: 'Add Soldier',
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () => _showAddPersonnelDialog(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
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 && state.filterQuery.isEmpty) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.person_outline,
|
||
size: 40,
|
||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'No soldiers yet',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FilledButton.tonalIcon(
|
||
icon: const Icon(Icons.person_add_outlined, size: 14),
|
||
label: const Text('Add Soldier'),
|
||
onPressed: () => _showAddPersonnelDialog(context),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
if (soldiers.isEmpty) {
|
||
return Center(
|
||
child: Text(
|
||
'No matches',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
itemCount: soldiers.length,
|
||
itemBuilder: (context, i) => PersonnelListTile(
|
||
record: soldiers[i],
|
||
onTap: () => _showEditPersonnelDialog(context, soldiers[i]),
|
||
),
|
||
);
|
||
}
|
||
return const SizedBox.shrink();
|
||
},
|
||
),
|
||
),
|
||
// Show all personnel (assigned + unassigned) section
|
||
BlocBuilder<PersonnelCubit, PersonnelState>(
|
||
builder: (context, state) {
|
||
if (state is! PersonnelLoaded) return const SizedBox.shrink();
|
||
final assigned = state.allPersonnel.where((p) => p.isAssigned).toList();
|
||
if (assigned.isEmpty) return const SizedBox.shrink();
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
color: theme.colorScheme.surfaceContainerLow,
|
||
child: Text(
|
||
'${assigned.length} assigned • ${state.unassigned.length} unassigned',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.touch_app_outlined,
|
||
size: 40,
|
||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Select a unit on the canvas to inspect its position slots.',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 16),
|
||
FilledButton.tonalIcon(
|
||
icon: const Icon(Icons.add_box_outlined, size: 14),
|
||
label: const Text('Add Unit'),
|
||
onPressed: () => _showAddUnitDialog(context, treeState),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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 with edit/delete actions
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'UNIT INSPECTOR',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: 1.2,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
// Edit unit button
|
||
SizedBox(
|
||
height: 24,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.edit_outlined, size: 15),
|
||
tooltip: 'Edit Unit',
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () =>
|
||
_showEditUnitDialog(context, selectedUnit, treeState.units),
|
||
),
|
||
),
|
||
// Add child unit button
|
||
SizedBox(
|
||
height: 24,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.add_box_outlined, size: 15),
|
||
tooltip: 'Add Child Unit',
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () =>
|
||
_showAddChildUnitDialog(context, selectedUnit, treeState),
|
||
),
|
||
),
|
||
// Delete unit button
|
||
SizedBox(
|
||
height: 24,
|
||
child: IconButton(
|
||
icon: Icon(
|
||
Icons.delete_outline,
|
||
size: 15,
|
||
color: theme.colorScheme.error,
|
||
),
|
||
tooltip: 'Delete Unit & Subtree',
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () => _confirmDeleteUnit(context, selectedUnit),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Validation findings for selected unit
|
||
BlocBuilder<ValidationCubit, ValidationState>(
|
||
builder: (context, valState) {
|
||
if (valState is! ValidationComplete) return const SizedBox.shrink();
|
||
final findings = valState.findingsForUnit(selectedUnit.unitId);
|
||
if (findings.isEmpty) return const SizedBox.shrink();
|
||
return Container(
|
||
color: theme.colorScheme.errorContainer.withAlpha(60),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'VALIDATION FINDINGS',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: 1.1,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
...findings.map((f) => _buildFindingTile(theme, f)),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
// Slots header with add-slot button
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
color: theme.colorScheme.surfaceContainerLow,
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
'POSITION SLOTS',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: 1.1,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${slots.where((s) => s.isFilled).length}/${slots.length} filled',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
height: 24,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.add_circle_outline, size: 15),
|
||
tooltip: 'Add Position Slot',
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () => _showAddSlotDialog(context, selectedUnit.unitId),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Slots list
|
||
Expanded(
|
||
child: slots.isEmpty
|
||
? Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.work_outline,
|
||
size: 32,
|
||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'No position slots defined',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FilledButton.tonalIcon(
|
||
icon: const Icon(Icons.add_circle_outline, size: 14),
|
||
label: const Text('Add Slot'),
|
||
onPressed: () => _showAddSlotDialog(context, selectedUnit.unitId),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
: 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),
|
||
onEdit: () => _showEditSlotDialog(context, slot),
|
||
onDelete: () => _confirmDeleteSlot(context, slot),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildFindingTile(ThemeData theme, ValidationFinding finding) {
|
||
final (color, icon) = switch (finding.severity) {
|
||
FindingSeverity.critical => (Colors.red.shade700, Icons.error_outline),
|
||
FindingSeverity.warning => (Colors.orange.shade700, Icons.warning_amber_outlined),
|
||
FindingSeverity.advisory => (Colors.blue.shade600, Icons.info_outline),
|
||
};
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(icon, size: 14, color: color),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
'[${finding.findingCode}] ${finding.description}',
|
||
style: theme.textTheme.labelSmall?.copyWith(color: color, fontSize: 10),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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: Row(
|
||
children: [
|
||
// Unit / slot counts
|
||
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 SizedBox(width: 16),
|
||
// Personnel count
|
||
BlocBuilder<PersonnelCubit, PersonnelState>(
|
||
builder: (context, state) {
|
||
if (state is! PersonnelLoaded) return const SizedBox.shrink();
|
||
return _statusChip(
|
||
theme,
|
||
Icons.people_outline,
|
||
'${state.allPersonnel.length} soldiers',
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(width: 16),
|
||
// Validation finding counts
|
||
BlocBuilder<ValidationCubit, ValidationState>(
|
||
builder: (context, valState) {
|
||
if (valState is ValidationRunning) {
|
||
return Row(
|
||
children: [
|
||
const SizedBox(
|
||
width: 10,
|
||
height: 10,
|
||
child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.white54),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'Validating…',
|
||
style:
|
||
theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
if (valState is ValidationComplete) {
|
||
final c = valState.critical.length;
|
||
final w = valState.warnings.length;
|
||
final a = valState.advisories.length;
|
||
if (valState.isClean) {
|
||
return _statusChip(
|
||
theme,
|
||
Icons.check_circle_outline,
|
||
'MTOE Valid',
|
||
Colors.green.shade400,
|
||
);
|
||
}
|
||
return Row(
|
||
children: [
|
||
if (c > 0) ...[
|
||
_statusChip(theme, Icons.error_outline, '$c critical', Colors.red.shade400),
|
||
const SizedBox(width: 8),
|
||
],
|
||
if (w > 0) ...[
|
||
_statusChip(
|
||
theme,
|
||
Icons.warning_amber_outlined,
|
||
'$w warnings',
|
||
Colors.orange.shade400,
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
if (a > 0)
|
||
_statusChip(theme, Icons.info_outline, '$a advisory', Colors.blue.shade400),
|
||
],
|
||
);
|
||
}
|
||
return const SizedBox.shrink();
|
||
},
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'Phase 4 — Persistence & Validation',
|
||
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statusChip(ThemeData theme, IconData icon, String label, [Color? color]) {
|
||
final c = color ?? Colors.white70;
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 12, color: c),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.labelSmall?.copyWith(color: c, fontSize: 10),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Assignment 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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Personnel CRUD
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Future<void> _showAddPersonnelDialog(BuildContext context) async {
|
||
final record = await showDialog<PersonnelRecord>(
|
||
context: context,
|
||
builder: (_) => AddEditPersonnelDialog(positionsRepository: positionsRepository),
|
||
);
|
||
if (record != null && context.mounted) {
|
||
unawaited(context.read<PersonnelCubit>().addPersonnel(record));
|
||
}
|
||
}
|
||
|
||
Future<void> _showEditPersonnelDialog(BuildContext context, PersonnelRecord existing) async {
|
||
final record = await showDialog<PersonnelRecord>(
|
||
context: context,
|
||
builder: (_) => AddEditPersonnelDialog(
|
||
positionsRepository: positionsRepository,
|
||
existing: existing,
|
||
),
|
||
);
|
||
if (record != null && context.mounted) {
|
||
unawaited(context.read<PersonnelCubit>().updatePersonnel(record));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Unit CRUD
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Future<void> _showAddUnitDialog(BuildContext context, UnitTreeState treeState) async {
|
||
final units = treeState is UnitTreeLoaded ? treeState.units : <UnitNode>[];
|
||
final unit = await showDialog<UnitNode>(
|
||
context: context,
|
||
builder: (_) => AddEditUnitDialog(availableParents: units),
|
||
);
|
||
if (unit != null && context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().addUnit(unit));
|
||
}
|
||
}
|
||
|
||
Future<void> _showAddChildUnitDialog(
|
||
BuildContext context,
|
||
UnitNode parent,
|
||
UnitTreeLoaded treeState,
|
||
) async {
|
||
final unit = await showDialog<UnitNode>(
|
||
context: context,
|
||
builder: (_) => AddEditUnitDialog(
|
||
parentUnit: parent,
|
||
availableParents: treeState.units,
|
||
),
|
||
);
|
||
if (unit != null && context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().addUnit(unit));
|
||
}
|
||
}
|
||
|
||
Future<void> _showEditUnitDialog(
|
||
BuildContext context,
|
||
UnitNode existing,
|
||
List<UnitNode> allUnits,
|
||
) async {
|
||
// Exclude the unit itself and its descendants from available parents
|
||
final unit = await showDialog<UnitNode>(
|
||
context: context,
|
||
builder: (_) => AddEditUnitDialog(
|
||
existing: existing,
|
||
availableParents: allUnits.where((u) => u.unitId != existing.unitId).toList(),
|
||
),
|
||
);
|
||
if (unit != null && context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().updateUnit(unit));
|
||
}
|
||
}
|
||
|
||
Future<void> _confirmDeleteUnit(BuildContext context, UnitNode unit) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Delete Unit?'),
|
||
content: Text(
|
||
'Delete "${unit.name}" and all its subordinate units and position slots? '
|
||
'This action cannot be undone.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('Cancel'),
|
||
),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: const Text('Delete'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if ((confirmed ?? false) && context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().deleteSubtree(unit.unitId));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Position Slot CRUD
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Future<void> _showAddSlotDialog(BuildContext context, String unitId) async {
|
||
final slot = await showDialog<PositionSlot>(
|
||
context: context,
|
||
builder: (_) => AddEditSlotDialog(unitId: unitId),
|
||
);
|
||
if (slot != null && context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().addSlot(slot));
|
||
}
|
||
}
|
||
|
||
Future<void> _showEditSlotDialog(BuildContext context, PositionSlot existing) async {
|
||
final slot = await showDialog<PositionSlot>(
|
||
context: context,
|
||
builder: (_) => AddEditSlotDialog(unitId: existing.unitId, existing: existing),
|
||
);
|
||
if (slot != null && context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().updateSlot(slot));
|
||
}
|
||
}
|
||
|
||
Future<void> _confirmDeleteSlot(BuildContext context, PositionSlot slot) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Delete Position Slot?'),
|
||
content: Text(
|
||
'Remove the "${slot.positionTitle}" (${slot.dutyMos.code}) slot? '
|
||
'${slot.isFilled ? "The assigned soldier will be unassigned. " : ""}'
|
||
'This cannot be undone.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('Cancel'),
|
||
),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: const Text('Delete'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if ((confirmed ?? false) && context.mounted) {
|
||
// If slot is filled, unassign the soldier first
|
||
if (slot.isFilled) {
|
||
await context.read<PersonnelCubit>().unassignFromSlot(slot);
|
||
}
|
||
if (context.mounted) {
|
||
unawaited(context.read<UnitTreeCubit>().deleteSlot(slot.slotId));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Validation & Export
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void _runValidation(BuildContext context, UnitTreeLoaded treeState) {
|
||
final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded
|
||
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
|
||
: <PersonnelRecord>[];
|
||
|
||
context.read<ValidationCubit>().validate(
|
||
units: treeState.units,
|
||
slots: treeState.slots,
|
||
personnel: personnel,
|
||
);
|
||
}
|
||
|
||
void _showExportDialog(BuildContext context, UnitTreeLoaded treeState) {
|
||
showDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => _ExportDialog(treeState: treeState),
|
||
);
|
||
}
|
||
|
||
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();
|
||
context.read<ValidationCubit>().clear();
|
||
},
|
||
child: const Text('Clear All'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Export Dialog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Dialog for selecting export format and triggering the export.
|
||
class _ExportDialog extends StatefulWidget {
|
||
const _ExportDialog({required this.treeState});
|
||
|
||
final UnitTreeLoaded treeState;
|
||
|
||
@override
|
||
State<_ExportDialog> createState() => _ExportDialogState();
|
||
}
|
||
|
||
class _ExportDialogState extends State<_ExportDialog> {
|
||
_ExportFormat _format = _ExportFormat.json;
|
||
PdfPageSize _pageSize = PdfPageSize.tabloid;
|
||
bool _exporting = false;
|
||
String? _result;
|
||
String? _error;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: const Row(
|
||
children: [
|
||
Icon(Icons.upload_file_outlined, size: 20),
|
||
SizedBox(width: 8),
|
||
Text('Export Unit Tree'),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 360,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Format:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 8),
|
||
RadioGroup<_ExportFormat>(
|
||
groupValue: _format,
|
||
onChanged: (v) => setState(() => _format = v!),
|
||
child: Column(
|
||
children: _ExportFormat.values
|
||
.map(
|
||
(f) => RadioListTile<_ExportFormat>(
|
||
title: Text(f.label),
|
||
subtitle: Text(f.description, style: const TextStyle(fontSize: 11)),
|
||
value: f,
|
||
dense: true,
|
||
),
|
||
)
|
||
.toList(),
|
||
),
|
||
),
|
||
if (_format == _ExportFormat.pdf) ...[
|
||
const SizedBox(height: 8),
|
||
const Text('Page size:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 4),
|
||
DropdownButton<PdfPageSize>(
|
||
value: _pageSize,
|
||
isExpanded: true,
|
||
items: PdfPageSize.values
|
||
.map((s) => DropdownMenuItem(value: s, child: Text(s.displayName)))
|
||
.toList(),
|
||
onChanged: (v) => setState(() => _pageSize = v!),
|
||
),
|
||
],
|
||
if (_result != null) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.green.shade900.withAlpha(80),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(_result!, style: const TextStyle(fontSize: 11)),
|
||
),
|
||
],
|
||
if (_error != null) ...[
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.red.shade900.withAlpha(80),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
_error!,
|
||
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Close'),
|
||
),
|
||
FilledButton.icon(
|
||
icon: _exporting
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||
)
|
||
: const Icon(Icons.download_outlined, size: 16),
|
||
label: Text(_exporting ? 'Exporting…' : 'Export'),
|
||
onPressed: _exporting ? null : _doExport,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _doExport() async {
|
||
setState(() {
|
||
_exporting = true;
|
||
_result = null;
|
||
_error = null;
|
||
});
|
||
|
||
try {
|
||
final units = widget.treeState.units;
|
||
final slots = widget.treeState.slots;
|
||
|
||
switch (_format) {
|
||
case _ExportFormat.json:
|
||
const codec = UnitTreeCodec();
|
||
final json = codec.encode(units: units, slots: slots, personnel: const []);
|
||
setState(
|
||
() => _result = 'JSON export ready — ${json.length} characters '
|
||
'(${(json.length / 1024).toStringAsFixed(1)} KB)',
|
||
);
|
||
|
||
case _ExportFormat.binary:
|
||
const codec = ProtobufCodec();
|
||
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||
final jsonLen =
|
||
const UnitTreeCodec().encode(units: units, slots: slots, personnel: const []).length;
|
||
final ratio = ProtobufCodec.compressionRatio(bytes.length, jsonLen);
|
||
setState(
|
||
() => _result = 'Binary export ready — ${bytes.length} bytes '
|
||
'(${(ratio * 100).round()}% of JSON size)',
|
||
);
|
||
|
||
case _ExportFormat.pdf:
|
||
const exporter = PdfChartExporter();
|
||
final bytes = exporter.export(
|
||
units: units,
|
||
slots: slots,
|
||
unitDesignation: units.isNotEmpty ? units.first.name : 'Unit Tree',
|
||
pageSize: _pageSize,
|
||
);
|
||
setState(
|
||
() => _result = 'PDF export ready — ${bytes.length} bytes '
|
||
'(${(bytes.length / 1024).toStringAsFixed(1)} KB), '
|
||
'${_pageSize.displayName}',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
setState(() => _error = 'Export failed: $e');
|
||
} finally {
|
||
setState(() => _exporting = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
enum _ExportFormat {
|
||
json,
|
||
binary,
|
||
pdf;
|
||
|
||
String get label {
|
||
switch (this) {
|
||
case _ExportFormat.json:
|
||
return 'JSON (human-readable)';
|
||
case _ExportFormat.binary:
|
||
return 'Binary / Protobuf (compact)';
|
||
case _ExportFormat.pdf:
|
||
return 'PDF Organizational Chart (vector)';
|
||
}
|
||
}
|
||
|
||
String get description {
|
||
switch (this) {
|
||
case _ExportFormat.json:
|
||
return 'Full unit tree as structured JSON. Importable.';
|
||
case _ExportFormat.binary:
|
||
return 'Gzip-compressed binary. ~60–75% smaller than JSON.';
|
||
case _ExportFormat.pdf:
|
||
return 'Vector PDF org chart with classification banner.';
|
||
}
|
||
}
|
||
}
|
||
|
||
extension on PdfPageSize {
|
||
String get displayName {
|
||
switch (this) {
|
||
case PdfPageSize.letter:
|
||
return 'Letter (8.5×11)';
|
||
case PdfPageSize.a4:
|
||
return 'A4 (210×297mm)';
|
||
case PdfPageSize.tabloid:
|
||
return 'Tabloid/A3 (11×17) — recommended for BCT';
|
||
}
|
||
}
|
||
}
|