381 lines
13 KiB
Dart
381 lines
13 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/unit_tree_cubit.dart';
|
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
|
import 'package:uto_manager/domain/entities/position_slot.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)
|
|
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: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 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),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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: 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 Spacer(),
|
|
Text(
|
|
'Phase 2 — Canvas Engine',
|
|
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _statusChip(ThemeData theme, IconData icon, String label) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 12, color: Colors.white70),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white70, 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 _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();
|
|
},
|
|
child: const Text('Clear All'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|