强宝健康模块

This commit is contained in:
2026-09-28 16:40:32 +08:00
parent d52775ec10
commit 2dd89bfde0
17 changed files with 4271 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4FA98A" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.199999999999999 9.2h1.4000000000000004a1.4 1.4 0 0 1 1.4 1.4v2.8a1.4 1.4 0 0 1 -1.4 1.4h-1.4000000000000004a1.4 1.4 0 0 1 -1.4 -1.4v-2.8a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
<path d="M7.800000000000001 7.4h0.6000000000000001a1.4 1.4 0 0 1 1.4 1.4v6.3999999999999995a1.4 1.4 0 0 1 -1.4 1.4h-0.6000000000000001a1.4 1.4 0 0 1 -1.4 -1.4v-6.3999999999999995a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
<path d="M9.8 12h4.4" fill="none"/>
<path d="M15.6 7.4h0.6000000000000001a1.4 1.4 0 0 1 1.4 1.4v6.3999999999999995a1.4 1.4 0 0 1 -1.4 1.4h-0.6000000000000001a1.4 1.4 0 0 1 -1.4 -1.4v-6.3999999999999995a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
<path d="M18.4 9.2h1.4000000000000004a1.4 1.4 0 0 1 1.4 1.4v2.8a1.4 1.4 0 0 1 -1.4 1.4h-1.4000000000000004a1.4 1.4 0 0 1 -1.4 -1.4v-2.8a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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