Poprawki
Some checks failed
Build Linux App / build (push) Failing after 1m12s

This commit is contained in:
Kazimierz Ciołek
2026-07-07 22:03:36 +02:00
parent 6dd7213eb0
commit ebb7426c67
14 changed files with 886 additions and 82 deletions

View File

@@ -1,6 +1,10 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
@@ -206,6 +210,71 @@ class ChatController extends _$ChatController {
/// kept and persisted like a normal reply.
void stopGeneration() => _cancelToken?.cancel();
/// Turns an assistant reply describing a workout into a real training plan
/// in the database. The model re-reads its own text and emits strict JSON,
/// which is parsed into sections/exercises. Returns the created plan name.
Future<String> createPlanFromText(String content) async {
const extractPrompt =
'You convert workout descriptions into strict JSON. '
'Respond with ONLY valid JSON — no markdown fences, no commentary. '
'Schema: {"name": string, "sections": [{"name": string, '
'"exercises": [{"name": string, "sets": int, "value": int, '
'"isTime": bool, "rest": int}]}]}. '
'"value" means repetitions when isTime=false, or seconds when '
'isTime=true. "rest" is rest between sets in seconds. '
'Keep exercise names short. If the text contains no workout plan, '
'respond with {"name": "", "sections": []}.';
final buffer = StringBuffer();
await for (final delta in _llm.streamChat([
{'role': 'system', 'content': extractPrompt},
{'role': 'user', 'content': content},
])) {
buffer.write(delta);
}
final raw = buffer.toString();
final start = raw.indexOf('{');
final end = raw.lastIndexOf('}');
if (start < 0 || end <= start) {
throw Exception('The model did not return a valid plan.');
}
final data =
jsonDecode(raw.substring(start, end + 1)) as Map<String, dynamic>;
final sectionsJson = (data['sections'] as List?) ?? [];
if (sectionsJson.isEmpty) {
throw Exception('No workout plan found in this reply.');
}
final sections = <TrainingSectionEntity>[
for (final s in sectionsJson)
TrainingSectionEntity(
id: const Uuid().v4(),
name: (s['name'] ?? 'Section') as String,
exercises: [
for (final e in (s['exercises'] as List? ?? []))
TrainingExerciseEntity(
instanceId: const Uuid().v4(),
exerciseId: '',
name: (e['name'] ?? 'Exercise') as String,
sets: (e['sets'] as num?)?.toInt() ?? 3,
value: (e['value'] as num?)?.toInt() ?? 10,
isTime: (e['isTime'] as bool?) ?? false,
rest: (e['rest'] as num?)?.toInt() ?? 60,
),
],
),
];
final planRepo = getIt<TrainingPlanRepository>();
final name = ((data['name'] as String?)?.trim().isEmpty ?? true)
? 'AI Plan'
: (data['name'] as String).trim();
final created = await planRepo.create(name);
await planRepo.update(created.copyWith(sections: sections));
return name;
}
Future<String> _streamResponse(
String systemPrompt,
List<Map<String, String>> history,

View File

@@ -41,7 +41,7 @@ final aiSettingsServiceProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6';
String _$chatControllerHash() => r'3856f4ae716d8ea0fab459f0b8182bc76e9f1e1b';
/// See also [ChatController].
@ProviderFor(ChatController)

View File

@@ -58,6 +58,37 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_inputFocusNode.requestFocus();
}
Future<void> _createPlanFromMessage(
ChatController controller,
String content,
) async {
// Simple progress dialog — extraction is a second LLM round-trip.
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
final name = await controller.createPlanFromText(content);
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Plan "$name" created — find it under Trainings.'),
),
);
} catch (e) {
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not create a plan: $e'),
backgroundColor: AppColors.surfaceContainerHigh,
),
);
}
}
String _formatTimestamp(String timestamp) {
try {
final dt = DateTime.parse(timestamp);
@@ -306,6 +337,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return MessageBubble(
message: msg,
formattedTime: _formatTimestamp(msg.createdAt),
onCreatePlan: msg.isUser
? null
: () => _createPlanFromMessage(controller, msg.content),
);
},
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
@@ -9,11 +10,16 @@ class MessageBubble extends StatelessWidget {
super.key,
required this.message,
required this.formattedTime,
this.onCreatePlan,
});
final ChatMessageEntity message;
final String formattedTime;
/// When set (assistant messages), shows a "create plan" action that turns
/// the reply into a real training plan.
final VoidCallback? onCreatePlan;
@override
Widget build(BuildContext context) {
final isUser = message.isUser;
@@ -21,8 +27,9 @@ class MessageBubble extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
child: Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) ...[
@@ -34,8 +41,9 @@ class MessageBubble extends StatelessWidget {
],
Flexible(
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Container(
constraints: BoxConstraints(maxWidth: maxWidth),
@@ -84,12 +92,50 @@ class MessageBubble extends StatelessWidget {
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
formattedTime,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formattedTime,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
),
if (!isUser && onCreatePlan != null) ...[
const SizedBox(width: UIConstants.spacing12),
InkWell(
onTap: onCreatePlan,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.playlist_add_rounded,
size: 13,
color: AppColors.accent,
),
const SizedBox(width: 4),
Text(
'CREATE PLAN',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: AppColors.accent,
),
),
],
),
),
),
],
],
),
),
],

View File

@@ -33,7 +33,7 @@ class GraphEdgeData {
///
/// Interactions:
/// - drag empty space to pan, pinch / scroll to zoom
/// - drag a node to move it ([onNodeMoved] per frame, [onNodeDragEnd] once)
/// - drag a node to move it ([onNodeMoved] fires once, at drag end)
/// - tap the link button on a node, then tap another node to connect them
/// ([onConnect]); connecting an already-linked pair is the caller's chance
/// to toggle the edge off
@@ -44,7 +44,6 @@ class NodeGraphCanvas extends StatefulWidget {
required this.nodes,
required this.edges,
this.onNodeMoved,
this.onNodeDragEnd,
this.onConnect,
this.onNodeTap,
this.onNodeLongPress,
@@ -53,8 +52,11 @@ class NodeGraphCanvas extends StatefulWidget {
final List<GraphNodeData> nodes;
final List<GraphEdgeData> edges;
/// Called once, when a drag ends, with the final position. During the
/// drag the canvas tracks positions locally so no parent rebuild (or
/// persistence) happens per pointer event — that's what keeps it smooth.
final void Function(String id, Offset position)? onNodeMoved;
final void Function(String id)? onNodeDragEnd;
final void Function(String fromId, String toId)? onConnect;
final void Function(String id)? onNodeTap;
final void Function(String id)? onNodeLongPress;
@@ -76,6 +78,27 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
/// this is what makes node dragging feel 1:1 with the mouse.
String? _draggingId;
/// Live positions — the canvas is the source of truth while dragging,
/// so a drag never triggers a parent rebuild per pointer event.
late Map<String, Offset> _positions;
@override
void initState() {
super.initState();
_positions = {for (final n in widget.nodes) n.id: n.position};
}
@override
void didUpdateWidget(NodeGraphCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
// Re-sync from the parent, but never yank the node under the cursor.
final dragging = _draggingId;
_positions = {
for (final n in widget.nodes)
n.id: n.id == dragging ? (_positions[n.id] ?? n.position) : n.position,
};
}
@override
void dispose() {
_transform.dispose();
@@ -116,18 +139,25 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
height: widget.canvasSize.height,
child: Stack(
children: [
// Static dot grid — painted once, never repaints on drag.
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(painter: _DotGridPainter()),
),
),
Positioned.fill(
child: CustomPaint(
painter: _GraphPainter(
painter: _EdgesPainter(
nodesById: nodesById,
positions: _positions,
edges: widget.edges,
),
),
),
for (final node in widget.nodes)
Positioned(
left: node.position.dx,
top: node.position.dy,
left: (_positions[node.id] ?? node.position).dx,
top: (_positions[node.id] ?? node.position).dy,
// Raw pointer events instead of a pan GestureDetector:
// no gesture-arena delay, no drag slop — the node
// follows the cursor from the first pixel.
@@ -139,15 +169,19 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
? null
: (event) {
if (_draggingId != node.id) return;
widget.onNodeMoved!(
node.id,
node.position + event.delta / _scale,
);
setState(() {
_positions[node.id] =
(_positions[node.id] ?? node.position) +
event.delta / _scale;
});
},
onPointerUp: (_) {
if (_draggingId == node.id) {
setState(() => _draggingId = null);
widget.onNodeDragEnd?.call(node.id);
widget.onNodeMoved?.call(
node.id,
_positions[node.id] ?? node.position,
);
}
},
onPointerCancel: (_) {
@@ -155,20 +189,22 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
setState(() => _draggingId = null);
}
},
child: _NodeCard(
node: node,
isConnectSource: _connectingFrom == node.id,
connectMode: _connectingFrom != null,
showLinkButton: widget.onConnect != null,
onTap: () => _handleNodeBodyTap(node.id),
onLongPress: widget.onNodeLongPress == null
? null
: () => widget.onNodeLongPress!(node.id),
onStartConnect: () => setState(() {
_connectingFrom = _connectingFrom == node.id
child: RepaintBoundary(
child: _NodeCard(
node: node,
isConnectSource: _connectingFrom == node.id,
connectMode: _connectingFrom != null,
showLinkButton: widget.onConnect != null,
onTap: () => _handleNodeBodyTap(node.id),
onLongPress: widget.onNodeLongPress == null
? null
: node.id;
}),
: () => widget.onNodeLongPress!(node.id),
onStartConnect: () => setState(() {
_connectingFrom = _connectingFrom == node.id
? null
: node.id;
}),
),
),
),
),
@@ -326,19 +362,45 @@ class _NodeCard extends StatelessWidget {
}
// ---------------------------------------------------------------------------
// Edges + dot grid painter
// Painters — the static dot grid is separate from the edges so that a node
// drag only repaints the (cheap) edge layer, never the thousands of dots.
// ---------------------------------------------------------------------------
class _GraphPainter extends CustomPainter {
_GraphPainter({required this.nodesById, required this.edges});
class _DotGridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
const step = 28.0;
final points = <Offset>[
for (double x = step; x < size.width; x += step)
for (double y = step; y < size.height; y += step) Offset(x, y),
];
canvas.drawPoints(
PointMode.points,
points,
Paint()
..color = AppColors.surfaceContainerHigh
..strokeWidth = 1.6
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(_DotGridPainter oldDelegate) => false;
}
class _EdgesPainter extends CustomPainter {
_EdgesPainter({
required this.nodesById,
required this.positions,
required this.edges,
});
final Map<String, GraphNodeData> nodesById;
final Map<String, Offset> positions;
final List<GraphEdgeData> edges;
@override
void paint(Canvas canvas, Size size) {
_paintDotGrid(canvas, size);
final edgePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
@@ -349,12 +411,14 @@ class _GraphPainter extends CustomPainter {
if (from == null || to == null) continue;
final start =
from.position +
(positions[edge.fromId] ?? from.position) +
const Offset(
NodeGraphCanvas.nodeWidth,
NodeGraphCanvas.nodeHeight / 2,
);
final end = to.position + const Offset(0, NodeGraphCanvas.nodeHeight / 2);
final end =
(positions[edge.toId] ?? to.position) +
const Offset(0, NodeGraphCanvas.nodeHeight / 2);
edgePaint.color = from.accent.withValues(alpha: 0.75);
@@ -384,22 +448,6 @@ class _GraphPainter extends CustomPainter {
}
}
void _paintDotGrid(Canvas canvas, Size size) {
const step = 28.0;
final points = <Offset>[
for (double x = step; x < size.width; x += step)
for (double y = step; y < size.height; y += step) Offset(x, y),
];
canvas.drawPoints(
PointMode.points,
points,
Paint()
..color = AppColors.surfaceContainerHigh
..strokeWidth = 1.6
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(_GraphPainter oldDelegate) => true;
bool shouldRepaint(_EdgesPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,320 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/data/services/grading_service.dart';
import 'package:trainhub_flutter/injection.dart';
/// Belt grading preparation: the ITF colored-belt syllabus as a checklist.
/// Pick the belt you're testing for, tick off requirements as they're ready.
@RoutePage()
class GradingPage extends StatefulWidget {
const GradingPage({super.key});
@override
State<GradingPage> createState() => _GradingPageState();
}
class _GradingPageState extends State<GradingPage> {
late final GradingService _service = getIt<GradingService>();
@override
void initState() {
super.initState();
_service.addListener(_onChanged);
_service.ensureLoaded();
}
@override
void dispose() {
_service.removeListener(_onChanged);
super.dispose();
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final currentId = _service.currentBeltId;
final current = kBeltSyllabus.where((b) => b.id == currentId).firstOrNull;
return ListView(
padding: const EdgeInsets.all(UIConstants.pagePadding),
children: [
Text(
'GRADING PREP',
style: GoogleFonts.chakraPetch(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing4),
Text(
current == null
? 'Tap a belt and set it as your current goal.'
: 'Preparing for ${current.gup} · ${current.name}',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
),
if (current != null) ...[
const SizedBox(height: UIConstants.spacing16),
_ReadinessBanner(belt: current, service: _service),
],
const SizedBox(height: UIConstants.spacing24),
for (final belt in kBeltSyllabus) ...[
_BeltCard(
belt: belt,
service: _service,
isCurrent: belt.id == currentId,
),
const SizedBox(height: UIConstants.spacing12),
],
],
);
}
}
// ---------------------------------------------------------------------------
// Readiness banner for the current belt
// ---------------------------------------------------------------------------
class _ReadinessBanner extends StatelessWidget {
const _ReadinessBanner({required this.belt, required this.service});
final BeltLevel belt;
final GradingService service;
@override
Widget build(BuildContext context) {
final progress = service.progress(belt);
final percent = (progress * 100).round();
final ready = progress >= 1.0;
return Container(
padding: const EdgeInsets.all(UIConstants.cardPadding),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(
color: ready ? AppColors.success : AppColors.accentBorder,
),
),
child: Row(
children: [
_BeltChip(belt: belt, size: 40),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ready ? 'READY FOR GRADING' : 'GRADING READINESS',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
color: ready ? AppColors.success : AppColors.accent,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: AppColors.surfaceContainerHigh,
valueColor: AlwaysStoppedAnimation<Color>(
ready ? AppColors.success : AppColors.accent,
),
),
),
],
),
),
const SizedBox(width: UIConstants.spacing16),
Text(
'$percent%',
style: GoogleFonts.jetBrainsMono(
fontSize: 22,
fontWeight: FontWeight.w700,
color: ready ? AppColors.success : AppColors.textPrimary,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Belt card with expandable requirement checklist
// ---------------------------------------------------------------------------
class _BeltCard extends StatelessWidget {
const _BeltCard({
required this.belt,
required this.service,
required this.isCurrent,
});
final BeltLevel belt;
final GradingService service;
final bool isCurrent;
@override
Widget build(BuildContext context) {
final checked = service.checkedCount(belt.id);
final total = belt.requirements.length;
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(
color: isCurrent ? AppColors.accentBorder : AppColors.border,
),
),
child: Theme(
// Remove the ExpansionTile divider lines.
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: isCurrent,
tilePadding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: 4,
),
leading: _BeltChip(belt: belt, size: 34),
title: Row(
children: [
Text(
belt.gup,
style: GoogleFonts.chakraPetch(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: AppColors.textPrimary,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
belt.name,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
if (isCurrent)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.accentBorder),
),
child: Text(
'CURRENT',
style: GoogleFonts.jetBrainsMono(
fontSize: 8,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.accent,
),
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'$checked / $total requirements',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
color: checked == total
? AppColors.success
: AppColors.textMuted,
),
),
),
children: [
for (var i = 0; i < belt.requirements.length; i++)
CheckboxListTile(
dense: true,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
),
value: service.isChecked(belt.id, i),
onChanged: (_) => service.toggle(belt.id, i),
title: Text(
belt.requirements[i],
style: GoogleFonts.inter(
fontSize: 13,
color: service.isChecked(belt.id, i)
? AppColors.textMuted
: AppColors.textPrimary,
decoration: service.isChecked(belt.id, i)
? TextDecoration.lineThrough
: null,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () =>
service.setCurrentBelt(isCurrent ? null : belt.id),
child: Text(
isCurrent ? 'UNSET CURRENT' : 'SET AS CURRENT GOAL',
),
),
),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Belt color chip
// ---------------------------------------------------------------------------
class _BeltChip extends StatelessWidget {
const _BeltChip({required this.belt, required this.size});
final BeltLevel belt;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size * 0.55,
decoration: BoxDecoration(
color: belt.baseColor,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: AppColors.border),
),
child: belt.stripeColor == null
? null
: Center(
child: Container(height: size * 0.14, color: belt.stripeColor),
),
);
}
}

View File

@@ -20,6 +20,7 @@ class ShellPage extends StatelessWidget {
TrainingsRoute(),
AnalysisRoute(),
CalendarRoute(),
GradingRoute(),
ChatRoute(),
],
builder: (context, child) {
@@ -86,6 +87,11 @@ class _Sidebar extends StatelessWidget {
activeIcon: Icons.calendar_today,
label: 'Calendar',
),
_NavItemData(
icon: Icons.military_tech_outlined,
activeIcon: Icons.military_tech,
label: 'Grading',
),
_NavItemData(
icon: Icons.chat_bubble_outline,
activeIcon: Icons.chat_bubble,

View File

@@ -95,6 +95,8 @@ class _ComboBuilderTabState extends State<ComboBuilderTab> {
_service.update(combo.copyWith(edges: edges));
}
// Called once, when a drag ends — the canvas tracks positions itself
// during the drag, so this can persist immediately.
void _moveNode(Combo combo, String id, Offset position) {
_service.update(
combo.copyWith(
@@ -103,7 +105,6 @@ class _ComboBuilderTabState extends State<ComboBuilderTab> {
n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n,
],
),
persist: false,
);
}
@@ -205,7 +206,6 @@ class _ComboBuilderTabState extends State<ComboBuilderTab> {
GraphEdgeData(e.fromId, e.toId),
],
onNodeMoved: (id, pos) => _moveNode(combo, id, pos),
onNodeDragEnd: (_) => _service.persistNow(),
onConnect: (from, to) => _toggleEdge(combo, from, to),
onNodeLongPress: (id) => _deleteNode(combo, id),
),