diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7f524d8..66c9487 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -5,6 +5,7 @@ import 'services/auth_service.dart'; import 'services/gps_service.dart'; import 'services/sync_service.dart'; import 'services/analytics_service.dart'; +import 'services/training_plan_service.dart'; import 'controllers/workout_controller.dart'; import 'screens/home_screen.dart'; import 'screens/phone_login_screen.dart'; @@ -16,6 +17,7 @@ void main() async { // بالترتيب: قاعدة البيانات -> الهوية -> GPS -> المزامنة -> التحليل await Get.putAsync(() => DatabaseService().init()); await Get.putAsync(() => AuthService().init()); + Get.put(TrainingPlanService(auth: Get.find())); Get.put(GpsService()); await Get.putAsync(() => SyncService().init()); Get.put(AnalyticsService()); diff --git a/mobile/lib/models/training_plan.dart b/mobile/lib/models/training_plan.dart new file mode 100644 index 0000000..76beadf --- /dev/null +++ b/mobile/lib/models/training_plan.dart @@ -0,0 +1,175 @@ +class TrainingPlan { + const TrainingPlan({ + required this.uuid, + required this.slug, + required this.title, + required this.summary, + required this.goal, + required this.level, + required this.weeksDuration, + required this.safetyNotes, + required this.sessions, + }); + + final String uuid; + final String slug; + final String title; + final String summary; + final String goal; + final String level; + final int weeksDuration; + final List safetyNotes; + final List sessions; + + factory TrainingPlan.fromJson(Map json) => TrainingPlan( + uuid: json['plan_uuid'] as String? ?? '', + slug: json['slug'] as String? ?? '', + title: json['title_ar'] as String? ?? 'خطة تدريب', + summary: json['summary_ar'] as String? ?? '', + goal: json['goal'] as String? ?? '', + level: json['level'] as String? ?? 'beginner', + weeksDuration: _asInt(json['weeks_duration']), + safetyNotes: _stringList(json['safety_notes_ar']), + sessions: _mapList(json['sessions']) + .map(TrainingSession.fromJson) + .toList(growable: false), + ); +} + +class TrainingSession { + const TrainingSession({ + required this.week, + required this.day, + required this.title, + required this.type, + required this.durationMinutes, + required this.intensity, + required this.notes, + required this.exercises, + }); + + final int week; + final int day; + final String title; + final String type; + final int durationMinutes; + final String intensity; + final String? notes; + final List exercises; + + factory TrainingSession.fromJson(Map json) => + TrainingSession( + week: _asInt(json['week']), + day: _asInt(json['day']), + title: json['title_ar'] as String? ?? 'جلسة تدريب', + type: json['type'] as String? ?? 'rest', + durationMinutes: _asInt(json['duration_minutes']), + intensity: json['intensity'] as String? ?? 'easy', + notes: json['notes_ar'] as String?, + exercises: _mapList(json['exercises']) + .map(TrainingExercise.fromJson) + .toList(growable: false), + ); +} + +class TrainingExercise { + const TrainingExercise({ + required this.uuid, + required this.slug, + required this.title, + required this.instructions, + required this.targetMuscles, + required this.equipment, + required this.gifUrl, + required this.posterUrl, + required this.durationSeconds, + required this.repetitions, + required this.safetyNotes, + required this.alternativeUuid, + required this.prescription, + }); + + final String uuid; + final String slug; + final String title; + final List instructions; + final List targetMuscles; + final List equipment; + final String? gifUrl; + final String? posterUrl; + final int? durationSeconds; + final String? repetitions; + final List safetyNotes; + final String? alternativeUuid; + final ExercisePrescription prescription; + + factory TrainingExercise.fromJson(Map json) { + final prescription = json['prescription']; + final prescriptionMap = prescription is Map + ? prescription + : const {}; + return TrainingExercise( + uuid: json['exercise_uuid'] as String? ?? '', + slug: json['slug'] as String? ?? '', + title: json['title_ar'] as String? ?? 'تمرين', + instructions: _stringList(json['instructions_ar']), + targetMuscles: _stringList(json['target_muscles']), + equipment: _stringList(json['equipment']), + gifUrl: json['gif_url'] as String?, + posterUrl: json['gif_poster_url'] as String?, + durationSeconds: _nullableInt(json['duration_seconds']), + repetitions: json['repetitions'] as String?, + safetyNotes: _stringList(json['safety_notes_ar']), + alternativeUuid: json['alternative_exercise_uuid'] as String?, + prescription: ExercisePrescription.fromJson(prescriptionMap), + ); + } +} + +class ExercisePrescription { + const ExercisePrescription({ + this.sets, + this.reps, + this.durationSeconds, + this.restSeconds = 0, + }); + + final int? sets; + final String? reps; + final int? durationSeconds; + final int restSeconds; + + factory ExercisePrescription.fromJson(Map json) => + ExercisePrescription( + sets: _nullableInt(json['sets']), + reps: json['reps'] as String?, + durationSeconds: _nullableInt(json['duration_seconds']), + restSeconds: _asInt(json['rest_seconds']), + ); +} + +class TrainingCatalogResult { + const TrainingCatalogResult({ + required this.plans, + required this.fromCache, + this.message, + }); + + final List plans; + final bool fromCache; + final String? message; +} + +List> _mapList(dynamic value) { + if (value is! List) return const []; + return value.whereType>().toList(growable: false); +} + +List _stringList(dynamic value) { + if (value is! List) return const []; + return value.whereType().toList(growable: false); +} + +int _asInt(dynamic value) => value is num ? value.toInt() : 0; + +int? _nullableInt(dynamic value) => value is num ? value.toInt() : null; diff --git a/mobile/lib/screens/home_screen.dart b/mobile/lib/screens/home_screen.dart index da5e201..2da48bd 100644 --- a/mobile/lib/screens/home_screen.dart +++ b/mobile/lib/screens/home_screen.dart @@ -5,6 +5,7 @@ import '../models/workout.dart'; import '../screens/history_screen.dart'; import '../services/sync_service.dart'; import '../services/auth_service.dart'; +import 'training_screens.dart'; import '../utils/formatters.dart'; import 'phone_login_screen.dart'; @@ -113,9 +114,7 @@ class HomeScreen extends StatelessWidget { style: TextStyle(color: _muted, fontSize: 14, height: 1.65)), const SizedBox(height: 22), - _WeeklyPlanCard( - onStartWalk: () => - workoutCtrl.startWorkout(WorkoutType.walking)), + const TrainingPlanSpotlight(), const SizedBox(height: 26), Row(children: [ const Text('ابدأ الآن', @@ -177,14 +176,24 @@ class HomeScreen extends StatelessWidget { detail: 'تذكير بسيط للعناية بنفسك.', tag: 'عافية'), const SizedBox(height: 25), - const Text('برنامج الأسبوع', - style: TextStyle( - color: _ink, - fontSize: 18, - fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - const _DayStrip(), - const SizedBox(height: 25), + Row(children: [ + const Text('برامج التمرين', + style: TextStyle( + color: _ink, + fontSize: 18, + fontWeight: FontWeight.w600)), + const Spacer(), + TextButton( + onPressed: () => + Get.to(() => const TrainingLibraryScreen()), + child: const Text('عرض المكتبة'), + ), + ]), + const SizedBox(height: 6), + const Text( + 'الخطط والجلسات تُحمّل من الخادم وتُحفظ للعرض دون اتصال.', + style: TextStyle(color: _muted, fontSize: 11, height: 1.5)), + const SizedBox(height: 20), Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( @@ -241,79 +250,6 @@ String _arabicToday() { return '${weekdays[now.weekday - 1]}، $day ${months[now.month - 1]}'; } -class _WeeklyPlanCard extends StatelessWidget { - const _WeeklyPlanCard({required this.onStartWalk}); - final VoidCallback onStartWalk; - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: _green, borderRadius: BorderRadius.circular(22)), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('خطة بداية متوازنة', - style: TextStyle( - color: Colors.white, - fontSize: 17, - fontWeight: FontWeight.w600)), - SizedBox(height: 4), - Text('أسبوع تمهيدي • حركة خفيفة', - style: TextStyle(color: Color(0xFFDCE8DA), fontSize: 11)), - ])), - SizedBox( - width: 76, - height: 70, - child: CustomPaint(painter: _PathPainter())), - ]), - const SizedBox(height: 17), - Row(children: [ - const Expanded( - child: Text('مشي مريح لمدة ٢٠ دقيقة', - style: TextStyle(color: Colors.white, fontSize: 13))), - TextButton( - onPressed: onStartWalk, - style: TextButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: _green, - padding: const EdgeInsets.symmetric(horizontal: 14)), - child: const Text('ابدأ المشي', - style: - TextStyle(fontSize: 11, fontWeight: FontWeight.w600))), - ]), - const SizedBox(height: 3), - const Text('عدّل المدة أو خذ يوم راحة حسب شعورك.', - style: TextStyle(color: Color(0xFFDCE8DA), fontSize: 10)), - ]), - ); -} - -class _PathPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = const Color(0xFFB8CAA9) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - final path = Path() - ..moveTo(4, size.height * .82) - ..cubicTo(size.width * .18, size.height * .12, size.width * .48, - size.height * .98, size.width * .68, size.height * .38) - ..quadraticBezierTo(size.width * .86, size.height * .02, size.width * .98, - size.height * .24); - canvas.drawPath(path, paint); - canvas.drawCircle(Offset(size.width * .68, size.height * .38), 4, - Paint()..color = Colors.white); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - class _ActionCard extends StatelessWidget { const _ActionCard( {required this.title, @@ -449,42 +385,3 @@ class _HabitCard extends StatelessWidget { ]), ); } - -class _DayStrip extends StatelessWidget { - const _DayStrip(); - static const days = [ - ('ح', 'راحة'), - ('ن', 'مشي'), - ('ث', 'قوة'), - ('ر', 'مشي'), - ('خ', 'راحة'), - ('ج', 'نشاط'), - ('س', 'راحة') - ]; - @override - Widget build(BuildContext context) => Row(children: [ - for (var i = 0; i < days.length; i++) - Expanded( - child: Padding( - padding: EdgeInsetsDirectional.only( - end: i == days.length - 1 ? 0 : 7), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: i == 0 ? _green : Colors.white, - borderRadius: BorderRadius.circular(13)), - child: Column(children: [ - Text(days[i].$1, - style: TextStyle( - color: i == 0 ? Colors.white : _ink, - fontSize: 13, - fontWeight: FontWeight.w700)), - const SizedBox(height: 4), - Text(days[i].$2, - style: TextStyle( - color: i == 0 ? Colors.white70 : _muted, - fontSize: 8)) - ]), - ))) - ]); -} diff --git a/mobile/lib/screens/training_screens.dart b/mobile/lib/screens/training_screens.dart new file mode 100644 index 0000000..3c5eb0a --- /dev/null +++ b/mobile/lib/screens/training_screens.dart @@ -0,0 +1,526 @@ +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 createState() => _TrainingLibraryScreenState(); +} + +class _TrainingLibraryScreenState extends State { + late Future _plans; + + @override + void initState() { + super.initState(); + _plans = Get.find().fetchPlans(); + } + + void _reload() => setState(() { + _plans = Get.find().fetchPlans(refresh: true); + }); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('خطط التمرين')), + body: FutureBuilder( + 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 createState() => _TrainingPlanSpotlightState(); +} + +class _TrainingPlanSpotlightState extends State { + late Future _plans; + + @override + void initState() { + super.initState(); + _plans = Get.find().fetchPlans(); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + 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 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 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 = []; + 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; +} diff --git a/mobile/lib/services/training_plan_service.dart b/mobile/lib/services/training_plan_service.dart new file mode 100644 index 0000000..6c5e173 --- /dev/null +++ b/mobile/lib/services/training_plan_service.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/training_plan.dart'; +import 'auth_service.dart'; + +class TrainingPlanService { + TrainingPlanService({required AuthService auth, http.Client? client}) + : _auth = auth, + _client = client ?? http.Client(); + + static const _cacheKey = 'sportpath_training_plans_v1'; + final AuthService _auth; + final http.Client _client; + + Future fetchPlans({bool refresh = false}) async { + final prefs = await SharedPreferences.getInstance(); + final cached = _decodeCache(prefs.getString(_cacheKey)); + if (!refresh && cached.isNotEmpty) { + return TrainingCatalogResult(plans: cached, fromCache: true); + } + if (!_auth.isApiConfigured) { + return TrainingCatalogResult( + plans: cached, + fromCache: cached.isNotEmpty, + message: 'عنوان الخادم غير مضبوط على هذه النسخة.', + ); + } + + try { + final response = await _client + .get(Uri.parse('${_auth.apiBase}/plans.php')) + .timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw TrainingCatalogException( + 'تعذر تحميل الخطط (${response.statusCode}).'); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map || decoded['plans'] is! List) { + throw const TrainingCatalogException('صيغة بيانات الخطط غير متوقعة.'); + } + final plans = (decoded['plans'] as List) + .whereType>() + .map(TrainingPlan.fromJson) + .where((plan) => plan.uuid.isNotEmpty) + .toList(growable: false); + await prefs.setString( + _cacheKey, + jsonEncode(plans.map(_toCacheJson).toList(growable: false)), + ); + return TrainingCatalogResult(plans: plans, fromCache: false); + } catch (error) { + if (cached.isNotEmpty) { + return TrainingCatalogResult( + plans: cached, + fromCache: true, + message: 'تعذر الاتصال؛ نعرض الخطط المحفوظة على الجهاز.', + ); + } + return TrainingCatalogResult( + plans: const [], + fromCache: false, + message: error is TrainingCatalogException + ? error.message + : 'لا يوجد اتصال بالخادم الآن.', + ); + } + } + + List _decodeCache(String? value) { + if (value == null || value.isEmpty) return const []; + try { + final decoded = jsonDecode(value); + if (decoded is! List) return const []; + return decoded + .whereType>() + .map(TrainingPlan.fromJson) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + Map _toCacheJson(TrainingPlan plan) => { + 'plan_uuid': plan.uuid, + 'slug': plan.slug, + 'title_ar': plan.title, + 'summary_ar': plan.summary, + 'goal': plan.goal, + 'level': plan.level, + 'weeks_duration': plan.weeksDuration, + 'safety_notes_ar': plan.safetyNotes, + 'sessions': plan.sessions + .map((session) => { + 'week': session.week, + 'day': session.day, + 'title_ar': session.title, + 'type': session.type, + 'duration_minutes': session.durationMinutes, + 'intensity': session.intensity, + 'notes_ar': session.notes, + 'exercises': session.exercises + .map((exercise) => { + 'exercise_uuid': exercise.uuid, + 'slug': exercise.slug, + 'title_ar': exercise.title, + 'instructions_ar': exercise.instructions, + 'target_muscles': exercise.targetMuscles, + 'equipment': exercise.equipment, + 'gif_url': exercise.gifUrl, + 'gif_poster_url': exercise.posterUrl, + 'duration_seconds': exercise.durationSeconds, + 'repetitions': exercise.repetitions, + 'safety_notes_ar': exercise.safetyNotes, + 'alternative_exercise_uuid': + exercise.alternativeUuid, + 'prescription': { + 'sets': exercise.prescription.sets, + 'reps': exercise.prescription.reps, + 'duration_seconds': + exercise.prescription.durationSeconds, + 'rest_seconds': exercise.prescription.restSeconds, + }, + }) + .toList(growable: false), + }) + .toList(growable: false), + }; +} + +class TrainingCatalogException implements Exception { + const TrainingCatalogException(this.message); + final String message; +}