Commit aa24744a608708019bffd7a87cab72fd235863ba

Authored by 常守达
1 parent e1d8b4de

feat(today): 无心率数据页

Showing 30 changed files with 1231 additions and 728 deletions
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE57D2FDA4F0E00F515B4"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "8">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
launchAutomaticallySubstyle = "8">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE57D2FDA4F0E00F515B4"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE5A42FDA518C00F515B4"
BuildableName = "doublefeel-watch-app-extensionExtension.appex"
BlueprintName = "doublefeel-watch-app-extensionExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE58A2FDA4F0F00F515B4"
BuildableName = "Runner Watch App.app"
BlueprintName = "Runner Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE57D2FDA4F0E00F515B4"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE57D2FDA4F0E00F515B4"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66FBE5A42FDA518C00F515B4"
BuildableName = "doublefeel-watch-app-extensionExtension.appex"
BlueprintName = "doublefeel-watch-app-extensionExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
import 'package:doublefeel_flutter/app/widget/dash_divider.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
... ... @@ -95,7 +96,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: const Color(0xFF0F0F11),
color: context.colors.textPrimary,
height: 1.4,
),
),
... ... @@ -110,7 +111,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xFF845EEE).withValues(alpha: 0.08),
color: context.colors.primary.withValues(alpha: 0.08),
blurRadius: 24,
offset: const Offset(0, 8),
),
... ... @@ -134,7 +135,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
onTap: controller.onSubmitPartnerId,
enabled: controller.canSubmit && !controller.isSubmitting.value,
isLoading: controller.isSubmitting.value,
primaryColor: const Color(0xFFFF9A6E),
primaryColor: context.colors.chartOrange,
);
}),
),
... ... @@ -156,7 +157,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: const Color(0xFF0F0F11),
color: context.colors.textPrimary,
),
),
SizedBox(height: 13),
... ... @@ -170,7 +171,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
color: const Color(0xFF845EEE),
color: context.colors.primary,
height: 1.4,
),
),
... ... @@ -189,10 +190,9 @@ class BindPartnerView extends GetView<BindPartnerController> {
color: const Color(0xFFF0F0F6),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.copy_rounded,
size: 12,
color: const Color(0xFF845EEE),
padding: EdgeInsets.all(3),
child: Image.asset(
'assets/images/common/ic_copy_purple.webp',
),
),
),
... ... @@ -205,6 +205,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
label: context.l10n.bindPartnerShareMyCode,
onTap: controller.onShareMyCode,
enabled: true,
primaryColor: context.colors.primary,
),
],
),
... ... @@ -233,7 +234,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: const Color(0xFF845EEE),
color: context.colors.primary,
),
),
),
... ... @@ -260,7 +261,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: const Color(0xFF0F0F11),
color: context.colors.textPrimary,
),
),
SizedBox(height: 16),
... ... @@ -278,7 +279,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w400,
color: const Color(0xFF0F0F11),
color: context.colors.textPrimary,
),
decoration: InputDecoration(
hintText: context.l10n.bindPartnerInputHint,
... ... @@ -303,7 +304,7 @@ class BindPartnerView extends GetView<BindPartnerController> {
required VoidCallback onTap,
bool enabled = true,
bool isLoading = false,
Color primaryColor = const Color(0xFF845EEE),
required Color primaryColor,
}) {
return GestureDetector(
onTap: enabled ? onTap : null,
... ...
import 'dart:async';
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';
... ... @@ -11,7 +12,6 @@ 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/user/user_models.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -66,6 +66,9 @@ class TodayController extends GetxController {
// ── 压力状态 ─────────────────────────────
final stressLabel = '状态正常'.obs;
/// 进度条压力分(null=加载中, 0~100=压力分: 0优秀, 100过载)
final stressScore = Rxn<int>();
// ── HRV 表盘引导 Banner ───────────────────
final showHrvAdBanner = true.obs;
final showPartnerAdBanner = true.obs;
... ... @@ -77,8 +80,6 @@ class TodayController extends GetxController {
// ── HRV 数字 + 心率 ───────────────────────
final avgHrv = '--'.obs;
final restingHeartRate = '--'.obs;
final latestHrvValue = '--'.obs;
final latestHrvTimeLabel = ''.obs;
// ── 睡眠卡片 ──────────────────────────────
final sleepHours = '--'.obs;
... ... @@ -110,7 +111,12 @@ class TodayController extends GetxController {
// } catch (error) {
// debugPrint('Health authorization skipped: $error');
// }
Get.to(NoHealthDataPage());
Get.to(NoHealthDataPage(
onRefresh: () {},
onHelp: () {
Get.toNamed(Routes.HELP);
},
));
}
@override
... ... @@ -129,8 +135,6 @@ class TodayController extends GetxController {
unawaited(loadDataForDate(today));
}
Future<void> loadTodayData() => loadDataForDate(lastSelectableDay);
void changeDate(DateTime date, {DateTime? focused}) {
final normalizedDate = _clampDate(date);
selectedDate.value = normalizedDate;
... ... @@ -148,7 +152,6 @@ class TodayController extends GetxController {
isLoadingToday.value = true;
try {
await Future.wait([
_refreshUserGreeting(),
// _refreshHealthAuthorizationState(),
_refreshHealthDataForDate(date),
]);
... ... @@ -161,16 +164,6 @@ class TodayController extends GetxController {
}
}
Future<void> _refreshUserGreeting() async {
final result = await _userApi.getUserInfo(errorHandlingPolicy: null);
if (result case AppSuccess<UserInfoResponse>(data: final user)) {
final name = user.nickname?.trim();
stressSubtitle.value = name == null || name.isEmpty
? 'Hi, 你今日的综合压力状态'
: 'Hi, $name 今日的综合压力状态';
}
}
Future<void> _refreshHealthAuthorizationState() async {
final serverAuth =
await _healthApi.checkServerHealthAuth(errorHandlingPolicy: null);
... ... @@ -197,6 +190,7 @@ class TodayController extends GetxController {
const dateRangeType = 1;
final isToday = DateUtils.isSameDay(date, lastSelectableDay);
stressSubtitle.value = isToday ? 'Hi, 你今日的综合压力状态' : '该日压力状态';
final todayResultFuture = isToday
? _healthApi.getTodayData(
... ... @@ -290,9 +284,8 @@ class TodayController extends GetxController {
stressChartData.clear();
hrvAnnotations.clear();
avgHrv.value = '--';
latestHrvValue.value = '--';
latestHrvTimeLabel.value = '';
stressLabel.value = '状态正常';
stressScore.value = null;
}
void _applyTodayData(TodayStatusData today) {
... ... @@ -331,6 +324,11 @@ class TodayController extends GetxController {
if (latestStatus != null) {
stressLabel.value = latestStatus.title;
}
// 更新进度条压力分
final latestHrvVal = latest?.value ?? average;
stressScore.value = null; // _computeStressScore(latestHrvVal);
_updateLatestHrvAnnotation(latest);
}
}
... ... @@ -340,15 +338,14 @@ class TodayController extends GetxController {
if (avgHrv.value == '--' && userHrv != null) {
avgHrv.value = _formatMetric(userHrv);
}
if (latestHrvValue.value == '--' && userHrv != null) {
latestHrvValue.value = _formatMetric(userHrv);
latestHrvTimeLabel.value = '';
}
if (userHrv != null) {
stressLabel.value = _hrvStatusTitle(
value: userHrv,
baseline: latestHrv.userHrvBaseline,
);
// 更新进度条压力分
stressScore.value = null;
//_computeStressScore(userHrv,baseline: latestHrv.userHrvBaseline,);
}
}
... ... @@ -484,11 +481,6 @@ class TodayController extends GetxController {
void _applyLatestTodayHrv(TodayHrvData? latest) {
final value = latest?.value;
if (value == null) return;
latestHrvValue.value = _formatMetric(value);
final time = latest?.time;
latestHrvTimeLabel.value =
time == null ? '' : _formatHour(_hourFromApiTime(time));
}
void _applySleepDuration(int? rawDuration) {
... ... @@ -568,6 +560,28 @@ class TodayController extends GetxController {
]);
}
/// 将 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;
... ...
... ... @@ -18,6 +18,7 @@ import '../../widgets/today/today_sleep_activity_cards.dart';
import '../../widgets/today/stress_status_explanation_bottom_sheet.dart';
import '../../widgets/today/premium_card.dart';
import '../../widgets/today/stress_progress_bar.dart';
class TodayTab extends GetView<TodayController> {
const TodayTab({super.key});
... ... @@ -99,6 +100,7 @@ class TodayTab extends GetView<TodayController> {
SliverToBoxAdapter(
child: Container(
height: 323 + pinnedHeaderHeight,
padding: EdgeInsets.only(bottom: 32),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
... ... @@ -117,7 +119,9 @@ class TodayTab extends GetView<TodayController> {
Obx(
() => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showStressStatusExplanationBottomSheet,
onTap: () {
showStressStatusExplanationBottomSheet(context);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
... ... @@ -152,6 +156,12 @@ class TodayTab extends GetView<TodayController> {
),
),
),
const SizedBox(height: 12),
Obx(
() => StressProgressBar(
score: controller.stressScore.value,
),
),
],
),
),
... ... @@ -456,12 +466,7 @@ class _LatestHrvCard extends GetView<TodayController> {
final colors = context.colors;
return Obx(() {
final value = controller.latestHrvValue.value;
final timeLabel = controller.latestHrvTimeLabel.value;
final hrv = _parseMetric(value);
final title = timeLabel.isEmpty ? '最近一次' : '最近一次($timeLabel)';
final statusLabel = hrv == null ? '暂无状态' : _statusLabel(hrv);
controller.avgHrv.value;
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
decoration: BoxDecoration(
... ... @@ -472,7 +477,7 @@ class _LatestHrvCard extends GetView<TodayController> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
'最近一次(17:20)',
style: TextStyle(
color: colors.textSecondary,
fontSize: 12,
... ... @@ -486,13 +491,13 @@ class _LatestHrvCard extends GetView<TodayController> {
width: 12,
height: 12,
decoration: BoxDecoration(
color: _statusColor(context, hrv),
color: context.colors.chartGreen,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'${hrv == null ? '--' : value}ms·$statusLabel',
'66ms·状态优秀',
style: TextStyle(
color: colors.textPrimary,
fontSize: 18,
... ... @@ -503,7 +508,7 @@ class _LatestHrvCard extends GetView<TodayController> {
),
const SizedBox(height: 8),
Text(
'暂无最近一次 HRV 数据。完成一次测量后,这里会展示你的最新状态。',
'你的 HRV 高于平时水平。你目前状态放松,压力控制良好——继续保持这样的平衡状态。',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
... ... @@ -517,29 +522,6 @@ class _LatestHrvCard extends GetView<TodayController> {
);
});
}
double? _parseMetric(String value) {
if (value == '--') return null;
return double.tryParse(value.replaceAll(',', ''));
}
String _statusLabel(double value) {
if (value > 60) return '状态优秀';
if (value > 50) return '状态良好';
if (value > 30) return '状态一般';
if (value > 20) return '状态较差';
return '状态极差';
}
Color _statusColor(BuildContext context, double? value) {
final colors = context.colors;
if (value == null) return colors.textTertiary;
if (value > 60) return colors.chartGreen;
if (value > 50) return colors.chartBlue;
if (value > 30) return colors.chartOrange;
if (value > 20) return colors.chartPink;
return colors.chartBlue;
}
}
class _TopDateBar extends StatelessWidget {
... ...
... ... @@ -5,12 +5,14 @@ import 'package:get/get.dart';
import 'today_faq_bottom_sheet.dart';
void showHrvPrincipleExplanationBottomSheet() {
void showHrvPrincipleExplanationBottomSheet(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final maxChildSize = (screenHeight - statusBarHeight) / screenHeight;
Get.bottomSheet(
DraggableScrollableSheet(
initialChildSize: 0.96,
minChildSize: 0.2,
maxChildSize: 0.96,
maxChildSize: maxChildSize,
initialChildSize: maxChildSize,
expand: false,
snap: true,
builder: (context, scrollController) {
... ... @@ -277,7 +279,7 @@ class _FaqLink extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showTodayFaqBottomSheet,
onTap: () => showTodayFaqBottomSheet(),
child: Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Text(
... ...
... ... @@ -229,52 +229,59 @@ class _AuthorizationCard extends StatelessWidget {
Widget build(BuildContext context) {
return _CardShell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(child: _IconPlaceholder()),
SizedBox(height: 10),
Text(
'错误 2:健康数据访问未授权',
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 22 / 16,
),
),
SizedBox(height: 6),
Text(
'DoubleFeel 需要访问 Apple Health 数据的权限,以提供压力统计、提醒和建议。如果未授权,部分功能可能无法正常使用。\n\n请放心,所有健康数据仅存储在本地,不会上传。\n\n要启用权限,请按提示操作,并在 iOS 设置中选择 允许所有 → 健康 → DoubleFeel。',
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 17 / 12,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Center(child: _IconPlaceholder()),
SizedBox(height: 10),
Text(
'错误 2:健康数据访问未授权',
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 22 / 16,
),
),
SizedBox(height: 6),
Text(
'DoubleFeel 需要访问 Apple Health 数据的权限,以提供压力统计、提醒和建议。如果未授权,部分功能可能无法正常使用。\n\n请放心,所有健康数据仅存储在本地,不会上传。\n\n要启用权限,请按提示操作,并在 iOS 设置中选择 允许所有 → 健康 → DoubleFeel。',
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 17 / 12,
),
),
],
),
SizedBox(height: 37),
Image.asset(
'assets/images/today/health_data_permission_step_1.png',
height: 140,
alignment: Alignment(0, 0.12),
'assets/images/permission/apple_privacy_cn.png',
height: 154,
width: 303,
),
SizedBox(
height: 10,
),
SizedBox(height: 20),
Image.asset(
'assets/images/today/health_data_permission_step_2.png',
'assets/images/permission/apple_health_item_cn.png',
height: 172,
alignment: Alignment(0, 0.24),
width: 303,
),
SizedBox(height: 20),
Image.asset(
'assets/images/today/health_data_permission_step_3.png',
'assets/images/permission/apple_double_feel_cn.png',
height: 210,
alignment: Alignment(0, -0.13),
width: 303,
),
SizedBox(height: 20),
Image.asset(
'assets/images/today/health_data_permission_step_4.png',
'assets/images/permission/apple_health_step_cn.png',
height: 167,
alignment: Alignment(0, -0.54),
width: 303,
),
],
),
... ...
... ... @@ -5,12 +5,14 @@ import 'package:get/get.dart';
import 'today_faq_bottom_sheet.dart';
void showRealtimeStressExplanationBottomSheet() {
void showRealtimeStressExplanationBottomSheet(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final maxChildSize = (screenHeight - statusBarHeight) / screenHeight;
Get.bottomSheet(
DraggableScrollableSheet(
initialChildSize: 0.96,
minChildSize: 0.2,
maxChildSize: 0.96,
maxChildSize: maxChildSize,
initialChildSize: maxChildSize,
expand: false,
snap: true,
builder: (context, scrollController) {
... ... @@ -309,7 +311,7 @@ class _FaqLink extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showTodayFaqBottomSheet,
onTap: () => showTodayFaqBottomSheet(),
child: Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Text(
... ...
import 'package:flutter/material.dart';
/// 压力状态进度条(四段式)
///
/// 对应 Figma node 1361-44236(轨道)和 1361-44489(indicator)。
///
/// 传入 [score](0~100 压力分):
/// - null → 加载态:四段等宽 20px,无 indicator
/// - 0~20 → 优秀·绿段(status 3)
/// - 21~50 → 正常·蓝段(status 2)
/// - 51~80 → 注意压力·橙段(status 1)
/// - 81~100 → 压力过载·红段(status 0)
///
/// indicator 在命中段内的横向位置由分数在该区间的位置决定:
/// 分数越低(越优秀)→ 指针越靠右;分数越高(越差)→ 越靠左。
///
/// 段间距 4px,轨道高 14px,indicator 24×24px(6px stroke)。
class StressProgressBar extends StatelessWidget {
const StressProgressBar({super.key, required this.score})
: assert(score == null || (score >= 0 && score <= 100));
/// null = 加载中;0~100 = 压力分
final int? score;
// ── Figma 图表色/1-4(index 0~3 对应 过载→活力)────────
static const _colors = [
Color(0xFFFF5279), // 0 压力过载·红
Color(0xFFFF9A6E), // 1 注意压力·橙
Color(0xFF7B9BFB), // 2 状态正常·蓝
Color(0xFF3BD49D), // 3 活力满满·绿
];
// ── 尺寸常量(来自 Figma)────────────────────────────
static const _defaultW = 20.0;
static const _activeW = 148.0;
static const _trackH = 14.0;
static const _gap = 4.0;
static const _dotSize = 24.0;
static const _dotBorder = 6.0;
// ── 动画参数 ──────────────────────────────────────────
static const _dur = Duration(milliseconds: 400);
static const _curve = Curves.easeInOut;
// ── 分数区间边界(左闭右闭)───────────────────────────
// status 3 (绿): 0~20 status 2 (蓝): 21~50
// status 1 (橙): 51~80 status 0 (红): 81~100
static const _ranges = [
(lo: 81, hi: 100), // status 0
(lo: 51, hi: 80), // status 1
(lo: 21, hi: 50), // status 2
(lo: 0, hi: 20), // status 3
];
/// 由压力分计算命中段(0=过载, 1=注意, 2=正常, 3=活力)
static int _statusFromScore(int s) {
if (s <= 20) return 3;
if (s <= 50) return 2;
if (s <= 80) return 1;
return 0;
}
/// 在命中段内的归一化位置(0.0=最左/最差, 1.0=最右/最优)
///
/// 分数越低越优秀 → 越靠右,所以 progress = (hi - score) / (hi - lo)
static double _segProgress(int s) {
final st = _statusFromScore(s);
final r = _ranges[st];
if (r.hi == r.lo) return 0.5;
return ((r.hi - s) / (r.hi - r.lo)).clamp(0.0, 1.0);
}
// ── 布局计算 ──────────────────────────────────────────
int? get _status => score != null ? _statusFromScore(score!) : null;
/// 第 [i] 段的当前宽度
double _w(int i) => (_status == i) ? _activeW : _defaultW;
/// 总宽度(含三个间距)
double get _totalW => _w(0) + _w(1) + _w(2) + _w(3) + 3 * _gap;
/// indicator 左边缘 X(在命中段内按 segProgress 定位)
double _dotLeft(int s) {
final prog = score != null ? _segProgress(score!) : 0.5;
double x = 0;
for (int i = 0; i < s; i++) {
x += _w(i) + _gap;
}
// 在命中段宽内按 progress 定位,并保证圆点不超出段的边缘
x += (_w(s) * prog).clamp(_dotSize / 2, _w(s) - _dotSize / 2);
return x - _dotSize / 2;
}
@override
Widget build(BuildContext context) {
final s = _status;
return AnimatedContainer(
duration: _dur,
curve: _curve,
width: _totalW,
height: _dotSize,
child: Stack(
clipBehavior: Clip.none,
children: [
// ── 轨道:四段色块 ─────────────────────────────
Positioned(
top: (_dotSize - _trackH) / 2,
left: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < 4; i++) ...[
AnimatedContainer(
duration: _dur,
curve: _curve,
width: _w(i),
height: _trackH,
decoration: BoxDecoration(
color: _colors[i],
borderRadius: BorderRadius.circular(_trackH / 2),
),
),
if (i < 3) const SizedBox(width: _gap),
],
],
),
),
// ── Indicator:白底 + 状态色描边 ────────────────
AnimatedPositioned(
duration: _dur,
curve: _curve,
top: 0,
left: s != null ? _dotLeft(s) : 0,
child: AnimatedOpacity(
opacity: s != null ? 1.0 : 0.0,
duration: _dur,
curve: _curve,
child: Container(
width: _dotSize,
height: _dotSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: _colors[(s ?? 0).clamp(0, 3)],
width: _dotBorder,
),
),
),
),
),
],
),
);
}
}
... ...
... ... @@ -5,12 +5,14 @@ import 'package:get/get.dart';
import 'today_faq_bottom_sheet.dart';
void showStressStatusExplanationBottomSheet() {
void showStressStatusExplanationBottomSheet(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final maxChildSize = (screenHeight - statusBarHeight) / screenHeight;
Get.bottomSheet(
DraggableScrollableSheet(
initialChildSize: 0.96,
minChildSize: 0.2,
maxChildSize: 0.96,
maxChildSize: maxChildSize,
initialChildSize: maxChildSize,
expand: false,
snap: true,
builder: (context, scrollController) {
... ... @@ -43,6 +45,9 @@ class StressStatusExplanationBottomSheet extends StatelessWidget {
color: Color(0xFFF5F2FF),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
),
child: SafeArea(
top: false,
child: Column(
... ... @@ -307,7 +312,7 @@ class _FaqLink extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showTodayFaqBottomSheet,
onTap: () => showTodayFaqBottomSheet(),
child: Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Text(
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
/// Figma: 横向 7 日日期条,当前选中日高亮品牌色圆角背景
/// 支持左右滑动切换周次(PageView)
class TodayDateStrip extends GetView<TodayController> {
const TodayDateStrip({super.key});
static const _brandColor = Color(0xFF845EEE);
static const _h1Color = Color(0xFF0F0F11);
static const _h5Color = Color(0xFFCCCCCC);
/// 获取以本周一为起点的 7 天列表
List<DateTime> _getWeekDays(DateTime base) {
final monday = base.subtract(Duration(days: base.weekday - 1));
return List.generate(7, (i) => monday.add(Duration(days: i)));
}
String _weekdayLabel(int weekday) {
const labels = ['一', '二', '三', '四', '五', '六', '日'];
return labels[weekday - 1];
}
@override
Widget build(BuildContext context) {
return Obx(() {
final selected = controller.selectedDate.value;
final today = DateTime.now();
final days = _getWeekDays(selected);
return Container(
height: 72,
decoration: BoxDecoration(
color: _brandColor,
borderRadius: BorderRadius.circular(22),
),
child: Row(
children: days.map((day) {
final isSelected = _isSameDay(day, selected);
final isToday = _isSameDay(day, today);
final isFuture = day.isAfter(today);
return Expanded(
child: GestureDetector(
onTap: isFuture ? null : () => controller.changeDate(day),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 周几
Text(
_weekdayLabel(day.weekday),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: isFuture
? _h5Color
: (isSelected ? _h1Color : Colors.white70),
),
),
const SizedBox(height: 4),
// 日期数字
AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.center,
child: Text(
'${day.day}',
style: TextStyle(
fontSize: 15,
fontWeight: isSelected || isToday
? FontWeight.w600
: FontWeight.w400,
color: isSelected
? _brandColor
: isFuture
? _h5Color
: Colors.white,
),
),
),
],
),
),
);
}).toList(),
),
);
});
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
}
... ... @@ -4,11 +4,15 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
void showTodayFaqBottomSheet() {
final view = WidgetsBinding.instance.platformDispatcher.views.first;
final devicePixelRatio = view.devicePixelRatio;
final screenHeight = view.physicalSize.height / devicePixelRatio;
final statusBarHeight = view.padding.top / devicePixelRatio;
final maxChildSize = (screenHeight - statusBarHeight) / screenHeight;
Get.bottomSheet(
DraggableScrollableSheet(
initialChildSize: 0.96,
minChildSize: 0.2,
maxChildSize: 0.96,
maxChildSize: maxChildSize,
initialChildSize: maxChildSize,
expand: false,
snap: true,
builder: (context, scrollController) {
... ...
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/l10n/l10n_extensions.dart';
... ... @@ -65,7 +66,8 @@ class TodayHrvChartCard extends GetView<TodayController> {
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showRealtimeStressExplanationBottomSheet,
onTap: () =>
showRealtimeStressExplanationBottomSheet(context),
child: Image.asset(
'assets/images/common/ic_pro_badge.webp',
width: 43,
... ... @@ -77,7 +79,8 @@ class TodayHrvChartCard extends GetView<TodayController> {
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showRealtimeStressExplanationBottomSheet,
onTap: () =>
showRealtimeStressExplanationBottomSheet(context),
child: Image.asset(
'assets/images/common/ic_info.png',
width: 14,
... ... @@ -91,7 +94,7 @@ class TodayHrvChartCard extends GetView<TodayController> {
),
),
SizedBox(
height: 174,
height: 190,
child: stressPoints.isEmpty
? const _EmptyChart(text: '暂无压力数据')
: _buildStressChart(
... ... @@ -117,7 +120,7 @@ class TodayHrvChartCard extends GetView<TodayController> {
const SizedBox(width: 4),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showHrvPrincipleExplanationBottomSheet,
onTap: () => showHrvPrincipleExplanationBottomSheet(context),
child: Image.asset(
'assets/images/common/ic_info.png',
width: 14,
... ... @@ -253,130 +256,218 @@ class TodayHrvChartCard extends GetView<TodayController> {
Widget _buildStressChart(
BuildContext context, List<HrvDataPoint> stressPoints, bool isVip) {
void onTapPurchase() => Get.toNamed(Routes.PURCHASE);
final spanWidth = 74.0;
return Stack(
children: [
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),
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),
),
),
),
],
);
},
),
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)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (val, _) {
if (val < 0 || val > 100) {
return const SizedBox.shrink();
}
return Text(
'${val.toInt()}',
style: TextStyle(
fontSize: 10, color: context.colors.textTertiary),
);
},
gridData: FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
leftTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 60,
getTitlesWidget: (val, meta) {
final label = _timeLabel(val / 10);
if (label == null) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(
label,
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),
),
);
},
),
),
),
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,
);
},
),
),
),
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)],
),
),
),
),
Container(
width: 74,
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,
),
),
if (isVip == false)
Image.asset(
'assets/images/today/bg_unlock_instant_stress_blur.png',
width: double.infinity,
height: 174,
fit: BoxFit.cover,
)
if (isVip == false) ...[
GestureDetector(
onTap: onTapPurchase,
child: Image.asset(
'assets/images/today/bg_unlock_instant_stress_blur.png',
width: double.infinity,
height: 174,
fit: BoxFit.cover,
),
),
Positioned(
top: 97,
left: 0,
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/common/ic_lock.png',
width: 12,
height: 12,
color: context.colors.textTertiary,
),
Text(
'会员可查看完整数据',
style: TextStyle(
color: context.colors.textTertiary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
),
GestureDetector(
onTap: onTapPurchase,
child: Container(
constraints: const BoxConstraints(
minWidth: 120,
),
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: ShapeDecoration(
color: const Color(0xFFFFDF50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: const Text(
'立即解锁',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
)
]),
),
]
],
);
}
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -51,9 +52,6 @@ class _NumberItem extends StatelessWidget {
required this.unit,
});
static const _h1 = Color(0xFF0F0F11);
static const _h2 = Color(0xFF666666);
@override
Widget build(BuildContext context) {
return Padding(
... ... @@ -63,9 +61,9 @@ class _NumberItem extends StatelessWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: _h2,
color: context.colors.textTertiary,
fontWeight: FontWeight.w400,
),
),
... ... @@ -75,10 +73,10 @@ class _NumberItem extends StatelessWidget {
children: [
Text(
value,
style: const TextStyle(
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: _h1,
color: context.colors.textPrimary,
height: 1,
),
),
... ... @@ -87,9 +85,9 @@ class _NumberItem extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 3),
child: Text(
unit,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: _h1,
color: context.colors.textPrimary,
fontWeight: FontWeight.w400,
),
),
... ...
import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -46,6 +47,26 @@ class TodaySleepCard extends GetView<TodayController> {
],
),
],
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),
],
gradientColors: [
[context.colors.primary],
[context.colors.chartBlue],
],
centerCircleSizeRatio: 0.3,
gapRatio: 0.1,
interpolatedColorRatio: 1),
),
),
);
}
... ... @@ -108,6 +129,48 @@ class TodayActivityCard extends GetView<TodayController> {
],
),
],
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,
],
backgroundColors: [
context.colors.chartPink.withOpacity(0.4),
context.colors.chartGreen.withOpacity(0.3),
context.colors.chartBlue.withOpacity(0.4),
],
gradientColors: [
[
context.colors.chartPink,
],
[
context.colors.chartGreen,
],
[
context.colors.chartBlue,
],
],
),
],
),
),
),
);
}
... ... @@ -122,6 +185,7 @@ class _TodaySummaryCard extends StatelessWidget {
required this.metrics,
required this.progressValues,
required this.progressColors,
required this.rightWidget,
});
final String iconAsset;
... ... @@ -131,6 +195,7 @@ class _TodaySummaryCard extends StatelessWidget {
final List<_MetricData> metrics;
final List<double> progressValues;
final List<Color> progressColors;
final Widget rightWidget;
@override
Widget build(BuildContext context) {
... ... @@ -194,13 +259,7 @@ class _TodaySummaryCard extends StatelessWidget {
],
),
),
const SizedBox(width: 4),
Container(
width: 50,
height: 50,
color: Colors.red,
),
const SizedBox(width: 6),
rightWidget
],
),
],
... ...
... ... @@ -7,7 +7,6 @@ import 'package:get/get.dart';
import '../controllers/user_onboarding_controller.dart';
import '../models/onboarding_option_data.dart';
import '../widget/guide_common_scaffold.dart';
import '../widget/onboarding_common_widgets.dart';
import '../widget/onboarding_illustrations.dart';
import '../widget/onboarding_em_rich_text.dart';
... ...
import 'package:flutter/material.dart';
import 'widget.dart';
///CircularGradientCombineWidget multi CircularGradientProgressWidget
class CircularGradientCombineWidget extends StatefulWidget {
///widget size
final double size;
///list of each sweep angle
final List<double> sweepAngles;
///List<Color> of each circular
final List<List<Color>> gradientColors;
///Background Color of each circular
final List<Color> backgroundColors;
///empty center circle size ratio by size
final double centerCircleSizeRatio;
///each gap of circular ratio by size
final double gapRatio;
/// default begin angle
final double initAngle;
///open animate
final bool animate;
/// If true, a smoother intermediate color will be automatically inserted between the two gradient colors.
final bool interpolatedColor;
/// Set the percentage of the gradient color based on the two colors when inserting the intermediate gradient color
final double interpolatedColorRatio;
/// When the change reaches a certain percentage, the color will be the same as the last color,
//so it needs to be limited, otherwise a circular_gradient_progress with the same color will appear instead of a gradient color.
final double maxInterpolatedColorRatio;
/// base duration
final Duration duration;
/// This value represents how much animation time needs to be increased for each 360 degrees after exceeding 360 degrees.
final Duration plusDuration;
/// max duration
final Duration maxDuration;
/// animation behavier
final Curve curve;
/// if true,be reverse
final bool reverse;
///
const CircularGradientCombineWidget({
super.key,
required this.size,
required this.sweepAngles,
required this.backgroundColors,
required this.gradientColors,
this.animate = true,
this.centerCircleSizeRatio = 0.15,
this.gapRatio = 0.05,
this.interpolatedColor = true,
this.interpolatedColorRatio = 0.1,
this.maxInterpolatedColorRatio = 0.7,
this.duration = const Duration(milliseconds: 1500),
this.plusDuration = const Duration(milliseconds: 500),
this.curve = Curves.easeInOutQuad,
this.initAngle = 0,
this.reverse = false,
this.maxDuration = Duration.zero,
});
@override
State<CircularGradientCombineWidget> createState() => _CircularGradientCombineWidgetState();
}
class _CircularGradientCombineWidgetState extends State<CircularGradientCombineWidget> with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final centerCircleSize = widget.size * widget.centerCircleSizeRatio;
final gap = widget.size * widget.gapRatio;
var strokeWidth = (widget.size - (widget.sweepAngles.length - 1) * gap - centerCircleSize) / widget.sweepAngles.length;
if (strokeWidth > widget.size / 3) {
strokeWidth = widget.size / 3;
}
return Stack(
alignment: Alignment.center,
children: List.generate(widget.sweepAngles.length, (index) {
return CircularGradientProgressWidget(
animate: widget.animate,
interpolatedColorRatio: widget.interpolatedColorRatio,
maxInterpolatedColorRatio: widget.maxInterpolatedColorRatio,
duration: widget.duration,
plusDuration: widget.plusDuration,
curve: widget.curve,
initAngle: widget.initAngle,
interpolatedColor: widget.interpolatedColor,
gradientColors: index >= widget.gradientColors.length ? widget.gradientColors.last : widget.gradientColors[index],
sweepAngle: index >= widget.sweepAngles.length ? widget.sweepAngles.last : widget.sweepAngles[index],
size: widget.size - strokeWidth * index - gap * index,
strokeWidth: strokeWidth / 2,
backgroundColor: index > widget.backgroundColors.length - 1 ? widget.backgroundColors.last : widget.backgroundColors[index],
reverse: widget.reverse,
maxDuration: widget.maxDuration,
);
}),
);
}
}
... ...
import 'dart:math' show cos, min, pi, sin;
import 'package:flutter/material.dart';
/// convert
extension NumToRadians on num {
/// toRadians
double toRadians() {
return toDouble() * (pi / 180.0);
}
/// toDegree
double toDegree() {
return toDouble() * (180 / pi);
}
}
/// painter for progress
class ProgressPainter extends CustomPainter {
///0 to max
final double sweepAngle;
/// circle stroke width
final double strokeWidth;
/// multi color
final List<List<Color>> progressColor;
/// base background color
final Color backgroundColor;
/// reverse
final bool reverse;
/// painter for progress
ProgressPainter({
this.sweepAngle = 360,
this.strokeWidth = 20,
this.reverse = false,
required this.progressColor,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
double wh = min(size.width, size.height);
var centerX = wh / 2;
var centerY = wh / 2;
var radius = wh / 2 - strokeWidth / 2;
double startAngle = 0;
var rect = Rect.fromCenter(
center: Offset(centerX, centerY),
width: wh - strokeWidth,
height: wh - strokeWidth,
);
int colorRangeIndex = (sweepAngle / 360).ceil() - 1;
if (colorRangeIndex > progressColor.length - 1) {
colorRangeIndex = progressColor.length - 1;
}
if (colorRangeIndex < 0) {
colorRangeIndex = 0;
}
var shader = SweepGradient(
startAngle: startAngle.toRadians(),
endAngle: 360.toRadians(),
colors: reverse ? progressColor[colorRangeIndex].reversed.toList() : progressColor[colorRangeIndex],
).createShader(rect);
var paint = Paint()
..strokeWidth = strokeWidth
..shader = shader
..strokeCap = StrokeCap.butt
..style = PaintingStyle.stroke;
var backgroundPaint = Paint()
..strokeWidth = strokeWidth
..color = backgroundColor
..strokeCap = StrokeCap.butt
..style = PaintingStyle.stroke;
var startCapPosition = Offset(
centerX + radius * cos(startAngle.toRadians()),
centerY + radius * sin(startAngle.toRadians()),
);
canvas.drawArc(
rect,
0.toRadians(),
360.toRadians(),
false,
backgroundPaint,
);
if (sweepAngle >= 0) {
canvas.save();
// Move the canvas origin to the center
canvas.translate(centerX, centerY);
double rotationAngle = 0;
if (sweepAngle > 360) {
rotationAngle = 360 - sweepAngle;
}
double rotationRadians = reverse ? -rotationAngle.toRadians() : rotationAngle.toRadians();
// Rotate the canvas
canvas.rotate((-90).toRadians() - rotationRadians);
// Move the canvas origin back
canvas.translate(-centerX, -centerY);
if (sweepAngle < 360) {
canvas.drawArc(
Rect.fromCenter(
center: startCapPosition,
width: strokeWidth,
height: strokeWidth,
),
0,
reverse ? 180.toRadians() : -180.toRadians(),
true,
Paint()..color = progressColor[0].first,
);
}
canvas.drawArc(
rect,
0.toRadians(),
reverse ? -(startAngle + sweepAngle).toRadians() : (startAngle + sweepAngle).toRadians(),
false,
paint,
);
if (sweepAngle < 360) {
canvas.drawRect(
Rect.fromCenter(
center: startCapPosition,
width: strokeWidth,
height: 1,
),
Paint()..color = progressColor[0].first,
);
}
canvas.translate(centerX, centerY);
if (sweepAngle < 360) {
final endCapRotate = 360 - sweepAngle;
canvas.rotate(reverse ? endCapRotate.toRadians() : -endCapRotate.toRadians());
}
//0.1 is because the angle calculation is not accurate enough, so we need to go back a little bit.
var endCapPosition = Offset(
centerX + radius * cos((startAngle).toRadians()),
centerY + radius * sin((startAngle).toRadians()),
);
canvas.translate(-centerX, -centerY);
final endColor = getColorAtAngle(sweepAngle, progressColor[colorRangeIndex]);
if (sweepAngle > 355) {
//Draw arc shadow
var shadowPosition = Offset(
centerX + radius * cos((startAngle).toRadians()),
centerY + (reverse ? -5 : 5) + radius * sin((startAngle).toRadians()),
);
final Paint shadowPaint = Paint()
..color = Colors.black.withOpacity(0.2)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth / 2
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
final Path path = Path()
..addArc(
Rect.fromCenter(
center: shadowPosition,
width: strokeWidth / 2,
height: strokeWidth / 2,
),
0,
reverse ? -pi : pi,
);
canvas.drawPath(path, shadowPaint);
}
//Draw the ending arc
canvas.drawArc(
Rect.fromCenter(
center: endCapPosition,
width: strokeWidth,
height: strokeWidth,
),
0,
reverse ? -180.toRadians() : 180.toRadians(),
true,
Paint()..color = endColor,
);
canvas.drawRect(
Rect.fromCenter(
center: endCapPosition,
width: strokeWidth,
height: 1,
),
Paint()..color = endColor,
);
// // Restore the canvas to the saved state
canvas.restore();
}
}
@override
bool shouldRepaint(covariant ProgressPainter oldDelegate) {
return sweepAngle != oldDelegate.sweepAngle;
}
/// get color by and angle
Color getColorAtAngle(double angle, List<Color> colors) {
double totalAngle = sweepAngle > 360 ? sweepAngle : 360;
double fraction = angle / totalAngle;
int segments = colors.length - 1;
double segmentFraction = fraction * segments;
int segmentIndex = segmentFraction.floor();
double segmentInnerFraction = segmentFraction - segmentIndex;
Color startColor = colors[segmentIndex];
Color endColor = colors[(segmentIndex + 1) % colors.length];
return Color.lerp(startColor, endColor, segmentInnerFraction)!;
}
}
... ...
import 'dart:math';
import 'package:flutter/material.dart';
import 'paint.dart';
/// CircularGradientProgressWidget
class CircularGradientProgressWidget extends StatefulWidget {
///widget size
final double size;
///circle size
final double strokeWidth;
///need > 0 ,< 0 means no data
final double sweepAngle;
/// default begin angle
final double initAngle;
///The colors need to go from light to dark, otherwise the effect is weird.
///Receive multiple color arrays so that more colors are needed when reaching a certain angle.
final List<Color> gradientColors;
/// color of when angle is zero
final Color backgroundColor;
///open animate
final bool animate;
/// If true, a smoother intermediate color will be automatically inserted between the two gradient colors.
final bool interpolatedColor;
/// Set the percentage of the gradient color based on the two colors when inserting the intermediate gradient color
final double interpolatedColorRatio;
/// When the change reaches a certain percentage, the color will be the same as the last color,
//so it needs to be limited, otherwise a circular_gradient_progress with the same color will appear instead of a gradient color.
final double maxInterpolatedColorRatio;
/// base duration
final Duration duration;
/// This value represents how much animation time needs to be increased for each 360 degrees after exceeding 360 degrees.
final Duration plusDuration;
/// max duration
final Duration maxDuration;
/// animation behavier
final Curve curve;
/// if true,be reverse
final bool reverse;
///
const CircularGradientProgressWidget({
super.key,
required this.gradientColors,
required this.backgroundColor,
required this.size,
this.sweepAngle = 0,
required this.strokeWidth,
this.animate = false,
this.interpolatedColor = true,
this.interpolatedColorRatio = 0.1,
this.maxInterpolatedColorRatio = 0.7,
this.duration = const Duration(milliseconds: 1500),
this.plusDuration = const Duration(milliseconds: 500),
this.curve = Curves.easeInOutQuad,
this.initAngle = 0,
this.reverse = false,
this.maxDuration = Duration.zero,
});
@override
State<CircularGradientProgressWidget> createState() => _CircularGradientProgressWidgetState();
}
class _CircularGradientProgressWidgetState extends State<CircularGradientProgressWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double sweepAngle = 0;
double initAngle = 0;
@override
void initState() {
super.initState();
initAngle = widget.initAngle;
if (widget.sweepAngle < 0) {
initAngle = -1;
}
_controller = AnimationController(
vsync: this,
duration: widget.duration,
);
_updateAnimation(widget.sweepAngle, initAngle);
}
@override
void didUpdateWidget(CircularGradientProgressWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.sweepAngle != oldWidget.sweepAngle) {
_updateAnimation(widget.sweepAngle, sweepAngle);
}
}
void _updateAnimation(double newSweepAngle, double oldSweepAngle) {
if (widget.sweepAngle < 0 || newSweepAngle < 0) {
setState(() {
sweepAngle = -1;
});
return;
}
final double angleDifference = (newSweepAngle - oldSweepAngle).abs();
int baseDurationMillis = widget.duration.inMilliseconds;
int totalDuration = 0;
if (angleDifference <= 360 && oldSweepAngle != 0) {
totalDuration = ((angleDifference / 360) * baseDurationMillis).toInt();
} else {
totalDuration = baseDurationMillis;
int full360Intervals = (angleDifference / 360).floor();
totalDuration += full360Intervals * widget.plusDuration.inMilliseconds;
}
if (widget.animate) {
if (widget.maxDuration.inMilliseconds != 0 && totalDuration > widget.maxDuration.inMilliseconds) {
_controller.duration = Duration(milliseconds: widget.maxDuration.inMilliseconds);
} else {
_controller.duration = Duration(milliseconds: totalDuration);
}
} else {
_controller.duration = Duration.zero;
}
_animation = Tween<double>(begin: oldSweepAngle, end: newSweepAngle).animate(
CurvedAnimation(
parent: _controller,
curve: widget.curve,
),
)..addListener(() {
setState(() {
sweepAngle = _animation.value;
});
});
_controller.forward(from: 0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<List<Color>> generateColorArray(List<Color> colorList, double totalAngle) {
final baseColorStart = colorList[0];
final baseColorEnd = colorList.length > 1 ? colorList[1] : colorList[0];
List<List<Color>> colors = [];
int fullRotations = (totalAngle / 360).floor();
colors.add([baseColorStart, baseColorEnd]);
for (int i = 1; i <= fullRotations; i++) {
double t = min(i * widget.interpolatedColorRatio, widget.maxInterpolatedColorRatio);
colors.add([Color.lerp(baseColorStart, baseColorEnd, t)!, baseColorEnd]);
}
return colors;
}
@override
Widget build(BuildContext context) {
final progressColors = generateColorArray(widget.gradientColors, widget.interpolatedColor ? sweepAngle : 0);
return CustomPaint(
painter: ProgressPainter(
sweepAngle: sweepAngle,
progressColor: progressColors,
strokeWidth: widget.strokeWidth,
backgroundColor: widget.backgroundColor,
reverse: widget.reverse,
),
size: Size(
widget.size,
widget.size,
),
);
}
}
... ...
... ... @@ -61,9 +61,9 @@
"onboardingResearchEnergy": "元气满满",
"onboardingResearchHrvDown": "HRV下降",
"onboardingResearchHrvUp": "HRV上升",
"onboardingHealthPermissionTitle": "允许访问健康数据",
"onboardingHealthPermissionTitle": "开启健康授权提醒、统计你的压力",
"onboardingHealthPermissionBody": "DoubleFeel需要连接健康穿戴设备数据,以提醒、统计压力时刻、提供建议。",
"onboardingHealthPermissionPrivacy": "请放心,你的健康数据只会存储在本地,我们不上传任何相关数据。",
"onboardingHealthPermissionPrivacy": "请放心,你的健康数据仅储存在本地,我们不上传任何相关数据。",
"onboardingNotificationTitle": "开启通知",
"onboardingNotificationSubtitle": "及时了解身体每一次异动",
"onboardingNotificationBody": "AppleWatch数据更新后会及时提醒你,帮助你及时行动,改善压力状态",
... ...
... ... @@ -446,7 +446,7 @@ abstract class AppLocalizations {
/// No description provided for @onboardingHealthPermissionTitle.
///
/// In zh, this message translates to:
/// **'允许访问健康数据'**
/// **'开启健康授权提醒、统计你的压力'**
String get onboardingHealthPermissionTitle;
/// No description provided for @onboardingHealthPermissionBody.
... ... @@ -458,7 +458,7 @@ abstract class AppLocalizations {
/// No description provided for @onboardingHealthPermissionPrivacy.
///
/// In zh, this message translates to:
/// **'请放心,你的健康数据只会存储在本地,我们不上传任何相关数据。'**
/// **'请放心,你的健康数据仅储存在本地,我们不上传任何相关数据。'**
String get onboardingHealthPermissionPrivacy;
/// No description provided for @onboardingNotificationTitle.
... ...
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
... ... @@ -70,7 +72,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get onboardingIntroTitle => 'DoubleFeel 是专为 Apple Watch 打造的健康陪伴app';
@override
String get onboardingIntroBody => '我们希望可以帮助你\n<em>关注自己的身心变化,也让爱你的人</em>及时发现你的<em>疲惫与需要</em>';
String get onboardingIntroBody =>
'我们希望可以帮助你\n<em>关注自己的身心变化,也让爱你的人</em>及时发现你的<em>疲惫与需要</em>';
@override
String get onboardingStateQuestion => '请问以下哪些描述,经常发生在你身上?';
... ... @@ -160,7 +163,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get onboardingHrvSubtitle => '它能帮助我们衡量整体的压力和健康状态';
@override
String get onboardingHrvDescription => '心率变异性(HRV, Heart Rate Variability)即心跳之间间隔时间的微小变化,反映了自主神经系统活动和身体对压力的反应能力';
String get onboardingHrvDescription =>
'心率变异性(HRV, Heart Rate Variability)即心跳之间间隔时间的微小变化,反映了自主神经系统活动和身体对压力的反应能力';
@override
String get onboardingTellMeMore => '展开说说';
... ... @@ -181,13 +185,15 @@ class AppLocalizationsZh extends AppLocalizations {
String get onboardingResearchHrvUp => 'HRV上升';
@override
String get onboardingHealthPermissionTitle => '允许访问健康数据';
String get onboardingHealthPermissionTitle => '开启健康授权提醒、统计你的压力';
@override
String get onboardingHealthPermissionBody => 'DoubleFeel需要连接健康穿戴设备数据,以提醒、统计压力时刻、提供建议。';
String get onboardingHealthPermissionBody =>
'DoubleFeel需要连接健康穿戴设备数据,以提醒、统计压力时刻、提供建议。';
@override
String get onboardingHealthPermissionPrivacy => '请放心,你的健康数据只会存储在本地,我们不上传任何相关数据。';
String get onboardingHealthPermissionPrivacy =>
'请放心,你的健康数据仅储存在本地,我们不上传任何相关数据。';
@override
String get onboardingNotificationTitle => '开启通知';
... ... @@ -196,7 +202,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get onboardingNotificationSubtitle => '及时了解身体每一次异动';
@override
String get onboardingNotificationBody => 'AppleWatch数据更新后会及时提醒你,帮助你及时行动,改善压力状态';
String get onboardingNotificationBody =>
'AppleWatch数据更新后会及时提醒你,帮助你及时行动,改善压力状态';
@override
String get onboardingMemberTitle => '获得年度会员优惠';
... ... @@ -220,7 +227,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get healthCompanionIsNowAvailable => '健康陪伴已开启';
@override
String get youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired => '你们现在可以查看彼此的 HRV、压力与睡眠变化,并在对方疲惫时及时送上关心。';
String
get youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired =>
'你们现在可以查看彼此的 HRV、压力与睡眠变化,并在对方疲惫时及时送上关心。';
@override
String get bindPartnerTitle => '添加亲密联系人\n多一个人关注你的健康';
... ... @@ -333,7 +342,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayHealthDataAuthTitle => '无法获取心率健康数据';
@override
String get todayHealthDataAuthDescription => 'DoubleFeel 需要授权访问你的健康数据,才能提供压力提醒、实时压力统计和健康建议;否则应用功能可能无法正常使用。请放心,你的健康数据仅存储在本地,不会上传到任何服务器。';
String get todayHealthDataAuthDescription =>
'DoubleFeel 需要授权访问你的健康数据,才能提供压力提醒、实时压力统计和健康建议;否则应用功能可能无法正常使用。请放心,你的健康数据仅存储在本地,不会上传到任何服务器。';
@override
String get todayHealthDataAuthAction => '授权访问健康数据';
... ... @@ -360,7 +370,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayFaqLinkWatchNoStatusNotification => '手表为什么无法收到状态通知?';
@override
String get todayFaqLinkWatchNoStatusAndInteractionNotification => '手表为什么无法收到状态和互动通知?';
String get todayFaqLinkWatchNoStatusAndInteractionNotification =>
'手表为什么无法收到状态和互动通知?';
@override
String get todayFaqLinkWatchFaceDataDelay => '手表表盘数据不更新或者有延迟?';
... ... @@ -393,22 +404,27 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayStressStatusInsufficientData => '数据不足';
@override
String get todayStressStatusOverloadDescription => '当前 HRV 明显低于你的长期平均水平,可能意味着身体疲劳、压力过高或恢复不足。建议及时休息。';
String get todayStressStatusOverloadDescription =>
'当前 HRV 明显低于你的长期平均水平,可能意味着身体疲劳、压力过高或恢复不足。建议及时休息。';
@override
String get todayStressStatusCautionDescription => '当前 HRV 低于正常范围,身体可能正在积累压力,需要注意作息与恢复。';
String get todayStressStatusCautionDescription =>
'当前 HRV 低于正常范围,身体可能正在积累压力,需要注意作息与恢复。';
@override
String get todayStressStatusNormalDescription => '当前身体状态处于你的正常波动范围内。';
@override
String get todayStressStatusExcellentDescription => '当前 HRV 高于近期平均水平,代表身体恢复与整体状态较好。';
String get todayStressStatusExcellentDescription =>
'当前 HRV 高于近期平均水平,代表身体恢复与整体状态较好。';
@override
String get todayStressStatusInsufficientDataDescription => '当前可用数据不足,暂时无法准确判断压力状态。';
String get todayStressStatusInsufficientDataDescription =>
'当前可用数据不足,暂时无法准确判断压力状态。';
@override
String get todayHrvMeasurementIntro => 'AppleWatch默认每2-5小时测量一次HRV,如果你希望立即手动进行测量,可以参考以下方法:';
String get todayHrvMeasurementIntro =>
'AppleWatch默认每2-5小时测量一次HRV,如果你希望立即手动进行测量,可以参考以下方法:';
@override
String get todayHrvMeasurementStep1 => '1、戴紧AppleWatch,坐下来,保持心境平和';
... ... @@ -426,7 +442,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayHrvMeasurementStep5 => '5、等待一分钟左右,StressWatch会收到你的数据并展示';
@override
String get todayHrvMeasurementHint => '提示:数据源来自AppleWatch,在测量之后可能存在延迟或是数据未能同步的情况。如若出现上述情况,请重新测量并等待数据读取。';
String get todayHrvMeasurementHint =>
'提示:数据源来自AppleWatch,在测量之后可能存在延迟或是数据未能同步的情况。如若出现上述情况,请重新测量并等待数据读取。';
@override
String get todayHrvMeasurementWarning => '注意:需打开健康里的权限,同时关闭省电模式。';
... ... @@ -435,10 +452,12 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayStressStatusWhatTitle => '什么是综合压力状态?';
@override
String get todayStressStatusWhatDescription1 => 'DoubleFeel 会结合你过去 30 天的 HRV(心率变异性)、静息心率以及当天的身体状态变化,综合评估你的整体压力水平。';
String get todayStressStatusWhatDescription1 =>
'DoubleFeel 会结合你过去 30 天的 HRV(心率变异性)、静息心率以及当天的身体状态变化,综合评估你的整体压力水平。';
@override
String get todayStressStatusWhatDescription2 => '由于 HRV 会随着情绪、运动、睡眠和疲劳不断波动,单次数据参考意义有限,因此我们更建议关注一整天的综合压力状态,让结果更稳定、更有参考价值。综合压力不仅能帮助你了解自己的身体状态,也能让亲密联系人更及时地关注你的变化。';
String get todayStressStatusWhatDescription2 =>
'由于 HRV 会随着情绪、运动、睡眠和疲劳不断波动,单次数据参考意义有限,因此我们更建议关注一整天的综合压力状态,让结果更稳定、更有参考价值。综合压力不仅能帮助你了解自己的身体状态,也能让亲密联系人更及时地关注你的变化。';
@override
String get todayStressStatusWhyHrvTitle => '为什么要参考 HRV(心率变异性)?';
... ... @@ -459,22 +478,27 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayStressStatusHrvChangesFast => '· HRV 变化较快,更适合观察短时间内的身体状态变化。';
@override
String get todayStressStatusAppWatchDifferenceTitle => '手机 App 与 Apple Watch 显示的压力状态有什么区别?';
String get todayStressStatusAppWatchDifferenceTitle =>
'手机 App 与 Apple Watch 显示的压力状态有什么区别?';
@override
String get todayStressStatusAppWatchDifferenceApp => '手机 App 首页显示的是当天的综合压力状态,会综合分析 HRV、静息心率与整体趋势。';
String get todayStressStatusAppWatchDifferenceApp =>
'手机 App 首页显示的是当天的综合压力状态,会综合分析 HRV、静息心率与整体趋势。';
@override
String get todayStressStatusAppWatchDifferenceWatch => 'Apple Watch 显示的是最近一次的实时压力状态,更适合快速查看当前身体变化。';
String get todayStressStatusAppWatchDifferenceWatch =>
'Apple Watch 显示的是最近一次的实时压力状态,更适合快速查看当前身体变化。';
@override
String get todayStressStatusWaitingDataTitle => '为什么会出现“等待数据”?';
@override
String get todayStressStatusWaitingDataDescription1 => '“等待数据”代表当前采集到的数据量不足,暂时无法生成可靠的压力评估。';
String get todayStressStatusWaitingDataDescription1 =>
'“等待数据”代表当前采集到的数据量不足,暂时无法生成可靠的压力评估。';
@override
String get todayStressStatusWaitingDataDescription2 => '请继续佩戴 Apple Watch,等待系统自动采集数据。';
String get todayStressStatusWaitingDataDescription2 =>
'请继续佩戴 Apple Watch,等待系统自动采集数据。';
@override
String get todayStressStatusWaitingDataReasonsIntro => '可能原因包括:';
... ... @@ -495,28 +519,35 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayHrvPrincipleHowMeasureTitle => 'DoubleFeel 如何测量压力状态?';
@override
String get todayHrvPrincipleHowMeasureDescription1 => '当你正常佩戴 Apple Watch 时,系统会自动采集你的心率数据,并同步至 Apple Health。';
String get todayHrvPrincipleHowMeasureDescription1 =>
'当你正常佩戴 Apple Watch 时,系统会自动采集你的心率数据,并同步至 Apple Health。';
@override
String get todayHrvPrincipleHowMeasureDescription2 => 'DoubleFeel 会基于这些数据计算 HRV(心率变异性)相关指标,用于评估你的身体压力与恢复状态。';
String get todayHrvPrincipleHowMeasureDescription2 =>
'DoubleFeel 会基于这些数据计算 HRV(心率变异性)相关指标,用于评估你的身体压力与恢复状态。';
@override
String get todayHrvPrincipleHowMeasureDescription3 => 'HRV 对压力、疲劳、睡眠、情绪与身体恢复都非常敏感,因此它能够帮助我们更早发现身体状态变化。';
String get todayHrvPrincipleHowMeasureDescription3 =>
'HRV 对压力、疲劳、睡眠、情绪与身体恢复都非常敏感,因此它能够帮助我们更早发现身体状态变化。';
@override
String get todayHrvPrincipleHowMeasureDescription4 => '为了让结果更准确,DoubleFeel 会将你当前的 HRV 状态与过去 30 天的个人平均水平进行对比,而不是直接与其他人比较。';
String get todayHrvPrincipleHowMeasureDescription4 =>
'为了让结果更准确,DoubleFeel 会将你当前的 HRV 状态与过去 30 天的个人平均水平进行对比,而不是直接与其他人比较。';
@override
String get todayRealtimeStressWhatTitle => '什么是实时压力?';
@override
String get todayRealtimeStressWhatDescription1 => '实时压力是 DoubleFeel 根据你当前的 HRV、心率状态与个人历史数据变化,动态生成的身体压力指标。';
String get todayRealtimeStressWhatDescription1 =>
'实时压力是 DoubleFeel 根据你当前的 HRV、心率状态与个人历史数据变化,动态生成的身体压力指标。';
@override
String get todayRealtimeStressWhatDescription2 => '压力值越高,代表你的身体状态相比平时偏离越明显,可能正处于疲劳、恢复不足或高压力状态。';
String get todayRealtimeStressWhatDescription2 =>
'压力值越高,代表你的身体状态相比平时偏离越明显,可能正处于疲劳、恢复不足或高压力状态。';
@override
String get todayRealtimeStressWhatDescription3 => '它能够帮助你更快发现身体变化,并及时调整休息、运动与生活节奏。';
String get todayRealtimeStressWhatDescription3 =>
'它能够帮助你更快发现身体变化,并及时调整休息、运动与生活节奏。';
@override
String get todayRealtimeStressDivisionTitle => '实时压力如何划分?';
... ... @@ -546,10 +577,12 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayRealtimeStressCautionDescription => '身体可能正在积累压力,需要适当休息与恢复。';
@override
String get todayRealtimeStressOverloadDescription => '身体压力明显偏高,建议减少负荷、注意睡眠与恢复。';
String get todayRealtimeStressOverloadDescription =>
'身体压力明显偏高,建议减少负荷、注意睡眠与恢复。';
@override
String get todayRealtimeStressDivisionBaseline => '以上区间会结合你的个人基线动态调整,不同用户之间并不直接比较。';
String get todayRealtimeStressDivisionBaseline =>
'以上区间会结合你的个人基线动态调整,不同用户之间并不直接比较。';
@override
String get todayRealtimeStressDivisionAwake => '此外,实时压力主要反映清醒状态下的身体压力变化。';
... ... @@ -564,25 +597,31 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayRealtimeStressLowBetterType => '身体压力分为“正常压力”与“异常压力”。';
@override
String get todayRealtimeStressLowBetterExample => '例如:运动期间或运动后,实时压力短时间升高属于正常恢复反应;工作专注、情绪兴奋时,压力也可能暂时升高,这些都属于正常的身体调节。';
String get todayRealtimeStressLowBetterExample =>
'例如:运动期间或运动后,实时压力短时间升高属于正常恢复反应;工作专注、情绪兴奋时,压力也可能暂时升高,这些都属于正常的身体调节。';
@override
String get todayRealtimeStressLowBetterHighStress => '但如果在静息、久坐或睡眠不足的情况下,压力长期偏高,则可能意味着身体疲劳、心理压力较大、睡眠恢复不足、运动恢复不充分、摄入过多咖啡因、酒精或刺激物、身体可能处于不适状态。';
String get todayRealtimeStressLowBetterHighStress =>
'但如果在静息、久坐或睡眠不足的情况下,压力长期偏高,则可能意味着身体疲劳、心理压力较大、睡眠恢复不足、运动恢复不充分、摄入过多咖啡因、酒精或刺激物、身体可能处于不适状态。';
@override
String get todayRealtimeStressLowBetterTrend => 'DoubleFeel 更关注的是你的长期变化趋势,而不是单次波动。';
String get todayRealtimeStressLowBetterTrend =>
'DoubleFeel 更关注的是你的长期变化趋势,而不是单次波动。';
@override
String get todayRealtimeStressScenarioTitle => 'HRV 与实时压力适用场景?';
@override
String get todayRealtimeStressScenarioHrvDefault => '在 Apple Watch 的默认设置下,HRV 每 2~5 小时更新一次。';
String get todayRealtimeStressScenarioHrvDefault =>
'在 Apple Watch 的默认设置下,HRV 每 2~5 小时更新一次。';
@override
String get todayRealtimeStressScenarioRegionLimit => '在部分地区,由于 Apple Watch 的呼吸功能受限,HRV 的更新频率可能会受到影响,并且开启呼吸功能后也会消耗更多电量。';
String get todayRealtimeStressScenarioRegionLimit =>
'在部分地区,由于 Apple Watch 的呼吸功能受限,HRV 的更新频率可能会受到影响,并且开启呼吸功能后也会消耗更多电量。';
@override
String get todayRealtimeStressScenarioIntro => '为了解决 HRV 更新间隔较长的问题,DoubleFeel 设计了实时压力功能:';
String get todayRealtimeStressScenarioIntro =>
'为了解决 HRV 更新间隔较长的问题,DoubleFeel 设计了实时压力功能:';
@override
String get todayRealtimeStressScenarioUpdateEvery6Min => '· 实时压力每 6 分钟更新一次';
... ... @@ -591,22 +630,27 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayRealtimeStressScenarioTimely => '· 可以更及时地反映身体状态变化';
@override
String get todayRealtimeStressScenarioConsistentTrend => '· 在大多数情况下,实时压力趋势与 HRV 趋势是一致的';
String get todayRealtimeStressScenarioConsistentTrend =>
'· 在大多数情况下,实时压力趋势与 HRV 趋势是一致的';
@override
String get todayRealtimeStressScenarioSummary => '这样用户既能获得 HRV 的长期趋势,也能通过实时压力获得短时身体状态的参考。';
String get todayRealtimeStressScenarioSummary =>
'这样用户既能获得 HRV 的长期趋势,也能通过实时压力获得短时身体状态的参考。';
@override
String get todayFaqNoDataTitle => 'APP或表盘有没有数据怎么办?';
@override
String get todayFaqNoDataDescription1 => '1. 确认苹果手表系统在10.0以上,手机系统在14以上,系统版本可在「关于本机」内查看。';
String get todayFaqNoDataDescription1 =>
'1. 确认苹果手表系统在10.0以上,手机系统在14以上,系统版本可在「关于本机」内查看。';
@override
String get todayFaqNoDataDescription2 => '2. 确认是否开启所有权限:手机「健康」-「共享」-「app」-「DoubleFeel」-「打开所有权限」。';
String get todayFaqNoDataDescription2 =>
'2. 确认是否开启所有权限:手机「健康」-「共享」-「app」-「DoubleFeel」-「打开所有权限」。';
@override
String get todayFaqNoDataDescription3 => '3. 确认设备是否处于省电模式、低电量状态或手表佩戴未贴紧,以上情况会影响手表数据采集。';
String get todayFaqNoDataDescription3 =>
'3. 确认设备是否处于省电模式、低电量状态或手表佩戴未贴紧,以上情况会影响手表数据采集。';
@override
String get todayFaqContactPrefix => '如以上均检查无问题,可以';
... ... @@ -621,37 +665,46 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayFaqWatchNoNotificationTitle => '手表无法收到状态通知?';
@override
String get todayFaqWatchNoNotificationDescription1 => '苹果手表和手机的通知展示有优先级:当手机已解锁并亮屏时,通知只会在手机端展示,不会在手表上出现。';
String get todayFaqWatchNoNotificationDescription1 =>
'苹果手表和手机的通知展示有优先级:当手机已解锁并亮屏时,通知只会在手机端展示,不会在手表上出现。';
@override
String get todayFaqWatchNoNotificationDescription2 => '若压力数据可正常显示和自动更新,但手表未收到通知,可尝试以下操作:';
String get todayFaqWatchNoNotificationDescription2 =>
'若压力数据可正常显示和自动更新,但手表未收到通知,可尝试以下操作:';
@override
String get todayFaqWatchNoNotificationCheckPhoneNotification => '1. 检查手机是否打开通知(设置-DoubleFeel-通知)。';
String get todayFaqWatchNoNotificationCheckPhoneNotification =>
'1. 检查手机是否打开通知(设置-DoubleFeel-通知)。';
@override
String get todayFaqWatchNoNotificationCheckPhoneBackgroundRefresh => '2. 检查手机是否打开后台App刷新(设置-DoubleFeel-后台App刷新)。';
String get todayFaqWatchNoNotificationCheckPhoneBackgroundRefresh =>
'2. 检查手机是否打开后台App刷新(设置-DoubleFeel-后台App刷新)。';
@override
String get todayFaqWatchNoNotificationCheckWatchBackgroundRefresh => '3. 检查手表是否打开后台App刷新(设置-通用-后台App刷新,并确保DoubleFeel开启)。';
String get todayFaqWatchNoNotificationCheckWatchBackgroundRefresh =>
'3. 检查手表是否打开后台App刷新(设置-通用-后台App刷新,并确保DoubleFeel开启)。';
@override
String get todayFaqWatchNoNotificationCheckModes => '4. 确保未处于低电量/专注/勿扰/剧院/睡眠等模式。';
String get todayFaqWatchNoNotificationCheckModes =>
'4. 确保未处于低电量/专注/勿扰/剧院/睡眠等模式。';
@override
String get todayFaqWatchNoNotificationReinstall => '5. 重装DoubleFeel 并重启AppleWatch与iPhone。';
String get todayFaqWatchNoNotificationReinstall =>
'5. 重装DoubleFeel 并重启AppleWatch与iPhone。';
@override
String get todayFaqWatchFaceDelayTitle => '手表表盘数据不更新或有延迟?';
@override
String get todayFaqWatchFaceDelayDescription1 => '由于苹果系统限制,所有手表表盘(第三方或官方)都会存在几分钟至半小时的延迟,开发者无法控制刷新频率。';
String get todayFaqWatchFaceDelayDescription1 =>
'由于苹果系统限制,所有手表表盘(第三方或官方)都会存在几分钟至半小时的延迟,开发者无法控制刷新频率。';
@override
String get todayFaqWatchFaceDelayIfOverOneHour => '若手机数据刷新后超过1小时表盘仍未更新:';
@override
String get todayFaqWatchFaceDelayOpenWatchApp => '请在手表上手动打开DoubleFeel,等待约1分钟。';
String get todayFaqWatchFaceDelayOpenWatchApp =>
'请在手表上手动打开DoubleFeel,等待约1分钟。';
@override
String get todayFaqWatchFaceDelayIfStill => '若仍未更新:';
... ... @@ -666,22 +719,27 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayFaqWatchFaceDelayCheckData => '· 手机和手表app是否可正常看到HRV数据。';
@override
String get todayFaqWatchFaceDelayCheckPhoneHealth => '· 确保手机端「iOS设置-隐私与安全性-健康-DoubleFeel」全部授权。';
String get todayFaqWatchFaceDelayCheckPhoneHealth =>
'· 确保手机端「iOS设置-隐私与安全性-健康-DoubleFeel」全部授权。';
@override
String get todayFaqWatchFaceDelayCheckWatchHealth => '· 确保手表端「设置-健康-数据来源、App和服务-DoubleFeel」全部授权。';
String get todayFaqWatchFaceDelayCheckWatchHealth =>
'· 确保手表端「设置-健康-数据来源、App和服务-DoubleFeel」全部授权。';
@override
String get todayFaqWatchFaceDelayCheckBackgroundRefresh => '· 确认AppleWatch-设置-通用-后台App刷新中DoubleFeel已开启。';
String get todayFaqWatchFaceDelayCheckBackgroundRefresh =>
'· 确认AppleWatch-设置-通用-后台App刷新中DoubleFeel已开启。';
@override
String get todayFaqWatchFaceDelayRestartWatch => '· 若仍未自动刷新,请重启手表。长时间运行或后台占用过高可能导致表盘暂停更新。';
String get todayFaqWatchFaceDelayRestartWatch =>
'· 若仍未自动刷新,请重启手表。长时间运行或后台占用过高可能导致表盘暂停更新。';
@override
String get todayFaqWatchFaceBlackScreenTitle => '手表表盘出现黑屏?';
@override
String get todayFaqWatchFaceBlackScreenDescription => '若添加专属互动表盘后出现黑屏(仅显示时间和日期),可长按表盘,点击「编辑」,左滑至「复杂功能」,选择 DoubleFeel,然后按需选择各组件重新添加。';
String get todayFaqWatchFaceBlackScreenDescription =>
'若添加专属互动表盘后出现黑屏(仅显示时间和日期),可长按表盘,点击「编辑」,左滑至「复杂功能」,选择 DoubleFeel,然后按需选择各组件重新添加。';
@override
String get today => '今天';
... ... @@ -765,7 +823,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get questionsAndFeedback => '问题和反馈';
@override
String get ifYouWouldLikeUsToReplyPleaseProvideYourEmailAddress => '如果需要我们回复,请填写联系邮箱';
String get ifYouWouldLikeUsToReplyPleaseProvideYourEmailAddress =>
'如果需要我们回复,请填写联系邮箱';
@override
String get uploadProof => '上传凭证';
... ...
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
... ... @@ -7,11 +10,22 @@ import 'app/double_feel_app.dart';
import 'core/theme/app_theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runZonedGuarded<Future<Null>>(
() async {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (errorDetails) {};
PlatformDispatcher.instance.onError = (error, stack) {
return true;
};
SystemChrome.setSystemUIOverlayStyle(AppTheme.systemUiOverlayStyle);
SystemChrome.setSystemUIOverlayStyle(AppTheme.systemUiOverlayStyle);
await AppBootstrap.init();
await initializeDateFormatting();
runApp(const DoubleFeelApp());
await AppBootstrap.init();
await initializeDateFormatting();
runApp(const DoubleFeelApp());
},
(error, stackTrace) async {
if (kDebugMode) {}
},
);
}
... ...
... ... @@ -70,5 +70,5 @@ flutter:
- assets/images/today/
- assets/images/friends/
- assets/images/my/
- assets/images/my/
- assets/images/permission/
- assets/images/watch_theme/
... ...