Commit 6bb68ed801a67cec553063b685ae24cf0ada9a6b

Authored by 常守达
1 parent e277b5d1

feat(today): 今日HRV

23.9 KB | W: | H:

18 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin
1 import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart'; 1 import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart';
2 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 2 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
3 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 3 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
  4 +import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
4 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart'; 5 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
5 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 6 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
6 import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart'; 7 import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
@@ -13,6 +14,7 @@ class FriendHomeBinding extends Bindings { @@ -13,6 +14,7 @@ class FriendHomeBinding extends Bindings {
13 final args = Get.arguments as FriendItem; 14 final args = Get.arguments as FriendItem;
14 Get.lazyPut<TodayController>( 15 Get.lazyPut<TodayController>(
15 () => TodayController( 16 () => TodayController(
  17 + Get.find<VipApi>(),
16 Get.find<HealthApi>(), 18 Get.find<HealthApi>(),
17 Get.find<FriendApi>(), 19 Get.find<FriendApi>(),
18 Get.find<UserStateService>(), 20 Get.find<UserStateService>(),
@@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.da @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.da
3 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 3 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
4 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 4 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
5 import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 5 import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  6 +import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
6 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart'; 7 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
7 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 8 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
8 import 'package:get/get.dart'; 9 import 'package:get/get.dart';
@@ -18,6 +19,7 @@ class HomeBinding extends Bindings { @@ -18,6 +19,7 @@ class HomeBinding extends Bindings {
18 Get.lazyPut<HomeController>(() => HomeController(), fenix: true); 19 Get.lazyPut<HomeController>(() => HomeController(), fenix: true);
19 Get.lazyPut<TodayController>( 20 Get.lazyPut<TodayController>(
20 () => TodayController( 21 () => TodayController(
  22 + Get.find<VipApi>(),
21 Get.find<HealthApi>(), 23 Get.find<HealthApi>(),
22 Get.find<FriendApi>(), 24 Get.find<FriendApi>(),
23 Get.find<UserStateService>(), 25 Get.find<UserStateService>(),
@@ -11,14 +11,15 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart'; @@ -11,14 +11,15 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart';
11 import 'package:doublefeel_flutter/core/logging/app_logger.dart'; 11 import 'package:doublefeel_flutter/core/logging/app_logger.dart';
12 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 12 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
13 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 13 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
14 -import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 14 +import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
15 import 'package:doublefeel_flutter/core/result/app_result.dart'; 15 import 'package:doublefeel_flutter/core/result/app_result.dart';
16 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart'; 16 import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
17 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 17 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
18 -import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; 18 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  19 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
19 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; 20 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
20 -import 'package:doublefeel_flutter/data/models/health/health_models.dart';  
21 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; 21 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
  22 +import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
22 import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; 23 import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
23 import 'package:flutter/material.dart'; 24 import 'package:flutter/material.dart';
24 import 'package:get/get.dart'; 25 import 'package:get/get.dart';
@@ -49,6 +50,7 @@ class HrvAnnotation { @@ -49,6 +50,7 @@ class HrvAnnotation {
49 50
50 class TodayController extends GetxController { 51 class TodayController extends GetxController {
51 TodayController( 52 TodayController(
  53 + this._vipApi,
52 this._healthApi, 54 this._healthApi,
53 this._friendApi, 55 this._friendApi,
54 this._userStateService, 56 this._userStateService,
@@ -57,6 +59,7 @@ class TodayController extends GetxController { @@ -57,6 +59,7 @@ class TodayController extends GetxController {
57 FriendItem? friendInfo, // null = 自己,非 null = 好友 59 FriendItem? friendInfo, // null = 自己,非 null = 好友
58 }) : _initialFriendInfo = friendInfo; 60 }) : _initialFriendInfo = friendInfo;
59 61
  62 + final VipApi _vipApi;
60 final HealthApi _healthApi; 63 final HealthApi _healthApi;
61 final FriendApi _friendApi; 64 final FriendApi _friendApi;
62 final UserStateService _userStateService; 65 final UserStateService _userStateService;
@@ -81,12 +84,12 @@ class TodayController extends GetxController { @@ -81,12 +84,12 @@ class TodayController extends GetxController {
81 final scrollOffset = 0.0.obs; 84 final scrollOffset = 0.0.obs;
82 85
83 final isLoadingToday = false.obs; 86 final isLoadingToday = false.obs;
84 - final showHealthDataAuthCard = true.obs; 87 + final showHealthDataAuthCardStatus = (-1).obs;
85 final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs; 88 final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs;
86 89
87 // ── HRV 表盘引导 Banner ─────────────────── 90 // ── HRV 表盘引导 Banner ───────────────────
88 - final showHrvAdBanner = true.obs;  
89 - final showPartnerAdBanner = true.obs; 91 + final showHrvAdBanner = false.obs;
  92 + final showPartnerAdBanner = false.obs;
90 93
91 void dismissHrvAdBanner() => showHrvAdBanner.value = false; 94 void dismissHrvAdBanner() => showHrvAdBanner.value = false;
92 95
@@ -107,8 +110,8 @@ class TodayController extends GetxController { @@ -107,8 +110,8 @@ class TodayController extends GetxController {
107 int? _activityStandTarget; 110 int? _activityStandTarget;
108 111
109 // ── HRV 趋势图 ──────────────────────────── 112 // ── HRV 趋势图 ────────────────────────────
110 - final hrvChartData = <HrvDataPoint>[].obs;  
111 - final stressChartData = <HrvDataPoint>[].obs; 113 + final hrvChartData = <V2HrvTrendItem>[].obs;
  114 + final stressChartData = <V2RealtimeStressItem>[].obs;
112 115
113 final hrvAnnotations = <HrvAnnotation>[].obs; 116 final hrvAnnotations = <HrvAnnotation>[].obs;
114 117
@@ -116,33 +119,10 @@ class TodayController extends GetxController { @@ -116,33 +119,10 @@ class TodayController extends GetxController {
116 final v2StressScore = Rxn<V2StressScore>(); 119 final v2StressScore = Rxn<V2StressScore>();
117 final v2LatestHrv = Rxn<V2LatestHrvData>(); 120 final v2LatestHrv = Rxn<V2LatestHrvData>();
118 final v2HrvTrend = Rxn<V2HrvTrendData>(); 121 final v2HrvTrend = Rxn<V2HrvTrendData>();
  122 +
119 final v2RealtimeStress = Rxn<V2RealtimeStressData>(); 123 final v2RealtimeStress = Rxn<V2RealtimeStressData>();
120 final v2ActivityTarget = Rxn<V2ActivityTarget>(); 124 final v2ActivityTarget = Rxn<V2ActivityTarget>();
121 125
122 - Future<void> requestHealthAuthorization() async {  
123 - if (GetPlatform.isIOS) {  
124 - try {  
125 - final result = await _hostApi.checkHealthAppAuthorization();  
126 - print("checkHealthAppAuthorization : $result");  
127 - if (result.status == 0) {  
128 - bool success = await _hostApi.requestHealthClientAuthorization();  
129 - print("requestHealthClientAuthorization : $success");  
130 - if (success) {  
131 - _performDataUpload();  
132 - } else {  
133 - Get.to(NoHealthDataPage(  
134 - onRefresh: _performDataUpload,  
135 - ));  
136 - }  
137 - return;  
138 - }  
139 - Get.to(NoHealthDataPage(  
140 - onRefresh: _performDataUpload,  
141 - ));  
142 - } catch (e) {}  
143 - }  
144 - }  
145 -  
146 @override 126 @override
147 void onInit() { 127 void onInit() {
148 super.onInit(); 128 super.onInit();
@@ -158,6 +138,32 @@ class TodayController extends GetxController { @@ -158,6 +138,32 @@ class TodayController extends GetxController {
158 }); 138 });
159 139
160 unawaited(loadDataForDate(today)); 140 unawaited(loadDataForDate(today));
  141 + checkAddFriendVisible();
  142 + checkHrvAdBannerVisible();
  143 + checkHealthDataAuthCardVisible();
  144 + }
  145 +
  146 + void checkHrvAdBannerVisible() {
  147 + if (isFriend) {
  148 + showHrvAdBanner.value = false;
  149 + } else {
  150 + showHrvAdBanner.value = true;
  151 + }
  152 + }
  153 +
  154 + void checkAddFriendVisible() {
  155 + if (isFriend) {
  156 + showHrvAdBanner.value = false;
  157 + showPartnerAdBanner.value = false;
  158 + } else {
  159 + _refreshFriendList(onFriendListUpdated: () {
  160 + if (friendsList.isEmpty || friendsList.value.length >= 10) {
  161 + showPartnerAdBanner.value = false;
  162 + } else {
  163 + showPartnerAdBanner.value = true;
  164 + }
  165 + });
  166 + }
161 } 167 }
162 168
163 void changeDate(DateTime date, {DateTime? focused}) { 169 void changeDate(DateTime date, {DateTime? focused}) {
@@ -177,7 +183,6 @@ class TodayController extends GetxController { @@ -177,7 +183,6 @@ class TodayController extends GetxController {
177 isLoadingToday.value = true; 183 isLoadingToday.value = true;
178 try { 184 try {
179 await Future.wait([ 185 await Future.wait([
180 - _refreshHealthAuthorizationState(),  
181 _refreshHealthDataForDate(date), 186 _refreshHealthDataForDate(date),
182 ]); 187 ]);
183 } catch (error, stackTrace) { 188 } catch (error, stackTrace) {
@@ -189,6 +194,43 @@ class TodayController extends GetxController { @@ -189,6 +194,43 @@ class TodayController extends GetxController {
189 } 194 }
190 } 195 }
191 196
  197 + Future<void> requestHealthAuthorization() async {
  198 + if (GetPlatform.isIOS) {
  199 + try {
  200 + final result = await _hostApi.checkHealthAppAuthorization();
  201 + AppLogger.d("checkHealthAppAuthorization : $result");
  202 + if (result.status == 0) {
  203 + bool success = await _hostApi.requestHealthClientAuthorization();
  204 + AppLogger.d("requestHealthClientAuthorization : $success");
  205 + if (success) {
  206 + _performDataUpload();
  207 + AppToast.show('刷新完成');
  208 + } else {
  209 + // Get.to(NoHealthDataPage(
  210 + // onRefresh: _performDataUpload,
  211 + // ));
  212 + }
  213 + return;
  214 + }
  215 + if (result.status == 1) {
  216 + _performDataUpload();
  217 + }
  218 + } catch (e) {}
  219 + }
  220 + }
  221 +
  222 + void onAuthorizeTap() {
  223 + if (showHealthDataAuthCardStatus.value == 0) {
  224 + Get.to(NoHealthDataPage(
  225 + onRefresh: requestHealthAuthorization,
  226 + ));
  227 + } else if (showHealthDataAuthCardStatus.value == -1) {
  228 + Get.to(NoHealthDataNoPermissionPage(
  229 + onRequest: requestHealthAuthorization,
  230 + ));
  231 + }
  232 + }
  233 +
192 /// 上传 Apple Health 新数据。 234 /// 上传 Apple Health 新数据。
193 Future<void> _performDataUpload() async { 235 Future<void> _performDataUpload() async {
194 try { 236 try {
@@ -207,11 +249,12 @@ class TodayController extends GetxController { @@ -207,11 +249,12 @@ class TodayController extends GetxController {
207 } 249 }
208 } 250 }
209 251
210 - Future<void> _refreshHealthAuthorizationState() async { 252 + Future<void> checkHealthDataAuthCardVisible() async {
211 try { 253 try {
212 final result = await _hostApi.checkHealthAppAuthorization(); 254 final result = await _hostApi.checkHealthAppAuthorization();
213 255
214 - showHealthDataAuthCard.value = result.status != 1; 256 + showHealthDataAuthCardStatus.value = result.status;
  257 + // TODO:若用户从始至终没有任何数据,也显示该引导
215 } catch (e) {} 258 } catch (e) {}
216 } 259 }
217 260
@@ -244,16 +287,68 @@ class TodayController extends GetxController { @@ -244,16 +287,68 @@ class TodayController extends GetxController {
244 287
245 switch (await _healthApi.getV2HrvTrend(friendUserId, intDate)) { 288 switch (await _healthApi.getV2HrvTrend(friendUserId, intDate)) {
246 case AppSuccess(:final data): 289 case AppSuccess(:final data):
247 - v2HrvTrend.value = data; 290 + // hrvChartData.assignAll(data.list ?? []);
  291 + hrvChartData.assignAll([
  292 + // 每小时一条,00:00 ~ 15:00(CST 午夜 = 1782057600)
  293 + V2HrvTrendItem(time: 1782057600, trendHrv: 45, state: 3), // 00:00
  294 + V2HrvTrendItem(time: 1782061200, trendHrv: 42, state: 3), // 01:00
  295 + V2HrvTrendItem(time: 1782064800, trendHrv: 38, state: 3), // 02:00
  296 + V2HrvTrendItem(time: 1782068400, trendHrv: 35, state: 2), // 03:00
  297 + V2HrvTrendItem(time: 1782072000, trendHrv: 40, state: 3), // 04:00
  298 + V2HrvTrendItem(time: 1782075600, trendHrv: 55, state: 4), // 05:00
  299 + V2HrvTrendItem(time: 1782079200, trendHrv: 65, state: 4), // 06:00
  300 + V2HrvTrendItem(time: 1782082800, trendHrv: 72, state: 4), // 07:00
  301 + V2HrvTrendItem(time: 1782086400, trendHrv: 68, state: 4), // 08:00
  302 + V2HrvTrendItem(time: 1782090000, trendHrv: 58, state: 4), // 09:00
  303 + V2HrvTrendItem(time: 1782093600, trendHrv: 52, state: 3), // 10:00
  304 + V2HrvTrendItem(time: 1782097200, trendHrv: 48, state: 3), // 11:00
  305 + V2HrvTrendItem(time: 1782100800, trendHrv: 44, state: 3), // 12:00
  306 + V2HrvTrendItem(time: 1782104400, trendHrv: 38, state: 2), // 13:00
  307 + V2HrvTrendItem(time: 1782108000, trendHrv: 42, state: 3), // 14:00
  308 + V2HrvTrendItem(time: 1782111600, trendHrv: 45, state: 3), // 15:00
  309 + ]);
248 case AppFailure(): 310 case AppFailure():
249 - v2HrvTrend.value = null; 311 + hrvChartData.clear();
250 } 312 }
251 313
252 switch (await _healthApi.getV2RealtimeStress(friendUserId, intDate)) { 314 switch (await _healthApi.getV2RealtimeStress(friendUserId, intDate)) {
253 case AppSuccess(:final data): 315 case AppSuccess(:final data):
254 - v2RealtimeStress.value = data; 316 + // stressChartData.assignAll(data.list ?? []);
  317 + stressChartData.assignAll(
  318 + // 每 6 分钟一条,00:00 ~ 15:00(共 150 条)
  319 + // midnight CST = 1782057600,步长 360s
  320 + List.generate(150, (i) {
  321 + const midnight = 1782057600;
  322 + final ts = midnight + i * 360;
  323 + final minutesFromMidnight = i * 6;
  324 + final hour = minutesFromMidnight ~/ 60;
  325 + final minInHour = minutesFromMidnight % 60;
  326 + // 用正弦波模拟真实波动,早高峰(7-9点)压力最高
  327 + final base = hour < 6
  328 + ? 25.0 // 深夜低压力
  329 + : hour < 9
  330 + ? 55.0 + (hour - 6) * 10.0 // 早高峰爬升
  331 + : hour < 12
  332 + ? 75.0 - (hour - 9) * 8.0 // 上午下降
  333 + : 50.0 - (hour - 12) * 3.0; // 下午缓降
  334 + // 叠加微小波动(用 i 模拟随机)
  335 + final jitter = (i % 7 - 3) * 2.0 + (minInHour % 3) * 1.5;
  336 + final value = (base + jitter).clamp(10.0, 100.0).round();
  337 + final int state;
  338 + if (value >= 75) {
  339 + state = 1;
  340 + } else if (value >= 55) {
  341 + state = 2;
  342 + } else if (value >= 35) {
  343 + state = 3;
  344 + } else {
  345 + state = 4;
  346 + }
  347 + return V2RealtimeStressItem(time: ts, value: value, state: state);
  348 + }),
  349 + );
255 case AppFailure(): 350 case AppFailure():
256 - v2RealtimeStress.value = null; 351 + stressChartData.clear();
257 } 352 }
258 if (v2ActivityTarget.value == null) { 353 if (v2ActivityTarget.value == null) {
259 switch (await _healthApi.getV2ActivityTarget(friendUserId)) { 354 switch (await _healthApi.getV2ActivityTarget(friendUserId)) {
@@ -278,235 +373,6 @@ class TodayController extends GetxController { @@ -278,235 +373,6 @@ class TodayController extends GetxController {
278 stressChartData.clear(); 373 stressChartData.clear();
279 hrvAnnotations.clear(); 374 hrvAnnotations.clear();
280 avgHrv.value = '--'; 375 avgHrv.value = '--';
281 - v2ActivityTarget.value = null;  
282 - }  
283 -  
284 - void _applyHrvStatistics(HrvStatisticsData data, DateTime date) {  
285 - final dateKey = date.day;  
286 - final isToday = DateUtils.isSameDay(date, lastSelectableDay);  
287 -  
288 - if (isToday) {  
289 - if (avgHrv.value == '--' && data.avgHrv != null) {  
290 - avgHrv.value = _formatMetric(data.avgHrv);  
291 - }  
292 - } else {  
293 - double? dailyHrv;  
294 - if (data.hrvTrendList != null) {  
295 - for (final trend in data.hrvTrendList!) {  
296 - if (trend.timeKey == dateKey) {  
297 - dailyHrv = trend.average;  
298 - break;  
299 - }  
300 - }  
301 - }  
302 - avgHrv.value = _formatMetric(dailyHrv);  
303 - }  
304 -  
305 - if (data.avgRestingHeartRate != null) {  
306 - restingHeartRate.value = _formatMetric(data.avgRestingHeartRate);  
307 - }  
308 - }  
309 -  
310 - void _applyActivityStatistics(  
311 - ActivityBurnStatisticsData data, DateTime date) {  
312 - _activityMoveTarget = data.activityTargetInfo?.move ?? _activityMoveTarget;  
313 - _activityStandTarget =  
314 - data.activityTargetInfo?.stand ?? _activityStandTarget;  
315 -  
316 - final dateKey = date.day;  
317 - final isToday = DateUtils.isSameDay(date, lastSelectableDay);  
318 -  
319 - if (isToday) {  
320 - if (activityCalories.value == '--' && data.totalCaloriesBurned != null) {  
321 - activityCalories.value = _formatMetric(data.totalCaloriesBurned);  
322 - }  
323 - if (activityStandHours.value == '--' && data.totalStand != null) {  
324 - activityStandHours.value = _formatMetric(data.totalStand);  
325 - }  
326 - } else {  
327 - int? dailyMove;  
328 - if (data.caloriesBurnedTrendList != null) {  
329 - for (final trend in data.caloriesBurnedTrendList!) {  
330 - if (trend.timeKey == dateKey) {  
331 - dailyMove = trend.value;  
332 - break;  
333 - }  
334 - }  
335 - }  
336 - activityCalories.value = _formatMetric(dailyMove);  
337 -  
338 - int? dailyExercise;  
339 - if (data.exerciseTimeTrendList != null) {  
340 - for (final trend in data.exerciseTimeTrendList!) {  
341 - if (trend.timeKey == dateKey) {  
342 - dailyExercise = trend.value;  
343 - break;  
344 - }  
345 - }  
346 - }  
347 - activityExerciseMinutes.value = _formatMetric(dailyExercise);  
348 - activityStandHours.value = '--';  
349 - }  
350 -  
351 - final moveValue = _parseMetric(activityCalories.value);  
352 - final exerciseValue = _parseMetric(activityExerciseMinutes.value);  
353 - final standValue = _parseMetric(activityStandHours.value);  
354 -  
355 - if (moveValue != null) {  
356 - activityMoveProgress.value = _progress(  
357 - moveValue,  
358 - (_activityMoveTarget ?? 400).toDouble(),  
359 - );  
360 - } else {  
361 - activityMoveProgress.value = 0.0;  
362 - }  
363 -  
364 - if (exerciseValue != null) {  
365 - activityExerciseProgress.value = _progress(  
366 - exerciseValue,  
367 - 30.0,  
368 - );  
369 - } else {  
370 - activityExerciseProgress.value = 0.0;  
371 - }  
372 -  
373 - if (standValue != null) {  
374 - activityStandProgress.value = _progress(  
375 - standValue,  
376 - (_activityStandTarget ?? 12).toDouble(),  
377 - );  
378 - } else {  
379 - activityStandProgress.value = 0.0;  
380 - }  
381 - }  
382 -  
383 - void _applyLatestTodayHrv(TodayHrvData? latest) {  
384 - final value = latest?.value;  
385 - if (value == null) return;  
386 - }  
387 -  
388 - void _applyActivityRecentData(TodayStatusRecentData? recent) {  
389 - activityCalories.value = _formatMetric(recent?.move);  
390 - activityExerciseMinutes.value = _formatMetric(recent?.exercise);  
391 - activityStandHours.value = _formatMetric(recent?.stand);  
392 -  
393 - activityMoveProgress.value = _progress(  
394 - (recent?.move ?? 0).toDouble(),  
395 - (_activityMoveTarget ?? 400).toDouble(),  
396 - );  
397 - activityExerciseProgress.value = _progress(  
398 - (recent?.exercise ?? 0).toDouble(),  
399 - 30,  
400 - );  
401 - activityStandProgress.value = _progress(  
402 - (recent?.stand ?? 0).toDouble(),  
403 - (_activityStandTarget ?? 12).toDouble(),  
404 - );  
405 - }  
406 -  
407 - List<HrvDataPoint> _buildHrvChartData(List<TodayHrvData>? dataList) {  
408 - final points = <HrvDataPoint>[];  
409 - for (final item in dataList ?? const <TodayHrvData>[]) {  
410 - final time = item.time;  
411 - final value = item.value;  
412 - if (time == null || value == null) continue;  
413 - points.add(HrvDataPoint(hour: _hourFromApiTime(time), hrv: value));  
414 - }  
415 - points.sort((a, b) => a.hour.compareTo(b.hour));  
416 - return points;  
417 - }  
418 -  
419 - TodayHrvData? _latestTodayHrv(List<TodayHrvData>? dataList) {  
420 - TodayHrvData? latest;  
421 - for (final item in dataList ?? const <TodayHrvData>[]) {  
422 - if (item.value == null) continue;  
423 - final latestTime = latest?.time ?? -1;  
424 - final itemTime = item.time ?? -1;  
425 - if (latest == null || itemTime >= latestTime) {  
426 - latest = item;  
427 - }  
428 - }  
429 - return latest;  
430 - }  
431 -  
432 - String _hrvStatusTitle({required double value, double? baseline}) {  
433 - if ((baseline ?? 0) > 0) {  
434 - if (value >= 1.2 * baseline!) return HrvStatus.energetic.title;  
435 - if (value <= 0.8 * baseline) return HrvStatus.overload.title;  
436 - return HrvStatus.normal.title;  
437 - }  
438 - if (value >= 128) return HrvStatus.energetic.title;  
439 - if (value <= 30) return HrvStatus.overload.title;  
440 - return HrvStatus.normal.title;  
441 - }  
442 -  
443 - double _stressScoreFromHrv(double hrv) {  
444 - return (100 - hrv).clamp(0, 100).toDouble();  
445 - }  
446 -  
447 - int? _normalizeDurationMinutes(int? rawDuration) {  
448 - if (rawDuration == null || rawDuration <= 0) return null;  
449 - if (rawDuration > 24 * 60 * 60) {  
450 - return (rawDuration / 60000).round();  
451 - }  
452 - if (rawDuration > 24 * 60) {  
453 - return (rawDuration / 60).round();  
454 - }  
455 - return rawDuration;  
456 - }  
457 -  
458 - String _sleepQualityFromDuration(int minutes) {  
459 - if (minutes >= 7 * 60 && minutes <= 9 * 60) return '优秀';  
460 - if (minutes >= 6 * 60 && minutes < 10 * 60) return '良好';  
461 - if (minutes >= 5 * 60) return '一般';  
462 - return '偏少';  
463 - }  
464 -  
465 - String _sleepQualityFromDeepPercentage(double percentage) {  
466 - if (percentage >= 25) return '优秀';  
467 - if (percentage >= 18) return '良好';  
468 - if (percentage >= 12) return '一般';  
469 - return '偏少';  
470 - }  
471 -  
472 - double _progress(double value, double target) {  
473 - if (target <= 0) return 0;  
474 - return (value / target).clamp(0, 1).toDouble();  
475 - }  
476 -  
477 - double? _parseMetric(String value) {  
478 - if (value == '--') return null;  
479 - return double.tryParse(value.replaceAll(',', ''));  
480 - }  
481 -  
482 - double _hourFromApiTime(int time) {  
483 - if (time >= 1000000000000) {  
484 - final date = DateTime.fromMillisecondsSinceEpoch(time);  
485 - return date.hour + date.minute / 60;  
486 - }  
487 - if (time >= 1000000000) {  
488 - final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);  
489 - return date.hour + date.minute / 60;  
490 - }  
491 - if (time > 24) {  
492 - final hour = (time ~/ 100).clamp(0, 23);  
493 - final minute = (time % 100).clamp(0, 59);  
494 - return hour + minute / 60;  
495 - }  
496 - return time.toDouble().clamp(0, 24).toDouble();  
497 - }  
498 -  
499 - String _formatMetric(num? value) {  
500 - if (value == null) return '--';  
501 - if (value % 1 == 0) return '${value.toInt()}';  
502 - return value.toStringAsFixed(1);  
503 - }  
504 -  
505 - String _formatHour(double hour) {  
506 - final totalMinutes = (hour * 60).round();  
507 - final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');  
508 - final m = (totalMinutes % 60).toString().padLeft(2, '0');  
509 - return '$h:$m';  
510 } 376 }
511 377
512 toTrendHrvPage() { 378 toTrendHrvPage() {
@@ -558,15 +424,7 @@ class TodayController extends GetxController { @@ -558,15 +424,7 @@ class TodayController extends GetxController {
558 } 424 }
559 425
560 showFriendListBottomSheet() async { 426 showFriendListBottomSheet() async {
561 - _friendApi.friendList(false).then(  
562 - (res) {  
563 - switch (res) {  
564 - case AppSuccess(:final data):  
565 - friendsList.assignAll(data.list);  
566 - case AppFailure():  
567 - }  
568 - },  
569 - ); 427 + _refreshFriendList();
570 428
571 Get.bottomSheet( 429 Get.bottomSheet(
572 DraggableScrollableSheet( 430 DraggableScrollableSheet(
@@ -586,5 +444,29 @@ class TodayController extends GetxController { @@ -586,5 +444,29 @@ class TodayController extends GetxController {
586 ); 444 );
587 } 445 }
588 446
  447 + void _refreshFriendList({VoidCallback? onFriendListUpdated}) {
  448 + _friendApi.friendList(false).then(
  449 + (res) {
  450 + switch (res) {
  451 + case AppSuccess(:final data):
  452 + friendsList.assignAll(data.list);
  453 + onFriendListUpdated?.call();
  454 + case AppFailure():
  455 + }
  456 + },
  457 + );
  458 + }
  459 +
589 RxList<FriendItem> friendsList = RxList.empty(); 460 RxList<FriendItem> friendsList = RxList.empty();
  461 +
  462 + Future<void> toPremiumPage() async {
  463 + await Get.toNamed(Routes.PURCHASE);
  464 + final vipResult = await _vipApi.getVipInfo();
  465 + if (vipResult case AppSuccess(data: final vip)) {
  466 + try {
  467 + final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip);
  468 + await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs);
  469 + } on Exception catch (e) {}
  470 + }
  471 + }
590 } 472 }
1 import 'package:cached_network_image/cached_network_image.dart'; 1 import 'package:cached_network_image/cached_network_image.dart';
2 2
3 import 'package:doublefeel_flutter/app/modules/home/widgets/today/hrv_measurement_bottom_sheet.dart'; 3 import 'package:doublefeel_flutter/app/modules/home/widgets/today/hrv_measurement_bottom_sheet.dart';
  4 +import 'package:doublefeel_flutter/app/modules/home/widgets/today/no_health_data_page.dart';
4 import 'package:doublefeel_flutter/app/modules/report_common/models/report_period.dart'; 5 import 'package:doublefeel_flutter/app/modules/report_common/models/report_period.dart';
5 import 'package:doublefeel_flutter/app/modules/report_common/widgets/report_date_picker_sheet.dart'; 6 import 'package:doublefeel_flutter/app/modules/report_common/widgets/report_date_picker_sheet.dart';
6 7
@@ -190,9 +191,11 @@ class TodayTabBody extends StatelessWidget { @@ -190,9 +191,11 @@ class TodayTabBody extends StatelessWidget {
190 sliver: SliverList( 191 sliver: SliverList(
191 delegate: SliverChildListDelegate([ 192 delegate: SliverChildListDelegate([
192 Obx( 193 Obx(
193 - () => controller.showHealthDataAuthCard.value 194 + () => controller.showHealthDataAuthCardStatus.value != 1
194 ? TodayHealthDataAuthCard( 195 ? TodayHealthDataAuthCard(
195 - onAuthorizeTap: controller.requestHealthAuthorization, 196 + onAuthorizeTap: () {
  197 + controller.onAuthorizeTap();
  198 + },
196 ) 199 )
197 : const SizedBox.shrink(), 200 : const SizedBox.shrink(),
198 ), 201 ),
@@ -201,8 +204,12 @@ class TodayTabBody extends StatelessWidget { @@ -201,8 +204,12 @@ class TodayTabBody extends StatelessWidget {
201 ? const SizedBox.shrink() 204 ? const SizedBox.shrink()
202 : const PremiumCard(); 205 : const PremiumCard();
203 }), 206 }),
204 - TodayHrvAdBanner(controller: controller),  
205 - TodayPartnerAdBanner(controller: controller), 207 + Obx(() => controller.showHrvAdBanner.value
  208 + ? TodayHrvAdBanner(controller: controller)
  209 + : const SizedBox.shrink()),
  210 + Obx(() => controller.showPartnerAdBanner.value
  211 + ? TodayPartnerAdBanner(controller: controller)
  212 + : const SizedBox.shrink()),
206 TodayHrvNumberCard(controller: controller), 213 TodayHrvNumberCard(controller: controller),
207 const SizedBox(height: 12), 214 const SizedBox(height: 12),
208 TodayHrvChartCard(controller: controller), 215 TodayHrvChartCard(controller: controller),
@@ -245,7 +252,6 @@ class TodayTabBody extends StatelessWidget { @@ -245,7 +252,6 @@ class TodayTabBody extends StatelessWidget {
245 return GestureDetector( 252 return GestureDetector(
246 onTap: showHrvMeasurementBottomSheet, 253 onTap: showHrvMeasurementBottomSheet,
247 child: Container( 254 child: Container(
248 - margin: EdgeInsets.only(top: 12),  
249 padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), 255 padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
250 decoration: BoxDecoration( 256 decoration: BoxDecoration(
251 color: Colors.white, 257 color: Colors.white,
@@ -506,6 +512,7 @@ class _LatestHrvCard extends StatelessWidget { @@ -506,6 +512,7 @@ class _LatestHrvCard extends StatelessWidget {
506 final latestDataTimeText = DateFormat('HH:mm') 512 final latestDataTimeText = DateFormat('HH:mm')
507 .format(DateTime.fromMillisecondsSinceEpoch(latestDataTime * 1000)); 513 .format(DateTime.fromMillisecondsSinceEpoch(latestDataTime * 1000));
508 return Container( 514 return Container(
  515 + margin: EdgeInsets.only(bottom: 12),
509 padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), 516 padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
510 decoration: BoxDecoration( 517 decoration: BoxDecoration(
511 color: Colors.white, 518 color: Colors.white,
@@ -44,7 +44,8 @@ class AccountSettingView extends GetView<MyController> { @@ -44,7 +44,8 @@ class AccountSettingView extends GetView<MyController> {
44 SizedBox(height: 16), 44 SizedBox(height: 16),
45 _buildAccountCard( 45 _buildAccountCard(
46 context: context, 46 context: context,
47 - phone: userPrefs.preferences.value.meUserInfo?.telephone ?? '', 47 + title: context.l10n.mobilePhoneNumber,
  48 + content: userPrefs.preferences.value.meUserInfo?.telephone ?? '',
48 ), 49 ),
49 SizedBox(height: 20), 50 SizedBox(height: 20),
50 GestureDetector( 51 GestureDetector(
@@ -80,8 +81,11 @@ class AccountSettingView extends GetView<MyController> { @@ -80,8 +81,11 @@ class AccountSettingView extends GetView<MyController> {
80 ); 81 );
81 } 82 }
82 83
83 - Widget _buildAccountCard(  
84 - {required BuildContext context, required String phone}) { 84 + Widget _buildAccountCard({
  85 + required BuildContext context,
  86 + required String title,
  87 + required String content,
  88 + }) {
85 return Container( 89 return Container(
86 height: 110, 90 height: 110,
87 margin: EdgeInsets.symmetric(horizontal: 15), 91 margin: EdgeInsets.symmetric(horizontal: 15),
@@ -98,7 +102,7 @@ class AccountSettingView extends GetView<MyController> { @@ -98,7 +102,7 @@ class AccountSettingView extends GetView<MyController> {
98 child: Row( 102 child: Row(
99 children: [ 103 children: [
100 Text( 104 Text(
101 - context.l10n.mobilePhoneNumber, 105 + title,
102 style: TextStyle( 106 style: TextStyle(
103 color: AppColors.textPrimary, 107 color: AppColors.textPrimary,
104 fontSize: 14, 108 fontSize: 14,
@@ -109,7 +113,7 @@ class AccountSettingView extends GetView<MyController> { @@ -109,7 +113,7 @@ class AccountSettingView extends GetView<MyController> {
109 SizedBox(width: 16), 113 SizedBox(width: 16),
110 Expanded( 114 Expanded(
111 child: Text( 115 child: Text(
112 - phone, 116 + content,
113 maxLines: 1, 117 maxLines: 1,
114 overflow: TextOverflow.ellipsis, 118 overflow: TextOverflow.ellipsis,
115 textAlign: TextAlign.right, 119 textAlign: TextAlign.right,
  1 +import 'package:doublefeel_flutter/app/modules/user_onboarding/views/user_onboarding_pages.dart';
  2 +import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
  3 +import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
1 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 4 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
2 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
3 import 'package:flutter/services.dart'; 6 import 'package:flutter/services.dart';
@@ -361,3 +364,31 @@ class _IconPlaceholder extends StatelessWidget { @@ -361,3 +364,31 @@ class _IconPlaceholder extends StatelessWidget {
361 ); 364 );
362 } 365 }
363 } 366 }
  367 +
  368 +class NoHealthDataNoPermissionPage extends StatelessWidget {
  369 + const NoHealthDataNoPermissionPage({
  370 + super.key,
  371 + this.onRequest,
  372 + });
  373 +
  374 + final VoidCallback? onRequest;
  375 +
  376 + @override
  377 + Widget build(BuildContext context) {
  378 + return AnnotatedRegion<SystemUiOverlayStyle>(
  379 + value: AppTheme.systemUiOverlayStyle.copyWith(
  380 + systemNavigationBarColor: context.colors.backgroundPage,
  381 + ),
  382 + child: GuideCommonScaffold(
  383 + onBackPressed: Get.back,
  384 + bottom: OnboardingBottomButton(
  385 + label: '前往开启',
  386 + enabled: true,
  387 + onPressed: () {
  388 + onRequest?.call();
  389 + },
  390 + ),
  391 + child: const HealthPermissionPage(),
  392 + ));
  393 + }
  394 +}
@@ -19,17 +19,23 @@ class TodayHrvAdBanner extends StatelessWidget { @@ -19,17 +19,23 @@ class TodayHrvAdBanner extends StatelessWidget {
19 return _TodayGuideBanner( 19 return _TodayGuideBanner(
20 title: context.l10n.clickToAddTheHrvThemedWatchFace, 20 title: context.l10n.clickToAddTheHrvThemedWatchFace,
21 subtitle: context.l10n.stayOnTopOfYourHealthFluctuations, 21 subtitle: context.l10n.stayOnTopOfYourHealthFluctuations,
22 - right: Container(  
23 - padding: const EdgeInsets.all(4),  
24 - decoration: BoxDecoration(  
25 - color: Colors.white,  
26 - shape: BoxShape.circle,  
27 - ),  
28 - child: Image.asset(  
29 - 'assets/images/common/ic_arrow_forward.png',  
30 - width: 20,  
31 - height: 20,  
32 - color: context.colors.primary, 22 + right: GestureDetector(
  23 + onTap: () async {
  24 + await Get.toNamed(Routes.WATCH_THEME);
  25 + controller.checkAddFriendVisible();
  26 + },
  27 + child: Container(
  28 + padding: const EdgeInsets.all(4),
  29 + decoration: BoxDecoration(
  30 + color: Colors.white,
  31 + shape: BoxShape.circle,
  32 + ),
  33 + child: Image.asset(
  34 + 'assets/images/common/ic_arrow_forward.png',
  35 + width: 20,
  36 + height: 20,
  37 + color: context.colors.primary,
  38 + ),
33 ), 39 ),
34 ), 40 ),
35 onClose: controller.dismissHrvAdBanner, 41 onClose: controller.dismissHrvAdBanner,
@@ -38,7 +44,6 @@ class TodayHrvAdBanner extends StatelessWidget { @@ -38,7 +44,6 @@ class TodayHrvAdBanner extends StatelessWidget {
38 } 44 }
39 } 45 }
40 46
41 -/// Figma: 添加亲密联系人 Banner,右上角关闭按钮  
42 class TodayPartnerAdBanner extends StatelessWidget { 47 class TodayPartnerAdBanner extends StatelessWidget {
43 const TodayPartnerAdBanner({super.key, required this.controller}); 48 const TodayPartnerAdBanner({super.key, required this.controller});
44 49
@@ -57,7 +62,10 @@ class TodayPartnerAdBanner extends StatelessWidget { @@ -57,7 +62,10 @@ class TodayPartnerAdBanner extends StatelessWidget {
57 title: context.l10n.addACloseContact, 62 title: context.l10n.addACloseContact,
58 subtitle: context.l10n.oneMorePersonLookingOutForYourHealth, 63 subtitle: context.l10n.oneMorePersonLookingOutForYourHealth,
59 right: GestureDetector( 64 right: GestureDetector(
60 - onTap: () => Get.toNamed(AppRoutes.bindPartner), 65 + onTap: () async {
  66 + await Get.toNamed(Routes.ADD_FRIEND);
  67 + controller.checkAddFriendVisible();
  68 + },
61 behavior: HitTestBehavior.opaque, 69 behavior: HitTestBehavior.opaque,
62 child: Container( 70 child: Container(
63 width: 72, 71 width: 72,
1 -import 'package:doublefeel_flutter/app/routes/app_pages.dart';  
2 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 1 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
3 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 2 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  3 +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 import 'package:fl_chart/fl_chart.dart'; 5 import 'package:fl_chart/fl_chart.dart';
6 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
@@ -22,17 +22,29 @@ class TodayHrvChartCard extends StatelessWidget { @@ -22,17 +22,29 @@ class TodayHrvChartCard extends StatelessWidget {
22 22
23 final TodayController controller; 23 final TodayController controller;
24 24
25 - static const _h5 = Color(0xFFCCCCCC); 25 + static const _h5 = Color(0xFFF3F3F3);
26 26
27 @override 27 @override
28 Widget build(BuildContext context) { 28 Widget build(BuildContext context) {
29 final userPrefs = Get.find<UserPreferencesStorage>(); 29 final userPrefs = Get.find<UserPreferencesStorage>();
30 return Obx(() { 30 return Obx(() {
31 - final hrvSpots =  
32 - controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList(); 31 + final hrvSpots = controller.hrvChartData
  32 + .map((p) => FlSpot(
  33 + _tsToSecondsFromMidnight(p.time),
  34 + (p.trendHrv ?? 0).toDouble(),
  35 + ))
  36 + .toList();
33 final stressPoints = controller.stressChartData.toList(); 37 final stressPoints = controller.stressChartData.toList();
34 final preferences = userPrefs.preferences.value; 38 final preferences = userPrefs.preferences.value;
35 final vipInfo = preferences.vipInfo; 39 final vipInfo = preferences.vipInfo;
  40 + final sleepList = controller.v2HealthData.value?.sleepTimeList;
  41 + final selectedDate = controller.selectedDate.value;
  42 +
  43 + // HRV: spot.x(秒偏移) → state 映射,供图表内部颜色/状态使用
  44 + final Map<double, int?> hrvStateMap = {
  45 + for (final p in controller.hrvChartData)
  46 + _tsToSecondsFromMidnight(p.time): p.state,
  47 + };
36 48
37 return Container( 49 return Container(
38 padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), 50 padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
@@ -49,7 +61,8 @@ class TodayHrvChartCard extends StatelessWidget { @@ -49,7 +61,8 @@ class TodayHrvChartCard extends StatelessWidget {
49 height: 174, 61 height: 174,
50 child: hrvSpots.isEmpty 62 child: hrvSpots.isEmpty
51 ? _EmptyChart(text: context.l10n.noDataAvailableForToday) 63 ? _EmptyChart(text: context.l10n.noDataAvailableForToday)
52 - : LineChart(_hrvLineChartData(context, hrvSpots)), 64 + : LineChart(
  65 + _hrvLineChartData(context, hrvSpots, hrvStateMap)),
53 ), 66 ),
54 Padding( 67 Padding(
55 padding: const EdgeInsets.only(bottom: 14, top: 16), 68 padding: const EdgeInsets.only(bottom: 14, top: 16),
@@ -97,10 +110,13 @@ class TodayHrvChartCard extends StatelessWidget { @@ -97,10 +110,13 @@ class TodayHrvChartCard extends StatelessWidget {
97 ), 110 ),
98 SizedBox( 111 SizedBox(
99 height: 190, 112 height: 190,
100 - child: stressPoints.isEmpty  
101 - ? const _EmptyChart(text: '暂无压力数据')  
102 - : _buildStressChart(  
103 - context, stressPoints, vipInfo?.isVip == true), 113 + child: _buildStressChart(
  114 + context,
  115 + sleepList,
  116 + stressPoints,
  117 + selectedDate,
  118 + vipInfo?.isVip == true,
  119 + ),
104 ), 120 ),
105 ], 121 ],
106 ), 122 ),
@@ -155,24 +171,62 @@ class TodayHrvChartCard extends StatelessWidget { @@ -155,24 +171,62 @@ class TodayHrvChartCard extends StatelessWidget {
155 ); 171 );
156 } 172 }
157 173
158 - LineChartData _hrvLineChartData(BuildContext context, List<FlSpot> spots) { 174 + /// 将 Unix 时间戳(秒)转换为距当天 00:00 的秒数偏移
  175 + static double _tsToSecondsFromMidnight(int? ts) {
  176 + if (ts == null) return 0;
  177 + final dt = DateTime.fromMillisecondsSinceEpoch(ts * 1000);
  178 + final midnight = DateTime(dt.year, dt.month, dt.day);
  179 + return (ts - midnight.millisecondsSinceEpoch / 1000.0);
  180 + }
  181 +
  182 + /// 下一个 6h 刻度相对当天 0 点的秒数
  183 + /// 例如当前 13:58 → 下一刻度 18:00 → 返回 18 * 3600 = 64800
  184 + double _maxChartSeconds() {
  185 + final now = DateTime.now();
  186 + final secondsFromMidnight = now.hour * 3600 + now.minute * 60 + now.second;
  187 + final nextSlot = ((secondsFromMidnight / (6 * 3600)).ceil()).clamp(1, 4);
  188 + return nextSlot * 6 * 3600.0;
  189 + }
  190 +
  191 + LineChartData _hrvLineChartData(
  192 + BuildContext context,
  193 + List<FlSpot> spots,
  194 + Map<double, int?> stateMap,
  195 + ) {
159 return LineChartData( 196 return LineChartData(
160 minX: 0, 197 minX: 0,
161 - maxX: _maxChartHour(spots), 198 + maxX: _maxChartSeconds(),
162 minY: 0, 199 minY: 0,
163 maxY: 80, 200 maxY: 80,
164 gridData: FlGridData( 201 gridData: FlGridData(
165 show: true, 202 show: true,
166 drawVerticalLine: true, 203 drawVerticalLine: true,
167 drawHorizontalLine: false, 204 drawHorizontalLine: false,
168 - verticalInterval: 6, 205 + verticalInterval: 6 * 3600,
  206 + checkToShowVerticalLine: (val) {
  207 + // if (val == startTs) return false;
  208 + // if (val > endTs) return false;
  209 + return true;
  210 + },
169 getDrawingVerticalLine: (_) => const FlLine( 211 getDrawingVerticalLine: (_) => const FlLine(
170 color: _h5, 212 color: _h5,
171 strokeWidth: 1, 213 strokeWidth: 1,
172 dashArray: [2, 2], 214 dashArray: [2, 2],
173 ), 215 ),
174 ), 216 ),
175 - borderData: FlBorderData(show: false), 217 + borderData: FlBorderData(
  218 + show: false,
  219 + ),
  220 + extraLinesData: ExtraLinesData(
  221 + verticalLines: [
  222 + VerticalLine(x: 0, color: _h5, strokeWidth: 1, dashArray: [2, 2]),
  223 + VerticalLine(
  224 + x: _maxChartSeconds(),
  225 + color: _h5,
  226 + strokeWidth: 1,
  227 + dashArray: [2, 2]),
  228 + ],
  229 + ),
176 titlesData: FlTitlesData( 230 titlesData: FlTitlesData(
177 topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), 231 topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
178 rightTitles: 232 rightTitles:
@@ -182,7 +236,7 @@ class TodayHrvChartCard extends StatelessWidget { @@ -182,7 +236,7 @@ class TodayHrvChartCard extends StatelessWidget {
182 sideTitles: SideTitles( 236 sideTitles: SideTitles(
183 showTitles: true, 237 showTitles: true,
184 reservedSize: 20, 238 reservedSize: 20,
185 - interval: 6, 239 + interval: 6 * 3600,
186 getTitlesWidget: (val, _) { 240 getTitlesWidget: (val, _) {
187 final label = _timeLabel(val); 241 final label = _timeLabel(val);
188 if (label == null) return const SizedBox.shrink(); 242 if (label == null) return const SizedBox.shrink();
@@ -207,7 +261,7 @@ class TodayHrvChartCard extends StatelessWidget { @@ -207,7 +261,7 @@ class TodayHrvChartCard extends StatelessWidget {
207 radius: 4, 261 radius: 4,
208 color: Colors.white, 262 color: Colors.white,
209 strokeWidth: 3, 263 strokeWidth: 3,
210 - strokeColor: _getColor(spot.y), 264 + strokeColor: _getColorByState(stateMap[spot.x]),
211 ); 265 );
212 }, 266 },
213 ), 267 ),
@@ -227,7 +281,7 @@ class TodayHrvChartCard extends StatelessWidget { @@ -227,7 +281,7 @@ class TodayHrvChartCard extends StatelessWidget {
227 radius: 5, 281 radius: 5,
228 color: Colors.white, 282 color: Colors.white,
229 strokeWidth: 3.5, 283 strokeWidth: 3.5,
230 - strokeColor: _getColor(spot.y), 284 + strokeColor: _getColorByState(stateMap[spot.x]),
231 ); 285 );
232 }, 286 },
233 ), 287 ),
@@ -237,6 +291,7 @@ class TodayHrvChartCard extends StatelessWidget { @@ -237,6 +291,7 @@ class TodayHrvChartCard extends StatelessWidget {
237 touchTooltipData: LineTouchTooltipData( 291 touchTooltipData: LineTouchTooltipData(
238 tooltipRoundedRadius: 8, 292 tooltipRoundedRadius: 8,
239 tooltipBorder: BorderSide.none, 293 tooltipBorder: BorderSide.none,
  294 + maxContentWidth: 153,
240 getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3), 295 getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
241 tooltipPadding: const EdgeInsets.only( 296 tooltipPadding: const EdgeInsets.only(
242 left: 12, 297 left: 12,
@@ -252,7 +307,7 @@ class TodayHrvChartCard extends StatelessWidget { @@ -252,7 +307,7 @@ class TodayHrvChartCard extends StatelessWidget {
252 color: Colors.white, 307 color: Colors.white,
253 fontWeight: FontWeight.bold, 308 fontWeight: FontWeight.bold,
254 ), 309 ),
255 - children: _getTooltipChildren(barSpot), 310 + children: _getTooltipChildren(barSpot, stateMap[barSpot.x]),
256 textAlign: TextAlign.start, 311 textAlign: TextAlign.start,
257 ); 312 );
258 }).toList(); 313 }).toList();
@@ -263,153 +318,247 @@ class TodayHrvChartCard extends StatelessWidget { @@ -263,153 +318,247 @@ class TodayHrvChartCard extends StatelessWidget {
263 } 318 }
264 319
265 Widget _buildStressChart( 320 Widget _buildStressChart(
266 - BuildContext context, List<HrvDataPoint> stressPoints, bool isVip) {  
267 - void onTapPurchase() => Get.toNamed(Routes.PURCHASE);  
268 - final spanWidth = 74.0; 321 + BuildContext context,
  322 + List<V2SleepTimeRange>? sleepList,
  323 + List<V2RealtimeStressItem> stressPoints,
  324 + DateTime selectedDate,
  325 + bool isVip) {
  326 + void onTapPurchase() {
  327 + controller.toPremiumPage();
  328 + }
  329 +
  330 + // 槽时间秒 → state 映射(与 _buildStressBarGroups 一致:取 value 最大那条 of state)
  331 + const slotSec = 6 * 60;
  332 + final slotStateMap = <int, ({int value, int? state})>{};
  333 + for (final p in stressPoints) {
  334 + final sec = _tsToSecondsFromMidnight(p.time).round();
  335 + final slot = (sec / slotSec).round() * slotSec;
  336 + final v = p.value ?? 0;
  337 + final existing = slotStateMap[slot];
  338 + if (existing == null || v > existing.value) {
  339 + slotStateMap[slot] = (value: v, state: p.state);
  340 + }
  341 + }
269 return Stack( 342 return Stack(
270 children: [ 343 children: [
271 - Padding(  
272 - padding: const EdgeInsets.only(top: 6),  
273 - child: BarChart(  
274 - BarChartData(  
275 - minY: 0,  
276 - maxY: 100,  
277 - groupsSpace: 1,  
278 - alignment: BarChartAlignment.start,  
279 - barGroups: List.generate(  
280 - stressPoints.length,  
281 - (i) {  
282 - final point = stressPoints[i];  
283 - return BarChartGroupData(  
284 - x: (point.hour * 10).round(),  
285 - barRods: [  
286 - BarChartRodData(  
287 - toY: point.hrv,  
288 - width: 2,  
289 - color: _getColor(point.hrv),  
290 - borderRadius: const BorderRadius.only(  
291 - topLeft: Radius.circular(2),  
292 - topRight: Radius.circular(2), 344 + if (stressPoints.isEmpty)
  345 + _EmptyChart(text: '暂无压力数据')
  346 + else
  347 + LayoutBuilder(
  348 + builder: (context, constraints) {
  349 + // 右轴 reservedSize=22,剩余宽度平分给所有槽
  350 + const rightAxisWidth = 22.0;
  351 + const slotSec = 6 * 60;
  352 + final slotCount =
  353 + (_maxChartSeconds() / slotSec).ceil().clamp(1, 9999);
  354 + // final barWidth =
  355 + // (constraints.maxWidth - rightAxisWidth) / slotCount;
  356 + const double groupsSpace = 0.5; // 设置间隔为 1 像素
  357 + final barWidth = (constraints.maxWidth -
  358 + rightAxisWidth -
  359 + (groupsSpace * (slotCount - 1))) /
  360 + slotCount;
  361 +
  362 + // 根据 V2SleepTimeRange 计算多段睡眠区间的 Positioned widgets
  363 + final sleepWidgets = <Widget>[];
  364 +
  365 + if (sleepList != null && sleepList.isNotEmpty) {
  366 + final todayMidnight = DateTime(
  367 + selectedDate.year, selectedDate.month, selectedDate.day);
  368 + final todayMidnightSec =
  369 + todayMidnight.millisecondsSinceEpoch ~/ 1000;
  370 + final maxSec = _maxChartSeconds();
  371 + final chartWidth = constraints.maxWidth - rightAxisWidth;
  372 +
  373 + for (final range in sleepList) {
  374 + final fromSec = range.fromTime - todayMidnightSec;
  375 + final toSec = range.toTime - todayMidnightSec;
  376 + final visibleFrom = fromSec.clamp(0.0, maxSec);
  377 + final visibleTo = toSec.clamp(0.0, maxSec);
  378 + if (visibleTo > visibleFrom) {
  379 + final sleepSpanLeft = chartWidth * (visibleFrom / maxSec);
  380 + final sleepSpanWidth =
  381 + (chartWidth * (visibleTo / maxSec)) - sleepSpanLeft;
  382 + if (sleepSpanWidth > 0) {
  383 + sleepWidgets.add(
  384 + Positioned(
  385 + left: sleepSpanLeft,
  386 + width: sleepSpanWidth,
  387 + top: 0,
  388 + bottom: 22,
  389 + child: IgnorePointer(
  390 + child: Stack(
  391 + clipBehavior: Clip.none,
  392 + children: [
  393 + Positioned(
  394 + left: 0,
  395 + right: 0,
  396 + top: 14,
  397 + bottom: 0,
  398 + child: Container(
  399 + decoration: const BoxDecoration(
  400 + gradient: LinearGradient(
  401 + begin: Alignment(0.50, -0.00),
  402 + end: Alignment(0.50, 1.00),
  403 + colors: [
  404 + Color(0x4C835DED),
  405 + Color(0x00845EEE)
  406 + ],
  407 + ),
  408 + ),
  409 + ),
  410 + ),
  411 + Positioned(
  412 + left: 0,
  413 + right: 0,
  414 + top: 14,
  415 + height: 2,
  416 + child:
  417 + ColoredBox(color: context.colors.primary),
  418 + ),
  419 + if (sleepSpanWidth >= 20.0)
  420 + Positioned(
  421 + left: (sleepSpanWidth - 12) / 2,
  422 + top: 0,
  423 + child: Image.asset(
  424 + 'assets/images/common/ic_health_bed.webp',
  425 + width: 12,
  426 + height: 12,
  427 + ),
  428 + ),
  429 + ],
  430 + ),
  431 + ),
293 ), 432 ),
294 - ),  
295 - ],  
296 - );  
297 - },  
298 - ),  
299 - gridData: FlGridData(  
300 - show: true,  
301 - drawVerticalLine: false,  
302 - drawHorizontalLine: true,  
303 - horizontalInterval: 25,  
304 - getDrawingHorizontalLine: (_) => const FlLine(  
305 - color: _h5,  
306 - strokeWidth: 1,  
307 - dashArray: [2, 2],  
308 - ),  
309 - ),  
310 - borderData: FlBorderData(show: false),  
311 - titlesData: FlTitlesData(  
312 - topTitles:  
313 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
314 - leftTitles:  
315 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
316 - rightTitles: AxisTitles(  
317 - sideTitles: SideTitles(  
318 - showTitles: true,  
319 - interval: 50,  
320 - reservedSize: 22,  
321 - getTitlesWidget: (val, _) {  
322 - if (val < 0 || val > 100) {  
323 - return const SizedBox.shrink();  
324 - }  
325 - return Text(  
326 - '${val.toInt()}',  
327 - style: TextStyle(  
328 - fontSize: 10, color: context.colors.textTertiary),  
329 ); 433 );
330 - },  
331 - ),  
332 - ),  
333 - bottomTitles: AxisTitles(  
334 - sideTitles: SideTitles(  
335 - showTitles: true,  
336 - reservedSize: 22,  
337 - interval: 60,  
338 - getTitlesWidget: (val, meta) {  
339 - final label = _timeLabel(val / 10);  
340 - if (label == null) return const SizedBox.shrink();  
341 - return SideTitleWidget(  
342 - meta: meta,  
343 - child: Text(  
344 - label,  
345 - style: TextStyle(  
346 - fontSize: 10, color: context.colors.textTertiary), 434 + }
  435 + }
  436 + }
  437 + }
  438 +
  439 + return Stack(
  440 + clipBehavior: Clip.none,
  441 + children: [
  442 + Padding(
  443 + padding: const EdgeInsets.only(top: 6),
  444 + child: BarChart(
  445 + BarChartData(
  446 + minY: 0,
  447 + maxY: 100,
  448 + groupsSpace: groupsSpace,
  449 + alignment: BarChartAlignment.start,
  450 + barGroups: _buildStressBarGroups(stressPoints,
  451 + barWidth: barWidth),
  452 + gridData: FlGridData(
  453 + show: true,
  454 + drawVerticalLine: false,
  455 + drawHorizontalLine: true,
  456 + horizontalInterval: 25,
  457 + getDrawingHorizontalLine: (_) => const FlLine(
  458 + color: _h5,
  459 + strokeWidth: 1,
  460 + dashArray: [2, 2],
  461 + ),
  462 + ),
  463 + extraLinesData: ExtraLinesData(
  464 + horizontalLines: [
  465 + HorizontalLine(
  466 + y: 0,
  467 + color: _h5,
  468 + strokeWidth: 1,
  469 + dashArray: [2, 2],
  470 + ),
  471 + HorizontalLine(
  472 + y: 100,
  473 + color: _h5,
  474 + strokeWidth: 1,
  475 + dashArray: [2, 2],
  476 + ),
  477 + ],
  478 + ),
  479 + borderData: FlBorderData(show: false),
  480 + titlesData: FlTitlesData(
  481 + topTitles: const AxisTitles(
  482 + sideTitles: SideTitles(showTitles: false)),
  483 + leftTitles: const AxisTitles(
  484 + sideTitles: SideTitles(showTitles: false)),
  485 + rightTitles: AxisTitles(
  486 + sideTitles: SideTitles(
  487 + showTitles: true,
  488 + interval: 50,
  489 + reservedSize: 22,
  490 + getTitlesWidget: (val, _) {
  491 + if (val < 0 || val > 100) {
  492 + return const SizedBox.shrink();
  493 + }
  494 + return Text(
  495 + '${val.toInt()}',
  496 + style: TextStyle(
  497 + fontSize: 10,
  498 + color: context.colors.textTertiary),
  499 + );
  500 + },
  501 + ),
  502 + ),
  503 + bottomTitles: AxisTitles(
  504 + sideTitles: SideTitles(
  505 + showTitles: true,
  506 + reservedSize: 22,
  507 + interval: 60,
  508 + getTitlesWidget: (val, meta) {
  509 + final label = _timeLabel(val);
  510 + if (label == null) {
  511 + return const SizedBox.shrink();
  512 + }
  513 + return SideTitleWidget(
  514 + meta: meta,
  515 + child: Text(
  516 + label,
  517 + style: TextStyle(
  518 + fontSize: 10,
  519 + color: context.colors.textTertiary),
  520 + ),
  521 + );
  522 + },
  523 + ),
  524 + ),
  525 + ),
  526 + barTouchData: BarTouchData(
  527 + touchTooltipData: BarTouchTooltipData(
  528 + tooltipRoundedRadius: 8,
  529 + tooltipBorder: BorderSide.none,
  530 + getTooltipColor: (touchedSpot) =>
  531 + const Color(0xFFF3F3F3),
  532 + tooltipPadding: const EdgeInsets.only(
  533 + left: 12,
  534 + right: 12,
  535 + top: 6,
  536 + bottom: 5,
  537 + ),
  538 + getTooltipItem: (group, groupIndex, rod, rodIndex) {
  539 + // group.x 是槽时间秒,对应 slotStateMap 的 key
  540 + final state = slotStateMap[group.x]?.state;
  541 + return BarTooltipItem(
  542 + '',
  543 + const TextStyle(
  544 + color: Colors.white,
  545 + fontWeight: FontWeight.bold,
  546 + ),
  547 + children: _getTooltip(rod, group.x, state),
  548 + textAlign: TextAlign.start,
  549 + );
  550 + },
  551 + ),
347 ), 552 ),
348 - );  
349 - },  
350 - ),  
351 - ),  
352 - ),  
353 - barTouchData: BarTouchData(  
354 - touchTooltipData: BarTouchTooltipData(  
355 - tooltipRoundedRadius: 8,  
356 - tooltipBorder: BorderSide.none,  
357 - getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),  
358 - tooltipPadding: const EdgeInsets.only(  
359 - left: 12,  
360 - right: 12,  
361 - top: 6,  
362 - bottom: 5,  
363 - ),  
364 - getTooltipItem: (group, groupIndex, rod, rodIndex) {  
365 - return BarTooltipItem(  
366 - '',  
367 - const TextStyle(  
368 - color: Colors.white,  
369 - fontWeight: FontWeight.bold,  
370 ), 553 ),
371 - children: _getTooltip(rod),  
372 - textAlign: TextAlign.start,  
373 - );  
374 - },  
375 - ),  
376 - ),  
377 - ),  
378 - ),  
379 - ),  
380 - Positioned(  
381 - left: 0,  
382 - top: 14,  
383 - bottom: 22,  
384 - child: Container(  
385 - width: spanWidth,  
386 - height: 174,  
387 - decoration: const BoxDecoration(  
388 - gradient: LinearGradient(  
389 - begin: Alignment(0.50, -0.00),  
390 - end: Alignment(0.50, 1.00),  
391 - colors: [Color(0x4C835DED), Color(0x00845EEE)],  
392 - ),  
393 - ),  
394 - ),  
395 - ),  
396 - Positioned(  
397 - left: 0,  
398 - top: 14,  
399 - width: spanWidth,  
400 - height: 2,  
401 - child: ColoredBox(color: context.colors.primary),  
402 - ),  
403 - Positioned(  
404 - left: spanWidth / 2 - 6,  
405 - top: 0,  
406 - child: Image.asset(  
407 - 'assets/images/common/ic_health_bed.webp',  
408 - width: 12,  
409 - height: 12, 554 + ),
  555 + ),
  556 + ...sleepWidgets,
  557 + ],
  558 + );
  559 + },
410 ), 560 ),
411 - ),  
412 - if (isVip == false) ...[ 561 + if (isVip != true) ...[
413 GestureDetector( 562 GestureDetector(
414 onTap: onTapPurchase, 563 onTap: onTapPurchase,
415 child: Image.asset( 564 child: Image.asset(
@@ -480,59 +629,53 @@ class TodayHrvChartCard extends StatelessWidget { @@ -480,59 +629,53 @@ class TodayHrvChartCard extends StatelessWidget {
480 ); 629 );
481 } 630 }
482 631
483 - double _maxChartHour(List<FlSpot> spots) {  
484 - final maxHour = spots.fold<double>(  
485 - 0,  
486 - (maxValue, spot) => spot.x > maxValue ? spot.x : maxValue,  
487 - );  
488 - return ((maxHour / 6).ceil() * 6).clamp(6, 24).toDouble();  
489 - }  
490 -  
491 - String? _timeLabel(double value) {  
492 - return switch (value) {  
493 - 0 => '00:00',  
494 - 6 => '06:00',  
495 - 12 => '12:00',  
496 - 18 => '18:00',  
497 - 24 => '24:00',  
498 - _ => null,  
499 - }; 632 + /// 将距当天0点的秒数转为 X 轴标签,仅在 6h 整点(0/6/12/18/24h)显示
  633 + String? _timeLabel(double seconds) {
  634 + // 允许 ±30 秒的浮点误差
  635 + final rounded = (seconds / 3600.0).round();
  636 + if ((seconds - rounded * 3600).abs() > 30) return null;
  637 + if (rounded < 0 || rounded > 24) return null;
  638 + if (rounded % 6 != 0) return null;
  639 + return '${rounded.toString().padLeft(2, '0')}:00';
500 } 640 }
501 641
502 - Color _getColor(double value) {  
503 - if (value > 60) {  
504 - return const Color(0xFF3BD49D);  
505 - } else if (value > 50) {  
506 - return const Color(0xFF7B9BFB);  
507 - } else if (value > 30) {  
508 - return const Color(0xFFFF9A6E);  
509 - } else if (value > 20) {  
510 - return const Color(0xFFFF5279);  
511 - } else {  
512 - return const Color(0xFF7B9BFB); 642 + /// 按 state(1=过载/2=注意/3=正常/4=优秀)返回颜色
  643 + Color _getColorByState(int? state) {
  644 + switch (state) {
  645 + case 4:
  646 + return const Color(0xFF3BD49D); // 优秀 → 绿
  647 + case 3:
  648 + return const Color(0xFF7B9BFB); // 正常 → 蓝
  649 + case 2:
  650 + return const Color(0xFFFF9A6E); // 注意 → 橙
  651 + case 1:
  652 + return const Color(0xFFFF5279); // 过载 → 红
  653 + default:
  654 + return const Color(0xFF3BD49D);
513 } 655 }
514 } 656 }
515 657
516 - String _getStatus(double value) {  
517 - if (value > 60) {  
518 - return '状态优秀';  
519 - } else if (value > 50) {  
520 - return '状态良好';  
521 - } else if (value > 30) {  
522 - return '状态一般';  
523 - } else if (value > 20) {  
524 - return '状态较差';  
525 - } else {  
526 - return '状态极差'; 658 + /// 按 state 返回状态文字
  659 + String _getStatusByState(int? state) {
  660 + switch (state) {
  661 + case 1:
  662 + return '压力过载';
  663 + case 2:
  664 + return '注意压力';
  665 + case 3:
  666 + return '状态正常';
  667 + case 4:
  668 + return '状态优秀';
527 } 669 }
  670 + return '等待数据';
528 } 671 }
529 672
530 - List<TextSpan>? _getTooltip(BarChartRodData rod) { 673 + List<TextSpan>? _getTooltip(BarChartRodData rod, int x, int? state) {
531 return [ 674 return [
532 TextSpan( 675 TextSpan(
533 - text: _getStatus(rod.toY), 676 + text: '○ ${_getStatusByState(state)}',
534 style: TextStyle( 677 style: TextStyle(
535 - color: _getColor(rod.toY), 678 + color: _getColorByState(state),
536 fontWeight: FontWeight.bold, 679 fontWeight: FontWeight.bold,
537 ), 680 ),
538 ), 681 ),
@@ -544,7 +687,7 @@ class TodayHrvChartCard extends StatelessWidget { @@ -544,7 +687,7 @@ class TodayHrvChartCard extends StatelessWidget {
544 ), 687 ),
545 ), 688 ),
546 TextSpan( 689 TextSpan(
547 - text: '压力 ${_formatNumber(rod.toY)}', 690 + text: '♥ 压力 ${_formatNumber(rod.toY)} · ${_formatHour(x.toDouble())}',
548 style: const TextStyle( 691 style: const TextStyle(
549 color: Color(0xFF78787D), 692 color: Color(0xFF78787D),
550 fontWeight: FontWeight.bold, 693 fontWeight: FontWeight.bold,
@@ -553,24 +696,25 @@ class TodayHrvChartCard extends StatelessWidget { @@ -553,24 +696,25 @@ class TodayHrvChartCard extends StatelessWidget {
553 ]; 696 ];
554 } 697 }
555 698
556 - List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot) { 699 + List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot, int? state) {
557 return [ 700 return [
558 TextSpan( 701 TextSpan(
559 - text: _getStatus(flSpot.y), 702 + text: '○ ${_getStatusByState(state)}',
560 style: TextStyle( 703 style: TextStyle(
561 - color: _getColor(flSpot.y),  
562 - fontWeight: FontWeight.bold, 704 + color: _getColorByState(state),
  705 + // fontWeight: FontWeight.bold,
  706 + fontSize: 14,
563 ), 707 ),
564 ), 708 ),
565 TextSpan( 709 TextSpan(
566 text: '\n', 710 text: '\n',
567 style: TextStyle( 711 style: TextStyle(
568 color: Colors.white, 712 color: Colors.white,
569 - fontWeight: FontWeight.bold, 713 + fontSize: 14,
570 ), 714 ),
571 ), 715 ),
572 TextSpan( 716 TextSpan(
573 - text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}', 717 + text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
574 style: const TextStyle( 718 style: const TextStyle(
575 color: Color(0xFF78787D), 719 color: Color(0xFF78787D),
576 fontWeight: FontWeight.bold, 720 fontWeight: FontWeight.bold,
@@ -584,12 +728,56 @@ class TodayHrvChartCard extends StatelessWidget { @@ -584,12 +728,56 @@ class TodayHrvChartCard extends StatelessWidget {
584 return value.toStringAsFixed(1); 728 return value.toStringAsFixed(1);
585 } 729 }
586 730
587 - String _formatHour(double hour) {  
588 - final totalMinutes = (hour * 60).round();  
589 - final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');  
590 - final m = (totalMinutes % 60).toString().padLeft(2, '0'); 731 + /// 将距当天0点的秒数转为 HH:MM 格式(用于 tooltip)
  732 + String _formatHour(double seconds) {
  733 + final total = seconds.round();
  734 + final h = (total ~/ 3600).clamp(0, 23).toString().padLeft(2, '0');
  735 + final m = ((total % 3600) ~/ 60).toString().padLeft(2, '0');
591 return '$h:$m'; 736 return '$h:$m';
592 } 737 }
  738 +
  739 + /// 构建压力柱状图分组:每 6 分钟一槽,同槽取最大值,无数据用透明占位
  740 + List<BarChartGroupData> _buildStressBarGroups(
  741 + List<V2RealtimeStressItem> stressPoints,
  742 + {required double barWidth}) {
  743 + const slotSec = 6 * 60; // 360 秒 = 6 分钟
  744 + final maxSec = _maxChartSeconds().round();
  745 +
  746 + // 按槽分组,记录每槽的最大 value 及对应的 state
  747 + final slotMax = <int, ({int value, int? state})>{};
  748 + for (final p in stressPoints) {
  749 + final sec = _tsToSecondsFromMidnight(p.time).round();
  750 + final slot = (sec / slotSec).round() * slotSec;
  751 + final v = p.value ?? 0;
  752 + final existing = slotMax[slot];
  753 + if (existing == null || v > existing.value) {
  754 + slotMax[slot] = (value: v, state: p.state);
  755 + }
  756 + }
  757 +
  758 + final groups = <BarChartGroupData>[];
  759 + for (int sec = 0; sec <= maxSec; sec += slotSec) {
  760 + final entry = slotMax[sec];
  761 + groups.add(BarChartGroupData(
  762 + x: sec,
  763 + barRods: [
  764 + BarChartRodData(
  765 + toY: entry != null ? entry.value.toDouble() : 0,
  766 + width: barWidth,
  767 + color: entry != null
  768 + ? _getColorByState(entry.state)
  769 + : Colors.transparent,
  770 + borderRadius: BorderRadius.zero,
  771 + // borderRadius: const BorderRadius.only(
  772 + // topLeft: Radius.circular(2),
  773 + // topRight: Radius.circular(2),
  774 + // ),
  775 + ),
  776 + ],
  777 + ));
  778 + }
  779 + return groups;
  780 + }
593 } 781 }
594 782
595 class _EmptyChart extends StatelessWidget { 783 class _EmptyChart extends StatelessWidget {
@@ -130,7 +130,7 @@ List<Widget> buildUserOnboardingPages(BuildContext context, @@ -130,7 +130,7 @@ List<Widget> buildUserOnboardingPages(BuildContext context,
130 const _FeatureIntroPage(), 130 const _FeatureIntroPage(),
131 const _HrvIntroPage(), 131 const _HrvIntroPage(),
132 const _HrvResearchPage(), 132 const _HrvResearchPage(),
133 - const _HealthPermissionPage(), 133 + const HealthPermissionPage(),
134 const _NotificationPermissionPage(), 134 const _NotificationPermissionPage(),
135 ]; 135 ];
136 } 136 }
@@ -531,8 +531,8 @@ class _ResearchPageItem extends StatelessWidget { @@ -531,8 +531,8 @@ class _ResearchPageItem extends StatelessWidget {
531 } 531 }
532 } 532 }
533 533
534 -class _HealthPermissionPage extends StatelessWidget {  
535 - const _HealthPermissionPage(); 534 +class HealthPermissionPage extends StatelessWidget {
  535 + const HealthPermissionPage();
536 536
537 @override 537 @override
538 Widget build(BuildContext context) { 538 Widget build(BuildContext context) {
@@ -69,6 +69,26 @@ class V2LatestHrvData { @@ -69,6 +69,26 @@ class V2LatestHrvData {
69 } 69 }
70 } 70 }
71 71
  72 +/// 单段睡眠时间区间
  73 +class V2SleepTimeRange {
  74 + const V2SleepTimeRange({required this.fromTime, required this.toTime});
  75 +
  76 + final int fromTime; // Unix 时间戳(秒)
  77 + final int toTime; // Unix 时间戳(秒)
  78 +
  79 + factory V2SleepTimeRange.fromJson(Map<String, dynamic> json) {
  80 + return V2SleepTimeRange(
  81 + fromTime: json['from_time'] as int,
  82 + toTime: json['to_time'] as int,
  83 + );
  84 + }
  85 +
  86 + Map<String, dynamic> toJson() => {
  87 + 'from_time': fromTime,
  88 + 'to_time': toTime,
  89 + };
  90 +}
  91 +
72 /// v2/health_data/ response 92 /// v2/health_data/ response
73 class V2HealthData { 93 class V2HealthData {
74 const V2HealthData({ 94 const V2HealthData({
@@ -82,6 +102,7 @@ class V2HealthData { @@ -82,6 +102,7 @@ class V2HealthData {
82 this.sleepDuration, 102 this.sleepDuration,
83 this.sleepScore, 103 this.sleepScore,
84 this.sleepState, 104 this.sleepState,
  105 + this.sleepTimeList,
85 }); 106 });
86 107
87 final int? hrvAvg; 108 final int? hrvAvg;
@@ -94,6 +115,7 @@ class V2HealthData { @@ -94,6 +115,7 @@ class V2HealthData {
94 final int? sleepDuration; 115 final int? sleepDuration;
95 final double? sleepScore; 116 final double? sleepScore;
96 final int? sleepState; 117 final int? sleepState;
  118 + final List<V2SleepTimeRange>? sleepTimeList;
97 119
98 factory V2HealthData.fromJson(Map<String, dynamic> json) { 120 factory V2HealthData.fromJson(Map<String, dynamic> json) {
99 return V2HealthData( 121 return V2HealthData(
@@ -109,6 +131,9 @@ class V2HealthData { @@ -109,6 +131,9 @@ class V2HealthData {
109 sleepDuration: _parseInt(json['sleep_duration']), 131 sleepDuration: _parseInt(json['sleep_duration']),
110 sleepScore: _parseDouble(json['sleep_score']), 132 sleepScore: _parseDouble(json['sleep_score']),
111 sleepState: _parseInt(json['sleep_state']), 133 sleepState: _parseInt(json['sleep_state']),
  134 + sleepTimeList: (json['sleep_time_list'] as List<dynamic>?)
  135 + ?.map((e) => V2SleepTimeRange.fromJson(e as Map<String, dynamic>))
  136 + .toList(),
112 ); 137 );
113 } 138 }
114 139
@@ -126,6 +151,9 @@ class V2HealthData { @@ -126,6 +151,9 @@ class V2HealthData {
126 if (sleepDuration != null) val['sleep_duration'] = sleepDuration; 151 if (sleepDuration != null) val['sleep_duration'] = sleepDuration;
127 if (sleepScore != null) val['sleep_score'] = sleepScore; 152 if (sleepScore != null) val['sleep_score'] = sleepScore;
128 if (sleepState != null) val['sleep_state'] = sleepState; 153 if (sleepState != null) val['sleep_state'] = sleepState;
  154 + if (sleepTimeList != null) {
  155 + val['sleep_time_list'] = sleepTimeList!.map((e) => e.toJson()).toList();
  156 + }
129 return val; 157 return val;
130 } 158 }
131 159