Add offline meal journal and secure sync
This commit is contained in:
@@ -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