527 lines
20 KiB
Dart
527 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import '../models/training_plan.dart';
|
|
import '../services/training_plan_service.dart';
|
|
|
|
const _green = Color(0xFF456C52);
|
|
const _ink = Color(0xFF17251E);
|
|
const _muted = Color(0xFF778078);
|
|
|
|
class TrainingLibraryScreen extends StatefulWidget {
|
|
const TrainingLibraryScreen({super.key});
|
|
|
|
@override
|
|
State<TrainingLibraryScreen> createState() => _TrainingLibraryScreenState();
|
|
}
|
|
|
|
class _TrainingLibraryScreenState extends State<TrainingLibraryScreen> {
|
|
late Future<TrainingCatalogResult> _plans;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_plans = Get.find<TrainingPlanService>().fetchPlans();
|
|
}
|
|
|
|
void _reload() => setState(() {
|
|
_plans = Get.find<TrainingPlanService>().fetchPlans(refresh: true);
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Scaffold(
|
|
appBar: AppBar(title: const Text('خطط التمرين')),
|
|
body: FutureBuilder<TrainingCatalogResult>(
|
|
future: _plans,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
final result = snapshot.data;
|
|
if (result == null) {
|
|
return _CatalogMessage(
|
|
message: 'تعذر تحميل مكتبة التمارين.',
|
|
onRetry: _reload,
|
|
);
|
|
}
|
|
if (result.plans.isEmpty) {
|
|
return _CatalogMessage(
|
|
message: result.message ??
|
|
'لا توجد خطط منشورة بعد. ستظهر الخطط هنا بعد إضافتها من لوحة الإدارة.',
|
|
onRetry: _reload,
|
|
);
|
|
}
|
|
return ListView(
|
|
padding: const EdgeInsets.all(18),
|
|
children: [
|
|
if (result.fromCache)
|
|
const _CacheNotice(
|
|
message: 'نعرض نسخة محفوظة على هذا الجهاز.'),
|
|
if (result.message != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Text(result.message!,
|
|
style: const TextStyle(color: _muted, height: 1.5)),
|
|
),
|
|
for (final plan in result.plans)
|
|
_PlanCard(
|
|
plan: plan,
|
|
onTap: () =>
|
|
Get.to(() => TrainingPlanDetailScreen(plan: plan)),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
class TrainingPlanSpotlight extends StatefulWidget {
|
|
const TrainingPlanSpotlight({super.key});
|
|
|
|
@override
|
|
State<TrainingPlanSpotlight> createState() => _TrainingPlanSpotlightState();
|
|
}
|
|
|
|
class _TrainingPlanSpotlightState extends State<TrainingPlanSpotlight> {
|
|
late Future<TrainingCatalogResult> _plans;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_plans = Get.find<TrainingPlanService>().fetchPlans();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => FutureBuilder<TrainingCatalogResult>(
|
|
future: _plans,
|
|
builder: (context, snapshot) {
|
|
final result = snapshot.data;
|
|
final plan =
|
|
result?.plans.isNotEmpty == true ? result!.plans.first : null;
|
|
return Container(
|
|
padding: const EdgeInsets.all(19),
|
|
decoration: BoxDecoration(
|
|
color: _green,
|
|
borderRadius: BorderRadius.circular(21),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
const Icon(Icons.fitness_center, color: Colors.white70),
|
|
const SizedBox(width: 9),
|
|
Expanded(
|
|
child: Text(
|
|
plan?.title ?? 'خطتك التدريبية',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (result?.fromCache == true)
|
|
const Icon(Icons.offline_bolt_outlined,
|
|
color: Colors.white70, size: 19),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
plan == null
|
|
? 'استعرض البرامج والجلسات والشرح المصوّر للحركات.'
|
|
: plan.summary.isNotEmpty
|
|
? plan.summary
|
|
: '${plan.weeksDuration} أسابيع • ${plan.sessions.length} جلسة',
|
|
style: const TextStyle(
|
|
color: Color(0xFFDCE8DA), fontSize: 12, height: 1.6),
|
|
),
|
|
const SizedBox(height: 13),
|
|
Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: TextButton.icon(
|
|
onPressed: () =>
|
|
Get.to(() => const TrainingLibraryScreen()),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: _green, backgroundColor: Colors.white),
|
|
icon: const Icon(Icons.arrow_forward, size: 16),
|
|
label:
|
|
Text(plan == null ? 'فتح مكتبة التمارين' : 'عرض الخطة'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class TrainingPlanDetailScreen extends StatelessWidget {
|
|
const TrainingPlanDetailScreen({super.key, required this.plan});
|
|
final TrainingPlan plan;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final sessions = [...plan.sessions]..sort((a, b) =>
|
|
a.week != b.week ? a.week.compareTo(b.week) : a.day.compareTo(b.day));
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('تفاصيل الخطة')),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(18, 8, 18, 28),
|
|
children: [
|
|
Text(plan.title,
|
|
style: const TextStyle(
|
|
color: _ink, fontSize: 25, fontWeight: FontWeight.w700)),
|
|
if (plan.summary.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(plan.summary,
|
|
style: const TextStyle(color: _muted, height: 1.6)),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Wrap(spacing: 8, runSpacing: 8, children: [
|
|
_InfoChip('${plan.weeksDuration} أسابيع'),
|
|
_InfoChip(_levelLabel(plan.level)),
|
|
if (plan.goal.isNotEmpty) _InfoChip(_goalLabel(plan.goal)),
|
|
]),
|
|
const SizedBox(height: 22),
|
|
if (sessions.isEmpty)
|
|
const Text('لم تتم إضافة جلسات لهذه الخطة بعد.',
|
|
style: TextStyle(color: _muted))
|
|
else
|
|
for (final session in sessions) _SessionCard(session: session),
|
|
if (plan.safetyNotes.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_NotesBox(title: 'ملاحظات السلامة', notes: plan.safetyNotes),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SessionCard extends StatelessWidget {
|
|
const _SessionCard({required this.session});
|
|
final TrainingSession session;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Card(
|
|
margin: const EdgeInsets.only(bottom: 11),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(15),
|
|
child:
|
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text('الأسبوع ${session.week} • اليوم ${session.day}',
|
|
style: const TextStyle(color: _green, fontSize: 11)),
|
|
const SizedBox(height: 4),
|
|
Text(session.title,
|
|
style: const TextStyle(
|
|
color: _ink, fontSize: 16, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
'${session.durationMinutes} دقيقة • ${_intensityLabel(session.intensity)}',
|
|
style: const TextStyle(color: _muted, fontSize: 12)),
|
|
if (session.notes?.isNotEmpty == true) ...[
|
|
const SizedBox(height: 7),
|
|
Text(session.notes!,
|
|
style: const TextStyle(color: _muted, height: 1.5)),
|
|
],
|
|
if (session.exercises.isNotEmpty) ...[
|
|
const Divider(height: 22),
|
|
for (final exercise in session.exercises)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const CircleAvatar(
|
|
backgroundColor: Color(0xFFE8EFE5),
|
|
child: Icon(Icons.play_arrow, color: _green)),
|
|
title: Text(exercise.title,
|
|
style: const TextStyle(
|
|
color: _ink, fontWeight: FontWeight.w600)),
|
|
subtitle: Text(_prescription(exercise),
|
|
style: const TextStyle(color: _muted)),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => Get.to(
|
|
() => TrainingExerciseDetailScreen(exercise: exercise)),
|
|
),
|
|
],
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
class TrainingExerciseDetailScreen extends StatelessWidget {
|
|
const TrainingExerciseDetailScreen({super.key, required this.exercise});
|
|
final TrainingExercise exercise;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final gif = _httpsUri(exercise.gifUrl);
|
|
final poster = _httpsUri(exercise.posterUrl);
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('شرح الحركة')),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(18, 8, 18, 28),
|
|
children: [
|
|
Text(exercise.title,
|
|
style: const TextStyle(
|
|
color: _ink, fontSize: 24, fontWeight: FontWeight.w700)),
|
|
const SizedBox(height: 14),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: AspectRatio(
|
|
aspectRatio: 4 / 3,
|
|
child: gif == null
|
|
? _GifPlaceholder(poster: poster, title: exercise.title)
|
|
: Image.network(
|
|
gif.toString(),
|
|
fit: BoxFit.contain,
|
|
semanticLabel: 'رسم متحرك لشرح ${exercise.title}',
|
|
loadingBuilder: (context, child, progress) =>
|
|
progress == null
|
|
? child
|
|
: _GifPlaceholder(
|
|
poster: poster, title: exercise.title),
|
|
errorBuilder: (context, error, stack) =>
|
|
_GifPlaceholder(
|
|
poster: poster, title: exercise.title),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Wrap(spacing: 7, runSpacing: 7, children: [
|
|
if (exercise.prescription.sets != null)
|
|
_InfoChip('${exercise.prescription.sets} مجموعات'),
|
|
if (exercise.prescription.reps?.isNotEmpty == true)
|
|
_InfoChip('${exercise.prescription.reps} تكرار'),
|
|
if (exercise.prescription.durationSeconds != null)
|
|
_InfoChip('${exercise.prescription.durationSeconds} ثانية'),
|
|
if (exercise.prescription.restSeconds > 0)
|
|
_InfoChip('راحة ${exercise.prescription.restSeconds} ث'),
|
|
]),
|
|
if (exercise.instructions.isNotEmpty) ...[
|
|
const SizedBox(height: 22),
|
|
const Text('طريقة الأداء',
|
|
style: TextStyle(
|
|
color: _ink, fontSize: 17, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 7),
|
|
for (var i = 0; i < exercise.instructions.length; i++)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('${i + 1}. ',
|
|
style: const TextStyle(
|
|
color: _green, fontWeight: FontWeight.w700)),
|
|
Expanded(
|
|
child: Text(exercise.instructions[i],
|
|
style: const TextStyle(
|
|
color: _ink, height: 1.55))),
|
|
]),
|
|
),
|
|
],
|
|
if (exercise.targetMuscles.isNotEmpty)
|
|
_TagSection(
|
|
title: 'العضلات المستهدفة', values: exercise.targetMuscles),
|
|
if (exercise.equipment.isNotEmpty)
|
|
_TagSection(title: 'الأدوات', values: exercise.equipment),
|
|
if (exercise.safetyNotes.isNotEmpty) ...[
|
|
const SizedBox(height: 18),
|
|
_NotesBox(title: 'تنبيهات السلامة', notes: exercise.safetyNotes),
|
|
],
|
|
if (exercise.alternativeUuid != null) ...[
|
|
const SizedBox(height: 12),
|
|
const Text('يتوفر بديل لهذه الحركة ضمن مكتبة التمارين.',
|
|
style: TextStyle(color: _muted, height: 1.5)),
|
|
],
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlanCard extends StatelessWidget {
|
|
const _PlanCard({required this.plan, required this.onTap});
|
|
final TrainingPlan plan;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.all(14),
|
|
leading: const CircleAvatar(
|
|
backgroundColor: Color(0xFFE8EFE5),
|
|
child: Icon(Icons.fitness_center, color: _green)),
|
|
title: Text(plan.title,
|
|
style: const TextStyle(color: _ink, fontWeight: FontWeight.w600)),
|
|
subtitle: Padding(
|
|
padding: const EdgeInsets.only(top: 5),
|
|
child: Text(
|
|
'${plan.weeksDuration} أسابيع • ${plan.sessions.length} جلسة${plan.summary.isNotEmpty ? '\n${plan.summary}' : ''}',
|
|
style: const TextStyle(color: _muted, height: 1.45),
|
|
),
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: onTap,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _CatalogMessage extends StatelessWidget {
|
|
const _CatalogMessage({required this.message, required this.onRetry});
|
|
final String message;
|
|
final VoidCallback onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(30),
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
const Icon(Icons.fitness_center, color: _green, size: 40),
|
|
const SizedBox(height: 14),
|
|
Text(message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: _ink, height: 1.6)),
|
|
const SizedBox(height: 10),
|
|
OutlinedButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('إعادة المحاولة')),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _CacheNotice extends StatelessWidget {
|
|
const _CacheNotice({required this.message});
|
|
final String message;
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(children: [
|
|
const Icon(Icons.offline_bolt_outlined, color: _green, size: 17),
|
|
const SizedBox(width: 7),
|
|
Text(message, style: const TextStyle(color: _muted, fontSize: 12)),
|
|
]),
|
|
);
|
|
}
|
|
|
|
class _GifPlaceholder extends StatelessWidget {
|
|
const _GifPlaceholder({this.poster, required this.title});
|
|
final Uri? poster;
|
|
final String title;
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
color: const Color(0xFFE8EFE5),
|
|
child: poster == null
|
|
? const Center(
|
|
child: Icon(Icons.motion_photos_on_outlined,
|
|
color: _green, size: 52))
|
|
: Image.network(poster.toString(),
|
|
fit: BoxFit.contain,
|
|
semanticLabel: 'صورة توضيحية لحركة $title',
|
|
errorBuilder: (context, error, stack) => const Center(
|
|
child: Icon(Icons.motion_photos_on_outlined,
|
|
color: _green, size: 52))),
|
|
);
|
|
}
|
|
|
|
class _TagSection extends StatelessWidget {
|
|
const _TagSection({required this.title, required this.values});
|
|
final String title;
|
|
final List<String> values;
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(top: 18),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(title,
|
|
style: const TextStyle(
|
|
color: _ink, fontSize: 16, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 7,
|
|
runSpacing: 7,
|
|
children: values.map((value) => _InfoChip(value)).toList()),
|
|
]),
|
|
);
|
|
}
|
|
|
|
class _NotesBox extends StatelessWidget {
|
|
const _NotesBox({required this.title, required this.notes});
|
|
final String title;
|
|
final List<String> notes;
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: const EdgeInsets.all(15),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFE8EEE5),
|
|
borderRadius: BorderRadius.circular(15)),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(title,
|
|
style:
|
|
const TextStyle(color: _green, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 6),
|
|
for (final note in notes)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text('• $note',
|
|
style: const TextStyle(color: _green, height: 1.5)),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
class _InfoChip extends StatelessWidget {
|
|
const _InfoChip(this.label);
|
|
final String label;
|
|
@override
|
|
Widget build(BuildContext context) => Chip(
|
|
label: Text(label, style: const TextStyle(color: _green, fontSize: 11)),
|
|
backgroundColor: const Color(0xFFE8EFE5),
|
|
side: BorderSide.none,
|
|
visualDensity: VisualDensity.compact,
|
|
);
|
|
}
|
|
|
|
String _prescription(TrainingExercise exercise) {
|
|
final values = <String>[];
|
|
if (exercise.prescription.sets != null) {
|
|
values.add('${exercise.prescription.sets} مجموعات');
|
|
}
|
|
if (exercise.prescription.reps?.isNotEmpty == true) {
|
|
values.add('${exercise.prescription.reps} تكرار');
|
|
}
|
|
if (values.isEmpty && exercise.durationSeconds != null) {
|
|
values.add('${exercise.durationSeconds} ثانية');
|
|
}
|
|
return values.isEmpty ? 'تفاصيل الحركة' : values.join(' • ');
|
|
}
|
|
|
|
String _levelLabel(String level) => switch (level) {
|
|
'beginner' => 'مبتدئ',
|
|
'intermediate' => 'متوسط',
|
|
'advanced' => 'متقدم',
|
|
_ => level,
|
|
};
|
|
|
|
String _goalLabel(String goal) => switch (goal) {
|
|
'weight_loss' => 'خفض الوزن',
|
|
'general_fitness' => 'لياقة عامة',
|
|
'strength' => 'قوة',
|
|
'mobility' => 'مرونة وحركة',
|
|
_ => goal,
|
|
};
|
|
|
|
String _intensityLabel(String intensity) => switch (intensity) {
|
|
'easy' => 'خفيفة',
|
|
'moderate' => 'متوسطة',
|
|
'hard' => 'عالية',
|
|
_ => intensity,
|
|
};
|
|
|
|
Uri? _httpsUri(String? value) {
|
|
if (value == null || value.isEmpty) return null;
|
|
final uri = Uri.tryParse(value);
|
|
return uri != null && uri.scheme == 'https' && uri.host.isNotEmpty
|
|
? uri
|
|
: null;
|
|
}
|