Initial commit

This commit is contained in:
Kazimierz Ciołek
2026-02-19 02:49:29 +01:00
commit 782986a632
148 changed files with 29230 additions and 0 deletions

View File

@@ -0,0 +1,493 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/router/app_router.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/confirm_dialog.dart';
@RoutePage()
class TrainingsPage extends ConsumerWidget {
const TrainingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncState = ref.watch(trainingsControllerProvider);
return DefaultTabController(
length: 2,
child: Column(
children: [
Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
border: const Border(bottom: BorderSide(color: AppColors.border)),
),
child: const TabBar(
tabs: [
Tab(text: 'Training Plans'),
Tab(text: 'Exercises'),
],
),
),
Expanded(
child: asyncState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error: $err')),
data: (state) => TabBarView(
children: [
_PlansTab(plans: state.plans, ref: ref),
_ExercisesTab(exercises: state.exercises, ref: ref),
],
),
),
),
],
),
);
}
}
class _PlansTab extends StatelessWidget {
final List<TrainingPlanEntity> plans;
final WidgetRef ref;
const _PlansTab({required this.plans, required this.ref});
@override
Widget build(BuildContext context) {
if (plans.isEmpty) {
return AppEmptyState(
icon: Icons.fitness_center,
title: 'No training plans yet',
subtitle: 'Create your first training plan to get started',
actionLabel: 'Create Plan',
onAction: () => _createPlan(context),
);
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(UIConstants.spacing16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton.icon(
onPressed: () => _createPlan(context),
icon: const Icon(Icons.add, size: 18),
label: const Text('New Plan'),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
),
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
return _PlanListItem(
plan: plan,
onEdit: () {
context.router.push(PlanEditorRoute(planId: plan.id));
},
onStart: () {
context.router.push(WorkoutSessionRoute(planId: plan.id));
},
onDelete: () => _deletePlan(context, plan),
);
},
),
),
],
);
}
Future<void> _createPlan(BuildContext context) async {
final name = await TextInputDialog.show(
context,
title: 'New Plan Name',
hintText: 'e.g. Push Pull Legs',
);
if (name != null && name.isNotEmpty) {
final plan = await ref
.read(trainingsControllerProvider.notifier)
.createPlan(name);
if (context.mounted) {
context.router.push(PlanEditorRoute(planId: plan.id));
}
}
}
Future<void> _deletePlan(
BuildContext context,
TrainingPlanEntity plan,
) async {
final confirmed = await ConfirmDialog.show(
context,
title: 'Delete Plan?',
message: 'Are you sure you want to delete "${plan.name}"?',
);
if (confirmed == true) {
ref.read(trainingsControllerProvider.notifier).deletePlan(plan.id);
}
}
}
class _PlanListItem extends StatefulWidget {
final TrainingPlanEntity plan;
final VoidCallback onEdit;
final VoidCallback onStart;
final VoidCallback onDelete;
const _PlanListItem({
required this.plan,
required this.onEdit,
required this.onStart,
required this.onDelete,
});
@override
State<_PlanListItem> createState() => _PlanListItemState();
}
class _PlanListItemState extends State<_PlanListItem> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Card(
margin: const EdgeInsets.only(bottom: UIConstants.spacing8),
child: Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.description_outlined,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.plan.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
'${widget.plan.sections.length} sections, ${widget.plan.totalExercises} exercises',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
],
),
),
if (_isHovered) ...[
IconButton(
icon: const Icon(
Icons.delete_outline,
color: AppColors.destructive,
size: 18,
),
onPressed: widget.onDelete,
tooltip: 'Delete',
),
const SizedBox(width: 4),
],
OutlinedButton(
onPressed: widget.onEdit,
child: const Text('Edit'),
),
const SizedBox(width: UIConstants.spacing8),
FilledButton.icon(
onPressed: widget.onStart,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Start'),
),
],
),
),
),
);
}
}
class _ExercisesTab extends StatelessWidget {
final List<ExerciseEntity> exercises;
final WidgetRef ref;
const _ExercisesTab({required this.exercises, required this.ref});
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showExerciseDialog(context),
backgroundColor: AppColors.zinc50,
foregroundColor: AppColors.zinc950,
child: const Icon(Icons.add),
),
body: exercises.isEmpty
? const AppEmptyState(
icon: Icons.fitness_center,
title: 'No exercises yet',
subtitle: 'Add exercises to use in your training plans',
)
: GridView.builder(
padding: const EdgeInsets.all(UIConstants.spacing16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3.0,
crossAxisSpacing: UIConstants.spacing12,
mainAxisSpacing: UIConstants.spacing12,
),
itemCount: exercises.length,
itemBuilder: (context, index) {
final exercise = exercises[index];
return _ExerciseCard(
exercise: exercise,
onEdit: () =>
_showExerciseDialog(context, exercise: exercise),
onDelete: () => ref
.read(trainingsControllerProvider.notifier)
.deleteExercise(exercise.id),
);
},
),
);
}
void _showExerciseDialog(BuildContext context, {ExerciseEntity? exercise}) {
final nameCtrl = TextEditingController(text: exercise?.name);
final instructionsCtrl = TextEditingController(
text: exercise?.instructions,
);
final tagsCtrl = TextEditingController(text: exercise?.tags);
final videoUrlCtrl = TextEditingController(text: exercise?.videoUrl);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(exercise == null ? 'New Exercise' : 'Edit 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)',
),
),
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: videoUrlCtrl,
decoration: const InputDecoration(labelText: 'Video URL'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (nameCtrl.text.isEmpty) return;
if (exercise == null) {
ref
.read(trainingsControllerProvider.notifier)
.addExercise(
name: nameCtrl.text,
instructions: instructionsCtrl.text,
tags: tagsCtrl.text,
videoUrl: videoUrlCtrl.text,
);
} else {
ref
.read(trainingsControllerProvider.notifier)
.updateExercise(
exercise.copyWith(
name: nameCtrl.text,
instructions: instructionsCtrl.text,
tags: tagsCtrl.text,
videoUrl: videoUrlCtrl.text,
),
);
}
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
}
class _ExerciseCard extends StatefulWidget {
final ExerciseEntity exercise;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _ExerciseCard({
required this.exercise,
required this.onEdit,
required this.onDelete,
});
@override
State<_ExerciseCard> createState() => _ExerciseCardState();
}
class _ExerciseCardState extends State<_ExerciseCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Card(
child: InkWell(
onTap: widget.onEdit,
borderRadius: UIConstants.cardBorderRadius,
child: Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.exercise.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.exercise.instructions != null &&
widget.exercise.instructions!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.exercise.instructions!,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (widget.exercise.tags != null &&
widget.exercise.tags!.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
children: widget.exercise.tags!
.split(',')
.take(3)
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.zinc800,
borderRadius: BorderRadius.circular(4),
),
child: Text(
tag.trim(),
style: const TextStyle(
fontSize: 10,
color: AppColors.textSecondary,
),
),
),
)
.toList(),
),
],
],
),
),
if (widget.exercise.videoUrl != null &&
widget.exercise.videoUrl!.isNotEmpty)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.videocam,
size: 16,
color: AppColors.info,
),
),
if (_isHovered) ...[
IconButton(
icon: const Icon(Icons.edit, size: 16),
onPressed: widget.onEdit,
),
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 16,
color: AppColors.destructive,
),
onPressed: widget.onDelete,
),
],
],
),
),
),
),
);
}
}