Files
trainhub-flutter/lib/presentation/trainings/widgets/combo_builder_tab.dart
Kazimierz Ciołek 6dd7213eb0
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
2026-07-06 23:44:17 +02:00

806 lines
26 KiB
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/combo_service.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/presentation/common/widgets/node_graph_canvas.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
/// Node-graph editor for technique combinations: nodes are techniques,
/// edges are the transitions that flow well between them. A random walk
/// over the graph generates combos to drill.
class ComboBuilderTab extends StatefulWidget {
const ComboBuilderTab({super.key});
@override
State<ComboBuilderTab> createState() => _ComboBuilderTabState();
}
class _ComboBuilderTabState extends State<ComboBuilderTab> {
late final ComboService _service = getIt<ComboService>();
String? _selectedId;
@override
void initState() {
super.initState();
_service.addListener(_onServiceChanged);
_service.ensureLoaded();
}
@override
void dispose() {
_service.removeListener(_onServiceChanged);
super.dispose();
}
void _onServiceChanged() {
if (mounted) setState(() {});
}
Combo? get _selected {
for (final c in _service.combos) {
if (c.id == _selectedId) return c;
}
return _service.combos.isEmpty ? null : _service.combos.first;
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
Future<void> _createCombo() async {
final name = await TextInputDialog.show(
context,
title: 'New Combo Graph',
hintText: 'e.g. Sparring counters',
confirmLabel: 'Create',
);
if (name == null || name.trim().isEmpty) return;
final combo = _service.create(name.trim());
setState(() => _selectedId = combo.id);
}
Future<void> _addTechnique(Combo combo) async {
final technique = await _showTechniquePicker();
if (technique == null) return;
// Drop new nodes in a loose diagonal so they don't stack exactly.
final i = combo.nodes.length;
final node = ComboNode(
id: _uuid.v4(),
technique: technique.$1,
translation: technique.$2,
dx: 80.0 + (i % 5) * 210.0,
dy: 80.0 + (i ~/ 5) * 110.0 + (i % 3) * 14.0,
);
_service.update(combo.copyWith(nodes: [...combo.nodes, node]));
}
void _toggleEdge(Combo combo, String fromId, String toId) {
final exists = combo.edges.any((e) => e.fromId == fromId && e.toId == toId);
final edges = exists
? combo.edges
.where((e) => !(e.fromId == fromId && e.toId == toId))
.toList()
: [...combo.edges, ComboEdge(fromId, toId)];
_service.update(combo.copyWith(edges: edges));
}
void _moveNode(Combo combo, String id, Offset position) {
_service.update(
combo.copyWith(
nodes: [
for (final n in combo.nodes)
n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n,
],
),
persist: false,
);
}
Future<void> _deleteNode(Combo combo, String id) async {
final node = combo.nodes.where((n) => n.id == id).firstOrNull;
if (node == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Remove "${node.technique}"?',
style: GoogleFonts.inter(fontSize: 15, fontWeight: FontWeight.w600),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(
'Remove',
style: GoogleFonts.inter(color: AppColors.destructive),
),
),
],
),
);
if (confirmed != true) return;
_service.update(
combo.copyWith(
nodes: combo.nodes.where((n) => n.id != id).toList(),
edges: combo.edges
.where((e) => e.fromId != id && e.toId != id)
.toList(),
),
);
}
void _generate(Combo combo) {
showDialog<void>(
context: context,
builder: (ctx) => _GeneratedComboDialog(combo: combo),
);
}
Future<(String, String)?> _showTechniquePicker() {
return showDialog<(String, String)>(
context: context,
builder: (ctx) => const _TechniquePickerDialog(),
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final combo = _selected;
return Row(
children: [
_ComboListPanel(
combos: _service.combos,
selectedId: combo?.id,
onSelect: (id) => setState(() => _selectedId = id),
onCreate: _createCombo,
onDelete: (id) => _service.delete(id),
),
const VerticalDivider(width: 1, color: AppColors.border),
Expanded(
child: combo == null
? _EmptyState(onCreate: _createCombo)
: Column(
children: [
_Toolbar(
combo: combo,
onAddTechnique: () => _addTechnique(combo),
onGenerate: combo.nodes.isEmpty
? null
: () => _generate(combo),
),
Expanded(
child: ClipRect(
child: NodeGraphCanvas(
nodes: [
for (final n in combo.nodes)
GraphNodeData(
id: n.id,
title: n.technique,
subtitle: n.translation,
accent: _accentFor(n.technique),
position: Offset(n.dx, n.dy),
),
],
edges: [
for (final e in combo.edges)
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),
),
),
),
],
),
),
],
);
}
static Color _accentFor(String technique) {
final t = technique.toLowerCase();
if (t.contains('chagi')) return AppColors.accent;
if (t.contains('jirugi') || t.contains('taerigi')) return AppColors.info;
if (t.contains('makgi')) return AppColors.purple;
return AppColors.zinc500;
}
}
// ---------------------------------------------------------------------------
// Left panel — combo list
// ---------------------------------------------------------------------------
class _ComboListPanel extends StatelessWidget {
const _ComboListPanel({
required this.combos,
required this.selectedId,
required this.onSelect,
required this.onCreate,
required this.onDelete,
});
final List<Combo> combos;
final String? selectedId;
final ValueChanged<String> onSelect;
final VoidCallback onCreate;
final ValueChanged<String> onDelete;
@override
Widget build(BuildContext context) {
return Container(
width: 230,
color: AppColors.surfaceContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: OutlinedButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add, size: 15),
label: const Text('New Combo'),
),
),
Expanded(
child: ListView.builder(
itemCount: combos.length,
itemBuilder: (context, index) {
final combo = combos[index];
final selected = combo.id == selectedId;
return InkWell(
onTap: () => onSelect(combo.id),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing8,
),
color: selected
? AppColors.accentMuted
: Colors.transparent,
child: Row(
children: [
Icon(
Icons.account_tree_outlined,
size: 14,
color: selected
? AppColors.accent
: AppColors.textMuted,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
combo.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: selected
? FontWeight.w600
: FontWeight.w400,
color: selected
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
Text(
'${combo.nodes.length} techniques · '
'${combo.edges.length} links',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
color: AppColors.textMuted,
),
),
],
),
),
IconButton(
onPressed: () => onDelete(combo.id),
icon: const Icon(Icons.delete_outline, size: 14),
color: AppColors.textMuted,
visualDensity: VisualDensity.compact,
tooltip: 'Delete combo',
),
],
),
),
);
},
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Toolbar above the canvas
// ---------------------------------------------------------------------------
class _Toolbar extends StatelessWidget {
const _Toolbar({
required this.combo,
required this.onAddTechnique,
required this.onGenerate,
});
final Combo combo;
final VoidCallback onAddTechnique;
final VoidCallback? onGenerate;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
OutlinedButton.icon(
onPressed: onAddTechnique,
icon: const Icon(Icons.add, size: 15),
label: const Text('Technique'),
),
const SizedBox(width: UIConstants.spacing8),
FilledButton.icon(
onPressed: onGenerate,
icon: const Icon(Icons.casino_outlined, size: 15),
label: const Text('GENERATE COMBO'),
),
const Spacer(),
Text(
'DRAG NODES · LINK WITH → · LONG-PRESS TO DELETE',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
letterSpacing: 0.8,
color: AppColors.textMuted,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Technique picker dialog
// ---------------------------------------------------------------------------
class _TechniquePickerDialog extends StatefulWidget {
const _TechniquePickerDialog();
@override
State<_TechniquePickerDialog> createState() => _TechniquePickerDialogState();
}
class _TechniquePickerDialogState extends State<_TechniquePickerDialog> {
final _customController = TextEditingController();
final _searchController = TextEditingController();
List<ExerciseEntity> _exercises = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final exercises = await getIt<ExerciseRepository>().getAll();
if (mounted) {
setState(() {
_exercises = exercises;
_loading = false;
});
}
}
@override
void dispose() {
_customController.dispose();
_searchController.dispose();
super.dispose();
}
List<ExerciseEntity> get _filtered {
final query = _searchController.text.trim().toLowerCase();
if (query.isEmpty) return _exercises;
return _exercises
.where(
(e) =>
e.name.toLowerCase().contains(query) ||
(e.tags ?? '').toLowerCase().contains(query),
)
.toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
return AlertDialog(
title: Text(
'ADD TECHNIQUE',
style: GoogleFonts.chakraPetch(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
content: SizedBox(
width: 420,
height: 460,
child: Column(
children: [
TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: 'Search your exercise library…',
prefixIcon: Icon(Icons.search, size: 16),
),
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing8),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: filtered.isEmpty
? Center(
child: Text(
_exercises.isEmpty
? 'No exercises in your library yet.\n'
'Add them in the EXERCISES tab, or type '
'a custom technique below.'
: 'No match — try the custom field below.',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
height: 1.6,
),
),
)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final exercise = filtered[index];
return ListTile(
dense: true,
onTap: () => Navigator.pop(context, (
exercise.name,
exercise.tags ?? '',
)),
title: Text(
exercise.name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
subtitle: (exercise.tags ?? '').isEmpty
? null
: Text(
exercise.tags!,
style: GoogleFonts.inter(
fontSize: 11,
color: AppColors.textMuted,
),
),
);
},
),
),
const Divider(height: 1, color: AppColors.border),
Padding(
padding: const EdgeInsets.only(top: UIConstants.spacing12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _customController,
decoration: const InputDecoration(
hintText: 'Custom technique…',
),
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
onSubmitted: (v) {
if (v.trim().isNotEmpty) {
Navigator.pop(context, (v.trim(), ''));
}
},
),
),
const SizedBox(width: UIConstants.spacing8),
FilledButton(
onPressed: () {
final v = _customController.text.trim();
if (v.isNotEmpty) Navigator.pop(context, (v, ''));
},
child: const Text('Add'),
),
],
),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Generated combo dialog
// ---------------------------------------------------------------------------
class _GeneratedComboDialog extends StatefulWidget {
const _GeneratedComboDialog({required this.combo});
final Combo combo;
@override
State<_GeneratedComboDialog> createState() => _GeneratedComboDialogState();
}
class _GeneratedComboDialogState extends State<_GeneratedComboDialog> {
late List<ComboNode> _sequence = widget.combo.generateSequence(length: 4);
bool _adding = false;
/// Appends the generated sequence to a chosen training plan as a new
/// section — one exercise per technique, editable later in the plan editor.
Future<void> _addToPlan() async {
final repo = getIt<TrainingPlanRepository>();
final plans = await repo.getAll();
if (!mounted) return;
if (plans.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No training plans yet — create one first.'),
),
);
return;
}
final plan = await showDialog<TrainingPlanEntity>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(
'ADD TO PLAN',
style: GoogleFonts.chakraPetch(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
children: [
for (final p in plans)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, p),
child: Text(
p.name,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
),
),
],
),
);
if (plan == null || !mounted) return;
setState(() => _adding = true);
final section = TrainingSectionEntity(
id: _uuid.v4(),
name: 'Combo: ${widget.combo.name}',
exercises: [
for (final node in _sequence)
TrainingExerciseEntity(
instanceId: _uuid.v4(),
exerciseId: node.id,
name: node.technique,
sets: 2,
value: 10,
isTime: false,
rest: 30,
),
],
);
await repo.update(plan.copyWith(sections: [...plan.sections, section]));
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
Navigator.pop(context);
messenger.showSnackBar(
SnackBar(content: Text('Combo added to "${plan.name}"')),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
'YOUR COMBO',
style: GoogleFonts.chakraPetch(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.accent,
),
),
content: SizedBox(
width: 420,
child: _sequence.isEmpty
? Text(
'Add techniques and link them first.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _sequence.length; i++) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Text(
'${i + 1}',
style: GoogleFonts.jetBrainsMono(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
_sequence[i].technique.toUpperCase(),
style: GoogleFonts.chakraPetch(
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: AppColors.textPrimary,
),
),
),
],
),
),
if (i < _sequence.length - 1)
const Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Icon(
Icons.arrow_downward_rounded,
size: 14,
color: AppColors.textMuted,
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => setState(
() => _sequence = widget.combo.generateSequence(length: 4),
),
child: const Text('REROLL'),
),
OutlinedButton.icon(
onPressed: _sequence.isEmpty || _adding ? null : _addToPlan,
icon: const Icon(Icons.playlist_add_rounded, size: 15),
label: const Text('ADD TO PLAN'),
),
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('DONE'),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.onCreate});
final VoidCallback onCreate;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.account_tree_outlined,
size: 48,
color: AppColors.textMuted,
),
const SizedBox(height: UIConstants.spacing16),
Text(
'COMBO BUILDER',
style: GoogleFonts.chakraPetch(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Map your techniques as a graph — nodes are techniques,\n'
'links are transitions that flow well. Then generate\n'
'random combos to drill on the timer.',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.6,
),
),
const SizedBox(height: UIConstants.spacing24),
FilledButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add, size: 16),
label: const Text('CREATE FIRST COMBO'),
),
],
),
);
}
}