Refactoring
Some checks failed
Build Linux App / build (push) Failing after 1m33s

This commit is contained in:
2026-02-23 10:02:23 -05:00
parent 21f1387fa8
commit 0c9eb8878d
57 changed files with 8179 additions and 1114 deletions

View File

@@ -0,0 +1,13 @@
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
class ExerciseDragData {
const ExerciseDragData({
required this.fromSectionIndex,
required this.exerciseIndex,
required this.exercise,
});
final int fromSectionIndex;
final int exerciseIndex;
final TrainingExerciseEntity exercise;
}

View File

@@ -3,7 +3,6 @@ import 'package:trainhub_flutter/core/utils/id_generator.dart';
import 'package:trainhub_flutter/injection.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';
@@ -14,14 +13,15 @@ part 'plan_editor_controller.g.dart';
@riverpod
class PlanEditorController extends _$PlanEditorController {
late TrainingPlanRepository _planRepo;
late ExerciseRepository _exerciseRepo;
@override
Future<PlanEditorState> build(String planId) async {
_planRepo = getIt<TrainingPlanRepository>();
final ExerciseRepository exerciseRepo = getIt<ExerciseRepository>();
_exerciseRepo = getIt<ExerciseRepository>();
final plan = await _planRepo.getById(planId);
final exercises = await exerciseRepo.getAll();
final exercises = await _exerciseRepo.getAll();
return PlanEditorState(plan: plan, availableExercises: exercises);
}
@@ -64,6 +64,21 @@ class PlanEditorController extends _$PlanEditorController {
);
}
void reorderSection(int oldIndex, int newIndex) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
if (oldIndex < newIndex) newIndex -= 1;
final item = sections.removeAt(oldIndex);
sections.insert(newIndex, item);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void updateSectionName(int sectionIndex, String name) {
final current = state.valueOrNull;
if (current == null) return;
@@ -116,6 +131,37 @@ class PlanEditorController extends _$PlanEditorController {
);
}
void moveExerciseBetweenSections({
required int fromSectionIndex,
required int exerciseIndex,
required int toSectionIndex,
}) {
final current = state.valueOrNull;
if (current == null) return;
if (fromSectionIndex == toSectionIndex) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
final fromExercises = List<TrainingExerciseEntity>.from(
sections[fromSectionIndex].exercises,
);
final toExercises = List<TrainingExerciseEntity>.from(
sections[toSectionIndex].exercises,
);
final exercise = fromExercises.removeAt(exerciseIndex);
toExercises.add(exercise);
sections[fromSectionIndex] = sections[fromSectionIndex].copyWith(
exercises: fromExercises,
);
sections[toSectionIndex] = sections[toSectionIndex].copyWith(
exercises: toExercises,
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void updateExerciseParams(
int sectionIndex,
int exerciseIndex, {
@@ -168,6 +214,25 @@ class PlanEditorController extends _$PlanEditorController {
);
}
Future<ExerciseEntity> createExercise({
required String name,
String? instructions,
String? tags,
String? videoUrl,
}) async {
final current = state.valueOrNull;
if (current == null) throw StateError('Controller state not loaded');
final exercise = await _exerciseRepo.create(
name: name,
instructions: instructions,
tags: tags,
videoUrl: videoUrl,
);
final exercises = await _exerciseRepo.getAll();
state = AsyncValue.data(current.copyWith(availableExercises: exercises));
return exercise;
}
Future<void> save() async {
final current = state.valueOrNull;
if (current == null) return;

View File

@@ -7,7 +7,7 @@ part of 'plan_editor_controller.dart';
// **************************************************************************
String _$planEditorControllerHash() =>
r'4045493829126f28b3a58695b68ade53519c1412';
r'6c6c2f74725e250bd41401cab12c1a62306d10ea';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -1,6 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/presentation/plan_editor/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
@@ -16,64 +19,182 @@ class PlanEditorPage extends ConsumerWidget {
final controller = ref.read(planEditorControllerProvider(planId).notifier);
return Scaffold(
appBar: AppBar(
title: state.when(
data: (data) => TextField(
controller: TextEditingController(text: data.plan.name)
..selection = TextSelection.fromPosition(
TextPosition(offset: data.plan.name.length),
),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Plan Name',
backgroundColor: AppColors.surface,
body: Column(
children: [
// --- Custom header bar ---
Container(
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
style: Theme.of(context).textTheme.titleLarge,
onChanged: controller.updatePlanName,
),
error: (_, __) => const Text('Error'),
loading: () => const Text('Loading...'),
),
actions: [
state.maybeWhen(
data: (data) => IconButton(
icon: Icon(
Icons.save,
color: data.isDirty ? Theme.of(context).primaryColor : null,
),
onPressed: data.isDirty ? () => controller.save() : null,
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
),
orElse: () => const SizedBox.shrink(),
),
],
),
body: state.when(
data: (data) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: data.plan.sections.length + 1,
itemBuilder: (context, index) {
if (index == data.plan.sections.length) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton.icon(
onPressed: controller.addSection,
icon: const Icon(Icons.add),
label: const Text('Add Section'),
child: Row(
children: [
// Back button
Material(
color: AppColors.zinc800,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => context.router.maybePop(),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(
Icons.arrow_back,
color: AppColors.textSecondary,
size: 18,
),
),
),
),
);
}
final section = data.plan.sections[index];
return PlanSectionCard(
section: section,
sectionIndex: index,
plan: data.plan,
availableExercises: data.availableExercises,
);
},
),
error: (e, s) => Center(child: Text('Error: $e')),
loading: () => const Center(child: CircularProgressIndicator()),
const SizedBox(width: UIConstants.spacing16),
// Plan icon badge
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.description_outlined,
color: AppColors.accent,
size: 18,
),
),
const SizedBox(width: UIConstants.spacing12),
// Plan name text field
Expanded(
child: state.maybeWhen(
data: (data) => TextField(
controller: TextEditingController(text: data.plan.name)
..selection = TextSelection.fromPosition(
TextPosition(offset: data.plan.name.length),
),
decoration: const InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: 'Untitled Plan',
contentPadding: EdgeInsets.zero,
),
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: -0.3,
),
onChanged: controller.updatePlanName,
),
orElse: () => Text(
'Loading...',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
),
),
),
),
// Unsaved changes badge + save button
state.maybeWhen(
data: (data) => data.isDirty
? Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.warning.withValues(
alpha: 0.3,
),
),
),
child: Text(
'Unsaved changes',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.warning,
),
),
),
const SizedBox(width: UIConstants.spacing12),
FilledButton.icon(
onPressed: controller.save,
icon: const Icon(Icons.save_outlined, size: 16),
label: const Text('Save'),
),
],
)
: const SizedBox.shrink(),
orElse: () => const SizedBox.shrink(),
),
],
),
),
// --- Body ---
Expanded(
child: state.when(
data: (data) => ReorderableListView.builder(
padding: const EdgeInsets.all(UIConstants.spacing24),
onReorder: controller.reorderSection,
footer: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: UIConstants.spacing16,
),
child: OutlinedButton.icon(
onPressed: controller.addSection,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add Section'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
side: const BorderSide(color: AppColors.border),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
),
),
),
),
),
itemCount: data.plan.sections.length,
itemBuilder: (context, index) {
final section = data.plan.sections[index];
return PlanSectionCard(
key: ValueKey(section.id),
section: section,
sectionIndex: index,
plan: data.plan,
availableExercises: data.availableExercises,
);
},
),
error: (e, s) => Center(
child: Text(
'Error: $e',
style: const TextStyle(color: AppColors.destructive),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
],
),
);
}

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/presentation/plan_editor/models/exercise_drag_data.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
class PlanExerciseTile extends ConsumerWidget {
class PlanExerciseTile extends ConsumerStatefulWidget {
final TrainingExerciseEntity exercise;
final int sectionIndex;
final int exerciseIndex;
@@ -19,100 +23,192 @@ class PlanExerciseTile extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
ConsumerState<PlanExerciseTile> createState() => _PlanExerciseTileState();
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 0,
color: Theme.of(context).colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
class _PlanExerciseTileState extends ConsumerState<PlanExerciseTile> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final controller = ref.read(
planEditorControllerProvider(widget.plan.id).notifier,
);
final dragData = ExerciseDragData(
fromSectionIndex: widget.sectionIndex,
exerciseIndex: widget.exerciseIndex,
exercise: widget.exercise,
);
return LongPressDraggable<ExerciseDragData>(
data: dragData,
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 220,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.zinc800,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.accent.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.drag_indicator,
size: 15,
color: AppColors.accent,
),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.exercise.name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.35,
child: _buildContent(controller),
),
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: _buildContent(controller),
),
);
}
Widget _buildContent(PlanEditorController controller) {
return AnimatedContainer(
duration: UIConstants.animationDuration,
margin: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: _isHovered ? AppColors.zinc800 : AppColors.zinc900,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isHovered ? AppColors.zinc700 : AppColors.border,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
children: [
// --- Header: drag handle + name + delete ---
Row(
children: [
const Icon(
Icons.drag_handle,
size: 15,
color: AppColors.zinc600,
),
const SizedBox(width: 8),
Expanded(
child: Text(
exercise.name,
style: const TextStyle(fontWeight: FontWeight.bold),
widget.exercise.name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => controller.removeExerciseFromSection(
sectionIndex,
exerciseIndex,
AnimatedOpacity(
opacity: _isHovered ? 1.0 : 0.0,
duration: UIConstants.animationDuration,
child: GestureDetector(
onTap: () => controller.removeExerciseFromSection(
widget.sectionIndex,
widget.exerciseIndex,
),
child: const Padding(
padding: EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 14,
color: AppColors.textMuted,
),
),
),
),
],
),
const SizedBox(height: 8),
// --- Fields row ---
Row(
children: [
_buildNumberInput(
context,
_FieldBox(
label: 'Sets',
value: exercise.sets,
value: widget.exercise.sets,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
widget.sectionIndex,
widget.exerciseIndex,
sets: val,
),
),
const SizedBox(width: 8),
_buildNumberInput(
context,
label: exercise.isTime ? 'Secs' : 'Reps',
value: exercise.value,
_FieldBox(
label: widget.exercise.isTime ? 'Secs' : 'Reps',
value: widget.exercise.value,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
widget.sectionIndex,
widget.exerciseIndex,
value: val,
),
),
const SizedBox(width: 8),
_buildNumberInput(
context,
_FieldBox(
label: 'Rest(s)',
value: exercise.rest,
value: widget.exercise.rest,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
widget.sectionIndex,
widget.exerciseIndex,
rest: val,
),
),
const SizedBox(width: 8),
// Toggle Time/Reps
InkWell(
// Time toggle pill
GestureDetector(
onTap: () => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
isTime: !exercise.isTime,
widget.sectionIndex,
widget.exerciseIndex,
isTime: !widget.exercise.isTime,
),
child: Container(
padding: const EdgeInsets.all(8),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
decoration: BoxDecoration(
color: widget.exercise.isTime
? AppColors.accentMuted
: AppColors.zinc800,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
color: widget.exercise.isTime
? AppColors.accent.withValues(alpha: 0.4)
: AppColors.border,
),
borderRadius: BorderRadius.circular(4),
color: exercise.isTime
? Theme.of(context).colorScheme.primaryContainer
: null,
),
child: Icon(
Icons.timer,
size: 16,
color: exercise.isTime
? Theme.of(context).colorScheme.onPrimaryContainer
: null,
Icons.timer_outlined,
size: 15,
color: widget.exercise.isTime
? AppColors.accent
: AppColors.textMuted,
),
),
),
@@ -123,38 +219,75 @@ class PlanExerciseTile extends ConsumerWidget {
),
);
}
}
Widget _buildNumberInput(
BuildContext context, {
required String label,
required int value,
required Function(int) onChanged,
}) {
// ---------------------------------------------------------------------------
// Compact field box (label + number input)
// ---------------------------------------------------------------------------
class _FieldBox extends StatelessWidget {
final String label;
final int value;
final Function(int) onChanged;
const _FieldBox({
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
Text(
label,
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
letterSpacing: 0.3,
),
),
const SizedBox(height: 3),
SizedBox(
height: 40,
height: 32,
child: TextField(
controller: TextEditingController(text: value.toString())
..selection = TextSelection.fromPosition(
TextPosition(offset: value.toString().length),
),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
filled: true,
fillColor: AppColors.zinc950,
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 0,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: AppColors.zinc500),
),
),
onChanged: (val) {
final intVal = int.tryParse(val);
if (intVal != null) {
onChanged(intVal);
}
if (intVal != null) onChanged(intVal);
},
),
),

View File

@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/presentation/plan_editor/models/exercise_drag_data.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_exercise_tile.dart';
class PlanSectionCard extends ConsumerWidget {
class PlanSectionCard extends ConsumerStatefulWidget {
final TrainingSectionEntity section;
final int sectionIndex;
final TrainingPlanEntity plan;
@@ -21,73 +25,251 @@ class PlanSectionCard extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
ConsumerState<PlanSectionCard> createState() => _PlanSectionCardState();
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
class _PlanSectionCardState extends ConsumerState<PlanSectionCard> {
bool _isDragOver = false;
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final controller = ref.read(
planEditorControllerProvider(widget.plan.id).notifier,
);
return DragTarget<ExerciseDragData>(
onWillAcceptWithDetails: (details) =>
details.data.fromSectionIndex != widget.sectionIndex,
onAcceptWithDetails: (details) {
controller.moveExerciseBetweenSections(
fromSectionIndex: details.data.fromSectionIndex,
exerciseIndex: details.data.exerciseIndex,
toSectionIndex: widget.sectionIndex,
);
setState(() => _isDragOver = false);
},
onLeave: (_) => setState(() => _isDragOver = false),
onMove: (_) => setState(() => _isDragOver = true),
builder: (context, candidateData, rejectedData) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
margin: const EdgeInsets.only(bottom: UIConstants.spacing16),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isDragOver ? AppColors.accent : AppColors.border,
width: _isDragOver ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: TextEditingController(text: section.name)
..selection = TextSelection.fromPosition(
TextPosition(offset: section.name.length),
// --- Section header ---
Padding(
padding: const EdgeInsets.fromLTRB(
UIConstants.spacing16,
UIConstants.spacing12,
UIConstants.spacing8,
UIConstants.spacing12,
),
child: Row(
children: [
const Icon(
Icons.drag_handle,
color: AppColors.zinc600,
size: 18,
),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Section Name',
isDense: true,
),
style: Theme.of(context).textTheme.titleMedium,
onChanged: (val) =>
controller.updateSectionName(sectionIndex, val),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: TextField(
controller: TextEditingController(
text: widget.section.name,
)..selection = TextSelection.fromPosition(
TextPosition(
offset: widget.section.name.length,
),
),
decoration: const InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: 'Section name',
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
onChanged: (val) => controller.updateSectionName(
widget.sectionIndex,
val,
),
),
),
// Exercise count badge
if (widget.section.exercises.isNotEmpty)
Container(
margin: const EdgeInsets.only(
right: UIConstants.spacing8,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.zinc800,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${widget.section.exercises.length}',
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
),
),
),
// Delete button — shows on hover
AnimatedOpacity(
opacity: _isHovered ? 1.0 : 0.0,
duration: UIConstants.animationDuration,
child: Material(
color: AppColors.destructive.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: () =>
controller.deleteSection(widget.sectionIndex),
borderRadius: BorderRadius.circular(6),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.delete_outline,
color: AppColors.destructive,
size: 15,
),
),
),
),
),
const SizedBox(width: UIConstants.spacing4),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => controller.deleteSection(sectionIndex),
const Divider(height: 1),
// --- Drop zone indicator ---
if (_isDragOver)
Container(
height: 48,
margin: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing8,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.accent.withValues(alpha: 0.5),
),
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.add_circle_outline,
color: AppColors.accent,
size: 15,
),
const SizedBox(width: 6),
Text(
'Drop exercise here',
style: GoogleFonts.inter(
color: AppColors.accent,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
// --- Exercise list ---
if (widget.section.exercises.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing8,
),
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.section.exercises.length,
onReorder: (oldIndex, newIndex) =>
controller.reorderExercise(
widget.sectionIndex,
oldIndex,
newIndex,
),
itemBuilder: (context, index) {
final exercise = widget.section.exercises[index];
return PlanExerciseTile(
key: ValueKey(exercise.instanceId),
exercise: exercise,
sectionIndex: widget.sectionIndex,
exerciseIndex: index,
plan: widget.plan,
);
},
),
),
// --- Bottom actions ---
Padding(
padding: const EdgeInsets.fromLTRB(
UIConstants.spacing12,
UIConstants.spacing4,
UIConstants.spacing12,
UIConstants.spacing12,
),
child: Row(
children: [
_ActionButton(
icon: Icons.add,
label: 'Add Exercise',
onTap: () =>
_showExercisePicker(context, (exercise) {
controller.addExerciseToSection(
widget.sectionIndex,
exercise,
);
}),
),
const SizedBox(width: UIConstants.spacing8),
_ActionButton(
icon: Icons.create_outlined,
label: 'Create New',
onTap: () =>
_showCreateExerciseDialog(context, controller),
),
],
),
),
],
),
const Divider(),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: section.exercises.length,
onReorder: (oldIndex, newIndex) =>
controller.reorderExercise(sectionIndex, oldIndex, newIndex),
itemBuilder: (context, index) {
final exercise = section.exercises[index];
return PlanExerciseTile(
key: ValueKey(exercise.instanceId),
exercise: exercise,
sectionIndex: sectionIndex,
exerciseIndex: index,
plan: plan,
);
},
),
const SizedBox(height: 8),
Center(
child: TextButton.icon(
onPressed: () {
_showExercisePicker(context, (exercise) {
controller.addExerciseToSection(sectionIndex, exercise);
});
},
icon: const Icon(Icons.add),
label: const Text('Add Exercise'),
),
),
],
),
),
),
);
},
);
}
@@ -107,22 +289,77 @@ class PlanSectionCard extends ConsumerWidget {
builder: (context, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Select Exercise',
style: Theme.of(context).textTheme.titleLarge,
Container(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
Text(
'Select Exercise',
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const Spacer(),
Text(
'${widget.availableExercises.length} available',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: availableExercises.length,
padding: const EdgeInsets.symmetric(
vertical: UIConstants.spacing8,
),
itemCount: widget.availableExercises.length,
itemBuilder: (context, index) {
final exercise = availableExercises[index];
final exercise = widget.availableExercises[index];
return ListTile(
title: Text(exercise.name),
subtitle: Text(exercise.muscleGroup ?? ''),
contentPadding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: 4,
),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.zinc800,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.fitness_center,
size: 16,
color: AppColors.textMuted,
),
),
title: Text(
exercise.name,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
subtitle: exercise.muscleGroup != null &&
exercise.muscleGroup!.isNotEmpty
? Text(
exercise.muscleGroup!,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
)
: null,
onTap: () {
onSelect(exercise);
Navigator.pop(context);
@@ -137,4 +374,136 @@ class PlanSectionCard extends ConsumerWidget {
),
);
}
void _showCreateExerciseDialog(
BuildContext context,
PlanEditorController controller,
) {
final nameCtrl = TextEditingController();
final instructionsCtrl = TextEditingController();
final tagsCtrl = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Create New Exercise'),
content: SizedBox(
width: UIConstants.dialogWidth,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: 'Name *'),
autofocus: true,
),
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: instructionsCtrl,
decoration: const InputDecoration(labelText: 'Instructions'),
maxLines: 2,
),
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: tagsCtrl,
decoration: const InputDecoration(
labelText: 'Tags (comma separated)',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
if (nameCtrl.text.isEmpty) return;
Navigator.pop(dialogContext);
final exercise = await controller.createExercise(
name: nameCtrl.text,
instructions: instructionsCtrl.text.isEmpty
? null
: instructionsCtrl.text,
tags: tagsCtrl.text.isEmpty ? null : tagsCtrl.text,
);
controller.addExerciseToSection(widget.sectionIndex, exercise);
},
child: const Text('Create & Add'),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Small action button (Add Exercise / Create New)
// ---------------------------------------------------------------------------
class _ActionButton extends StatefulWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
State<_ActionButton> createState() => _ActionButtonState();
}
class _ActionButtonState extends State<_ActionButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: UIConstants.animationDuration,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _isHovered ? AppColors.zinc800 : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: _isHovered ? AppColors.zinc600 : AppColors.border,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.icon,
size: 13,
color: _isHovered
? AppColors.textSecondary
: AppColors.textMuted,
),
const SizedBox(width: 5),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _isHovered
? AppColors.textSecondary
: AppColors.textMuted,
),
),
],
),
),
),
);
}
}