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( 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( 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( 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().setFilter(q), ); }, ), ], ), ), Expanded( child: BlocBuilder( 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( 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().selectUnit(id), onReparent: (unitId, newParentId) => context.read().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( 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().state is PersonnelLoaded ? (context.read().state as PersonnelLoaded).allPersonnel : [])) 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( 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( 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( 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().assignSoldierToSlot(soldier, slot); } void _unassign(BuildContext context, PositionSlot slot) { context.read().unassignFromSlot(slot); } void _runValidation(BuildContext context, UnitTreeLoaded treeState) { final personnel = context.read().state is PersonnelLoaded ? (context.read().state as PersonnelLoaded).allPersonnel : []; context.read().validate( units: treeState.units, slots: treeState.slots, personnel: personnel, ); } void _showExportDialog(BuildContext context, UnitTreeLoaded treeState) { showDialog( context: context, builder: (ctx) => _ExportDialog(treeState: treeState), ); } void _confirmClearAll(BuildContext context) { showDialog( 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().clearAll(); context.read().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( 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 _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'; } } }