184 lines
5.4 KiB
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|