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 createState() => _ComboBuilderTabState(); } class _ComboBuilderTabState extends State { late final ComboService _service = getIt(); 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 _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 _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 _deleteNode(Combo combo, String id) async { final node = combo.nodes.where((n) => n.id == id).firstOrNull; if (node == null) return; final confirmed = await showDialog( 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( 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 combos; final String? selectedId; final ValueChanged onSelect; final VoidCallback onCreate; final ValueChanged 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 _exercises = []; bool _loading = true; @override void initState() { super.initState(); _load(); } Future _load() async { final exercises = await getIt().getAll(); if (mounted) { setState(() { _exercises = exercises; _loading = false; }); } } @override void dispose() { _customController.dispose(); _searchController.dispose(); super.dispose(); } List 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 _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 _addToPlan() async { final repo = getIt(); 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( 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'), ), ], ), ); } }