Add offline meal journal and secure sync

This commit is contained in:
Hamza-Ayed
2026-10-04 02:13:23 +03:00
parent d892e3d981
commit 27fc2d5eb1
12 changed files with 936 additions and 23 deletions
+4
View File
@@ -78,6 +78,10 @@ Git: مصدر الإصدار → SSH:2101 → نسخة إصدار → تبديل
أضيفت الآن صفحة `/admin/` عربية لإدارة الحركات والخطط، ودخول OTP للحساب الموجود مسبقًا. لا تسمح الواجهة بالمستخدم العادي؛ يتحقق الخادم من دور `owner` أو `content_manager` لكل طلب. واجهة `/api/v1/admin/content.php` تقرأ وتكتب التمارين والخطط والجلسات وبيانات الوسائط، وتقيّد روابط GIF إلى HTTPS، وتتحقق من القيم والحدود، كما تمنع سحب نشر تمرين مرتبط بخطة منشورة. تسجل تغييرات المحتوى في `training_content_audit` عبر migration `004_content_audit.sql`. هذه لوحة تحرير أولية: جدول جلسات الخطة يحرر حاليًا كـJSON، ولا تتضمن بعد محرر رفع ملفات أو مراجعة مصادر مدمجة. يجب تطبيق migrations بالترتيب `001` إلى `004` على staging بعد النسخ الاحتياطي، وإنشاء حساب مالك بدور صحيح خارج الواجهة؛ لم تُطبق بعد على قاعدة حية. أضيفت الآن صفحة `/admin/` عربية لإدارة الحركات والخطط، ودخول OTP للحساب الموجود مسبقًا. لا تسمح الواجهة بالمستخدم العادي؛ يتحقق الخادم من دور `owner` أو `content_manager` لكل طلب. واجهة `/api/v1/admin/content.php` تقرأ وتكتب التمارين والخطط والجلسات وبيانات الوسائط، وتقيّد روابط GIF إلى HTTPS، وتتحقق من القيم والحدود، كما تمنع سحب نشر تمرين مرتبط بخطة منشورة. تسجل تغييرات المحتوى في `training_content_audit` عبر migration `004_content_audit.sql`. هذه لوحة تحرير أولية: جدول جلسات الخطة يحرر حاليًا كـJSON، ولا تتضمن بعد محرر رفع ملفات أو مراجعة مصادر مدمجة. يجب تطبيق migrations بالترتيب `001` إلى `004` على staging بعد النسخ الاحتياطي، وإنشاء حساب مالك بدور صحيح خارج الواجهة؛ لم تُطبق بعد على قاعدة حية.
أضيف سجل وجبات عربي أولي في التطبيق: إدخال الاسم والنوع والوقت والسعرات والمغذيات التي يحددها المستخدم، تصحيح السجل، جمع يومي، تخزين SQLite دون اتصال، ثم رفع idempotent بمعرّف UUID إلى `/api/v1/meals.php` ضمن هوية المستخدم. الترحيل `005_meal_journal.sql` يضيف السجل الخاص بالمستخدم. القيم الموسومة حاليًا «تقدير» وليست مخرجات AI. لم يجر بعد اختيار مزود رؤية أو بناء مسار رفع صور آمن وتحليل قابل للمراجعة؛ لذلك لم نعرض زر تصوير وهميًا ولم نسمح للعميل بادعاء أن الوجبة حُللت آليًا. يجب تطبيق migrations `001` إلى `005` بالترتيب على staging بعد أخذ نسخة احتياطية؛ لم تُطبق على MySQL حية.
للتثبيت الجديد، `backend/schema.sql` يتضمن الآن جداول سجل الوجبات وتدقيق المحتوى. لا تشغّل migrations `001` إلى `005` فوق قاعدة أنشئت كاملًا من `schema.sql`؛ تلك للمخططات القائمة التي تحتاج ترقية، وتُختار بعد مقارنة version/schema والنسخة الاحتياطية.
قاعدة البيانات في CloudPanel تنشأ باسم ومستخدم مخصصين من واجهة الإدارة، بصلاحيات قاعدة التطبيق فقط، واتصال محلي إن كانت PHP وMySQL على المضيف نفسه. تحفظ نسخة احتياطية دورية وتختبر استعادتها. اسم قاعدة البيانات والمستخدم وكلمة المرور الفعلية تأتي من بيئة الخادم؛ لا تضاف إلى Flutter أو Git. قاعدة البيانات في CloudPanel تنشأ باسم ومستخدم مخصصين من واجهة الإدارة، بصلاحيات قاعدة التطبيق فقط، واتصال محلي إن كانت PHP وMySQL على المضيف نفسه. تحفظ نسخة احتياطية دورية وتختبر استعادتها. اسم قاعدة البيانات والمستخدم وكلمة المرور الفعلية تأتي من بيئة الخادم؛ لا تضاف إلى Flutter أو Git.
## إعداد Git وبيئة المشروع ## إعداد Git وبيئة المشروع
+27
View File
@@ -0,0 +1,27 @@
-- User-owned meal journal. Photos are deliberately not stored in these rows.
-- Apply after migrations 001-004 with a verified database backup.
CREATE TABLE meal_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
client_meal_uuid CHAR(36) NOT NULL,
name VARCHAR(180) NOT NULL,
meal_type ENUM('breakfast', 'lunch', 'dinner', 'snack') NOT NULL,
calories DECIMAL(8,2) NOT NULL,
protein_grams DECIMAL(7,2) NOT NULL DEFAULT 0,
carbohydrate_grams DECIMAL(7,2) NOT NULL DEFAULT 0,
fat_grams DECIMAL(7,2) NOT NULL DEFAULT 0,
consumed_at DATETIME NOT NULL,
source ENUM('manual', 'photo_ai') NOT NULL DEFAULT 'manual',
notes VARCHAR(1000) NOT NULL DEFAULT '',
revision INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_user_client_uuid (user_id, client_meal_uuid),
INDEX idx_meals_user_consumed (user_id, consumed_at DESC),
CONSTRAINT fk_meals_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (calories >= 0 AND calories <= 10000),
CHECK (protein_grams >= 0 AND protein_grams <= 1000),
CHECK (carbohydrate_grams >= 0 AND carbohydrate_grams <= 1000),
CHECK (fat_grams >= 0 AND fat_grams <= 1000)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+40
View File
@@ -124,6 +124,46 @@ CREATE TABLE training_session_exercises (
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_content_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
entity_type ENUM('exercise', 'plan') NOT NULL,
entity_uuid CHAR(36) NOT NULL,
action ENUM('create', 'update', 'publish', 'unpublish') NOT NULL,
previous_value JSON NULL,
new_value JSON NOT NULL,
actor_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_content_audit_entity_time (entity_type, entity_uuid, created_at),
INDEX idx_content_audit_actor_time (actor_user_id, created_at),
CONSTRAINT fk_content_audit_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE meal_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
client_meal_uuid CHAR(36) NOT NULL,
name VARCHAR(180) NOT NULL,
meal_type ENUM('breakfast', 'lunch', 'dinner', 'snack') NOT NULL,
calories DECIMAL(8,2) NOT NULL,
protein_grams DECIMAL(7,2) NOT NULL DEFAULT 0,
carbohydrate_grams DECIMAL(7,2) NOT NULL DEFAULT 0,
fat_grams DECIMAL(7,2) NOT NULL DEFAULT 0,
consumed_at DATETIME NOT NULL,
source ENUM('manual', 'photo_ai') NOT NULL DEFAULT 'manual',
notes VARCHAR(1000) NOT NULL DEFAULT '',
revision INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_user_client_uuid (user_id, client_meal_uuid),
INDEX idx_meals_user_consumed (user_id, consumed_at DESC),
CONSTRAINT fk_meals_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (calories >= 0 AND calories <= 10000),
CHECK (protein_grams >= 0 AND protein_grams <= 1000),
CHECK (carbohydrate_grams >= 0 AND carbohydrate_grams <= 1000),
CHECK (fat_grams >= 0 AND fat_grams <= 1000)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Phone OTP challenges contain keyed digests, never the plaintext code. -- Phone OTP challenges contain keyed digests, never the plaintext code.
CREATE TABLE otp_challenges ( CREATE TABLE otp_challenges (
challenge_uuid CHAR(36) PRIMARY KEY, challenge_uuid CHAR(36) PRIMARY KEY,
+2 -2
View File
@@ -1,6 +1,6 @@
# SportPath mobile # SportPath mobile
تطبيق Flutter عربي لحفظ جلسات المشي والجري محليًا، تسجيل الدخول برقم الهاتف، ومزامنة التمارين إلى PHP API. يحتوي المشروع الآن منصتي iOS وAndroid؛ `android/` يستخدم معرّف تطوير مؤقتًا `com.example.fitness_tracker` ويجب استبداله بمعرّف الحزمة الذي سيعتمده مالك المنتج قبل النشر على المتاجر. تطبيق Flutter عربي لحفظ جلسات المشي والجري وسجل الوجبات محليًا، تسجيل الدخول برقم الهاتف، ومزامنة بيانات المستخدم مع PHP API. سجل الطعام الحالي إدخال يدوي قابل للتصحيح، وليس تحليلًا بصريًا بالذكاء الاصطناعي. يحتوي المشروع الآن منصتي iOS وAndroid؛ `android/` يستخدم معرّف تطوير مؤقتًا `com.example.fitness_tracker` ويجب استبداله بمعرّف الحزمة الذي سيعتمده مالك المنتج قبل النشر على المتاجر.
## عنوان خادم API ## عنوان خادم 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 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 إلى حين تسجيل الدخول والاتصال. لا تحذف التطبيق أو بياناته أثناء المزامنة إن كان لديك سجل غير متزامن. جلسة التطبيق تحتفظ بـrefresh token في التخزين الآمن للنظام وتحدّث access token تلقائيًا عند استجابة 401. بيانات GPS والتمارين تبقى محليًا في SQLite/outbox إلى حين تسجيل الدخول والاتصال. لا تحذف التطبيق أو بياناته أثناء المزامنة إن كان لديك سجل غير متزامن.
+84
View File
@@ -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;
+14 -5
View File
@@ -6,6 +6,7 @@ import '../screens/history_screen.dart';
import '../services/sync_service.dart'; import '../services/sync_service.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import 'training_screens.dart'; import 'training_screens.dart';
import 'meal_journal_screen.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
import 'phone_login_screen.dart'; import 'phone_login_screen.dart';
@@ -167,8 +168,9 @@ class HomeScreen extends StatelessWidget {
const _HabitCard( const _HabitCard(
icon: Icons.restaurant_outlined, icon: Icons.restaurant_outlined,
title: 'وجبتك القادمة', title: 'وجبتك القادمة',
detail: 'سجّلها يدويًا الآن؛ تحليل الصور قيد الإعداد.', detail: 'سجّلها يدويًا أو صحّح تقديرك.',
tag: 'تغذية'), tag: 'تغذية',
onTap: _openMealJournal),
const SizedBox(height: 9), const SizedBox(height: 9),
const _HabitCard( const _HabitCard(
icon: Icons.water_drop_outlined, icon: Icons.water_drop_outlined,
@@ -349,11 +351,16 @@ class _HabitCard extends StatelessWidget {
{required this.icon, {required this.icon,
required this.title, required this.title,
required this.detail, required this.detail,
required this.tag}); required this.tag,
this.onTap});
final IconData icon; final IconData icon;
final String title, detail, tag; final String title, detail, tag;
final VoidCallback? onTap;
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(15)), color: Colors.white, borderRadius: BorderRadius.circular(15)),
@@ -383,5 +390,7 @@ class _HabitCard extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
color: _green, fontSize: 10, fontWeight: FontWeight.w600)), color: _green, fontSize: 10, fontWeight: FontWeight.w600)),
]), ]),
); ));
} }
void _openMealJournal() => Get.to(() => const MealJournalScreen());
+419
View File
@@ -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 => 'تعذرت المزامنة',
};
+17
View File
@@ -119,6 +119,23 @@ class AuthService {
return response; 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 { Future<bool> _refreshSession() async {
final activeRefresh = _refreshing; final activeRefresh = _refreshing;
if (activeRefresh != null) return activeRefresh; if (activeRefresh != null) return activeRefresh;
+105 -1
View File
@@ -5,12 +5,13 @@ import 'package:path/path.dart';
import '../models/gps_coordinate.dart'; import '../models/gps_coordinate.dart';
import '../models/workout.dart'; import '../models/workout.dart';
import '../models/workout_segment.dart'; import '../models/workout_segment.dart';
import '../models/meal_entry.dart';
/// خدمة قاعدة البيانات المحلية /// خدمة قاعدة البيانات المحلية
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة /// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
class DatabaseService extends GetxService { class DatabaseService extends GetxService {
static const String _dbName = 'sportpath_tracker.db'; static const String _dbName = 'sportpath_tracker.db';
static const int _dbVersion = 3; static const int _dbVersion = 4;
late Database _db; late Database _db;
@@ -142,6 +143,8 @@ class DatabaseService extends GetxService {
'CREATE INDEX idx_sync_workout ON sync_queue(workout_id)', 'CREATE INDEX idx_sync_workout ON sync_queue(workout_id)',
); );
await _createMealTable(db);
debugPrint('[DB] All tables created successfully'); 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)', '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 ───────────────────────────────────── // ─── GPS Coordinates CRUD ─────────────────────────────────────
@@ -576,6 +606,80 @@ class DatabaseService extends GetxService {
return result.isNotEmpty ? result.first : null; 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 @override
void onClose() { void onClose() {
_db.close(); _db.close();
+106 -13
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import '../models/workout.dart'; import '../models/workout.dart';
import '../models/meal_entry.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'database_service.dart'; import 'database_service.dart';
@@ -94,7 +95,10 @@ class SyncService extends GetxService {
} }
/// Process all pending sync items /// 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; if (_isSyncing.value) return;
_isSyncing.value = true; _isSyncing.value = true;
@@ -104,24 +108,34 @@ class SyncService extends GetxService {
return; return;
} }
await _syncPendingMeals();
final pendingItems = await _db.getPendingSyncs( final pendingItems = await _db.getPendingSyncs(
includeExhausted: retryNow, 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) { await _db.removeSentFromQueue();
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 _updatePendingCount(); await _updatePendingCount();
if (pullMeals) {
try {
await _pullRecentMeals();
} catch (error) {
debugPrint('[SYNC] Meal history refresh failed: $error');
}
}
} catch (e) { } catch (e) {
debugPrint('[SYNC] Error during sync: $e'); debugPrint('[SYNC] Error during sync: $e');
} finally { } 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({ Future<void> _syncSingleItem({
required int id, required int id,
required String workoutId, required String workoutId,
@@ -163,7 +255,8 @@ class SyncService extends GetxService {
} }
Future<void> _updatePendingCount() async { Future<void> _updatePendingCount() async {
_pendingCount.value = await _db.getPendingSyncCount(); _pendingCount.value =
await _db.getPendingSyncCount() + await _db.getPendingMealCount();
} }
bool _isRetryDue(Map<String, dynamic> item) { bool _isRetryDue(Map<String, dynamic> item) {
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once dirname(__DIR__, 4) . '/backend/Config.php'; require_once dirname(__DIR__, 3) . '/backend/Config.php';
require_once dirname(__DIR__, 4) . '/backend/Database.php'; require_once dirname(__DIR__, 3) . '/backend/Database.php';
function api_json(array $payload, int $status = 200): void function api_json(array $payload, int $status = 200): void
{ {
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/backend/JwtToken.php';
require_once dirname(__DIR__, 3) . '/backend/ApiAuth.php';
require_once __DIR__ . '/_bootstrap.php';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
api_json(['error' => 'method_not_allowed'], 405);
}
$auth = ApiAuth::bearerClaims();
function meal_api_number($value, string $field, float $max): float
{
if (!is_int($value) && !is_float($value)) api_json(['error' => 'invalid_' . $field], 400);
$number = (float) $value;
if (!is_finite($number) || $number < 0 || $number > $max) api_json(['error' => 'invalid_' . $field], 400);
return $number;
}
function meal_api_date(string $value): ?DateTimeImmutable
{
try {
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
return $date->setTimezone(new DateTimeZone('UTC'));
} catch (Throwable $exception) {
return null;
}
}
try {
$db = Database::getInstance();
if ($method === 'GET') {
$fromRaw = $_GET['from'] ?? gmdate('Y-m-d', strtotime('-30 days'));
$toRaw = $_GET['to'] ?? gmdate('Y-m-d', strtotime('+1 day'));
if (!is_string($fromRaw) || !is_string($toRaw)) api_json(['error' => 'invalid_date_range'], 400);
$from = meal_api_date($fromRaw);
$to = meal_api_date($toRaw);
if ($from === null || $to === null || $from >= $to || $to->diff($from)->days > 92) api_json(['error' => 'invalid_date_range'], 400);
$userId = $auth['user_id'];
$fromSql = $from->format('Y-m-d H:i:s');
$toSql = $to->format('Y-m-d H:i:s');
$query = $db->prepare('SELECT client_meal_uuid, name, meal_type, calories, protein_grams, carbohydrate_grams, fat_grams, consumed_at, source, notes, revision, updated_at FROM meal_entries WHERE user_id = ? AND consumed_at >= ? AND consumed_at < ? ORDER BY consumed_at DESC LIMIT 2000');
$query->bind_param('iss', $userId, $fromSql, $toSql);
$query->execute();
$result = $query->get_result();
$entries = [];
while ($row = $result->fetch_assoc()) {
foreach (['calories', 'protein_grams', 'carbohydrate_grams', 'fat_grams'] as $key) $row[$key] = (float) $row[$key];
$row['revision'] = (int) $row['revision'];
$row['consumed_at'] = gmdate('Y-m-d\\TH:i:s\\Z', strtotime($row['consumed_at'] . ' UTC'));
$row['updated_at'] = gmdate('Y-m-d\\TH:i:s\\Z', strtotime($row['updated_at'] . ' UTC'));
$entries[] = $row;
}
$query->close();
header('Cache-Control: no-store');
api_json(['meals' => $entries]);
}
$rawBody = file_get_contents('php://input') ?: '';
if (strlen($rawBody) > 16000) api_json(['error' => 'payload_too_large'], 413);
$body = json_decode($rawBody, true);
if (!is_array($body)) api_json(['error' => 'invalid_meal_payload'], 400);
$clientUuid = $body['client_meal_uuid'] ?? null;
if (!is_string($clientUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $clientUuid)) api_json(['error' => 'invalid_client_meal_uuid'], 400);
$name = $body['name'] ?? null;
if (!is_string($name) || trim($name) === '' || mb_strlen($name) > 180) api_json(['error' => 'invalid_name'], 400);
$mealType = $body['meal_type'] ?? null;
if (!is_string($mealType) || !in_array($mealType, ['breakfast', 'lunch', 'dinner', 'snack'], true)) api_json(['error' => 'invalid_meal_type'], 400);
$source = $body['source'] ?? 'manual';
// Until a server-side vision provider exists, clients cannot label guesses as AI analysis.
if ($source !== 'manual') api_json(['error' => 'unsupported_meal_source'], 400);
$calories = meal_api_number($body['calories'] ?? null, 'calories', 10000);
$protein = meal_api_number($body['protein_grams'] ?? 0, 'protein_grams', 1000);
$carbs = meal_api_number($body['carbohydrate_grams'] ?? 0, 'carbohydrate_grams', 1000);
$fat = meal_api_number($body['fat_grams'] ?? 0, 'fat_grams', 1000);
$consumedRaw = $body['consumed_at'] ?? null;
$consumed = is_string($consumedRaw) ? meal_api_date($consumedRaw) : null;
if ($consumed === null) api_json(['error' => 'invalid_consumed_at'], 400);
$consumedSql = $consumed->format('Y-m-d H:i:s');
$notes = $body['notes'] ?? '';
if (!is_string($notes) || mb_strlen($notes) > 1000) api_json(['error' => 'invalid_notes'], 400);
$name = trim($name);
$notes = trim($notes);
$userId = $auth['user_id'];
$connection = $db->getConnection();
$connection->begin_transaction();
$lookup = $db->prepare('SELECT id FROM meal_entries WHERE user_id = ? AND client_meal_uuid = ? FOR UPDATE');
$lookup->bind_param('is', $userId, $clientUuid);
$lookup->execute();
$existing = $lookup->get_result()->fetch_assoc();
$lookup->close();
if ($existing) {
$update = $db->prepare('UPDATE meal_entries SET name = ?, meal_type = ?, calories = ?, protein_grams = ?, carbohydrate_grams = ?, fat_grams = ?, consumed_at = ?, source = \'manual\', notes = ?, revision = revision + 1 WHERE user_id = ? AND client_meal_uuid = ?');
$update->bind_param('ssddddssis', $name, $mealType, $calories, $protein, $carbs, $fat, $consumedSql, $notes, $userId, $clientUuid);
$update->execute();
$update->close();
$status = 200;
} else {
$insert = $db->prepare('INSERT INTO meal_entries (user_id, client_meal_uuid, name, meal_type, calories, protein_grams, carbohydrate_grams, fat_grams, consumed_at, source, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, \'manual\', ?)');
$insert->bind_param('isssddddss', $userId, $clientUuid, $name, $mealType, $calories, $protein, $carbs, $fat, $consumedSql, $notes);
$insert->execute();
$insert->close();
$status = 201;
}
$connection->commit();
api_json(['status' => 'saved', 'client_meal_uuid' => $clientUuid], $status);
} catch (Throwable $exception) {
if (isset($connection) && $connection instanceof mysqli) {
try { $connection->rollback(); } catch (Throwable $ignored) {}
}
error_log('Meal journal request failed: ' . $exception->getMessage());
api_json(['error' => 'service_unavailable'], 503);
}