From 2dd89bfde057b2ccbb9c7fea481db96d46b84b35 Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Mon, 28 Sep 2026 16:40:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=AE=9D=E5=81=A5=E5=BA=B7=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icons/health.svg | 7 + lib/api/health_api.dart | 257 +++++++ lib/core/module/app_module.dart | 10 + lib/models/health.dart | 428 ++++++++++++ lib/pages/health/diet_page.dart | 338 +++++++++ lib/pages/health/food_picker_page.dart | 565 +++++++++++++++ lib/pages/health/goal_setup_page.dart | 488 +++++++++++++ lib/pages/health/health_home_page.dart | 650 ++++++++++++++++++ lib/pages/health/weight_page.dart | 488 +++++++++++++ lib/pages/health/widgets/health_card.dart | 168 +++++ .../health/widgets/health_module_summary.dart | 65 ++ .../health/widgets/weight_line_chart.dart | 200 ++++++ lib/providers/health_provider.dart | 80 +++ lib/widgets/cute_icon.dart | 2 +- test/health_layout_test.dart | 265 +++++++ test/health_test.dart | 253 +++++++ tools/make_icons.py | 8 + 17 files changed, 4271 insertions(+), 1 deletion(-) create mode 100644 assets/icons/health.svg create mode 100644 lib/api/health_api.dart create mode 100644 lib/models/health.dart create mode 100644 lib/pages/health/diet_page.dart create mode 100644 lib/pages/health/food_picker_page.dart create mode 100644 lib/pages/health/goal_setup_page.dart create mode 100644 lib/pages/health/health_home_page.dart create mode 100644 lib/pages/health/weight_page.dart create mode 100644 lib/pages/health/widgets/health_card.dart create mode 100644 lib/pages/health/widgets/health_module_summary.dart create mode 100644 lib/pages/health/widgets/weight_line_chart.dart create mode 100644 lib/providers/health_provider.dart create mode 100644 test/health_layout_test.dart create mode 100644 test/health_test.dart diff --git a/assets/icons/health.svg b/assets/icons/health.svg new file mode 100644 index 0000000..a8d869a --- /dev/null +++ b/assets/icons/health.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/lib/api/health_api.dart b/lib/api/health_api.dart new file mode 100644 index 0000000..5671c92 --- /dev/null +++ b/lib/api/health_api.dart @@ -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((ref) => HealthApi(ref.watch(dioProvider))); + +/// 「强宝健康」接口:体重 / 减重目标 / 饮食 / 食物库 +/// +/// 后端路径都挂在 /api/health/ 下,dio 的 baseUrl 已含 /api。 +class HealthApi { + final Dio _dio; + + HealthApi(this._dio); + + // ================================================================ + // 体重 + // ================================================================ + + /// 记一次体重。同一天再记就是覆盖(后端保证)。 + /// + /// [note] 不传 = 不改动已有备注;传空串 = 清空。 + Future 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({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> 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 deleteWeight(int id) async { + try { + await _dio.delete('/health/weight/$id'); + } on DioException catch (e) { + throw _asApiException(e, '删除失败'); + } + } + + // ================================================================ + // 减重目标 + // ================================================================ + + Future 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 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({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 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 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 updateDietAmount(int id, double grams) async { + try { + await _dio.put('/health/diet/$id', data: {'amountGrams': grams}); + } on DioException catch (e) { + throw _asApiException(e, '修改失败'); + } + } + + Future deleteDiet(int id) async { + try { + await _dio.delete('/health/diet/$id'); + } on DioException catch (e) { + throw _asApiException(e, '删除失败'); + } + } + + // ================================================================ + // 食物库 + // ================================================================ + + Future> 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> 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> 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> 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 _asMap(Object? data) { + if (data is Map) { + // 饮食接口是 { success, data: {...} } 包了一层 + if (data['data'] is Map) { + return Map.from(data['data'] as Map); + } + return data; + } + return const {}; + } +} + +/// 后端这类接口把数组放在 { success, list: [...] } 里, +/// 直接从 data 上取会拿到 null,这里统一拆一层。 +List> asMapListFrom(Object? data) { + if (data is Map) { + final list = data['list'] ?? data['data']; + if (list is List) { + return list.whereType>().toList(); + } + } + if (data is List) { + return data.whereType>().toList(); + } + return const []; +} diff --git a/lib/core/module/app_module.dart b/lib/core/module/app_module.dart index b9a6c48..a821578 100644 --- a/lib/core/module/app_module.dart +++ b/lib/core/module/app_module.dart @@ -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 appModules = [ open: (_) => const WatermarkCameraPage(), summary: (_) => const WatermarkModuleSummary(), ), + AppModule( + id: 'health', + name: '强宝健康', + blurb: '减重进度、体重与每日饮食', + icon: 'health', + open: (_) => const HealthHomePage(), + summary: (_) => const HealthModuleSummary(), + ), ]; diff --git a/lib/models/health.dart b/lib/models/health.dart new file mode 100644 index 0000000..7d13858 --- /dev/null +++ b/lib/models/health.dart @@ -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 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 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 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 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 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 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 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 portions; + + factory FoodItem.fromJson(Map 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 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 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 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 items; + + factory MealGroup.fromJson(Map json) { + return MealGroup( + mealType: asNonEmpty(json['mealType'], 'snack'), + totals: NutritionTotals.fromJson( + (json['totals'] as Map?)?.cast() ?? 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 meals; + + static const empty = DietDay(date: null, totals: NutritionTotals.zero, meals: []); + + factory DietDay.fromJson(Map json) { + return DietDay( + date: WeightPoint.tryParse(json['date']), + totals: NutritionTotals.fromJson( + (json['totals'] as Map?)?.cast() ?? const {}), + meals: asMapList(json['meals']).map(MealGroup.fromJson).toList(), + ); + } +} + +/// 餐次的中文名与显示顺序 +const Map kMealLabels = { + 'breakfast': '早餐', + 'lunch': '午餐', + 'dinner': '晚餐', + 'snack': '加餐', +}; + +String mealLabel(String mealType) => kMealLabels[mealType] ?? '加餐'; diff --git a/lib/pages/health/diet_page.dart b/lib/pages/health/diet_page.dart new file mode 100644 index 0000000..8b99617 --- /dev/null +++ b/lib/pages/health/diet_page.dart @@ -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 createState() => _DietPageState(); +} + +class _DietPageState extends ConsumerState { + Future _addTo(String mealType) async { + final added = await Navigator.of(context).push( + 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 _editAmount(DietRecordItem item) async { + final ctrl = TextEditingController(text: _fmt(item.amountGrams)); + final grams = await showDialog( + 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 _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); +} diff --git a/lib/pages/health/food_picker_page.dart b/lib/pages/health/food_picker_page.dart new file mode 100644 index 0000000..476a658 --- /dev/null +++ b/lib/pages/health/food_picker_page.dart @@ -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 createState() => _FoodPickerPageState(); +} + +class _FoodPickerPageState extends ConsumerState { + 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 = []; + 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 _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); +} diff --git a/lib/pages/health/goal_setup_page.dart b/lib/pages/health/goal_setup_page.dart new file mode 100644 index 0000000..3ac53e7 --- /dev/null +++ b/lib/pages/health/goal_setup_page.dart @@ -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 createState() => _GoalSetupPageState(); +} + +class _GoalSetupPageState extends ConsumerState { + 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 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 _pickDate( + DateTime initial, ValueChanged 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 _weekRows(double start, int weeks, double weekly) { + final rows = []; + final showAll = weeks <= 8; + final indices = showAll + ? List.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 _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'; +} diff --git a/lib/pages/health/health_home_page.dart b/lib/pages/health/health_home_page.dart new file mode 100644 index 0000000..ee08890 --- /dev/null +++ b/lib/pages/health/health_home_page.dart @@ -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 _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 _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 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((p) => p.weight).toList(), + ), + ), + ); + } +} + +class _MiniTrendPainter extends CustomPainter { + _MiniTrendPainter({required this.values}); + + final List 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 _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 '加'; + } +} diff --git a/lib/pages/health/weight_page.dart b/lib/pages/health/weight_page.dart new file mode 100644 index 0000000..7dd4d1c --- /dev/null +++ b/lib/pages/health/weight_page.dart @@ -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 createState() => _WeightPageState(); +} + +class _WeightPageState extends ConsumerState { + 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 _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + if (picked != null) setState(() => _date = picked); + } + + Future _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 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 items) { + // 相邻两条的差值,让「掉了多少」一眼可见 + return Column( + children: [ + for (var i = 0; i < items.length; i++) + _historyRow(items, i), + ], + ); + } + + Widget _historyRow(List 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 _confirmDelete(WeightRecordItem item) async { + final ok = await showDialog( + 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')}'; diff --git a/lib/pages/health/widgets/health_card.dart b/lib/pages/health/widgets/health_card.dart new file mode 100644 index 0000000..68f7799 --- /dev/null +++ b/lib/pages/health/widgets/health_card.dart @@ -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), + ), + ); + } +} + +/// 一个「标签 + 数值」的小块,卡片里成排用 +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))), + ], + ], + ), + ], + ); + } +} diff --git a/lib/pages/health/widgets/health_module_summary.dart b/lib/pages/health/widgets/health_module_summary.dart new file mode 100644 index 0000000..6fb13bd --- /dev/null +++ b/lib/pages/health/widgets/health_module_summary.dart @@ -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); +} diff --git a/lib/pages/health/widgets/weight_line_chart.dart b/lib/pages/health/widgets/weight_line_chart.dart new file mode 100644 index 0000000..216eca5 --- /dev/null +++ b/lib/pages/health/widgets/weight_line_chart.dart @@ -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 points; + + /// 理想体重曲线,为空就不画 + final List 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 = []; + 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 actual, List ideal) { + final values = [ + ...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); + } +} diff --git a/lib/providers/health_provider.dart b/lib/providers/health_provider.dart new file mode 100644 index 0000000..0996730 --- /dev/null +++ b/lib/providers/health_provider.dart @@ -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((ref) { + return ref.watch(healthApiProvider).goal(); +}); + +/// 体重折线图 +final weightChartProvider = + FutureProvider.autoDispose((ref) { + return ref.watch(healthApiProvider).weightChart(); +}); + +/// 体重历史记录 +final weightHistoryProvider = + FutureProvider.autoDispose>((ref) { + return ref.watch(healthApiProvider).weightHistory(); +}); + +/// 今日饮食明细 +final dietTodayProvider = FutureProvider.autoDispose((ref) { + return ref.watch(healthApiProvider).dietDay(); +}); + +/// 首页卡片用:只想知道「今天吃了多少 kcal、记了几条」。 +/// +/// 故意不复用 [dietTodayProvider] —— 那个会把整天的明细都拉回来, +/// 就为了一个数字。首页卡片只要合计。 +final dietTodayTotalsProvider = + FutureProvider.autoDispose((ref) { + return ref.watch(healthApiProvider).todayTotals(); +}); + +/// 食物搜索关键词 → 结果。family 让每个关键词有独立的缓存。 +final foodSearchProvider = FutureProvider.autoDispose + .family, String>((ref, keyword) { + if (keyword.trim().isEmpty) return Future.value(const []); + return ref.watch(healthApiProvider).searchFood(keyword.trim()); +}); + +/// 一级食物分类(分类浏览入口) +final foodCategoriesProvider = + FutureProvider.autoDispose>((ref) { + return ref.watch(healthApiProvider).foodCategories(); +}); + +/// 某个分类下的食物 +final foodByCategoryProvider = FutureProvider.autoDispose + .family, ({String category, String? subCategory})>( + (ref, key) { + return ref + .watch(healthApiProvider) + .foodByCategory(key.category, subCategory: key.subCategory); +}); + +/// 某个一级分类下的二级分类 +final foodSubCategoriesProvider = + FutureProvider.autoDispose.family, 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); +}); diff --git a/lib/widgets/cute_icon.dart b/lib/widgets/cute_icon.dart index 07f16ea..25dc489 100644 --- a/lib/widgets/cute_icon.dart +++ b/lib/widgets/cute_icon.dart @@ -39,7 +39,7 @@ const cuteIconNames = { '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', diff --git a/test/health_layout_test.dart b/test/health_layout_test.dart new file mode 100644 index 0000000..86552ae --- /dev/null +++ b/test/health_layout_test.dart @@ -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 goal() async { + if (failing) throw Exception('boom'); + return weightGoal; + } + + @override + Future weightChart({int days = 90}) async { + if (failing) throw Exception('boom'); + return weightChartData; + } + + @override + Future dietDay({DateTime? date}) async { + if (failing) throw Exception('boom'); + return diet; + } + + @override + Future todayTotals() async { + if (failing) throw Exception('boom'); + return diet.totals; + } +} + +/// 起页面:把三个 provider 都换成假数据,然后显式 pump 两帧 +Future _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)); +} diff --git a/test/health_test.dart b/test/health_test.dart new file mode 100644 index 0000000..b7c68f2 --- /dev/null +++ b/test/health_test.dart @@ -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(''), '加餐'); + }); + }); +} diff --git a/tools/make_icons.py b/tools/make_icons.py index e6163a7..7dd0eb8 100644 --- a/tools/make_icons.py +++ b/tools/make_icons.py @@ -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, [ # 支出:托盘 + 向上箭头