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( 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( 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: 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( 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 && 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( 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( 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: 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().state is PersonnelLoaded ? (context.read().state as PersonnelLoaded).allPersonnel : [])) 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( 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( 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( 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( 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().assignSoldierToSlot(soldier, slot); } void _unassign(BuildContext context, PositionSlot slot) { context.read().unassignFromSlot(slot); } // --------------------------------------------------------------------------- // Personnel CRUD // --------------------------------------------------------------------------- Future _showAddPersonnelDialog(BuildContext context) async { final record = await showDialog( context: context, builder: (_) => AddEditPersonnelDialog(positionsRepository: positionsRepository), ); if (record != null && context.mounted) { unawaited(context.read().addPersonnel(record)); } } Future _showEditPersonnelDialog(BuildContext context, PersonnelRecord existing) async { final record = await showDialog( context: context, builder: (_) => AddEditPersonnelDialog( positionsRepository: positionsRepository, existing: existing, ), ); if (record != null && context.mounted) { unawaited(context.read().updatePersonnel(record)); } } // --------------------------------------------------------------------------- // Unit CRUD // --------------------------------------------------------------------------- Future _showAddUnitDialog(BuildContext context, UnitTreeState treeState) async { final units = treeState is UnitTreeLoaded ? treeState.units : []; final unit = await showDialog( context: context, builder: (_) => AddEditUnitDialog(availableParents: units), ); if (unit != null && context.mounted) { unawaited(context.read().addUnit(unit)); } } Future _showAddChildUnitDialog( BuildContext context, UnitNode parent, UnitTreeLoaded treeState, ) async { final unit = await showDialog( context: context, builder: (_) => AddEditUnitDialog( parentUnit: parent, availableParents: treeState.units, ), ); if (unit != null && context.mounted) { unawaited(context.read().addUnit(unit)); } } Future _showEditUnitDialog( BuildContext context, UnitNode existing, List allUnits, ) async { // Exclude the unit itself and its descendants from available parents final unit = await showDialog( context: context, builder: (_) => AddEditUnitDialog( existing: existing, availableParents: allUnits.where((u) => u.unitId != existing.unitId).toList(), ), ); if (unit != null && context.mounted) { unawaited(context.read().updateUnit(unit)); } } Future _confirmDeleteUnit(BuildContext context, UnitNode unit) async { final confirmed = await showDialog( 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().deleteSubtree(unit.unitId)); } } // --------------------------------------------------------------------------- // Position Slot CRUD // --------------------------------------------------------------------------- Future _showAddSlotDialog(BuildContext context, String unitId) async { final slot = await showDialog( context: context, builder: (_) => AddEditSlotDialog(unitId: unitId), ); if (slot != null && context.mounted) { unawaited(context.read().addSlot(slot)); } } Future _showEditSlotDialog(BuildContext context, PositionSlot existing) async { final slot = await showDialog( context: context, builder: (_) => AddEditSlotDialog(unitId: existing.unitId, existing: existing), ); if (slot != null && context.mounted) { unawaited(context.read().updateSlot(slot)); } } Future _confirmDeleteSlot(BuildContext context, PositionSlot slot) async { final confirmed = await showDialog( 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().unassignFromSlot(slot); } if (context.mounted) { unawaited(context.read().deleteSlot(slot.slotId)); } } } // --------------------------------------------------------------------------- // Validation & Export // --------------------------------------------------------------------------- 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), 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( 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: 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'; } } }