Showing
11 changed files
with
665 additions
and
513 deletions
| 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 |
-
Please register or login to post a comment