465 lines
13 KiB
Dart
465 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
|
import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
|
import 'package:trainhub_flutter/domain/entities/program_week.dart';
|
|
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
|
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
|
|
|
class ProgramWeekView extends StatelessWidget {
|
|
final ProgramWeekEntity week;
|
|
final List<ProgramWorkoutEntity> workouts;
|
|
final List<TrainingPlanEntity> availablePlans;
|
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
|
final Function(String) onDeleteWorkout;
|
|
|
|
const ProgramWeekView({
|
|
super.key,
|
|
required this.week,
|
|
required this.workouts,
|
|
required this.availablePlans,
|
|
required this.onAddWorkout,
|
|
required this.onDeleteWorkout,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 24),
|
|
elevation: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Week ${week.position}',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
const Divider(),
|
|
SizedBox(
|
|
height: 500,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: List.generate(7, (dayIndex) {
|
|
final dayNum = dayIndex + 1;
|
|
final dayWorkouts = workouts
|
|
.where((w) => w.day == dayNum.toString())
|
|
.toList();
|
|
|
|
return Expanded(
|
|
child: _DayColumn(
|
|
dayNum: dayNum,
|
|
dayIndex: dayIndex,
|
|
dayWorkouts: dayWorkouts,
|
|
availablePlans: availablePlans,
|
|
week: week,
|
|
onAddWorkout: onAddWorkout,
|
|
onDeleteWorkout: onDeleteWorkout,
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DayColumn extends StatelessWidget {
|
|
final int dayNum;
|
|
final int dayIndex;
|
|
final List<ProgramWorkoutEntity> dayWorkouts;
|
|
final List<TrainingPlanEntity> availablePlans;
|
|
final ProgramWeekEntity week;
|
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
|
final Function(String) onDeleteWorkout;
|
|
|
|
const _DayColumn({
|
|
required this.dayNum,
|
|
required this.dayIndex,
|
|
required this.dayWorkouts,
|
|
required this.availablePlans,
|
|
required this.week,
|
|
required this.onAddWorkout,
|
|
required this.onDeleteWorkout,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: dayIndex < 6
|
|
? const Border(
|
|
right: BorderSide(color: Colors.grey, width: 0.5),
|
|
)
|
|
: null,
|
|
color: dayIndex % 2 == 0
|
|
? Theme.of(context).colorScheme.surfaceContainerLow
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
|
border: const Border(
|
|
bottom: BorderSide(color: Colors.grey, width: 0.5),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
_getDayName(dayNum),
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
InkWell(
|
|
onTap: () => _showAddWorkoutSheet(context),
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: const Icon(Icons.add_circle_outline, size: 20),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(4),
|
|
child: Column(
|
|
children: [
|
|
if (dayWorkouts.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16.0),
|
|
child: Center(
|
|
child: Text(
|
|
'Rest',
|
|
style: TextStyle(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
...dayWorkouts.map(
|
|
(workout) => _WorkoutCard(
|
|
workout: workout,
|
|
onDelete: () => onDeleteWorkout(workout.id),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _getDayName(int day) {
|
|
const days = [
|
|
'Mon',
|
|
'Tue',
|
|
'Wed',
|
|
'Thu',
|
|
'Fri',
|
|
'Sat',
|
|
'Sun',
|
|
];
|
|
if (day >= 1 && day <= 7) return days[day - 1];
|
|
return 'Day $day';
|
|
}
|
|
|
|
void _showAddWorkoutSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => _AddWorkoutSheet(
|
|
availablePlans: availablePlans,
|
|
week: week,
|
|
dayNum: dayNum,
|
|
onAddWorkout: (workout) {
|
|
onAddWorkout(workout);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WorkoutCard extends StatelessWidget {
|
|
final ProgramWorkoutEntity workout;
|
|
final VoidCallback onDelete;
|
|
|
|
const _WorkoutCard({required this.workout, required this.onDelete});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isNote = workout.type == 'note';
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 4),
|
|
color: isNote
|
|
? AppColors.info.withValues(alpha: 0.08)
|
|
: null,
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 0,
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
leading: Icon(
|
|
isNote ? Icons.sticky_note_2_outlined : Icons.fitness_center,
|
|
size: 14,
|
|
color: isNote ? AppColors.info : AppColors.textMuted,
|
|
),
|
|
title: Text(
|
|
workout.name ?? 'Untitled',
|
|
style: const TextStyle(fontSize: 12),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: workout.description != null && workout.description!.isNotEmpty
|
|
? Text(
|
|
workout.description!,
|
|
style: const TextStyle(fontSize: 10),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
trailing: InkWell(
|
|
onTap: onDelete,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: const Icon(Icons.close, size: 14),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AddWorkoutSheet extends StatefulWidget {
|
|
final List<TrainingPlanEntity> availablePlans;
|
|
final ProgramWeekEntity week;
|
|
final int dayNum;
|
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
|
|
|
const _AddWorkoutSheet({
|
|
required this.availablePlans,
|
|
required this.week,
|
|
required this.dayNum,
|
|
required this.onAddWorkout,
|
|
});
|
|
|
|
@override
|
|
State<_AddWorkoutSheet> createState() => _AddWorkoutSheetState();
|
|
}
|
|
|
|
class _AddWorkoutSheetState extends State<_AddWorkoutSheet>
|
|
with SingleTickerProviderStateMixin {
|
|
late final TabController _tabController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
|
child: Text(
|
|
'Add to Schedule',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(
|
|
icon: Icon(Icons.fitness_center, size: 16),
|
|
text: 'Training Plan',
|
|
),
|
|
Tab(
|
|
icon: Icon(Icons.sticky_note_2_outlined, size: 16),
|
|
text: 'Note',
|
|
),
|
|
],
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_PlanPickerTab(
|
|
availablePlans: widget.availablePlans,
|
|
week: widget.week,
|
|
dayNum: widget.dayNum,
|
|
onAddWorkout: widget.onAddWorkout,
|
|
),
|
|
_NoteTab(
|
|
week: widget.week,
|
|
dayNum: widget.dayNum,
|
|
onAddWorkout: widget.onAddWorkout,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlanPickerTab extends StatelessWidget {
|
|
final List<TrainingPlanEntity> availablePlans;
|
|
final ProgramWeekEntity week;
|
|
final int dayNum;
|
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
|
|
|
const _PlanPickerTab({
|
|
required this.availablePlans,
|
|
required this.week,
|
|
required this.dayNum,
|
|
required this.onAddWorkout,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (availablePlans.isEmpty) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(16.0),
|
|
child: Center(
|
|
child: Text('No training plans available. Create one first!'),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: availablePlans.length,
|
|
itemBuilder: (context, index) {
|
|
final plan = availablePlans[index];
|
|
return ListTile(
|
|
leading: const Icon(Icons.fitness_center),
|
|
title: Text(plan.name),
|
|
subtitle: Text('${plan.totalExercises} exercises'),
|
|
onTap: () {
|
|
final newWorkout = ProgramWorkoutEntity(
|
|
id: IdGenerator.generate(),
|
|
programId: week.programId,
|
|
weekId: week.id,
|
|
day: dayNum.toString(),
|
|
type: 'workout',
|
|
name: plan.name,
|
|
refId: plan.id,
|
|
description: '${plan.sections.length} sections',
|
|
completed: false,
|
|
);
|
|
onAddWorkout(newWorkout);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NoteTab extends StatefulWidget {
|
|
final ProgramWeekEntity week;
|
|
final int dayNum;
|
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
|
|
|
const _NoteTab({
|
|
required this.week,
|
|
required this.dayNum,
|
|
required this.onAddWorkout,
|
|
});
|
|
|
|
@override
|
|
State<_NoteTab> createState() => _NoteTabState();
|
|
}
|
|
|
|
class _NoteTabState extends State<_NoteTab> {
|
|
final TextEditingController _titleController = TextEditingController();
|
|
final TextEditingController _contentController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_contentController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(UIConstants.spacing16),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Title',
|
|
hintText: 'e.g. Active Recovery',
|
|
),
|
|
autofocus: true,
|
|
),
|
|
const SizedBox(height: UIConstants.spacing12),
|
|
TextField(
|
|
controller: _contentController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Note (optional)',
|
|
hintText: 'Additional details...',
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: UIConstants.spacing16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: () {
|
|
if (_titleController.text.isEmpty) return;
|
|
final newWorkout = ProgramWorkoutEntity(
|
|
id: IdGenerator.generate(),
|
|
programId: widget.week.programId,
|
|
weekId: widget.week.id,
|
|
day: widget.dayNum.toString(),
|
|
type: 'note',
|
|
name: _titleController.text,
|
|
description: _contentController.text.isEmpty
|
|
? null
|
|
: _contentController.text,
|
|
completed: false,
|
|
);
|
|
widget.onAddWorkout(newWorkout);
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Add Note'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|