429 lines
12 KiB
Dart
429 lines
12 KiB
Dart
import 'json_util.dart';
|
|
|
|
/// 「强宝健康」模块的数据模型。
|
|
///
|
|
/// 后端接口一律给 kcal(换算在服务端做掉了),所以这里不会再出现千焦 ——
|
|
/// 前端只有一个能量口径。
|
|
|
|
// ==================================================================
|
|
// 体重
|
|
// ==================================================================
|
|
|
|
/// 一个体重数据点(折线图用)
|
|
class WeightPoint {
|
|
const WeightPoint({required this.date, required this.weight});
|
|
|
|
final DateTime date;
|
|
final double weight;
|
|
|
|
factory WeightPoint.fromJson(Map<String, dynamic> json) {
|
|
return WeightPoint(
|
|
date: tryParse(json['date']) ?? DateTime.now(),
|
|
weight: asDouble(json['weight']),
|
|
);
|
|
}
|
|
|
|
static DateTime? tryParse(Object? value) {
|
|
final text = asString(value).trim();
|
|
if (text.isEmpty) return null;
|
|
return DateTime.tryParse(text);
|
|
}
|
|
}
|
|
|
|
/// 体重折线图数据
|
|
class WeightChart {
|
|
const WeightChart({
|
|
required this.points,
|
|
required this.currentWeight,
|
|
required this.delta,
|
|
required this.maxWeight,
|
|
required this.minWeight,
|
|
});
|
|
|
|
final List<WeightPoint> points;
|
|
|
|
/// 最近一次体重,没记过就是 null
|
|
final double? currentWeight;
|
|
|
|
/// 与上一次记录的差值。正数 = 涨了。只有一个点时是 null。
|
|
final double? delta;
|
|
|
|
/// Y 轴范围(后端已把目标体重也算进来,避免理想线画在可见区外)
|
|
final double? maxWeight;
|
|
final double? minWeight;
|
|
|
|
static const empty = WeightChart(
|
|
points: [],
|
|
currentWeight: null,
|
|
delta: null,
|
|
maxWeight: null,
|
|
minWeight: null,
|
|
);
|
|
|
|
bool get hasData => points.isNotEmpty;
|
|
|
|
factory WeightChart.fromJson(Map<String, dynamic> json) {
|
|
return WeightChart(
|
|
points: asMapList(json['points']).map(WeightPoint.fromJson).toList(),
|
|
currentWeight: asNullableDouble(json['currentWeight']),
|
|
delta: asNullableDouble(json['delta']),
|
|
maxWeight: asNullableDouble(json['maxWeight']),
|
|
minWeight: asNullableDouble(json['minWeight']),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 一条体重记录(历史列表用)
|
|
class WeightRecordItem {
|
|
const WeightRecordItem({
|
|
required this.id,
|
|
required this.date,
|
|
required this.weight,
|
|
required this.note,
|
|
});
|
|
|
|
final int id;
|
|
final DateTime? date;
|
|
final double weight;
|
|
final String note;
|
|
|
|
factory WeightRecordItem.fromJson(Map<String, dynamic> json) {
|
|
return WeightRecordItem(
|
|
id: asInt(json['id']),
|
|
date: WeightPoint.tryParse(json['recordDate']),
|
|
weight: asDouble(json['weight']),
|
|
note: asString(json['note']),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==================================================================
|
|
// 减重目标
|
|
// ==================================================================
|
|
|
|
/// 减重目标 + 由它推出来的一整套「每周该减多少」
|
|
class WeightGoal {
|
|
const WeightGoal({
|
|
required this.hasGoal,
|
|
required this.startWeight,
|
|
required this.targetWeight,
|
|
required this.startDate,
|
|
required this.targetDate,
|
|
required this.totalWeeks,
|
|
required this.weeklyLoss,
|
|
required this.weeklyLossUnhealthy,
|
|
required this.idealCurve,
|
|
required this.currentWeight,
|
|
required this.lostWeight,
|
|
required this.remainWeight,
|
|
required this.weightProgress,
|
|
required this.timeProgress,
|
|
required this.id,
|
|
});
|
|
|
|
final bool hasGoal;
|
|
final int? id;
|
|
|
|
final double startWeight;
|
|
final double targetWeight;
|
|
|
|
final DateTime? startDate;
|
|
final DateTime? targetDate;
|
|
|
|
/// 总周数(后端向上取整)
|
|
final int totalWeeks;
|
|
|
|
/// 每周应减 kg
|
|
final double weeklyLoss;
|
|
|
|
/// 每周超过 1kg,界面上要提示「掉太快」
|
|
final bool weeklyLossUnhealthy;
|
|
|
|
/// 理想体重曲线(每周一个点),画在体重折线图上当参照
|
|
final List<WeightPoint> idealCurve;
|
|
|
|
final double? currentWeight;
|
|
|
|
/// 已减 / 还差。没记过体重时是 null。
|
|
final double? lostWeight;
|
|
final double? remainWeight;
|
|
|
|
/// 0.0 ~ 1.0
|
|
final double weightProgress;
|
|
final double timeProgress;
|
|
|
|
static const none = WeightGoal(
|
|
hasGoal: false,
|
|
startWeight: 0,
|
|
targetWeight: 0,
|
|
startDate: null,
|
|
targetDate: null,
|
|
totalWeeks: 0,
|
|
weeklyLoss: 0,
|
|
weeklyLossUnhealthy: false,
|
|
idealCurve: [],
|
|
currentWeight: null,
|
|
lostWeight: null,
|
|
remainWeight: null,
|
|
weightProgress: 0,
|
|
timeProgress: 0,
|
|
id: null,
|
|
);
|
|
|
|
factory WeightGoal.fromJson(Map<String, dynamic> json) {
|
|
if (json['hasGoal'] != true) return none;
|
|
return WeightGoal(
|
|
hasGoal: true,
|
|
id: asNullableInt(json['id']),
|
|
startWeight: asDouble(json['startWeight']),
|
|
targetWeight: asDouble(json['targetWeight']),
|
|
startDate: WeightPoint.tryParse(json['startDate']),
|
|
targetDate: WeightPoint.tryParse(json['targetDate']),
|
|
totalWeeks: asInt(json['totalWeeks']),
|
|
weeklyLoss: asDouble(json['weeklyLoss']),
|
|
weeklyLossUnhealthy: json['weeklyLossUnhealthy'] == true,
|
|
idealCurve:
|
|
asMapList(json['idealCurve']).map(WeightPoint.fromJson).toList(),
|
|
currentWeight: asNullableDouble(json['currentWeight']),
|
|
lostWeight: asNullableDouble(json['lostWeight']),
|
|
remainWeight: asNullableDouble(json['remainWeight']),
|
|
weightProgress: asDouble(json['weightProgress']),
|
|
timeProgress: asDouble(json['timeProgress']),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==================================================================
|
|
// 食物 + 饮食
|
|
// ==================================================================
|
|
|
|
/// 一个份量档位,如「一碗 150g」
|
|
class FoodPortion {
|
|
const FoodPortion({required this.label, required this.grams});
|
|
|
|
final String label;
|
|
final int grams;
|
|
|
|
factory FoodPortion.fromJson(Map<String, dynamic> json) {
|
|
return FoodPortion(
|
|
label: asString(json['label']),
|
|
grams: asInt(json['grams']),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 一条食物(营养值都是「每 100g」)
|
|
class FoodItem {
|
|
const FoodItem({
|
|
required this.id,
|
|
required this.name,
|
|
required this.alias,
|
|
required this.categoryName,
|
|
required this.subCategoryName,
|
|
required this.ediblePercent,
|
|
required this.energyKcal,
|
|
required this.protein,
|
|
required this.fat,
|
|
required this.carbohydrate,
|
|
required this.dietaryFiber,
|
|
required this.sodium,
|
|
required this.portions,
|
|
});
|
|
|
|
final int id;
|
|
final String name;
|
|
final String alias;
|
|
final String categoryName;
|
|
final String subCategoryName;
|
|
|
|
/// 食部 %。展示用参考,不参与热量换算。
|
|
final double? ediblePercent;
|
|
|
|
/// 每 100g 的能量 kcal(后端已经把 kJ 换掉了)
|
|
final double? energyKcal;
|
|
|
|
final double? protein;
|
|
final double? fat;
|
|
final double? carbohydrate;
|
|
final double? dietaryFiber;
|
|
final double? sodium;
|
|
|
|
/// 份量档位。后端保证非空。
|
|
final List<FoodPortion> portions;
|
|
|
|
factory FoodItem.fromJson(Map<String, dynamic> json) {
|
|
return FoodItem(
|
|
id: asInt(json['id']),
|
|
name: asNonEmpty(json['name'], '未命名食物'),
|
|
alias: asString(json['alias']),
|
|
categoryName: asString(json['categoryName']),
|
|
subCategoryName: asString(json['subCategoryName']),
|
|
ediblePercent: asNullableDouble(json['ediblePercent']),
|
|
energyKcal: asNullableDouble(json['energyKcal']),
|
|
protein: asNullableDouble(json['protein']),
|
|
fat: asNullableDouble(json['fat']),
|
|
carbohydrate: asNullableDouble(json['carbohydrate']),
|
|
dietaryFiber: asNullableDouble(json['dietaryFiber']),
|
|
sodium: asNullableDouble(json['sodium']),
|
|
portions:
|
|
asMapList(json['portions']).map(FoodPortion.fromJson).toList(),
|
|
);
|
|
}
|
|
|
|
/// 按份量算这条食物实际摄入的热量(前端预览用,服务端会独立再算一次)
|
|
double kcalFor(num grams) {
|
|
if (energyKcal == null || grams <= 0) return 0;
|
|
return energyKcal! * grams / 100;
|
|
}
|
|
}
|
|
|
|
/// 食物分类 + 该分类下的食物数
|
|
class FoodCategory {
|
|
const FoodCategory({required this.name, required this.count});
|
|
|
|
final String name;
|
|
final int count;
|
|
|
|
factory FoodCategory.fromJson(Map<String, dynamic> json) {
|
|
return FoodCategory(
|
|
name: asNonEmpty(json['name'], '未分类'),
|
|
count: asInt(json['count']),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 一条饮食记录
|
|
class DietRecordItem {
|
|
const DietRecordItem({
|
|
required this.id,
|
|
required this.mealType,
|
|
required this.foodName,
|
|
required this.amountGrams,
|
|
required this.energyKcal,
|
|
required this.protein,
|
|
required this.fat,
|
|
required this.carbohydrate,
|
|
required this.dietaryFiber,
|
|
required this.sodium,
|
|
});
|
|
|
|
final int id;
|
|
final String mealType;
|
|
final String foodName;
|
|
final double amountGrams;
|
|
final double energyKcal;
|
|
final double protein;
|
|
final double fat;
|
|
final double carbohydrate;
|
|
final double dietaryFiber;
|
|
final double sodium;
|
|
|
|
factory DietRecordItem.fromJson(Map<String, dynamic> json) {
|
|
return DietRecordItem(
|
|
id: asInt(json['id']),
|
|
mealType: asNonEmpty(json['mealType'], 'snack'),
|
|
foodName: asNonEmpty(json['foodName'], '未命名食物'),
|
|
amountGrams: asDouble(json['amountGrams']),
|
|
energyKcal: asDouble(json['energyKcal']),
|
|
protein: asDouble(json['protein']),
|
|
fat: asDouble(json['fat']),
|
|
carbohydrate: asDouble(json['carbohydrate']),
|
|
dietaryFiber: asDouble(json['dietaryFiber']),
|
|
sodium: asDouble(json['sodium']),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 营养合计
|
|
class NutritionTotals {
|
|
const NutritionTotals({
|
|
this.energyKcal = 0,
|
|
this.protein = 0,
|
|
this.fat = 0,
|
|
this.carbohydrate = 0,
|
|
this.dietaryFiber = 0,
|
|
this.sodium = 0,
|
|
this.itemCount = 0,
|
|
});
|
|
|
|
final double energyKcal;
|
|
final double protein;
|
|
final double fat;
|
|
final double carbohydrate;
|
|
final double dietaryFiber;
|
|
final double sodium;
|
|
final int itemCount;
|
|
|
|
static const zero = NutritionTotals();
|
|
|
|
factory NutritionTotals.fromJson(Map<String, dynamic> json) {
|
|
return NutritionTotals(
|
|
energyKcal: asDouble(json['energyKcal']),
|
|
protein: asDouble(json['protein']),
|
|
fat: asDouble(json['fat']),
|
|
carbohydrate: asDouble(json['carbohydrate']),
|
|
dietaryFiber: asDouble(json['dietaryFiber']),
|
|
sodium: asDouble(json['sodium']),
|
|
itemCount: asInt(json['itemCount']),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 一个餐次的分组
|
|
class MealGroup {
|
|
const MealGroup({
|
|
required this.mealType,
|
|
required this.totals,
|
|
required this.items,
|
|
});
|
|
|
|
final String mealType;
|
|
final NutritionTotals totals;
|
|
final List<DietRecordItem> items;
|
|
|
|
factory MealGroup.fromJson(Map<String, dynamic> json) {
|
|
return MealGroup(
|
|
mealType: asNonEmpty(json['mealType'], 'snack'),
|
|
totals: NutritionTotals.fromJson(
|
|
(json['totals'] as Map?)?.cast<String, dynamic>() ?? const {}),
|
|
items: asMapList(json['items']).map(DietRecordItem.fromJson).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 某一天的饮食情况
|
|
class DietDay {
|
|
const DietDay({
|
|
required this.date,
|
|
required this.totals,
|
|
required this.meals,
|
|
});
|
|
|
|
final DateTime? date;
|
|
final NutritionTotals totals;
|
|
|
|
/// 固定 4 个餐次(后端保留了空餐次,前端不用补位)
|
|
final List<MealGroup> meals;
|
|
|
|
static const empty = DietDay(date: null, totals: NutritionTotals.zero, meals: []);
|
|
|
|
factory DietDay.fromJson(Map<String, dynamic> json) {
|
|
return DietDay(
|
|
date: WeightPoint.tryParse(json['date']),
|
|
totals: NutritionTotals.fromJson(
|
|
(json['totals'] as Map?)?.cast<String, dynamic>() ?? const {}),
|
|
meals: asMapList(json['meals']).map(MealGroup.fromJson).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 餐次的中文名与显示顺序
|
|
const Map<String, String> kMealLabels = {
|
|
'breakfast': '早餐',
|
|
'lunch': '午餐',
|
|
'dinner': '晚餐',
|
|
'snack': '加餐',
|
|
};
|
|
|
|
String mealLabel(String mealType) => kMealLabels[mealType] ?? '加餐';
|