uto_manager/lib/presentation/screens/home_screen.dart

1136 lines
41 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
}
}
}