Commit 6bb68ed801a67cec553063b685ae24cf0ada9a6b

Authored by 常守达
1 parent e277b5d1

feat(today): 今日HRV

23.9 KB | W: | H:

18 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin
import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
... ... @@ -13,6 +14,7 @@ class FriendHomeBinding extends Bindings {
final args = Get.arguments as FriendItem;
Get.lazyPut<TodayController>(
() => TodayController(
Get.find<VipApi>(),
Get.find<HealthApi>(),
Get.find<FriendApi>(),
Get.find<UserStateService>(),
... ...
... ... @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.da
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:get/get.dart';
... ... @@ -18,6 +19,7 @@ class HomeBinding extends Bindings {
Get.lazyPut<HomeController>(() => HomeController(), fenix: true);
Get.lazyPut<TodayController>(
() => TodayController(
Get.find<VipApi>(),
Get.find<HealthApi>(),
Get.find<FriendApi>(),
Get.find<UserStateService>(),
... ...
... ... @@ -11,14 +11,15 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -49,6 +50,7 @@ class HrvAnnotation {
class TodayController extends GetxController {
TodayController(
this._vipApi,
this._healthApi,
this._friendApi,
this._userStateService,
... ... @@ -57,6 +59,7 @@ class TodayController extends GetxController {
FriendItem? friendInfo, // null = 自己,非 null = 好友
}) : _initialFriendInfo = friendInfo;
final VipApi _vipApi;
final HealthApi _healthApi;
final FriendApi _friendApi;
final UserStateService _userStateService;
... ... @@ -81,12 +84,12 @@ class TodayController extends GetxController {
final scrollOffset = 0.0.obs;
final isLoadingToday = false.obs;
final showHealthDataAuthCard = true.obs;
final showHealthDataAuthCardStatus = (-1).obs;
final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs;
// ── HRV 表盘引导 Banner ───────────────────
final showHrvAdBanner = true.obs;
final showPartnerAdBanner = true.obs;
final showHrvAdBanner = false.obs;
final showPartnerAdBanner = false.obs;
void dismissHrvAdBanner() => showHrvAdBanner.value = false;
... ... @@ -107,8 +110,8 @@ class TodayController extends GetxController {
int? _activityStandTarget;
// ── HRV 趋势图 ────────────────────────────
final hrvChartData = <HrvDataPoint>[].obs;
final stressChartData = <HrvDataPoint>[].obs;
final hrvChartData = <V2HrvTrendItem>[].obs;
final stressChartData = <V2RealtimeStressItem>[].obs;
final hrvAnnotations = <HrvAnnotation>[].obs;
... ... @@ -116,33 +119,10 @@ class TodayController extends GetxController {
final v2StressScore = Rxn<V2StressScore>();
final v2LatestHrv = Rxn<V2LatestHrvData>();
final v2HrvTrend = Rxn<V2HrvTrendData>();
final v2RealtimeStress = Rxn<V2RealtimeStressData>();
final v2ActivityTarget = Rxn<V2ActivityTarget>();
Future<void> requestHealthAuthorization() async {
if (GetPlatform.isIOS) {
try {
final result = await _hostApi.checkHealthAppAuthorization();
print("checkHealthAppAuthorization : $result");
if (result.status == 0) {
bool success = await _hostApi.requestHealthClientAuthorization();
print("requestHealthClientAuthorization : $success");
if (success) {
_performDataUpload();
} else {
Get.to(NoHealthDataPage(
onRefresh: _performDataUpload,
));
}
return;
}
Get.to(NoHealthDataPage(
onRefresh: _performDataUpload,
));
} catch (e) {}
}
}
@override
void onInit() {
super.onInit();
... ... @@ -158,6 +138,32 @@ class TodayController extends GetxController {
});
unawaited(loadDataForDate(today));
checkAddFriendVisible();
checkHrvAdBannerVisible();
checkHealthDataAuthCardVisible();
}
void checkHrvAdBannerVisible() {
if (isFriend) {
showHrvAdBanner.value = false;
} else {
showHrvAdBanner.value = true;
}
}
void checkAddFriendVisible() {
if (isFriend) {
showHrvAdBanner.value = false;
showPartnerAdBanner.value = false;
} else {
_refreshFriendList(onFriendListUpdated: () {
if (friendsList.isEmpty || friendsList.value.length >= 10) {
showPartnerAdBanner.value = false;
} else {
showPartnerAdBanner.value = true;
}
});
}
}
void changeDate(DateTime date, {DateTime? focused}) {
... ... @@ -177,7 +183,6 @@ class TodayController extends GetxController {
isLoadingToday.value = true;
try {
await Future.wait([
_refreshHealthAuthorizationState(),
_refreshHealthDataForDate(date),
]);
} catch (error, stackTrace) {
... ... @@ -189,6 +194,43 @@ class TodayController extends GetxController {
}
}
Future<void> requestHealthAuthorization() async {
if (GetPlatform.isIOS) {
try {
final result = await _hostApi.checkHealthAppAuthorization();
AppLogger.d("checkHealthAppAuthorization : $result");
if (result.status == 0) {
bool success = await _hostApi.requestHealthClientAuthorization();
AppLogger.d("requestHealthClientAuthorization : $success");
if (success) {
_performDataUpload();
AppToast.show('刷新完成');
} else {
// Get.to(NoHealthDataPage(
// onRefresh: _performDataUpload,
// ));
}
return;
}
if (result.status == 1) {
_performDataUpload();
}
} catch (e) {}
}
}
void onAuthorizeTap() {
if (showHealthDataAuthCardStatus.value == 0) {
Get.to(NoHealthDataPage(
onRefresh: requestHealthAuthorization,
));
} else if (showHealthDataAuthCardStatus.value == -1) {
Get.to(NoHealthDataNoPermissionPage(
onRequest: requestHealthAuthorization,
));
}
}
/// 上传 Apple Health 新数据。
Future<void> _performDataUpload() async {
try {
... ... @@ -207,11 +249,12 @@ class TodayController extends GetxController {
}
}
Future<void> _refreshHealthAuthorizationState() async {
Future<void> checkHealthDataAuthCardVisible() async {
try {
final result = await _hostApi.checkHealthAppAuthorization();
showHealthDataAuthCard.value = result.status != 1;
showHealthDataAuthCardStatus.value = result.status;
// TODO:若用户从始至终没有任何数据,也显示该引导
} catch (e) {}
}
... ... @@ -244,16 +287,68 @@ class TodayController extends GetxController {
switch (await _healthApi.getV2HrvTrend(friendUserId, intDate)) {
case AppSuccess(:final data):
v2HrvTrend.value = data;
// hrvChartData.assignAll(data.list ?? []);
hrvChartData.assignAll([
// 每小时一条,00:00 ~ 15:00(CST 午夜 = 1782057600)
V2HrvTrendItem(time: 1782057600, trendHrv: 45, state: 3), // 00:00
V2HrvTrendItem(time: 1782061200, trendHrv: 42, state: 3), // 01:00
V2HrvTrendItem(time: 1782064800, trendHrv: 38, state: 3), // 02:00
V2HrvTrendItem(time: 1782068400, trendHrv: 35, state: 2), // 03:00
V2HrvTrendItem(time: 1782072000, trendHrv: 40, state: 3), // 04:00
V2HrvTrendItem(time: 1782075600, trendHrv: 55, state: 4), // 05:00
V2HrvTrendItem(time: 1782079200, trendHrv: 65, state: 4), // 06:00
V2HrvTrendItem(time: 1782082800, trendHrv: 72, state: 4), // 07:00
V2HrvTrendItem(time: 1782086400, trendHrv: 68, state: 4), // 08:00
V2HrvTrendItem(time: 1782090000, trendHrv: 58, state: 4), // 09:00
V2HrvTrendItem(time: 1782093600, trendHrv: 52, state: 3), // 10:00
V2HrvTrendItem(time: 1782097200, trendHrv: 48, state: 3), // 11:00
V2HrvTrendItem(time: 1782100800, trendHrv: 44, state: 3), // 12:00
V2HrvTrendItem(time: 1782104400, trendHrv: 38, state: 2), // 13:00
V2HrvTrendItem(time: 1782108000, trendHrv: 42, state: 3), // 14:00
V2HrvTrendItem(time: 1782111600, trendHrv: 45, state: 3), // 15:00
]);
case AppFailure():
v2HrvTrend.value = null;
hrvChartData.clear();
}
switch (await _healthApi.getV2RealtimeStress(friendUserId, intDate)) {
case AppSuccess(:final data):
v2RealtimeStress.value = data;
// stressChartData.assignAll(data.list ?? []);
stressChartData.assignAll(
// 每 6 分钟一条,00:00 ~ 15:00(共 150 条)
// midnight CST = 1782057600,步长 360s
List.generate(150, (i) {
const midnight = 1782057600;
final ts = midnight + i * 360;
final minutesFromMidnight = i * 6;
final hour = minutesFromMidnight ~/ 60;
final minInHour = minutesFromMidnight % 60;
// 用正弦波模拟真实波动,早高峰(7-9点)压力最高
final base = hour < 6
? 25.0 // 深夜低压力
: hour < 9
? 55.0 + (hour - 6) * 10.0 // 早高峰爬升
: hour < 12
? 75.0 - (hour - 9) * 8.0 // 上午下降
: 50.0 - (hour - 12) * 3.0; // 下午缓降
// 叠加微小波动(用 i 模拟随机)
final jitter = (i % 7 - 3) * 2.0 + (minInHour % 3) * 1.5;
final value = (base + jitter).clamp(10.0, 100.0).round();
final int state;
if (value >= 75) {
state = 1;
} else if (value >= 55) {
state = 2;
} else if (value >= 35) {
state = 3;
} else {
state = 4;
}
return V2RealtimeStressItem(time: ts, value: value, state: state);
}),
);
case AppFailure():
v2RealtimeStress.value = null;
stressChartData.clear();
}
if (v2ActivityTarget.value == null) {
switch (await _healthApi.getV2ActivityTarget(friendUserId)) {
... ... @@ -278,235 +373,6 @@ class TodayController extends GetxController {
stressChartData.clear();
hrvAnnotations.clear();
avgHrv.value = '--';
v2ActivityTarget.value = null;
}
void _applyHrvStatistics(HrvStatisticsData data, DateTime date) {
final dateKey = date.day;
final isToday = DateUtils.isSameDay(date, lastSelectableDay);
if (isToday) {
if (avgHrv.value == '--' && data.avgHrv != null) {
avgHrv.value = _formatMetric(data.avgHrv);
}
} else {
double? dailyHrv;
if (data.hrvTrendList != null) {
for (final trend in data.hrvTrendList!) {
if (trend.timeKey == dateKey) {
dailyHrv = trend.average;
break;
}
}
}
avgHrv.value = _formatMetric(dailyHrv);
}
if (data.avgRestingHeartRate != null) {
restingHeartRate.value = _formatMetric(data.avgRestingHeartRate);
}
}
void _applyActivityStatistics(
ActivityBurnStatisticsData data, DateTime date) {
_activityMoveTarget = data.activityTargetInfo?.move ?? _activityMoveTarget;
_activityStandTarget =
data.activityTargetInfo?.stand ?? _activityStandTarget;
final dateKey = date.day;
final isToday = DateUtils.isSameDay(date, lastSelectableDay);
if (isToday) {
if (activityCalories.value == '--' && data.totalCaloriesBurned != null) {
activityCalories.value = _formatMetric(data.totalCaloriesBurned);
}
if (activityStandHours.value == '--' && data.totalStand != null) {
activityStandHours.value = _formatMetric(data.totalStand);
}
} else {
int? dailyMove;
if (data.caloriesBurnedTrendList != null) {
for (final trend in data.caloriesBurnedTrendList!) {
if (trend.timeKey == dateKey) {
dailyMove = trend.value;
break;
}
}
}
activityCalories.value = _formatMetric(dailyMove);
int? dailyExercise;
if (data.exerciseTimeTrendList != null) {
for (final trend in data.exerciseTimeTrendList!) {
if (trend.timeKey == dateKey) {
dailyExercise = trend.value;
break;
}
}
}
activityExerciseMinutes.value = _formatMetric(dailyExercise);
activityStandHours.value = '--';
}
final moveValue = _parseMetric(activityCalories.value);
final exerciseValue = _parseMetric(activityExerciseMinutes.value);
final standValue = _parseMetric(activityStandHours.value);
if (moveValue != null) {
activityMoveProgress.value = _progress(
moveValue,
(_activityMoveTarget ?? 400).toDouble(),
);
} else {
activityMoveProgress.value = 0.0;
}
if (exerciseValue != null) {
activityExerciseProgress.value = _progress(
exerciseValue,
30.0,
);
} else {
activityExerciseProgress.value = 0.0;
}
if (standValue != null) {
activityStandProgress.value = _progress(
standValue,
(_activityStandTarget ?? 12).toDouble(),
);
} else {
activityStandProgress.value = 0.0;
}
}
void _applyLatestTodayHrv(TodayHrvData? latest) {
final value = latest?.value;
if (value == null) return;
}
void _applyActivityRecentData(TodayStatusRecentData? recent) {
activityCalories.value = _formatMetric(recent?.move);
activityExerciseMinutes.value = _formatMetric(recent?.exercise);
activityStandHours.value = _formatMetric(recent?.stand);
activityMoveProgress.value = _progress(
(recent?.move ?? 0).toDouble(),
(_activityMoveTarget ?? 400).toDouble(),
);
activityExerciseProgress.value = _progress(
(recent?.exercise ?? 0).toDouble(),
30,
);
activityStandProgress.value = _progress(
(recent?.stand ?? 0).toDouble(),
(_activityStandTarget ?? 12).toDouble(),
);
}
List<HrvDataPoint> _buildHrvChartData(List<TodayHrvData>? dataList) {
final points = <HrvDataPoint>[];
for (final item in dataList ?? const <TodayHrvData>[]) {
final time = item.time;
final value = item.value;
if (time == null || value == null) continue;
points.add(HrvDataPoint(hour: _hourFromApiTime(time), hrv: value));
}
points.sort((a, b) => a.hour.compareTo(b.hour));
return points;
}
TodayHrvData? _latestTodayHrv(List<TodayHrvData>? dataList) {
TodayHrvData? latest;
for (final item in dataList ?? const <TodayHrvData>[]) {
if (item.value == null) continue;
final latestTime = latest?.time ?? -1;
final itemTime = item.time ?? -1;
if (latest == null || itemTime >= latestTime) {
latest = item;
}
}
return latest;
}
String _hrvStatusTitle({required double value, double? baseline}) {
if ((baseline ?? 0) > 0) {
if (value >= 1.2 * baseline!) return HrvStatus.energetic.title;
if (value <= 0.8 * baseline) return HrvStatus.overload.title;
return HrvStatus.normal.title;
}
if (value >= 128) return HrvStatus.energetic.title;
if (value <= 30) return HrvStatus.overload.title;
return HrvStatus.normal.title;
}
double _stressScoreFromHrv(double hrv) {
return (100 - hrv).clamp(0, 100).toDouble();
}
int? _normalizeDurationMinutes(int? rawDuration) {
if (rawDuration == null || rawDuration <= 0) return null;
if (rawDuration > 24 * 60 * 60) {
return (rawDuration / 60000).round();
}
if (rawDuration > 24 * 60) {
return (rawDuration / 60).round();
}
return rawDuration;
}
String _sleepQualityFromDuration(int minutes) {
if (minutes >= 7 * 60 && minutes <= 9 * 60) return '优秀';
if (minutes >= 6 * 60 && minutes < 10 * 60) return '良好';
if (minutes >= 5 * 60) return '一般';
return '偏少';
}
String _sleepQualityFromDeepPercentage(double percentage) {
if (percentage >= 25) return '优秀';
if (percentage >= 18) return '良好';
if (percentage >= 12) return '一般';
return '偏少';
}
double _progress(double value, double target) {
if (target <= 0) return 0;
return (value / target).clamp(0, 1).toDouble();
}
double? _parseMetric(String value) {
if (value == '--') return null;
return double.tryParse(value.replaceAll(',', ''));
}
double _hourFromApiTime(int time) {
if (time >= 1000000000000) {
final date = DateTime.fromMillisecondsSinceEpoch(time);
return date.hour + date.minute / 60;
}
if (time >= 1000000000) {
final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);
return date.hour + date.minute / 60;
}
if (time > 24) {
final hour = (time ~/ 100).clamp(0, 23);
final minute = (time % 100).clamp(0, 59);
return hour + minute / 60;
}
return time.toDouble().clamp(0, 24).toDouble();
}
String _formatMetric(num? value) {
if (value == null) return '--';
if (value % 1 == 0) return '${value.toInt()}';
return value.toStringAsFixed(1);
}
String _formatHour(double hour) {
final totalMinutes = (hour * 60).round();
final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
final m = (totalMinutes % 60).toString().padLeft(2, '0');
return '$h:$m';
}
toTrendHrvPage() {
... ... @@ -558,15 +424,7 @@ class TodayController extends GetxController {
}
showFriendListBottomSheet() async {
_friendApi.friendList(false).then(
(res) {
switch (res) {
case AppSuccess(:final data):
friendsList.assignAll(data.list);
case AppFailure():
}
},
);
_refreshFriendList();
Get.bottomSheet(
DraggableScrollableSheet(
... ... @@ -586,5 +444,29 @@ class TodayController extends GetxController {
);
}
void _refreshFriendList({VoidCallback? onFriendListUpdated}) {
_friendApi.friendList(false).then(
(res) {
switch (res) {
case AppSuccess(:final data):
friendsList.assignAll(data.list);
onFriendListUpdated?.call();
case AppFailure():
}
},
);
}
RxList<FriendItem> friendsList = RxList.empty();
Future<void> toPremiumPage() async {
await Get.toNamed(Routes.PURCHASE);
final vipResult = await _vipApi.getVipInfo();
if (vipResult case AppSuccess(data: final vip)) {
try {
final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip);
await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs);
} on Exception catch (e) {}
}
}
}
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/app/modules/home/widgets/today/hrv_measurement_bottom_sheet.dart';
import 'package:doublefeel_flutter/app/modules/home/widgets/today/no_health_data_page.dart';
import 'package:doublefeel_flutter/app/modules/report_common/models/report_period.dart';
import 'package:doublefeel_flutter/app/modules/report_common/widgets/report_date_picker_sheet.dart';
... ... @@ -190,9 +191,11 @@ class TodayTabBody extends StatelessWidget {
sliver: SliverList(
delegate: SliverChildListDelegate([
Obx(
() => controller.showHealthDataAuthCard.value
() => controller.showHealthDataAuthCardStatus.value != 1
? TodayHealthDataAuthCard(
onAuthorizeTap: controller.requestHealthAuthorization,
onAuthorizeTap: () {
controller.onAuthorizeTap();
},
)
: const SizedBox.shrink(),
),
... ... @@ -201,8 +204,12 @@ class TodayTabBody extends StatelessWidget {
? const SizedBox.shrink()
: const PremiumCard();
}),
TodayHrvAdBanner(controller: controller),
TodayPartnerAdBanner(controller: controller),
Obx(() => controller.showHrvAdBanner.value
? TodayHrvAdBanner(controller: controller)
: const SizedBox.shrink()),
Obx(() => controller.showPartnerAdBanner.value
? TodayPartnerAdBanner(controller: controller)
: const SizedBox.shrink()),
TodayHrvNumberCard(controller: controller),
const SizedBox(height: 12),
TodayHrvChartCard(controller: controller),
... ... @@ -245,7 +252,6 @@ class TodayTabBody extends StatelessWidget {
return GestureDetector(
onTap: showHrvMeasurementBottomSheet,
child: Container(
margin: EdgeInsets.only(top: 12),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
... ... @@ -506,6 +512,7 @@ class _LatestHrvCard extends StatelessWidget {
final latestDataTimeText = DateFormat('HH:mm')
.format(DateTime.fromMillisecondsSinceEpoch(latestDataTime * 1000));
return Container(
margin: EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
decoration: BoxDecoration(
color: Colors.white,
... ...
... ... @@ -44,7 +44,8 @@ class AccountSettingView extends GetView<MyController> {
SizedBox(height: 16),
_buildAccountCard(
context: context,
phone: userPrefs.preferences.value.meUserInfo?.telephone ?? '',
title: context.l10n.mobilePhoneNumber,
content: userPrefs.preferences.value.meUserInfo?.telephone ?? '',
),
SizedBox(height: 20),
GestureDetector(
... ... @@ -80,8 +81,11 @@ class AccountSettingView extends GetView<MyController> {
);
}
Widget _buildAccountCard(
{required BuildContext context, required String phone}) {
Widget _buildAccountCard({
required BuildContext context,
required String title,
required String content,
}) {
return Container(
height: 110,
margin: EdgeInsets.symmetric(horizontal: 15),
... ... @@ -98,7 +102,7 @@ class AccountSettingView extends GetView<MyController> {
child: Row(
children: [
Text(
context.l10n.mobilePhoneNumber,
title,
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
... ... @@ -109,7 +113,7 @@ class AccountSettingView extends GetView<MyController> {
SizedBox(width: 16),
Expanded(
child: Text(
phone,
content,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
... ...
import 'package:doublefeel_flutter/app/modules/user_onboarding/views/user_onboarding_pages.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
... ... @@ -361,3 +364,31 @@ class _IconPlaceholder extends StatelessWidget {
);
}
}
class NoHealthDataNoPermissionPage extends StatelessWidget {
const NoHealthDataNoPermissionPage({
super.key,
this.onRequest,
});
final VoidCallback? onRequest;
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: AppTheme.systemUiOverlayStyle.copyWith(
systemNavigationBarColor: context.colors.backgroundPage,
),
child: GuideCommonScaffold(
onBackPressed: Get.back,
bottom: OnboardingBottomButton(
label: '前往开启',
enabled: true,
onPressed: () {
onRequest?.call();
},
),
child: const HealthPermissionPage(),
));
}
}
... ...
... ... @@ -19,17 +19,23 @@ class TodayHrvAdBanner extends StatelessWidget {
return _TodayGuideBanner(
title: context.l10n.clickToAddTheHrvThemedWatchFace,
subtitle: context.l10n.stayOnTopOfYourHealthFluctuations,
right: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/common/ic_arrow_forward.png',
width: 20,
height: 20,
color: context.colors.primary,
right: GestureDetector(
onTap: () async {
await Get.toNamed(Routes.WATCH_THEME);
controller.checkAddFriendVisible();
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/common/ic_arrow_forward.png',
width: 20,
height: 20,
color: context.colors.primary,
),
),
),
onClose: controller.dismissHrvAdBanner,
... ... @@ -38,7 +44,6 @@ class TodayHrvAdBanner extends StatelessWidget {
}
}
/// Figma: 添加亲密联系人 Banner,右上角关闭按钮
class TodayPartnerAdBanner extends StatelessWidget {
const TodayPartnerAdBanner({super.key, required this.controller});
... ... @@ -57,7 +62,10 @@ class TodayPartnerAdBanner extends StatelessWidget {
title: context.l10n.addACloseContact,
subtitle: context.l10n.oneMorePersonLookingOutForYourHealth,
right: GestureDetector(
onTap: () => Get.toNamed(AppRoutes.bindPartner),
onTap: () async {
await Get.toNamed(Routes.ADD_FRIEND);
controller.checkAddFriendVisible();
},
behavior: HitTestBehavior.opaque,
child: Container(
width: 72,
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
... ... @@ -22,17 +22,29 @@ class TodayHrvChartCard extends StatelessWidget {
final TodayController controller;
static const _h5 = Color(0xFFCCCCCC);
static const _h5 = Color(0xFFF3F3F3);
@override
Widget build(BuildContext context) {
final userPrefs = Get.find<UserPreferencesStorage>();
return Obx(() {
final hrvSpots =
controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList();
final hrvSpots = controller.hrvChartData
.map((p) => FlSpot(
_tsToSecondsFromMidnight(p.time),
(p.trendHrv ?? 0).toDouble(),
))
.toList();
final stressPoints = controller.stressChartData.toList();
final preferences = userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
final sleepList = controller.v2HealthData.value?.sleepTimeList;
final selectedDate = controller.selectedDate.value;
// HRV: spot.x(秒偏移) → state 映射,供图表内部颜色/状态使用
final Map<double, int?> hrvStateMap = {
for (final p in controller.hrvChartData)
_tsToSecondsFromMidnight(p.time): p.state,
};
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
... ... @@ -49,7 +61,8 @@ class TodayHrvChartCard extends StatelessWidget {
height: 174,
child: hrvSpots.isEmpty
? _EmptyChart(text: context.l10n.noDataAvailableForToday)
: LineChart(_hrvLineChartData(context, hrvSpots)),
: LineChart(
_hrvLineChartData(context, hrvSpots, hrvStateMap)),
),
Padding(
padding: const EdgeInsets.only(bottom: 14, top: 16),
... ... @@ -97,10 +110,13 @@ class TodayHrvChartCard extends StatelessWidget {
),
SizedBox(
height: 190,
child: stressPoints.isEmpty
? const _EmptyChart(text: '暂无压力数据')
: _buildStressChart(
context, stressPoints, vipInfo?.isVip == true),
child: _buildStressChart(
context,
sleepList,
stressPoints,
selectedDate,
vipInfo?.isVip == true,
),
),
],
),
... ... @@ -155,24 +171,62 @@ class TodayHrvChartCard extends StatelessWidget {
);
}
LineChartData _hrvLineChartData(BuildContext context, List<FlSpot> spots) {
/// 将 Unix 时间戳(秒)转换为距当天 00:00 的秒数偏移
static double _tsToSecondsFromMidnight(int? ts) {
if (ts == null) return 0;
final dt = DateTime.fromMillisecondsSinceEpoch(ts * 1000);
final midnight = DateTime(dt.year, dt.month, dt.day);
return (ts - midnight.millisecondsSinceEpoch / 1000.0);
}
/// 下一个 6h 刻度相对当天 0 点的秒数
/// 例如当前 13:58 → 下一刻度 18:00 → 返回 18 * 3600 = 64800
double _maxChartSeconds() {
final now = DateTime.now();
final secondsFromMidnight = now.hour * 3600 + now.minute * 60 + now.second;
final nextSlot = ((secondsFromMidnight / (6 * 3600)).ceil()).clamp(1, 4);
return nextSlot * 6 * 3600.0;
}
LineChartData _hrvLineChartData(
BuildContext context,
List<FlSpot> spots,
Map<double, int?> stateMap,
) {
return LineChartData(
minX: 0,
maxX: _maxChartHour(spots),
maxX: _maxChartSeconds(),
minY: 0,
maxY: 80,
gridData: FlGridData(
show: true,
drawVerticalLine: true,
drawHorizontalLine: false,
verticalInterval: 6,
verticalInterval: 6 * 3600,
checkToShowVerticalLine: (val) {
// if (val == startTs) return false;
// if (val > endTs) return false;
return true;
},
getDrawingVerticalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
borderData: FlBorderData(show: false),
borderData: FlBorderData(
show: false,
),
extraLinesData: ExtraLinesData(
verticalLines: [
VerticalLine(x: 0, color: _h5, strokeWidth: 1, dashArray: [2, 2]),
VerticalLine(
x: _maxChartSeconds(),
color: _h5,
strokeWidth: 1,
dashArray: [2, 2]),
],
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
... ... @@ -182,7 +236,7 @@ class TodayHrvChartCard extends StatelessWidget {
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 6,
interval: 6 * 3600,
getTitlesWidget: (val, _) {
final label = _timeLabel(val);
if (label == null) return const SizedBox.shrink();
... ... @@ -207,7 +261,7 @@ class TodayHrvChartCard extends StatelessWidget {
radius: 4,
color: Colors.white,
strokeWidth: 3,
strokeColor: _getColor(spot.y),
strokeColor: _getColorByState(stateMap[spot.x]),
);
},
),
... ... @@ -227,7 +281,7 @@ class TodayHrvChartCard extends StatelessWidget {
radius: 5,
color: Colors.white,
strokeWidth: 3.5,
strokeColor: _getColor(spot.y),
strokeColor: _getColorByState(stateMap[spot.x]),
);
},
),
... ... @@ -237,6 +291,7 @@ class TodayHrvChartCard extends StatelessWidget {
touchTooltipData: LineTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
maxContentWidth: 153,
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
tooltipPadding: const EdgeInsets.only(
left: 12,
... ... @@ -252,7 +307,7 @@ class TodayHrvChartCard extends StatelessWidget {
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: _getTooltipChildren(barSpot),
children: _getTooltipChildren(barSpot, stateMap[barSpot.x]),
textAlign: TextAlign.start,
);
}).toList();
... ... @@ -263,153 +318,247 @@ class TodayHrvChartCard extends StatelessWidget {
}
Widget _buildStressChart(
BuildContext context, List<HrvDataPoint> stressPoints, bool isVip) {
void onTapPurchase() => Get.toNamed(Routes.PURCHASE);
final spanWidth = 74.0;
BuildContext context,
List<V2SleepTimeRange>? sleepList,
List<V2RealtimeStressItem> stressPoints,
DateTime selectedDate,
bool isVip) {
void onTapPurchase() {
controller.toPremiumPage();
}
// 槽时间秒 → state 映射(与 _buildStressBarGroups 一致:取 value 最大那条 of state)
const slotSec = 6 * 60;
final slotStateMap = <int, ({int value, int? state})>{};
for (final p in stressPoints) {
final sec = _tsToSecondsFromMidnight(p.time).round();
final slot = (sec / slotSec).round() * slotSec;
final v = p.value ?? 0;
final existing = slotStateMap[slot];
if (existing == null || v > existing.value) {
slotStateMap[slot] = (value: v, state: p.state);
}
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: BarChart(
BarChartData(
minY: 0,
maxY: 100,
groupsSpace: 1,
alignment: BarChartAlignment.start,
barGroups: List.generate(
stressPoints.length,
(i) {
final point = stressPoints[i];
return BarChartGroupData(
x: (point.hour * 10).round(),
barRods: [
BarChartRodData(
toY: point.hrv,
width: 2,
color: _getColor(point.hrv),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(2),
topRight: Radius.circular(2),
if (stressPoints.isEmpty)
_EmptyChart(text: '暂无压力数据')
else
LayoutBuilder(
builder: (context, constraints) {
// 右轴 reservedSize=22,剩余宽度平分给所有槽
const rightAxisWidth = 22.0;
const slotSec = 6 * 60;
final slotCount =
(_maxChartSeconds() / slotSec).ceil().clamp(1, 9999);
// final barWidth =
// (constraints.maxWidth - rightAxisWidth) / slotCount;
const double groupsSpace = 0.5; // 设置间隔为 1 像素
final barWidth = (constraints.maxWidth -
rightAxisWidth -
(groupsSpace * (slotCount - 1))) /
slotCount;
// 根据 V2SleepTimeRange 计算多段睡眠区间的 Positioned widgets
final sleepWidgets = <Widget>[];
if (sleepList != null && sleepList.isNotEmpty) {
final todayMidnight = DateTime(
selectedDate.year, selectedDate.month, selectedDate.day);
final todayMidnightSec =
todayMidnight.millisecondsSinceEpoch ~/ 1000;
final maxSec = _maxChartSeconds();
final chartWidth = constraints.maxWidth - rightAxisWidth;
for (final range in sleepList) {
final fromSec = range.fromTime - todayMidnightSec;
final toSec = range.toTime - todayMidnightSec;
final visibleFrom = fromSec.clamp(0.0, maxSec);
final visibleTo = toSec.clamp(0.0, maxSec);
if (visibleTo > visibleFrom) {
final sleepSpanLeft = chartWidth * (visibleFrom / maxSec);
final sleepSpanWidth =
(chartWidth * (visibleTo / maxSec)) - sleepSpanLeft;
if (sleepSpanWidth > 0) {
sleepWidgets.add(
Positioned(
left: sleepSpanLeft,
width: sleepSpanWidth,
top: 0,
bottom: 22,
child: IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: 14,
bottom: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
colors: [
Color(0x4C835DED),
Color(0x00845EEE)
],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 14,
height: 2,
child:
ColoredBox(color: context.colors.primary),
),
if (sleepSpanWidth >= 20.0)
Positioned(
left: (sleepSpanWidth - 12) / 2,
top: 0,
child: Image.asset(
'assets/images/common/ic_health_bed.webp',
width: 12,
height: 12,
),
),
],
),
),
),
),
],
);
},
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 22,
getTitlesWidget: (val, _) {
if (val < 0 || val > 100) {
return const SizedBox.shrink();
}
return Text(
'${val.toInt()}',
style: TextStyle(
fontSize: 10, color: context.colors.textTertiary),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: 60,
getTitlesWidget: (val, meta) {
final label = _timeLabel(val / 10);
if (label == null) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(
label,
style: TextStyle(
fontSize: 10, color: context.colors.textTertiary),
}
}
}
}
return Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: BarChart(
BarChartData(
minY: 0,
maxY: 100,
groupsSpace: groupsSpace,
alignment: BarChartAlignment.start,
barGroups: _buildStressBarGroups(stressPoints,
barWidth: barWidth),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
extraLinesData: ExtraLinesData(
horizontalLines: [
HorizontalLine(
y: 0,
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
HorizontalLine(
y: 100,
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
],
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 22,
getTitlesWidget: (val, _) {
if (val < 0 || val > 100) {
return const SizedBox.shrink();
}
return Text(
'${val.toInt()}',
style: TextStyle(
fontSize: 10,
color: context.colors.textTertiary),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: 60,
getTitlesWidget: (val, meta) {
final label = _timeLabel(val);
if (label == null) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
child: Text(
label,
style: TextStyle(
fontSize: 10,
color: context.colors.textTertiary),
),
);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
getTooltipColor: (touchedSpot) =>
const Color(0xFFF3F3F3),
tooltipPadding: const EdgeInsets.only(
left: 12,
right: 12,
top: 6,
bottom: 5,
),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
// group.x 是槽时间秒,对应 slotStateMap 的 key
final state = slotStateMap[group.x]?.state;
return BarTooltipItem(
'',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: _getTooltip(rod, group.x, state),
textAlign: TextAlign.start,
);
},
),
),
);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
tooltipPadding: const EdgeInsets.only(
left: 12,
right: 12,
top: 6,
bottom: 5,
),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: _getTooltip(rod),
textAlign: TextAlign.start,
);
},
),
),
),
),
),
Positioned(
left: 0,
top: 14,
bottom: 22,
child: Container(
width: spanWidth,
height: 174,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
colors: [Color(0x4C835DED), Color(0x00845EEE)],
),
),
),
),
Positioned(
left: 0,
top: 14,
width: spanWidth,
height: 2,
child: ColoredBox(color: context.colors.primary),
),
Positioned(
left: spanWidth / 2 - 6,
top: 0,
child: Image.asset(
'assets/images/common/ic_health_bed.webp',
width: 12,
height: 12,
),
),
...sleepWidgets,
],
);
},
),
),
if (isVip == false) ...[
if (isVip != true) ...[
GestureDetector(
onTap: onTapPurchase,
child: Image.asset(
... ... @@ -480,59 +629,53 @@ class TodayHrvChartCard extends StatelessWidget {
);
}
double _maxChartHour(List<FlSpot> spots) {
final maxHour = spots.fold<double>(
0,
(maxValue, spot) => spot.x > maxValue ? spot.x : maxValue,
);
return ((maxHour / 6).ceil() * 6).clamp(6, 24).toDouble();
}
String? _timeLabel(double value) {
return switch (value) {
0 => '00:00',
6 => '06:00',
12 => '12:00',
18 => '18:00',
24 => '24:00',
_ => null,
};
/// 将距当天0点的秒数转为 X 轴标签,仅在 6h 整点(0/6/12/18/24h)显示
String? _timeLabel(double seconds) {
// 允许 ±30 秒的浮点误差
final rounded = (seconds / 3600.0).round();
if ((seconds - rounded * 3600).abs() > 30) return null;
if (rounded < 0 || rounded > 24) return null;
if (rounded % 6 != 0) return null;
return '${rounded.toString().padLeft(2, '0')}:00';
}
Color _getColor(double value) {
if (value > 60) {
return const Color(0xFF3BD49D);
} else if (value > 50) {
return const Color(0xFF7B9BFB);
} else if (value > 30) {
return const Color(0xFFFF9A6E);
} else if (value > 20) {
return const Color(0xFFFF5279);
} else {
return const Color(0xFF7B9BFB);
/// 按 state(1=过载/2=注意/3=正常/4=优秀)返回颜色
Color _getColorByState(int? state) {
switch (state) {
case 4:
return const Color(0xFF3BD49D); // 优秀 → 绿
case 3:
return const Color(0xFF7B9BFB); // 正常 → 蓝
case 2:
return const Color(0xFFFF9A6E); // 注意 → 橙
case 1:
return const Color(0xFFFF5279); // 过载 → 红
default:
return const Color(0xFF3BD49D);
}
}
String _getStatus(double value) {
if (value > 60) {
return '状态优秀';
} else if (value > 50) {
return '状态良好';
} else if (value > 30) {
return '状态一般';
} else if (value > 20) {
return '状态较差';
} else {
return '状态极差';
/// 按 state 返回状态文字
String _getStatusByState(int? state) {
switch (state) {
case 1:
return '压力过载';
case 2:
return '注意压力';
case 3:
return '状态正常';
case 4:
return '状态优秀';
}
return '等待数据';
}
List<TextSpan>? _getTooltip(BarChartRodData rod) {
List<TextSpan>? _getTooltip(BarChartRodData rod, int x, int? state) {
return [
TextSpan(
text: _getStatus(rod.toY),
text: '○ ${_getStatusByState(state)}',
style: TextStyle(
color: _getColor(rod.toY),
color: _getColorByState(state),
fontWeight: FontWeight.bold,
),
),
... ... @@ -544,7 +687,7 @@ class TodayHrvChartCard extends StatelessWidget {
),
),
TextSpan(
text: '压力 ${_formatNumber(rod.toY)}',
text: '♥ 压力 ${_formatNumber(rod.toY)} · ${_formatHour(x.toDouble())}',
style: const TextStyle(
color: Color(0xFF78787D),
fontWeight: FontWeight.bold,
... ... @@ -553,24 +696,25 @@ class TodayHrvChartCard extends StatelessWidget {
];
}
List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot) {
List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot, int? state) {
return [
TextSpan(
text: _getStatus(flSpot.y),
text: '○ ${_getStatusByState(state)}',
style: TextStyle(
color: _getColor(flSpot.y),
fontWeight: FontWeight.bold,
color: _getColorByState(state),
// fontWeight: FontWeight.bold,
fontSize: 14,
),
),
TextSpan(
text: '\n',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
TextSpan(
text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
style: const TextStyle(
color: Color(0xFF78787D),
fontWeight: FontWeight.bold,
... ... @@ -584,12 +728,56 @@ class TodayHrvChartCard extends StatelessWidget {
return value.toStringAsFixed(1);
}
String _formatHour(double hour) {
final totalMinutes = (hour * 60).round();
final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
final m = (totalMinutes % 60).toString().padLeft(2, '0');
/// 将距当天0点的秒数转为 HH:MM 格式(用于 tooltip)
String _formatHour(double seconds) {
final total = seconds.round();
final h = (total ~/ 3600).clamp(0, 23).toString().padLeft(2, '0');
final m = ((total % 3600) ~/ 60).toString().padLeft(2, '0');
return '$h:$m';
}
/// 构建压力柱状图分组:每 6 分钟一槽,同槽取最大值,无数据用透明占位
List<BarChartGroupData> _buildStressBarGroups(
List<V2RealtimeStressItem> stressPoints,
{required double barWidth}) {
const slotSec = 6 * 60; // 360 秒 = 6 分钟
final maxSec = _maxChartSeconds().round();
// 按槽分组,记录每槽的最大 value 及对应的 state
final slotMax = <int, ({int value, int? state})>{};
for (final p in stressPoints) {
final sec = _tsToSecondsFromMidnight(p.time).round();
final slot = (sec / slotSec).round() * slotSec;
final v = p.value ?? 0;
final existing = slotMax[slot];
if (existing == null || v > existing.value) {
slotMax[slot] = (value: v, state: p.state);
}
}
final groups = <BarChartGroupData>[];
for (int sec = 0; sec <= maxSec; sec += slotSec) {
final entry = slotMax[sec];
groups.add(BarChartGroupData(
x: sec,
barRods: [
BarChartRodData(
toY: entry != null ? entry.value.toDouble() : 0,
width: barWidth,
color: entry != null
? _getColorByState(entry.state)
: Colors.transparent,
borderRadius: BorderRadius.zero,
// borderRadius: const BorderRadius.only(
// topLeft: Radius.circular(2),
// topRight: Radius.circular(2),
// ),
),
],
));
}
return groups;
}
}
class _EmptyChart extends StatelessWidget {
... ...
... ... @@ -130,7 +130,7 @@ List<Widget> buildUserOnboardingPages(BuildContext context,
const _FeatureIntroPage(),
const _HrvIntroPage(),
const _HrvResearchPage(),
const _HealthPermissionPage(),
const HealthPermissionPage(),
const _NotificationPermissionPage(),
];
}
... ... @@ -531,8 +531,8 @@ class _ResearchPageItem extends StatelessWidget {
}
}
class _HealthPermissionPage extends StatelessWidget {
const _HealthPermissionPage();
class HealthPermissionPage extends StatelessWidget {
const HealthPermissionPage();
@override
Widget build(BuildContext context) {
... ...
... ... @@ -69,6 +69,26 @@ class V2LatestHrvData {
}
}
/// 单段睡眠时间区间
class V2SleepTimeRange {
const V2SleepTimeRange({required this.fromTime, required this.toTime});
final int fromTime; // Unix 时间戳(秒)
final int toTime; // Unix 时间戳(秒)
factory V2SleepTimeRange.fromJson(Map<String, dynamic> json) {
return V2SleepTimeRange(
fromTime: json['from_time'] as int,
toTime: json['to_time'] as int,
);
}
Map<String, dynamic> toJson() => {
'from_time': fromTime,
'to_time': toTime,
};
}
/// v2/health_data/ response
class V2HealthData {
const V2HealthData({
... ... @@ -82,6 +102,7 @@ class V2HealthData {
this.sleepDuration,
this.sleepScore,
this.sleepState,
this.sleepTimeList,
});
final int? hrvAvg;
... ... @@ -94,6 +115,7 @@ class V2HealthData {
final int? sleepDuration;
final double? sleepScore;
final int? sleepState;
final List<V2SleepTimeRange>? sleepTimeList;
factory V2HealthData.fromJson(Map<String, dynamic> json) {
return V2HealthData(
... ... @@ -109,6 +131,9 @@ class V2HealthData {
sleepDuration: _parseInt(json['sleep_duration']),
sleepScore: _parseDouble(json['sleep_score']),
sleepState: _parseInt(json['sleep_state']),
sleepTimeList: (json['sleep_time_list'] as List<dynamic>?)
?.map((e) => V2SleepTimeRange.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
... ... @@ -126,6 +151,9 @@ class V2HealthData {
if (sleepDuration != null) val['sleep_duration'] = sleepDuration;
if (sleepScore != null) val['sleep_score'] = sleepScore;
if (sleepState != null) val['sleep_state'] = sleepState;
if (sleepTimeList != null) {
val['sleep_time_list'] = sleepTimeList!.map((e) => e.toJson()).toList();
}
return val;
}
... ...