Commit aeeca5589bad6c68f6a108e5479f05bcc61c1b37

Authored by 常守达
1 parent 962df483

feat(login): 登陆注册接口调试

Showing 48 changed files with 3025 additions and 1308 deletions
No preview for this file type
@@ -17,16 +17,22 @@ import '../../core/services/push_service.dart'; @@ -17,16 +17,22 @@ import '../../core/services/push_service.dart';
17 import '../../core/services/thinking_data_service.dart'; 17 import '../../core/services/thinking_data_service.dart';
18 import '../../core/services/user_state_service.dart'; 18 import '../../core/services/user_state_service.dart';
19 import '../../core/services/wear_engine_service.dart'; 19 import '../../core/services/wear_engine_service.dart';
  20 +import '../../data/local/local_storage.dart';
  21 +import '../../data/local/user_account_storage.dart';
20 import '../../data/local/user_preferences_storage.dart'; 22 import '../../data/local/user_preferences_storage.dart';
21 23
22 /// App-wide infrastructure (error handling, config, network client). 24 /// App-wide infrastructure (error handling, config, network client).
23 void registerCoreDeps({ 25 void registerCoreDeps({
24 required AppEnvironmentConfig environmentConfig, 26 required AppEnvironmentConfig environmentConfig,
25 required UserPreferencesStorage userPreferencesStorage, 27 required UserPreferencesStorage userPreferencesStorage,
  28 + required UserAccountStorage userAccountStorage,
  29 + required LocalStorage localStorage,
26 }) { 30 }) {
27 Get.put(AppErrorHandler(), permanent: true); 31 Get.put(AppErrorHandler(), permanent: true);
  32 + Get.put(localStorage, permanent: true);
28 Get.put(environmentConfig, permanent: true); 33 Get.put(environmentConfig, permanent: true);
29 Get.put(userPreferencesStorage, permanent: true); 34 Get.put(userPreferencesStorage, permanent: true);
  35 + Get.put(userAccountStorage, permanent: true);
30 Get.put( 36 Get.put(
31 DioClient( 37 DioClient(
32 Get.find<AppEnvironmentConfig>(), 38 Get.find<AppEnvironmentConfig>(),
@@ -2,6 +2,8 @@ import 'package:get/get.dart'; @@ -2,6 +2,8 @@ import 'package:get/get.dart';
2 2
3 import '../../core/config/app_environment_config.dart'; 3 import '../../core/config/app_environment_config.dart';
4 import '../../core/network/dio_client.dart'; 4 import '../../core/network/dio_client.dart';
  5 +import '../../data/local/local_storage.dart';
  6 +import '../../data/local/user_account_storage.dart';
5 import '../../data/local/user_preferences_storage.dart'; 7 import '../../data/local/user_preferences_storage.dart';
6 import 'dependency_registrars.dart'; 8 import 'dependency_registrars.dart';
7 9
@@ -10,16 +12,22 @@ class InitialBinding extends Bindings { @@ -10,16 +12,22 @@ class InitialBinding extends Bindings {
10 InitialBinding({ 12 InitialBinding({
11 required this.environmentConfig, 13 required this.environmentConfig,
12 required this.userPreferencesStorage, 14 required this.userPreferencesStorage,
  15 + required this.userAccountStorage,
  16 + required this.localStorage,
13 }); 17 });
14 18
15 final AppEnvironmentConfig environmentConfig; 19 final AppEnvironmentConfig environmentConfig;
16 final UserPreferencesStorage userPreferencesStorage; 20 final UserPreferencesStorage userPreferencesStorage;
  21 + final UserAccountStorage userAccountStorage;
  22 + final LocalStorage localStorage;
17 23
18 @override 24 @override
19 void dependencies() { 25 void dependencies() {
20 registerCoreDeps( 26 registerCoreDeps(
21 environmentConfig: environmentConfig, 27 environmentConfig: environmentConfig,
22 userPreferencesStorage: userPreferencesStorage, 28 userPreferencesStorage: userPreferencesStorage,
  29 + userAccountStorage: userAccountStorage,
  30 + localStorage: localStorage,
23 ); 31 );
24 registerUserSessionDeps(); 32 registerUserSessionDeps();
25 33
@@ -5,6 +5,7 @@ import '../../core/config/app_environment_config.dart'; @@ -5,6 +5,7 @@ import '../../core/config/app_environment_config.dart';
5 import '../../core/logging/app_logger.dart'; 5 import '../../core/logging/app_logger.dart';
6 import '../../core/services/user_state_service.dart'; 6 import '../../core/services/user_state_service.dart';
7 import '../../data/local/local_storage.dart'; 7 import '../../data/local/local_storage.dart';
  8 +import '../../data/local/user_account_storage.dart';
8 import '../../data/local/user_preferences_storage.dart'; 9 import '../../data/local/user_preferences_storage.dart';
9 import '../bindings/initial_binding.dart'; 10 import '../bindings/initial_binding.dart';
10 11
@@ -23,9 +24,13 @@ abstract final class AppBootstrap { @@ -23,9 +24,13 @@ abstract final class AppBootstrap {
23 UserPreferencesStorage(local.sharedPreferences); 24 UserPreferencesStorage(local.sharedPreferences);
24 await userPreferencesStorage.init(); 25 await userPreferencesStorage.init();
25 26
  27 + final userAccountStorage = UserAccountStorage(local.sharedPreferences);
  28 +
26 InitialBinding( 29 InitialBinding(
27 environmentConfig: environmentConfig, 30 environmentConfig: environmentConfig,
28 userPreferencesStorage: userPreferencesStorage, 31 userPreferencesStorage: userPreferencesStorage,
  32 + userAccountStorage: userAccountStorage,
  33 + localStorage: local,
29 ).dependencies(); 34 ).dependencies();
30 35
31 if (local.termsAgreed) { 36 if (local.termsAgreed) {
  1 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
3 import '../controllers/bind_partner_controller.dart'; 4 import '../controllers/bind_partner_controller.dart';
@@ -5,6 +6,8 @@ import '../controllers/bind_partner_controller.dart'; @@ -5,6 +6,8 @@ import '../controllers/bind_partner_controller.dart';
5 class BindPartnerBinding extends Bindings { 6 class BindPartnerBinding extends Bindings {
6 @override 7 @override
7 void dependencies() { 8 void dependencies() {
8 - Get.put(BindPartnerController()); 9 + Get.put(BindPartnerController(
  10 + Get.find<UserApi>(),
  11 + ));
9 } 12 }
10 } 13 }
1 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 1 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  2 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  3 +import 'package:doublefeel_flutter/core/result/app_result.dart';
2 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 4 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
3 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 5 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  6 +import 'package:doublefeel_flutter/data/models/user/user_models.dart';
4 import 'package:flutter/widgets.dart'; 7 import 'package:flutter/widgets.dart';
5 import 'package:get/get.dart'; 8 import 'package:get/get.dart';
6 9
7 class BindPartnerController extends GetxController { 10 class BindPartnerController extends GetxController {
  11 + BindPartnerController(this._userApi);
  12 +
  13 + final UserApi _userApi;
  14 +
8 final TextEditingController partnerIdController = TextEditingController(); 15 final TextEditingController partnerIdController = TextEditingController();
9 16
10 final RxString partnerIdInput = ''.obs; 17 final RxString partnerIdInput = ''.obs;
11 18
12 final RxBool isSubmitting = false.obs; 19 final RxBool isSubmitting = false.obs;
13 20
14 - final RxString myInviteCode = 'AC1192'.obs; 21 + final RxString myInviteCode = ''.obs;
15 22
16 @override 23 @override
17 void onInit() { 24 void onInit() {
@@ -19,6 +26,9 @@ class BindPartnerController extends GetxController { @@ -19,6 +26,9 @@ class BindPartnerController extends GetxController {
19 partnerIdController.addListener(() { 26 partnerIdController.addListener(() {
20 partnerIdInput.value = partnerIdController.text.trim(); 27 partnerIdInput.value = partnerIdController.text.trim();
21 }); 28 });
  29 +
  30 + final userPrefs = Get.find<UserPreferencesStorage>();
  31 + myInviteCode.value = userPrefs.preferences.value.meUserInfo?.pairCode ?? '';
22 } 32 }
23 33
24 @override 34 @override
@@ -35,6 +45,19 @@ class BindPartnerController extends GetxController { @@ -35,6 +45,19 @@ class BindPartnerController extends GetxController {
35 if (!canSubmit || isSubmitting.value) return; 45 if (!canSubmit || isSubmitting.value) return;
36 isSubmitting.value = true; 46 isSubmitting.value = true;
37 try { 47 try {
  48 + final result = await _userApi.bindPartner(partnerIdInput.value);
  49 + if (result is AppFailure) {
  50 + return;
  51 + } else {
  52 + final partnerResult = await _userApi.getPartnerUserInfo();
  53 + if (partnerResult is AppSuccess<BoundUserInfoResponse>) {
  54 + final partner = partnerResult.data.partnerUserInfo;
  55 + if (partner != null) {
  56 + await Get.find<UserPreferencesStorage>()
  57 + .updatePartnerUserInfo(partner);
  58 + }
  59 + }
  60 + }
38 final userStateService = Get.find<UserStateService>(); 61 final userStateService = Get.find<UserStateService>();
39 final partnerId = partnerIdInput.value; 62 final partnerId = partnerIdInput.value;
40 if (!userStateService.isVip) { 63 if (!userStateService.isVip) {
  1 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
  2 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  3 +import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
  4 +import 'package:doublefeel_flutter/core/services/user_state_service.dart';
1 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
2 6
3 import '../controllers/home_controller.dart'; 7 import '../controllers/home_controller.dart';
@@ -11,7 +15,15 @@ class HomeBinding extends Bindings { @@ -11,7 +15,15 @@ class HomeBinding extends Bindings {
11 @override 15 @override
12 void dependencies() { 16 void dependencies() {
13 Get.lazyPut<HomeController>(() => HomeController(), fenix: true); 17 Get.lazyPut<HomeController>(() => HomeController(), fenix: true);
14 - Get.lazyPut<TodayController>(() => TodayController(), fenix: true); 18 + Get.lazyPut<TodayController>(
  19 + () => TodayController(
  20 + Get.find<UserApi>(),
  21 + Get.find<HealthApi>(),
  22 + Get.find<UserStateService>(),
  23 + Get.find<HealthKitUploadService>(),
  24 + ),
  25 + fenix: true,
  26 + );
15 Get.lazyPut<TrendController>(() => TrendController(), fenix: true); 27 Get.lazyPut<TrendController>(() => TrendController(), fenix: true);
16 Get.lazyPut<HrvController>(() => HrvController(), fenix: true); 28 Get.lazyPut<HrvController>(() => HrvController(), fenix: true);
17 Get.lazyPut<ActivityController>(() => ActivityController(), fenix: true); 29 Get.lazyPut<ActivityController>(() => ActivityController(), fenix: true);
@@ -8,13 +8,21 @@ class HomeController extends GetxController { @@ -8,13 +8,21 @@ class HomeController extends GetxController {
8 final selectedIndex = 0.obs; 8 final selectedIndex = 0.obs;
9 9
10 /// 当前选中的日期(Today tab 使用) 10 /// 当前选中的日期(Today tab 使用)
11 - final selectedDate = DateTime.now().obs; 11 + final selectedDate = _dateOnly(DateTime.now()).obs;
12 12
13 void changeTab(int index) { 13 void changeTab(int index) {
14 selectedIndex.value = index; 14 selectedIndex.value = index;
15 } 15 }
16 16
17 void changeDate(DateTime date) { 17 void changeDate(DateTime date) {
18 - selectedDate.value = date; 18 + final normalizedDate = _dateOnly(date);
  19 + final currentDate = selectedDate.value;
  20 + if (currentDate == normalizedDate) return;
  21 +
  22 + selectedDate.value = normalizedDate;
  23 + }
  24 +
  25 + static DateTime _dateOnly(DateTime date) {
  26 + return DateTime(date.year, date.month, date.day);
19 } 27 }
20 } 28 }
  1 +import 'dart:async';
  2 +
  3 +import 'package:doublefeel_flutter/core/logging/app_logger.dart';
  4 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
  5 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
  6 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  7 +import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
1 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 8 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  9 +import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
  10 +import 'package:doublefeel_flutter/data/models/health/health_models.dart';
  11 +import 'package:doublefeel_flutter/data/models/health/health_upload_models.dart';
  12 +import 'package:doublefeel_flutter/data/models/user/user_models.dart';
  13 +import 'package:flutter/foundation.dart';
2 import 'package:get/get.dart'; 14 import 'package:get/get.dart';
3 15
4 -/// 每日行动 item 数据模型  
5 -class DailyActionItem {  
6 - final String label;  
7 - final String value;  
8 - final String unit;  
9 - final String? subValue;  
10 - final String? subUnit;  
11 -  
12 - const DailyActionItem({  
13 - required this.label,  
14 - required this.value,  
15 - required this.unit,  
16 - this.subValue,  
17 - this.subUnit,  
18 - });  
19 -}  
20 -  
21 /// HRV 趋势数据点 16 /// HRV 趋势数据点
22 class HrvDataPoint { 17 class HrvDataPoint {
23 final double hour; // 0.0 ~ 24.0 18 final double hour; // 0.0 ~ 24.0
@@ -42,70 +37,446 @@ class HrvAnnotation { @@ -42,70 +37,446 @@ class HrvAnnotation {
42 } 37 }
43 38
44 class TodayController extends GetxController { 39 class TodayController extends GetxController {
45 - final UserStateService userStateService = Get.find<UserStateService>(); 40 + TodayController(
  41 + this._userApi,
  42 + this._healthApi,
  43 + this._userStateService,
  44 + this._healthKitUploadService,
  45 + );
  46 +
  47 + final UserApi _userApi;
  48 + final HealthApi _healthApi;
  49 + final UserStateService _userStateService;
  50 + final HealthKitUploadService _healthKitUploadService;
  51 +
  52 + UserStateService get userStateService => _userStateService;
  53 +
  54 + final isLoadingToday = false.obs;
  55 + final showHealthDataAuthCard = true.obs;
  56 + final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs;
46 57
47 // ── 压力状态 ───────────────────────────── 58 // ── 压力状态 ─────────────────────────────
48 final stressLabel = '状态正常'.obs; 59 final stressLabel = '状态正常'.obs;
49 60
50 // ── HRV 表盘引导 Banner ─────────────────── 61 // ── HRV 表盘引导 Banner ───────────────────
51 final showHrvAdBanner = true.obs; 62 final showHrvAdBanner = true.obs;
  63 + final showPartnerAdBanner = true.obs;
52 64
53 void dismissHrvAdBanner() => showHrvAdBanner.value = false; 65 void dismissHrvAdBanner() => showHrvAdBanner.value = false;
54 66
  67 + void dismissPartnerAdBanner() => showPartnerAdBanner.value = false;
  68 +
55 // ── HRV 数字 + 心率 ─────────────────────── 69 // ── HRV 数字 + 心率 ───────────────────────
56 - final avgHrv = 46.obs;  
57 - final restingHeartRate = 63.obs; 70 + final avgHrv = '--'.obs;
  71 + final restingHeartRate = '--'.obs;
  72 +
  73 + // ── 睡眠卡片 ──────────────────────────────
  74 + final sleepHours = '--'.obs;
  75 + final sleepMinutes = '--'.obs;
  76 + final sleepQuality = '--'.obs;
  77 + final sleepAverageHeartRate = '--'.obs;
  78 + final sleepProgress = 0.0.obs;
  79 +
  80 + // ── 健身卡片 ──────────────────────────────
  81 + final activityCalories = '--'.obs;
  82 + final activityExerciseMinutes = '--'.obs;
  83 + final activityStandHours = '--'.obs;
  84 + final activityMoveProgress = 0.0.obs;
  85 + final activityExerciseProgress = 0.0.obs;
  86 + final activityStandProgress = 0.0.obs;
  87 + int? _activityMoveTarget;
  88 + int? _activityStandTarget;
58 89
59 // ── HRV 趋势图 ──────────────────────────── 90 // ── HRV 趋势图 ────────────────────────────
60 - final List<HrvDataPoint> hrvChartData = const [  
61 - HrvDataPoint(hour: 0, hrv: 30),  
62 - HrvDataPoint(hour: 1, hrv: 45),  
63 - HrvDataPoint(hour: 2, hrv: 40),  
64 - HrvDataPoint(hour: 3, hrv: 55),  
65 - HrvDataPoint(hour: 4, hrv: 50),  
66 - HrvDataPoint(hour: 5, hrv: 60),  
67 - HrvDataPoint(hour: 6, hrv: 65),  
68 - HrvDataPoint(hour: 7, hrv: 70),  
69 - HrvDataPoint(hour: 8, hrv: 62),  
70 - HrvDataPoint(hour: 9, hrv: 58),  
71 - HrvDataPoint(hour: 10, hrv: 75),  
72 - HrvDataPoint(hour: 11, hrv: 80),  
73 - HrvDataPoint(hour: 12, hrv: 72),  
74 - HrvDataPoint(hour: 13, hrv: 68),  
75 - HrvDataPoint(hour: 14, hrv: 55),  
76 - HrvDataPoint(hour: 15, hrv: 50),  
77 - HrvDataPoint(hour: 16, hrv: 45),  
78 - HrvDataPoint(hour: 17, hrv: 42),  
79 - HrvDataPoint(hour: 18, hrv: 38),  
80 - ];  
81 -  
82 - final List<HrvAnnotation> hrvAnnotations = const [  
83 - HrvAnnotation(  
84 - hour: 11,  
85 - hrv: 80,  
86 - stateLabel: '状态优秀',  
87 - detail: 'HRV 23ms · 11:28',  
88 - ),  
89 - ];  
90 -  
91 - // ── 每日行动 ──────────────────────────────  
92 - final List<DailyActionItem> dailyActions = const [  
93 - DailyActionItem(  
94 - label: '睡眠',  
95 - value: '7',  
96 - unit: 'h',  
97 - subValue: '32',  
98 - subUnit: 'min',  
99 - ),  
100 - DailyActionItem(  
101 - label: '健身',  
102 - value: '45',  
103 - unit: 'min',  
104 - ),  
105 - DailyActionItem(  
106 - label: '步数',  
107 - value: '8,234',  
108 - unit: '步',  
109 - ),  
110 - ]; 91 + final hrvChartData = <HrvDataPoint>[].obs;
  92 + final stressChartData = <HrvDataPoint>[].obs;
  93 +
  94 + final hrvAnnotations = <HrvAnnotation>[].obs;
  95 +
  96 + Future<void> requestHealthAuthorization() async {
  97 + try {
  98 + await _healthKitUploadService.requestClientAuthorization();
  99 + await _refreshHealthAuthorizationState();
  100 + } catch (error) {
  101 + debugPrint('Health authorization skipped: $error');
  102 + }
  103 + }
  104 +
  105 + @override
  106 + void onInit() {
  107 + super.onInit();
  108 + unawaited(loadTodayData());
  109 + }
  110 +
  111 + Future<void> loadTodayData() async {
  112 + if (isLoadingToday.value) return;
  113 + isLoadingToday.value = true;
  114 + try {
  115 + await Future.wait([
  116 + _refreshUserGreeting(),
  117 + // _refreshHealthAuthorizationState(),
  118 + _refreshTodayHealthData(),
  119 + ]);
  120 + } catch (error, stackTrace) {
  121 + AppLogger.e('TodayController.loadTodayData failed', error, stackTrace);
  122 + } finally {
  123 + isLoadingToday.value = false;
  124 + }
  125 + }
  126 +
  127 + Future<void> _refreshUserGreeting() async {
  128 + final result = await _userApi.getUserInfo(errorHandlingPolicy: null);
  129 + if (result case AppSuccess<UserInfoResponse>(data: final user)) {
  130 + final name = user.nickname?.trim();
  131 + stressSubtitle.value = name == null || name.isEmpty
  132 + ? 'Hi, 你今日的综合压力状态'
  133 + : 'Hi, $name 今日的综合压力状态';
  134 + }
  135 + }
  136 +
  137 + Future<void> _refreshHealthAuthorizationState() async {
  138 + final serverAuth =
  139 + await _healthApi.checkServerHealthAuth(errorHandlingPolicy: null);
  140 + final hasServerAuth = switch (serverAuth) {
  141 + AppSuccess<HealthAuthResponse>(data: final auth) =>
  142 + auth.scope?.trim().isNotEmpty == true,
  143 + _ => false,
  144 + };
  145 +
  146 + bool hasClientAuth = false;
  147 + try {
  148 + hasClientAuth = await _healthKitUploadService.isHealthAuthorized();
  149 + } catch (error, stackTrace) {
  150 + AppLogger.w('Health authorization check failed', error, stackTrace);
  151 + }
  152 +
  153 + showHealthDataAuthCard.value = !(hasServerAuth || hasClientAuth);
  154 + }
  155 +
  156 + Future<void> _refreshTodayHealthData() async {
  157 + final startDate = _todayDateKey();
  158 + const dateRangeType = 1;
  159 +
  160 + final todayResultFuture = _healthApi.getTodayData(
  161 + isOther: false,
  162 + errorHandlingPolicy: null,
  163 + );
  164 + final latestHrvResultFuture = _healthApi.getLatestHrvData(
  165 + errorHandlingPolicy: null,
  166 + );
  167 + final hrvStatisticsResultFuture = _healthApi.getHrvStatistics(
  168 + isOther: false,
  169 + dateRangeType: dateRangeType,
  170 + startDate: startDate,
  171 + errorHandlingPolicy: null,
  172 + );
  173 + final sleepStatisticsResultFuture = _healthApi.getSleepStateStatistics(
  174 + isOther: false,
  175 + dateRangeType: dateRangeType,
  176 + startDate: startDate,
  177 + errorHandlingPolicy: null,
  178 + );
  179 + final activityStatisticsResultFuture = _healthApi.getActivityBurnStatistics(
  180 + isOther: false,
  181 + dateRangeType: dateRangeType,
  182 + startDate: startDate,
  183 + errorHandlingPolicy: null,
  184 + );
  185 +
  186 + final todayResult = await todayResultFuture;
  187 + final latestHrvResult = await latestHrvResultFuture;
  188 + final hrvStatisticsResult = await hrvStatisticsResultFuture;
  189 + final sleepStatisticsResult = await sleepStatisticsResultFuture;
  190 + final activityStatisticsResult = await activityStatisticsResultFuture;
  191 +
  192 + if (todayResult case AppSuccess<TodayStatusData>(data: final today)) {
  193 + _applyTodayData(today);
  194 + } else if (todayResult case AppFailure(error: final error)) {
  195 + AppLogger.w('HealthApi.getTodayData failed: $error');
  196 + }
  197 +
  198 + if (latestHrvResult case AppSuccess<LatestHrvData>(data: final latestHrv)) {
  199 + _applyLatestHrvData(latestHrv);
  200 + }
  201 +
  202 + if (hrvStatisticsResult
  203 + case AppSuccess<HrvStatisticsData>(data: final hrvStatistics)) {
  204 + _applyHrvStatistics(hrvStatistics);
  205 + }
  206 +
  207 + if (sleepStatisticsResult
  208 + case AppSuccess<SleepStatisticsData>(data: final sleepStatistics)) {
  209 + _applySleepStatistics(sleepStatistics);
  210 + }
  211 +
  212 + if (activityStatisticsResult
  213 + case AppSuccess<ActivityBurnStatisticsData>(
  214 + data: final activityStatistics
  215 + )) {
  216 + _applyActivityStatistics(activityStatistics);
  217 + }
  218 + }
  219 +
  220 + void _applyTodayData(TodayStatusData today) {
  221 + final recent = today.recentData;
  222 + if (recent?.heartRate != null) {
  223 + restingHeartRate.value = _formatMetric(recent!.heartRate);
  224 + if (sleepAverageHeartRate.value == '--') {
  225 + sleepAverageHeartRate.value = _formatMetric(recent.heartRate);
  226 + }
  227 + }
  228 +
  229 + _applySleepDuration(today.sleepDuration);
  230 + _applyActivityRecentData(recent);
  231 +
  232 + final hrvPoints = _buildHrvChartData(today.hrvDataList);
  233 + hrvChartData.assignAll(hrvPoints);
  234 + stressChartData.assignAll(
  235 + hrvPoints.map((point) {
  236 + return HrvDataPoint(
  237 + hour: point.hour,
  238 + hrv: _stressScoreFromHrv(point.hrv),
  239 + );
  240 + }),
  241 + );
  242 +
  243 + final hrvValues = hrvPoints.map((point) => point.hrv).toList();
  244 + if (hrvValues.isNotEmpty) {
  245 + final average = hrvValues.reduce((value, element) => value + element) /
  246 + hrvValues.length;
  247 + avgHrv.value = _formatMetric(average);
  248 +
  249 + final latest = _latestTodayHrv(today.hrvDataList);
  250 + final latestStatus = latest?.hrvStatus;
  251 + if (latestStatus != null) {
  252 + stressLabel.value = latestStatus.title;
  253 + }
  254 + _updateLatestHrvAnnotation(latest);
  255 + }
  256 + }
  257 +
  258 + void _applyLatestHrvData(LatestHrvData latestHrv) {
  259 + final userHrv = latestHrv.userHrv;
  260 + if (avgHrv.value == '--' && userHrv != null) {
  261 + avgHrv.value = _formatMetric(userHrv);
  262 + }
  263 + if (userHrv != null) {
  264 + stressLabel.value = _hrvStatusTitle(
  265 + value: userHrv,
  266 + baseline: latestHrv.userHrvBaseline,
  267 + );
  268 + }
  269 + }
  270 +
  271 + void _applyHrvStatistics(HrvStatisticsData data) {
  272 + if (avgHrv.value == '--' && data.avgHrv != null) {
  273 + avgHrv.value = _formatMetric(data.avgHrv);
  274 + }
  275 + if (data.avgRestingHeartRate != null) {
  276 + restingHeartRate.value = _formatMetric(data.avgRestingHeartRate);
  277 + }
  278 + if (data.avgSleepingHeartRate != null) {
  279 + sleepAverageHeartRate.value = _formatMetric(data.avgSleepingHeartRate);
  280 + }
  281 + }
  282 +
  283 + void _applySleepStatistics(SleepStatisticsData data) {
  284 + if (sleepHours.value == '--' && data.avgSleepDuration != null) {
  285 + _applySleepDuration(data.avgSleepDuration);
  286 + }
  287 + final deepPercentage = data.deepPercentage;
  288 + if (deepPercentage != null) {
  289 + sleepQuality.value = _sleepQualityFromDeepPercentage(deepPercentage);
  290 + }
  291 + }
  292 +
  293 + void _applyActivityStatistics(ActivityBurnStatisticsData data) {
  294 + _activityMoveTarget = data.activityTargetInfo?.move ?? _activityMoveTarget;
  295 + _activityStandTarget =
  296 + data.activityTargetInfo?.stand ?? _activityStandTarget;
  297 +
  298 + if (activityCalories.value == '--' && data.totalCaloriesBurned != null) {
  299 + activityCalories.value = _formatMetric(data.totalCaloriesBurned);
  300 + }
  301 + if (activityStandHours.value == '--' && data.totalStand != null) {
  302 + activityStandHours.value = _formatMetric(data.totalStand);
  303 + }
  304 +
  305 + final moveValue = _parseMetric(activityCalories.value);
  306 + final standValue = _parseMetric(activityStandHours.value);
  307 + if (moveValue != null) {
  308 + activityMoveProgress.value = _progress(
  309 + moveValue,
  310 + (_activityMoveTarget ?? 400).toDouble(),
  311 + );
  312 + }
  313 + if (standValue != null) {
  314 + activityStandProgress.value = _progress(
  315 + standValue,
  316 + (_activityStandTarget ?? 12).toDouble(),
  317 + );
  318 + }
  319 + }
  320 +
  321 + void _applySleepDuration(int? rawDuration) {
  322 + final minutes = _normalizeDurationMinutes(rawDuration);
  323 + if (minutes == null) {
  324 + sleepHours.value = '--';
  325 + sleepMinutes.value = '--';
  326 + sleepQuality.value = '--';
  327 + sleepProgress.value = 0;
  328 + return;
  329 + }
  330 +
  331 + sleepHours.value = '${minutes ~/ 60}';
  332 + sleepMinutes.value = '${minutes % 60}';
  333 + sleepQuality.value = _sleepQualityFromDuration(minutes);
  334 + sleepProgress.value = _progress(minutes.toDouble(), 8 * 60);
  335 + }
  336 +
  337 + void _applyActivityRecentData(TodayStatusRecentData? recent) {
  338 + activityCalories.value = _formatMetric(recent?.move);
  339 + activityExerciseMinutes.value = _formatMetric(recent?.exercise);
  340 + activityStandHours.value = _formatMetric(recent?.stand);
  341 +
  342 + activityMoveProgress.value = _progress(
  343 + (recent?.move ?? 0).toDouble(),
  344 + (_activityMoveTarget ?? 400).toDouble(),
  345 + );
  346 + activityExerciseProgress.value = _progress(
  347 + (recent?.exercise ?? 0).toDouble(),
  348 + 30,
  349 + );
  350 + activityStandProgress.value = _progress(
  351 + (recent?.stand ?? 0).toDouble(),
  352 + (_activityStandTarget ?? 12).toDouble(),
  353 + );
  354 + }
  355 +
  356 + List<HrvDataPoint> _buildHrvChartData(List<TodayHrvData>? dataList) {
  357 + final points = <HrvDataPoint>[];
  358 + for (final item in dataList ?? const <TodayHrvData>[]) {
  359 + final time = item.time;
  360 + final value = item.value;
  361 + if (time == null || value == null) continue;
  362 + points.add(HrvDataPoint(hour: _hourFromApiTime(time), hrv: value));
  363 + }
  364 + points.sort((a, b) => a.hour.compareTo(b.hour));
  365 + return points;
  366 + }
  367 +
  368 + TodayHrvData? _latestTodayHrv(List<TodayHrvData>? dataList) {
  369 + TodayHrvData? latest;
  370 + for (final item in dataList ?? const <TodayHrvData>[]) {
  371 + if (item.value == null) continue;
  372 + final latestTime = latest?.time ?? -1;
  373 + final itemTime = item.time ?? -1;
  374 + if (latest == null || itemTime >= latestTime) {
  375 + latest = item;
  376 + }
  377 + }
  378 + return latest;
  379 + }
  380 +
  381 + void _updateLatestHrvAnnotation(TodayHrvData? latest) {
  382 + if (latest?.time == null || latest?.value == null) {
  383 + hrvAnnotations.clear();
  384 + return;
  385 + }
  386 +
  387 + final hour = _hourFromApiTime(latest!.time!);
  388 + hrvAnnotations.assignAll([
  389 + HrvAnnotation(
  390 + hour: hour,
  391 + hrv: latest.value!,
  392 + stateLabel: latest.hrvStatus?.title ?? stressLabel.value,
  393 + detail: 'HRV ${_formatMetric(latest.value)}ms · ${_formatHour(hour)}',
  394 + ),
  395 + ]);
  396 + }
  397 +
  398 + String _hrvStatusTitle({required double value, double? baseline}) {
  399 + if ((baseline ?? 0) > 0) {
  400 + if (value >= 1.2 * baseline!) return HrvStatus.energetic.title;
  401 + if (value <= 0.8 * baseline) return HrvStatus.overload.title;
  402 + return HrvStatus.normal.title;
  403 + }
  404 + if (value >= 128) return HrvStatus.energetic.title;
  405 + if (value <= 30) return HrvStatus.overload.title;
  406 + return HrvStatus.normal.title;
  407 + }
  408 +
  409 + double _stressScoreFromHrv(double hrv) {
  410 + return (100 - hrv).clamp(0, 100).toDouble();
  411 + }
  412 +
  413 + int? _normalizeDurationMinutes(int? rawDuration) {
  414 + if (rawDuration == null || rawDuration <= 0) return null;
  415 + if (rawDuration > 24 * 60 * 60) {
  416 + return (rawDuration / 60000).round();
  417 + }
  418 + if (rawDuration > 24 * 60) {
  419 + return (rawDuration / 60).round();
  420 + }
  421 + return rawDuration;
  422 + }
  423 +
  424 + String _sleepQualityFromDuration(int minutes) {
  425 + if (minutes >= 7 * 60 && minutes <= 9 * 60) return '优秀';
  426 + if (minutes >= 6 * 60 && minutes < 10 * 60) return '良好';
  427 + if (minutes >= 5 * 60) return '一般';
  428 + return '偏少';
  429 + }
  430 +
  431 + String _sleepQualityFromDeepPercentage(double percentage) {
  432 + if (percentage >= 25) return '优秀';
  433 + if (percentage >= 18) return '良好';
  434 + if (percentage >= 12) return '一般';
  435 + return '偏少';
  436 + }
  437 +
  438 + double _progress(double value, double target) {
  439 + if (target <= 0) return 0;
  440 + return (value / target).clamp(0, 1).toDouble();
  441 + }
  442 +
  443 + double? _parseMetric(String value) {
  444 + if (value == '--') return null;
  445 + return double.tryParse(value.replaceAll(',', ''));
  446 + }
  447 +
  448 + double _hourFromApiTime(int time) {
  449 + if (time >= 1000000000000) {
  450 + final date = DateTime.fromMillisecondsSinceEpoch(time);
  451 + return date.hour + date.minute / 60;
  452 + }
  453 + if (time >= 1000000000) {
  454 + final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);
  455 + return date.hour + date.minute / 60;
  456 + }
  457 + if (time > 24) {
  458 + final hour = (time ~/ 100).clamp(0, 23);
  459 + final minute = (time % 100).clamp(0, 59);
  460 + return hour + minute / 60;
  461 + }
  462 + return time.toDouble().clamp(0, 24).toDouble();
  463 + }
  464 +
  465 + String _formatMetric(num? value) {
  466 + if (value == null) return '--';
  467 + if (value % 1 == 0) return '${value.toInt()}';
  468 + return value.toStringAsFixed(1);
  469 + }
  470 +
  471 + String _formatHour(double hour) {
  472 + final totalMinutes = (hour * 60).round();
  473 + final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
  474 + final m = (totalMinutes % 60).toString().padLeft(2, '0');
  475 + return '$h:$m';
  476 + }
  477 +
  478 + int _todayDateKey() {
  479 + final now = DateTime.now();
  480 + return now.year * 10000 + now.month * 100 + now.day - 2;
  481 + }
111 } 482 }
1 -import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 1 +import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
4 -import 'package:intl/intl.dart';  
5 import 'package:table_calendar/table_calendar.dart'; 5 import 'package:table_calendar/table_calendar.dart';
6 6
7 import '../../controllers/home_controller.dart'; 7 import '../../controllers/home_controller.dart';
8 import '../../controllers/today_controller.dart'; 8 import '../../controllers/today_controller.dart';
9 -import '../../widgets/today/today_date_strip.dart'; 9 +import '../../widgets/today/today_health_data_auth_card.dart';
10 import '../../widgets/today/today_hrv_ad_banner.dart'; 10 import '../../widgets/today/today_hrv_ad_banner.dart';
11 import '../../widgets/today/today_hrv_number_card.dart'; 11 import '../../widgets/today/today_hrv_number_card.dart';
12 import '../../widgets/today/today_hrv_chart_card.dart'; 12 import '../../widgets/today/today_hrv_chart_card.dart';
13 -import '../../widgets/today/today_actions_card.dart'; 13 +import '../../widgets/today/today_sleep_activity_cards.dart';
  14 +
14 import '../../widgets/today/premium_card.dart'; 15 import '../../widgets/today/premium_card.dart';
15 16
16 -class TodayTab extends GetView<TodayController> { 17 +class TodayTab extends StatefulWidget {
17 const TodayTab({super.key}); 18 const TodayTab({super.key});
18 19
  20 + @override
  21 + State<TodayTab> createState() => _TodayTabState();
  22 +}
  23 +
  24 +class _TodayTabState extends State<TodayTab> {
19 static const _bgColor = Color(0xFFF2F2F7); 25 static const _bgColor = Color(0xFFF2F2F7);
20 - static const _h1 = Color(0xFF0F0F11); 26 + static const _topBarHeight = 48.0;
  27 + static const _weekCalendarHeight = 58.0;
  28 + static const _topContentHeight = _topBarHeight + _weekCalendarHeight;
  29 + static const _weekRowsHeight = 50.0;
  30 + static const _selectedDayWidth = 32.0;
  31 +
  32 + final TodayController controller = Get.find<TodayController>();
  33 + final HomeController homeController = Get.find<HomeController>();
  34 + late final DateTime _firstSelectableDay;
  35 + late final DateTime _lastSelectableDay;
  36 + late final int _dayPageCount;
  37 + late final PageController _pageController;
  38 + late final Worker _selectedDateWorker;
  39 + late DateTime _focusedDay;
  40 + final ValueNotifier<double> _scrollOffset = ValueNotifier<double>(0);
  41 +
  42 + @override
  43 + void initState() {
  44 + super.initState();
  45 + final today = DateUtils.dateOnly(DateTime.now());
  46 + _firstSelectableDay = DateTime(today.year - 1);
  47 + _lastSelectableDay = today;
  48 + _dayPageCount =
  49 + _lastSelectableDay.difference(_firstSelectableDay).inDays + 1;
  50 +
  51 + final initialSelectedDay =
  52 + _clampSelectableDay(homeController.selectedDate.value);
  53 + _focusedDay = initialSelectedDay;
  54 + homeController.changeDate(initialSelectedDay);
  55 + _pageController = PageController(
  56 + initialPage: _pageIndexForDay(initialSelectedDay),
  57 + );
  58 + _selectedDateWorker = ever<DateTime>(
  59 + homeController.selectedDate,
  60 + _handleSelectedDateChanged,
  61 + );
  62 + }
  63 +
  64 + @override
  65 + void dispose() {
  66 + _selectedDateWorker.dispose();
  67 + _pageController.dispose();
  68 + _scrollOffset.dispose();
  69 + super.dispose();
  70 + }
21 71
22 @override 72 @override
23 Widget build(BuildContext context) { 73 Widget build(BuildContext context) {
24 - final homeCtrl = Get.find<HomeController>(); 74 + final topPadding = MediaQuery.paddingOf(context).top;
  75 + final pinnedHeaderHeight = topPadding + _topContentHeight;
25 76
26 return Container( 77 return Container(
27 color: _bgColor, 78 color: _bgColor,
28 child: Stack( 79 child: Stack(
29 children: [ 80 children: [
30 - CustomScrollView(  
31 - physics: const ClampingScrollPhysics(),  
32 - slivers: [  
33 - SliverToBoxAdapter(  
34 - child: SizedBox(  
35 - height: 323,  
36 - child: PageView.builder(  
37 - itemCount: 10,  
38 - itemBuilder: (context, index) {  
39 - return Container(  
40 - decoration: BoxDecoration(  
41 - gradient: LinearGradient(  
42 - begin: Alignment.topCenter,  
43 - end: Alignment.bottomCenter,  
44 - colors: [  
45 - const Color(0xFFC5B0FF),  
46 - const Color(0xFFF5F2FF)  
47 - ],  
48 - ),  
49 - ),  
50 - child: Column(  
51 - mainAxisAlignment: MainAxisAlignment.end,  
52 - children: [  
53 - Text('Page $index'),  
54 - Text('Page $index'),  
55 - Text('Page $index'),  
56 - Text(  
57 - 'Hi, 你今日的综合压力状态',  
58 - textAlign: TextAlign.center,  
59 - style: TextStyle(  
60 - color: const Color(0xFF78787D),  
61 - fontSize: 14,  
62 - fontWeight: FontWeight.w500,  
63 - ),  
64 - ),  
65 - Text(  
66 - '状态正常',  
67 - textAlign: TextAlign.center,  
68 - style: TextStyle(  
69 - color: const Color(0xFF0F0F11),  
70 - fontSize: 28,  
71 - fontWeight: FontWeight.w500,  
72 - ),  
73 - ),  
74 - Container(  
75 - width: 220,  
76 - height: 14,  
77 - margin: EdgeInsets.only(bottom: 36),  
78 - decoration: ShapeDecoration(  
79 - shape: RoundedRectangleBorder(  
80 - borderRadius: BorderRadius.circular(7)),  
81 - ),  
82 - child: Stack(  
83 - children: [  
84 - Positioned(  
85 - left: 0,  
86 - top: 0,  
87 - child: Opacity(  
88 - opacity: 0.50,  
89 - child: Container(  
90 - width: 20,  
91 - height: 14,  
92 - decoration: ShapeDecoration(  
93 - color: const Color(0xFFFF5279),  
94 - shape: RoundedRectangleBorder(  
95 - borderRadius:  
96 - BorderRadius.circular(7)),  
97 - ),  
98 - ),  
99 - ),  
100 - ),  
101 - Positioned(  
102 - left: 24,  
103 - top: 0,  
104 - child: Opacity(  
105 - opacity: 0.50,  
106 - child: Container(  
107 - width: 20,  
108 - height: 14,  
109 - decoration: ShapeDecoration(  
110 - color: const Color(0xFFFF9A6E),  
111 - shape: RoundedRectangleBorder(  
112 - borderRadius:  
113 - BorderRadius.circular(7)),  
114 - ),  
115 - ),  
116 - ),  
117 - ),  
118 - Positioned(  
119 - left: 48,  
120 - top: 0,  
121 - child: Container(  
122 - width: 148,  
123 - height: 14,  
124 - decoration: ShapeDecoration(  
125 - color: const Color(0xFF7B9BFB),  
126 - shape: RoundedRectangleBorder(  
127 - borderRadius:  
128 - BorderRadius.circular(7)),  
129 - ),  
130 - ),  
131 - ),  
132 - Positioned(  
133 - left: 200,  
134 - top: 0,  
135 - child: Opacity(  
136 - opacity: 0.50,  
137 - child: Container(  
138 - width: 20,  
139 - height: 14,  
140 - decoration: ShapeDecoration(  
141 - color: const Color(0xFF3BD39C),  
142 - shape: RoundedRectangleBorder(  
143 - borderRadius:  
144 - BorderRadius.circular(7)),  
145 - ),  
146 - ),  
147 - ),  
148 - ),  
149 - ],  
150 - ),  
151 - )  
152 - ],  
153 - ),  
154 - );  
155 - },  
156 - ),  
157 - ),  
158 - ),  
159 - // ── 各内容卡片 ──  
160 - SliverPadding(  
161 - padding: const EdgeInsets.symmetric(horizontal: 16),  
162 - sliver: SliverList(  
163 - delegate: SliverChildListDelegate([  
164 - Obx(() {  
165 - final isVip = Get.find<UserPreferencesStorage>()  
166 - .preferences  
167 - .value  
168 - .vipInfo  
169 - ?.isVip ??  
170 - false;  
171 - if (isVip) return const SizedBox.shrink();  
172 - return const PremiumCard();  
173 - }),  
174 - // 1. HRV 表盘引导 Banner  
175 - const TodayHrvAdBanner(),  
176 - const SizedBox(height: 12),  
177 -  
178 - // 3. HRV + 心率数字卡  
179 - const TodayHrvNumberCard(),  
180 - const SizedBox(height: 12),  
181 -  
182 - // 4. HRV 趋势图  
183 - const TodayHrvChartCard(),  
184 - const SizedBox(height: 12),  
185 - Container(  
186 - padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),  
187 - margin: const EdgeInsets.only(bottom: 12),  
188 - decoration: BoxDecoration(  
189 - color: Colors.white,  
190 - borderRadius: BorderRadius.circular(16),  
191 - ),  
192 - child: Text(  
193 - '立即测量HRV',  
194 - style: TextStyle(  
195 - color: const Color(0xFF0F0F11),  
196 - fontSize: 16,  
197 - fontWeight: FontWeight.w600,  
198 - ),  
199 - ),  
200 - ),  
201 - // 5. 每日行动  
202 - const TodayActionsCard(),  
203 -  
204 - // 底部安全间距(留给 TabBar 高度)  
205 - const SizedBox(height: 180),  
206 - ]),  
207 - ),  
208 - ),  
209 - ], 81 + NotificationListener<ScrollNotification>(
  82 + onNotification: _handleScrollNotification,
  83 + child: PageView.builder(
  84 + controller: _pageController,
  85 + itemCount: _dayPageCount,
  86 + onPageChanged: _onDayPageChanged,
  87 + itemBuilder: (context, index) {
  88 + return _buildDayScrollView(context, pinnedHeaderHeight);
  89 + },
  90 + ),
210 ), 91 ),
211 Positioned( 92 Positioned(
212 top: 0, 93 top: 0,
213 left: 0, 94 left: 0,
214 right: 0, 95 right: 0,
215 - height: 106 + MediaQuery.of(context).padding.top,  
216 - child: Container(  
217 - color: const Color(0xFFDDD2FF).withValues(alpha: 0.9), 96 + height: pinnedHeaderHeight,
  97 + child: ValueListenableBuilder<double>(
  98 + valueListenable: _scrollOffset,
  99 + builder: (context, offset, child) {
  100 + final opacity =
  101 + (offset / _topContentHeight).clamp(0.0, 1).toDouble();
  102 +
  103 + return Container(
  104 + color: const Color(0xFFDDD2FF).withValues(alpha: opacity),
  105 + );
  106 + },
218 ), 107 ),
219 ), 108 ),
220 Positioned( 109 Positioned(
221 - top: MediaQuery.of(context).padding.top, 110 + top: topPadding,
222 left: 0, 111 left: 0,
223 right: 0, 112 right: 0,
224 - child: Column(  
225 - mainAxisSize: MainAxisSize.min,  
226 - children: [  
227 - Container(  
228 - height: 48,  
229 - padding:  
230 - const EdgeInsets.symmetric(horizontal: 16, vertical: 4),  
231 - child: Row(  
232 - children: [  
233 - Text(  
234 - '今日',  
235 - textAlign: TextAlign.center,  
236 - style: TextStyle(  
237 - color: const Color(0xFF0F0F11),  
238 - fontSize: 24,  
239 - fontWeight: FontWeight.w600,  
240 - ), 113 + child: Obx(
  114 + () => Column(
  115 + mainAxisSize: MainAxisSize.min,
  116 + children: [
  117 + _TopDateBar(
  118 + title: _dateTitle,
  119 + showBackToToday: !_isSelectedToday,
  120 + onBackToToday: _backToToday,
  121 + ),
  122 + _buildWeekCalendar(),
  123 + ],
  124 + ),
  125 + ),
  126 + ),
  127 + ],
  128 + ),
  129 + );
  130 + }
  131 +
  132 + Widget _buildDayScrollView(
  133 + BuildContext context,
  134 + double pinnedHeaderHeight,
  135 + ) {
  136 + return CustomScrollView(
  137 + primary: false,
  138 + physics: const ClampingScrollPhysics(),
  139 + slivers: [
  140 + SliverToBoxAdapter(
  141 + child: Container(
  142 + height: 323 + pinnedHeaderHeight,
  143 + decoration: const BoxDecoration(
  144 + gradient: LinearGradient(
  145 + begin: Alignment.topCenter,
  146 + end: Alignment.bottomCenter,
  147 + colors: [
  148 + Color(0xFFC5B0FF),
  149 + Color(0xFFF5F2FF),
  150 + ],
  151 + ),
  152 + ),
  153 + child: Container(
  154 + margin: EdgeInsets.only(top: pinnedHeaderHeight),
  155 + child: Column(
  156 + mainAxisAlignment: MainAxisAlignment.end,
  157 + children: [
  158 + Obx(
  159 + () => Text(
  160 + controller.stressSubtitle.value,
  161 + textAlign: TextAlign.center,
  162 + style: const TextStyle(
  163 + color: Color(0xFF78787D),
  164 + fontSize: 14,
  165 + fontWeight: FontWeight.w500,
241 ), 166 ),
242 - const Spacer(),  
243 - // Pro 折扣徽章  
244 - Container(  
245 - width: 95,  
246 - height: 28.6,  
247 - decoration: BoxDecoration(  
248 - color: const Color(0xFFFFDF51),  
249 - borderRadius: BorderRadius.circular(27.6),  
250 - ),  
251 - child: Row(  
252 - mainAxisAlignment: MainAxisAlignment.center,  
253 - crossAxisAlignment: CrossAxisAlignment.center,  
254 - children: [  
255 - // Pro 皇冠图标  
256 - const Icon(  
257 - Icons.workspace_premium,  
258 - size: 20,  
259 - color: Color(0xFF0F0F11),  
260 - ),  
261 - const SizedBox(width: 2),  
262 - const Text(  
263 - '20%优惠',  
264 - style: TextStyle(  
265 - fontSize: 14,  
266 - fontWeight: FontWeight.w500,  
267 - color: Color(0xFF0F0F11),  
268 - ),  
269 - ),  
270 - ],  
271 - ), 167 + ),
  168 + ),
  169 + Obx(
  170 + () => Text(
  171 + controller.stressLabel.value,
  172 + textAlign: TextAlign.center,
  173 + style: const TextStyle(
  174 + color: Color(0xFF0F0F11),
  175 + fontSize: 28,
  176 + fontWeight: FontWeight.w500,
272 ), 177 ),
273 - ], 178 + ),
274 ), 179 ),
  180 + ],
  181 + ),
  182 + ),
  183 + ),
  184 + ),
  185 + SliverPadding(
  186 + padding: const EdgeInsets.symmetric(horizontal: 16),
  187 + sliver: SliverList(
  188 + delegate: SliverChildListDelegate([
  189 + Obx(
  190 + () => controller.showHealthDataAuthCard.value
  191 + ? TodayHealthDataAuthCard(
  192 + onAuthorizeTap: controller.requestHealthAuthorization,
  193 + )
  194 + : const SizedBox.shrink(),
  195 + ),
  196 + Obx(() {
  197 + return Get.find<UserStateService>().isVip
  198 + ? const SizedBox.shrink()
  199 + : const PremiumCard();
  200 + }),
  201 + const TodayHrvAdBanner(),
  202 + const TodayPartnerAdBanner(),
  203 + const TodayHrvNumberCard(),
  204 + const SizedBox(height: 12),
  205 + const TodayHrvChartCard(),
  206 + const SizedBox(height: 12),
  207 + const TodaySleepCard(),
  208 + const TodayActivityCard(),
  209 + const SizedBox(height: 4),
  210 + Container(
  211 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
  212 + decoration: BoxDecoration(
  213 + color: Colors.white,
  214 + borderRadius: BorderRadius.circular(16),
  215 + ),
  216 + child: const Text(
  217 + '立即测量HRV',
  218 + style: TextStyle(
  219 + color: Color(0xFF0F0F11),
  220 + fontSize: 16,
  221 + fontWeight: FontWeight.w600,
  222 + ),
  223 + ),
  224 + ),
  225 + Container(
  226 + alignment: Alignment.center,
  227 + margin: EdgeInsets.only(
  228 + bottom: 16 + MediaQuery.paddingOf(context).bottom,
  229 + top: 28,
  230 + ),
  231 + child: Image.asset(
  232 + 'assets/images/common/ic_bottom_slogan.png',
  233 + width: 223,
  234 + height: 35,
275 ), 235 ),
276 - TableCalendar( 236 + ),
  237 + ]),
  238 + ),
  239 + ),
  240 + ],
  241 + );
  242 + }
  243 +
  244 + Widget _buildWeekCalendar() {
  245 + return SizedBox(
  246 + height: _weekCalendarHeight,
  247 + child: Padding(
  248 + padding: const EdgeInsets.fromLTRB(13, 4, 18, 4),
  249 + child: Row(
  250 + crossAxisAlignment: CrossAxisAlignment.start,
  251 + children: [
  252 + Expanded(
  253 + child: SizedBox(
  254 + height: _weekRowsHeight,
  255 + child: TableCalendar(
277 calendarFormat: CalendarFormat.week, 256 calendarFormat: CalendarFormat.week,
278 headerVisible: false, 257 headerVisible: false,
279 - focusedDay: DateTime.now(),  
280 - firstDay: DateTime(2026),  
281 - lastDay: DateTime(2027), 258 + daysOfWeekHeight: _weekRowsHeight / 2,
  259 + rowHeight: _weekRowsHeight / 2,
  260 + focusedDay: _focusedDay,
  261 + firstDay: _firstSelectableDay,
  262 + lastDay: _lastSelectableDay,
  263 + currentDay: _lastSelectableDay,
  264 + startingDayOfWeek: StartingDayOfWeek.monday,
  265 + availableCalendarFormats: const {
  266 + CalendarFormat.week: '',
  267 + },
  268 + availableGestures: AvailableGestures.horizontalSwipe,
  269 + enabledDayPredicate: (day) => !_isAfterToday(day),
  270 + selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
  271 + onDaySelected: _onDaySelected,
  272 + onPageChanged: _onCalendarPageChanged,
  273 + calendarStyle: const CalendarStyle(
  274 + cellMargin: EdgeInsets.zero,
  275 + cellPadding: EdgeInsets.zero,
  276 + isTodayHighlighted: false,
  277 + outsideDaysVisible: true,
  278 + ),
  279 + daysOfWeekStyle: const DaysOfWeekStyle(
  280 + decoration: BoxDecoration(),
  281 + ),
  282 + calendarBuilders: CalendarBuilders(
  283 + dowBuilder: (context, day) => _buildWeekdayCell(day),
  284 + defaultBuilder: (context, day, focusedDay) =>
  285 + _buildDateCell(day),
  286 + disabledBuilder: (context, day, focusedDay) =>
  287 + _buildDateCell(day),
  288 + outsideBuilder: (context, day, focusedDay) =>
  289 + _buildDateCell(day),
  290 + selectedBuilder: (context, day, focusedDay) =>
  291 + _buildDateCell(day, selected: true),
  292 + ),
282 ), 293 ),
283 - ], 294 + ),
  295 + ),
  296 + const SizedBox(width: 3),
  297 + Container(
  298 + width: 1,
  299 + height: 20,
  300 + margin: const EdgeInsets.only(top: 15),
  301 + color: context.colors.textPrimary.withValues(alpha: 0.2),
284 ), 302 ),
  303 + const SizedBox(width: 12),
  304 + Padding(
  305 + padding: const EdgeInsets.only(top: 15),
  306 + child: Image.asset(
  307 + 'assets/images/common/ic_calendar.png',
  308 + width: 20,
  309 + height: 20,
  310 + color: context.colors.textPrimary.withValues(alpha: 0.6),
  311 + ),
  312 + ),
  313 + ],
  314 + ),
  315 + ),
  316 + );
  317 + }
  318 +
  319 + bool _handleScrollNotification(ScrollNotification notification) {
  320 + if (notification.metrics.axis != Axis.vertical) return false;
  321 +
  322 + final offset = notification.metrics.pixels.clamp(0.0, double.infinity);
  323 + if (_scrollOffset.value != offset) {
  324 + _scrollOffset.value = offset.toDouble();
  325 + }
  326 + return false;
  327 + }
  328 +
  329 + Widget _buildWeekdayCell(DateTime day) {
  330 + final selected = isSameDay(_selectedDay, day);
  331 +
  332 + return Center(
  333 + child: Container(
  334 + width: _selectedDayWidth,
  335 + height: _weekRowsHeight / 2,
  336 + alignment: Alignment.center,
  337 + decoration: selected
  338 + ? BoxDecoration(
  339 + color: context.colors.primary,
  340 + borderRadius: const BorderRadius.vertical(
  341 + top: Radius.circular(22),
  342 + ),
  343 + )
  344 + : null,
  345 + child: Text(
  346 + isSameDay(day, _lastSelectableDay) ? '今' : _weekdayText(day),
  347 + style: TextStyle(
  348 + color: _calendarTextColor(day, selected: selected),
  349 + fontSize: 12,
  350 + fontWeight: FontWeight.w500,
285 ), 351 ),
286 - ], 352 + ),
287 ), 353 ),
288 ); 354 );
289 } 355 }
290 356
291 - bool _isSameDay(DateTime a, DateTime b) =>  
292 - a.year == b.year && a.month == b.month && a.day == b.day; 357 + Widget _buildDateCell(DateTime day, {bool selected = false}) {
  358 + return Center(
  359 + child: Container(
  360 + width: _selectedDayWidth,
  361 + height: _weekRowsHeight / 2,
  362 + alignment: Alignment.center,
  363 + decoration: selected
  364 + ? BoxDecoration(
  365 + color: context.colors.primary,
  366 + borderRadius: BorderRadius.vertical(
  367 + bottom: Radius.circular(22),
  368 + ),
  369 + )
  370 + : null,
  371 + child: Text(
  372 + '${day.day}',
  373 + style: TextStyle(
  374 + color: _calendarTextColor(day, selected: selected),
  375 + fontSize: 12,
  376 + fontWeight: FontWeight.w500,
  377 + ),
  378 + ),
  379 + ),
  380 + );
  381 + }
  382 +
  383 + void _handleSelectedDateChanged(DateTime selectedDate) {
  384 + final normalizedDay = _clampSelectableDay(selectedDate);
  385 + if (selectedDate != normalizedDay) {
  386 + homeController.changeDate(normalizedDay);
  387 + return;
  388 + }
  389 +
  390 + if (mounted) {
  391 + setState(() {
  392 + _focusedDay = normalizedDay;
  393 + });
  394 + }
  395 + _syncPagerToSelectedDay(normalizedDay);
  396 + }
  397 +
  398 + void _onDayPageChanged(int page) {
  399 + _selectDate(_dateForPage(page));
  400 + }
  401 +
  402 + void _onDaySelected(DateTime selectedDay, DateTime focusedDay) {
  403 + _selectDate(selectedDay, focusedDay: focusedDay);
  404 + }
  405 +
  406 + void _onCalendarPageChanged(DateTime focusedDay) {
  407 + final selectedWeekdayOffset = _selectedDay.weekday - DateTime.monday;
  408 + final nextSelectedDay = _weekStart(focusedDay).add(
  409 + Duration(days: selectedWeekdayOffset),
  410 + );
  411 + _selectDate(nextSelectedDay, focusedDay: focusedDay);
  412 + }
  413 +
  414 + void _backToToday() {
  415 + _selectDate(_lastSelectableDay);
  416 + }
  417 +
  418 + void _selectDate(DateTime selectedDay, {DateTime? focusedDay}) {
  419 + final normalizedSelectedDay = _clampSelectableDay(selectedDay);
  420 + final normalizedFocusedDay = _clampSelectableDay(
  421 + focusedDay ?? normalizedSelectedDay,
  422 + );
  423 +
  424 + setState(() {
  425 + _focusedDay = normalizedFocusedDay;
  426 + });
  427 + homeController.changeDate(normalizedSelectedDay);
  428 + }
  429 +
  430 + void _syncPagerToSelectedDay(DateTime selectedDay) {
  431 + final targetPage = _pageIndexForDay(selectedDay);
  432 + if (!_pageController.hasClients) {
  433 + WidgetsBinding.instance.addPostFrameCallback((_) {
  434 + if (!mounted) return;
  435 + _syncPagerToSelectedDay(selectedDay);
  436 + });
  437 + return;
  438 + }
  439 +
  440 + final currentPage =
  441 + _pageController.page?.round() ?? _pageController.initialPage;
  442 + if (currentPage == targetPage) return;
  443 +
  444 + _pageController.animateToPage(
  445 + targetPage,
  446 + duration: const Duration(milliseconds: 220),
  447 + curve: Curves.easeOut,
  448 + );
  449 + }
  450 +
  451 + DateTime _dateForPage(int page) {
  452 + return _firstSelectableDay.add(Duration(days: page));
  453 + }
  454 +
  455 + int _pageIndexForDay(DateTime day) {
  456 + final dayIndex =
  457 + _clampSelectableDay(day).difference(_firstSelectableDay).inDays;
  458 + return dayIndex.clamp(0, _dayPageCount - 1).toInt();
  459 + }
  460 +
  461 + DateTime _clampSelectableDay(DateTime day) {
  462 + final normalizedDay = DateUtils.dateOnly(day);
  463 + if (normalizedDay.isBefore(_firstSelectableDay)) return _firstSelectableDay;
  464 + if (normalizedDay.isAfter(_lastSelectableDay)) return _lastSelectableDay;
  465 + return normalizedDay;
  466 + }
  467 +
  468 + DateTime _weekStart(DateTime day) {
  469 + final normalizedDay = DateUtils.dateOnly(day);
  470 + return normalizedDay.subtract(Duration(days: normalizedDay.weekday - 1));
  471 + }
  472 +
  473 + DateTime get _selectedDay =>
  474 + _clampSelectableDay(homeController.selectedDate.value);
  475 +
  476 + String get _dateTitle {
  477 + final today = _lastSelectableDay;
  478 + if (isSameDay(_selectedDay, today)) return '今日';
  479 + if (isSameDay(_selectedDay, today.subtract(const Duration(days: 1)))) {
  480 + return '昨日';
  481 + }
  482 + if (isSameDay(_selectedDay, today.add(const Duration(days: 1)))) {
  483 + return '明日';
  484 + }
  485 + return '${_selectedDay.month}${_selectedDay.day}日';
  486 + }
  487 +
  488 + bool get _isSelectedToday => isSameDay(_selectedDay, _lastSelectableDay);
  489 +
  490 + bool _isAfterToday(DateTime day) {
  491 + final normalizedDay = DateUtils.dateOnly(day);
  492 + return normalizedDay.isAfter(_lastSelectableDay);
  493 + }
  494 +
  495 + Color _calendarTextColor(DateTime day, {required bool selected}) {
  496 + if (selected) return Colors.white;
  497 + return context.colors.textPrimary
  498 + .withValues(alpha: _isAfterToday(day) ? 0.2 : 0.6);
  499 + }
  500 +
  501 + String _weekdayText(DateTime day) {
  502 + switch (day.weekday) {
  503 + case DateTime.monday:
  504 + return '一';
  505 + case DateTime.tuesday:
  506 + return '二';
  507 + case DateTime.wednesday:
  508 + return '三';
  509 + case DateTime.thursday:
  510 + return '四';
  511 + case DateTime.friday:
  512 + return '五';
  513 + case DateTime.saturday:
  514 + return '六';
  515 + case DateTime.sunday:
  516 + default:
  517 + return '日';
  518 + }
  519 + }
  520 +}
  521 +
  522 +class _TopDateBar extends StatelessWidget {
  523 + const _TopDateBar({
  524 + required this.title,
  525 + required this.showBackToToday,
  526 + required this.onBackToToday,
  527 + });
  528 +
  529 + final String title;
  530 + final bool showBackToToday;
  531 + final VoidCallback onBackToToday;
  532 +
  533 + @override
  534 + Widget build(BuildContext context) {
  535 + return SizedBox(
  536 + height: _TodayTabState._topBarHeight,
  537 + child: Padding(
  538 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  539 + child: Row(
  540 + children: [
  541 + Text(
  542 + title,
  543 + textAlign: TextAlign.center,
  544 + style: TextStyle(
  545 + color: context.colors.textPrimary,
  546 + fontSize: 24,
  547 + fontWeight: FontWeight.w600,
  548 + ),
  549 + ),
  550 + if (showBackToToday) ...[
  551 + const SizedBox(width: 12),
  552 + GestureDetector(
  553 + behavior: HitTestBehavior.opaque,
  554 + onTap: onBackToToday,
  555 + child: SizedBox(
  556 + height: 24,
  557 + child: Row(
  558 + children: [
  559 + Image.asset(
  560 + 'assets/images/common/ic_back_to_today.png',
  561 + width: 12,
  562 + height: 12,
  563 + color: context.colors.primary,
  564 + ),
  565 + const SizedBox(width: 4),
  566 + Text(
  567 + '回今天',
  568 + style: TextStyle(
  569 + color: context.colors.primary,
  570 + fontSize: 12,
  571 + fontWeight: FontWeight.w400,
  572 + ),
  573 + ),
  574 + ],
  575 + ),
  576 + ),
  577 + ),
  578 + ],
  579 + const Spacer(),
  580 + Obx(() {
  581 + return Get.find<UserStateService>().isVip
  582 + ? const SizedBox.shrink()
  583 + : Container(
  584 + height: 29,
  585 + constraints: const BoxConstraints(minWidth: 95),
  586 + padding: const EdgeInsets.symmetric(horizontal: 7),
  587 + decoration: BoxDecoration(
  588 + color: const Color(0xFFFFDF51),
  589 + borderRadius: BorderRadius.circular(27.6),
  590 + ),
  591 + child: Row(
  592 + mainAxisAlignment: MainAxisAlignment.center,
  593 + crossAxisAlignment: CrossAxisAlignment.center,
  594 + mainAxisSize: MainAxisSize.min,
  595 + children: [
  596 + Image.asset(
  597 + 'assets/images/common/ic_pro.png',
  598 + width: 20,
  599 + height: 20,
  600 + color: context.colors.textPrimary,
  601 + ),
  602 + const SizedBox(width: 2),
  603 + Text(
  604 + '20%优惠',
  605 + style: TextStyle(
  606 + fontSize: 14,
  607 + fontWeight: FontWeight.w500,
  608 + color: context.colors.textPrimary,
  609 + ),
  610 + ),
  611 + ],
  612 + ),
  613 + );
  614 + }),
  615 + ],
  616 + ),
  617 + ),
  618 + );
  619 + }
293 } 620 }
@@ -17,7 +17,8 @@ class TrendTab extends StatefulWidget { @@ -17,7 +17,8 @@ class TrendTab extends StatefulWidget {
17 State<TrendTab> createState() => _TrendTabState(); 17 State<TrendTab> createState() => _TrendTabState();
18 } 18 }
19 19
20 -class _TrendTabState extends State<TrendTab> with SingleTickerProviderStateMixin { 20 +class _TrendTabState extends State<TrendTab>
  21 + with SingleTickerProviderStateMixin {
21 late final TabController _tabController; 22 late final TabController _tabController;
22 late final TrendController _trendController; 23 late final TrendController _trendController;
23 late final Worker _rxWorker; 24 late final Worker _rxWorker;
@@ -36,7 +37,7 @@ class _TrendTabState extends State<TrendTab> with SingleTickerProviderStateMixin @@ -36,7 +37,7 @@ class _TrendTabState extends State<TrendTab> with SingleTickerProviderStateMixin
36 void initState() { 37 void initState() {
37 super.initState(); 38 super.initState();
38 _trendController = Get.find<TrendController>(); 39 _trendController = Get.find<TrendController>();
39 - 40 +
40 // 初始化 TabController 41 // 初始化 TabController
41 _tabController = TabController( 42 _tabController = TabController(
42 length: 3, 43 length: 3,
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
1 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 4
@@ -11,8 +12,6 @@ class DfTabBar extends StatelessWidget { @@ -11,8 +12,6 @@ class DfTabBar extends StatelessWidget {
11 required this.onTap, 12 required this.onTap,
12 }); 13 });
13 14
14 - static const _brandColor = Color(0xFF845EEE);  
15 - static const _unselectedColor = Color(0xFF0F0F11);  
16 static const _selectedBgColor = Color(0xFFF3F3F3); 15 static const _selectedBgColor = Color(0xFFF3F3F3);
17 16
18 static const _tabIcons = [ 17 static const _tabIcons = [
@@ -22,6 +21,13 @@ class DfTabBar extends StatelessWidget { @@ -22,6 +21,13 @@ class DfTabBar extends StatelessWidget {
22 'assets/images/tabbar/icon_my.png', 21 'assets/images/tabbar/icon_my.png',
23 ]; 22 ];
24 23
  24 + static const _selectedTabIcons = [
  25 + 'assets/images/tabbar/icon_today_selected.png',
  26 + 'assets/images/tabbar/icon_trend_selected.png',
  27 + 'assets/images/tabbar/icon_friends_selected.png',
  28 + 'assets/images/tabbar/icon_my_selected.png',
  29 + ];
  30 +
25 @override 31 @override
26 Widget build(BuildContext context) { 32 Widget build(BuildContext context) {
27 final l10n = context.l10n; 33 final l10n = context.l10n;
@@ -83,14 +89,12 @@ class DfTabBar extends StatelessWidget { @@ -83,14 +89,12 @@ class DfTabBar extends StatelessWidget {
83 mainAxisAlignment: MainAxisAlignment.center, 89 mainAxisAlignment: MainAxisAlignment.center,
84 children: [ 90 children: [
85 Image.asset( 91 Image.asset(
86 - _tabIcons[i], 92 + isSelected
  93 + ? _selectedTabIcons[i]
  94 + : _tabIcons[i],
87 width: 24, 95 width: 24,
88 height: 24, 96 height: 24,
89 gaplessPlayback: true, 97 gaplessPlayback: true,
90 - color: isSelected  
91 - ? _brandColor  
92 - : _unselectedColor,  
93 - colorBlendMode: BlendMode.srcIn,  
94 ), 98 ),
95 const SizedBox(height: 2), 99 const SizedBox(height: 2),
96 Text( 100 Text(
@@ -99,8 +103,8 @@ class DfTabBar extends StatelessWidget { @@ -99,8 +103,8 @@ class DfTabBar extends StatelessWidget {
99 fontSize: 9, 103 fontSize: 9,
100 fontWeight: FontWeight.w500, 104 fontWeight: FontWeight.w500,
101 color: isSelected 105 color: isSelected
102 - ? _brandColor  
103 - : _unselectedColor, 106 + ? context.colors.primary
  107 + : context.colors.textSecondary,
104 height: 1.4, 108 height: 1.4,
105 ), 109 ),
106 ), 110 ),
1 -import 'package:flutter/material.dart';  
2 -import 'package:get/get.dart';  
3 -  
4 -import '../../controllers/today_controller.dart';  
5 -  
6 -/// Figma: 每日行动 — 睡眠/健身/步数卡片,横向可滑动  
7 -class TodayActionsCard extends GetView<TodayController> {  
8 - const TodayActionsCard({super.key});  
9 -  
10 - @override  
11 - Widget build(BuildContext context) {  
12 - return Column(  
13 - crossAxisAlignment: CrossAxisAlignment.start,  
14 - children: [  
15 - // 标题  
16 - const Padding(  
17 - padding: EdgeInsets.only(bottom: 12),  
18 - child: Text(  
19 - '每日行动',  
20 - style: TextStyle(  
21 - fontSize: 15,  
22 - fontWeight: FontWeight.w600,  
23 - color: Color(0xFF0F0F11),  
24 - ),  
25 - ),  
26 - ),  
27 - // 横向滑动卡片列表  
28 - SizedBox(  
29 - height: 110,  
30 - child: ListView.separated(  
31 - scrollDirection: Axis.horizontal,  
32 - itemCount: controller.dailyActions.length,  
33 - separatorBuilder: (_, __) => const SizedBox(width: 12),  
34 - itemBuilder: (_, i) {  
35 - final item = controller.dailyActions[i];  
36 - return _ActionCard(item: item);  
37 - },  
38 - ),  
39 - ),  
40 - ],  
41 - );  
42 - }  
43 -}  
44 -  
45 -class _ActionCard extends StatelessWidget {  
46 - final DailyActionItem item;  
47 -  
48 - const _ActionCard({required this.item});  
49 -  
50 - static const _brandColor = Color(0xFF845EEE);  
51 - static const _h1 = Color(0xFF0F0F11);  
52 - static const _h2 = Color(0xFF666666);  
53 -  
54 - @override  
55 - Widget build(BuildContext context) {  
56 - return Container(  
57 - width: 140,  
58 - padding: const EdgeInsets.all(14),  
59 - decoration: BoxDecoration(  
60 - color: Colors.white,  
61 - borderRadius: BorderRadius.circular(16),  
62 - ),  
63 - child: Column(  
64 - crossAxisAlignment: CrossAxisAlignment.start,  
65 - children: [  
66 - // 标题行  
67 - Row(  
68 - mainAxisAlignment: MainAxisAlignment.spaceBetween,  
69 - children: [  
70 - Text(  
71 - item.label,  
72 - style: const TextStyle(  
73 - fontSize: 13,  
74 - color: _brandColor,  
75 - fontWeight: FontWeight.w500,  
76 - ),  
77 - ),  
78 - const Icon(Icons.chevron_right, size: 16, color: _h2),  
79 - ],  
80 - ),  
81 - const Spacer(),  
82 - // 数值  
83 - Row(  
84 - crossAxisAlignment: CrossAxisAlignment.end,  
85 - children: [  
86 - Text(  
87 - item.value,  
88 - style: const TextStyle(  
89 - fontSize: 26,  
90 - fontWeight: FontWeight.w700,  
91 - color: _h1,  
92 - height: 1,  
93 - ),  
94 - ),  
95 - const SizedBox(width: 3),  
96 - Padding(  
97 - padding: const EdgeInsets.only(bottom: 2),  
98 - child: Text(  
99 - item.unit,  
100 - style: const TextStyle(  
101 - fontSize: 12,  
102 - color: _h2,  
103 - ),  
104 - ),  
105 - ),  
106 - if (item.subValue != null) ...[  
107 - const SizedBox(width: 2),  
108 - Text(  
109 - item.subValue!,  
110 - style: const TextStyle(  
111 - fontSize: 20,  
112 - fontWeight: FontWeight.w700,  
113 - color: _h1,  
114 - height: 1,  
115 - ),  
116 - ),  
117 - const SizedBox(width: 3),  
118 - Padding(  
119 - padding: const EdgeInsets.only(bottom: 2),  
120 - child: Text(  
121 - item.subUnit ?? '',  
122 - style: const TextStyle(  
123 - fontSize: 12,  
124 - color: _h2,  
125 - ),  
126 - ),  
127 - ),  
128 - ],  
129 - ],  
130 - ),  
131 - ],  
132 - ),  
133 - );  
134 - }  
135 -}  
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:flutter/material.dart';
  4 +
  5 +class TodayHealthDataAuthCard extends StatelessWidget {
  6 + const TodayHealthDataAuthCard({
  7 + super.key,
  8 + this.onAuthorizeTap,
  9 + });
  10 +
  11 + final VoidCallback? onAuthorizeTap;
  12 +
  13 + @override
  14 + Widget build(BuildContext context) {
  15 + final colors = context.colors;
  16 + final l10n = context.l10n;
  17 +
  18 + return Container(
  19 + margin: const EdgeInsets.only(bottom: 12),
  20 + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0),
  21 + decoration: BoxDecoration(
  22 + color: context.theme.cardColor,
  23 + borderRadius: BorderRadius.circular(16),
  24 + ),
  25 + child: Column(
  26 + mainAxisSize: MainAxisSize.min,
  27 + children: [
  28 + Image.asset(
  29 + 'assets/images/common/ic_apple_health.webp',
  30 + width: 56,
  31 + height: 53,
  32 + ),
  33 + const SizedBox(height: 12),
  34 + Text(
  35 + l10n.todayHealthDataAuthTitle,
  36 + textAlign: TextAlign.center,
  37 + style: TextStyle(
  38 + color: colors.textPrimary,
  39 + fontSize: 16,
  40 + fontWeight: FontWeight.w600,
  41 + height: 1.2,
  42 + ),
  43 + ),
  44 + const SizedBox(height: 5),
  45 + Text(
  46 + l10n.todayHealthDataAuthDescription,
  47 + style: TextStyle(
  48 + color: colors.textSecondary,
  49 + fontSize: 13,
  50 + fontWeight: FontWeight.w400,
  51 + height: 1.35,
  52 + ),
  53 + ),
  54 + const SizedBox(height: 16),
  55 + Divider(
  56 + height: 1,
  57 + thickness: 1,
  58 + color: colors.border,
  59 + ),
  60 + const SizedBox(height: 20),
  61 + GestureDetector(
  62 + onTap: onAuthorizeTap,
  63 + behavior: HitTestBehavior.opaque,
  64 + child: Text(
  65 + l10n.todayHealthDataAuthAction,
  66 + style: TextStyle(
  67 + color: colors.primary,
  68 + fontSize: 12,
  69 + fontWeight: FontWeight.w500,
  70 + height: 1.2,
  71 + ),
  72 + ),
  73 + ),
  74 + const SizedBox(height: 20),
  75 + ],
  76 + ),
  77 + );
  78 + }
  79 +}
  1 +import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
1 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
2 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
3 5
4 import '../../controllers/today_controller.dart'; 6 import '../../controllers/today_controller.dart';
5 7
6 -/// Figma: 添加 HRV 主题表盘引导 Banner,右上角关闭按钮  
7 class TodayHrvAdBanner extends GetView<TodayController> { 8 class TodayHrvAdBanner extends GetView<TodayController> {
8 const TodayHrvAdBanner({super.key}); 9 const TodayHrvAdBanner({super.key});
9 10
@@ -12,63 +13,169 @@ class TodayHrvAdBanner extends GetView<TodayController> { @@ -12,63 +13,169 @@ class TodayHrvAdBanner extends GetView<TodayController> {
12 return Obx(() { 13 return Obx(() {
13 if (!controller.showHrvAdBanner.value) return const SizedBox.shrink(); 14 if (!controller.showHrvAdBanner.value) return const SizedBox.shrink();
14 15
15 - return Container(  
16 - decoration: BoxDecoration(  
17 - color: const Color(0xFF1E1B2E),  
18 - borderRadius: BorderRadius.circular(16), 16 + return _TodayGuideBanner(
  17 + title: '点击添加HRV主题表盘',
  18 + subtitle: '时刻掌握自身健康波动',
  19 + right: Container(
  20 + padding: const EdgeInsets.all(4),
  21 + decoration: BoxDecoration(
  22 + color: Colors.white,
  23 + shape: BoxShape.circle,
  24 + ),
  25 + child: Image.asset(
  26 + 'assets/images/common/ic_chevron_right.png',
  27 + width: 20,
  28 + height: 20,
  29 + color: context.colors.primary,
  30 + ),
19 ), 31 ),
20 - clipBehavior: Clip.hardEdge,  
21 - child: Stack(  
22 - children: [  
23 - // 内容区  
24 - Padding(  
25 - padding: const EdgeInsets.fromLTRB(16, 16, 48, 16),  
26 - child: Column(  
27 - crossAxisAlignment: CrossAxisAlignment.start,  
28 - children: const [  
29 - Text(  
30 - '点击添加HRV主题表盘',  
31 - style: TextStyle(  
32 - fontSize: 16,  
33 - fontWeight: FontWeight.w600,  
34 - color: Colors.white,  
35 - ),  
36 - ),  
37 - SizedBox(height: 4),  
38 - Text(  
39 - '时刻掌握自身健康波动',  
40 - style: TextStyle(  
41 - fontSize: 13,  
42 - color: Color(0xFF999999),  
43 - ),  
44 - ),  
45 - ],  
46 - ), 32 + onClose: controller.dismissHrvAdBanner,
  33 + );
  34 + });
  35 + }
  36 +}
  37 +
  38 +/// Figma: 添加亲密联系人 Banner,右上角关闭按钮
  39 +class TodayPartnerAdBanner extends GetView<TodayController> {
  40 + const TodayPartnerAdBanner({super.key});
  41 +
  42 + @override
  43 + Widget build(BuildContext context) {
  44 + final colors = context.colors;
  45 +
  46 + return Obx(() {
  47 + final shouldShow = controller.showPartnerAdBanner.value &&
  48 + !controller.userStateService.isBound;
  49 + if (!shouldShow) return const SizedBox.shrink();
  50 +
  51 + return _TodayGuideBanner(
  52 + title: '添加亲密联系人',
  53 + subtitle: '多一个人关注你的健康',
  54 + right: GestureDetector(
  55 + onTap: () => Get.toNamed(AppRoutes.bindPartner),
  56 + behavior: HitTestBehavior.opaque,
  57 + child: Container(
  58 + width: 72,
  59 + height: 28,
  60 + alignment: Alignment.center,
  61 + decoration: BoxDecoration(
  62 + color: colors.primary,
  63 + borderRadius: BorderRadius.circular(23),
47 ), 64 ),
48 - // 关闭按钮  
49 - Positioned(  
50 - top: 8,  
51 - right: 8,  
52 - child: GestureDetector(  
53 - onTap: controller.dismissHrvAdBanner,  
54 - child: Container(  
55 - width: 24,  
56 - height: 24,  
57 - decoration: const BoxDecoration(  
58 - color: Color(0x66000000),  
59 - shape: BoxShape.circle,  
60 - ),  
61 - child: const Icon(  
62 - Icons.close,  
63 - size: 14,  
64 - color: Colors.white,  
65 - ),  
66 - ), 65 + child: Text(
  66 + '加好友',
  67 + style: TextStyle(
  68 + fontSize: 12,
  69 + fontWeight: FontWeight.w500,
  70 + color: Colors.white,
  71 + height: 17 / 12,
67 ), 72 ),
68 ), 73 ),
69 - ], 74 + ),
70 ), 75 ),
  76 + onClose: controller.dismissPartnerAdBanner,
71 ); 77 );
72 }); 78 });
73 } 79 }
74 } 80 }
  81 +
  82 +class _TodayGuideBanner extends StatelessWidget {
  83 + const _TodayGuideBanner({
  84 + required this.title,
  85 + required this.subtitle,
  86 + required this.onClose,
  87 + this.right,
  88 + });
  89 +
  90 + final String title;
  91 + final String subtitle;
  92 + final VoidCallback onClose;
  93 + final Widget? right;
  94 +
  95 + @override
  96 + Widget build(BuildContext context) {
  97 + final colors = context.colors;
  98 +
  99 + return Container(
  100 + height: 65,
  101 + margin: const EdgeInsets.only(bottom: 12),
  102 + decoration: BoxDecoration(
  103 + color: colors.brandBackgroundLight,
  104 + borderRadius: BorderRadius.circular(16),
  105 + ),
  106 + clipBehavior: Clip.hardEdge,
  107 + child: Stack(
  108 + children: [
  109 + Row(
  110 + crossAxisAlignment: CrossAxisAlignment.center,
  111 + children: [
  112 + SizedBox(
  113 + width: 100,
  114 + height: double.infinity,
  115 + child: ColoredBox(
  116 + color: colors.chartPink.withValues(alpha: 0.72),
  117 + child: Center(
  118 + child: Text(
  119 + '插图',
  120 + style: TextStyle(
  121 + fontSize: 12,
  122 + color: colors.chartPink,
  123 + height: 1.2,
  124 + ),
  125 + ),
  126 + ),
  127 + ),
  128 + ),
  129 + const SizedBox(width: 12),
  130 + Expanded(
  131 + child: Column(
  132 + mainAxisAlignment: MainAxisAlignment.center,
  133 + crossAxisAlignment: CrossAxisAlignment.start,
  134 + children: [
  135 + Text(
  136 + title,
  137 + maxLines: 1,
  138 + overflow: TextOverflow.ellipsis,
  139 + style: TextStyle(
  140 + fontSize: 14,
  141 + fontWeight: FontWeight.w500,
  142 + color: colors.textPrimary,
  143 + height: 20 / 14,
  144 + ),
  145 + ),
  146 + const SizedBox(height: 2),
  147 + Text(
  148 + subtitle,
  149 + maxLines: 1,
  150 + overflow: TextOverflow.ellipsis,
  151 + style: TextStyle(
  152 + fontSize: 12,
  153 + color: colors.textSecondary,
  154 + height: 17 / 12,
  155 + ),
  156 + ),
  157 + ],
  158 + ),
  159 + ),
  160 + if (right != null) ...[
  161 + const SizedBox(width: 8),
  162 + right!,
  163 + ],
  164 + const SizedBox(width: 20),
  165 + ],
  166 + ),
  167 + Positioned(
  168 + top: 6,
  169 + right: 6,
  170 + child: GestureDetector(
  171 + onTap: onClose,
  172 + behavior: HitTestBehavior.opaque,
  173 + child: Image.asset('assets/images/common/ic_close_round.png',
  174 + width: 13.7, height: 13.7),
  175 + ),
  176 + ),
  177 + ],
  178 + ),
  179 + );
  180 + }
  181 +}
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
1 import 'package:fl_chart/fl_chart.dart'; 2 import 'package:fl_chart/fl_chart.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
@@ -18,322 +19,342 @@ class TodayHrvChartCard extends GetView<TodayController> { @@ -18,322 +19,342 @@ class TodayHrvChartCard extends GetView<TodayController> {
18 static const _h3 = Color(0xFF999999); 19 static const _h3 = Color(0xFF999999);
19 static const _h5 = Color(0xFFCCCCCC); 20 static const _h5 = Color(0xFFCCCCCC);
20 21
21 - /// 实时压力 mock:按小时 0~18,Y 轴 0~80  
22 - static const List<double> _mockStressByHour = [  
23 - 18,  
24 - 22,  
25 - 28,  
26 - 35,  
27 - 32,  
28 - 40,  
29 - 48,  
30 - 55,  
31 - 62,  
32 - 58,  
33 - 52,  
34 - 45,  
35 - 38,  
36 - 42,  
37 - 50,  
38 - 56,  
39 - 64,  
40 - 58,  
41 - 46,  
42 - ];  
43 -  
44 @override 22 @override
45 Widget build(BuildContext context) { 23 Widget build(BuildContext context) {
46 - final spots =  
47 - controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList(); 24 + return Obx(() {
  25 + final hrvSpots =
  26 + controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList();
  27 + final stressPoints = controller.stressChartData.toList();
48 28
49 - return Container(  
50 - padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),  
51 - decoration: BoxDecoration(  
52 - color: Colors.white,  
53 - borderRadius: BorderRadius.circular(16),  
54 - ),  
55 - child: Column(  
56 - crossAxisAlignment: CrossAxisAlignment.start,  
57 - children: [  
58 - Row(  
59 - children: [  
60 - const Text(  
61 - '今日HRV趋势',  
62 - style: TextStyle(  
63 - color: Color(0xFF0F0F11),  
64 - fontSize: 16,  
65 - fontWeight: FontWeight.w600,  
66 - ),  
67 - ),  
68 - const SizedBox(width: 4),  
69 - const Icon(Icons.info_outline, size: 14, color: _h3),  
70 - const Spacer(),  
71 - ],  
72 - ),  
73 - const SizedBox(width: 14),  
74 - SizedBox(  
75 - height: 174,  
76 - child: LineChart(  
77 - LineChartData(  
78 - minX: 0,  
79 - maxX: 18,  
80 - minY: 0,  
81 - maxY: 80,  
82 - // clipData: const FlClipData.all(),  
83 - gridData: FlGridData(  
84 - show: true,  
85 - drawVerticalLine: true,  
86 - drawHorizontalLine: false,  
87 - verticalInterval: 6,  
88 - getDrawingVerticalLine: (_) => const FlLine(  
89 - color: _h5,  
90 - strokeWidth: 1,  
91 - dashArray: [2, 2],  
92 - ),  
93 - ),  
94 - borderData: FlBorderData(show: false),  
95 - titlesData: FlTitlesData(  
96 - topTitles: const AxisTitles(  
97 - sideTitles: SideTitles(showTitles: false),  
98 - ),  
99 - rightTitles: AxisTitles(  
100 - sideTitles: SideTitles(  
101 - showTitles: false,  
102 - )),  
103 - leftTitles: const AxisTitles(  
104 - sideTitles: SideTitles(showTitles: false),  
105 - ),  
106 - bottomTitles: AxisTitles(  
107 - sideTitles: SideTitles(  
108 - showTitles: true,  
109 - reservedSize: 20,  
110 - interval: 6,  
111 - getTitlesWidget: (val, _) {  
112 - final labels = {  
113 - 0.0: '00:00',  
114 - 6.0: '06:00',  
115 - 12.0: '12:00',  
116 - 18.0: '18:00',  
117 - };  
118 - final label = labels[val];  
119 - if (label == null) return const SizedBox.shrink();  
120 - return Text(  
121 - label,  
122 - style: const TextStyle(  
123 - fontSize: 10,  
124 - color: _h3,  
125 - ),  
126 - );  
127 - }, 29 + return Container(
  30 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
  31 + decoration: BoxDecoration(
  32 + color: Colors.white,
  33 + borderRadius: BorderRadius.circular(16),
  34 + ),
  35 + child: Column(
  36 + crossAxisAlignment: CrossAxisAlignment.start,
  37 + children: [
  38 + _buildHeader(context),
  39 + const SizedBox(height: 14),
  40 + SizedBox(
  41 + height: 174,
  42 + child: hrvSpots.isEmpty
  43 + ? const _EmptyChart(text: '暂无HRV数据')
  44 + : LineChart(_hrvLineChartData(hrvSpots)),
  45 + ),
  46 + Padding(
  47 + padding: const EdgeInsets.only(bottom: 14, top: 16),
  48 + child: Row(
  49 + children: [
  50 + const Text(
  51 + '实时压力',
  52 + style: TextStyle(
  53 + color: _h2,
  54 + fontSize: 14,
  55 + fontWeight: FontWeight.w600,
128 ), 56 ),
129 ), 57 ),
130 - ),  
131 - lineBarsData: [  
132 - LineChartBarData(  
133 - spots: spots,  
134 - isCurved: false,  
135 - color: const Color(0xFFD9D9D9),  
136 - barWidth: 2,  
137 - dotData: FlDotData(  
138 - getDotPainter: (spot, percent, barData, index) =>  
139 - FlDotCirclePainter(  
140 - radius: 4,  
141 - color: Colors.white,  
142 - strokeWidth: 3,  
143 - strokeColor: _getColor(spot.y),  
144 - ),  
145 - )) 58 + const SizedBox(width: 4),
  59 + const Icon(Icons.info_outline, size: 14, color: _h3),
  60 + const Spacer(),
146 ], 61 ],
147 - lineTouchData: LineTouchData(  
148 - getTouchedSpotIndicator: (barData, spotIndexes) {  
149 - return spotIndexes.map((spotIndex) {  
150 - // final spot = barData.spots[spotIndex];  
151 -  
152 - return TouchedSpotIndicatorData(  
153 - FlLine(  
154 - color: Color(0xFFB0B0B6),  
155 - strokeWidth: 2,  
156 - ),  
157 - FlDotData(  
158 - getDotPainter: (spot, percent, barData, index) {  
159 - return FlDotCirclePainter(  
160 - radius: 5,  
161 - color: Colors.white,  
162 - strokeWidth: 3.5,  
163 - strokeColor: _getColor(spot.y),  
164 - );  
165 - },  
166 - ),  
167 - );  
168 - }).toList();  
169 - },  
170 - touchTooltipData: LineTouchTooltipData(  
171 - tooltipRoundedRadius: 8,  
172 - tooltipBorder: BorderSide.none,  
173 - getTooltipColor: (touchedSpot) => Color(0xFFF3F3F3),  
174 - tooltipPadding: EdgeInsets.only(  
175 - left: 12, right: 12, top: 6, bottom: 5),  
176 - getTooltipItems: (List<LineBarSpot> touchedBarSpots) {  
177 - return touchedBarSpots.map((barSpot) {  
178 - final flSpot = barSpot;  
179 - return LineTooltipItem(  
180 - '',  
181 - TextStyle(  
182 - color: Colors.white,  
183 - fontWeight: FontWeight.bold,  
184 - ),  
185 - children: _getTooltipChildren(flSpot),  
186 - textAlign: TextAlign.start,  
187 - );  
188 - }).toList();  
189 - })),  
190 ), 62 ),
191 ), 63 ),
192 - ),  
193 - Padding(  
194 - padding: const EdgeInsets.only(bottom: 14, top: 16),  
195 - child: Row(  
196 - children: [  
197 - const Text(  
198 - '实时压力',  
199 - style: TextStyle(  
200 - color: _h2,  
201 - fontSize: 14,  
202 - fontWeight: FontWeight.w600,  
203 - ),  
204 - ),  
205 - const SizedBox(width: 4),  
206 - const Icon(Icons.info_outline, size: 14, color: _h3),  
207 - const Spacer(),  
208 - ], 64 + SizedBox(
  65 + height: 174,
  66 + child: stressPoints.isEmpty
  67 + ? const _EmptyChart(text: '暂无压力数据')
  68 + : _buildStressChart(stressPoints),
209 ), 69 ),
  70 + ],
  71 + ),
  72 + );
  73 + });
  74 + }
  75 +
  76 + Widget _buildHeader(BuildContext context) {
  77 + return Row(
  78 + children: [
  79 + Text(
  80 + '今日HRV趋势',
  81 + style: TextStyle(
  82 + color: context.colors.textPrimary,
  83 + fontSize: 16,
  84 + fontWeight: FontWeight.w600,
210 ), 85 ),
211 - SizedBox(  
212 - height: 174,  
213 - child: Stack(  
214 - children: [  
215 - BarChart(  
216 - BarChartData(  
217 - minY: 0,  
218 - maxY: 100,  
219 - groupsSpace: 1,  
220 - alignment: BarChartAlignment.start,  
221 - barGroups: List.generate(  
222 - _mockStressByHour.length * 5,  
223 - (i) => BarChartGroupData(  
224 - x: i,  
225 - barRods: [  
226 - BarChartRodData(  
227 - toY:  
228 - _mockStressByHour[i % _mockStressByHour.length],  
229 - width: 2,  
230 - color: _getColor(_mockStressByHour[  
231 - i % _mockStressByHour.length]),  
232 - borderRadius: const BorderRadius.only(  
233 - topLeft: Radius.circular(2),  
234 - topRight: Radius.circular(2),  
235 - ),  
236 - ),  
237 - ],  
238 - ),  
239 - ),  
240 - gridData: FlGridData(  
241 - show: true,  
242 - drawVerticalLine: false,  
243 - drawHorizontalLine: true,  
244 - horizontalInterval: 25,  
245 - getDrawingHorizontalLine: (_) => const FlLine(  
246 - color: _h5,  
247 - strokeWidth: 1,  
248 - dashArray: [2, 2], 86 + ),
  87 + const SizedBox(width: 4),
  88 + Image.asset(
  89 + 'assets/images/common/ic_info.png',
  90 + width: 14,
  91 + height: 14,
  92 + color: context.colors.textTertiary,
  93 + ),
  94 + const Spacer(),
  95 + Text(
  96 + '更多',
  97 + style: TextStyle(
  98 + color: context.colors.textSecondary,
  99 + fontSize: 12,
  100 + fontWeight: FontWeight.w400,
  101 + ),
  102 + ),
  103 + Image.asset(
  104 + 'assets/images/common/ic_more_gray.png',
  105 + width: 16,
  106 + height: 16,
  107 + color: context.colors.textTertiary,
  108 + ),
  109 + ],
  110 + );
  111 + }
  112 +
  113 + LineChartData _hrvLineChartData(List<FlSpot> spots) {
  114 + return LineChartData(
  115 + minX: 0,
  116 + maxX: _maxChartHour(spots),
  117 + minY: 0,
  118 + maxY: 80,
  119 + gridData: FlGridData(
  120 + show: true,
  121 + drawVerticalLine: true,
  122 + drawHorizontalLine: false,
  123 + verticalInterval: 6,
  124 + getDrawingVerticalLine: (_) => const FlLine(
  125 + color: _h5,
  126 + strokeWidth: 1,
  127 + dashArray: [2, 2],
  128 + ),
  129 + ),
  130 + borderData: FlBorderData(show: false),
  131 + titlesData: FlTitlesData(
  132 + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  133 + rightTitles:
  134 + const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  135 + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  136 + bottomTitles: AxisTitles(
  137 + sideTitles: SideTitles(
  138 + showTitles: true,
  139 + reservedSize: 20,
  140 + interval: 6,
  141 + getTitlesWidget: (val, _) {
  142 + final label = _timeLabel(val);
  143 + if (label == null) return const SizedBox.shrink();
  144 + return Text(
  145 + label,
  146 + style: const TextStyle(fontSize: 10, color: _h3),
  147 + );
  148 + },
  149 + ),
  150 + ),
  151 + ),
  152 + lineBarsData: [
  153 + LineChartBarData(
  154 + spots: spots,
  155 + isCurved: false,
  156 + color: const Color(0xFFD9D9D9),
  157 + barWidth: 2,
  158 + dotData: FlDotData(
  159 + getDotPainter: (spot, percent, barData, index) {
  160 + return FlDotCirclePainter(
  161 + radius: 4,
  162 + color: Colors.white,
  163 + strokeWidth: 3,
  164 + strokeColor: _getColor(spot.y),
  165 + );
  166 + },
  167 + ),
  168 + )
  169 + ],
  170 + lineTouchData: LineTouchData(
  171 + getTouchedSpotIndicator: (barData, spotIndexes) {
  172 + return spotIndexes.map((spotIndex) {
  173 + return TouchedSpotIndicatorData(
  174 + const FlLine(
  175 + color: Color(0xFFB0B0B6),
  176 + strokeWidth: 2,
  177 + ),
  178 + FlDotData(
  179 + getDotPainter: (spot, percent, barData, index) {
  180 + return FlDotCirclePainter(
  181 + radius: 5,
  182 + color: Colors.white,
  183 + strokeWidth: 3.5,
  184 + strokeColor: _getColor(spot.y),
  185 + );
  186 + },
  187 + ),
  188 + );
  189 + }).toList();
  190 + },
  191 + touchTooltipData: LineTouchTooltipData(
  192 + tooltipRoundedRadius: 8,
  193 + tooltipBorder: BorderSide.none,
  194 + getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
  195 + tooltipPadding: const EdgeInsets.only(
  196 + left: 12,
  197 + right: 12,
  198 + top: 6,
  199 + bottom: 5,
  200 + ),
  201 + getTooltipItems: (touchedBarSpots) {
  202 + return touchedBarSpots.map((barSpot) {
  203 + return LineTooltipItem(
  204 + '',
  205 + const TextStyle(
  206 + color: Colors.white,
  207 + fontWeight: FontWeight.bold,
  208 + ),
  209 + children: _getTooltipChildren(barSpot),
  210 + textAlign: TextAlign.start,
  211 + );
  212 + }).toList();
  213 + },
  214 + ),
  215 + ),
  216 + );
  217 + }
  218 +
  219 + Widget _buildStressChart(List<HrvDataPoint> stressPoints) {
  220 + return Stack(
  221 + children: [
  222 + BarChart(
  223 + BarChartData(
  224 + minY: 0,
  225 + maxY: 100,
  226 + groupsSpace: 1,
  227 + alignment: BarChartAlignment.start,
  228 + barGroups: List.generate(
  229 + stressPoints.length,
  230 + (i) {
  231 + final point = stressPoints[i];
  232 + return BarChartGroupData(
  233 + x: (point.hour * 10).round(),
  234 + barRods: [
  235 + BarChartRodData(
  236 + toY: point.hrv,
  237 + width: 2,
  238 + color: _getColor(point.hrv),
  239 + borderRadius: const BorderRadius.only(
  240 + topLeft: Radius.circular(2),
  241 + topRight: Radius.circular(2),
249 ), 242 ),
250 ), 243 ),
251 - borderData: FlBorderData(show: false),  
252 - titlesData: FlTitlesData(  
253 - topTitles: const AxisTitles(  
254 - sideTitles: SideTitles(showTitles: false),  
255 - ),  
256 - rightTitles: AxisTitles(  
257 - sideTitles: SideTitles(  
258 - showTitles: true,  
259 - getTitlesWidget: (val, _) {  
260 - return Text(  
261 - '${val.toInt()}',  
262 - style: const TextStyle(fontSize: 10, color: _h3),  
263 - );  
264 - },  
265 - )),  
266 - leftTitles: const AxisTitles(  
267 - sideTitles: SideTitles(showTitles: false),  
268 - ),  
269 - bottomTitles: AxisTitles(  
270 - sideTitles: SideTitles(  
271 - showTitles: true,  
272 - reservedSize: 20,  
273 - interval: 60,  
274 - getTitlesWidget: (val, meta) {  
275 - final labels = {  
276 - 0: '00:00',  
277 - 33: '06:00',  
278 - 66: '12:00',  
279 - 99: '18:00',  
280 - };  
281 - final label = labels[val];  
282 - if (label == null) return const SizedBox.shrink();  
283 - return SideTitleWidget(  
284 - meta: meta,  
285 - child: Text(  
286 - label,  
287 - style:  
288 - const TextStyle(fontSize: 10, color: _h3),  
289 - ),  
290 - );  
291 - },  
292 - ), 244 + ],
  245 + );
  246 + },
  247 + ),
  248 + gridData: FlGridData(
  249 + show: true,
  250 + drawVerticalLine: false,
  251 + drawHorizontalLine: true,
  252 + horizontalInterval: 25,
  253 + getDrawingHorizontalLine: (_) => const FlLine(
  254 + color: _h5,
  255 + strokeWidth: 1,
  256 + dashArray: [2, 2],
  257 + ),
  258 + ),
  259 + borderData: FlBorderData(show: false),
  260 + titlesData: FlTitlesData(
  261 + topTitles:
  262 + const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  263 + rightTitles: AxisTitles(
  264 + sideTitles: SideTitles(
  265 + showTitles: true,
  266 + getTitlesWidget: (val, _) {
  267 + if (val < 0 || val > 100) {
  268 + return const SizedBox.shrink();
  269 + }
  270 + return Text(
  271 + '${val.toInt()}',
  272 + style: const TextStyle(fontSize: 10, color: _h3),
  273 + );
  274 + },
  275 + ),
  276 + ),
  277 + leftTitles:
  278 + const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  279 + bottomTitles: AxisTitles(
  280 + sideTitles: SideTitles(
  281 + showTitles: true,
  282 + reservedSize: 20,
  283 + interval: 60,
  284 + getTitlesWidget: (val, meta) {
  285 + final label = _timeLabel(val / 10);
  286 + if (label == null) return const SizedBox.shrink();
  287 + return SideTitleWidget(
  288 + meta: meta,
  289 + child: Text(
  290 + label,
  291 + style: const TextStyle(fontSize: 10, color: _h3),
293 ), 292 ),
294 - ),  
295 - barTouchData: BarTouchData(  
296 - touchTooltipData: BarTouchTooltipData(  
297 - tooltipRoundedRadius: 8,  
298 - tooltipBorder: BorderSide.none,  
299 - getTooltipColor: (touchedSpot) => Color(0xFFF3F3F3),  
300 - tooltipPadding: EdgeInsets.only(  
301 - left: 12, right: 12, top: 6, bottom: 5),  
302 - getTooltipItem: (group, groupIndex, rod, rodIndex) {  
303 - return BarTooltipItem(  
304 - '',  
305 - TextStyle(  
306 - color: Colors.white,  
307 - fontWeight: FontWeight.bold,  
308 - ),  
309 - children: _getTooltip(rod),  
310 - textAlign: TextAlign.start,  
311 - );  
312 - })),  
313 - ), 293 + );
  294 + },
314 ), 295 ),
315 - Container(  
316 - width: 74,  
317 - height: 160,  
318 - decoration: BoxDecoration(  
319 - gradient: LinearGradient(  
320 - begin: Alignment(0.50, -0.00),  
321 - end: Alignment(0.50, 1.00),  
322 - colors: [  
323 - const Color(0x4C835DED),  
324 - const Color(0x00845EEE)  
325 - ], 296 + ),
  297 + ),
  298 + barTouchData: BarTouchData(
  299 + touchTooltipData: BarTouchTooltipData(
  300 + tooltipRoundedRadius: 8,
  301 + tooltipBorder: BorderSide.none,
  302 + getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
  303 + tooltipPadding: const EdgeInsets.only(
  304 + left: 12,
  305 + right: 12,
  306 + top: 6,
  307 + bottom: 5,
  308 + ),
  309 + getTooltipItem: (group, groupIndex, rod, rodIndex) {
  310 + return BarTooltipItem(
  311 + '',
  312 + const TextStyle(
  313 + color: Colors.white,
  314 + fontWeight: FontWeight.bold,
326 ), 315 ),
327 - ),  
328 - )  
329 - ], 316 + children: _getTooltip(rod),
  317 + textAlign: TextAlign.start,
  318 + );
  319 + },
  320 + ),
330 ), 321 ),
331 ), 322 ),
332 - ],  
333 - ), 323 + ),
  324 + Container(
  325 + width: 74,
  326 + height: 160,
  327 + decoration: const BoxDecoration(
  328 + gradient: LinearGradient(
  329 + begin: Alignment(0.50, -0.00),
  330 + end: Alignment(0.50, 1.00),
  331 + colors: [Color(0x4C835DED), Color(0x00845EEE)],
  332 + ),
  333 + ),
  334 + )
  335 + ],
334 ); 336 );
335 } 337 }
336 338
  339 + double _maxChartHour(List<FlSpot> spots) {
  340 + final maxHour = spots.fold<double>(
  341 + 0,
  342 + (maxValue, spot) => spot.x > maxValue ? spot.x : maxValue,
  343 + );
  344 + return ((maxHour / 6).ceil() * 6).clamp(6, 24).toDouble();
  345 + }
  346 +
  347 + String? _timeLabel(double value) {
  348 + return switch (value) {
  349 + 0 => '00:00',
  350 + 6 => '06:00',
  351 + 12 => '12:00',
  352 + 18 => '18:00',
  353 + 24 => '24:00',
  354 + _ => null,
  355 + };
  356 + }
  357 +
337 Color _getColor(double value) { 358 Color _getColor(double value) {
338 if (value > 60) { 359 if (value > 60) {
339 return const Color(0xFF3BD49D); 360 return const Color(0xFF3BD49D);
@@ -364,10 +385,6 @@ class TodayHrvChartCard extends GetView<TodayController> { @@ -364,10 +385,6 @@ class TodayHrvChartCard extends GetView<TodayController> {
364 385
365 List<TextSpan>? _getTooltip(BarChartRodData rod) { 386 List<TextSpan>? _getTooltip(BarChartRodData rod) {
366 return [ 387 return [
367 - // WidgetSpan(  
368 - // child: Image.asset('assets/images/home/today/ic_tooltip_dot.png',  
369 - // width: 12, height: 12),  
370 - // ),  
371 TextSpan( 388 TextSpan(
372 text: _getStatus(rod.toY), 389 text: _getStatus(rod.toY),
373 style: TextStyle( 390 style: TextStyle(
@@ -383,8 +400,8 @@ class TodayHrvChartCard extends GetView<TodayController> { @@ -383,8 +400,8 @@ class TodayHrvChartCard extends GetView<TodayController> {
383 ), 400 ),
384 ), 401 ),
385 TextSpan( 402 TextSpan(
386 - text: '${rod.toY}ms.${rod.fromY}:00',  
387 - style: TextStyle( 403 + text: '压力 ${_formatNumber(rod.toY)}',
  404 + style: const TextStyle(
388 color: Color(0xFF78787D), 405 color: Color(0xFF78787D),
389 fontWeight: FontWeight.bold, 406 fontWeight: FontWeight.bold,
390 ), 407 ),
@@ -394,10 +411,6 @@ class TodayHrvChartCard extends GetView<TodayController> { @@ -394,10 +411,6 @@ class TodayHrvChartCard extends GetView<TodayController> {
394 411
395 List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot) { 412 List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot) {
396 return [ 413 return [
397 - // WidgetSpan(  
398 - // child: Image.asset('assets/images/home/today/ic_tooltip_dot.png',  
399 - // width: 12, height: 12),  
400 - // ),  
401 TextSpan( 414 TextSpan(
402 text: _getStatus(flSpot.y), 415 text: _getStatus(flSpot.y),
403 style: TextStyle( 416 style: TextStyle(
@@ -413,12 +426,44 @@ class TodayHrvChartCard extends GetView<TodayController> { @@ -413,12 +426,44 @@ class TodayHrvChartCard extends GetView<TodayController> {
413 ), 426 ),
414 ), 427 ),
415 TextSpan( 428 TextSpan(
416 - text: '${flSpot.y}ms.${flSpot.x.toInt()}:00',  
417 - style: TextStyle( 429 + text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
  430 + style: const TextStyle(
418 color: Color(0xFF78787D), 431 color: Color(0xFF78787D),
419 fontWeight: FontWeight.bold, 432 fontWeight: FontWeight.bold,
420 ), 433 ),
421 ), 434 ),
422 ]; 435 ];
423 } 436 }
  437 +
  438 + String _formatNumber(double value) {
  439 + if (value % 1 == 0) return '${value.toInt()}';
  440 + return value.toStringAsFixed(1);
  441 + }
  442 +
  443 + String _formatHour(double hour) {
  444 + final totalMinutes = (hour * 60).round();
  445 + final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
  446 + final m = (totalMinutes % 60).toString().padLeft(2, '0');
  447 + return '$h:$m';
  448 + }
  449 +}
  450 +
  451 +class _EmptyChart extends StatelessWidget {
  452 + const _EmptyChart({required this.text});
  453 +
  454 + final String text;
  455 +
  456 + @override
  457 + Widget build(BuildContext context) {
  458 + return Center(
  459 + child: Text(
  460 + text,
  461 + style: const TextStyle(
  462 + color: Color(0xFF999999),
  463 + fontSize: 12,
  464 + fontWeight: FontWeight.w400,
  465 + ),
  466 + ),
  467 + );
  468 + }
424 } 469 }
@@ -21,21 +21,15 @@ class TodayHrvNumberCard extends GetView<TodayController> { @@ -21,21 +21,15 @@ class TodayHrvNumberCard extends GetView<TodayController> {
21 Expanded( 21 Expanded(
22 child: _NumberItem( 22 child: _NumberItem(
23 label: '该日平均HRV', 23 label: '该日平均HRV',
24 - value: '${controller.avgHrv.value}', 24 + value: controller.avgHrv.value,
25 unit: 'ms', 25 unit: 'ms',
26 ), 26 ),
27 ), 27 ),
28 - // 分割线  
29 - // Container(  
30 - // width: 1,  
31 - // height: 40,  
32 - // color: const Color(0xFFE0E0E0),  
33 - // ),  
34 - // 心率 28 +
35 Expanded( 29 Expanded(
36 child: _NumberItem( 30 child: _NumberItem(
37 label: '静息心率', 31 label: '静息心率',
38 - value: '${controller.restingHeartRate.value}', 32 + value: controller.restingHeartRate.value,
39 unit: 'bpm', 33 unit: 'bpm',
40 ), 34 ),
41 ), 35 ),
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  2 +import 'package:flutter/material.dart';
  3 +import 'package:get/get.dart';
  4 +
  5 +import '../../controllers/today_controller.dart';
  6 +
  7 +class TodaySleepCard extends GetView<TodayController> {
  8 + const TodaySleepCard({super.key});
  9 +
  10 + @override
  11 + Widget build(BuildContext context) {
  12 + return Obx(
  13 + () => _TodaySummaryCard(
  14 + iconAsset: 'assets/images/common/ic_sleep_stroke.png',
  15 + iconColor: context.colors.primary,
  16 + title: '睡眠',
  17 + actionText: '查看睡眠报告',
  18 + progressValues: [controller.sleepProgress.value],
  19 + progressColors: [context.colors.primary],
  20 + metrics: [
  21 + _MetricData(
  22 + label: '时长',
  23 + labelColor: context.colors.primary,
  24 + valueSpans: [
  25 + _MetricValueSpan(controller.sleepHours.value, '小时'),
  26 + _MetricValueSpan(controller.sleepMinutes.value, '分钟'),
  27 + ],
  28 + ),
  29 + _MetricData(
  30 + label: '质量',
  31 + labelColor: const Color(0xFF7B9BFB),
  32 + valueSpans: [
  33 + _MetricValueSpan(
  34 + controller.sleepQuality.value,
  35 + '',
  36 + valueColor: _qualityColor(controller.sleepQuality.value),
  37 + ),
  38 + ],
  39 + ),
  40 + _MetricData(
  41 + label: '平均心率',
  42 + labelColor: context.colors.textSecondary,
  43 + valueSpans: [
  44 + _MetricValueSpan(controller.sleepAverageHeartRate.value, 'bpm'),
  45 + ],
  46 + ),
  47 + ],
  48 + ),
  49 + );
  50 + }
  51 +
  52 + Color _qualityColor(String quality) {
  53 + return switch (quality) {
  54 + '优秀' => const Color(0xFF3BD49D),
  55 + '良好' => const Color(0xFF7B9BFB),
  56 + '一般' => const Color(0xFFFF9A6E),
  57 + '偏少' => const Color(0xFFFF5279),
  58 + _ => const Color(0xFF0F0F11),
  59 + };
  60 + }
  61 +}
  62 +
  63 +class TodayActivityCard extends GetView<TodayController> {
  64 + const TodayActivityCard({super.key});
  65 +
  66 + @override
  67 + Widget build(BuildContext context) {
  68 + return Obx(
  69 + () => _TodaySummaryCard(
  70 + iconAsset: 'assets/images/common/ic_exercise.png',
  71 + iconColor: const Color(0xFFFF5279),
  72 + title: '健身',
  73 + actionText: '查看健身报告',
  74 + progressValues: [
  75 + controller.activityMoveProgress.value,
  76 + controller.activityExerciseProgress.value,
  77 + controller.activityStandProgress.value,
  78 + ],
  79 + progressColors: const [
  80 + Color(0xFFFF5279),
  81 + Color(0xFF3BD49D),
  82 + Color(0xFF7B9BFB),
  83 + ],
  84 + metrics: [
  85 + _MetricData(
  86 + label: '活动',
  87 + labelColor: const Color(0xFFFF5279),
  88 + valueSpans: [
  89 + _MetricValueSpan(controller.activityCalories.value, '千卡'),
  90 + ],
  91 + ),
  92 + _MetricData(
  93 + label: '锻炼',
  94 + labelColor: const Color(0xFF3BD49D),
  95 + valueSpans: [
  96 + _MetricValueSpan(
  97 + controller.activityExerciseMinutes.value,
  98 + '分钟',
  99 + ),
  100 + ],
  101 + ),
  102 + _MetricData(
  103 + label: '站立',
  104 + labelColor: const Color(0xFF7B9BFB),
  105 + valueSpans: [
  106 + _MetricValueSpan(controller.activityStandHours.value, '小时'),
  107 + ],
  108 + ),
  109 + ],
  110 + ),
  111 + );
  112 + }
  113 +}
  114 +
  115 +class _TodaySummaryCard extends StatelessWidget {
  116 + const _TodaySummaryCard({
  117 + required this.iconAsset,
  118 + required this.iconColor,
  119 + required this.title,
  120 + required this.actionText,
  121 + required this.metrics,
  122 + required this.progressValues,
  123 + required this.progressColors,
  124 + });
  125 +
  126 + final String iconAsset;
  127 + final Color iconColor;
  128 + final String title;
  129 + final String actionText;
  130 + final List<_MetricData> metrics;
  131 + final List<double> progressValues;
  132 + final List<Color> progressColors;
  133 +
  134 + @override
  135 + Widget build(BuildContext context) {
  136 + final colors = context.colors;
  137 +
  138 + return Container(
  139 + margin: const EdgeInsets.only(bottom: 8),
  140 + padding: const EdgeInsets.fromLTRB(20, 18, 20, 15),
  141 + decoration: BoxDecoration(
  142 + color: Colors.white,
  143 + borderRadius: BorderRadius.circular(16),
  144 + ),
  145 + child: Column(
  146 + children: [
  147 + Row(
  148 + children: [
  149 + Image.asset(
  150 + iconAsset,
  151 + width: 16,
  152 + height: 16,
  153 + color: iconColor,
  154 + ),
  155 + const SizedBox(width: 4),
  156 + Text(
  157 + title,
  158 + style: TextStyle(
  159 + color: colors.textPrimary,
  160 + fontSize: 14,
  161 + fontWeight: FontWeight.w500,
  162 + height: 20 / 14,
  163 + ),
  164 + ),
  165 + const Spacer(),
  166 + Text(
  167 + actionText,
  168 + style: TextStyle(
  169 + color: colors.textSecondary,
  170 + fontSize: 12,
  171 + fontWeight: FontWeight.w400,
  172 + height: 17 / 12,
  173 + ),
  174 + ),
  175 + Image.asset(
  176 + 'assets/images/common/ic_more_gray.png',
  177 + width: 16,
  178 + height: 16,
  179 + color: colors.textTertiary,
  180 + ),
  181 + ],
  182 + ),
  183 + const SizedBox(height: 16),
  184 + Row(
  185 + crossAxisAlignment: CrossAxisAlignment.start,
  186 + children: [
  187 + Expanded(
  188 + child: Row(
  189 + crossAxisAlignment: CrossAxisAlignment.start,
  190 + children: [
  191 + for (final metric in metrics)
  192 + Expanded(child: _MetricBlock(metric: metric)),
  193 + ],
  194 + ),
  195 + ),
  196 + const SizedBox(width: 4),
  197 + Container(
  198 + width: 50,
  199 + height: 50,
  200 + color: Colors.red,
  201 + ),
  202 + const SizedBox(width: 6),
  203 + ],
  204 + ),
  205 + ],
  206 + ),
  207 + );
  208 + }
  209 +}
  210 +
  211 +class _MetricBlock extends StatelessWidget {
  212 + const _MetricBlock({required this.metric});
  213 +
  214 + final _MetricData metric;
  215 +
  216 + @override
  217 + Widget build(BuildContext context) {
  218 + return SizedBox(
  219 + width: double.infinity,
  220 + child: Column(
  221 + crossAxisAlignment: CrossAxisAlignment.start,
  222 + children: [
  223 + Text(
  224 + metric.label,
  225 + maxLines: 1,
  226 + overflow: TextOverflow.ellipsis,
  227 + style: TextStyle(
  228 + color: metric.labelColor,
  229 + fontSize: 12,
  230 + fontWeight: FontWeight.w500,
  231 + height: 17 / 12,
  232 + ),
  233 + ),
  234 + const SizedBox(height: 5),
  235 + FittedBox(
  236 + fit: BoxFit.scaleDown,
  237 + alignment: Alignment.centerLeft,
  238 + child: Row(
  239 + crossAxisAlignment: CrossAxisAlignment.end,
  240 + children: [
  241 + for (final span in metric.valueSpans) _MetricValue(value: span),
  242 + ],
  243 + ),
  244 + ),
  245 + ],
  246 + ),
  247 + );
  248 + }
  249 +}
  250 +
  251 +class _MetricValue extends StatelessWidget {
  252 + const _MetricValue({required this.value});
  253 +
  254 + final _MetricValueSpan value;
  255 +
  256 + @override
  257 + Widget build(BuildContext context) {
  258 + return Row(
  259 + mainAxisSize: MainAxisSize.min,
  260 + crossAxisAlignment: CrossAxisAlignment.end,
  261 + children: [
  262 + Text(
  263 + value.value,
  264 + style: TextStyle(
  265 + color: value.valueColor ?? context.colors.textPrimary,
  266 + fontSize: 16,
  267 + fontWeight: FontWeight.w600,
  268 + height: 19 / 16,
  269 + ),
  270 + ),
  271 + if (value.unit.isNotEmpty)
  272 + Padding(
  273 + padding: const EdgeInsets.only(left: 1, bottom: 1),
  274 + child: Text(
  275 + value.unit,
  276 + style: TextStyle(
  277 + color: context.colors.textSecondary,
  278 + fontSize: 12,
  279 + fontWeight: FontWeight.w400,
  280 + height: 17 / 12,
  281 + ),
  282 + ),
  283 + ),
  284 + if (value.unit.isNotEmpty) const SizedBox(width: 4),
  285 + ],
  286 + );
  287 + }
  288 +}
  289 +
  290 +class _MetricData {
  291 + const _MetricData({
  292 + required this.label,
  293 + required this.labelColor,
  294 + required this.valueSpans,
  295 + });
  296 +
  297 + final String label;
  298 + final Color labelColor;
  299 + final List<_MetricValueSpan> valueSpans;
  300 +}
  301 +
  302 +class _MetricValueSpan {
  303 + const _MetricValueSpan(
  304 + this.value,
  305 + this.unit, {
  306 + this.valueColor,
  307 + });
  308 +
  309 + final String value;
  310 + final String unit;
  311 + final Color? valueColor;
  312 +}
1 import 'dart:async'; 1 import 'dart:async';
  2 +import 'package:doublefeel_flutter/core/constants/app_const.dart';
  3 +import 'package:doublefeel_flutter/l10n/gen/app_localizations.dart';
2 import 'package:flutter/widgets.dart'; 4 import 'package:flutter/widgets.dart';
3 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
  6 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
4 import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 7 import 'package:doublefeel_flutter/core/network/api/user_api.dart';
5 import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; 8 import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
6 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 9 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  10 +import 'package:doublefeel_flutter/data/local/local_storage.dart';
  11 +import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
7 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 12 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
8 import 'package:doublefeel_flutter/data/models/user/user_models.dart'; 13 import 'package:doublefeel_flutter/data/models/user/user_models.dart';
9 import 'package:doublefeel_flutter/data/models/vip/vip_info.dart'; 14 import 'package:doublefeel_flutter/data/models/vip/vip_info.dart';
@@ -14,6 +19,7 @@ class LoginController extends GetxController { @@ -14,6 +19,7 @@ class LoginController extends GetxController {
14 final UserApi _userApi = Get.find<UserApi>(); 19 final UserApi _userApi = Get.find<UserApi>();
15 final VipApi _vipApi = Get.find<VipApi>(); 20 final VipApi _vipApi = Get.find<VipApi>();
16 final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>(); 21 final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>();
  22 + final UserAccountStorage _userAccount = Get.find<UserAccountStorage>();
17 final UserStateService _userStateService = Get.find<UserStateService>(); 23 final UserStateService _userStateService = Get.find<UserStateService>();
18 24
19 final phoneController = TextEditingController(); 25 final phoneController = TextEditingController();
@@ -27,6 +33,7 @@ class LoginController extends GetxController { @@ -27,6 +33,7 @@ class LoginController extends GetxController {
27 Timer? _countdownTimer; 33 Timer? _countdownTimer;
28 34
29 final isLoggingIn = false.obs; 35 final isLoggingIn = false.obs;
  36 + final hasSentCode = false.obs;
30 37
31 final termsChecked = false.obs; 38 final termsChecked = false.obs;
32 39
@@ -36,7 +43,7 @@ class LoginController extends GetxController { @@ -36,7 +43,7 @@ class LoginController extends GetxController {
36 43
37 void onPhoneLoginPressed() { 44 void onPhoneLoginPressed() {
38 if (!termsChecked.value) { 45 if (!termsChecked.value) {
39 - Get.snackbar('提示', '请先阅读并同意《用户协议》与《隐私协议》'); 46 + AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
40 return; 47 return;
41 } 48 }
42 Get.toNamed(AppRoutes.phoneLogin); 49 Get.toNamed(AppRoutes.phoneLogin);
@@ -44,10 +51,10 @@ class LoginController extends GetxController { @@ -44,10 +51,10 @@ class LoginController extends GetxController {
44 51
45 void onAppleLoginPressed() { 52 void onAppleLoginPressed() {
46 if (!termsChecked.value) { 53 if (!termsChecked.value) {
47 - Get.snackbar('提示', '请先阅读并同意《用户协议》与《隐私协议》'); 54 + AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
48 return; 55 return;
49 } 56 }
50 - Get.snackbar('提示', 'Apple 登录功能待接入'); 57 + //todo: implement apple login
51 } 58 }
52 59
53 void onDebugPressed() { 60 void onDebugPressed() {
@@ -57,20 +64,14 @@ class LoginController extends GetxController { @@ -57,20 +64,14 @@ class LoginController extends GetxController {
57 void openUserTerms() { 64 void openUserTerms() {
58 Get.toNamed( 65 Get.toNamed(
59 AppRoutes.webview, 66 AppRoutes.webview,
60 - parameters: {  
61 - 'url':  
62 - 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html'  
63 - }, 67 + parameters: {'url': AppConst.userTerms},
64 ); 68 );
65 } 69 }
66 70
67 void openPrivacyPolicy() { 71 void openPrivacyPolicy() {
68 Get.toNamed( 72 Get.toNamed(
69 AppRoutes.webview, 73 AppRoutes.webview,
70 - parameters: {  
71 - 'url':  
72 - 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html'  
73 - }, 74 + parameters: {'url': AppConst.privacyPolicy},
74 ); 75 );
75 } 76 }
76 77
@@ -109,7 +110,7 @@ class LoginController extends GetxController { @@ -109,7 +110,7 @@ class LoginController extends GetxController {
109 if (!canRequestCode) return; 110 if (!canRequestCode) return;
110 111
111 if (cleanPhone.length < 11) { 112 if (cleanPhone.length < 11) {
112 - Get.snackbar('提示', '手机号格式错误'); 113 + AppToast.show(AppLocalizations.of(Get.context!)!.phoneLoginInvalidPhone);
113 return; 114 return;
114 } 115 }
115 116
@@ -119,12 +120,14 @@ class LoginController extends GetxController { @@ -119,12 +120,14 @@ class LoginController extends GetxController {
119 120
120 isSendingCode.value = false; 121 isSendingCode.value = false;
121 if (result is AppSuccess<void>) { 122 if (result is AppSuccess<void>) {
122 - Get.snackbar('提示', '发送成功'); 123 + AppToast.show(
  124 + AppLocalizations.of(Get.context!)!.phoneLoginCodeSentSuccess);
123 _startCountdown(); 125 _startCountdown();
124 } 126 }
125 } 127 }
126 128
127 void _startCountdown() { 129 void _startCountdown() {
  130 + hasSentCode.value = true;
128 _countdownTimer?.cancel(); 131 _countdownTimer?.cancel();
129 countdownSeconds.value = 60; 132 countdownSeconds.value = 60;
130 _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { 133 _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
@@ -141,11 +144,11 @@ class LoginController extends GetxController { @@ -141,11 +144,11 @@ class LoginController extends GetxController {
141 if (!canLogin) return; 144 if (!canLogin) return;
142 145
143 if (cleanPhone.length < 11) { 146 if (cleanPhone.length < 11) {
144 - Get.snackbar('提示', '手机号格式错误'); 147 + AppToast.show(AppLocalizations.of(Get.context!)!.phoneLoginInvalidPhone);
145 return; 148 return;
146 } 149 }
147 if (codeInput.value.isEmpty) { 150 if (codeInput.value.isEmpty) {
148 - Get.snackbar('提示', '验证码格式错误'); 151 + AppToast.show(AppLocalizations.of(Get.context!)!.phoneLoginInvalidCode);
149 return; 152 return;
150 } 153 }
151 154
@@ -209,11 +212,24 @@ class LoginController extends GetxController { @@ -209,11 +212,24 @@ class LoginController extends GetxController {
209 212
210 await _userStateService.onLogin(); 213 await _userStateService.onLogin();
211 214
  215 + // 用户登录成功即代表同意了协议,持久化到本地供冷启动时 SDK 初始化判断使用
  216 + await Get.find<LocalStorage>().setTermsAgreed(true);
  217 +
212 isLoggingIn.value = false; 218 isLoggingIn.value = false;
213 - if (isRegister) {  
214 - Get.offAllNamed(AppRoutes.userOnboarding);  
215 - } else { 219 +
  220 + // 根据引导完成状态决定跳转目标
  221 + final userId = me.id ?? 0;
  222 + if (_userAccount.hasCompletedOnboarding(userId)) {
  223 + // 该账号已完成引导,直接进主页
216 Get.offAllNamed(AppRoutes.home); 224 Get.offAllNamed(AppRoutes.home);
  225 + } else {
  226 + // 新用户或未完成引导,进入引导页(支持断点续做)
  227 + final resumeStage = _userAccount.onboardingResumeStage(userId);
  228 + Get.offAllNamed(
  229 + AppRoutes.userOnboarding,
  230 + arguments:
  231 + resumeStage != null ? {'resumeStage': resumeStage} : null,
  232 + );
217 } 233 }
218 } else { 234 } else {
219 isLoggingIn.value = false; 235 isLoggingIn.value = false;
@@ -5,6 +5,7 @@ import '../../../../core/config/app_environment.dart'; @@ -5,6 +5,7 @@ import '../../../../core/config/app_environment.dart';
5 import '../../../../core/config/app_environment_config.dart'; 5 import '../../../../core/config/app_environment_config.dart';
6 import '../../../../core/constants/app_const.dart'; 6 import '../../../../core/constants/app_const.dart';
7 import '../../../../core/network/dio_client.dart'; 7 import '../../../../core/network/dio_client.dart';
  8 +import '../../../../core/util/app_toast.dart';
8 9
9 class DebugEnvironmentView extends StatelessWidget { 10 class DebugEnvironmentView extends StatelessWidget {
10 const DebugEnvironmentView({super.key}); 11 const DebugEnvironmentView({super.key});
@@ -66,10 +67,8 @@ class DebugEnvironmentView extends StatelessWidget { @@ -66,10 +67,8 @@ class DebugEnvironmentView extends StatelessWidget {
66 67
67 await environmentConfig.setEnvironment(value); 68 await environmentConfig.setEnvironment(value);
68 dioClient.refreshBaseUrl(); 69 dioClient.refreshBaseUrl();
69 - Get.snackbar(  
70 - '环境已切换',  
71 - environmentConfig.serverBaseUrl,  
72 - snackPosition: SnackPosition.BOTTOM, 70 + AppToast.show(
  71 + '环境已切换: ${environmentConfig.serverBaseUrl}',
73 ); 72 );
74 }, 73 },
75 ), 74 ),
1 import 'dart:math' as math; 1 import 'dart:math' as math;
2 2
3 import 'package:doublefeel_flutter/core/config/app_environment_config.dart'; 3 import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
  4 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  5 +import 'package:doublefeel_flutter/data/local/local_storage.dart';
  6 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  7 +
4 import 'package:flutter/foundation.dart'; 8 import 'package:flutter/foundation.dart';
5 import 'package:flutter/gestures.dart'; 9 import 'package:flutter/gestures.dart';
6 import 'package:flutter/material.dart'; 10 import 'package:flutter/material.dart';
@@ -12,10 +16,6 @@ import '../controllers/login_controller.dart'; @@ -12,10 +16,6 @@ import '../controllers/login_controller.dart';
12 class LoginView extends GetView<LoginController> { 16 class LoginView extends GetView<LoginController> {
13 const LoginView({super.key}); 17 const LoginView({super.key});
14 18
15 - static const _designHeight = 812.0;  
16 - static const _brandColor = Color(0xFF845EEE);  
17 - static const _titleColor = Color(0xFF0F0F11);  
18 -  
19 @override 19 @override
20 Widget build(BuildContext context) { 20 Widget build(BuildContext context) {
21 return AnnotatedRegion<SystemUiOverlayStyle>( 21 return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -31,168 +31,57 @@ class LoginView extends GetView<LoginController> { @@ -31,168 +31,57 @@ class LoginView extends GetView<LoginController> {
31 body: LayoutBuilder( 31 body: LayoutBuilder(
32 builder: (context, constraints) { 32 builder: (context, constraints) {
33 final screenSize = MediaQuery.sizeOf(context); 33 final screenSize = MediaQuery.sizeOf(context);
  34 + var bottomInset = MediaQuery.paddingOf(context).bottom;
  35 + bottomInset = bottomInset > 0 ? bottomInset : 34;
34 final width = constraints.maxWidth.isFinite 36 final width = constraints.maxWidth.isFinite
35 ? constraints.maxWidth 37 ? constraints.maxWidth
36 : screenSize.width; 38 : screenSize.width;
37 - final height = constraints.maxHeight.isFinite  
38 - ? constraints.maxHeight  
39 - : screenSize.height;  
40 final buttonWidth = math.min(280.0, math.max(0.0, width - 64.0)); 39 final buttonWidth = math.min(280.0, math.max(0.0, width - 64.0));
41 -  
42 - double top(double value) => height * value / _designHeight;  
43 - 40 + final rightOffset = (width - buttonWidth) / 2;
  41 + double bottom(double value) => bottomInset + value;
  42 + final l10n = context.l10n;
44 return Stack( 43 return Stack(
45 fit: StackFit.expand, 44 fit: StackFit.expand,
46 children: [ 45 children: [
47 - // Background Gradient  
48 const DecoratedBox( 46 const DecoratedBox(
49 decoration: BoxDecoration( 47 decoration: BoxDecoration(
50 gradient: LinearGradient( 48 gradient: LinearGradient(
51 - begin: Alignment.topCenter,  
52 - end: Alignment.bottomCenter, 49 + begin: Alignment(0.50, -0.00),
  50 + end: Alignment(0.50, 1.00),
53 colors: [ 51 colors: [
54 - Color(0xFFECE3FF),  
55 - Color(0xFFEDE4FF),  
56 - Color(0xFFEEE6FF),  
57 - Color(0xFFF9F6FF), 52 + const Color(0xFFE6DAFF),
  53 + const Color(0xFFECE4FF),
  54 + const Color(0xFFEEE6FF),
  55 + const Color(0xFFF8F6FF)
58 ], 56 ],
59 - stops: [0, 0.33173, 0.74038, 1],  
60 - ),  
61 - ),  
62 - ),  
63 -  
64 - // Beautiful custom illustration (Instead of red text placeholder)  
65 - Positioned(  
66 - top: top(140),  
67 - left: 0,  
68 - right: 0,  
69 - child: Center(  
70 - child: SizedBox(  
71 - width: 220,  
72 - height: 220,  
73 - child: Stack(  
74 - alignment: Alignment.center,  
75 - children: [  
76 - // Base glow circle  
77 - Container(  
78 - width: 180,  
79 - height: 180,  
80 - decoration: BoxDecoration(  
81 - shape: BoxShape.circle,  
82 - gradient: RadialGradient(  
83 - colors: [  
84 - _brandColor.withValues(alpha: 0.25),  
85 - Colors.transparent,  
86 - ],  
87 - ),  
88 - ),  
89 - ),  
90 - // Premium geometric shapes with glassmorphism glow  
91 - Positioned(  
92 - top: 20,  
93 - left: 30,  
94 - child: Container(  
95 - width: 60,  
96 - height: 60,  
97 - decoration: BoxDecoration(  
98 - shape: BoxShape.circle,  
99 - color: Colors.white.withValues(alpha: 0.5),  
100 - border: Border.all(  
101 - color: Colors.white.withValues(alpha: 0.6),  
102 - width: 1.5,  
103 - ),  
104 - ),  
105 - ),  
106 - ),  
107 - Positioned(  
108 - bottom: 30,  
109 - right: 40,  
110 - child: Container(  
111 - width: 80,  
112 - height: 80,  
113 - decoration: BoxDecoration(  
114 - borderRadius: BorderRadius.circular(24),  
115 - gradient: LinearGradient(  
116 - begin: Alignment.topLeft,  
117 - end: Alignment.bottomRight,  
118 - colors: [  
119 - Colors.white.withValues(alpha: 0.6),  
120 - Colors.white.withValues(alpha: 0.2),  
121 - ],  
122 - ),  
123 - border: Border.all(  
124 - color: Colors.white.withValues(alpha: 0.8),  
125 - width: 1.5,  
126 - ),  
127 - ),  
128 - ),  
129 - ),  
130 - // Heart inside glass sphere  
131 - Container(  
132 - width: 120,  
133 - height: 120,  
134 - decoration: BoxDecoration(  
135 - shape: BoxShape.circle,  
136 - gradient: LinearGradient(  
137 - begin: Alignment.topLeft,  
138 - end: Alignment.bottomRight,  
139 - colors: [  
140 - Colors.white.withValues(alpha: 0.8),  
141 - Colors.white.withValues(alpha: 0.1),  
142 - ],  
143 - ),  
144 - border: Border.all(  
145 - color: Colors.white.withValues(alpha: 0.9),  
146 - width: 2,  
147 - ),  
148 - boxShadow: [  
149 - BoxShadow(  
150 - color: _brandColor.withValues(alpha: 0.15),  
151 - blurRadius: 20,  
152 - offset: const Offset(0, 8),  
153 - ),  
154 - ],  
155 - ),  
156 - child: const Center(  
157 - child: Icon(  
158 - Icons.favorite_rounded,  
159 - color: _brandColor,  
160 - size: 54,  
161 - ),  
162 - ),  
163 - ),  
164 - ],  
165 - ),  
166 ), 57 ),
167 ), 58 ),
168 ), 59 ),
169 60
170 // Welcome texts 61 // Welcome texts
171 Positioned( 62 Positioned(
172 - top: top(420), 63 + bottom: bottom(253),
173 left: 0, 64 left: 0,
174 right: 0, 65 right: 0,
175 - child: const Column( 66 + child: Column(
  67 + mainAxisSize: MainAxisSize.min,
  68 + crossAxisAlignment: CrossAxisAlignment.center,
176 children: [ 69 children: [
177 - Text(  
178 - '欢迎使用DoubleFeel',  
179 - textAlign: TextAlign.center,  
180 - style: TextStyle(  
181 - color: _titleColor,  
182 - fontSize: 28,  
183 - fontWeight: FontWeight.w800,  
184 - letterSpacing: 0.5,  
185 - ), 70 + Image.asset(
  71 + 'assets/images/common/ic_double_feel_text.png',
  72 + width: 160,
  73 + height: 42,
186 ), 74 ),
187 - SizedBox(height: 16), 75 + SizedBox(height: 8),
188 Text( 76 Text(
189 - '开启压力预警与健康陪伴之旅\n让爱与关心从不缺席', 77 + l10n.loginSlogan,
190 textAlign: TextAlign.center, 78 textAlign: TextAlign.center,
191 style: TextStyle( 79 style: TextStyle(
192 - color: Color(0xFF666666),  
193 - fontSize: 14, 80 + color: context.colors.textSecondary,
  81 + fontSize: 12,
194 fontWeight: FontWeight.w400, 82 fontWeight: FontWeight.w400,
195 - height: 1.4, 83 + height: 1.50,
  84 + letterSpacing: 6,
196 ), 85 ),
197 ), 86 ),
198 ], 87 ],
@@ -201,13 +90,13 @@ class LoginView extends GetView<LoginController> { @@ -201,13 +90,13 @@ class LoginView extends GetView<LoginController> {
201 90
202 // Phone login button 91 // Phone login button
203 Positioned( 92 Positioned(
204 - top: top(560), 93 + bottom: bottom(145),
205 left: 0, 94 left: 0,
206 right: 0, 95 right: 0,
207 child: Center( 96 child: Center(
208 child: _LoginButton( 97 child: _LoginButton(
209 width: buttonWidth, 98 width: buttonWidth,
210 - label: '手机号登录/注册', 99 + label: l10n.loginWithPhone,
211 onPressed: controller.onPhoneLoginPressed, 100 onPressed: controller.onPhoneLoginPressed,
212 ), 101 ),
213 ), 102 ),
@@ -215,22 +104,69 @@ class LoginView extends GetView<LoginController> { @@ -215,22 +104,69 @@ class LoginView extends GetView<LoginController> {
215 104
216 // Apple login button 105 // Apple login button
217 Positioned( 106 Positioned(
218 - top: top(628), 107 + bottom: bottom(85),
219 left: 0, 108 left: 0,
220 right: 0, 109 right: 0,
221 child: Center( 110 child: Center(
222 child: _LoginButton( 111 child: _LoginButton(
223 width: buttonWidth, 112 width: buttonWidth,
224 - label: '通过Apple登录',  
225 - icon: Icons.apple, 113 + label: l10n.loginWithApple,
226 onPressed: controller.onAppleLoginPressed, 114 onPressed: controller.onAppleLoginPressed,
  115 + icon: Image.asset(
  116 + 'assets/images/common/ic_apple.png',
  117 + width: 24,
  118 + height: 24,
  119 + color: context.colors.textPrimary,
  120 + ),
  121 + buttonStyle: ElevatedButton.styleFrom(
  122 + backgroundColor: Colors.white,
  123 + foregroundColor: context.colors.textPrimary,
  124 + disabledBackgroundColor:
  125 + context.colors.textPrimary.withValues(alpha: 0.5),
  126 + disabledForegroundColor: context.colors.textPrimary,
  127 + elevation: 0,
  128 + shadowColor: Colors.transparent,
  129 + padding: EdgeInsets.symmetric(horizontal: 12),
  130 + shape: RoundedRectangleBorder(
  131 + borderRadius: BorderRadius.circular(24),
  132 + ),
  133 + textStyle: const TextStyle(
  134 + fontSize: 16,
  135 + fontWeight: FontWeight.w700,
  136 + ),
  137 + ),
227 ), 138 ),
228 ), 139 ),
229 ), 140 ),
230 - 141 + if (Get.find<LocalStorage>().lastLoginMethod.isNotEmpty)
  142 + Positioned(
  143 + right: rightOffset - 4,
  144 + bottom: Get.find<LocalStorage>().lastLoginMethod == 'apple'
  145 + ? bottom(117)
  146 + : bottom(200),
  147 + child: Container(
  148 + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  149 + decoration: ShapeDecoration(
  150 + color: const Color(0xFFFF9A6E),
  151 + shape: RoundedRectangleBorder(
  152 + side: BorderSide(width: 1, color: Colors.white),
  153 + borderRadius: BorderRadius.circular(25),
  154 + ),
  155 + ),
  156 + child: Text(
  157 + l10n.loginLastUsed,
  158 + textAlign: TextAlign.center,
  159 + style: TextStyle(
  160 + color: Colors.white,
  161 + fontSize: 11,
  162 + fontWeight: FontWeight.w500,
  163 + ),
  164 + ),
  165 + ),
  166 + ),
231 // Terms agreement text + checkbox 167 // Terms agreement text + checkbox
232 Positioned( 168 Positioned(
233 - top: top(712), 169 + bottom: bottom(40),
234 left: 0, 170 left: 0,
235 right: 0, 171 right: 0,
236 child: const _AgreementText(), 172 child: const _AgreementText(),
@@ -240,12 +176,13 @@ class LoginView extends GetView<LoginController> { @@ -240,12 +176,13 @@ class LoginView extends GetView<LoginController> {
240 Positioned( 176 Positioned(
241 left: 0, 177 left: 0,
242 right: 0, 178 right: 0,
243 - bottom: MediaQuery.paddingOf(context).bottom + 12, 179 + bottom: bottom(0),
244 child: Center( 180 child: Center(
245 child: TextButton( 181 child: TextButton(
246 onPressed: controller.onDebugPressed, 182 onPressed: controller.onDebugPressed,
247 style: TextButton.styleFrom( 183 style: TextButton.styleFrom(
248 - foregroundColor: _titleColor.withValues(alpha: 0.55), 184 + foregroundColor: context.colors.textSecondary
  185 + .withValues(alpha: 0.55),
249 textStyle: const TextStyle( 186 textStyle: const TextStyle(
250 fontSize: 12, 187 fontSize: 12,
251 fontWeight: FontWeight.w600, 188 fontWeight: FontWeight.w600,
@@ -274,42 +211,46 @@ class _LoginButton extends StatelessWidget { @@ -274,42 +211,46 @@ class _LoginButton extends StatelessWidget {
274 required this.label, 211 required this.label,
275 required this.onPressed, 212 required this.onPressed,
276 this.icon, 213 this.icon,
  214 + this.buttonStyle,
277 }); 215 });
278 216
279 final double width; 217 final double width;
280 final String label; 218 final String label;
281 - final IconData? icon; 219 + final Image? icon;
  220 + final ButtonStyle? buttonStyle;
282 final VoidCallback onPressed; 221 final VoidCallback onPressed;
283 222
284 @override 223 @override
285 Widget build(BuildContext context) { 224 Widget build(BuildContext context) {
286 return SizedBox( 225 return SizedBox(
287 width: width, 226 width: width,
288 - height: 52, 227 + height: 48,
289 child: ElevatedButton( 228 child: ElevatedButton(
290 onPressed: onPressed, 229 onPressed: onPressed,
291 - style: ElevatedButton.styleFrom(  
292 - backgroundColor: LoginView._brandColor,  
293 - foregroundColor: Colors.white,  
294 - disabledBackgroundColor: LoginView._brandColor.withValues(alpha: 0.5),  
295 - disabledForegroundColor: Colors.white,  
296 - elevation: 0,  
297 - shadowColor: Colors.transparent,  
298 - padding: EdgeInsets.symmetric(horizontal: icon == null ? 24 : 12),  
299 - shape: RoundedRectangleBorder(  
300 - borderRadius: BorderRadius.circular(26),  
301 - ),  
302 - textStyle: const TextStyle(  
303 - fontSize: 16,  
304 - fontWeight: FontWeight.w700,  
305 - ),  
306 - ), 230 + style: buttonStyle ??
  231 + ElevatedButton.styleFrom(
  232 + backgroundColor: context.colors.primary,
  233 + foregroundColor: Colors.white,
  234 + disabledBackgroundColor:
  235 + context.colors.primary.withValues(alpha: 0.5),
  236 + disabledForegroundColor: Colors.white,
  237 + elevation: 0,
  238 + shadowColor: Colors.transparent,
  239 + padding: EdgeInsets.symmetric(horizontal: icon == null ? 24 : 12),
  240 + shape: RoundedRectangleBorder(
  241 + borderRadius: BorderRadius.circular(24),
  242 + ),
  243 + textStyle: const TextStyle(
  244 + fontSize: 16,
  245 + fontWeight: FontWeight.w700,
  246 + ),
  247 + ),
307 child: Row( 248 child: Row(
308 mainAxisSize: MainAxisSize.min, 249 mainAxisSize: MainAxisSize.min,
309 mainAxisAlignment: MainAxisAlignment.center, 250 mainAxisAlignment: MainAxisAlignment.center,
310 children: [ 251 children: [
311 if (icon != null) ...[ 252 if (icon != null) ...[
312 - Icon(icon, size: 24), 253 + icon!,
313 const SizedBox(width: 8), 254 const SizedBox(width: 8),
314 ], 255 ],
315 Flexible( 256 Flexible(
@@ -329,18 +270,18 @@ class _LoginButton extends StatelessWidget { @@ -329,18 +270,18 @@ class _LoginButton extends StatelessWidget {
329 class _AgreementText extends GetView<LoginController> { 270 class _AgreementText extends GetView<LoginController> {
330 const _AgreementText(); 271 const _AgreementText();
331 272
332 - static const _mutedColor = Color(0xFF78787D);  
333 static const _linkColor = Color(0xFF14121E); 273 static const _linkColor = Color(0xFF14121E);
334 274
335 @override 275 @override
336 Widget build(BuildContext context) { 276 Widget build(BuildContext context) {
337 - const baseStyle = TextStyle(  
338 - color: _mutedColor, 277 + final l10n = AppLocalizations.of(context)!;
  278 + final baseStyle = TextStyle(
  279 + color: context.colors.textSecondary,
339 fontSize: 12, 280 fontSize: 12,
340 fontWeight: FontWeight.w400, 281 fontWeight: FontWeight.w400,
341 height: 1.2, 282 height: 1.2,
342 ); 283 );
343 - const linkStyle = TextStyle( 284 + final linkStyle = TextStyle(
344 color: _linkColor, 285 color: _linkColor,
345 fontSize: 12, 286 fontSize: 12,
346 fontWeight: FontWeight.w600, 287 fontWeight: FontWeight.w600,
@@ -362,7 +303,9 @@ class _AgreementText extends GetView<LoginController> { @@ -362,7 +303,9 @@ class _AgreementText extends GetView<LoginController> {
362 isChecked 303 isChecked
363 ? Icons.check_circle_rounded 304 ? Icons.check_circle_rounded
364 : Icons.radio_button_unchecked_rounded, 305 : Icons.radio_button_unchecked_rounded,
365 - color: isChecked ? LoginView._brandColor : _mutedColor, 306 + color: isChecked
  307 + ? context.colors.primary
  308 + : context.colors.textSecondary,
366 size: 18, 309 size: 18,
367 ), 310 ),
368 ), 311 ),
@@ -372,16 +315,16 @@ class _AgreementText extends GetView<LoginController> { @@ -372,16 +315,16 @@ class _AgreementText extends GetView<LoginController> {
372 TextSpan( 315 TextSpan(
373 style: baseStyle, 316 style: baseStyle,
374 children: [ 317 children: [
375 - const TextSpan(text: '我已阅读并同意'), 318 + TextSpan(text: l10n.loginAgreementPrefix),
376 TextSpan( 319 TextSpan(
377 - text: '《用户协议》', 320 + text: l10n.loginTerms,
378 style: linkStyle, 321 style: linkStyle,
379 recognizer: TapGestureRecognizer() 322 recognizer: TapGestureRecognizer()
380 ..onTap = controller.openUserTerms, 323 ..onTap = controller.openUserTerms,
381 ), 324 ),
382 - const TextSpan(text: '与'), 325 + TextSpan(text: l10n.loginAgreementAnd),
383 TextSpan( 326 TextSpan(
384 - text: '《隐私协议》', 327 + text: l10n.loginPrivacy,
385 style: linkStyle, 328 style: linkStyle,
386 recognizer: TapGestureRecognizer() 329 recognizer: TapGestureRecognizer()
387 ..onTap = controller.openPrivacyPolicy, 330 ..onTap = controller.openPrivacyPolicy,
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
1 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
2 import 'package:flutter/services.dart'; 4 import 'package:flutter/services.dart';
3 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
@@ -7,13 +9,9 @@ import '../controllers/login_controller.dart'; @@ -7,13 +9,9 @@ import '../controllers/login_controller.dart';
7 class PhoneLoginView extends GetView<LoginController> { 9 class PhoneLoginView extends GetView<LoginController> {
8 const PhoneLoginView({super.key}); 10 const PhoneLoginView({super.key});
9 11
10 - static const _designHeight = 812.0;  
11 - static const _brandColor = Color(0xFF845EEE);  
12 - static const _titleColor = Color(0xFF2C2020);  
13 - static const _subtitleColor = Color(0xFF908B91);  
14 -  
15 @override 12 @override
16 Widget build(BuildContext context) { 13 Widget build(BuildContext context) {
  14 + final l10n = context.l10n;
17 return AnnotatedRegion<SystemUiOverlayStyle>( 15 return AnnotatedRegion<SystemUiOverlayStyle>(
18 value: const SystemUiOverlayStyle( 16 value: const SystemUiOverlayStyle(
19 statusBarColor: Colors.transparent, 17 statusBarColor: Colors.transparent,
@@ -23,146 +21,104 @@ class PhoneLoginView extends GetView<LoginController> { @@ -23,146 +21,104 @@ class PhoneLoginView extends GetView<LoginController> {
23 systemNavigationBarIconBrightness: Brightness.dark, 21 systemNavigationBarIconBrightness: Brightness.dark,
24 ), 22 ),
25 child: Scaffold( 23 child: Scaffold(
26 - backgroundColor: Colors.white,  
27 - body: LayoutBuilder(  
28 - builder: (context, constraints) {  
29 - final screenSize = MediaQuery.sizeOf(context);  
30 - final width = constraints.maxWidth.isFinite  
31 - ? constraints.maxWidth  
32 - : screenSize.width;  
33 - final height = constraints.maxHeight.isFinite  
34 - ? constraints.maxHeight  
35 - : screenSize.height;  
36 -  
37 - double top(double value) => height * value / _designHeight;  
38 -  
39 - return SingleChildScrollView(  
40 - physics: const ClampingScrollPhysics(),  
41 - child: SizedBox(  
42 - height: height,  
43 - width: width,  
44 - child: Stack(  
45 - fit: StackFit.expand,  
46 - children: [  
47 - // Background Gradient  
48 - const DecoratedBox(  
49 - decoration: BoxDecoration(  
50 - gradient: LinearGradient(  
51 - begin: Alignment.topCenter,  
52 - end: Alignment.bottomCenter,  
53 - colors: [  
54 - Color(0xFFECE3FF),  
55 - Color(0xFFEDE4FF),  
56 - Color(0xFFEEE6FF),  
57 - Color(0xFFF9F6FF),  
58 - ],  
59 - stops: [0, 0.33173, 0.74038, 1],  
60 - ),  
61 - ),  
62 - ),  
63 -  
64 - // Top Back Button  
65 - Positioned(  
66 - top: top(48),  
67 - left: 12,  
68 - child: IconButton(  
69 - icon: const Icon(  
70 - Icons.arrow_back_ios_new_rounded,  
71 - color: _titleColor,  
72 - size: 22,  
73 - ),  
74 - onPressed: () {  
75 - Get.back();  
76 - },  
77 - ),  
78 - ),  
79 -  
80 - // Top logo text & stylized icon  
81 - Positioned(  
82 - top: top(62),  
83 - left: 0,  
84 - right: 0,  
85 - child: Row(  
86 - mainAxisAlignment: MainAxisAlignment.center, 24 + resizeToAvoidBottomInset: false,
  25 + body: DecoratedBox(
  26 + decoration: const BoxDecoration(
  27 + gradient: LinearGradient(
  28 + begin: Alignment.topCenter,
  29 + end: Alignment.bottomCenter,
  30 + stops: [0.0, 0.25, 0.75, 1.0],
  31 + colors: [
  32 + Color(0xFFE6DBFF),
  33 + Color(0xFFEDE4FF),
  34 + Color(0xFFEEE6FF),
  35 + Color(0xFFF9F6FF),
  36 + ],
  37 + ),
  38 + ),
  39 + child: Stack(
  40 + fit: StackFit.expand,
  41 + children: [
  42 + // 主体内容
  43 + SafeArea(
  44 + child: SingleChildScrollView(
  45 + physics: const ClampingScrollPhysics(),
  46 + padding: EdgeInsets.only(
  47 + bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
  48 + ),
  49 + child: Column(
  50 + crossAxisAlignment: CrossAxisAlignment.stretch,
  51 + children: [
  52 + // 顶部栏:返回按钮 + 角色插图
  53 + Stack(
  54 + clipBehavior: Clip.none,
87 children: [ 55 children: [
88 - Container(  
89 - padding: const EdgeInsets.all(6),  
90 - decoration: const BoxDecoration(  
91 - color: _brandColor,  
92 - shape: BoxShape.circle,  
93 - ),  
94 - child: const Icon(  
95 - Icons.favorite_rounded,  
96 - color: Colors.white,  
97 - size: 14,  
98 - ),  
99 - ),  
100 - const SizedBox(width: 8),  
101 - const Text(  
102 - 'Double Feel',  
103 - style: TextStyle(  
104 - color: _titleColor,  
105 - fontSize: 18,  
106 - fontWeight: FontWeight.w700,  
107 - letterSpacing: 0.5, 56 + // 返回按钮
  57 + Align(
  58 + alignment: Alignment.centerLeft,
  59 + child: IconButton(
  60 + icon: Icon(
  61 + Icons.arrow_back_ios_new_rounded,
  62 + color: context.colors.textPrimary,
  63 + size: 20,
  64 + ),
  65 + onPressed: Get.back,
108 ), 66 ),
109 ), 67 ),
  68 + // // 右上角角色插图
  69 + // Positioned(
  70 + // right: 16,
  71 + // top: -8,
  72 + // child: Image.asset(
  73 + // '',
  74 + // width: 113,
  75 + // height: 140,
  76 + // fit: BoxFit.contain,
  77 + // ),
  78 + // ),
110 ], 79 ],
111 ), 80 ),
112 - ),  
113 81
114 - // Headers  
115 - Positioned(  
116 - top: top(147),  
117 - left: 30,  
118 - right: 30,  
119 - child: Column(  
120 - crossAxisAlignment: CrossAxisAlignment.start,  
121 - children: [  
122 - const Text(  
123 - '还没有Feel牌吗?',  
124 - style: TextStyle(  
125 - color: _titleColor,  
126 - fontSize: 26,  
127 - fontWeight: FontWeight.w800,  
128 - ),  
129 - ),  
130 - const SizedBox(height: 7),  
131 - const Text(  
132 - '注册/登录手机号,成为Double Feel的一员吧~',  
133 - style: TextStyle(  
134 - color: _subtitleColor,  
135 - fontSize: 14,  
136 - fontWeight: FontWeight.w400,  
137 - ),  
138 - ),  
139 - ],  
140 - ),  
141 - ), 82 + const SizedBox(height: 44),
142 83
143 - // Card with input fields  
144 - Positioned(  
145 - top: top(240),  
146 - left: 16,  
147 - right: 16,  
148 - child: Container(  
149 - padding: const EdgeInsets.all(24),  
150 - decoration: BoxDecoration(  
151 - color: Colors.white,  
152 - borderRadius: BorderRadius.circular(32),  
153 - boxShadow: [  
154 - BoxShadow(  
155 - color: Colors.black.withValues(alpha: 0.04),  
156 - blurRadius: 24,  
157 - offset: const Offset(0, 8), 84 + // 标题区
  85 + Padding(
  86 + padding: const EdgeInsets.symmetric(horizontal: 40),
  87 + child: Column(
  88 + crossAxisAlignment: CrossAxisAlignment.start,
  89 + children: [
  90 + Text(
  91 + l10n.phoneLoginHello,
  92 + style: TextStyle(
  93 + fontSize: 28,
  94 + fontWeight: FontWeight.w600,
  95 + color: context.colors.textPrimary,
  96 + height: 1.4,
  97 + ),
  98 + ),
  99 + const SizedBox(height: 4),
  100 + Text(
  101 + l10n.phoneLoginWelcome,
  102 + style: TextStyle(
  103 + fontSize: 16,
  104 + fontWeight: FontWeight.w600,
  105 + color: context.colors.textPrimary,
  106 + height: 1.4,
  107 + ),
158 ), 108 ),
159 ], 109 ],
160 ), 110 ),
  111 + ),
  112 +
  113 + const SizedBox(height: 28),
  114 +
  115 + // 输入区
  116 + Padding(
  117 + padding: const EdgeInsets.symmetric(horizontal: 28),
161 child: Column( 118 child: Column(
162 - mainAxisSize: MainAxisSize.min, 119 + crossAxisAlignment: CrossAxisAlignment.stretch,
163 children: [ 120 children: [
164 - const SizedBox(height: 12),  
165 - // Phone Number Field 121 + // 手机号输入框
166 Obx(() { 122 Obx(() {
167 final phoneNotEmpty = 123 final phoneNotEmpty =
168 controller.phoneInput.value.isNotEmpty; 124 controller.phoneInput.value.isNotEmpty;
@@ -175,198 +131,250 @@ class PhoneLoginView extends GetView<LoginController> { @@ -175,198 +131,250 @@ class PhoneLoginView extends GetView<LoginController> {
175 RegExp(r'[0-9\s]')), 131 RegExp(r'[0-9\s]')),
176 _PhoneTextInputFormatter(), 132 _PhoneTextInputFormatter(),
177 ], 133 ],
178 - style: const TextStyle(  
179 - color: _titleColor,  
180 - fontSize: 15,  
181 - fontWeight: FontWeight.w500, 134 + style: TextStyle(
  135 + color: context.colors.textPrimary,
  136 + fontSize: 16,
  137 + fontWeight: FontWeight.w400,
182 ), 138 ),
183 decoration: InputDecoration( 139 decoration: InputDecoration(
184 - hintText: '请输入手机号',  
185 - hintStyle: const TextStyle(  
186 - color: _subtitleColor,  
187 - fontSize: 14, 140 + hintText: l10n.phoneLoginPhoneHint,
  141 + hintStyle: TextStyle(
  142 + color: context.colors.textTertiary,
  143 + fontSize: 16,
188 ), 144 ),
189 counterText: '', 145 counterText: '',
190 filled: true, 146 filled: true,
191 - fillColor: const Color(0xFFF7F6FA), 147 + fillColor: Colors.white,
192 contentPadding: const EdgeInsets.symmetric( 148 contentPadding: const EdgeInsets.symmetric(
193 horizontal: 20, 149 horizontal: 20,
194 vertical: 16, 150 vertical: 16,
195 ), 151 ),
196 border: OutlineInputBorder( 152 border: OutlineInputBorder(
197 - borderRadius: BorderRadius.circular(16), 153 + borderRadius: BorderRadius.circular(27),
  154 + borderSide: BorderSide.none,
  155 + ),
  156 + enabledBorder: OutlineInputBorder(
  157 + borderRadius: BorderRadius.circular(27),
198 borderSide: BorderSide.none, 158 borderSide: BorderSide.none,
199 ), 159 ),
  160 + focusedBorder: OutlineInputBorder(
  161 + borderRadius: BorderRadius.circular(27),
  162 + borderSide: BorderSide(
  163 + color: context.colors.primary,
  164 + width: 1,
  165 + ),
  166 + ),
200 suffixIcon: phoneNotEmpty 167 suffixIcon: phoneNotEmpty
201 ? GestureDetector( 168 ? GestureDetector(
202 - onTap: () {  
203 - controller.phoneController.clear();  
204 - },  
205 - child: const Icon( 169 + onTap:
  170 + controller.phoneController.clear,
  171 + child: Icon(
206 Icons.cancel, 172 Icons.cancel,
207 - color: _subtitleColor,  
208 - size: 20, 173 + color: context.colors.textTertiary,
  174 + size: 18,
209 ), 175 ),
210 ) 176 )
211 : null, 177 : null,
212 ), 178 ),
213 ); 179 );
214 }), 180 }),
  181 +
215 const SizedBox(height: 16), 182 const SizedBox(height: 16),
216 183
217 - // Verification Code Field Row 184 + // 验证码输入框(内嵌发送按钮)
218 Obx(() { 185 Obx(() {
219 final countdown = 186 final countdown =
220 controller.countdownSeconds.value; 187 controller.countdownSeconds.value;
221 final isSending = controller.isSendingCode.value; 188 final isSending = controller.isSendingCode.value;
222 final isCounting = countdown > 0; 189 final isCounting = countdown > 0;
  190 + final hasSent = controller.hasSentCode.value;
223 191
224 - String btnText = '发送验证码'; 192 + String btnText = hasSent
  193 + ? l10n.phoneLoginResend
  194 + : l10n.phoneLoginSendCode;
225 if (isSending) { 195 if (isSending) {
226 - btnText = '发送中'; 196 + btnText = l10n.phoneLoginSending;
227 } else if (isCounting) { 197 } else if (isCounting) {
228 - btnText = '已发送 ($countdown)'; 198 + btnText =
  199 + l10n.phoneLoginSentCountdown(countdown);
229 } 200 }
230 201
231 - final bool canSend = controller.canRequestCode;  
232 -  
233 - return Row(  
234 - children: [  
235 - Expanded(  
236 - child: TextField(  
237 - controller: controller.codeController,  
238 - keyboardType: TextInputType.text,  
239 - maxLength: 8,  
240 - style: const TextStyle(  
241 - color: _titleColor,  
242 - fontSize: 15,  
243 - fontWeight: FontWeight.w500,  
244 - ),  
245 - decoration: InputDecoration(  
246 - hintText: '请输入验证码',  
247 - hintStyle: const TextStyle(  
248 - color: _subtitleColor,  
249 - fontSize: 14,  
250 - ),  
251 - counterText: '',  
252 - filled: true,  
253 - fillColor: const Color(0xFFF7F6FA),  
254 - contentPadding:  
255 - const EdgeInsets.symmetric(  
256 - horizontal: 20,  
257 - vertical: 16,  
258 - ),  
259 - border: OutlineInputBorder(  
260 - borderRadius:  
261 - BorderRadius.circular(16),  
262 - borderSide: BorderSide.none,  
263 - ),  
264 - ), 202 + return TextField(
  203 + controller: controller.codeController,
  204 + keyboardType: TextInputType.text,
  205 + maxLength: 8,
  206 + style: TextStyle(
  207 + color: context.colors.textPrimary,
  208 + fontSize: 16,
  209 + fontWeight: FontWeight.w400,
  210 + ),
  211 + decoration: InputDecoration(
  212 + hintText: l10n.phoneLoginCodeHint,
  213 + hintStyle: TextStyle(
  214 + color: context.colors.textTertiary,
  215 + fontSize: 16,
  216 + ),
  217 + counterText: '',
  218 + filled: true,
  219 + fillColor: Colors.white,
  220 + contentPadding: const EdgeInsets.symmetric(
  221 + horizontal: 20,
  222 + vertical: 16,
  223 + ),
  224 + border: OutlineInputBorder(
  225 + borderRadius: BorderRadius.circular(27),
  226 + borderSide: BorderSide.none,
  227 + ),
  228 + enabledBorder: OutlineInputBorder(
  229 + borderRadius: BorderRadius.circular(27),
  230 + borderSide: BorderSide.none,
  231 + ),
  232 + focusedBorder: OutlineInputBorder(
  233 + borderRadius: BorderRadius.circular(27),
  234 + borderSide: BorderSide(
  235 + color: context.colors.primary,
  236 + width: 1,
265 ), 237 ),
266 ), 238 ),
267 - const SizedBox(width: 12),  
268 - SizedBox(  
269 - height: 52,  
270 - width: 110,  
271 - child: ElevatedButton(  
272 - onPressed: canSend 239 + suffixIcon: Padding(
  240 + padding: const EdgeInsets.only(right: 12),
  241 + child: TextButton(
  242 + onPressed: controller.canRequestCode
273 ? controller.requestVerifyCode 243 ? controller.requestVerifyCode
274 : null, 244 : null,
275 - style: ElevatedButton.styleFrom(  
276 - backgroundColor: _brandColor,  
277 - foregroundColor: Colors.white,  
278 - disabledBackgroundColor: const Color(  
279 - 0xFFECE9F6), // subtle tint  
280 - disabledForegroundColor: isCounting  
281 - ? _brandColor  
282 - : _subtitleColor,  
283 - elevation: 0,  
284 - padding: EdgeInsets.zero,  
285 - shape: RoundedRectangleBorder(  
286 - borderRadius:  
287 - BorderRadius.circular(16),  
288 - ), 245 + style: TextButton.styleFrom(
  246 + minimumSize: Size.zero,
  247 + padding: const EdgeInsets.symmetric(
  248 + horizontal: 8, vertical: 4),
  249 + tapTargetSize:
  250 + MaterialTapTargetSize.shrinkWrap,
  251 + foregroundColor: context.colors.primary,
  252 + disabledForegroundColor:
  253 + context.colors.textTertiary,
289 textStyle: const TextStyle( 254 textStyle: const TextStyle(
290 - fontSize: 12,  
291 - fontWeight: FontWeight.w700, 255 + fontSize: 14,
  256 + fontWeight: FontWeight.w500,
292 ), 257 ),
293 ), 258 ),
294 child: Text(btnText), 259 child: Text(btnText),
295 ), 260 ),
296 ), 261 ),
297 - ], 262 + suffixIconConstraints:
  263 + const BoxConstraints(minWidth: 0),
  264 + ),
298 ); 265 );
299 }), 266 }),
300 - const SizedBox(height: 24),  
301 267
302 - // Info label  
303 - const Text(  
304 - '未注册的手机号验证通过后将自动注册', 268 + const SizedBox(height: 56),
  269 +
  270 + // 提示文字
  271 + Text(
  272 + l10n.phoneLoginAutoRegisterHint,
305 style: TextStyle( 273 style: TextStyle(
306 - color: Color(0xFFCCCCCC), 274 + color: context.colors.textTertiary,
307 fontSize: 12, 275 fontSize: 12,
308 fontWeight: FontWeight.w400, 276 fontWeight: FontWeight.w400,
309 ), 277 ),
310 textAlign: TextAlign.center, 278 textAlign: TextAlign.center,
311 ), 279 ),
  280 +
312 const SizedBox(height: 16), 281 const SizedBox(height: 16),
313 282
314 - // Immediate Login Button 283 + // 立即登录按钮
315 Obx(() { 284 Obx(() {
316 - final canLogin = controller.canLogin;  
317 - final isLoggingIn =  
318 - controller.isLoggingIn.value;  
319 - 285 + final isLoggingIn = controller.isLoggingIn.value;
320 return SizedBox( 286 return SizedBox(
321 - width: double.infinity,  
322 - height: 56, 287 + height: 48,
323 child: ElevatedButton( 288 child: ElevatedButton(
324 - onPressed:  
325 - canLogin ? controller.login : null, 289 + onPressed: controller.canLogin
  290 + ? controller.login
  291 + : null,
326 style: ElevatedButton.styleFrom( 292 style: ElevatedButton.styleFrom(
327 - backgroundColor: _brandColor, 293 + backgroundColor: context.colors.primary,
328 foregroundColor: Colors.white, 294 foregroundColor: Colors.white,
329 - disabledBackgroundColor:  
330 - _brandColor.withValues(alpha: 0.4), 295 + disabledBackgroundColor: context
  296 + .colors.primary
  297 + .withValues(alpha: 0.4),
331 disabledForegroundColor: Colors.white, 298 disabledForegroundColor: Colors.white,
332 elevation: 0, 299 elevation: 0,
333 shape: RoundedRectangleBorder( 300 shape: RoundedRectangleBorder(
334 - borderRadius: BorderRadius.circular(28), 301 + borderRadius: BorderRadius.circular(24),
335 ), 302 ),
336 textStyle: const TextStyle( 303 textStyle: const TextStyle(
337 - fontSize: 18,  
338 - fontWeight: FontWeight.bold, 304 + fontSize: 16,
  305 + fontWeight: FontWeight.w600,
339 ), 306 ),
340 ), 307 ),
341 child: isLoggingIn 308 child: isLoggingIn
342 - ? const SizedBox(  
343 - width: 24,  
344 - height: 24,  
345 - child: CircularProgressIndicator(  
346 - color: Colors.white,  
347 - strokeWidth: 2.5,  
348 - ), 309 + ? Row(
  310 + mainAxisAlignment:
  311 + MainAxisAlignment.center,
  312 + children: [
  313 + const _SpinningLoader(),
  314 + const SizedBox(width: 8),
  315 + Text(l10n.phoneLoginLoggingIn),
  316 + ],
349 ) 317 )
350 - : const Text('立即登录'), 318 + : Text(l10n.loginBtn),
351 ), 319 ),
352 ); 320 );
353 }), 321 }),
354 - const SizedBox(height: 12),  
355 ], 322 ],
356 ), 323 ),
357 ), 324 ),
358 - ),  
359 - ], 325 + ],
  326 + ),
360 ), 327 ),
361 ), 328 ),
362 - );  
363 - }, 329 + ],
  330 + ),
364 ), 331 ),
365 ), 332 ),
366 ); 333 );
367 } 334 }
368 } 335 }
369 336
  337 +// ── 旋转 Loading 图标 ────────────────────────────────────────────
  338 +class _SpinningLoader extends StatefulWidget {
  339 + const _SpinningLoader();
  340 +
  341 + @override
  342 + State<_SpinningLoader> createState() => _SpinningLoaderState();
  343 +}
  344 +
  345 +class _SpinningLoaderState extends State<_SpinningLoader>
  346 + with SingleTickerProviderStateMixin {
  347 + late final AnimationController _ctrl;
  348 +
  349 + @override
  350 + void initState() {
  351 + super.initState();
  352 + _ctrl = AnimationController(
  353 + vsync: this,
  354 + duration: const Duration(milliseconds: 900),
  355 + )..repeat();
  356 + }
  357 +
  358 + @override
  359 + void dispose() {
  360 + _ctrl.dispose();
  361 + super.dispose();
  362 + }
  363 +
  364 + @override
  365 + Widget build(BuildContext context) {
  366 + return RotationTransition(
  367 + turns: _ctrl,
  368 + child: Image.asset(
  369 + 'assets/images/common/ic_loading_ios_style.png',
  370 + width: 24,
  371 + height: 24,
  372 + ),
  373 + );
  374 + }
  375 +}
  376 +
  377 +// ── 手机号格式化 ───────────────────────────────────────────────────
370 class _PhoneTextInputFormatter extends TextInputFormatter { 378 class _PhoneTextInputFormatter extends TextInputFormatter {
371 @override 379 @override
372 TextEditingValue formatEditUpdate( 380 TextEditingValue formatEditUpdate(
1 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 1 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  2 +import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
  3 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
2 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
3 -import 'package:flutter/services.dart';  
4 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
5 import 'package:permission_handler/permission_handler.dart'; 6 import 'package:permission_handler/permission_handler.dart';
6 7
@@ -33,9 +34,15 @@ class UserOnboardingController extends GetxController { @@ -33,9 +34,15 @@ class UserOnboardingController extends GetxController {
33 34
34 @override 35 @override
35 void onInit() { 36 void onInit() {
36 - totalPages = Get.arguments?['type'] == 'MembershipOfferPage'  
37 - ? (Get.arguments?['needBindSuccessGuide'] == true ? 2 : 1)  
38 - : 9; 37 + if (Get.arguments?['type'] == 'MembershipOfferPage') {
  38 + totalPages = (Get.arguments?['needBindSuccessGuide'] == true ? 2 : 1);
  39 + } else {
  40 + final argument = Get.arguments?['resumeStage'];
  41 + if (argument != null && argument is int) {
  42 + currentPageIndex.value = argument.clamp(0, totalPages - 1);
  43 + }
  44 + totalPages = 9;
  45 + }
39 super.onInit(); 46 super.onInit();
40 } 47 }
41 48
@@ -82,7 +89,8 @@ class UserOnboardingController extends GetxController { @@ -82,7 +89,8 @@ class UserOnboardingController extends GetxController {
82 if (Get.key.currentState?.canPop() ?? false) { 89 if (Get.key.currentState?.canPop() ?? false) {
83 Get.back(); 90 Get.back();
84 } else { 91 } else {
85 - SystemNavigator.pop(); 92 + // SystemNavigator.pop();
  93 + Get.offAllNamed(AppRoutes.home);
86 } 94 }
87 } 95 }
88 96
@@ -91,6 +99,16 @@ class UserOnboardingController extends GetxController { @@ -91,6 +99,16 @@ class UserOnboardingController extends GetxController {
91 if (!await prepareContinue(pageIndex)) { 99 if (!await prepareContinue(pageIndex)) {
92 return; 100 return;
93 } 101 }
  102 +
  103 + // 保存当前页进度(中途退出后可断点续做)
  104 + final userId =
  105 + Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
  106 + 0;
  107 + if (userId > 0) {
  108 + await Get.find<UserAccountStorage>()
  109 + .saveOnboardingStage(userId, pageIndex);
  110 + }
  111 +
94 if (pageIndex >= totalPages - 1) { 112 if (pageIndex >= totalPages - 1) {
95 finishOnboarding(); 113 finishOnboarding();
96 return; 114 return;
@@ -113,6 +131,14 @@ class UserOnboardingController extends GetxController { @@ -113,6 +131,14 @@ class UserOnboardingController extends GetxController {
113 } 131 }
114 132
115 void finishOnboarding() { 133 void finishOnboarding() {
  134 + // 标记当前账号引导已全部完成
  135 + final userId =
  136 + Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
  137 + 0;
  138 + if (userId > 0) {
  139 + Get.find<UserAccountStorage>().markOnboardingCompleted(userId);
  140 + }
  141 +
116 if (Get.arguments?['type'] == 'MembershipOfferPage') { 142 if (Get.arguments?['type'] == 'MembershipOfferPage') {
117 Get.offAllNamed(AppRoutes.home); 143 Get.offAllNamed(AppRoutes.home);
118 } else { 144 } else {
@@ -18,4 +18,9 @@ abstract final class AppConst { @@ -18,4 +18,9 @@ abstract final class AppConst {
18 static const String appKeyRongCloudDev = 'k51hidwqkz8bb'; 18 static const String appKeyRongCloudDev = 'k51hidwqkz8bb';
19 19
20 static const String obsSceneAvatar = 'avatar'; 20 static const String obsSceneAvatar = 'avatar';
  21 +
  22 + static const String userTerms =
  23 + 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html';
  24 + static const String privacyPolicy =
  25 + 'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html';
21 } 26 }
@@ -6,4 +6,5 @@ abstract final class StorageConst { @@ -6,4 +6,5 @@ abstract final class StorageConst {
6 6
7 static const String appSettingsPrefsName = 'app_settings'; 7 static const String appSettingsPrefsName = 'app_settings';
8 static const String termsAgreedKey = 'terms_agreed'; 8 static const String termsAgreedKey = 'terms_agreed';
  9 + static const String lastLoginMethodKey = 'last_login_method';
9 } 10 }
1 import 'package:get/get.dart'; 1 import 'package:get/get.dart';
2 2
3 import '../constants/network_const.dart'; 3 import '../constants/network_const.dart';
  4 +import '../util/app_toast.dart';
4 import '../logging/app_logger.dart'; 5 import '../logging/app_logger.dart';
5 import '../services/user_state_service.dart'; 6 import '../services/user_state_service.dart';
6 import 'app_error.dart'; 7 import 'app_error.dart';
@@ -53,6 +54,6 @@ class AppErrorHandler { @@ -53,6 +54,6 @@ class AppErrorHandler {
53 if (message == null || message.isEmpty) { 54 if (message == null || message.isEmpty) {
54 return; 55 return;
55 } 56 }
56 - AppLogger.i('Toast (placeholder): $message'); 57 + AppToast.show(message);
57 } 58 }
58 } 59 }
  1 +import '../../error/http_error_handling_policy.dart';
1 import '../../result/app_result.dart'; 2 import '../../result/app_result.dart';
2 import '../../result/safe_call.dart'; 3 import '../../result/safe_call.dart';
3 import '../../../data/models/enums/app_enums.dart'; 4 import '../../../data/models/enums/app_enums.dart';
@@ -18,30 +19,44 @@ class HealthApi { @@ -18,30 +19,44 @@ class HealthApi {
18 ApiPaths.huaweiAuth, 19 ApiPaths.huaweiAuth,
19 data: HealthAuthRequest(code: code).toJson(), 20 data: HealthAuthRequest(code: code).toJson(),
20 ); 21 );
21 - return HealthAuthResponse.fromJson(response.data as Map<String, dynamic>); 22 + return HealthAuthResponse.fromJson(
  23 + response.data as Map<String, dynamic>);
22 }, 24 },
23 ); 25 );
24 } 26 }
25 27
26 - Future<AppResult<HealthAuthResponse>> checkServerHealthAuth() { 28 + Future<AppResult<HealthAuthResponse>> checkServerHealthAuth({
  29 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  30 + HttpErrorHandlingPolicy.defaultPolicy,
  31 + }) {
27 return safeCall( 32 return safeCall(
28 call: () async { 33 call: () async {
29 final response = await _dioClient.dio.get(ApiPaths.huaweiAuth); 34 final response = await _dioClient.dio.get(ApiPaths.huaweiAuth);
30 - return HealthAuthResponse.fromJson(response.data as Map<String, dynamic>); 35 + return HealthAuthResponse.fromJson(
  36 + response.data as Map<String, dynamic>);
31 }, 37 },
  38 + errorHandlingPolicy: errorHandlingPolicy,
32 ); 39 );
33 } 40 }
34 41
35 - Future<AppResult<LatestHrvData>> getLatestHrvData() { 42 + Future<AppResult<LatestHrvData>> getLatestHrvData({
  43 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  44 + HttpErrorHandlingPolicy.defaultPolicy,
  45 + }) {
36 return safeCall( 46 return safeCall(
37 call: () async { 47 call: () async {
38 final response = await _dioClient.dio.get(ApiPaths.healthLatestHrv); 48 final response = await _dioClient.dio.get(ApiPaths.healthLatestHrv);
39 return LatestHrvData.fromJson(response.data as Map<String, dynamic>); 49 return LatestHrvData.fromJson(response.data as Map<String, dynamic>);
40 }, 50 },
  51 + errorHandlingPolicy: errorHandlingPolicy,
41 ); 52 );
42 } 53 }
43 54
44 - Future<AppResult<TodayStatusData>> getTodayData({required bool isOther}) { 55 + Future<AppResult<TodayStatusData>> getTodayData({
  56 + required bool isOther,
  57 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  58 + HttpErrorHandlingPolicy.defaultPolicy,
  59 + }) {
45 return safeCall( 60 return safeCall(
46 call: () async { 61 call: () async {
47 final response = await _dioClient.dio.get( 62 final response = await _dioClient.dio.get(
@@ -50,20 +65,29 @@ class HealthApi { @@ -50,20 +65,29 @@ class HealthApi {
50 ); 65 );
51 return TodayStatusData.fromJson(response.data as Map<String, dynamic>); 66 return TodayStatusData.fromJson(response.data as Map<String, dynamic>);
52 }, 67 },
  68 + errorHandlingPolicy: errorHandlingPolicy,
53 ); 69 );
54 } 70 }
55 71
56 - Future<AppResult<PkCurrentMonthData>> getCurrentMonthPkData() { 72 + Future<AppResult<PkCurrentMonthData>> getCurrentMonthPkData({
  73 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  74 + HttpErrorHandlingPolicy.defaultPolicy,
  75 + }) {
57 return safeCall( 76 return safeCall(
58 call: () async { 77 call: () async {
59 final response = await _dioClient.dio.get(ApiPaths.healthPkInfo); 78 final response = await _dioClient.dio.get(ApiPaths.healthPkInfo);
60 - return PkCurrentMonthData.fromJson(response.data as Map<String, dynamic>); 79 + return PkCurrentMonthData.fromJson(
  80 + response.data as Map<String, dynamic>);
61 }, 81 },
  82 + errorHandlingPolicy: errorHandlingPolicy,
62 ); 83 );
63 } 84 }
64 85
65 Future<AppResult<HealthDataLatestUploadRecordList>> 86 Future<AppResult<HealthDataLatestUploadRecordList>>
66 - getCommonHealthDataLatestUploadRecordList() { 87 + getCommonHealthDataLatestUploadRecordList({
  88 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  89 + HttpErrorHandlingPolicy.defaultPolicy,
  90 + }) {
67 return safeCall( 91 return safeCall(
68 call: () async { 92 call: () async {
69 final response = await _dioClient.dio.get(ApiPaths.healthUploadCommon); 93 final response = await _dioClient.dio.get(ApiPaths.healthUploadCommon);
@@ -71,6 +95,7 @@ class HealthApi { @@ -71,6 +95,7 @@ class HealthApi {
71 response.data as Map<String, dynamic>, 95 response.data as Map<String, dynamic>,
72 ); 96 );
73 }, 97 },
  98 + errorHandlingPolicy: errorHandlingPolicy,
74 ); 99 );
75 } 100 }
76 101
@@ -88,7 +113,10 @@ class HealthApi { @@ -88,7 +113,10 @@ class HealthApi {
88 } 113 }
89 114
90 Future<AppResult<HealthDataLatestUploadRecord>> 115 Future<AppResult<HealthDataLatestUploadRecord>>
91 - getSleepStateHealthDataLatestUploadRecord() { 116 + getSleepStateHealthDataLatestUploadRecord({
  117 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  118 + HttpErrorHandlingPolicy.defaultPolicy,
  119 + }) {
92 return safeCall( 120 return safeCall(
93 call: () async { 121 call: () async {
94 final response = await _dioClient.dio.get(ApiPaths.healthUploadSleep); 122 final response = await _dioClient.dio.get(ApiPaths.healthUploadSleep);
@@ -96,6 +124,7 @@ class HealthApi { @@ -96,6 +124,7 @@ class HealthApi {
96 response.data as Map<String, dynamic>, 124 response.data as Map<String, dynamic>,
97 ); 125 );
98 }, 126 },
  127 + errorHandlingPolicy: errorHandlingPolicy,
99 ); 128 );
100 } 129 }
101 130
@@ -116,6 +145,8 @@ class HealthApi { @@ -116,6 +145,8 @@ class HealthApi {
116 required bool isOther, 145 required bool isOther,
117 required int dateRangeType, 146 required int dateRangeType,
118 required int startDate, 147 required int startDate,
  148 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  149 + HttpErrorHandlingPolicy.defaultPolicy,
119 }) { 150 }) {
120 return safeCall( 151 return safeCall(
121 call: () async { 152 call: () async {
@@ -127,8 +158,10 @@ class HealthApi { @@ -127,8 +158,10 @@ class HealthApi {
127 'start_date': startDate, 158 'start_date': startDate,
128 }, 159 },
129 ); 160 );
130 - return SleepStatisticsData.fromJson(response.data as Map<String, dynamic>); 161 + return SleepStatisticsData.fromJson(
  162 + response.data as Map<String, dynamic>);
131 }, 163 },
  164 + errorHandlingPolicy: errorHandlingPolicy,
132 ); 165 );
133 } 166 }
134 167
@@ -136,6 +169,8 @@ class HealthApi { @@ -136,6 +169,8 @@ class HealthApi {
136 required bool isOther, 169 required bool isOther,
137 required int dateRangeType, 170 required int dateRangeType,
138 required int startDate, 171 required int startDate,
  172 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  173 + HttpErrorHandlingPolicy.defaultPolicy,
139 }) { 174 }) {
140 return safeCall( 175 return safeCall(
141 call: () async { 176 call: () async {
@@ -151,6 +186,7 @@ class HealthApi { @@ -151,6 +186,7 @@ class HealthApi {
151 response.data as Map<String, dynamic>, 186 response.data as Map<String, dynamic>,
152 ); 187 );
153 }, 188 },
  189 + errorHandlingPolicy: errorHandlingPolicy,
154 ); 190 );
155 } 191 }
156 192
@@ -158,6 +194,8 @@ class HealthApi { @@ -158,6 +194,8 @@ class HealthApi {
158 required bool isOther, 194 required bool isOther,
159 required int dateRangeType, 195 required int dateRangeType,
160 required int startDate, 196 required int startDate,
  197 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  198 + HttpErrorHandlingPolicy.defaultPolicy,
161 }) { 199 }) {
162 return safeCall( 200 return safeCall(
163 call: () async { 201 call: () async {
@@ -169,8 +207,10 @@ class HealthApi { @@ -169,8 +207,10 @@ class HealthApi {
169 'start_date': startDate, 207 'start_date': startDate,
170 }, 208 },
171 ); 209 );
172 - return HrvStatisticsData.fromJson(response.data as Map<String, dynamic>); 210 + return HrvStatisticsData.fromJson(
  211 + response.data as Map<String, dynamic>);
173 }, 212 },
  213 + errorHandlingPolicy: errorHandlingPolicy,
174 ); 214 );
175 } 215 }
176 216
@@ -187,6 +227,8 @@ class HealthApi { @@ -187,6 +227,8 @@ class HealthApi {
187 227
188 Future<AppResult<PulseTypeResponse>> getLatestPulseTypeResult({ 228 Future<AppResult<PulseTypeResponse>> getLatestPulseTypeResult({
189 required bool isOther, 229 required bool isOther,
  230 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  231 + HttpErrorHandlingPolicy.defaultPolicy,
190 }) { 232 }) {
191 return safeCall( 233 return safeCall(
192 call: () async { 234 call: () async {
@@ -194,8 +236,10 @@ class HealthApi { @@ -194,8 +236,10 @@ class HealthApi {
194 ApiPaths.healthPulseLatest, 236 ApiPaths.healthPulseLatest,
195 queryParameters: {'is_other': isOther ? 1 : 0}, 237 queryParameters: {'is_other': isOther ? 1 : 0},
196 ); 238 );
197 - return PulseTypeResponse.fromJson(response.data as Map<String, dynamic>); 239 + return PulseTypeResponse.fromJson(
  240 + response.data as Map<String, dynamic>);
198 }, 241 },
  242 + errorHandlingPolicy: errorHandlingPolicy,
199 ); 243 );
200 } 244 }
201 } 245 }
1 import '../../constants/app_const.dart'; 1 import '../../constants/app_const.dart';
  2 +import '../../error/http_error_handling_policy.dart';
2 import '../../result/app_result.dart'; 3 import '../../result/app_result.dart';
3 import '../../result/safe_call.dart'; 4 import '../../result/safe_call.dart';
4 import '../../../data/models/user/user_models.dart'; 5 import '../../../data/models/user/user_models.dart';
@@ -79,6 +80,8 @@ class UserApi { @@ -79,6 +80,8 @@ class UserApi {
79 Future<AppResult<UserInfoResponse>> getUserInfo({ 80 Future<AppResult<UserInfoResponse>> getUserInfo({
80 String? bindCode, 81 String? bindCode,
81 String? accessToken, 82 String? accessToken,
  83 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  84 + HttpErrorHandlingPolicy.defaultPolicy,
82 }) { 85 }) {
83 return safeCall( 86 return safeCall(
84 call: () async { 87 call: () async {
@@ -91,11 +94,14 @@ class UserApi { @@ -91,11 +94,14 @@ class UserApi {
91 ); 94 );
92 return UserInfoResponse.fromJson(response.data as Map<String, dynamic>); 95 return UserInfoResponse.fromJson(response.data as Map<String, dynamic>);
93 }, 96 },
  97 + errorHandlingPolicy: errorHandlingPolicy,
94 ); 98 );
95 } 99 }
96 100
97 Future<AppResult<BoundUserInfoResponse>> getPartnerUserInfo({ 101 Future<AppResult<BoundUserInfoResponse>> getPartnerUserInfo({
98 String? accessToken, 102 String? accessToken,
  103 + HttpErrorHandlingPolicy? errorHandlingPolicy =
  104 + HttpErrorHandlingPolicy.defaultPolicy,
99 }) { 105 }) {
100 return safeCall( 106 return safeCall(
101 call: () async { 107 call: () async {
@@ -107,6 +113,7 @@ class UserApi { @@ -107,6 +113,7 @@ class UserApi {
107 response.data as Map<String, dynamic>, 113 response.data as Map<String, dynamic>,
108 ); 114 );
109 }, 115 },
  116 + errorHandlingPolicy: errorHandlingPolicy,
110 ); 117 );
111 } 118 }
112 119
1 import 'package:flutter/material.dart'; 1 import 'package:flutter/material.dart';
  2 +import 'package:flutter/services.dart';
2 import 'app_colors_extension.dart'; 3 import 'app_colors_extension.dart';
3 4
4 /// Class managing the Application ThemeData for both Light and Dark themes. 5 /// Class managing the Application ThemeData for both Light and Dark themes.
5 /// Automatically hooks up our custom Figma colors system as a ThemeExtension. 6 /// Automatically hooks up our custom Figma colors system as a ThemeExtension.
6 class AppTheme { 7 class AppTheme {
7 AppTheme._(); 8 AppTheme._();
  9 + static const systemUiOverlayStyle = SystemUiOverlayStyle(
  10 + statusBarColor: Colors.transparent,
  11 + statusBarIconBrightness: Brightness.dark,
  12 + statusBarBrightness: Brightness.light,
  13 + systemNavigationBarColor: Colors.transparent,
  14 + systemNavigationBarIconBrightness: Brightness.dark,
  15 + );
8 16
9 /// The standard light theme configuration. 17 /// The standard light theme configuration.
10 static ThemeData get lightTheme { 18 static ThemeData get lightTheme {
11 final colors = AppColorsExtension.light(); 19 final colors = AppColorsExtension.light();
  20 +
12 return ThemeData( 21 return ThemeData(
13 useMaterial3: true, 22 useMaterial3: true,
14 brightness: Brightness.light, 23 brightness: Brightness.light,
15 primaryColor: colors.primary, 24 primaryColor: colors.primary,
16 scaffoldBackgroundColor: colors.backgroundLight, 25 scaffoldBackgroundColor: colors.backgroundLight,
17 - 26 +
18 // Clean modern AppBar theme using Figma colors 27 // Clean modern AppBar theme using Figma colors
19 appBarTheme: AppBarTheme( 28 appBarTheme: AppBarTheme(
20 backgroundColor: colors.backgroundLight, 29 backgroundColor: colors.backgroundLight,
@@ -27,6 +36,7 @@ class AppTheme { @@ -27,6 +36,7 @@ class AppTheme {
27 fontSize: 18, 36 fontSize: 18,
28 fontWeight: FontWeight.w600, 37 fontWeight: FontWeight.w600,
29 ), 38 ),
  39 + systemOverlayStyle: systemUiOverlayStyle,
30 ), 40 ),
31 41
32 // Configure default ColorScheme using Figma specs 42 // Configure default ColorScheme using Figma specs
@@ -55,7 +65,6 @@ class AppTheme { @@ -55,7 +65,6 @@ class AppTheme {
55 brightness: Brightness.dark, 65 brightness: Brightness.dark,
56 primaryColor: colors.primary, 66 primaryColor: colors.primary,
57 scaffoldBackgroundColor: colors.backgroundLight, 67 scaffoldBackgroundColor: colors.backgroundLight,
58 -  
59 appBarTheme: AppBarTheme( 68 appBarTheme: AppBarTheme(
60 backgroundColor: colors.backgroundLight, 69 backgroundColor: colors.backgroundLight,
61 elevation: 0, 70 elevation: 0,
@@ -67,8 +76,8 @@ class AppTheme { @@ -67,8 +76,8 @@ class AppTheme {
67 fontSize: 18, 76 fontSize: 18,
68 fontWeight: FontWeight.w600, 77 fontWeight: FontWeight.w600,
69 ), 78 ),
  79 + systemOverlayStyle: systemUiOverlayStyle,
70 ), 80 ),
71 -  
72 colorScheme: ColorScheme.dark( 81 colorScheme: ColorScheme.dark(
73 primary: colors.primary, 82 primary: colors.primary,
74 secondary: colors.primary, 83 secondary: colors.primary,
@@ -78,7 +87,6 @@ class AppTheme { @@ -78,7 +87,6 @@ class AppTheme {
78 onSurface: colors.textPrimary, 87 onSurface: colors.textPrimary,
79 outline: colors.border, 88 outline: colors.border,
80 ), 89 ),
81 -  
82 extensions: [ 90 extensions: [
83 colors, 91 colors,
84 ], 92 ],
@@ -89,6 +97,7 @@ class AppTheme { @@ -89,6 +97,7 @@ class AppTheme {
89 /// Helper extension to easily access custom Figma colors inside widgets 97 /// Helper extension to easily access custom Figma colors inside widgets
90 /// by using `context.colors.<semanticName>` instead of verbose lookups. 98 /// by using `context.colors.<semanticName>` instead of verbose lookups.
91 extension AppThemeContextExtension on BuildContext { 99 extension AppThemeContextExtension on BuildContext {
92 - AppColorsExtension get colors => Theme.of(this).extension<AppColorsExtension>()!; 100 + AppColorsExtension get colors =>
  101 + Theme.of(this).extension<AppColorsExtension>()!;
93 ThemeData get theme => Theme.of(this); 102 ThemeData get theme => Theme.of(this);
94 } 103 }
  1 +import 'package:fluttertoast/fluttertoast.dart';
  2 +
  3 +/// App-wide short toast. Prefer this over calling [Fluttertoast] directly.
  4 +abstract final class AppToast {
  5 + AppToast._();
  6 +
  7 + static void show(
  8 + String message, {
  9 + ToastGravity gravity = ToastGravity.CENTER,
  10 + }) {
  11 + if (message.isEmpty) return;
  12 + Fluttertoast.showToast(msg: message, gravity: gravity);
  13 + }
  14 +}
@@ -39,4 +39,11 @@ class LocalStorage { @@ -39,4 +39,11 @@ class LocalStorage {
39 Future<void> setTermsAgreed(bool value) async { 39 Future<void> setTermsAgreed(bool value) async {
40 await sharedPreferences.setBool(StorageConst.termsAgreedKey, value); 40 await sharedPreferences.setBool(StorageConst.termsAgreedKey, value);
41 } 41 }
  42 +
  43 + String get lastLoginMethod =>
  44 + sharedPreferences.getString(StorageConst.lastLoginMethodKey) ?? '';
  45 +
  46 + Future<void> setLastLoginMethod(String value) async {
  47 + await sharedPreferences.setString(StorageConst.lastLoginMethodKey, value);
  48 + }
42 } 49 }
  1 +import 'package:shared_preferences/shared_preferences.dart';
  2 +
  3 +/// 账号级持久化存储。
  4 +///
  5 +/// 与 [UserPreferencesStorage] 的区别:
  6 +/// - [UserPreferencesStorage] 存储会话数据,退登时 clear() 会全部清除。
  7 +/// - [UserAccountStorage] 存储账号元数据,退登不清除,按 userId 隔离。
  8 +///
  9 +/// 适合存储:新手引导进度、账号历史记录等跨会话数据。
  10 +class UserAccountStorage {
  11 + UserAccountStorage(this._prefs);
  12 +
  13 + final SharedPreferences _prefs;
  14 +
  15 + // ─── Keys ──────────────────────────────────────────────────────────────────
  16 +
  17 + /// Onboarding 阶段 key,按 userId 隔离
  18 + static String _onboardingKey(int userId) =>
  19 + 'account_onboarding_stage_$userId';
  20 +
  21 + /// Onboarding 已全部完成的哨兵值
  22 + static const int _kOnboardingCompleted = -1;
  23 +
  24 + // ─── Onboarding 阶段 ───────────────────────────────────────────────────────
  25 + //
  26 + // 存储规则:
  27 + // null(key 不存在) → 从未开始
  28 + // 0 ~ N(页码) → 进行中,记录上次停留的页码
  29 + // -1(kCompleted) → 已全部完成
  30 + //
  31 + // 状态机:
  32 + // null ──[进入引导]──▶ 0 ──[翻页]──▶ 1 ... N ──[完成]──▶ -1
  33 + // ↑ │
  34 + // └───────────────────[resetOnboarding]───────────────────┘
  35 +
  36 + /// 是否已完成全部引导流程。
  37 + bool hasCompletedOnboarding(int userId) =>
  38 + _prefs.getInt(_onboardingKey(userId)) == _kOnboardingCompleted;
  39 +
  40 + /// 是否已开始过引导(包括进行中和已完成)。
  41 + bool hasStartedOnboarding(int userId) =>
  42 + _prefs.getInt(_onboardingKey(userId)) != null;
  43 +
  44 + /// 中途退出时上次停留的页码;null 表示从未开始或已完成。
  45 + int? onboardingResumeStage(int userId) {
  46 + final v = _prefs.getInt(_onboardingKey(userId));
  47 + if (v == null || v == _kOnboardingCompleted) return null;
  48 + return v;
  49 + }
  50 +
  51 + /// 每次翻页时调用,保存当前页码进度。
  52 + Future<void> saveOnboardingStage(int userId, int pageIndex) =>
  53 + _prefs.setInt(_onboardingKey(userId), pageIndex);
  54 +
  55 + /// 引导全部完成时调用。
  56 + Future<void> markOnboardingCompleted(int userId) =>
  57 + _prefs.setInt(_onboardingKey(userId), _kOnboardingCompleted);
  58 +}
@@ -84,5 +84,34 @@ @@ -84,5 +84,34 @@
84 "onboardingResearchSick": "Sick or Unwell", 84 "onboardingResearchSick": "Sick or Unwell",
85 "onboardingResearchHealthy": "Feeling Great", 85 "onboardingResearchHealthy": "Feeling Great",
86 "onboardingResearchPoorSleep": "Poor Sleep", 86 "onboardingResearchPoorSleep": "Poor Sleep",
87 - "onboardingResearchGoodSleep": "Good Sleep"  
88 -}  
  87 + "onboardingResearchGoodSleep": "Good Sleep",
  88 +
  89 + "loginSlogan": "Start your pressure alert and health companion journey\nso love and care are always present",
  90 + "loginWithPhone": "Sign in with Phone",
  91 + "loginWithApple": "Sign in with Apple",
  92 + "loginLastUsed": "Last used",
  93 + "loginAgreementPrefix": "I have read and agree to the ",
  94 + "loginTerms": "Terms of Service",
  95 + "loginAgreementAnd": " and ",
  96 + "loginPrivacy": "Privacy Policy",
  97 +
  98 + "phoneLoginHello": "Hello",
  99 + "phoneLoginWelcome": "Welcome to Double Feel",
  100 + "phoneLoginPhoneHint": "Enter phone number",
  101 + "phoneLoginSendCode": "Send Code",
  102 + "phoneLoginSending": "Sending",
  103 + "phoneLoginSentCountdown": "Sent ({countdown}s)",
  104 + "phoneLoginResend": "Resend",
  105 + "phoneLoginCodeHint": "Enter verification code",
  106 + "phoneLoginAutoRegisterHint": "Unregistered numbers will be registered automatically",
  107 + "phoneLoginLoggingIn": "Signing in...",
  108 +
  109 + "loginAgreeToTermsToast": "Please read and agree to the Terms of Service and Privacy Policy first",
  110 + "phoneLoginInvalidPhone": "Invalid phone number",
  111 + "phoneLoginCodeSentSuccess": "Code sent",
  112 + "phoneLoginInvalidCode": "Invalid verification code",
  113 +
  114 + "todayHealthDataAuthTitle": "Unable to access heart rate health data",
  115 + "todayHealthDataAuthDescription": "DoubleFeel needs permission to access your health data to provide stress reminders, real-time stress statistics, and health suggestions. Otherwise, some app features may not work properly. Your health data is stored locally only and will not be uploaded to any server.",
  116 + "todayHealthDataAuthAction": "Authorize health data access"
  117 +}
@@ -87,5 +87,64 @@ @@ -87,5 +87,64 @@
87 "onboardingResearchSick": "感冒生病", 87 "onboardingResearchSick": "感冒生病",
88 "onboardingResearchHealthy": "身体倍儿棒", 88 "onboardingResearchHealthy": "身体倍儿棒",
89 "onboardingResearchPoorSleep": "睡眠不足", 89 "onboardingResearchPoorSleep": "睡眠不足",
90 - "onboardingResearchGoodSleep": "睡眠充足"  
91 -}  
  90 + "onboardingResearchGoodSleep": "睡眠充足",
  91 +
  92 + "loginSlogan": "开启压力预警与健康陪伴之旅\n让爱与关心从不缺席",
  93 + "@loginSlogan": { "description": "登录页 slogan" },
  94 + "loginWithPhone": "手机号登录/注册",
  95 + "@loginWithPhone": { "description": "手机号登录按钮" },
  96 + "loginWithApple": "通过Apple登录",
  97 + "@loginWithApple": { "description": "Apple登录按钮" },
  98 + "loginLastUsed": "上次使用",
  99 + "@loginLastUsed": { "description": "上次使用标签" },
  100 + "loginAgreementPrefix": "我已阅读并同意",
  101 + "@loginAgreementPrefix": { "description": "协议勾选前缀" },
  102 + "loginTerms": "用户协议",
  103 + "@loginTerms": { "description": "用户协议链接" },
  104 + "loginAgreementAnd": "与",
  105 + "@loginAgreementAnd": { "description": "协议连接词" },
  106 + "loginPrivacy": "隐私协议",
  107 + "@loginPrivacy": { "description": "隐私协议链接" },
  108 +
  109 + "phoneLoginHello": "Hello~",
  110 + "@phoneLoginHello": { "description": "手机号登录页打招呼" },
  111 + "phoneLoginWelcome": "欢迎来到 Double Feel",
  112 + "@phoneLoginWelcome": { "description": "手机号登录页欢迎语" },
  113 + "phoneLoginPhoneHint": "请输入手机号",
  114 + "@phoneLoginPhoneHint": { "description": "手机号输入框占位符" },
  115 + "phoneLoginSendCode": "发送验证码",
  116 + "@phoneLoginSendCode": { "description": "发送验证码按钮(首次)" },
  117 + "phoneLoginSending": "发送中",
  118 + "@phoneLoginSending": { "description": "发送验证码按钮(请求中)" },
  119 + "phoneLoginSentCountdown": "已发送 {countdown}s",
  120 + "@phoneLoginSentCountdown": {
  121 + "description": "倒计时文案",
  122 + "placeholders": {
  123 + "countdown": { "type": "int" }
  124 + }
  125 + },
  126 + "phoneLoginResend": "重新发送",
  127 + "@phoneLoginResend": { "description": "倒计时结束后重新发送按钮" },
  128 + "phoneLoginCodeHint": "请输入验证码",
  129 + "@phoneLoginCodeHint": { "description": "验证码输入框占位符" },
  130 + "phoneLoginAutoRegisterHint": "未注册的手机号验证通过后将自动注册",
  131 + "@phoneLoginAutoRegisterHint": { "description": "自动注册提示" },
  132 + "phoneLoginLoggingIn": "登录中...",
  133 + "@phoneLoginLoggingIn": { "description": "登录按钮 loading 文案" },
  134 +
  135 + "loginAgreeToTermsToast": "请先阅读并同意《用户协议》与《隐私协议》",
  136 + "@loginAgreeToTermsToast": { "description": "未勾选协议时的 toast" },
  137 + "phoneLoginInvalidPhone": "手机号格式错误",
  138 + "@phoneLoginInvalidPhone": { "description": "手机号格式错误 toast" },
  139 + "phoneLoginCodeSentSuccess": "发送成功",
  140 + "@phoneLoginCodeSentSuccess": { "description": "验证码发送成功 toast" },
  141 + "phoneLoginInvalidCode": "验证码格式错误",
  142 + "@phoneLoginInvalidCode": { "description": "验证码格式错误 toast" },
  143 +
  144 + "todayHealthDataAuthTitle": "无法获取心率健康数据",
  145 + "@todayHealthDataAuthTitle": { "description": "今日页健康数据授权卡片标题" },
  146 + "todayHealthDataAuthDescription": "DoubleFeel 需要授权访问你的健康数据,才能提供压力提醒、实时压力统计和健康建议;否则应用功能可能无法正常使用。请放心,你的健康数据仅存储在本地,不会上传到任何服务器。",
  147 + "@todayHealthDataAuthDescription": { "description": "今日页健康数据授权卡片说明" },
  148 + "todayHealthDataAuthAction": "授权访问健康数据",
  149 + "@todayHealthDataAuthAction": { "description": "今日页健康数据授权卡片按钮文案" }
  150 +}
@@ -604,6 +604,156 @@ abstract class AppLocalizations { @@ -604,6 +604,156 @@ abstract class AppLocalizations {
604 /// In zh, this message translates to: 604 /// In zh, this message translates to:
605 /// **'睡眠充足'** 605 /// **'睡眠充足'**
606 String get onboardingResearchGoodSleep; 606 String get onboardingResearchGoodSleep;
  607 +
  608 + /// 登录页 slogan
  609 + ///
  610 + /// In zh, this message translates to:
  611 + /// **'开启压力预警与健康陪伴之旅\n让爱与关心从不缺席'**
  612 + String get loginSlogan;
  613 +
  614 + /// 手机号登录按钮
  615 + ///
  616 + /// In zh, this message translates to:
  617 + /// **'手机号登录/注册'**
  618 + String get loginWithPhone;
  619 +
  620 + /// Apple登录按钮
  621 + ///
  622 + /// In zh, this message translates to:
  623 + /// **'通过Apple登录'**
  624 + String get loginWithApple;
  625 +
  626 + /// 上次使用标签
  627 + ///
  628 + /// In zh, this message translates to:
  629 + /// **'上次使用'**
  630 + String get loginLastUsed;
  631 +
  632 + /// 协议勾选前缀
  633 + ///
  634 + /// In zh, this message translates to:
  635 + /// **'我已阅读并同意'**
  636 + String get loginAgreementPrefix;
  637 +
  638 + /// 用户协议链接
  639 + ///
  640 + /// In zh, this message translates to:
  641 + /// **'用户协议'**
  642 + String get loginTerms;
  643 +
  644 + /// 协议连接词
  645 + ///
  646 + /// In zh, this message translates to:
  647 + /// **'与'**
  648 + String get loginAgreementAnd;
  649 +
  650 + /// 隐私协议链接
  651 + ///
  652 + /// In zh, this message translates to:
  653 + /// **'隐私协议'**
  654 + String get loginPrivacy;
  655 +
  656 + /// 手机号登录页打招呼
  657 + ///
  658 + /// In zh, this message translates to:
  659 + /// **'Hello~'**
  660 + String get phoneLoginHello;
  661 +
  662 + /// 手机号登录页欢迎语
  663 + ///
  664 + /// In zh, this message translates to:
  665 + /// **'欢迎来到 Double Feel'**
  666 + String get phoneLoginWelcome;
  667 +
  668 + /// 手机号输入框占位符
  669 + ///
  670 + /// In zh, this message translates to:
  671 + /// **'请输入手机号'**
  672 + String get phoneLoginPhoneHint;
  673 +
  674 + /// 发送验证码按钮(首次)
  675 + ///
  676 + /// In zh, this message translates to:
  677 + /// **'发送验证码'**
  678 + String get phoneLoginSendCode;
  679 +
  680 + /// 发送验证码按钮(请求中)
  681 + ///
  682 + /// In zh, this message translates to:
  683 + /// **'发送中'**
  684 + String get phoneLoginSending;
  685 +
  686 + /// 倒计时文案
  687 + ///
  688 + /// In zh, this message translates to:
  689 + /// **'已发送 {countdown}s'**
  690 + String phoneLoginSentCountdown(int countdown);
  691 +
  692 + /// 倒计时结束后重新发送按钮
  693 + ///
  694 + /// In zh, this message translates to:
  695 + /// **'重新发送'**
  696 + String get phoneLoginResend;
  697 +
  698 + /// 验证码输入框占位符
  699 + ///
  700 + /// In zh, this message translates to:
  701 + /// **'请输入验证码'**
  702 + String get phoneLoginCodeHint;
  703 +
  704 + /// 自动注册提示
  705 + ///
  706 + /// In zh, this message translates to:
  707 + /// **'未注册的手机号验证通过后将自动注册'**
  708 + String get phoneLoginAutoRegisterHint;
  709 +
  710 + /// 登录按钮 loading 文案
  711 + ///
  712 + /// In zh, this message translates to:
  713 + /// **'登录中...'**
  714 + String get phoneLoginLoggingIn;
  715 +
  716 + /// 未勾选协议时的 toast
  717 + ///
  718 + /// In zh, this message translates to:
  719 + /// **'请先阅读并同意《用户协议》与《隐私协议》'**
  720 + String get loginAgreeToTermsToast;
  721 +
  722 + /// 手机号格式错误 toast
  723 + ///
  724 + /// In zh, this message translates to:
  725 + /// **'手机号格式错误'**
  726 + String get phoneLoginInvalidPhone;
  727 +
  728 + /// 验证码发送成功 toast
  729 + ///
  730 + /// In zh, this message translates to:
  731 + /// **'发送成功'**
  732 + String get phoneLoginCodeSentSuccess;
  733 +
  734 + /// 验证码格式错误 toast
  735 + ///
  736 + /// In zh, this message translates to:
  737 + /// **'验证码格式错误'**
  738 + String get phoneLoginInvalidCode;
  739 +
  740 + /// 今日页健康数据授权卡片标题
  741 + ///
  742 + /// In zh, this message translates to:
  743 + /// **'无法获取心率健康数据'**
  744 + String get todayHealthDataAuthTitle;
  745 +
  746 + /// 今日页健康数据授权卡片说明
  747 + ///
  748 + /// In zh, this message translates to:
  749 + /// **'DoubleFeel 需要授权访问你的健康数据,才能提供压力提醒、实时压力统计和健康建议;否则应用功能可能无法正常使用。请放心,你的健康数据仅存储在本地,不会上传到任何服务器。'**
  750 + String get todayHealthDataAuthDescription;
  751 +
  752 + /// 今日页健康数据授权卡片按钮文案
  753 + ///
  754 + /// In zh, this message translates to:
  755 + /// **'授权访问健康数据'**
  756 + String get todayHealthDataAuthAction;
607 } 757 }
608 758
609 class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> { 759 class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
@@ -260,4 +260,81 @@ class AppLocalizationsEn extends AppLocalizations { @@ -260,4 +260,81 @@ class AppLocalizationsEn extends AppLocalizations {
260 260
261 @override 261 @override
262 String get onboardingResearchGoodSleep => 'Good Sleep'; 262 String get onboardingResearchGoodSleep => 'Good Sleep';
  263 +
  264 + @override
  265 + String get loginSlogan => 'Start your pressure alert and health companion journey\nso love and care are always present';
  266 +
  267 + @override
  268 + String get loginWithPhone => 'Sign in with Phone';
  269 +
  270 + @override
  271 + String get loginWithApple => 'Sign in with Apple';
  272 +
  273 + @override
  274 + String get loginLastUsed => 'Last used';
  275 +
  276 + @override
  277 + String get loginAgreementPrefix => 'I have read and agree to the ';
  278 +
  279 + @override
  280 + String get loginTerms => 'Terms of Service';
  281 +
  282 + @override
  283 + String get loginAgreementAnd => ' and ';
  284 +
  285 + @override
  286 + String get loginPrivacy => 'Privacy Policy';
  287 +
  288 + @override
  289 + String get phoneLoginHello => 'Hello';
  290 +
  291 + @override
  292 + String get phoneLoginWelcome => 'Welcome to Double Feel';
  293 +
  294 + @override
  295 + String get phoneLoginPhoneHint => 'Enter phone number';
  296 +
  297 + @override
  298 + String get phoneLoginSendCode => 'Send Code';
  299 +
  300 + @override
  301 + String get phoneLoginSending => 'Sending';
  302 +
  303 + @override
  304 + String phoneLoginSentCountdown(int countdown) {
  305 + return 'Sent (${countdown}s)';
  306 + }
  307 +
  308 + @override
  309 + String get phoneLoginResend => 'Resend';
  310 +
  311 + @override
  312 + String get phoneLoginCodeHint => 'Enter verification code';
  313 +
  314 + @override
  315 + String get phoneLoginAutoRegisterHint => 'Unregistered numbers will be registered automatically';
  316 +
  317 + @override
  318 + String get phoneLoginLoggingIn => 'Signing in...';
  319 +
  320 + @override
  321 + String get loginAgreeToTermsToast => 'Please read and agree to the Terms of Service and Privacy Policy first';
  322 +
  323 + @override
  324 + String get phoneLoginInvalidPhone => 'Invalid phone number';
  325 +
  326 + @override
  327 + String get phoneLoginCodeSentSuccess => 'Code sent';
  328 +
  329 + @override
  330 + String get phoneLoginInvalidCode => 'Invalid verification code';
  331 +
  332 + @override
  333 + String get todayHealthDataAuthTitle => 'Unable to access heart rate health data';
  334 +
  335 + @override
  336 + String get todayHealthDataAuthDescription => 'DoubleFeel needs permission to access your health data to provide stress reminders, real-time stress statistics, and health suggestions. Otherwise, some app features may not work properly. Your health data is stored locally only and will not be uploaded to any server.';
  337 +
  338 + @override
  339 + String get todayHealthDataAuthAction => 'Authorize health data access';
263 } 340 }
@@ -260,4 +260,81 @@ class AppLocalizationsZh extends AppLocalizations { @@ -260,4 +260,81 @@ class AppLocalizationsZh extends AppLocalizations {
260 260
261 @override 261 @override
262 String get onboardingResearchGoodSleep => '睡眠充足'; 262 String get onboardingResearchGoodSleep => '睡眠充足';
  263 +
  264 + @override
  265 + String get loginSlogan => '开启压力预警与健康陪伴之旅\n让爱与关心从不缺席';
  266 +
  267 + @override
  268 + String get loginWithPhone => '手机号登录/注册';
  269 +
  270 + @override
  271 + String get loginWithApple => '通过Apple登录';
  272 +
  273 + @override
  274 + String get loginLastUsed => '上次使用';
  275 +
  276 + @override
  277 + String get loginAgreementPrefix => '我已阅读并同意';
  278 +
  279 + @override
  280 + String get loginTerms => '用户协议';
  281 +
  282 + @override
  283 + String get loginAgreementAnd => '与';
  284 +
  285 + @override
  286 + String get loginPrivacy => '隐私协议';
  287 +
  288 + @override
  289 + String get phoneLoginHello => 'Hello~';
  290 +
  291 + @override
  292 + String get phoneLoginWelcome => '欢迎来到 Double Feel';
  293 +
  294 + @override
  295 + String get phoneLoginPhoneHint => '请输入手机号';
  296 +
  297 + @override
  298 + String get phoneLoginSendCode => '发送验证码';
  299 +
  300 + @override
  301 + String get phoneLoginSending => '发送中';
  302 +
  303 + @override
  304 + String phoneLoginSentCountdown(int countdown) {
  305 + return '已发送 ${countdown}s';
  306 + }
  307 +
  308 + @override
  309 + String get phoneLoginResend => '重新发送';
  310 +
  311 + @override
  312 + String get phoneLoginCodeHint => '请输入验证码';
  313 +
  314 + @override
  315 + String get phoneLoginAutoRegisterHint => '未注册的手机号验证通过后将自动注册';
  316 +
  317 + @override
  318 + String get phoneLoginLoggingIn => '登录中...';
  319 +
  320 + @override
  321 + String get loginAgreeToTermsToast => '请先阅读并同意《用户协议》与《隐私协议》';
  322 +
  323 + @override
  324 + String get phoneLoginInvalidPhone => '手机号格式错误';
  325 +
  326 + @override
  327 + String get phoneLoginCodeSentSuccess => '发送成功';
  328 +
  329 + @override
  330 + String get phoneLoginInvalidCode => '验证码格式错误';
  331 +
  332 + @override
  333 + String get todayHealthDataAuthTitle => '无法获取心率健康数据';
  334 +
  335 + @override
  336 + String get todayHealthDataAuthDescription => 'DoubleFeel 需要授权访问你的健康数据,才能提供压力提醒、实时压力统计和健康建议;否则应用功能可能无法正常使用。请放心,你的健康数据仅存储在本地,不会上传到任何服务器。';
  337 +
  338 + @override
  339 + String get todayHealthDataAuthAction => '授权访问健康数据';
263 } 340 }
1 import 'package:flutter/material.dart'; 1 import 'package:flutter/material.dart';
  2 +import 'package:flutter/services.dart';
  3 +import 'package:intl/date_symbol_data_local.dart';
2 4
3 import 'app/bootstrap/app_bootstrap.dart'; 5 import 'app/bootstrap/app_bootstrap.dart';
4 import 'app/double_feel_app.dart'; 6 import 'app/double_feel_app.dart';
  7 +import 'core/theme/app_theme.dart';
5 8
6 Future<void> main() async { 9 Future<void> main() async {
  10 + WidgetsFlutterBinding.ensureInitialized();
  11 +
  12 + SystemChrome.setSystemUIOverlayStyle(AppTheme.systemUiOverlayStyle);
  13 +
7 await AppBootstrap.init(); 14 await AppBootstrap.init();
  15 + await initializeDateFormatting();
8 runApp(const DoubleFeelApp()); 16 runApp(const DoubleFeelApp());
9 } 17 }
@@ -349,6 +349,14 @@ packages: @@ -349,6 +349,14 @@ packages:
349 description: flutter 349 description: flutter
350 source: sdk 350 source: sdk
351 version: "0.0.0" 351 version: "0.0.0"
  352 + fluttertoast:
  353 + dependency: "direct main"
  354 + description:
  355 + name: fluttertoast
  356 + sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8"
  357 + url: "https://pub.dev"
  358 + source: hosted
  359 + version: "8.2.14"
352 frontend_server_client: 360 frontend_server_client:
353 dependency: transitive 361 dependency: transitive
354 description: 362 description:
@@ -43,10 +43,11 @@ dependencies: @@ -43,10 +43,11 @@ dependencies:
43 url: https://gitcode.com/openharmony-sig/flutter_permission_handler.git 43 url: https://gitcode.com/openharmony-sig/flutter_permission_handler.git
44 path: permission_handler_ohos 44 path: permission_handler_ohos
45 ref: br_permission_handler_v11.3.1_ohos 45 ref: br_permission_handler_v11.3.1_ohos
46 - intl: any 46 + intl: ^0.19.0
47 flutter_localizations: 47 flutter_localizations:
48 sdk: flutter 48 sdk: flutter
49 table_calendar: ^3.1.3 49 table_calendar: ^3.1.3
  50 + fluttertoast: ^8.2.2
50 51
51 dev_dependencies: 52 dev_dependencies:
52 flutter_test: 53 flutter_test: