420 lines
14 KiB
Dart
420 lines
14 KiB
Dart
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 => 'تعذرت المزامنة',
|
|
};
|