Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Some checks failed
Build Linux App / build (push) Failing after 1m18s
This commit is contained in:
805
lib/presentation/trainings/widgets/combo_builder_tab.dart
Normal file
805
lib/presentation/trainings/widgets/combo_builder_tab.dart
Normal file
@@ -0,0 +1,805 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user