806 lines
26 KiB
Dart
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));
|
|
}
|
|
|
|
// 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(
|
|
nodes: [
|
|
for (final n in combo.nodes)
|
|
n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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),
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|