Add offline meal journal and secure sync
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
# SportPath mobile
|
||||
|
||||
تطبيق Flutter عربي لحفظ جلسات المشي والجري محليًا، تسجيل الدخول برقم الهاتف، ومزامنة التمارين إلى PHP API. يحتوي المشروع الآن منصتي iOS وAndroid؛ `android/` يستخدم معرّف تطوير مؤقتًا `com.example.fitness_tracker` ويجب استبداله بمعرّف الحزمة الذي سيعتمده مالك المنتج قبل النشر على المتاجر.
|
||||
تطبيق Flutter عربي لحفظ جلسات المشي والجري وسجل الوجبات محليًا، تسجيل الدخول برقم الهاتف، ومزامنة بيانات المستخدم مع PHP API. سجل الطعام الحالي إدخال يدوي قابل للتصحيح، وليس تحليلًا بصريًا بالذكاء الاصطناعي. يحتوي المشروع الآن منصتي iOS وAndroid؛ `android/` يستخدم معرّف تطوير مؤقتًا `com.example.fitness_tracker` ويجب استبداله بمعرّف الحزمة الذي سيعتمده مالك المنتج قبل النشر على المتاجر.
|
||||
|
||||
## عنوان خادم API
|
||||
|
||||
@@ -11,6 +11,6 @@ flutter build apk --dart-define=API_BASE_URL=https://fitness.example.com
|
||||
flutter build ios --dart-define=API_BASE_URL=https://fitness.example.com
|
||||
```
|
||||
|
||||
يستخدم التطبيق مسارات `API_BASE_URL/api/v1/auth/` للمصادقة و`API_BASE_URL/api/v1/workouts.php` لمزامنة سجل التمرين. لا تضع مفاتيح OTP أو JWT أو قواعد البيانات في `--dart-define`؛ هذه تبقى على الخادم. يلزم إعداد مزود OTP وتطبيق migrations على قاعدة staging قبل نجاح تسجيل الدخول.
|
||||
يستخدم التطبيق مسارات `API_BASE_URL/api/v1/auth/` للمصادقة، و`workouts.php` للتمارين، و`meals.php` لسجل الطعام بين الأجهزة. تُحفظ وجبات الهاتف دون اتصال ثم تُرفع بمعرّف ثابت، ويُسحب آخر 30 يومًا عند فتح السجل. لا تضع مفاتيح OTP أو JWT أو قواعد البيانات في `--dart-define`؛ هذه تبقى على الخادم. يلزم إعداد مزود OTP وتطبيق migrations `001`–`005` بالترتيب على قاعدة قديمة، أو تجهيز القاعدة الجديدة من `backend/schema.sql` بدل تطبيق الترحيلات عليها.
|
||||
|
||||
جلسة التطبيق تحتفظ بـrefresh token في التخزين الآمن للنظام وتحدّث access token تلقائيًا عند استجابة 401. بيانات GPS والتمارين تبقى محليًا في SQLite/outbox إلى حين تسجيل الدخول والاتصال. لا تحذف التطبيق أو بياناته أثناء المزامنة إن كان لديك سجل غير متزامن.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
enum MealType { breakfast, lunch, dinner, snack }
|
||||
|
||||
enum MealSource { manual, photoAi }
|
||||
|
||||
enum MealSyncStatus { pending, synced, failed }
|
||||
|
||||
class MealEntry {
|
||||
MealEntry({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.mealType,
|
||||
required this.calories,
|
||||
required this.proteinGrams,
|
||||
required this.carbohydrateGrams,
|
||||
required this.fatGrams,
|
||||
required this.consumedAt,
|
||||
this.source = MealSource.manual,
|
||||
this.notes = '',
|
||||
this.syncStatus = MealSyncStatus.pending,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final MealType mealType;
|
||||
final double calories;
|
||||
final double proteinGrams;
|
||||
final double carbohydrateGrams;
|
||||
final double fatGrams;
|
||||
final DateTime consumedAt;
|
||||
final MealSource source;
|
||||
final String notes;
|
||||
MealSyncStatus syncStatus;
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'meal_type': mealType.name,
|
||||
'calories': calories,
|
||||
'protein_grams': proteinGrams,
|
||||
'carbohydrate_grams': carbohydrateGrams,
|
||||
'fat_grams': fatGrams,
|
||||
'consumed_at': consumedAt.toUtc().toIso8601String(),
|
||||
'source': source == MealSource.photoAi ? 'photo_ai' : 'manual',
|
||||
'notes': notes,
|
||||
'sync_status': syncStatus.name,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toApiPayload() => {
|
||||
'client_meal_uuid': id,
|
||||
'name': name,
|
||||
'meal_type': mealType.name,
|
||||
'calories': calories,
|
||||
'protein_grams': proteinGrams,
|
||||
'carbohydrate_grams': carbohydrateGrams,
|
||||
'fat_grams': fatGrams,
|
||||
'consumed_at': consumedAt.toUtc().toIso8601String(),
|
||||
'source': source == MealSource.photoAi ? 'photo_ai' : 'manual',
|
||||
'notes': notes,
|
||||
};
|
||||
|
||||
factory MealEntry.fromMap(Map<String, dynamic> map) => MealEntry(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String,
|
||||
mealType: MealType.values.firstWhere(
|
||||
(value) => value.name == map['meal_type'],
|
||||
orElse: () => MealType.snack,
|
||||
),
|
||||
calories: _asDouble(map['calories']),
|
||||
proteinGrams: _asDouble(map['protein_grams']),
|
||||
carbohydrateGrams: _asDouble(map['carbohydrate_grams']),
|
||||
fatGrams: _asDouble(map['fat_grams']),
|
||||
consumedAt: DateTime.parse(map['consumed_at'] as String).toLocal(),
|
||||
source: map['source'] == 'photo_ai' || map['source'] == 'photoAi'
|
||||
? MealSource.photoAi
|
||||
: MealSource.manual,
|
||||
notes: map['notes'] as String? ?? '',
|
||||
syncStatus: MealSyncStatus.values.firstWhere(
|
||||
(value) => value.name == map['sync_status'],
|
||||
orElse: () => MealSyncStatus.pending,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _asDouble(dynamic value) => value is num ? value.toDouble() : 0;
|
||||
@@ -6,6 +6,7 @@ import '../screens/history_screen.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import 'training_screens.dart';
|
||||
import 'meal_journal_screen.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import 'phone_login_screen.dart';
|
||||
|
||||
@@ -167,8 +168,9 @@ class HomeScreen extends StatelessWidget {
|
||||
const _HabitCard(
|
||||
icon: Icons.restaurant_outlined,
|
||||
title: 'وجبتك القادمة',
|
||||
detail: 'سجّلها يدويًا الآن؛ تحليل الصور قيد الإعداد.',
|
||||
tag: 'تغذية'),
|
||||
detail: 'سجّلها يدويًا أو صحّح تقديرك.',
|
||||
tag: 'تغذية',
|
||||
onTap: _openMealJournal),
|
||||
const SizedBox(height: 9),
|
||||
const _HabitCard(
|
||||
icon: Icons.water_drop_outlined,
|
||||
@@ -349,11 +351,16 @@ class _HabitCard extends StatelessWidget {
|
||||
{required this.icon,
|
||||
required this.title,
|
||||
required this.detail,
|
||||
required this.tag});
|
||||
required this.tag,
|
||||
this.onTap});
|
||||
final IconData icon;
|
||||
final String title, detail, tag;
|
||||
final VoidCallback? onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(15)),
|
||||
@@ -383,5 +390,7 @@ class _HabitCard extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: _green, fontSize: 10, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
void _openMealJournal() => Get.to(() => const MealJournalScreen());
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/meal_entry.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
const _green = Color(0xFF456C52);
|
||||
const _ink = Color(0xFF17251E);
|
||||
const _muted = Color(0xFF778078);
|
||||
|
||||
class MealJournalScreen extends StatefulWidget {
|
||||
const MealJournalScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MealJournalScreen> createState() => _MealJournalScreenState();
|
||||
}
|
||||
|
||||
class _MealJournalScreenState extends State<MealJournalScreen> {
|
||||
final _db = Get.find<DatabaseService>();
|
||||
final _sync = Get.find<SyncService>();
|
||||
final _name = TextEditingController();
|
||||
final _calories = TextEditingController();
|
||||
final _protein = TextEditingController(text: '0');
|
||||
final _carbs = TextEditingController(text: '0');
|
||||
final _fat = TextEditingController(text: '0');
|
||||
final _notes = TextEditingController();
|
||||
DateTime _day = DateTime.now();
|
||||
DateTime _consumedAt = DateTime.now();
|
||||
MealType _type = MealType.lunch;
|
||||
MealEntry? _editing;
|
||||
List<MealEntry> _entries = const [];
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDay();
|
||||
_syncAndRefresh();
|
||||
}
|
||||
|
||||
Future<void> _syncAndRefresh() async {
|
||||
await _sync.syncMealsNow();
|
||||
if (mounted) await _loadDay();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_calories.dispose();
|
||||
_protein.dispose();
|
||||
_carbs.dispose();
|
||||
_fat.dispose();
|
||||
_notes.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDay() async {
|
||||
setState(() => _loading = true);
|
||||
final entries = await _db.getMealsForDay(_day);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearForm() {
|
||||
_name.clear();
|
||||
_calories.clear();
|
||||
_protein.text = '0';
|
||||
_carbs.text = '0';
|
||||
_fat.text = '0';
|
||||
_notes.clear();
|
||||
setState(() {
|
||||
_editing = null;
|
||||
_type = MealType.lunch;
|
||||
final now = DateTime.now();
|
||||
_consumedAt =
|
||||
DateTime(_day.year, _day.month, _day.day, now.hour, now.minute);
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _edit(MealEntry entry) {
|
||||
_name.text = entry.name;
|
||||
_calories.text = _number(entry.calories);
|
||||
_protein.text = _number(entry.proteinGrams);
|
||||
_carbs.text = _number(entry.carbohydrateGrams);
|
||||
_fat.text = _number(entry.fatGrams);
|
||||
_notes.text = entry.notes;
|
||||
setState(() {
|
||||
_editing = entry;
|
||||
_type = entry.mealType;
|
||||
_consumedAt = entry.consumedAt;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _name.text.trim();
|
||||
final calories = _parseNumber(_calories.text);
|
||||
final protein = _parseNumber(_protein.text);
|
||||
final carbs = _parseNumber(_carbs.text);
|
||||
final fat = _parseNumber(_fat.text);
|
||||
if (name.isEmpty ||
|
||||
calories == null ||
|
||||
calories > 10000 ||
|
||||
protein == null ||
|
||||
protein > 1000 ||
|
||||
carbs == null ||
|
||||
carbs > 1000 ||
|
||||
fat == null ||
|
||||
fat > 1000) {
|
||||
setState(
|
||||
() => _error = 'أدخل اسم الوجبة وأرقامًا صالحة ضمن الحدود الموضحة.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
final entry = MealEntry(
|
||||
id: _editing?.id ?? const Uuid().v4(),
|
||||
name: name,
|
||||
mealType: _type,
|
||||
calories: calories,
|
||||
proteinGrams: protein,
|
||||
carbohydrateGrams: carbs,
|
||||
fatGrams: fat,
|
||||
consumedAt: _consumedAt,
|
||||
notes: _notes.text.trim(),
|
||||
);
|
||||
try {
|
||||
await _sync.queueMeal(entry);
|
||||
if (!mounted) return;
|
||||
_clearForm();
|
||||
await _loadDay();
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setState(() => _error = 'تعذر حفظ الوجبة على الجهاز: $error');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _chooseDay() async {
|
||||
final chosen = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _day,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
helpText: 'اختر يوم السجل',
|
||||
);
|
||||
if (chosen != null) {
|
||||
final now = DateTime.now();
|
||||
setState(() {
|
||||
_day = chosen;
|
||||
if (_editing == null) {
|
||||
_consumedAt = DateTime(
|
||||
chosen.year, chosen.month, chosen.day, now.hour, now.minute);
|
||||
}
|
||||
});
|
||||
await _loadDay();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _chooseTime() async {
|
||||
final chosen = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_consumedAt),
|
||||
helpText: 'اختر وقت الوجبة',
|
||||
);
|
||||
if (chosen != null) {
|
||||
setState(() => _consumedAt = DateTime(
|
||||
_day.year, _day.month, _day.day, chosen.hour, chosen.minute));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final caloriesTotal =
|
||||
_entries.fold<double>(0, (sum, item) => sum + item.calories);
|
||||
final proteinTotal =
|
||||
_entries.fold<double>(0, (sum, item) => sum + item.proteinGrams);
|
||||
final carbsTotal =
|
||||
_entries.fold<double>(0, (sum, item) => sum + item.carbohydrateGrams);
|
||||
final fatTotal =
|
||||
_entries.fold<double>(0, (sum, item) => sum + item.fatGrams);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('سجل الطعام'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'مزامنة الوجبات',
|
||||
onPressed: _sync.syncMealsNow,
|
||||
icon: const Icon(Icons.sync),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 8, 18, 28),
|
||||
children: [
|
||||
Row(children: [
|
||||
const Expanded(
|
||||
child: Text('ملخص اليوم المختار',
|
||||
style: TextStyle(
|
||||
color: _ink, fontSize: 21, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _chooseDay,
|
||||
icon: const Icon(Icons.calendar_today_outlined, size: 16),
|
||||
label: Text('${_day.day}/${_day.month}/${_day.year}'),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _green, borderRadius: BorderRadius.circular(18)),
|
||||
child:
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(_number(caloriesTotal),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const Text('سعرة مقدّرة • مجموع ما أدخلته',
|
||||
style: TextStyle(color: Color(0xFFDCE8DA), fontSize: 11)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 6, children: [
|
||||
_MacroChip('بروتين ${_number(proteinTotal)}غ'),
|
||||
_MacroChip('كربوهيدرات ${_number(carbsTotal)}غ'),
|
||||
_MacroChip('دهون ${_number(fatTotal)}غ'),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: Text(_editing == null ? 'إضافة وجبة' : 'تصحيح الوجبة',
|
||||
style: const TextStyle(
|
||||
color: _ink,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600))),
|
||||
if (_editing != null)
|
||||
TextButton(
|
||||
onPressed: _clearForm, child: const Text('إلغاء التعديل')),
|
||||
]),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
onPressed: _chooseTime,
|
||||
icon: const Icon(Icons.schedule, size: 17),
|
||||
label: Text(
|
||||
'وقت الوجبة: ${TimeOfDay.fromDateTime(_consumedAt).format(context)}'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_input(_name, 'اسم الوجبة أو الطعام',
|
||||
maxLength: 180, textInputAction: TextInputAction.next),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<MealType>(
|
||||
key: ValueKey(_type),
|
||||
// `value` keeps compatibility with the project's Flutter 3.13 floor.
|
||||
// ignore: deprecated_member_use
|
||||
value: _type,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'نوع الوجبة', border: OutlineInputBorder()),
|
||||
items: MealType.values
|
||||
.map((type) => DropdownMenuItem(
|
||||
value: type, child: Text(_mealTypeLabel(type))))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _type = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_input(_calories, 'السعرات (تقدير من الملصق أو إدخالك)',
|
||||
number: true),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Expanded(child: _input(_protein, 'بروتين غ', number: true)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _input(_carbs, 'كربوهيدرات غ', number: true)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _input(_fat, 'دهون غ', number: true)),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
_input(_notes, 'ملاحظات أو حجم الحصة (اختياري)',
|
||||
maxLines: 2, maxLength: 1000),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: Icon(_editing == null ? Icons.add : Icons.save_outlined),
|
||||
label: Text(_saving
|
||||
? 'جارٍ الحفظ…'
|
||||
: _editing == null
|
||||
? 'حفظ على الجهاز'
|
||||
: 'حفظ التصحيح'),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 9),
|
||||
child: Text(
|
||||
'هذه تقديرات أدخلتها أنت وليست تحليلًا آليًا للصورة. تبقى محليًا دون اتصال وتُزامن عند توفر الدخول والخادم.',
|
||||
style: TextStyle(color: _muted, fontSize: 10, height: 1.6)),
|
||||
),
|
||||
const SizedBox(height: 23),
|
||||
const Text('الوجبات المسجلة',
|
||||
style: TextStyle(
|
||||
color: _ink, fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 9),
|
||||
if (_loading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: CircularProgressIndicator()))
|
||||
else if (_entries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 15),
|
||||
child: Text('لا توجد وجبات مسجلة لهذا اليوم.',
|
||||
style: TextStyle(color: _muted)),
|
||||
)
|
||||
else
|
||||
for (final entry in _entries)
|
||||
_MealCard(entry: entry, onTap: () => _edit(entry)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MealCard extends StatelessWidget {
|
||||
const _MealCard({required this.entry, required this.onTap});
|
||||
final MealEntry entry;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Color(0xFFE8EFE5),
|
||||
child: Icon(Icons.restaurant_outlined, color: _green)),
|
||||
title: Text(entry.name,
|
||||
style: const TextStyle(color: _ink, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'${_mealTypeLabel(entry.mealType)} • ${entry.calories.round()} سعرة • ${_syncLabel(entry.syncStatus)}',
|
||||
style: const TextStyle(color: _muted, fontSize: 11)),
|
||||
trailing: const Icon(Icons.edit_outlined, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _MacroChip extends StatelessWidget {
|
||||
const _MacroChip(this.label);
|
||||
final String label;
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24, borderRadius: BorderRadius.circular(20)),
|
||||
child: Text(label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _input(TextEditingController controller, String label,
|
||||
{bool number = false,
|
||||
int maxLines = 1,
|
||||
int? maxLength,
|
||||
TextInputAction? textInputAction}) =>
|
||||
TextField(
|
||||
controller: controller,
|
||||
maxLength: maxLength,
|
||||
keyboardType: number
|
||||
? const TextInputType.numberWithOptions(decimal: true)
|
||||
: TextInputType.text,
|
||||
inputFormatters: number
|
||||
? [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d{0,5}(?:[.,]\d{0,2})?'))
|
||||
]
|
||||
: null,
|
||||
maxLines: maxLines,
|
||||
textInputAction: textInputAction,
|
||||
decoration:
|
||||
InputDecoration(labelText: label, border: const OutlineInputBorder()),
|
||||
);
|
||||
|
||||
double? _parseNumber(String value) =>
|
||||
double.tryParse(value.trim().replaceAll(',', '.'));
|
||||
|
||||
String _number(double value) => value == value.roundToDouble()
|
||||
? value.toInt().toString()
|
||||
: value.toStringAsFixed(1);
|
||||
|
||||
String _mealTypeLabel(MealType type) => switch (type) {
|
||||
MealType.breakfast => 'فطور',
|
||||
MealType.lunch => 'غداء',
|
||||
MealType.dinner => 'عشاء',
|
||||
MealType.snack => 'وجبة خفيفة',
|
||||
};
|
||||
|
||||
String _syncLabel(MealSyncStatus status) => switch (status) {
|
||||
MealSyncStatus.pending => 'بانتظار المزامنة',
|
||||
MealSyncStatus.synced => 'تمت المزامنة',
|
||||
MealSyncStatus.failed => 'تعذرت المزامنة',
|
||||
};
|
||||
@@ -119,6 +119,23 @@ class AuthService {
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<http.Response> authenticatedGet(Uri uri) async {
|
||||
_requireApi();
|
||||
_accessToken ??= await _storage.read(key: _accessKey);
|
||||
if (_accessToken == null && !await _refreshSession()) {
|
||||
throw const AuthException('سجّل الدخول لمزامنة بياناتك.');
|
||||
}
|
||||
var response = await _client
|
||||
.get(uri, headers: _bearerHeaders(_accessToken!))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode == 401 && await _refreshSession()) {
|
||||
response = await _client
|
||||
.get(uri, headers: _bearerHeaders(_accessToken!))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<bool> _refreshSession() async {
|
||||
final activeRefresh = _refreshing;
|
||||
if (activeRefresh != null) return activeRefresh;
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:path/path.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../models/workout_segment.dart';
|
||||
import '../models/meal_entry.dart';
|
||||
|
||||
/// خدمة قاعدة البيانات المحلية
|
||||
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
|
||||
class DatabaseService extends GetxService {
|
||||
static const String _dbName = 'sportpath_tracker.db';
|
||||
static const int _dbVersion = 3;
|
||||
static const int _dbVersion = 4;
|
||||
|
||||
late Database _db;
|
||||
|
||||
@@ -142,6 +143,8 @@ class DatabaseService extends GetxService {
|
||||
'CREATE INDEX idx_sync_workout ON sync_queue(workout_id)',
|
||||
);
|
||||
|
||||
await _createMealTable(db);
|
||||
|
||||
debugPrint('[DB] All tables created successfully');
|
||||
}
|
||||
|
||||
@@ -165,6 +168,33 @@ class DatabaseService extends GetxService {
|
||||
'CREATE INDEX IF NOT EXISTS idx_sync_workout ON sync_queue(workout_id)',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 4) await _createMealTable(db);
|
||||
}
|
||||
|
||||
Future<void> _createMealTable(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS meal_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL CHECK(meal_type IN ('breakfast','lunch','dinner','snack')),
|
||||
calories REAL NOT NULL CHECK(calories >= 0 AND calories <= 10000),
|
||||
protein_grams REAL NOT NULL DEFAULT 0 CHECK(protein_grams >= 0 AND protein_grams <= 1000),
|
||||
carbohydrate_grams REAL NOT NULL DEFAULT 0 CHECK(carbohydrate_grams >= 0 AND carbohydrate_grams <= 1000),
|
||||
fat_grams REAL NOT NULL DEFAULT 0 CHECK(fat_grams >= 0 AND fat_grams <= 1000),
|
||||
consumed_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK(source IN ('manual','photo_ai')),
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending' CHECK(sync_status IN ('pending','synced','failed')),
|
||||
last_sync_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_meals_consumed_at ON meal_entries(consumed_at DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_meals_sync_status ON meal_entries(sync_status, consumed_at)',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── GPS Coordinates CRUD ─────────────────────────────────────
|
||||
@@ -576,6 +606,80 @@ class DatabaseService extends GetxService {
|
||||
return result.isNotEmpty ? result.first : null;
|
||||
}
|
||||
|
||||
Future<void> saveMeal(MealEntry meal) async {
|
||||
await _db.insert(
|
||||
'meal_entries',
|
||||
{...meal.toMap(), 'updated_at': DateTime.now().toUtc().toIso8601String()},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> mergeSyncedMeal(MealEntry meal) async {
|
||||
final local = await _db.query(
|
||||
'meal_entries',
|
||||
columns: ['sync_status'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [meal.id],
|
||||
limit: 1,
|
||||
);
|
||||
if (local.isNotEmpty &&
|
||||
(local.first['sync_status'] == MealSyncStatus.pending.name ||
|
||||
local.first['sync_status'] == MealSyncStatus.failed.name)) {
|
||||
return;
|
||||
}
|
||||
meal.syncStatus = MealSyncStatus.synced;
|
||||
await saveMeal(meal);
|
||||
}
|
||||
|
||||
Future<List<MealEntry>> getMealsForDay(DateTime day) async {
|
||||
final start = DateTime(day.year, day.month, day.day).toUtc();
|
||||
final end = DateTime(day.year, day.month, day.day + 1).toUtc();
|
||||
final rows = await _db.query(
|
||||
'meal_entries',
|
||||
where: 'consumed_at >= ? AND consumed_at < ?',
|
||||
whereArgs: [start.toIso8601String(), end.toIso8601String()],
|
||||
orderBy: 'consumed_at DESC',
|
||||
);
|
||||
return rows.map(MealEntry.fromMap).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<MealEntry>> getPendingMeals({int limit = 100}) async {
|
||||
final rows = await _db.query(
|
||||
'meal_entries',
|
||||
where: "sync_status IN ('pending','failed')",
|
||||
orderBy: 'consumed_at ASC',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(MealEntry.fromMap).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<int> getPendingMealCount() async {
|
||||
final result = await _db.rawQuery(
|
||||
"SELECT COUNT(*) AS total FROM meal_entries WHERE sync_status IN ('pending','failed')",
|
||||
);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<void> updateMealSyncStatus(
|
||||
String id,
|
||||
MealSyncStatus status, {
|
||||
String? error,
|
||||
}) async {
|
||||
await _db.update(
|
||||
'meal_entries',
|
||||
{
|
||||
'sync_status': status.name,
|
||||
'last_sync_error': error,
|
||||
'updated_at': DateTime.now().toUtc().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getMealCountForDay(DateTime day) async =>
|
||||
(await getMealsForDay(day)).length;
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_db.close();
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../models/meal_entry.dart';
|
||||
import 'auth_service.dart';
|
||||
import 'database_service.dart';
|
||||
|
||||
@@ -94,7 +95,10 @@ class SyncService extends GetxService {
|
||||
}
|
||||
|
||||
/// Process all pending sync items
|
||||
Future<void> syncPendingWorkouts({bool retryNow = false}) async {
|
||||
Future<void> syncPendingWorkouts({
|
||||
bool retryNow = false,
|
||||
bool pullMeals = false,
|
||||
}) async {
|
||||
if (_isSyncing.value) return;
|
||||
_isSyncing.value = true;
|
||||
|
||||
@@ -104,24 +108,34 @@ class SyncService extends GetxService {
|
||||
return;
|
||||
}
|
||||
|
||||
await _syncPendingMeals();
|
||||
|
||||
final pendingItems = await _db.getPendingSyncs(
|
||||
includeExhausted: retryNow,
|
||||
);
|
||||
if (pendingItems.isEmpty) return;
|
||||
if (pendingItems.isNotEmpty) {
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
for (final item in pendingItems) {
|
||||
if (!retryNow && !_isRetryDue(item)) continue;
|
||||
await _syncSingleItem(
|
||||
id: item['id'] as int,
|
||||
workoutId: item['workout_id'] as String,
|
||||
payload: item['payload'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
for (final item in pendingItems) {
|
||||
if (!retryNow && !_isRetryDue(item)) continue;
|
||||
await _syncSingleItem(
|
||||
id: item['id'] as int,
|
||||
workoutId: item['workout_id'] as String,
|
||||
payload: item['payload'] as String,
|
||||
);
|
||||
await _db.removeSentFromQueue();
|
||||
}
|
||||
|
||||
await _db.removeSentFromQueue();
|
||||
await _updatePendingCount();
|
||||
|
||||
if (pullMeals) {
|
||||
try {
|
||||
await _pullRecentMeals();
|
||||
} catch (error) {
|
||||
debugPrint('[SYNC] Meal history refresh failed: $error');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SYNC] Error during sync: $e');
|
||||
} finally {
|
||||
@@ -129,6 +143,84 @@ class SyncService extends GetxService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> queueMeal(MealEntry meal) async {
|
||||
meal.syncStatus = MealSyncStatus.pending;
|
||||
await _db.saveMeal(meal);
|
||||
await _updatePendingCount();
|
||||
if (_isOnline.value && _auth.isSignedIn && !_isSyncing.value) {
|
||||
await syncPendingWorkouts();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncMealsNow() async {
|
||||
if (!_isOnline.value || !_auth.isSignedIn || _isSyncing.value) return;
|
||||
await syncPendingWorkouts(retryNow: true, pullMeals: true);
|
||||
}
|
||||
|
||||
Future<void> _syncPendingMeals() async {
|
||||
final pendingMeals = await _db.getPendingMeals();
|
||||
for (final meal in pendingMeals) {
|
||||
try {
|
||||
final response = await _auth.authenticatedPost(
|
||||
Uri.parse('${_auth.apiBase}/meals.php'),
|
||||
jsonEncode(meal.toApiPayload()),
|
||||
);
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
await _db.updateMealSyncStatus(meal.id, MealSyncStatus.synced);
|
||||
} else {
|
||||
await _db.updateMealSyncStatus(
|
||||
meal.id,
|
||||
MealSyncStatus.failed,
|
||||
error: 'HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await _db.updateMealSyncStatus(
|
||||
meal.id,
|
||||
MealSyncStatus.failed,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullRecentMeals() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final from = now.subtract(const Duration(days: 30));
|
||||
final to = now.add(const Duration(days: 1));
|
||||
final uri = Uri.parse('${_auth.apiBase}/meals.php').replace(
|
||||
queryParameters: {
|
||||
'from':
|
||||
'${from.year.toString().padLeft(4, '0')}-${from.month.toString().padLeft(2, '0')}-${from.day.toString().padLeft(2, '0')}',
|
||||
'to':
|
||||
'${to.year.toString().padLeft(4, '0')}-${to.month.toString().padLeft(2, '0')}-${to.day.toString().padLeft(2, '0')}',
|
||||
},
|
||||
);
|
||||
final response = await _auth.authenticatedGet(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw AuthException('تعذر جلب سجل الطعام (${response.statusCode}).');
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic> || decoded['meals'] is! List) return;
|
||||
for (final raw in decoded['meals'] as List) {
|
||||
if (raw is! Map<String, dynamic>) continue;
|
||||
final local = <String, dynamic>{
|
||||
'id': raw['client_meal_uuid'],
|
||||
'name': raw['name'],
|
||||
'meal_type': raw['meal_type'],
|
||||
'calories': raw['calories'],
|
||||
'protein_grams': raw['protein_grams'],
|
||||
'carbohydrate_grams': raw['carbohydrate_grams'],
|
||||
'fat_grams': raw['fat_grams'],
|
||||
'consumed_at': raw['consumed_at'],
|
||||
'source': raw['source'],
|
||||
'notes': raw['notes'],
|
||||
'sync_status': MealSyncStatus.synced.name,
|
||||
};
|
||||
await _db.mergeSyncedMeal(MealEntry.fromMap(local));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncSingleItem({
|
||||
required int id,
|
||||
required String workoutId,
|
||||
@@ -163,7 +255,8 @@ class SyncService extends GetxService {
|
||||
}
|
||||
|
||||
Future<void> _updatePendingCount() async {
|
||||
_pendingCount.value = await _db.getPendingSyncCount();
|
||||
_pendingCount.value =
|
||||
await _db.getPendingSyncCount() + await _db.getPendingMealCount();
|
||||
}
|
||||
|
||||
bool _isRetryDue(Map<String, dynamic> item) {
|
||||
|
||||
Reference in New Issue
Block a user