Refresh cached training catalog safely
This commit is contained in:
@@ -16,63 +16,80 @@ class TrainingLibraryScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TrainingLibraryScreenState extends State<TrainingLibraryScreen> {
|
class _TrainingLibraryScreenState extends State<TrainingLibraryScreen> {
|
||||||
late Future<TrainingCatalogResult> _plans;
|
final TrainingPlanService _service = Get.find<TrainingPlanService>();
|
||||||
|
TrainingCatalogResult? _result;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_plans = Get.find<TrainingPlanService>().fetchPlans();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reload() => setState(() {
|
Future<void> _load() async {
|
||||||
_plans = Get.find<TrainingPlanService>().fetchPlans(refresh: true);
|
final cached = await _service.readCachedPlans();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cached.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_result = TrainingCatalogResult(plans: cached, fromCache: true);
|
||||||
|
_loading = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
final result = await _service.fetchPlans(refresh: true);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_result = result;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reload() {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
appBar: AppBar(title: const Text('خطط التمرين')),
|
appBar: AppBar(title: const Text('خطط التمرين')),
|
||||||
body: FutureBuilder<TrainingCatalogResult>(
|
body: Builder(builder: (context) {
|
||||||
future: _plans,
|
final result = _result;
|
||||||
builder: (context, snapshot) {
|
if (_loading && result == null) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
return const Center(child: CircularProgressIndicator());
|
||||||
return const Center(child: CircularProgressIndicator());
|
}
|
||||||
}
|
if (result == null) {
|
||||||
final result = snapshot.data;
|
return _CatalogMessage(
|
||||||
if (result == null) {
|
message: 'تعذر تحميل مكتبة التمارين.',
|
||||||
return _CatalogMessage(
|
onRetry: _reload,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +106,7 @@ class _TrainingPlanSpotlightState extends State<TrainingPlanSpotlight> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_plans = Get.find<TrainingPlanService>().fetchPlans();
|
_plans = Get.find<TrainingPlanService>().fetchPlans(refresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -503,17 +520,17 @@ String _levelLabel(String level) => switch (level) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
String _goalLabel(String goal) => switch (goal) {
|
String _goalLabel(String goal) => switch (goal) {
|
||||||
'weight_loss' => 'خفض الوزن',
|
'weight_management' => 'إدارة الوزن',
|
||||||
'general_fitness' => 'لياقة عامة',
|
'general_fitness' => 'لياقة عامة',
|
||||||
'strength' => 'قوة',
|
|
||||||
'mobility' => 'مرونة وحركة',
|
'mobility' => 'مرونة وحركة',
|
||||||
|
'endurance' => 'التحمل',
|
||||||
_ => goal,
|
_ => goal,
|
||||||
};
|
};
|
||||||
|
|
||||||
String _intensityLabel(String intensity) => switch (intensity) {
|
String _intensityLabel(String intensity) => switch (intensity) {
|
||||||
'easy' => 'خفيفة',
|
'easy' => 'خفيفة',
|
||||||
'moderate' => 'متوسطة',
|
'moderate' => 'متوسطة',
|
||||||
'hard' => 'عالية',
|
'vigorous' => 'عالية',
|
||||||
_ => intensity,
|
_ => intensity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ class TrainingPlanService {
|
|||||||
final AuthService _auth;
|
final AuthService _auth;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
||||||
|
Future<List<TrainingPlan>> readCachedPlans() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return _decodeCache(prefs.getString(_cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
Future<TrainingCatalogResult> fetchPlans({bool refresh = false}) async {
|
Future<TrainingCatalogResult> fetchPlans({bool refresh = false}) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final cached = _decodeCache(prefs.getString(_cacheKey));
|
final cached = _decodeCache(prefs.getString(_cacheKey));
|
||||||
if (!refresh && cached.isNotEmpty) {
|
|
||||||
return TrainingCatalogResult(plans: cached, fromCache: true);
|
|
||||||
}
|
|
||||||
if (!_auth.isApiConfigured) {
|
if (!_auth.isApiConfigured) {
|
||||||
return TrainingCatalogResult(
|
return TrainingCatalogResult(
|
||||||
plans: cached,
|
plans: cached,
|
||||||
@@ -46,6 +48,15 @@ class TrainingPlanService {
|
|||||||
.map(TrainingPlan.fromJson)
|
.map(TrainingPlan.fromJson)
|
||||||
.where((plan) => plan.uuid.isNotEmpty)
|
.where((plan) => plan.uuid.isNotEmpty)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
// An empty catalog can be an unpublished/temporarily incomplete response.
|
||||||
|
// Keep the last known good catalog rather than erasing offline content.
|
||||||
|
if (plans.isEmpty && cached.isNotEmpty) {
|
||||||
|
return TrainingCatalogResult(
|
||||||
|
plans: cached,
|
||||||
|
fromCache: true,
|
||||||
|
message: 'لا توجد خطط منشورة حاليًا؛ نعرض آخر نسخة محفوظة.',
|
||||||
|
);
|
||||||
|
}
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_cacheKey,
|
_cacheKey,
|
||||||
jsonEncode(plans.map(_toCacheJson).toList(growable: false)),
|
jsonEncode(plans.map(_toCacheJson).toList(growable: false)),
|
||||||
|
|||||||
Reference in New Issue
Block a user