258 lines
8.0 KiB
Dart
258 lines
8.0 KiB
Dart
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 [];
|
|
}
|