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,64 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
import 'package:trainhub_flutter/presentation/trainings/trainings_state.dart';
part 'trainings_controller.g.dart';
@riverpod
class TrainingsController extends _$TrainingsController {
late final TrainingPlanRepository _planRepo;
late final ExerciseRepository _exerciseRepo;
@override
Future<TrainingsState> build() async {
_planRepo = getIt<TrainingPlanRepository>();
_exerciseRepo = getIt<ExerciseRepository>();
final plans = await _planRepo.getAll();
final exercises = await _exerciseRepo.getAll();
return TrainingsState(plans: plans, exercises: exercises);
}
Future<TrainingPlanEntity> createPlan(String name) async {
final plan = await _planRepo.create(name);
await _reload();
return plan;
}
Future<void> deletePlan(String id) async {
await _planRepo.delete(id);
await _reload();
}
Future<void> addExercise({
required String name,
String? instructions,
String? tags,
String? videoUrl,
}) async {
await _exerciseRepo.create(
name: name,
instructions: instructions,
tags: tags,
videoUrl: videoUrl,
);
await _reload();
}
Future<void> updateExercise(ExerciseEntity exercise) async {
await _exerciseRepo.update(exercise);
await _reload();
}
Future<void> deleteExercise(String id) async {
await _exerciseRepo.delete(id);
await _reload();
}
Future<void> _reload() async {
state = await AsyncValue.guard(() => build());
}
}

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trainings_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$trainingsControllerHash() =>
r'15c54eb8211e3b2549af6ef25a9cb451a7a9988a';
/// See also [TrainingsController].
@ProviderFor(TrainingsController)
final trainingsControllerProvider =
AutoDisposeAsyncNotifierProvider<
TrainingsController,
TrainingsState
>.internal(
TrainingsController.new,
name: r'trainingsControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$trainingsControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$TrainingsController = AutoDisposeAsyncNotifier<TrainingsState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

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,
),
],
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
part 'trainings_state.freezed.dart';
@freezed
class TrainingsState with _$TrainingsState {
const factory TrainingsState({
@Default([]) List<TrainingPlanEntity> plans,
@Default([]) List<ExerciseEntity> exercises,
}) = _TrainingsState;
}

View File

@@ -0,0 +1,192 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'trainings_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$TrainingsState {
List<TrainingPlanEntity> get plans => throw _privateConstructorUsedError;
List<ExerciseEntity> get exercises => throw _privateConstructorUsedError;
/// Create a copy of TrainingsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$TrainingsStateCopyWith<TrainingsState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TrainingsStateCopyWith<$Res> {
factory $TrainingsStateCopyWith(
TrainingsState value,
$Res Function(TrainingsState) then,
) = _$TrainingsStateCopyWithImpl<$Res, TrainingsState>;
@useResult
$Res call({List<TrainingPlanEntity> plans, List<ExerciseEntity> exercises});
}
/// @nodoc
class _$TrainingsStateCopyWithImpl<$Res, $Val extends TrainingsState>
implements $TrainingsStateCopyWith<$Res> {
_$TrainingsStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TrainingsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? plans = null, Object? exercises = null}) {
return _then(
_value.copyWith(
plans: null == plans
? _value.plans
: plans // ignore: cast_nullable_to_non_nullable
as List<TrainingPlanEntity>,
exercises: null == exercises
? _value.exercises
: exercises // ignore: cast_nullable_to_non_nullable
as List<ExerciseEntity>,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$TrainingsStateImplCopyWith<$Res>
implements $TrainingsStateCopyWith<$Res> {
factory _$$TrainingsStateImplCopyWith(
_$TrainingsStateImpl value,
$Res Function(_$TrainingsStateImpl) then,
) = __$$TrainingsStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({List<TrainingPlanEntity> plans, List<ExerciseEntity> exercises});
}
/// @nodoc
class __$$TrainingsStateImplCopyWithImpl<$Res>
extends _$TrainingsStateCopyWithImpl<$Res, _$TrainingsStateImpl>
implements _$$TrainingsStateImplCopyWith<$Res> {
__$$TrainingsStateImplCopyWithImpl(
_$TrainingsStateImpl _value,
$Res Function(_$TrainingsStateImpl) _then,
) : super(_value, _then);
/// Create a copy of TrainingsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? plans = null, Object? exercises = null}) {
return _then(
_$TrainingsStateImpl(
plans: null == plans
? _value._plans
: plans // ignore: cast_nullable_to_non_nullable
as List<TrainingPlanEntity>,
exercises: null == exercises
? _value._exercises
: exercises // ignore: cast_nullable_to_non_nullable
as List<ExerciseEntity>,
),
);
}
}
/// @nodoc
class _$TrainingsStateImpl implements _TrainingsState {
const _$TrainingsStateImpl({
final List<TrainingPlanEntity> plans = const [],
final List<ExerciseEntity> exercises = const [],
}) : _plans = plans,
_exercises = exercises;
final List<TrainingPlanEntity> _plans;
@override
@JsonKey()
List<TrainingPlanEntity> get plans {
if (_plans is EqualUnmodifiableListView) return _plans;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_plans);
}
final List<ExerciseEntity> _exercises;
@override
@JsonKey()
List<ExerciseEntity> get exercises {
if (_exercises is EqualUnmodifiableListView) return _exercises;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_exercises);
}
@override
String toString() {
return 'TrainingsState(plans: $plans, exercises: $exercises)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TrainingsStateImpl &&
const DeepCollectionEquality().equals(other._plans, _plans) &&
const DeepCollectionEquality().equals(
other._exercises,
_exercises,
));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_plans),
const DeepCollectionEquality().hash(_exercises),
);
/// Create a copy of TrainingsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TrainingsStateImplCopyWith<_$TrainingsStateImpl> get copyWith =>
__$$TrainingsStateImplCopyWithImpl<_$TrainingsStateImpl>(
this,
_$identity,
);
}
abstract class _TrainingsState implements TrainingsState {
const factory _TrainingsState({
final List<TrainingPlanEntity> plans,
final List<ExerciseEntity> exercises,
}) = _$TrainingsStateImpl;
@override
List<TrainingPlanEntity> get plans;
@override
List<ExerciseEntity> get exercises;
/// Create a copy of TrainingsState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TrainingsStateImplCopyWith<_$TrainingsStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}