uto_manager/lib/presentation/screens/home_screen.dart

731 lines
26 KiB
Dart
Raw 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 '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/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/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/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({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: [
// 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: 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,
),
),
],
),
),
// 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 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),
);
},
),
),
],
);
},
);
}
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),
// 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),
),
],
);
}
// ---------------------------------------------------------------------------
// 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 _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),
..._ExportFormat.values.map(
(f) => RadioListTile<_ExportFormat>(
title: Text(f.label),
subtitle: Text(f.description, style: const TextStyle(fontSize: 11)),
value: f,
groupValue: _format,
dense: true,
onChanged: (v) => setState(() => _format = v!),
),
),
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:
final codec = const 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:
final codec = const 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:
final exporter = const 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';
}
}
}