731 lines
26 KiB
Dart
731 lines
26 KiB
Dart
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';
|
||
}
|
||
}
|
||
}
|