uto_manager/lib/presentation/widgets/uto_canvas_widget.dart

184 lines
5.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:uto_manager/domain/services/unit_tree_service.dart';
import 'package:uto_manager/presentation/widgets/unit_node_card.dart';
/// An interactive, scrollable/zoomable canvas displaying the unit org chart.
///
/// Uses [InteractiveViewer] for pan and zoom. Unit nodes are rendered as
/// [UnitNodeCard] widgets arranged in a hierarchical tree layout.
class UtoCanvasWidget extends StatefulWidget {
const UtoCanvasWidget({
super.key,
required this.roots,
required this.selectedUnitId,
required this.onUnitSelected,
required this.onReparent,
});
/// Root-level tree nodes to render.
final List<UnitTreeNode> roots;
/// Currently selected unit ID (or null).
final String? selectedUnitId;
/// Called when a unit node is tapped.
final void Function(String unitId) onUnitSelected;
/// Called when a unit is dragged onto a new parent.
final Future<bool> Function(String unitId, String newParentId) onReparent;
@override
State<UtoCanvasWidget> createState() => _UtoCanvasWidgetState();
}
class _UtoCanvasWidgetState extends State<UtoCanvasWidget> {
final TransformationController _transformController = TransformationController();
@override
void dispose() {
_transformController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.roots.isEmpty) {
return _buildEmptyState(context);
}
return InteractiveViewer(
transformationController: _transformController,
minScale: 0.2,
maxScale: 3.0,
boundaryMargin: const EdgeInsets.all(500),
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: widget.roots.map((root) => _buildSubtree(root, 0)).toList(),
),
),
);
}
Widget _buildSubtree(UnitTreeNode node, int depth) {
final card = _buildDraggableNode(node);
if (node.children.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: card,
);
}
return Column(
children: [
card,
// Connector line
Container(width: 2, height: 20, color: Colors.blueGrey.withValues(alpha: 0.4)),
// Children row
IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children.map((child) {
return Column(
children: [
Container(width: 2, height: 20, color: Colors.blueGrey.withValues(alpha: 0.4)),
_buildSubtree(child, depth + 1),
],
);
}).toList(),
),
),
],
);
}
Widget _buildDraggableNode(UnitTreeNode node) {
final unitId = node.unit.unitId;
final isSelected = widget.selectedUnitId == unitId;
return DragTarget<String>(
onWillAcceptWithDetails: (details) => details.data != unitId,
onAcceptWithDetails: (details) async {
await widget.onReparent(details.data, unitId);
},
builder: (context, candidateData, rejectedData) {
final isDragOver = candidateData.isNotEmpty;
return Draggable<String>(
data: unitId,
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(6),
child: Opacity(
opacity: 0.85,
child: UnitNodeCard(
treeNode: node,
isSelected: false,
onTap: () {},
),
),
),
childWhenDragging: Opacity(
opacity: 0.3,
child: UnitNodeCard(
treeNode: node,
isSelected: false,
onTap: () {},
),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: isDragOver
? BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
)
: null,
child: UnitNodeCard(
treeNode: node,
isSelected: isSelected,
onTap: () => widget.onUnitSelected(unitId),
),
),
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.account_tree_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
'No units loaded',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add units using the controls above,\nor load a saved template.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
);
}
}