510 lines
19 KiB
Dart
510 lines
19 KiB
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/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 ConsumerStatefulWidget {
|
|
final TrainingSectionEntity section;
|
|
final int sectionIndex;
|
|
final TrainingPlanEntity plan;
|
|
final List<ExerciseEntity> availableExercises;
|
|
|
|
const PlanSectionCard({
|
|
super.key,
|
|
required this.section,
|
|
required this.sectionIndex,
|
|
required this.plan,
|
|
required this.availableExercises,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PlanSectionCard> createState() => _PlanSectionCardState();
|
|
}
|
|
|
|
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: [
|
|
// --- 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,
|
|
),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showExercisePicker(
|
|
BuildContext context,
|
|
Function(ExerciseEntity) onSelect,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (context) => DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.7,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
builder: (context, scrollController) {
|
|
return Column(
|
|
children: [
|
|
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,
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: UIConstants.spacing8,
|
|
),
|
|
itemCount: widget.availableExercises.length,
|
|
itemBuilder: (context, index) {
|
|
final exercise = widget.availableExercises[index];
|
|
return ListTile(
|
|
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);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|