Add training plan and GIF exercise catalog
This commit is contained in:
@@ -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>(() => DatabaseService().init());
|
||||
await Get.putAsync<AuthService>(() => AuthService().init());
|
||||
Get.put(TrainingPlanService(auth: Get.find<AuthService>()));
|
||||
Get.put(GpsService());
|
||||
await Get.putAsync<SyncService>(() => SyncService().init());
|
||||
Get.put(AnalyticsService());
|
||||
|
||||
@@ -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<String> safetyNotes;
|
||||
final List<TrainingSession> sessions;
|
||||
|
||||
factory TrainingPlan.fromJson(Map<String, dynamic> 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<TrainingExercise> exercises;
|
||||
|
||||
factory TrainingSession.fromJson(Map<String, dynamic> 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<String> instructions;
|
||||
final List<String> targetMuscles;
|
||||
final List<String> equipment;
|
||||
final String? gifUrl;
|
||||
final String? posterUrl;
|
||||
final int? durationSeconds;
|
||||
final String? repetitions;
|
||||
final List<String> safetyNotes;
|
||||
final String? alternativeUuid;
|
||||
final ExercisePrescription prescription;
|
||||
|
||||
factory TrainingExercise.fromJson(Map<String, dynamic> json) {
|
||||
final prescription = json['prescription'];
|
||||
final prescriptionMap = prescription is Map<String, dynamic>
|
||||
? prescription
|
||||
: const <String, dynamic>{};
|
||||
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<String, dynamic> 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<TrainingPlan> plans;
|
||||
final bool fromCache;
|
||||
final String? message;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _mapList(dynamic value) {
|
||||
if (value is! List) return const [];
|
||||
return value.whereType<Map<String, dynamic>>().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _stringList(dynamic value) {
|
||||
if (value is! List) return const [];
|
||||
return value.whereType<String>().toList(growable: false);
|
||||
}
|
||||
|
||||
int _asInt(dynamic value) => value is num ? value.toInt() : 0;
|
||||
|
||||
int? _nullableInt(dynamic value) => value is num ? value.toInt() : null;
|
||||
@@ -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))
|
||||
]),
|
||||
)))
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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<TrainingCatalogResult> 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<String, dynamic> || decoded['plans'] is! List) {
|
||||
throw const TrainingCatalogException('صيغة بيانات الخطط غير متوقعة.');
|
||||
}
|
||||
final plans = (decoded['plans'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<TrainingPlan> _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<String, dynamic>>()
|
||||
.map(TrainingPlan.fromJson)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _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;
|
||||
}
|
||||
Reference in New Issue
Block a user