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,177 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
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';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_state.dart';
part 'plan_editor_controller.g.dart';
@riverpod
class PlanEditorController extends _$PlanEditorController {
late final TrainingPlanRepository _planRepo;
@override
Future<PlanEditorState> build(String planId) async {
_planRepo = getIt<TrainingPlanRepository>();
final ExerciseRepository exerciseRepo = getIt<ExerciseRepository>();
final plan = await _planRepo.getById(planId);
final exercises = await exerciseRepo.getAll();
return PlanEditorState(plan: plan, availableExercises: exercises);
}
void updatePlanName(String name) {
final current = state.valueOrNull;
if (current == null) return;
state = AsyncValue.data(
current.copyWith(plan: current.plan.copyWith(name: name), isDirty: true),
);
}
void addSection() {
final current = state.valueOrNull;
if (current == null) return;
final newSection = TrainingSectionEntity(
id: IdGenerator.generate(),
name: 'New Section',
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(
sections: [...current.plan.sections, newSection],
),
isDirty: true,
),
);
}
void deleteSection(int index) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
sections.removeAt(index);
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;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
sections[sectionIndex] = sections[sectionIndex].copyWith(name: name);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void addExerciseToSection(int sectionIndex, ExerciseEntity exercise) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
final newExercise = TrainingExerciseEntity(
instanceId: IdGenerator.generate(),
exerciseId: exercise.id,
name: exercise.name,
);
sections[sectionIndex] = sections[sectionIndex].copyWith(
exercises: [...sections[sectionIndex].exercises, newExercise],
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void removeExerciseFromSection(int sectionIndex, int exerciseIndex) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
final exercises = List<TrainingExerciseEntity>.from(
sections[sectionIndex].exercises,
);
exercises.removeAt(exerciseIndex);
sections[sectionIndex] = sections[sectionIndex].copyWith(
exercises: exercises,
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void updateExerciseParams(
int sectionIndex,
int exerciseIndex, {
int? sets,
int? value,
bool? isTime,
int? rest,
}) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
final exercises = List<TrainingExerciseEntity>.from(
sections[sectionIndex].exercises,
);
exercises[exerciseIndex] = exercises[exerciseIndex].copyWith(
sets: sets ?? exercises[exerciseIndex].sets,
value: value ?? exercises[exerciseIndex].value,
isTime: isTime ?? exercises[exerciseIndex].isTime,
rest: rest ?? exercises[exerciseIndex].rest,
);
sections[sectionIndex] = sections[sectionIndex].copyWith(
exercises: exercises,
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void reorderExercise(int sectionIndex, int oldIndex, int newIndex) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
final exercises = List<TrainingExerciseEntity>.from(
sections[sectionIndex].exercises,
);
if (oldIndex < newIndex) newIndex -= 1;
final item = exercises.removeAt(oldIndex);
exercises.insert(newIndex, item);
sections[sectionIndex] = sections[sectionIndex].copyWith(
exercises: exercises,
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
Future<void> save() async {
final current = state.valueOrNull;
if (current == null) return;
await _planRepo.update(current.plan);
state = AsyncValue.data(current.copyWith(isDirty: false));
}
}

View File

@@ -0,0 +1,175 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'plan_editor_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$planEditorControllerHash() =>
r'4045493829126f28b3a58695b68ade53519c1412';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$PlanEditorController
extends BuildlessAutoDisposeAsyncNotifier<PlanEditorState> {
late final String planId;
FutureOr<PlanEditorState> build(String planId);
}
/// See also [PlanEditorController].
@ProviderFor(PlanEditorController)
const planEditorControllerProvider = PlanEditorControllerFamily();
/// See also [PlanEditorController].
class PlanEditorControllerFamily extends Family<AsyncValue<PlanEditorState>> {
/// See also [PlanEditorController].
const PlanEditorControllerFamily();
/// See also [PlanEditorController].
PlanEditorControllerProvider call(String planId) {
return PlanEditorControllerProvider(planId);
}
@override
PlanEditorControllerProvider getProviderOverride(
covariant PlanEditorControllerProvider provider,
) {
return call(provider.planId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'planEditorControllerProvider';
}
/// See also [PlanEditorController].
class PlanEditorControllerProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
PlanEditorController,
PlanEditorState
> {
/// See also [PlanEditorController].
PlanEditorControllerProvider(String planId)
: this._internal(
() => PlanEditorController()..planId = planId,
from: planEditorControllerProvider,
name: r'planEditorControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$planEditorControllerHash,
dependencies: PlanEditorControllerFamily._dependencies,
allTransitiveDependencies:
PlanEditorControllerFamily._allTransitiveDependencies,
planId: planId,
);
PlanEditorControllerProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.planId,
}) : super.internal();
final String planId;
@override
FutureOr<PlanEditorState> runNotifierBuild(
covariant PlanEditorController notifier,
) {
return notifier.build(planId);
}
@override
Override overrideWith(PlanEditorController Function() create) {
return ProviderOverride(
origin: this,
override: PlanEditorControllerProvider._internal(
() => create()..planId = planId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
planId: planId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<PlanEditorController, PlanEditorState>
createElement() {
return _PlanEditorControllerProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PlanEditorControllerProvider && other.planId == planId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, planId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PlanEditorControllerRef
on AutoDisposeAsyncNotifierProviderRef<PlanEditorState> {
/// The parameter `planId` of this provider.
String get planId;
}
class _PlanEditorControllerProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
PlanEditorController,
PlanEditorState
>
with PlanEditorControllerRef {
_PlanEditorControllerProviderElement(super.provider);
@override
String get planId => (origin as PlanEditorControllerProvider).planId;
}
// 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,80 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
@RoutePage()
class PlanEditorPage extends ConsumerWidget {
final String planId;
const PlanEditorPage({super.key, @PathParam('planId') required this.planId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(planEditorControllerProvider(planId));
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',
),
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,
),
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'),
),
),
);
}
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()),
),
);
}
}

View File

@@ -0,0 +1,14 @@
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 'plan_editor_state.freezed.dart';
@freezed
class PlanEditorState with _$PlanEditorState {
const factory PlanEditorState({
required TrainingPlanEntity plan,
@Default(false) bool isDirty,
@Default([]) List<ExerciseEntity> availableExercises,
}) = _PlanEditorState;
}

View File

@@ -0,0 +1,235 @@
// 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 'plan_editor_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 _$PlanEditorState {
TrainingPlanEntity get plan => throw _privateConstructorUsedError;
bool get isDirty => throw _privateConstructorUsedError;
List<ExerciseEntity> get availableExercises =>
throw _privateConstructorUsedError;
/// Create a copy of PlanEditorState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$PlanEditorStateCopyWith<PlanEditorState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $PlanEditorStateCopyWith<$Res> {
factory $PlanEditorStateCopyWith(
PlanEditorState value,
$Res Function(PlanEditorState) then,
) = _$PlanEditorStateCopyWithImpl<$Res, PlanEditorState>;
@useResult
$Res call({
TrainingPlanEntity plan,
bool isDirty,
List<ExerciseEntity> availableExercises,
});
$TrainingPlanEntityCopyWith<$Res> get plan;
}
/// @nodoc
class _$PlanEditorStateCopyWithImpl<$Res, $Val extends PlanEditorState>
implements $PlanEditorStateCopyWith<$Res> {
_$PlanEditorStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of PlanEditorState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? plan = null,
Object? isDirty = null,
Object? availableExercises = null,
}) {
return _then(
_value.copyWith(
plan: null == plan
? _value.plan
: plan // ignore: cast_nullable_to_non_nullable
as TrainingPlanEntity,
isDirty: null == isDirty
? _value.isDirty
: isDirty // ignore: cast_nullable_to_non_nullable
as bool,
availableExercises: null == availableExercises
? _value.availableExercises
: availableExercises // ignore: cast_nullable_to_non_nullable
as List<ExerciseEntity>,
)
as $Val,
);
}
/// Create a copy of PlanEditorState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$TrainingPlanEntityCopyWith<$Res> get plan {
return $TrainingPlanEntityCopyWith<$Res>(_value.plan, (value) {
return _then(_value.copyWith(plan: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$PlanEditorStateImplCopyWith<$Res>
implements $PlanEditorStateCopyWith<$Res> {
factory _$$PlanEditorStateImplCopyWith(
_$PlanEditorStateImpl value,
$Res Function(_$PlanEditorStateImpl) then,
) = __$$PlanEditorStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
TrainingPlanEntity plan,
bool isDirty,
List<ExerciseEntity> availableExercises,
});
@override
$TrainingPlanEntityCopyWith<$Res> get plan;
}
/// @nodoc
class __$$PlanEditorStateImplCopyWithImpl<$Res>
extends _$PlanEditorStateCopyWithImpl<$Res, _$PlanEditorStateImpl>
implements _$$PlanEditorStateImplCopyWith<$Res> {
__$$PlanEditorStateImplCopyWithImpl(
_$PlanEditorStateImpl _value,
$Res Function(_$PlanEditorStateImpl) _then,
) : super(_value, _then);
/// Create a copy of PlanEditorState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? plan = null,
Object? isDirty = null,
Object? availableExercises = null,
}) {
return _then(
_$PlanEditorStateImpl(
plan: null == plan
? _value.plan
: plan // ignore: cast_nullable_to_non_nullable
as TrainingPlanEntity,
isDirty: null == isDirty
? _value.isDirty
: isDirty // ignore: cast_nullable_to_non_nullable
as bool,
availableExercises: null == availableExercises
? _value._availableExercises
: availableExercises // ignore: cast_nullable_to_non_nullable
as List<ExerciseEntity>,
),
);
}
}
/// @nodoc
class _$PlanEditorStateImpl implements _PlanEditorState {
const _$PlanEditorStateImpl({
required this.plan,
this.isDirty = false,
final List<ExerciseEntity> availableExercises = const [],
}) : _availableExercises = availableExercises;
@override
final TrainingPlanEntity plan;
@override
@JsonKey()
final bool isDirty;
final List<ExerciseEntity> _availableExercises;
@override
@JsonKey()
List<ExerciseEntity> get availableExercises {
if (_availableExercises is EqualUnmodifiableListView)
return _availableExercises;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_availableExercises);
}
@override
String toString() {
return 'PlanEditorState(plan: $plan, isDirty: $isDirty, availableExercises: $availableExercises)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$PlanEditorStateImpl &&
(identical(other.plan, plan) || other.plan == plan) &&
(identical(other.isDirty, isDirty) || other.isDirty == isDirty) &&
const DeepCollectionEquality().equals(
other._availableExercises,
_availableExercises,
));
}
@override
int get hashCode => Object.hash(
runtimeType,
plan,
isDirty,
const DeepCollectionEquality().hash(_availableExercises),
);
/// Create a copy of PlanEditorState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$PlanEditorStateImplCopyWith<_$PlanEditorStateImpl> get copyWith =>
__$$PlanEditorStateImplCopyWithImpl<_$PlanEditorStateImpl>(
this,
_$identity,
);
}
abstract class _PlanEditorState implements PlanEditorState {
const factory _PlanEditorState({
required final TrainingPlanEntity plan,
final bool isDirty,
final List<ExerciseEntity> availableExercises,
}) = _$PlanEditorStateImpl;
@override
TrainingPlanEntity get plan;
@override
bool get isDirty;
@override
List<ExerciseEntity> get availableExercises;
/// Create a copy of PlanEditorState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$PlanEditorStateImplCopyWith<_$PlanEditorStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/plan_editor_controller.dart';
class PlanExerciseTile extends ConsumerWidget {
final TrainingExerciseEntity exercise;
final int sectionIndex;
final int exerciseIndex;
final TrainingPlanEntity plan;
const PlanExerciseTile({
super.key,
required this.exercise,
required this.sectionIndex,
required this.exerciseIndex,
required this.plan,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
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),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
exercise.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => controller.removeExerciseFromSection(
sectionIndex,
exerciseIndex,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
_buildNumberInput(
context,
label: 'Sets',
value: exercise.sets,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
sets: val,
),
),
const SizedBox(width: 8),
_buildNumberInput(
context,
label: exercise.isTime ? 'Secs' : 'Reps',
value: exercise.value,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
value: val,
),
),
const SizedBox(width: 8),
_buildNumberInput(
context,
label: 'Rest(s)',
value: exercise.rest,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
rest: val,
),
),
const SizedBox(width: 8),
// Toggle Time/Reps
InkWell(
onTap: () => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
isTime: !exercise.isTime,
),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
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,
),
),
),
],
),
],
),
),
);
}
Widget _buildNumberInput(
BuildContext context, {
required String label,
required int value,
required Function(int) onChanged,
}) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
SizedBox(
height: 40,
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,
vertical: 0,
),
),
onChanged: (val) {
final intVal = int.tryParse(val);
if (intVal != null) {
onChanged(intVal);
}
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_exercise_tile.dart';
class PlanSectionCard extends ConsumerWidget {
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
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: TextEditingController(text: section.name)
..selection = TextSelection.fromPosition(
TextPosition(offset: section.name.length),
),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Section Name',
isDense: true,
),
style: Theme.of(context).textTheme.titleMedium,
onChanged: (val) =>
controller.updateSectionName(sectionIndex, val),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => controller.deleteSection(sectionIndex),
),
],
),
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'),
),
),
],
),
),
);
}
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: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Select Exercise',
style: Theme.of(context).textTheme.titleLarge,
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: availableExercises.length,
itemBuilder: (context, index) {
final exercise = availableExercises[index];
return ListTile(
title: Text(exercise.name),
subtitle: Text(exercise.muscleGroup ?? ''),
onTap: () {
onSelect(exercise);
Navigator.pop(context);
},
);
},
),
),
],
);
},
),
);
}
}