Commit 9956ee47f347c3fda0b5a9c8407b5dc3d6e413ea

Authored by 常守达
1 parent 2166905b

feat(today): 苹果登陆

... ... @@ -3,15 +3,15 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
0798E27DB95F2A823881D75A /* Pods_Runner_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A488EFC0BD642ED604C4E43 /* Pods_Runner_Watch_App.framework */; };
20A3C4994C9B0F60D1F3BCBE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8DC48B3736B7FEB548D0E65 /* Pods_Runner.framework */; };
66FB19002FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB19032FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift */; };
66FB19012FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB19042FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift */; };
66FB19022FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB19052FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift */; };
66FB19002FDBC80100F515B4 /* DFWatchFigmaTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB19032FDBC80100F515B4 /* DFWatchFigmaTokens.swift */; };
66FB19012FDBC80100F515B4 /* DFWatchFigmaHomeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB19042FDBC80100F515B4 /* DFWatchFigmaHomeViews.swift */; };
66FB19022FDBC80100F515B4 /* DFWatchFigmaDetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB19052FDBC80100F515B4 /* DFWatchFigmaDetailViews.swift */; };
66FBE58C2FDA4F0F00F515B4 /* Runner Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 66FBE58B2FDA4F0F00F515B4 /* Runner Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
... ... @@ -54,9 +54,9 @@
27E4D5A8995AB1EC91E7330E /* Pods-Runner Watch App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner Watch App.debug.xcconfig"; path = "Target Support Files/Pods-Runner Watch App/Pods-Runner Watch App.debug.xcconfig"; sourceTree = "<group>"; };
4A9B7C102FDB0A1200F515B4 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
4A9B7C112FDB0A1200F515B4 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
66FB19032FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift"; sourceTree = "<group>"; };
66FB19042FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift"; sourceTree = "<group>"; };
66FB19052FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift"; sourceTree = "<group>"; };
66FB19032FDBC80100F515B4 /* DFWatchFigmaTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift"; sourceTree = "<group>"; };
66FB19042FDBC80100F515B4 /* DFWatchFigmaHomeViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift"; sourceTree = "<group>"; };
66FB19052FDBC80100F515B4 /* DFWatchFigmaDetailViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift"; sourceTree = "<group>"; };
66FBE57E2FDA4F0E00F515B4 /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
66FBE58B2FDA4F0F00F515B4 /* Runner Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Runner Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
66FBE5A72FDA518C00F515B4 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
... ... @@ -97,6 +97,8 @@
};
66FBE58F2FDA4F0F00F515B4 /* Runner Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = "Runner Watch App";
sourceTree = "<group>";
};
... ... @@ -134,9 +136,9 @@
66FB19072FDBC80100F515B4 /* FigmaHome Preview Sources */ = {
isa = PBXGroup;
children = (
66FB19052FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift */,
66FB19042FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift */,
66FB19032FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift */,
66FB19052FDBC80100F515B4 /* DFWatchFigmaDetailViews.swift */,
66FB19042FDBC80100F515B4 /* DFWatchFigmaHomeViews.swift */,
66FB19032FDBC80100F515B4 /* DFWatchFigmaTokens.swift */,
);
name = "FigmaHome Preview Sources";
sourceTree = "<group>";
... ... @@ -303,14 +305,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
... ... @@ -381,14 +379,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
... ... @@ -402,14 +396,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner Watch App/Pods-Runner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner Watch App/Pods-Runner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner Watch App/Pods-Runner Watch App-frameworks.sh\"\n";
... ... @@ -451,9 +441,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
66FB19022FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaDetailViews.swift in Sources */,
66FB19012FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaHomeViews.swift in Sources */,
66FB19002FDBC80100F515B4 /* Runner Watch App/FigmaHome/DFWatchFigmaTokens.swift in Sources */,
66FB19022FDBC80100F515B4 /* DFWatchFigmaDetailViews.swift in Sources */,
66FB19012FDBC80100F515B4 /* DFWatchFigmaHomeViews.swift in Sources */,
66FB19002FDBC80100F515B4 /* DFWatchFigmaTokens.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
... ...
... ... @@ -28,7 +28,8 @@ class BindPartnerController extends GetxController {
});
final userPrefs = Get.find<UserPreferencesStorage>();
myInviteCode.value = userPrefs.preferences.value.meUserInfo?.pairCode ?? '';
myInviteCode.value =
userPrefs.preferences.value.meUserInfo?.uniqueCode ?? '';
}
@override
... ...
import 'dart:async';
import 'dart:math';
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart';
import 'package:doublefeel_flutter/app/modules/home/controllers/home_controller.dart';
import 'package:doublefeel_flutter/app/modules/home/controllers/trend/trend_controller.dart';
import 'package:doublefeel_flutter/app/modules/home/widgets/today/no_health_data_page.dart';
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/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
... ... @@ -10,7 +15,6 @@ 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/data/models/health/health_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_upload_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:flutter/material.dart';
... ... @@ -75,12 +79,6 @@ class TodayController extends GetxController {
final showHealthDataAuthCard = true.obs;
final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs;
// ── 压力状态 ─────────────────────────────
final stressLabel = '状态正常'.obs;
/// 进度条压力分(null=加载中, 0~100=压力分: 0优秀, 100过载)
final stressScore = Rxn<int>();
// ── HRV 表盘引导 Banner ───────────────────
final showHrvAdBanner = true.obs;
final showPartnerAdBanner = true.obs;
... ... @@ -93,13 +91,6 @@ class TodayController extends GetxController {
final avgHrv = '--'.obs;
final restingHeartRate = '--'.obs;
// ── 睡眠卡片 ──────────────────────────────
final sleepHours = '--'.obs;
final sleepMinutes = '--'.obs;
final sleepQuality = '--'.obs;
final sleepAverageHeartRate = '--'.obs;
final sleepProgress = 0.0.obs;
// ── 健身卡片 ──────────────────────────────
final activityCalories = '--'.obs;
final activityExerciseMinutes = '--'.obs;
... ... @@ -219,7 +210,7 @@ class TodayController extends GetxController {
Future<void> _refreshHealthDataForDate(DateTime date) async {
_clearRealTimeData();
final intDate = int.parse(DateFormat('yyyyMM01').format(date));
final intDate = int.parse(DateFormat('yyyyMMdd').format(date));
final isToday = DateUtils.isSameDay(date, lastSelectableDay);
stressSubtitle.value = isToday ? 'Hi, 你今日的综合压力状态' : '该日压力状态';
... ... @@ -261,11 +252,7 @@ class TodayController extends GetxController {
void _clearRealTimeData() {
restingHeartRate.value = '--';
sleepAverageHeartRate.value = '--';
sleepHours.value = '--';
sleepMinutes.value = '--';
sleepQuality.value = '--';
sleepProgress.value = 0.0;
activityCalories.value = '--';
activityExerciseMinutes.value = '--';
activityStandHours.value = '--';
... ... @@ -276,69 +263,6 @@ class TodayController extends GetxController {
stressChartData.clear();
hrvAnnotations.clear();
avgHrv.value = '--';
stressLabel.value = '状态正常';
stressScore.value = null;
}
void _applyTodayData(TodayStatusData today) {
final recent = today.recentData;
if (recent?.heartRate != null) {
restingHeartRate.value = _formatMetric(recent!.heartRate);
if (sleepAverageHeartRate.value == '--') {
sleepAverageHeartRate.value = _formatMetric(recent.heartRate);
}
}
_applySleepDuration(today.sleepDuration);
_applyActivityRecentData(recent);
final hrvPoints = _buildHrvChartData(today.hrvDataList);
hrvChartData.assignAll(hrvPoints);
stressChartData.assignAll(
hrvPoints.map((point) {
return HrvDataPoint(
hour: point.hour,
hrv: _stressScoreFromHrv(point.hrv),
);
}),
);
final latest = _latestTodayHrv(today.hrvDataList);
_applyLatestTodayHrv(latest);
final hrvValues = hrvPoints.map((point) => point.hrv).toList();
if (hrvValues.isNotEmpty) {
final average = hrvValues.reduce((value, element) => value + element) /
hrvValues.length;
avgHrv.value = _formatMetric(average);
final latestStatus = latest?.hrvStatus;
if (latestStatus != null) {
stressLabel.value = latestStatus.title;
}
// 更新进度条压力分
final latestHrvVal = latest?.value ?? average;
stressScore.value = null; // _computeStressScore(latestHrvVal);
_updateLatestHrvAnnotation(latest);
}
}
void _applyLatestHrvData(LatestHrvData latestHrv) {
final userHrv = latestHrv.userHrv;
if (avgHrv.value == '--' && userHrv != null) {
avgHrv.value = _formatMetric(userHrv);
}
if (userHrv != null) {
stressLabel.value = _hrvStatusTitle(
value: userHrv,
baseline: latestHrv.userHrvBaseline,
);
// 更新进度条压力分
stressScore.value = null;
//_computeStressScore(userHrv,baseline: latestHrv.userHrvBaseline,);
}
}
void _applyHrvStatistics(HrvStatisticsData data, DateTime date) {
... ... @@ -365,36 +289,6 @@ class TodayController extends GetxController {
if (data.avgRestingHeartRate != null) {
restingHeartRate.value = _formatMetric(data.avgRestingHeartRate);
}
if (data.avgSleepingHeartRate != null) {
sleepAverageHeartRate.value = _formatMetric(data.avgSleepingHeartRate);
}
}
void _applySleepStatistics(SleepStatisticsData data, DateTime date) {
final dateKey = date.day;
final isToday = DateUtils.isSameDay(date, lastSelectableDay);
if (isToday) {
if (sleepHours.value == '--' && data.avgSleepDuration != null) {
_applySleepDuration(data.avgSleepDuration);
}
} else {
int? dailySleep;
if (data.sleepTrendList != null) {
for (final trend in data.sleepTrendList!) {
if (trend.timeKey == dateKey) {
dailySleep = trend.average;
break;
}
}
}
_applySleepDuration(dailySleep);
}
final deepPercentage = data.deepPercentage;
if (deepPercentage != null) {
sleepQuality.value = _sleepQualityFromDeepPercentage(deepPercentage);
}
}
void _applyActivityStatistics(
... ... @@ -475,22 +369,6 @@ class TodayController extends GetxController {
if (value == null) return;
}
void _applySleepDuration(int? rawDuration) {
final minutes = _normalizeDurationMinutes(rawDuration);
if (minutes == null) {
sleepHours.value = '--';
sleepMinutes.value = '--';
sleepQuality.value = '--';
sleepProgress.value = 0;
return;
}
sleepHours.value = '${minutes ~/ 60}';
sleepMinutes.value = '${minutes % 60}';
sleepQuality.value = _sleepQualityFromDuration(minutes);
sleepProgress.value = _progress(minutes.toDouble(), 8 * 60);
}
void _applyActivityRecentData(TodayStatusRecentData? recent) {
activityCalories.value = _formatMetric(recent?.move);
activityExerciseMinutes.value = _formatMetric(recent?.exercise);
... ... @@ -535,45 +413,6 @@ class TodayController extends GetxController {
return latest;
}
void _updateLatestHrvAnnotation(TodayHrvData? latest) {
if (latest?.time == null || latest?.value == null) {
hrvAnnotations.clear();
return;
}
final hour = _hourFromApiTime(latest!.time!);
hrvAnnotations.assignAll([
HrvAnnotation(
hour: hour,
hrv: latest.value!,
stateLabel: latest.hrvStatus?.title ?? stressLabel.value,
detail: 'HRV ${_formatMetric(latest.value)}ms · ${_formatHour(hour)}',
),
]);
}
/// 将 HRV 值映射为压力分 0~100(足基线时按相对偏差映射,无基线时按 100-HRV)。
int _computeStressScore(double hrv, {double? baseline}) {
if ((baseline ?? 0) > 0) {
final ratio = hrv / baseline!;
// ratio≥1.2 → 0~20(优秀)
if (ratio >= 1.2)
return ((1.2 - ratio.clamp(1.2, 1.5)) / 0.3 * 20).round().clamp(0, 20);
// ratio ∈ [0.9,1.2) → 21~50(正常)
if (ratio >= 0.9)
return (((1.2 - ratio) / 0.3) * 29 + 21).round().clamp(21, 50);
// ratio ∈ [0.8,0.9) → 51~80(注意)
if (ratio >= 0.8)
return (((0.9 - ratio) / 0.1) * 29 + 51).round().clamp(51, 80);
// ratio < 0.8 → 81~100(过载)
return ((0.8 - ratio.clamp(0.4, 0.8)) / 0.4 * 19 + 81)
.round()
.clamp(81, 100);
}
// 无基线:100 - HRV,HRV 越高 → 分数越低
return (100 - hrv).clamp(0, 100).round();
}
String _hrvStatusTitle({required double value, double? baseline}) {
if ((baseline ?? 0) > 0) {
if (value >= 1.2 * baseline!) return HrvStatus.energetic.title;
... ... @@ -653,4 +492,49 @@ class TodayController extends GetxController {
final m = (totalMinutes % 60).toString().padLeft(2, '0');
return '$h:$m';
}
toTrendHrvPage() {
if (isFriend) {
Get.toNamed(
Routes.FRIEND_TREND,
arguments: FriendTrendArguments(
userId: 58,
name: 'xxa',
avatarUrl: 'asdfa',
),
);
} else {
Get.find<HomeController>().openTrend(TrendType.hrv);
}
}
toTrendSleepPage() {
if (isFriend) {
Get.toNamed(
Routes.FRIEND_TREND,
arguments: FriendTrendArguments(
userId: 58,
name: 'xxa',
avatarUrl: 'asdfa',
),
);
} else {
Get.find<HomeController>().openTrend(TrendType.sleep);
}
}
toTrendActivityPage() {
if (isFriend) {
Get.toNamed(
Routes.FRIEND_TREND,
arguments: FriendTrendArguments(
userId: 58,
name: 'xxa',
avatarUrl: 'asdfa',
),
);
} else {
Get.find<HomeController>().openTrend(TrendType.activity);
}
}
}
... ...
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/report_common/models/report_period.dart';
import 'package:doublefeel_flutter/app/modules/report_common/widgets/report_date_picker_sheet.dart';
... ... @@ -81,14 +82,17 @@ class TodayTabBody extends StatelessWidget {
() => Column(
mainAxisSize: MainAxisSize.min,
children: [
_TopDateBar(
title: _dateTitle(context, controller.selectedDate.value),
showBackToToday: !isSameDay(
controller.selectedDate.value,
controller.lastSelectableDay,
),
onBackToToday: _backToToday,
),
controller.isFriend
? _TopFriendBar()
: _TopDateBar(
title: _dateTitle(
context, controller.selectedDate.value),
showBackToToday: !isSameDay(
controller.selectedDate.value,
controller.lastSelectableDay,
),
onBackToToday: _backToToday,
),
_buildWeekCalendar(context),
],
),
... ... @@ -156,7 +160,7 @@ class TodayTabBody extends StatelessWidget {
),
Obx(
() => Text(
controller.stressLabel.value,
controller.v2StressScore.value?.stateString() ?? '',
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
... ... @@ -168,7 +172,9 @@ class TodayTabBody extends StatelessWidget {
const SizedBox(height: 12),
Obx(
() => StressProgressBar(
score: controller.stressScore.value,
score: controller.v2StressScore.value?.state == 0
? null
: controller.v2StressScore.value?.comprehensiveScore,
),
),
],
... ... @@ -429,6 +435,9 @@ class TodayTabBody extends StatelessWidget {
if (isSameDay(selectedDay, today.subtract(const Duration(days: 1)))) {
return context.l10n.yesterday;
}
if (isSameDay(selectedDay, today.subtract(const Duration(days: 2)))) {
return '前天';
}
return MaterialLocalizations.of(context).formatShortMonthDay(selectedDay);
}
... ... @@ -480,6 +489,8 @@ class _LatestHrvCard extends StatelessWidget {
final latestHrv = controller.v2LatestHrv.value?.latestHrv;
final latestDataTime = controller.v2LatestHrv.value?.latestDataTime;
final state = controller.v2LatestHrv.value?.state;
final stateStr = controller.v2LatestHrv.value?.stateString();
final stateColor = controller.v2LatestHrv.value?.stateColor(context);
final tip = controller.v2LatestHrv.value?.tip;
if (latestHrv == null ||
... ... @@ -488,11 +499,9 @@ class _LatestHrvCard extends StatelessWidget {
tip == null) {
return const SizedBox.shrink();
}
final latestHrvText = '$latestHrv ms';
final latestDataTimeText =
'${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(latestDataTime * 1000))}';
final stateText =
'${state == 1 ? '状态优秀' : state == 2 ? '状态' : state == 3 ? '状态一般' : state == 4 ? '状态较差' : '状态极差'}';
final latestDataTimeText = DateFormat('HH:mm')
.format(DateTime.fromMillisecondsSinceEpoch(latestDataTime * 1000));
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
decoration: BoxDecoration(
... ... @@ -517,13 +526,13 @@ class _LatestHrvCard extends StatelessWidget {
width: 12,
height: 12,
decoration: BoxDecoration(
color: context.colors.chartGreen,
color: stateColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'${latestHrvText}ms·$stateText',
'${latestHrv}ms · $stateStr',
style: TextStyle(
color: colors.textPrimary,
fontSize: 18,
... ... @@ -550,6 +559,64 @@ class _LatestHrvCard extends StatelessWidget {
}
}
class _TopFriendBar extends StatelessWidget {
final _topBarHeight = 48.0;
@override
Widget build(BuildContext context) {
return SizedBox(
height: _topBarHeight,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
IconButton(
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: Get.back,
icon: Image.asset(
'assets/images/common/ic_nav_back.webp',
width: 24,
height: 24,
),
),
Container(
width: 28,
height: 28,
margin: EdgeInsets.only(left: 4, right: 8),
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(width: 0.78, color: Colors.white),
borderRadius: BorderRadius.circular(35.78),
),
),
child: CachedNetworkImage(imageUrl: 'imageUrl'),
),
Text(
'男朋友的状态',
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
Spacer(),
IconButton(
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: Get.back,
icon: Image.asset(
'assets/images/common/ic_replace.png',
width: 20,
height: 20,
),
),
],
),
));
}
}
class _TopDateBar extends StatelessWidget {
const _TopDateBar({
required this.title,
... ...
... ... @@ -30,11 +30,10 @@ class AccountSettingView extends GetView<MyController> {
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: Get.back,
icon: Image(
icon: Image.asset(
'assets/images/common/ic_nav_back.webp',
width: 24,
height: 24,
alignment: Alignment.centerLeft,
image: AssetImage(R.assetsImagesNavBackIcon),
),
),
),
... ...
... ... @@ -131,19 +131,25 @@ class TodayHrvChartCard extends StatelessWidget {
),
),
const Spacer(),
Text(
context.l10n.more,
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
GestureDetector(
onTap: controller.toTrendHrvPage,
child: Text(
context.l10n.more,
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
Image.asset(
'assets/images/common/ic_more_gray.png',
width: 16,
height: 16,
color: context.colors.textTertiary,
GestureDetector(
onTap: controller.toTrendHrvPage,
child: Image.asset(
'assets/images/common/ic_more_gray.png',
width: 16,
height: 16,
color: context.colors.textTertiary,
),
),
],
);
... ...
... ... @@ -13,75 +13,83 @@ class TodaySleepCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(
() => _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_sleep_stroke.png',
iconColor: context.colors.primary,
title: context.l10n.sleep,
actionText: context.l10n.viewSleepReport,
progressValues: [controller.sleepProgress.value],
progressColors: [context.colors.primary],
metrics: [
_MetricData(
label: context.l10n.duration,
labelColor: context.colors.primary,
valueSpans: [
_MetricValueSpan(controller.sleepHours.value, '小时'),
_MetricValueSpan(controller.sleepMinutes.value, '分钟'),
],
),
_MetricData(
label: context.l10n.quality,
labelColor: const Color(0xFF7B9BFB),
valueSpans: [
_MetricValueSpan(
controller.sleepQuality.value,
'',
valueColor: _qualityColor(controller.sleepQuality.value),
),
],
),
_MetricData(
label: context.l10n.averageHeartRate,
labelColor: context.colors.textSecondary,
valueSpans: [
_MetricValueSpan(controller.sleepAverageHeartRate.value, 'bpm'),
],
),
],
rightWidget: Container(
width: 50,
height: 50,
margin: EdgeInsets.only(right: 6, left: 4),
child: CircularGradientCombineWidget(
size: 50,
duration: const Duration(seconds: 1),
sweepAngles: const [240, 210],
backgroundColors: const [
Color(0xFFF3F3F3),
Color(0xFFF3F3F3),
return GestureDetector(
onTap: controller.toTrendSleepPage,
child: Obx(() {
var healthData = controller.v2HealthData.value;
int sleepDuration = healthData?.sleepDuration ?? 0;
int sleepHours = sleepDuration ~/ 3600;
int sleepMinutes = sleepDuration % 60;
Color sleepStateColor =
healthData?.sleepStateColor(context) ?? context.colors.chartGreen;
String sleepStateStr = healthData?.sleepStateStr() ?? '--';
String sleepAverageHeartRate =
(healthData?.hrAvg == null || healthData?.hrAvg == 0)
? '--'
: '${healthData?.hrAvg}';
double sleepDurationProgress =
((healthData?.sleepDurationScore() ?? 0) * 3.60);
double sleepScoreProgress = (healthData?.sleepScore ?? 0.0) * 3.6;
return _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_sleep_stroke.png',
iconColor: context.colors.primary,
title: context.l10n.sleep,
actionText: context.l10n.viewSleepReport,
metrics: [
_MetricData(
label: context.l10n.duration,
labelColor: context.colors.primary,
valueSpans: [
_MetricValueSpan(sleepHours > 0 ? '$sleepHours' : '--', '小时'),
_MetricValueSpan(
sleepMinutes > 0 ? '$sleepMinutes' : '--', '分钟'),
],
gradientColors: [
[context.colors.primary],
[context.colors.chartBlue],
),
_MetricData(
label: context.l10n.quality,
labelColor: const Color(0xFF7B9BFB),
valueSpans: [
_MetricValueSpan(
sleepStateStr,
'',
valueColor: sleepStateColor,
),
],
centerCircleSizeRatio: 0.3,
gapRatio: 0.1,
interpolatedColorRatio: 1),
),
),
),
_MetricData(
label: context.l10n.averageHeartRate,
labelColor: context.colors.textSecondary,
valueSpans: [
_MetricValueSpan(sleepAverageHeartRate, 'bpm'),
],
),
],
rightWidget: Container(
width: 50,
height: 50,
margin: EdgeInsets.only(right: 6, left: 4),
child: CircularGradientCombineWidget(
size: 50,
duration: const Duration(seconds: 1),
sweepAngles: [sleepDurationProgress, sleepScoreProgress],
backgroundColors: const [
Color(0xFFF3F3F3),
Color(0xFFF3F3F3),
],
gradientColors: [
[context.colors.primary],
[context.colors.chartBlue],
],
centerCircleSizeRatio: 0.3,
gapRatio: 0.1,
interpolatedColorRatio: 1),
),
);
}),
);
}
Color _qualityColor(String quality) {
return switch (quality) {
'优秀' => const Color(0xFF3BD49D),
'良好' => const Color(0xFF7B9BFB),
'一般' => const Color(0xFFFF9A6E),
'偏少' => const Color(0xFFFF5279),
_ => const Color(0xFF0F0F11),
};
}
}
class TodayActivityCard extends StatelessWidget {
... ... @@ -91,91 +99,93 @@ class TodayActivityCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(
() => _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_exercise.png',
iconColor: const Color(0xFFFF5279),
title: context.l10n.fitness,
actionText: context.l10n.viewFitnessReport,
progressValues: [
controller.activityMoveProgress.value,
controller.activityExerciseProgress.value,
controller.activityStandProgress.value,
],
progressColors: const [
Color(0xFFFF5279),
Color(0xFF3BD49D),
Color(0xFF7B9BFB),
],
metrics: [
_MetricData(
label: context.l10n.event,
labelColor: const Color(0xFFFF5279),
valueSpans: [
_MetricValueSpan(controller.activityCalories.value, '千卡'),
],
),
_MetricData(
label: context.l10n.exercise,
labelColor: const Color(0xFF3BD49D),
valueSpans: [
_MetricValueSpan(
controller.activityExerciseMinutes.value,
'分钟',
),
],
),
_MetricData(
label: context.l10n.standing,
labelColor: const Color(0xFF7B9BFB),
valueSpans: [
_MetricValueSpan(controller.activityStandHours.value, '小时'),
],
),
],
rightWidget: Container(
width: 50,
height: 50,
margin: EdgeInsets.only(right: 6, left: 4),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.textPrimary,
return GestureDetector(
onTap: controller.toTrendActivityPage,
child: Obx(() {
var healthData = controller.v2HealthData.value;
var activity = healthData?.move;
var stand = healthData?.stand ?? 0;
var exercise = healthData?.exercise ?? 0;
var standStr = stand > 0 ? '${stand ~/ 3600}' : '--';
var exerciseStr = exercise > 0 ? '${exercise % 60}' : '--';
return _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_exercise.png',
iconColor: const Color(0xFFFF5279),
title: context.l10n.fitness,
actionText: context.l10n.viewFitnessReport,
metrics: [
_MetricData(
label: context.l10n.event,
labelColor: const Color(0xFFFF5279),
valueSpans: [
_MetricValueSpan(
(activity == null || activity == 0) ? '--' : '$activity',
'千卡'),
],
),
_MetricData(
label: context.l10n.exercise,
labelColor: const Color(0xFF3BD49D),
valueSpans: [
_MetricValueSpan(
exerciseStr,
'分钟',
),
),
CircularGradientCombineWidget(
size: 50,
duration: const Duration(seconds: 1),
centerCircleSizeRatio: 0.2,
gapRatio: 0.06,
sweepAngles: const [
180,
230,
180,
],
backgroundColors: [
context.colors.chartPink.withOpacity(0.4),
context.colors.chartGreen.withOpacity(0.3),
context.colors.chartBlue.withOpacity(0.4),
],
gradientColors: [
[
context.colors.chartPink,
],
),
_MetricData(
label: context.l10n.standing,
labelColor: const Color(0xFF7B9BFB),
valueSpans: [
_MetricValueSpan(standStr, '小时'),
],
),
],
rightWidget: Container(
width: 50,
height: 50,
margin: EdgeInsets.only(right: 6, left: 4),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.textPrimary,
),
),
CircularGradientCombineWidget(
size: 50,
duration: const Duration(seconds: 1),
centerCircleSizeRatio: 0.2,
gapRatio: 0.06,
sweepAngles: const [
180,
230,
180,
],
[
context.colors.chartGreen,
backgroundColors: [
context.colors.chartPink.withOpacity(0.4),
context.colors.chartGreen.withOpacity(0.3),
context.colors.chartBlue.withOpacity(0.4),
],
[
context.colors.chartBlue,
gradientColors: [
[
context.colors.chartPink,
],
[
context.colors.chartGreen,
],
[
context.colors.chartBlue,
],
],
],
),
],
),
],
),
),
),
),
);
}),
);
}
}
... ... @@ -187,8 +197,6 @@ class _TodaySummaryCard extends StatelessWidget {
required this.title,
required this.actionText,
required this.metrics,
required this.progressValues,
required this.progressColors,
required this.rightWidget,
});
... ... @@ -197,8 +205,7 @@ class _TodaySummaryCard extends StatelessWidget {
final String title;
final String actionText;
final List<_MetricData> metrics;
final List<double> progressValues;
final List<Color> progressColors;
final Widget rightWidget;
@override
... ...
... ... @@ -63,7 +63,9 @@ class LoginController extends GetxController {
}
final result = await PlatformHostApi().requestAppleSignIn();
// result 为 null 或 identityToken 为空,说明用户取消或苹果授权失败
if (result == null || result.identityToken?.isNotEmpty != true) {
if (result == null ||
result.identityToken?.isNotEmpty != true ||
result.userId.isNotEmpty != true) {
appleSignInModel = null;
return;
}
... ... @@ -184,26 +186,31 @@ class LoginController extends GetxController {
final loginRes = await _userApi.loginWithApple(appleModel);
if (loginRes is AppSuccess<LoginResponse>) {
await _handleLoginSuccess(loginRes.data, isApple: true);
await _handleLoginSuccess(loginRes.data, appleModel: appleModel);
} else {
isLoggingIn.value = false;
}
}
/// 登录/注册成功后的公共处理逻辑(手机号登录与苹果登录共用)
Future<void> _handleLoginSuccess(
LoginResponse loginData, {
bool isApple = false,
}) async {
Future<void> _handleLoginSuccess(LoginResponse loginData,
{AppleSignInModel? appleModel}) async {
bool isApple = appleModel != null;
final isRegister = loginData.isNewUser == true;
String accessToken = loginData.accessToken;
if (isRegister && !isApple) {
// 苹果登录时后端直接完成注册,无需二次注册请求
final regRes = await _userApi.register(
telephone: cleanPhone,
verifyCode: codeInput.value,
);
if (isRegister) {
final regRes = await _userApi.register(isApple
? {
'login_type': 'apple',
'apple_user_id': appleModel.userId,
'identity_token': appleModel.identityToken,
}
: {
'login_type': 'verification_code',
'telephone': cleanPhone,
'verification_code': codeInput.value,
});
if (regRes is AppSuccess<RegisterResponse>) {
accessToken = regRes.data.accessToken;
} else {
... ...
... ... @@ -47,14 +47,18 @@ class UserApi {
Future<AppResult<LoginResponse>> loginWithApple(AppleSignInModel appleModel) {
return safeCall(
call: () async {
var appleLoginRequest = AppleLoginRequest(
identityToken: appleModel.identityToken ?? '',
userId: appleModel.userId);
if (appleModel.nickname != null) {
appleLoginRequest.nickname = appleModel.nickname;
}
if (appleModel.email != null) {
appleLoginRequest.email = appleModel.email;
}
final response = await _dioClient.dio.post(
ApiPaths.userLogin,
data: AppleLoginRequest(
identityToken: appleModel.identityToken ?? '',
email: appleModel.email ?? '',
nickname: appleModel.nickname ?? '',
userId: appleModel.userId)
.toJson(),
data: appleLoginRequest.toJson(),
options: _dioClient.noTokenOptions(),
);
return LoginResponse.fromJson(response.data as Map<String, dynamic>);
... ... @@ -62,18 +66,12 @@ class UserApi {
);
}
Future<AppResult<RegisterResponse>> register({
required String telephone,
required String verifyCode,
}) {
Future<AppResult<RegisterResponse>> register(Object? registerData) {
return safeCall(
call: () async {
final response = await _dioClient.dio.post(
ApiPaths.userRegister,
data: RegisterRequest(
telephone: telephone,
verificationCode: verifyCode,
).toJson(),
data: registerData,
options: _dioClient.noTokenOptions(),
);
return RegisterResponse.fromJson(response.data as Map<String, dynamic>);
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:flutter/src/widgets/framework.dart';
int? _parseInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
... ... @@ -28,6 +31,34 @@ class V2LatestHrvData {
final int? state;
final String? tip;
String stateString() {
switch (state) {
case 1:
return '压力过载';
case 2:
return '注意压力';
case 3:
return '状态正常';
case 4:
return '状态优秀';
}
return '等待数据';
}
stateColor(BuildContext context) {
switch (state) {
case 1:
return context.colors.chartPink;
case 2:
return context.colors.chartOrange;
case 3:
return context.colors.chartGreen;
case 4:
return context.colors.chartBlue;
}
return context.colors.chartGreen;
}
factory V2LatestHrvData.fromJson(Map<String, dynamic> json) {
return V2LatestHrvData(
latestHrv: _parseDouble(json['latest_hrv']),
... ... @@ -95,6 +126,43 @@ class V2HealthData {
if (sleepState != null) val['sleep_state'] = sleepState;
return val;
}
/// 根据睡眠时长(秒)计算得分。
int sleepDurationScore() {
final duration = sleepDuration;
if (duration == null || duration <= 0) return 0;
final hours = duration / 3600.0;
if (hours >= 7 && hours <= 9) return 100;
if (hours > 9 && hours <= 10) return 80;
if (hours >= 6 && hours < 7) return 80;
if (hours >= 5 && hours < 6) return 60;
if (hours > 10) return 50;
return 30; // < 5h
}
sleepStateColor(BuildContext context) {
switch (sleepState) {
case 1:
return context.colors.chartBlue;
case 2:
return context.colors.chartGreen;
case 3:
return context.colors.chartPink;
}
return context.colors.chartGreen;
}
sleepStateStr() {
switch (sleepState) {
case 1:
return '睡得很棒';
case 2:
return '睡得不错';
case 3:
return '睡得差';
}
return '--';
}
}
/// v2/stress_score/ response
... ... @@ -134,6 +202,20 @@ class V2StressScore {
}
return val;
}
String stateString() {
switch (state) {
case 1:
return '压力过载';
case 2:
return '注意压力';
case 3:
return '状态正常';
case 4:
return '状态优秀';
}
return '等待数据';
}
}
/// Single item in v2/hrv_trend/ list
... ...
... ... @@ -19,7 +19,7 @@ class LoginRequest {
}
class AppleLoginRequest {
const AppleLoginRequest({
AppleLoginRequest({
this.loginType = 'apple',
required this.identityToken,
required this.userId,
... ... @@ -30,8 +30,8 @@ class AppleLoginRequest {
final String loginType;
final String identityToken;
final String userId;
final String? email;
final String? nickname;
String? email;
String? nickname;
Map<String, dynamic> toJson() => {
'login_type': loginType,
... ... @@ -42,21 +42,6 @@ class AppleLoginRequest {
};
}
class RegisterRequest {
const RegisterRequest({
required this.telephone,
required this.verificationCode,
});
final String telephone;
final String verificationCode;
Map<String, dynamic> toJson() => {
'telephone': telephone,
'verification_code': verificationCode,
};
}
class VerifyCodeRequest {
const VerifyCodeRequest({required this.telephone, required this.scene});
... ... @@ -193,19 +178,20 @@ class RegisterResponse {
}
class UserInfoResponse {
const UserInfoResponse({
this.id,
this.pairCode,
this.pairId,
this.telephone,
this.nickname,
this.avatar,
this.persona,
this.status,
this.lastLoginTime,
this.createTime,
this.isBot,
});
const UserInfoResponse(
{this.id,
this.pairCode,
this.pairId,
this.telephone,
this.nickname,
this.avatar,
this.persona,
this.status,
this.lastLoginTime,
this.createTime,
this.isBot,
this.uniqueCode,
this.enableAddWithUCode});
final int? id;
final String? pairCode;
... ... @@ -218,6 +204,8 @@ class UserInfoResponse {
final int? lastLoginTime;
final int? createTime;
final int? isBot;
final String? uniqueCode;
final int? enableAddWithUCode;
factory UserInfoResponse.fromJson(Map<String, dynamic> json) {
return UserInfoResponse(
... ... @@ -232,6 +220,8 @@ class UserInfoResponse {
lastLoginTime: json['last_login_time'] as int?,
createTime: json['create_time'] as int?,
isBot: json['is_bot'] as int?,
uniqueCode: json['unique_code'] as String?,
enableAddWithUCode: json['enable_add_with_ucode'] as int?,
);
}
... ... @@ -248,6 +238,10 @@ class UserInfoResponse {
if (lastLoginTime != null) val['last_login_time'] = lastLoginTime;
if (createTime != null) val['create_time'] = createTime;
if (isBot != null) val['is_bot'] = isBot;
if (uniqueCode != null) val['unique_code'] = uniqueCode;
if (enableAddWithUCode != null) {
val['enable_add_with_ucode'] = enableAddWithUCode;
}
return val;
}
}
... ...