Showing
48 changed files
with
3025 additions
and
1308 deletions
assets/images/common/ic_apple_health.webp
0 → 100644
No preview for this file type
assets/images/common/ic_bottom_slogan.png
0 → 100644
7.05 KB
assets/images/common/ic_double_feel_text.png
0 → 100644
2.72 KB
821 Bytes
1.02 KB
assets/images/tabbar/icon_my_selected.png
0 → 100644
808 Bytes
assets/images/tabbar/icon_today_selected.png
0 → 100644
842 Bytes
assets/images/tabbar/icon_trend_selected.png
0 → 100644
403 Bytes
| @@ -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 | } |
lib/core/util/app_toast.dart
0 → 100644
| 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 | } |
lib/data/local/user_account_storage.dart
0 → 100644
| 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: |
-
Please register or login to post a comment