强宝健康模块
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4FA98A" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.199999999999999 9.2h1.4000000000000004a1.4 1.4 0 0 1 1.4 1.4v2.8a1.4 1.4 0 0 1 -1.4 1.4h-1.4000000000000004a1.4 1.4 0 0 1 -1.4 -1.4v-2.8a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||
<path d="M7.800000000000001 7.4h0.6000000000000001a1.4 1.4 0 0 1 1.4 1.4v6.3999999999999995a1.4 1.4 0 0 1 -1.4 1.4h-0.6000000000000001a1.4 1.4 0 0 1 -1.4 -1.4v-6.3999999999999995a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||
<path d="M9.8 12h4.4" fill="none"/>
|
||||
<path d="M15.6 7.4h0.6000000000000001a1.4 1.4 0 0 1 1.4 1.4v6.3999999999999995a1.4 1.4 0 0 1 -1.4 1.4h-0.6000000000000001a1.4 1.4 0 0 1 -1.4 -1.4v-6.3999999999999995a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||
<path d="M18.4 9.2h1.4000000000000004a1.4 1.4 0 0 1 1.4 1.4v2.8a1.4 1.4 0 0 1 -1.4 1.4h-1.4000000000000004a1.4 1.4 0 0 1 -1.4 -1.4v-2.8a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,257 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/network/dio_client.dart';
|
||||
import '../models/health.dart';
|
||||
import '../models/json_util.dart';
|
||||
|
||||
final healthApiProvider =
|
||||
Provider<HealthApi>((ref) => HealthApi(ref.watch(dioProvider)));
|
||||
|
||||
/// 「强宝健康」接口:体重 / 减重目标 / 饮食 / 食物库
|
||||
///
|
||||
/// 后端路径都挂在 /api/health/ 下,dio 的 baseUrl 已含 /api。
|
||||
class HealthApi {
|
||||
final Dio _dio;
|
||||
|
||||
HealthApi(this._dio);
|
||||
|
||||
// ================================================================
|
||||
// 体重
|
||||
// ================================================================
|
||||
|
||||
/// 记一次体重。同一天再记就是覆盖(后端保证)。
|
||||
///
|
||||
/// [note] 不传 = 不改动已有备注;传空串 = 清空。
|
||||
Future<void> saveWeight({
|
||||
required double weight,
|
||||
DateTime? date,
|
||||
String? note,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.post('/health/weight', data: {
|
||||
'weight': weight,
|
||||
if (date != null) 'recordDate': _dateText(date),
|
||||
'note': ?note,
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '体重保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 折线图数据。[days] 是时间窗(默认 90 天)。
|
||||
Future<WeightChart> weightChart({int days = 90}) async {
|
||||
try {
|
||||
final res = await _dio.get('/health/weight/chart',
|
||||
queryParameters: {'days': days});
|
||||
return WeightChart.fromJson(_asMap(res.data));
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '体重数据加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<WeightRecordItem>> weightHistory() async {
|
||||
try {
|
||||
final res = await _dio.get('/health/weight/history');
|
||||
return asMapListFrom(res.data).map(WeightRecordItem.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '历史记录加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteWeight(int id) async {
|
||||
try {
|
||||
await _dio.delete('/health/weight/$id');
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 减重目标
|
||||
// ================================================================
|
||||
|
||||
Future<WeightGoal> goal() async {
|
||||
try {
|
||||
final res = await _dio.get('/health/weight/goal');
|
||||
return WeightGoal.fromJson(_asMap(res.data));
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '目标加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置目标。会覆盖当前进行中的目标。
|
||||
Future<WeightGoal> saveGoal({
|
||||
required double startWeight,
|
||||
required double targetWeight,
|
||||
required DateTime startDate,
|
||||
required DateTime targetDate,
|
||||
}) async {
|
||||
try {
|
||||
final res = await _dio.post('/health/weight/goal', data: {
|
||||
'startWeight': startWeight,
|
||||
'targetWeight': targetWeight,
|
||||
'startDate': _dateText(startDate),
|
||||
'targetDate': _dateText(targetDate),
|
||||
});
|
||||
return WeightGoal.fromJson(_asMap(res.data));
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '目标保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 饮食
|
||||
// ================================================================
|
||||
|
||||
/// 某天的饮食明细 + 全天合计
|
||||
Future<DietDay> dietDay({DateTime? date}) async {
|
||||
try {
|
||||
final res = await _dio.get('/health/diet/day', queryParameters: {
|
||||
if (date != null) 'date': _dateText(date),
|
||||
});
|
||||
return DietDay.fromJson(_asMap(res.data));
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '饮食记录加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 今日热量合计(首页卡片用)
|
||||
Future<NutritionTotals> todayTotals() async {
|
||||
try {
|
||||
final res = await _dio.get('/health/diet/today');
|
||||
final data = _asMap(res.data);
|
||||
// 这个接口只回能量和条数,别的字段是 0
|
||||
return NutritionTotals(
|
||||
energyKcal: asDouble(data['energyKcal']),
|
||||
itemCount: asInt(data['itemCount']),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '今日热量加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 加一条饮食记录。营养值由服务端算,这里只给食物 + 克数。
|
||||
Future<void> addDiet({
|
||||
required int foodId,
|
||||
required double grams,
|
||||
required String mealType,
|
||||
DateTime? date,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.post('/health/diet', data: {
|
||||
'foodId': foodId,
|
||||
'amountGrams': grams,
|
||||
'mealType': mealType,
|
||||
if (date != null) 'recordDate': _dateText(date),
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '添加失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 改份量(营养值会跟着重算)
|
||||
Future<void> updateDietAmount(int id, double grams) async {
|
||||
try {
|
||||
await _dio.put('/health/diet/$id', data: {'amountGrams': grams});
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteDiet(int id) async {
|
||||
try {
|
||||
await _dio.delete('/health/diet/$id');
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 食物库
|
||||
// ================================================================
|
||||
|
||||
Future<List<FoodItem>> searchFood(String keyword) async {
|
||||
try {
|
||||
final res = await _dio.get('/health/food/search',
|
||||
queryParameters: {'keyword': keyword});
|
||||
return asMapListFrom(res.data).map(FoodItem.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '搜索失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类浏览。[category] 为空时返回一级分类列表,
|
||||
/// 传了就返回该分类下的食物 —— 后端按参数切换返回类型。
|
||||
Future<List<FoodCategory>> foodCategories() async {
|
||||
try {
|
||||
final res = await _dio.get('/health/food/browse');
|
||||
return asMapListFrom(res.data).map(FoodCategory.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '分类加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FoodItem>> foodByCategory(String category,
|
||||
{String? subCategory}) async {
|
||||
try {
|
||||
final res = await _dio.get('/health/food/browse', queryParameters: {
|
||||
'category': category,
|
||||
'subCategory': ?subCategory,
|
||||
});
|
||||
return asMapListFrom(res.data).map(FoodItem.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '食物加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FoodCategory>> foodSubCategories(String category) async {
|
||||
try {
|
||||
final res = await _dio.get('/health/food/sub-categories',
|
||||
queryParameters: {'category': category});
|
||||
return asMapListFrom(res.data).map(FoodCategory.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
throw _asApiException(e, '子分类加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
||||
static String _dateText(DateTime date) {
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$m-$d';
|
||||
}
|
||||
|
||||
ApiException _asApiException(DioException e, String fallback) {
|
||||
final err = e.error;
|
||||
return err is ApiException ? err : ApiException(-1, fallback);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
// 饮食接口是 { success, data: {...} } 包了一层
|
||||
if (data['data'] is Map) {
|
||||
return Map<String, dynamic>.from(data['data'] as Map);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端这类接口把数组放在 { success, list: [...] } 里,
|
||||
/// 直接从 data 上取会拿到 null,这里统一拆一层。
|
||||
List<Map<String, dynamic>> asMapListFrom(Object? data) {
|
||||
if (data is Map) {
|
||||
final list = data['list'] ?? data['data'];
|
||||
if (list is List) {
|
||||
return list.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
}
|
||||
if (data is List) {
|
||||
return data.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../pages/bill/bill_home_page.dart';
|
||||
import '../../pages/bill/widgets/bill_module_summary.dart';
|
||||
import '../../pages/health/health_home_page.dart';
|
||||
import '../../pages/health/widgets/health_module_summary.dart';
|
||||
import '../../pages/note/note_list_page.dart';
|
||||
import '../../pages/note/widgets/note_module_summary.dart';
|
||||
import '../../pages/watermark/watermark_camera_page.dart';
|
||||
@@ -75,4 +77,12 @@ final List<AppModule> appModules = [
|
||||
open: (_) => const WatermarkCameraPage(),
|
||||
summary: (_) => const WatermarkModuleSummary(),
|
||||
),
|
||||
AppModule(
|
||||
id: 'health',
|
||||
name: '强宝健康',
|
||||
blurb: '减重进度、体重与每日饮食',
|
||||
icon: 'health',
|
||||
open: (_) => const HealthHomePage(),
|
||||
summary: (_) => const HealthModuleSummary(),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
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] ?? '加餐';
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../api/health_api.dart';
|
||||
import '../../models/health.dart';
|
||||
import '../../providers/health_provider.dart';
|
||||
import '../../utils/toast.dart';
|
||||
import '../../widgets/state_views.dart';
|
||||
import 'food_picker_page.dart';
|
||||
|
||||
/// 今日饮食:营养汇总 + 按餐次分组明细 + 增删改。
|
||||
class DietPage extends ConsumerStatefulWidget {
|
||||
const DietPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DietPage> createState() => _DietPageState();
|
||||
}
|
||||
|
||||
class _DietPageState extends ConsumerState<DietPage> {
|
||||
Future<void> _addTo(String mealType) async {
|
||||
final added = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => FoodPickerPage(mealType: mealType),
|
||||
),
|
||||
);
|
||||
if (added == true) {
|
||||
ref.invalidate(dietTodayProvider);
|
||||
ref.invalidate(dietTodayTotalsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final async = ref.watch(dietTodayProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: const Text('今日饮食'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: switch (async) {
|
||||
AsyncValue(hasError: true) => Center(
|
||||
child: ErrorView(
|
||||
message: '饮食记录加载失败',
|
||||
onRetry: () => ref.invalidate(dietTodayProvider),
|
||||
),
|
||||
),
|
||||
AsyncValue(:final value?) => RefreshIndicator(
|
||||
color: const Color(0xFFC8956E),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(dietTodayProvider);
|
||||
await ref.read(dietTodayProvider.future).catchError((_) => DietDay.empty);
|
||||
},
|
||||
child: _body(value),
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(DietDay day) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
|
||||
children: [
|
||||
_summaryCard(day.totals),
|
||||
const SizedBox(height: 12),
|
||||
for (final meal in day.meals) ...[
|
||||
_mealCard(meal),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 营养汇总 ----------------
|
||||
|
||||
Widget _summaryCard(NutritionTotals totals) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('今日摄入',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
_fmt(totals.energyKcal),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text('kcal',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${totals.itemCount} 条记录',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// 营养看板只显示这 5 项 —— 表里有 36 个字段,
|
||||
// 胡萝卜素、碘、锰这些日常记录用不上,全摆出来只会糊住
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _nutrient('蛋白质', totals.protein, 'g')),
|
||||
Expanded(child: _nutrient('脂肪', totals.fat, 'g')),
|
||||
Expanded(child: _nutrient('碳水', totals.carbohydrate, 'g')),
|
||||
Expanded(child: _nutrient('纤维', totals.dietaryFiber, 'g')),
|
||||
Expanded(child: _nutrient('钠', totals.sodium, 'mg')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nutrient(String label, double value, String unit) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${_fmt(value)}$unit',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 餐次 ----------------
|
||||
|
||||
Widget _mealCard(MealGroup meal) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 10, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
mealLabel(meal.mealType),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (meal.items.isNotEmpty)
|
||||
Text(
|
||||
'${_fmt(meal.totals.energyKcal)} kcal',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => _addTo(meal.mealType),
|
||||
icon: const Icon(Icons.add, size: 15),
|
||||
label: const Text('添加', style: TextStyle(fontSize: 13)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFC8956E),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (meal.items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4, bottom: 6),
|
||||
child: Text('还没记',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFFBCAAA4))),
|
||||
)
|
||||
else
|
||||
...meal.items.map((item) => _dietRow(item)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dietRow(DietRecordItem item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.foodName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 点份量可以改克数
|
||||
GestureDetector(
|
||||
onTap: () => _editAmount(item),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F4F1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${_fmt(item.amountGrams)}g',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 62,
|
||||
child: Text(
|
||||
'${_fmt(item.energyKcal)} kcal',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _delete(item),
|
||||
icon: const Icon(Icons.close, size: 15),
|
||||
color: const Color(0xFFD7CCC8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editAmount(DietRecordItem item) async {
|
||||
final ctrl = TextEditingController(text: _fmt(item.amountGrams));
|
||||
final grams = await showDialog<double>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(item.foodName, style: const TextStyle(fontSize: 16)),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
suffixText: 'g',
|
||||
labelText: '份量',
|
||||
helperText: '营养值会跟着重算',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final value = double.tryParse(ctrl.text.trim());
|
||||
Navigator.of(ctx).pop(value);
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
ctrl.dispose();
|
||||
|
||||
if (grams == null) return;
|
||||
if (grams <= 0) {
|
||||
if (!mounted) return;
|
||||
showToast(context, '份量必须大于 0', error: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(healthApiProvider).updateDietAmount(item.id, grams);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dietTodayProvider);
|
||||
ref.invalidate(dietTodayTotalsProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(DietRecordItem item) async {
|
||||
try {
|
||||
await ref.read(healthApiProvider).deleteDiet(item.id);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dietTodayProvider);
|
||||
ref.invalidate(dietTodayTotalsProvider);
|
||||
showToast(context, '已删除', icon: 'check');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
String _fmt(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../api/health_api.dart';
|
||||
import '../../models/health.dart';
|
||||
import '../../providers/health_provider.dart';
|
||||
import '../../utils/toast.dart';
|
||||
|
||||
/// 食物选择器:搜索 + 分类浏览 → 选份量 → 记入指定餐次。
|
||||
///
|
||||
/// 两层导航:分类列表 → 分类下的食物。搜索框常驻在顶部,
|
||||
/// 因为「知道吃什么」的时候搜索最快,「不知道」的时候才需要翻分类。
|
||||
class FoodPickerPage extends ConsumerStatefulWidget {
|
||||
const FoodPickerPage({super.key, required this.mealType});
|
||||
|
||||
/// 记到哪一餐
|
||||
final String mealType;
|
||||
|
||||
@override
|
||||
ConsumerState<FoodPickerPage> createState() => _FoodPickerPageState();
|
||||
}
|
||||
|
||||
class _FoodPickerPageState extends ConsumerState<FoodPickerPage> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
|
||||
String _keyword = '';
|
||||
Timer? _debounce;
|
||||
|
||||
/// 当前浏览的分类。null = 显示分类列表。
|
||||
String? _category;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: Text('添加到${mealLabel(widget.mealType)}'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_searchBar(),
|
||||
if (_category != null) _categoryBreadcrumb(),
|
||||
Expanded(child: _content()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _searchBar() {
|
||||
return Container(
|
||||
color: const Color(0xFFF7F4F1),
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 10),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
onChanged: _onSearchChanged,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜食物名,比如「米饭」「鸡蛋」',
|
||||
hintStyle:
|
||||
const TextStyle(fontSize: 13.5, color: Color(0xFFBCAAA4)),
|
||||
prefixIcon: const Icon(Icons.search,
|
||||
size: 19, color: Color(0xFFBCAAA4)),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
_searchCtrl.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const Icon(Icons.close,
|
||||
size: 17, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 11),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索时显示「返回分类」的入口 —— 否则搜过一次就回不到分类浏览了
|
||||
Widget _categoryBreadcrumb() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFF7F4F1),
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _category = null),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.arrow_back_ios_new,
|
||||
size: 11, color: Color(0xFFC8956E)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_category!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text('· 全部分类',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 320), () {
|
||||
if (!mounted) return;
|
||||
setState(() => _keyword = value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Widget _content() {
|
||||
// 搜索优先:输了关键词就不看分类了
|
||||
if (_keyword.isNotEmpty) {
|
||||
return _searchResults();
|
||||
}
|
||||
if (_category != null) {
|
||||
return _categoryFoods(_category!);
|
||||
}
|
||||
return _categoryList();
|
||||
}
|
||||
|
||||
Widget _searchResults() {
|
||||
final async = ref.watch(foodSearchProvider(_keyword));
|
||||
|
||||
return switch (async) {
|
||||
AsyncValue(hasError: true) => const Center(
|
||||
child: Text('搜索失败',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) when value.isEmpty => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'没搜到。换个词试试,或者回分类里翻',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
AsyncValue(:final value?) => ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 20),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, i) => _foodTile(value[i]),
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _categoryList() {
|
||||
final async = ref.watch(foodCategoriesProvider);
|
||||
|
||||
return switch (async) {
|
||||
AsyncValue(hasError: true) => Center(
|
||||
child: Text('分类加载失败',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) => ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 20),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, i) {
|
||||
final category = value[i];
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _category = category.name),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
category.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${category.count} 种',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right,
|
||||
size: 18, color: Color(0xFFD7CCC8)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _categoryFoods(String category) {
|
||||
final async = ref.watch(
|
||||
foodByCategoryProvider((category: category, subCategory: null)),
|
||||
);
|
||||
|
||||
return switch (async) {
|
||||
AsyncValue(hasError: true) => const Center(
|
||||
child: Text('加载失败',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) => ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 20),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, i) => _foodTile(value[i]),
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _foodTile(FoodItem food) {
|
||||
return GestureDetector(
|
||||
onTap: () => _pickPortion(food),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
food.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Color(0xFF5D4037)),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
// 食部只在 <100 时有意义(鸡蛋 88 = 壳占 12%),
|
||||
// 100 的写出来只是噪音
|
||||
_subtitle(food),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_fmt(food.energyKcal ?? 0),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const Text('kcal/100g',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFFBCAAA4))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _subtitle(FoodItem food) {
|
||||
final parts = <String>[];
|
||||
if (food.subCategoryName.isNotEmpty) parts.add(food.subCategoryName);
|
||||
final edible = food.ediblePercent;
|
||||
if (edible != null && edible < 100) {
|
||||
parts.add('食部 ${_fmt(edible)}%');
|
||||
}
|
||||
return parts.isEmpty ? food.categoryName : parts.join(' · ');
|
||||
}
|
||||
|
||||
// ---------------- 选份量 ----------------
|
||||
|
||||
/// 选份量。这是「每 100g 的数据」变成「我吃了多少」的那一步。
|
||||
Future<void> _pickPortion(FoodItem food) async {
|
||||
final result = await showModalBottomSheet<_PortionResult>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _PortionSheet(food: food),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(healthApiProvider).addDiet(
|
||||
foodId: food.id,
|
||||
grams: result.grams,
|
||||
mealType: widget.mealType,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dietTodayProvider);
|
||||
ref.invalidate(dietTodayTotalsProvider);
|
||||
showToast(context, '已添加到${mealLabel(widget.mealType)}',
|
||||
icon: 'check');
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PortionResult {
|
||||
const _PortionResult(this.grams);
|
||||
final double grams;
|
||||
}
|
||||
|
||||
/// 份量选择弹层:常用档位 + 手动改克数 + 实时热量预览
|
||||
class _PortionSheet extends StatefulWidget {
|
||||
const _PortionSheet({required this.food});
|
||||
|
||||
final FoodItem food;
|
||||
|
||||
@override
|
||||
State<_PortionSheet> createState() => _PortionSheetState();
|
||||
}
|
||||
|
||||
class _PortionSheetState extends State<_PortionSheet> {
|
||||
late final TextEditingController _ctrl;
|
||||
int? _selectedIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 默认选中第一个档位(就是「最常用」的那个,规则表里排第一)
|
||||
final portions = widget.food.portions;
|
||||
if (portions.isNotEmpty) {
|
||||
_selectedIndex = 0;
|
||||
_ctrl = TextEditingController(text: '${portions.first.grams}');
|
||||
} else {
|
||||
_ctrl = TextEditingController(text: '100');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _grams => double.tryParse(_ctrl.text.trim()) ?? 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final food = widget.food;
|
||||
final kcal = food.kcalFor(_grams);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF7F4F1),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
food.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'每 100g ${_fmt(food.energyKcal ?? 0)} kcal'
|
||||
'${food.ediblePercent != null && food.ediblePercent! < 100 ? ' · 食部 ${_fmt(food.ediblePercent!)}%' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 常用档位
|
||||
if (food.portions.isNotEmpty) ...[
|
||||
const Text('常用份量',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < food.portions.length; i++)
|
||||
_portionChip(food.portions[i], i),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// 手动克数
|
||||
const Text('或者直接填克数',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
onChanged: (_) => setState(() => _selectedIndex = null),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
suffixText: 'g',
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 实时热量:改克数时立刻能看到结果,不用等提交
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_fmt(kcal),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const Text('kcal',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Color(0xFFBCAAA4))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _grams <= 0
|
||||
? null
|
||||
: () => Navigator.of(context)
|
||||
.pop(_PortionResult(_grams)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC8956E),
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: const Color(0xFFE0D5CC),
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_grams <= 0 ? '请输入份量' : '添加 ${_fmt(_grams)}g',
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _portionChip(FoodPortion portion, int index) {
|
||||
final selected = _selectedIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_selectedIndex = index;
|
||||
_ctrl.text = '${portion.grams}';
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFFC8956E) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFFC8956E)
|
||||
: const Color(0xFFE0D5CC),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${portion.label} ${portion.grams}g',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: selected ? Colors.white : const Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
String _fmt(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../api/health_api.dart';
|
||||
import '../../providers/health_provider.dart';
|
||||
import '../../utils/toast.dart';
|
||||
|
||||
/// 设置减重目标 + 展示「每周该减多少」。
|
||||
///
|
||||
/// 这个页面的核心价值是第二块:把「三个月减 7kg」翻译成「每周 0.54kg」——
|
||||
/// 前者没有可执行性,后者才有。
|
||||
class GoalSetupPage extends ConsumerStatefulWidget {
|
||||
const GoalSetupPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<GoalSetupPage> createState() => _GoalSetupPageState();
|
||||
}
|
||||
|
||||
class _GoalSetupPageState extends ConsumerState<GoalSetupPage> {
|
||||
final _startCtrl = TextEditingController();
|
||||
final _targetCtrl = TextEditingController();
|
||||
|
||||
DateTime _startDate = DateTime.now();
|
||||
DateTime _targetDate = DateTime.now().add(const Duration(days: 90));
|
||||
|
||||
bool _saving = false;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 已有目标就预填,让「想改一改」不用重敲
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final goal = await ref.read(healthApiProvider).goal();
|
||||
if (!mounted) return;
|
||||
if (goal.hasGoal) {
|
||||
setState(() {
|
||||
_startCtrl.text = _num(goal.startWeight);
|
||||
_targetCtrl.text = _num(goal.targetWeight);
|
||||
_startDate = goal.startDate ?? _startDate;
|
||||
_targetDate = goal.targetDate ?? _targetDate;
|
||||
});
|
||||
} else {
|
||||
// 没设过目标时,用最近一次体重当起始值
|
||||
final chart = await ref.read(healthApiProvider).weightChart();
|
||||
if (!mounted) return;
|
||||
final current = chart.currentWeight;
|
||||
if (current != null) {
|
||||
setState(() => _startCtrl.text = _num(current));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 拿不到就不预填,用户手填即可
|
||||
} finally {
|
||||
if (mounted) setState(() => _loaded = true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_startCtrl.dispose();
|
||||
_targetCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: const Text('减重目标'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: !_loaded
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
|
||||
children: [
|
||||
_formCard(),
|
||||
const SizedBox(height: 12),
|
||||
_planCard(),
|
||||
const SizedBox(height: 12),
|
||||
_weeklyTableCard(),
|
||||
const SizedBox(height: 20),
|
||||
_saveButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 输入区 ----------------
|
||||
|
||||
Widget _formCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _numberField(
|
||||
controller: _startCtrl,
|
||||
label: '起始体重',
|
||||
hint: '65.0',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _numberField(
|
||||
controller: _targetCtrl,
|
||||
label: '目标体重',
|
||||
hint: '58.0',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_dateRow('开始日期', _startDate,
|
||||
(d) => setState(() => _startDate = d)),
|
||||
_dateRow('目标日期', _targetDate,
|
||||
(d) => setState(() => _targetDate = d)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _numberField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037)),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFD7CCC8)),
|
||||
suffixText: 'kg',
|
||||
suffixStyle:
|
||||
const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Color(0xFFE0D5CC)),
|
||||
),
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Color(0xFFC8956E), width: 1.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateRow(String label, DateTime value, ValueChanged<DateTime> onPick) {
|
||||
return InkWell(
|
||||
onTap: () => _pickDate(value, onPick),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_dateText(value),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right,
|
||||
size: 18, color: Color(0xFFBCAAA4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(
|
||||
DateTime initial, ValueChanged<DateTime> onPick) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2035),
|
||||
);
|
||||
if (picked != null) onPick(picked);
|
||||
}
|
||||
|
||||
// ---------------- 计划预览 ----------------
|
||||
|
||||
/// 本地先算一遍计划,让用户改数字时立刻看到「每周该减多少」,
|
||||
/// 不用等保存。算法和后端一致(总周数向上取整)。
|
||||
({int weeks, double weeklyLoss, bool valid}) _computePlan() {
|
||||
final start = double.tryParse(_startCtrl.text.trim());
|
||||
final target = double.tryParse(_targetCtrl.text.trim());
|
||||
if (start == null || target == null || start <= target) {
|
||||
return (weeks: 0, weeklyLoss: 0, valid: false);
|
||||
}
|
||||
final days = _targetDate.difference(_startDate).inDays;
|
||||
if (days <= 0) return (weeks: 0, weeklyLoss: 0, valid: false);
|
||||
|
||||
final weeks = (days / 7).ceil();
|
||||
final weekly = (start - target) / weeks;
|
||||
return (weeks: weeks, weeklyLoss: weekly, valid: true);
|
||||
}
|
||||
|
||||
Widget _planCard() {
|
||||
final plan = _computePlan();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: !plan.valid
|
||||
? const Text(
|
||||
'填好起始体重和目标体重\n(目标要低于起始)就能算出每周该减多少',
|
||||
style: TextStyle(
|
||||
fontSize: 13, height: 1.7, color: Color(0xFFBCAAA4)),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('按这个目标',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'每周 ${plan.weeklyLoss.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text('kg',
|
||||
style: TextStyle(
|
||||
fontSize: 14, color: Color(0xFF8D6E63))),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'共 ${plan.weeks} 周',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (plan.weeklyLoss > 1) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8ECD2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline,
|
||||
size: 14, color: Color(0xFFD9A93C)),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'每周超过 1kg 通常掉的是肌肉和水分,'
|
||||
'建议把目标日期往后放一放',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 每周目标体重表 —— 让「每周 0.54kg」变成一串能对照的数字
|
||||
Widget _weeklyTableCard() {
|
||||
final plan = _computePlan();
|
||||
if (!plan.valid) return const SizedBox.shrink();
|
||||
|
||||
final start = double.parse(_startCtrl.text.trim());
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'每周目标体重',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'对照着看,就能知道自己落后了还是超前了',
|
||||
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 只列前 8 周和最后一周:全列出来(13 周以上)会很长且没人看
|
||||
..._weekRows(start, plan.weeks, plan.weeklyLoss),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _weekRows(double start, int weeks, double weekly) {
|
||||
final rows = <Widget>[];
|
||||
final showAll = weeks <= 8;
|
||||
final indices = showAll
|
||||
? List<int>.generate(weeks, (i) => i + 1)
|
||||
: [1, 2, 3, 4, 5, 6, 7, 8, weeks];
|
||||
|
||||
int? previous;
|
||||
for (final week in indices) {
|
||||
// 中间省略时插一行省略号
|
||||
if (previous != null && week != previous + 1) {
|
||||
rows.add(const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6),
|
||||
child: Center(
|
||||
child: Text('···',
|
||||
style: TextStyle(color: Color(0xFFBCAAA4), fontSize: 13)),
|
||||
),
|
||||
));
|
||||
}
|
||||
previous = week;
|
||||
|
||||
final ideal = start - weekly * week;
|
||||
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 62,
|
||||
child: Text(
|
||||
'第 $week 周',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${ideal.toStringAsFixed(1)} kg',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'累计减 ${(weekly * week).toStringAsFixed(1)} kg',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------------- 保存 ----------------
|
||||
|
||||
Widget _saveButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC8956E),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_saving ? '保存中…' : '保存目标',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final start = double.tryParse(_startCtrl.text.trim());
|
||||
final target = double.tryParse(_targetCtrl.text.trim());
|
||||
|
||||
if (start == null || target == null) {
|
||||
showToast(context, '请填写起始体重和目标体重', error: true);
|
||||
return;
|
||||
}
|
||||
if (start < 20 || start > 300 || target < 20 || target > 300) {
|
||||
showToast(context, '体重需在 20 ~ 300 kg 之间', error: true);
|
||||
return;
|
||||
}
|
||||
if (target >= start) {
|
||||
showToast(context, '目标体重要低于起始体重', error: true);
|
||||
return;
|
||||
}
|
||||
if (!_targetDate.isAfter(_startDate)) {
|
||||
showToast(context, '目标日期要晚于开始日期', error: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(healthApiProvider).saveGoal(
|
||||
startWeight: start,
|
||||
targetWeight: target,
|
||||
startDate: _startDate,
|
||||
targetDate: _targetDate,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(weightGoalProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
showToast(context, '目标已保存', icon: 'check');
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _num(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
String _dateText(DateTime date) {
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$m-$d';
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/health.dart';
|
||||
import '../../providers/health_provider.dart';
|
||||
import 'diet_page.dart';
|
||||
import 'goal_setup_page.dart';
|
||||
import 'weight_page.dart';
|
||||
import 'widgets/health_card.dart';
|
||||
|
||||
/// 「强宝健康」模块入口页。
|
||||
///
|
||||
/// 三个卡片就是三件事,按用户关心的顺序排:
|
||||
/// ① 减肥进度(目标层面:还差多少、节奏对不对)
|
||||
/// ② 当前体重(日常层面:今天称了没、趋势如何)
|
||||
/// ③ 今日饮食(执行层面:吃了多少)
|
||||
class HealthHomePage extends ConsumerWidget {
|
||||
const HealthHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: const Text('强宝健康'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: const Color(0xFFC8956E),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(weightGoalProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(dietTodayProvider);
|
||||
// 等目标那一路拉完再收起转圈 —— 它是三个卡片里最先渲染的
|
||||
try {
|
||||
await ref.read(weightGoalProvider.future);
|
||||
} catch (_) {
|
||||
// 失败就失败,RefreshIndicator 该收起来
|
||||
}
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
|
||||
children: const [
|
||||
_GoalCard(),
|
||||
SizedBox(height: 12),
|
||||
_WeightCard(),
|
||||
SizedBox(height: 12),
|
||||
_DietCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// ① 减肥进度
|
||||
// ==================================================================
|
||||
|
||||
class _GoalCard extends ConsumerWidget {
|
||||
const _GoalCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(weightGoalProvider);
|
||||
final goal = async.value;
|
||||
|
||||
// 加载中和出错都退化成「还没设目标」的形态 —— 卡片不该变成一块错误提示,
|
||||
// 用户想做的只是点进去设个目标
|
||||
if (goal == null || !goal.hasGoal) {
|
||||
return HealthCard(
|
||||
title: '减肥进度',
|
||||
onTap: () => _openGoal(context, ref),
|
||||
child: async.isLoading
|
||||
? const _CardLoading()
|
||||
: const _GoalEmpty(),
|
||||
);
|
||||
}
|
||||
|
||||
final current = goal.currentWeight;
|
||||
final lost = goal.lostWeight;
|
||||
final remain = goal.remainWeight;
|
||||
|
||||
return HealthCard(
|
||||
title: '减肥进度',
|
||||
onTap: () => _openGoal(context, ref),
|
||||
trailing: Text(
|
||||
'目标 ${_num(goal.targetWeight)} kg',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
current == null ? '--' : _num(current),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Text('kg',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(width: 8),
|
||||
// Expanded + 右对齐:小屏上「已减 x kg」会被压窄,
|
||||
// 但不能把左边的体重数字顶出去
|
||||
Expanded(
|
||||
child: Text(
|
||||
lost == null ? '' : '已减 ${_num(lost)} kg',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4FA98A),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
HealthProgressBar(value: goal.weightProgress),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
remain == null
|
||||
? '还没记体重,记一次就能看进度'
|
||||
: '还差 ${_num(remain)} kg',
|
||||
maxLines: 2,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 单独一行放「每周应减」,不和「还差」挤同一个方向
|
||||
Flexible(
|
||||
child: Text(
|
||||
'每周应减 ${_num(goal.weeklyLoss)} kg',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
// 每周超过 1kg 通常是掉肌肉和水,标黄提示
|
||||
color: goal.weeklyLossUnhealthy
|
||||
? const Color(0xFFD9A93C)
|
||||
: const Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (goal.weeklyLossUnhealthy) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8ECD2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 14, color: Color(0xFFD9A93C)),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'节奏偏快,每周超过 1kg 掉的往往是肌肉和水分',
|
||||
style: TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openGoal(BuildContext context, WidgetRef ref) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GoalSetupPage()),
|
||||
);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoalEmpty extends StatelessWidget {
|
||||
const _GoalEmpty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'还没设目标\n设一个,就有每周该减多少了',
|
||||
style: TextStyle(
|
||||
fontSize: 13, height: 1.6, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC8956E).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'去设置',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// ② 当前体重
|
||||
// ==================================================================
|
||||
|
||||
class _WeightCard extends ConsumerWidget {
|
||||
const _WeightCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(weightChartProvider);
|
||||
final chart = async.value;
|
||||
|
||||
final delta = chart?.delta;
|
||||
|
||||
return HealthCard(
|
||||
title: '当前体重',
|
||||
onTap: () => _openWeight(context, ref),
|
||||
trailing: delta == null
|
||||
? null
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
delta > 0 ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 13,
|
||||
// 涨了用红、掉了用绿 —— 这个模块的语境就是「掉是好事」
|
||||
color: delta > 0
|
||||
? const Color(0xFFE0725C)
|
||||
: const Color(0xFF4FA98A),
|
||||
),
|
||||
Text(
|
||||
'${_num(delta.abs())} kg',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: delta > 0
|
||||
? const Color(0xFFE0725C)
|
||||
: const Color(0xFF4FA98A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: async.isLoading
|
||||
? const _CardLoading()
|
||||
: chart == null || !chart.hasData
|
||||
? const _WeightEmpty()
|
||||
: _WeightBody(chart: chart),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openWeight(BuildContext context, WidgetRef ref) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const WeightPage()),
|
||||
);
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeightBody extends StatelessWidget {
|
||||
const _WeightBody({required this.chart});
|
||||
|
||||
final WeightChart chart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = chart.currentWeight;
|
||||
final points = chart.points;
|
||||
final last = points.isEmpty ? null : points.last;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
current == null ? '--' : _num(current),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Text('kg',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(width: 8),
|
||||
if (last != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'最近 ${_shortDate(last.date)}',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 卡片里只给迷你趋势,看完整图表点进详情页
|
||||
_MiniTrend(points: points),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片内嵌的迷你折线(不引图表库:这里只要一条走势线,
|
||||
/// fl_chart 在这么小的尺寸里坐标轴反而占掉一半空间)
|
||||
class _MiniTrend extends StatelessWidget {
|
||||
const _MiniTrend({required this.points});
|
||||
|
||||
final List<WeightPoint> points;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (points.length < 2) {
|
||||
return const SizedBox(
|
||||
height: 46,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'再记一次就能看出趋势了',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _MiniTrendPainter(
|
||||
values: points.map<double>((p) => p.weight).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniTrendPainter extends CustomPainter {
|
||||
_MiniTrendPainter({required this.values});
|
||||
|
||||
final List<double> values;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (values.length < 2) return;
|
||||
|
||||
var min = values.first;
|
||||
var max = values.first;
|
||||
for (final v in values) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
// 上下各留 20% 余量,否则线会贴着边框
|
||||
final span = (max - min).abs() < 0.1 ? 1.0 : (max - min);
|
||||
final pad = span * 0.2;
|
||||
|
||||
final dx = size.width / (values.length - 1);
|
||||
final path = Path();
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
// 体重掉下去应该往下走 —— Y 轴要翻转(屏幕坐标 y 向下增长)
|
||||
final ratio = (values[i] - (min - pad)) / (span + pad * 2);
|
||||
final y = size.height - ratio * size.height;
|
||||
final x = dx * i;
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = const Color(0xFFC8956E)
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
|
||||
// 末端点一个实心圆,提示「这里是现在」
|
||||
final lastRatio = (values.last - (min - pad)) / (span + pad * 2);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width, size.height - lastRatio * size.height),
|
||||
3.2,
|
||||
Paint()..color = const Color(0xFFC8956E),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MiniTrendPainter old) =>
|
||||
old.values != values;
|
||||
}
|
||||
|
||||
class _WeightEmpty extends StatelessWidget {
|
||||
const _WeightEmpty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'还没记过体重\n点这里记一下,就能看趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 13, height: 1.6, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add_circle_outline, size: 26, color: Color(0xFFC8956E)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// ③ 今日饮食
|
||||
// ==================================================================
|
||||
|
||||
class _DietCard extends ConsumerWidget {
|
||||
const _DietCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(dietTodayProvider);
|
||||
final day = async.value;
|
||||
|
||||
final kcal = day?.totals.energyKcal ?? 0;
|
||||
final count = day?.totals.itemCount ?? 0;
|
||||
|
||||
return HealthCard(
|
||||
title: '今日饮食',
|
||||
onTap: () => _openDiet(context, ref),
|
||||
trailing: count == 0
|
||||
? null
|
||||
: Text(
|
||||
'$count 条',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
action: GestureDetector(
|
||||
onTap: () => _openDiet(context, ref),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC8956E).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16, color: Color(0xFFC8956E)),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'记一笔',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: async.isLoading
|
||||
? const _CardLoading()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
_num(kcal),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Text('kcal',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (day == null || count == 0)
|
||||
const Text(
|
||||
'今天还没记,点这里加一条',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||
)
|
||||
else
|
||||
// 只列有内容的餐次,空餐次不占位
|
||||
...day.meals
|
||||
.where((m) => m.items.isNotEmpty)
|
||||
.map((m) => _MealLine(group: m)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openDiet(BuildContext context, WidgetRef ref) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DietPage()),
|
||||
);
|
||||
ref.invalidate(dietTodayProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _MealLine extends StatelessWidget {
|
||||
const _MealLine({required this.group});
|
||||
|
||||
final MealGroup group;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final names = group.items
|
||||
.map((e) => '${e.foodName}(${_num(e.amountGrams)}g)')
|
||||
.join('、');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0EAE4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_mealLabel(group.mealType),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
names,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, height: 1.5, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_num(group.totals.energyKcal)} kcal',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
class _CardLoading extends StatelessWidget {
|
||||
const _CardLoading();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Color(0xFFC8956E)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 数字去掉多余的小数位:65.0 → 65,65.5 → 65.5
|
||||
String _num(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
String _shortDate(DateTime date) {
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '$m-$d';
|
||||
}
|
||||
|
||||
String _mealLabel(String mealType) {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '早';
|
||||
case 'lunch':
|
||||
return '午';
|
||||
case 'dinner':
|
||||
return '晚';
|
||||
default:
|
||||
return '加';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../api/health_api.dart';
|
||||
import '../../models/health.dart';
|
||||
import '../../providers/health_provider.dart';
|
||||
import '../../utils/toast.dart';
|
||||
import '../../widgets/state_views.dart';
|
||||
import 'widgets/weight_line_chart.dart';
|
||||
|
||||
/// 体重页面:记体重 + 看折线图 + 看历史。
|
||||
///
|
||||
/// 顶部一个「记一下」输入区放着不动,下面依次是图表和历史 ——
|
||||
/// 这个页面最高频的动作就是「记一次」,它必须在第一屏、不用滚动就能点到。
|
||||
class WeightPage extends ConsumerStatefulWidget {
|
||||
const WeightPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WeightPage> createState() => _WeightPageState();
|
||||
}
|
||||
|
||||
class _WeightPageState extends ConsumerState<WeightPage> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _noteCtrl = TextEditingController();
|
||||
|
||||
bool _saving = false;
|
||||
DateTime _date = DateTime.now();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 预填最近一次体重 —— 大部分时候体重变化很小,改一下比重新敲快
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final chart = await ref.read(healthApiProvider).weightChart();
|
||||
if (!mounted) return;
|
||||
final current = chart.currentWeight;
|
||||
if (current != null) {
|
||||
setState(() => _ctrl.text = _fmt(current));
|
||||
}
|
||||
} catch (_) {
|
||||
// 拿不到就留空手填
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
_noteCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chartAsync = ref.watch(weightChartProvider);
|
||||
final goalAsync = ref.watch(weightGoalProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: const Text('体重记录'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
|
||||
children: [
|
||||
_inputCard(),
|
||||
const SizedBox(height: 12),
|
||||
_chartCard(chartAsync, goalAsync.value),
|
||||
const SizedBox(height: 12),
|
||||
_historyCard(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 输入区 ----------------
|
||||
|
||||
Widget _inputCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('记录体重',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
)),
|
||||
const Spacer(),
|
||||
// 日期默认今天,要补记以前的就点这里改
|
||||
GestureDetector(
|
||||
onTap: _pickDate,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_isToday(_date) ? '今天' : _dateText(_date),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFFC8956E)),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
size: 16, color: Color(0xFFC8956E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '65.0',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFEFE5DC)),
|
||||
suffixText: 'kg',
|
||||
suffixStyle:
|
||||
TextStyle(fontSize: 14, color: Color(0xFF8D6E63)),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC8956E),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 22, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
),
|
||||
child: Text(_saving ? '…' : '记录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _noteCtrl,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037)),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '备注(可选),比如「早上空腹」',
|
||||
hintStyle: TextStyle(fontSize: 12.5, color: Color(0xFFBCAAA4)),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) setState(() => _date = picked);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final weight = double.tryParse(_ctrl.text.trim());
|
||||
if (weight == null) {
|
||||
showToast(context, '请输入体重', error: true);
|
||||
return;
|
||||
}
|
||||
if (weight < 20 || weight > 300) {
|
||||
showToast(context, '体重需在 20 ~ 300 kg 之间', error: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(healthApiProvider).saveWeight(
|
||||
weight: weight,
|
||||
date: _date,
|
||||
note: _noteCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _noteCtrl.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(weightHistoryProvider);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _noteCtrl.clear());
|
||||
showToast(context, '已记录', icon: 'check');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 图表 ----------------
|
||||
|
||||
Widget _chartCard(AsyncValue<WeightChart> chartAsync, WeightGoal? goal) {
|
||||
final chart = chartAsync.value;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 14, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: Text('体重变化',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
)),
|
||||
),
|
||||
if (chart != null && chart.hasData && goal != null && goal.hasGoal)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6, top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
_legend(const Color(0xFFC8956E), '实测'),
|
||||
const SizedBox(width: 14),
|
||||
_legend(const Color(0xFF4FA98A), '理想'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (chartAsync.isLoading)
|
||||
const SizedBox(height: 220, child: LoadingView(tip: ''))
|
||||
else if (chartAsync.hasError)
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: Center(
|
||||
child: ErrorView(
|
||||
message: '图表加载失败',
|
||||
onRetry: () => ref.invalidate(weightChartProvider),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
WeightLineChart(
|
||||
points: chart?.points ?? const [],
|
||||
idealCurve: goal?.idealCurve ?? const [],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legend(Color color, String label) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 2.5,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFF8D6E63))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 历史 ----------------
|
||||
|
||||
Widget _historyCard() {
|
||||
final async = ref.watch(weightHistoryProvider);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('历史记录',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
switch (async) {
|
||||
AsyncValue(hasError: true) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text('加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) when value.isEmpty => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text('还没有记录',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) => _historyList(value),
|
||||
_ => const SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Color(0xFFC8956E)),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _historyList(List<WeightRecordItem> items) {
|
||||
// 相邻两条的差值,让「掉了多少」一眼可见
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_historyRow(items, i),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _historyRow(List<WeightRecordItem> items, int index) {
|
||||
final item = items[index];
|
||||
// 历史是倒序的,所以「上一次」是后面那条
|
||||
final previous = index + 1 < items.length ? items[index + 1] : null;
|
||||
final diff = previous == null ? null : item.weight - previous.weight;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 74,
|
||||
child: Text(
|
||||
item.date == null ? '--' : _dateShort(item.date!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${_fmt(item.weight)} kg',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
if (diff != null && diff.abs() >= 0.05) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
// 掉了给绿色,涨了给红 —— 这个模块的语境界定了颜色方向
|
||||
'${diff > 0 ? '+' : ''}${diff.toStringAsFixed(1)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: diff > 0
|
||||
? const Color(0xFFE0725C)
|
||||
: const Color(0xFF4FA98A),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.note.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.note,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _confirmDelete(item),
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
color: const Color(0xFFD7CCC8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(WeightRecordItem item) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除这条记录?'),
|
||||
content: Text(
|
||||
'${item.date == null ? '' : _dateShort(item.date!)} · ${_fmt(item.weight)} kg'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFE53935)),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
try {
|
||||
await ref.read(healthApiProvider).deleteWeight(item.id);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(weightHistoryProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
showToast(context, '已删除', icon: 'check');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
String _fmt(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
bool _isToday(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
return date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day;
|
||||
}
|
||||
|
||||
String _dateText(DateTime date) =>
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
|
||||
String _dateShort(DateTime date) =>
|
||||
'${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 强宝健康主页上的一个卡片外壳。
|
||||
///
|
||||
/// 三个卡片(减肥进度 / 当前体重 / 今日饮食)结构完全一样:
|
||||
/// 标题 + 右上角可选的附属信息 + 内容区 + 底部按钮。抽出来是为了
|
||||
/// **高度和间距自动一致** —— 各自手写一遍迟早会差几个像素。
|
||||
class HealthCard extends StatelessWidget {
|
||||
const HealthCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// 右上角的小字,如「已减 1.8kg」
|
||||
final Widget? trailing;
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// 点卡片整体的行为(一般是进详情页)
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 底部按钮(如「+ 记一笔」)。为空时不占高度。
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.07),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
// Flexible:右侧小字是「锦上添花」,空间不够时它该先让位,
|
||||
// 而不是把标题挤到溢出
|
||||
Flexible(child: trailing!),
|
||||
if (onTap != null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 2),
|
||||
child: Icon(Icons.chevron_right,
|
||||
size: 18, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条横向的进度条(减肥进度 / 时间进度共用)
|
||||
///
|
||||
/// [value] 会被夹到 0~1:后端虽然已经 Clamp 过,但这里再兜一次,
|
||||
/// 免得某天后端改了什么导致宽度溢出。
|
||||
class HealthProgressBar extends StatelessWidget {
|
||||
const HealthProgressBar({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.color = const Color(0xFFC8956E),
|
||||
this.height = 8,
|
||||
this.background = const Color(0xFFF0EAE4),
|
||||
});
|
||||
|
||||
final double value;
|
||||
final Color color;
|
||||
final double height;
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final safe = value.isFinite ? value.clamp(0.0, 1.0) : 0.0;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
child: LinearProgressIndicator(
|
||||
value: safe,
|
||||
minHeight: height,
|
||||
backgroundColor: background,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个「标签 + 数值」的小块,卡片里成排用
|
||||
class HealthStat extends StatelessWidget {
|
||||
const HealthStat({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.unit,
|
||||
this.valueColor = const Color(0xFF5D4037),
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String? unit;
|
||||
final Color valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
if (unit != null) ...[
|
||||
const SizedBox(width: 2),
|
||||
Text(unit!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFF8D6E63))),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/health_provider.dart';
|
||||
|
||||
/// 首页仪表盘上「强宝健康」卡片的摘要:当前体重 + 今日热量。
|
||||
///
|
||||
/// 只是两个数字,所以单独取 —— 不复用主页那套 provider(那些会拉明细),
|
||||
/// 为了一行字把整天饮食记录取回来是不划算的。
|
||||
class HealthModuleSummary extends ConsumerWidget {
|
||||
const HealthModuleSummary({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final goal = ref.watch(weightGoalProvider).value;
|
||||
final totals = ref.watch(dietTodayTotalsProvider).value;
|
||||
|
||||
final weight = goal?.currentWeight;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _item(
|
||||
'当前体重',
|
||||
weight == null ? null : '${_fmt(weight)} kg',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _item(
|
||||
'今日热量',
|
||||
totals == null || totals.itemCount == 0
|
||||
? null
|
||||
: '${_fmt(totals.energyKcal)} kcal',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item(String label, String? value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value ?? '--',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _fmt(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/health.dart';
|
||||
|
||||
/// 体重折线图(含理想目标线)。
|
||||
///
|
||||
/// 和 `donut_chart.dart` 一个纪律:**fl_chart 的类型只出现在这个文件里**。
|
||||
/// 页面只传数据进来,升级图表库时只需要改这一处。
|
||||
///
|
||||
/// 两条线:
|
||||
/// · 实测体重(实线,圆点)
|
||||
/// · 理想体重(虚线,来自目标)—— 一眼看出「落后了还是超前了」
|
||||
class WeightLineChart extends StatelessWidget {
|
||||
const WeightLineChart({
|
||||
super.key,
|
||||
required this.points,
|
||||
this.idealCurve = const [],
|
||||
this.height = 220,
|
||||
});
|
||||
|
||||
/// 实测体重点(按日期升序)
|
||||
final List<WeightPoint> points;
|
||||
|
||||
/// 理想体重曲线,为空就不画
|
||||
final List<WeightPoint> idealCurve;
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (points.isEmpty) {
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'还没有体重记录',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// X 轴用「距首日天数」而不是时间戳:时间戳在 fl_chart 里是毫秒级的大数,
|
||||
// 坐标轴标签会很难格式化,换算成天数后间隔和标签都很直观。
|
||||
final base = points.first.date;
|
||||
double dayOf(DateTime d) => d.difference(base).inDays.toDouble();
|
||||
|
||||
final actual = points
|
||||
.map((p) => FlSpot(dayOf(p.date), p.weight))
|
||||
.toList(growable: false);
|
||||
|
||||
// 理想曲线的起点可能早于实测(比如先设目标后开始记体重),
|
||||
// 所以基准日要取两者更早的那个,否则理想线会被挤到左边界外
|
||||
final ideal = <FlSpot>[];
|
||||
for (final p in idealCurve) {
|
||||
final x = dayOf(p.date);
|
||||
// 只画在实测数据的可见区间内,避免 X 轴被拉得过长
|
||||
if (x >= 0 && x <= dayOf(points.last.date)) {
|
||||
ideal.add(FlSpot(x, p.weight));
|
||||
}
|
||||
}
|
||||
|
||||
final (minY, maxY) = _yRange(actual, ideal);
|
||||
final maxX = dayOf(points.last.date);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: maxX <= 0 ? 1 : maxX,
|
||||
minY: minY,
|
||||
maxY: maxY,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: ((maxY - minY) / 4).clamp(0.5, 100),
|
||||
getDrawingHorizontalLine: (_) => const FlLine(
|
||||
color: Color(0xFFF0EAE4),
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(),
|
||||
rightTitles: const AxisTitles(),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: ((maxY - minY) / 4).clamp(0.5, 100),
|
||||
getTitlesWidget: (value, meta) => Text(
|
||||
value.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 26,
|
||||
// 最多标 4 个日期,再多会挤成一团
|
||||
interval: maxX <= 0 ? 1 : (maxX / 3).ceilToDouble(),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final date = base.add(Duration(days: value.round()));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'${date.month}-${date.day}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipColor: (_) => const Color(0xFF5D4037),
|
||||
getTooltipItems: (spots) => spots.map((spot) {
|
||||
final date = base.add(Duration(days: spot.x.round()));
|
||||
return LineTooltipItem(
|
||||
'${date.month}月${date.day}日\n${spot.y.toStringAsFixed(1)} kg',
|
||||
const TextStyle(
|
||||
color: Colors.white, fontSize: 12, height: 1.5),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
// 实测:实线 + 圆点
|
||||
LineChartBarData(
|
||||
spots: actual,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.25,
|
||||
preventCurveOverShooting: true,
|
||||
color: const Color(0xFFC8956E),
|
||||
barWidth: 2.6,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
// 点多了就不画圆点,否则整条线会变成一串珠子
|
||||
show: actual.length <= 20,
|
||||
getDotPainter: (spot, percent, bar, index) =>
|
||||
FlDotCirclePainter(
|
||||
radius: 3,
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
strokeColor: const Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: const Color(0xFFC8956E).withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
// 理想:虚线、不带点 —— 视觉上明确区分「这是参照不是实测」
|
||||
if (ideal.length >= 2)
|
||||
LineChartBarData(
|
||||
spots: ideal,
|
||||
isCurved: false,
|
||||
color: const Color(0xFF4FA98A),
|
||||
barWidth: 1.6,
|
||||
dashArray: const [5, 4],
|
||||
dotData: const FlDotData(show: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Y 轴范围:把实测点和理想线都包进来,上下各留 10% 余量。
|
||||
///
|
||||
/// 余量是必要的 —— 贴着边框的线看不出趋势,而且圆点会被裁掉半个。
|
||||
(double, double) _yRange(List<FlSpot> actual, List<FlSpot> ideal) {
|
||||
final values = <double>[
|
||||
...actual.map((s) => s.y),
|
||||
...ideal.map((s) => s.y),
|
||||
];
|
||||
if (values.isEmpty) return (0, 100);
|
||||
|
||||
var min = values.first;
|
||||
var max = values.first;
|
||||
for (final v in values) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
|
||||
// 所有点都一样重(用户连着记了几天同一个数)时给一个固定跨度,
|
||||
// 否则 maxY == minY 会让图表算出 NaN
|
||||
if ((max - min).abs() < 0.1) {
|
||||
return (min - 1, max + 1);
|
||||
}
|
||||
final pad = (max - min) * 0.1;
|
||||
return (min - pad, max + pad);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/health_api.dart';
|
||||
import '../models/health.dart';
|
||||
|
||||
/// 「强宝健康」的状态。
|
||||
///
|
||||
/// 全部用 autoDispose 的 FutureProvider:体重和饮食都要跨设备看,
|
||||
/// 数据在服务端是唯一真相,本地缓存一份只会带来「改完看到旧数据」的问题。
|
||||
/// 页面每次进入重新拉一次,代价是一个很轻的请求,换来的是不会看到过期数字。
|
||||
|
||||
/// 减重目标(含每周应减、理想曲线、进度)
|
||||
final weightGoalProvider = FutureProvider.autoDispose<WeightGoal>((ref) {
|
||||
return ref.watch(healthApiProvider).goal();
|
||||
});
|
||||
|
||||
/// 体重折线图
|
||||
final weightChartProvider =
|
||||
FutureProvider.autoDispose<WeightChart>((ref) {
|
||||
return ref.watch(healthApiProvider).weightChart();
|
||||
});
|
||||
|
||||
/// 体重历史记录
|
||||
final weightHistoryProvider =
|
||||
FutureProvider.autoDispose<List<WeightRecordItem>>((ref) {
|
||||
return ref.watch(healthApiProvider).weightHistory();
|
||||
});
|
||||
|
||||
/// 今日饮食明细
|
||||
final dietTodayProvider = FutureProvider.autoDispose<DietDay>((ref) {
|
||||
return ref.watch(healthApiProvider).dietDay();
|
||||
});
|
||||
|
||||
/// 首页卡片用:只想知道「今天吃了多少 kcal、记了几条」。
|
||||
///
|
||||
/// 故意不复用 [dietTodayProvider] —— 那个会把整天的明细都拉回来,
|
||||
/// 就为了一个数字。首页卡片只要合计。
|
||||
final dietTodayTotalsProvider =
|
||||
FutureProvider.autoDispose<NutritionTotals>((ref) {
|
||||
return ref.watch(healthApiProvider).todayTotals();
|
||||
});
|
||||
|
||||
/// 食物搜索关键词 → 结果。family 让每个关键词有独立的缓存。
|
||||
final foodSearchProvider = FutureProvider.autoDispose
|
||||
.family<List<FoodItem>, String>((ref, keyword) {
|
||||
if (keyword.trim().isEmpty) return Future.value(const []);
|
||||
return ref.watch(healthApiProvider).searchFood(keyword.trim());
|
||||
});
|
||||
|
||||
/// 一级食物分类(分类浏览入口)
|
||||
final foodCategoriesProvider =
|
||||
FutureProvider.autoDispose<List<FoodCategory>>((ref) {
|
||||
return ref.watch(healthApiProvider).foodCategories();
|
||||
});
|
||||
|
||||
/// 某个分类下的食物
|
||||
final foodByCategoryProvider = FutureProvider.autoDispose
|
||||
.family<List<FoodItem>, ({String category, String? subCategory})>(
|
||||
(ref, key) {
|
||||
return ref
|
||||
.watch(healthApiProvider)
|
||||
.foodByCategory(key.category, subCategory: key.subCategory);
|
||||
});
|
||||
|
||||
/// 某个一级分类下的二级分类
|
||||
final foodSubCategoriesProvider =
|
||||
FutureProvider.autoDispose.family<List<FoodCategory>, String>(
|
||||
(ref, category) {
|
||||
return ref.watch(healthApiProvider).foodSubCategories(category);
|
||||
});
|
||||
|
||||
/// 首页卡片的减肥进度摘要。
|
||||
///
|
||||
/// 只取目标和「最近一次体重」,不拉折线图 —— 卡片上只显示一个进度条
|
||||
/// 和几个数字,没必要为此把半年的数据点都取回来。
|
||||
final healthSummaryProvider = FutureProvider.autoDispose<
|
||||
({WeightGoal goal, double? currentWeight})>((ref) async {
|
||||
final goal = await ref.watch(weightGoalProvider.future);
|
||||
return (goal: goal, currentWeight: goal.currentWeight);
|
||||
});
|
||||
@@ -39,7 +39,7 @@ const cuteIconNames = <String>{
|
||||
'housing', 'utilities', 'phone', 'other', 'salary', 'bonus', 'invest',
|
||||
'parttime',
|
||||
// 模块
|
||||
'bill', 'note', 'assistant',
|
||||
'bill', 'note', 'assistant', 'health',
|
||||
// 界面
|
||||
'expense', 'income', 'camera', 'search', 'user', 'lock', 'eye', 'image',
|
||||
'file', 'folder', 'folder-open', 'check', 'pin', 'target', 'inbox', 'error',
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:qiangbao_accounting/api/health_api.dart';
|
||||
import 'package:qiangbao_accounting/models/health.dart';
|
||||
import 'package:qiangbao_accounting/pages/health/health_home_page.dart';
|
||||
|
||||
/// 「强宝健康」的页面布局回归测试。
|
||||
///
|
||||
/// 只断言「不溢出、不抛异常」——具体样式靠人眼验收。价值在于:
|
||||
/// 顶部那行「65.0 / 已减 1.8kg / 每周应减 0.54kg」在窄屏上很容易挤爆,
|
||||
/// 而光看代码看不出来(记账页就栽过这个)。
|
||||
///
|
||||
/// 两个测试上的坑(在这个项目里踩过):
|
||||
/// 1. 页面上的加载圈是**无限动画**,`pumpAndSettle` 永远等不到静止 → 超时。
|
||||
/// 所以只能用显式 `pump`。
|
||||
/// 2. Provider 里的接口调用要换成假的,否则会真的去连 localhost,
|
||||
/// 每个用例白等好几秒。
|
||||
void main() {
|
||||
/// 常见机型宽度:小屏安卓 / iPhone 13 / 大屏
|
||||
const sizes = [
|
||||
('小屏', Size(320, 568)),
|
||||
('常规', Size(393, 851)),
|
||||
('大屏', Size(430, 932)),
|
||||
];
|
||||
|
||||
for (final (label, size) in sizes) {
|
||||
testWidgets('主页 $label 不溢出', (tester) async {
|
||||
await _pumpHome(tester, size);
|
||||
|
||||
expect(find.text('强宝健康'), findsOneWidget);
|
||||
expect(find.text('减肥进度'), findsOneWidget);
|
||||
expect(find.text('当前体重'), findsOneWidget);
|
||||
expect(find.text('今日饮食'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('没设过目标时显示「去设置」引导', (tester) async {
|
||||
await _pumpHome(tester, const Size(393, 851),
|
||||
goal: WeightGoal.none, chart: WeightChart.empty);
|
||||
|
||||
expect(find.text('去设置'), findsOneWidget);
|
||||
expect(find.textContaining('还没设目标'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('有目标时显示进度、已减、每周应减', (tester) async {
|
||||
await _pumpHome(tester, const Size(393, 851), goal: _goalWithPlan());
|
||||
|
||||
expect(find.textContaining('已减'), findsOneWidget);
|
||||
expect(find.textContaining('每周应减'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('每周超过 1kg 时显示「节奏偏快」提示', (tester) async {
|
||||
await _pumpHome(
|
||||
tester,
|
||||
const Size(393, 851),
|
||||
goal: _goalWithPlan(weeklyLoss: 2.33, unhealthy: true),
|
||||
);
|
||||
|
||||
expect(find.textContaining('节奏偏快'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('没记过体重时不显示脏数字(显示 --)', (tester) async {
|
||||
await _pumpHome(
|
||||
tester,
|
||||
const Size(393, 851),
|
||||
goal: _goalWithPlan(currentWeight: null, lostWeight: null,
|
||||
remainWeight: null),
|
||||
chart: WeightChart.empty,
|
||||
);
|
||||
|
||||
expect(find.text('--'), findsWidgets);
|
||||
expect(find.textContaining('还没记体重'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('今日饮食有记录时列出餐次和热量', (tester) async {
|
||||
await _pumpHome(
|
||||
tester,
|
||||
const Size(393, 851),
|
||||
goal: _goalWithPlan(),
|
||||
diet: _dietWithMeals(),
|
||||
);
|
||||
|
||||
expect(find.textContaining('鸡蛋'), findsOneWidget);
|
||||
expect(find.text('记一笔'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('接口报错时三个卡片仍可用(不整页变错误态)', (tester) async {
|
||||
await _pumpHome(tester, const Size(393, 851), failing: true);
|
||||
|
||||
// 卡片还在,只是内容退化成空态 —— 用户该能点进去重试
|
||||
expect(find.text('减肥进度'), findsOneWidget);
|
||||
expect(find.text('当前体重'), findsOneWidget);
|
||||
expect(find.text('今日饮食'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 假数据
|
||||
// ==================================================================
|
||||
|
||||
WeightGoal _goalWithPlan({
|
||||
double? currentWeight = 63.2,
|
||||
double? lostWeight = 1.8,
|
||||
double? remainWeight = 5.2,
|
||||
double weeklyLoss = 0.54,
|
||||
bool unhealthy = false,
|
||||
}) {
|
||||
return WeightGoal.fromJson({
|
||||
'hasGoal': true,
|
||||
'id': 1,
|
||||
'startWeight': 65.0,
|
||||
'targetWeight': 58.0,
|
||||
'startDate': '2026-01-01',
|
||||
'targetDate': '2026-04-01',
|
||||
'totalWeeks': 13,
|
||||
'weeklyLoss': weeklyLoss,
|
||||
'weeklyLossUnhealthy': unhealthy,
|
||||
'idealCurve': [
|
||||
{'date': '2026-01-01', 'weight': 65.0},
|
||||
{'date': '2026-02-01', 'weight': 62.8},
|
||||
],
|
||||
'currentWeight': currentWeight,
|
||||
'lostWeight': lostWeight,
|
||||
'remainWeight': remainWeight,
|
||||
'weightProgress': 0.257,
|
||||
'timeProgress': 0.4,
|
||||
});
|
||||
}
|
||||
|
||||
DietDay _dietWithMeals() {
|
||||
return DietDay.fromJson({
|
||||
'date': '2026-09-28',
|
||||
'totals': {
|
||||
'energyKcal': 1280.5,
|
||||
'protein': 62.3,
|
||||
'fat': 41.0,
|
||||
'carbohydrate': 168.2,
|
||||
'dietaryFiber': 12.5,
|
||||
'sodium': 2100.0,
|
||||
'itemCount': 2,
|
||||
},
|
||||
'meals': [
|
||||
{
|
||||
'mealType': 'breakfast',
|
||||
'totals': {'energyKcal': 300.0, 'itemCount': 2},
|
||||
'items': [
|
||||
{
|
||||
'id': 1,
|
||||
'mealType': 'breakfast',
|
||||
'foodName': '鸡蛋',
|
||||
'amountGrams': 50,
|
||||
'energyKcal': 72.0,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'mealType': 'breakfast',
|
||||
'foodName': '牛奶',
|
||||
'amountGrams': 200,
|
||||
'energyKcal': 228.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
WeightChart _chartWithPoints() {
|
||||
return WeightChart.fromJson({
|
||||
'points': [
|
||||
{'date': '2026-09-01', 'weight': 65.0},
|
||||
{'date': '2026-09-10', 'weight': 64.0},
|
||||
{'date': '2026-09-20', 'weight': 63.2},
|
||||
],
|
||||
'currentWeight': 63.2,
|
||||
'delta': -0.8,
|
||||
'maxWeight': 65.0,
|
||||
'minWeight': 58.0,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 假接口
|
||||
// ==================================================================
|
||||
|
||||
class _FakeHealthApi extends HealthApi {
|
||||
_FakeHealthApi({
|
||||
required this.weightGoal,
|
||||
required this.weightChartData,
|
||||
required this.diet,
|
||||
this.failing = false,
|
||||
}) : super(Dio());
|
||||
|
||||
final WeightGoal weightGoal;
|
||||
final WeightChart weightChartData;
|
||||
final DietDay diet;
|
||||
final bool failing;
|
||||
|
||||
@override
|
||||
Future<WeightGoal> goal() async {
|
||||
if (failing) throw Exception('boom');
|
||||
return weightGoal;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WeightChart> weightChart({int days = 90}) async {
|
||||
if (failing) throw Exception('boom');
|
||||
return weightChartData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DietDay> dietDay({DateTime? date}) async {
|
||||
if (failing) throw Exception('boom');
|
||||
return diet;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NutritionTotals> todayTotals() async {
|
||||
if (failing) throw Exception('boom');
|
||||
return diet.totals;
|
||||
}
|
||||
}
|
||||
|
||||
/// 起页面:把三个 provider 都换成假数据,然后显式 pump 两帧
|
||||
Future<void> _pumpHome(
|
||||
WidgetTester tester,
|
||||
Size size, {
|
||||
WeightGoal? goal,
|
||||
WeightChart? chart,
|
||||
DietDay? diet,
|
||||
bool failing = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final fake = _FakeHealthApi(
|
||||
weightGoal: goal ?? _goalWithPlan(),
|
||||
weightChartData: chart ?? _chartWithPoints(),
|
||||
diet: diet ?? _dietWithMeals(),
|
||||
failing: failing,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [healthApiProvider.overrideWithValue(fake)],
|
||||
child: const MaterialApp(home: HealthHomePage()),
|
||||
),
|
||||
);
|
||||
|
||||
// 不用 pumpAndSettle:页面里的加载圈是无限动画,等不到静止会超时。
|
||||
// 显式 pump 两帧足够让 FutureProvider 完成并重建。
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:qiangbao_accounting/models/health.dart';
|
||||
|
||||
/// 「强宝健康」模型的解析测试。
|
||||
///
|
||||
/// 这些 fromJson 全部要能扛住后端字段缺失/类型不符 —— 它们被用在
|
||||
/// 首屏三个卡片上,任何一处抛异常都会让整个首页变成错误态。
|
||||
/// 所以每个测试都在验证「给脏数据也不炸」。
|
||||
void main() {
|
||||
group('体重折线图解析', () {
|
||||
test('正常数据:点位、当前体重、差值都对', () {
|
||||
final chart = WeightChart.fromJson({
|
||||
'points': [
|
||||
{'date': '2026-09-01', 'weight': 65.0},
|
||||
{'date': '2026-09-05', 'weight': 64.2},
|
||||
],
|
||||
'currentWeight': 64.2,
|
||||
'delta': -0.8,
|
||||
'maxWeight': 66.0,
|
||||
'minWeight': 58.0,
|
||||
});
|
||||
|
||||
expect(chart.points.length, 2);
|
||||
expect(chart.points.first.weight, 65.0);
|
||||
expect(chart.points.last.date.day, 5);
|
||||
expect(chart.currentWeight, 64.2);
|
||||
expect(chart.delta, -0.8);
|
||||
expect(chart.hasData, isTrue);
|
||||
});
|
||||
|
||||
test('空数据不炸,hasData 为 false', () {
|
||||
final chart = WeightChart.fromJson(const {});
|
||||
expect(chart.points, isEmpty);
|
||||
expect(chart.hasData, isFalse);
|
||||
expect(chart.currentWeight, isNull);
|
||||
expect(chart.delta, isNull);
|
||||
});
|
||||
|
||||
test('字段类型不符(字符串数字)也能解析', () {
|
||||
final chart = WeightChart.fromJson({
|
||||
'points': [
|
||||
{'date': '2026-09-01', 'weight': '65.5'},
|
||||
],
|
||||
'currentWeight': '65.5',
|
||||
});
|
||||
expect(chart.points.first.weight, 65.5);
|
||||
expect(chart.currentWeight, 65.5);
|
||||
});
|
||||
|
||||
test('日期解析不出来时不抛异常', () {
|
||||
final chart = WeightChart.fromJson({
|
||||
'points': [
|
||||
{'date': '不是日期', 'weight': 65},
|
||||
],
|
||||
});
|
||||
expect(chart.points.length, 1);
|
||||
expect(chart.points.first.weight, 65);
|
||||
});
|
||||
|
||||
test('delta 为 null(只记过一次)时保持 null,不退化成 0', () {
|
||||
// 0 会被显示成「没变化」,而实际含义是「还没有上一次可比」——
|
||||
// 这两件事在界面上必须能区分
|
||||
final chart = WeightChart.fromJson({
|
||||
'points': [
|
||||
{'date': '2026-09-01', 'weight': 65},
|
||||
],
|
||||
'delta': null,
|
||||
});
|
||||
expect(chart.delta, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('减重目标解析', () {
|
||||
test('没设过目标时 hasGoal=false,字段全是安全的默认值', () {
|
||||
final goal = WeightGoal.fromJson({'hasGoal': false});
|
||||
expect(goal.hasGoal, isFalse);
|
||||
expect(goal.idealCurve, isEmpty);
|
||||
expect(goal.weeklyLoss, 0);
|
||||
expect(goal.currentWeight, isNull);
|
||||
});
|
||||
|
||||
test('正常目标:每周应减 + 理想曲线 + 进度', () {
|
||||
final goal = WeightGoal.fromJson({
|
||||
'hasGoal': true,
|
||||
'id': 3,
|
||||
'startWeight': 65.0,
|
||||
'targetWeight': 58.0,
|
||||
'startDate': '2026-01-01',
|
||||
'targetDate': '2026-04-01',
|
||||
'totalWeeks': 13,
|
||||
'weeklyLoss': 0.54,
|
||||
'weeklyLossUnhealthy': false,
|
||||
'idealCurve': [
|
||||
{'date': '2026-01-01', 'weight': 65.0},
|
||||
{'date': '2026-01-08', 'weight': 64.46},
|
||||
],
|
||||
'currentWeight': 63.2,
|
||||
'lostWeight': 1.8,
|
||||
'remainWeight': 5.2,
|
||||
'weightProgress': 0.257,
|
||||
'timeProgress': 0.4,
|
||||
});
|
||||
|
||||
expect(goal.hasGoal, isTrue);
|
||||
expect(goal.weeklyLoss, 0.54);
|
||||
expect(goal.idealCurve.length, 2);
|
||||
expect(goal.idealCurve.first.weight, 65.0);
|
||||
expect(goal.lostWeight, 1.8);
|
||||
expect(goal.weightProgress, closeTo(0.257, 0.001));
|
||||
expect(goal.weeklyLossUnhealthy, isFalse);
|
||||
});
|
||||
|
||||
test('每周超过 1kg 时标成不健康', () {
|
||||
final goal = WeightGoal.fromJson({
|
||||
'hasGoal': true,
|
||||
'startWeight': 65.0,
|
||||
'targetWeight': 58.0,
|
||||
'weeklyLoss': 2.33,
|
||||
'weeklyLossUnhealthy': true,
|
||||
});
|
||||
expect(goal.weeklyLossUnhealthy, isTrue);
|
||||
});
|
||||
|
||||
test('缺 hasGoal 字段时按「没设过目标」处理', () {
|
||||
final goal = WeightGoal.fromJson(const {});
|
||||
expect(goal.hasGoal, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('食物与份量', () {
|
||||
test('份量列表解析,并保证有兜底', () {
|
||||
final food = FoodItem.fromJson({
|
||||
'id': 1,
|
||||
'name': '米饭(蒸)',
|
||||
'energyKcal': 116.0,
|
||||
'portions': [
|
||||
{'label': '一碗', 'grams': 150},
|
||||
{'label': '小碗', 'grams': 100},
|
||||
],
|
||||
});
|
||||
expect(food.portions.length, 2);
|
||||
expect(food.portions.first.label, '一碗');
|
||||
expect(food.portions.first.grams, 150);
|
||||
});
|
||||
|
||||
test('kcalFor 按份量换算', () {
|
||||
final food = FoodItem.fromJson({
|
||||
'id': 1,
|
||||
'name': '米饭',
|
||||
'energyKcal': 116.0,
|
||||
});
|
||||
// 每 100g 116 kcal,吃 150g 就是 174 kcal
|
||||
expect(food.kcalFor(150), closeTo(174.0, 0.01));
|
||||
expect(food.kcalFor(100), closeTo(116.0, 0.01));
|
||||
});
|
||||
|
||||
test('kcalFor 遇到 0 / 负数 / 缺能量值都返回 0,不抛不返回 NaN', () {
|
||||
final food = FoodItem.fromJson({'id': 1, 'name': '未知食物'});
|
||||
expect(food.kcalFor(150), 0);
|
||||
expect(food.kcalFor(0), 0);
|
||||
expect(food.kcalFor(-10), 0);
|
||||
});
|
||||
|
||||
test('食物名为空时给占位,不显示空白', () {
|
||||
final food = FoodItem.fromJson({'id': 1});
|
||||
expect(food.name, '未命名食物');
|
||||
});
|
||||
});
|
||||
|
||||
group('饮食记录解析', () {
|
||||
test('某天的饮食:合计 + 按餐次分组', () {
|
||||
final day = DietDay.fromJson({
|
||||
'date': '2026-09-28',
|
||||
'totals': {
|
||||
'energyKcal': 1280.5,
|
||||
'protein': 62.3,
|
||||
'fat': 41.0,
|
||||
'carbohydrate': 168.2,
|
||||
'dietaryFiber': 12.5,
|
||||
'sodium': 2100.0,
|
||||
'itemCount': 5,
|
||||
},
|
||||
'meals': [
|
||||
{
|
||||
'mealType': 'breakfast',
|
||||
'totals': {'energyKcal': 300.0, 'itemCount': 2},
|
||||
'items': [
|
||||
{
|
||||
'id': 1,
|
||||
'mealType': 'breakfast',
|
||||
'foodName': '鸡蛋',
|
||||
'amountGrams': 50,
|
||||
'energyKcal': 72.0,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'mealType': 'breakfast',
|
||||
'foodName': '牛奶',
|
||||
'amountGrams': 200,
|
||||
'energyKcal': 228.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(day.totals.energyKcal, 1280.5);
|
||||
expect(day.totals.itemCount, 5);
|
||||
expect(day.meals.length, 1);
|
||||
expect(day.meals.first.mealType, 'breakfast');
|
||||
expect(day.meals.first.items.length, 2);
|
||||
expect(day.meals.first.items.first.foodName, '鸡蛋');
|
||||
});
|
||||
|
||||
test('空的一天不炸,合计归零', () {
|
||||
final day = DietDay.fromJson(const {});
|
||||
expect(day.totals.energyKcal, 0);
|
||||
expect(day.totals.itemCount, 0);
|
||||
expect(day.meals, isEmpty);
|
||||
});
|
||||
|
||||
test('明细缺字段时用安全默认值', () {
|
||||
final day = DietDay.fromJson({
|
||||
'meals': [
|
||||
{
|
||||
'items': [
|
||||
{'id': 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
final item = day.meals.first.items.first;
|
||||
expect(item.foodName, '未命名食物');
|
||||
expect(item.energyKcal, 0);
|
||||
expect(item.mealType, 'snack');
|
||||
});
|
||||
});
|
||||
|
||||
group('餐次标签', () {
|
||||
test('四个餐次都有中文名', () {
|
||||
expect(mealLabel('breakfast'), '早餐');
|
||||
expect(mealLabel('lunch'), '午餐');
|
||||
expect(mealLabel('dinner'), '晚餐');
|
||||
expect(mealLabel('snack'), '加餐');
|
||||
});
|
||||
|
||||
test('未知餐次兜底到加餐,不返回空字符串', () {
|
||||
expect(mealLabel('brunch'), '加餐');
|
||||
expect(mealLabel(''), '加餐');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -95,6 +95,7 @@ PALETTE = {
|
||||
"bill": (C_BRAND, C_BRAND_L),
|
||||
"note": (C_BRAND, C_BRAND_L),
|
||||
"assistant": ("#D9A93C", "#F8ECD2"),
|
||||
"health": ("#4FA98A", "#DBEFE8"), # 青绿(和 invest 同色系,属"正向"语义)
|
||||
}
|
||||
|
||||
# name -> (palette_key, [(path, 是否填充), ...])
|
||||
@@ -188,6 +189,13 @@ ICONS: dict[str, tuple[str, list[tuple[str, bool]]]] = {
|
||||
(sparkle(), True),
|
||||
("M18.4 15.6l.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9.9-2.3z", True),
|
||||
]),
|
||||
"health": ("health", [
|
||||
(rr(2.8, 9.2, 4.2, 5.6, 1.4), True), # 左外配重
|
||||
(rr(6.4, 7.4, 3.4, 9.2, 1.4), True), # 左内配重
|
||||
("M9.8 12h4.4", False), # 横杆
|
||||
(rr(14.2, 7.4, 3.4, 9.2, 1.4), True), # 右内配重
|
||||
(rr(17.0, 9.2, 4.2, 5.6, 1.4), True), # 右外配重
|
||||
]),
|
||||
|
||||
# ============================ 界面图标 ============================
|
||||
"expense": (None, [ # 支出:托盘 + 向上箭头
|
||||
|
||||
Reference in New Issue
Block a user