Commit aeeca5589bad6c68f6a108e5479f05bcc61c1b37

Authored by 常守达
1 parent 962df483

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

Showing 40 changed files with 2611 additions and 1303 deletions

Too many changes to show.

To preserve performance only 40 of 40+ files are displayed.

No preview for this file type
... ... @@ -17,16 +17,22 @@ import '../../core/services/push_service.dart';
import '../../core/services/thinking_data_service.dart';
import '../../core/services/user_state_service.dart';
import '../../core/services/wear_engine_service.dart';
import '../../data/local/local_storage.dart';
import '../../data/local/user_account_storage.dart';
import '../../data/local/user_preferences_storage.dart';
/// App-wide infrastructure (error handling, config, network client).
void registerCoreDeps({
required AppEnvironmentConfig environmentConfig,
required UserPreferencesStorage userPreferencesStorage,
required UserAccountStorage userAccountStorage,
required LocalStorage localStorage,
}) {
Get.put(AppErrorHandler(), permanent: true);
Get.put(localStorage, permanent: true);
Get.put(environmentConfig, permanent: true);
Get.put(userPreferencesStorage, permanent: true);
Get.put(userAccountStorage, permanent: true);
Get.put(
DioClient(
Get.find<AppEnvironmentConfig>(),
... ...
... ... @@ -2,6 +2,8 @@ import 'package:get/get.dart';
import '../../core/config/app_environment_config.dart';
import '../../core/network/dio_client.dart';
import '../../data/local/local_storage.dart';
import '../../data/local/user_account_storage.dart';
import '../../data/local/user_preferences_storage.dart';
import 'dependency_registrars.dart';
... ... @@ -10,16 +12,22 @@ class InitialBinding extends Bindings {
InitialBinding({
required this.environmentConfig,
required this.userPreferencesStorage,
required this.userAccountStorage,
required this.localStorage,
});
final AppEnvironmentConfig environmentConfig;
final UserPreferencesStorage userPreferencesStorage;
final UserAccountStorage userAccountStorage;
final LocalStorage localStorage;
@override
void dependencies() {
registerCoreDeps(
environmentConfig: environmentConfig,
userPreferencesStorage: userPreferencesStorage,
userAccountStorage: userAccountStorage,
localStorage: localStorage,
);
registerUserSessionDeps();
... ...
... ... @@ -5,6 +5,7 @@ import '../../core/config/app_environment_config.dart';
import '../../core/logging/app_logger.dart';
import '../../core/services/user_state_service.dart';
import '../../data/local/local_storage.dart';
import '../../data/local/user_account_storage.dart';
import '../../data/local/user_preferences_storage.dart';
import '../bindings/initial_binding.dart';
... ... @@ -23,9 +24,13 @@ abstract final class AppBootstrap {
UserPreferencesStorage(local.sharedPreferences);
await userPreferencesStorage.init();
final userAccountStorage = UserAccountStorage(local.sharedPreferences);
InitialBinding(
environmentConfig: environmentConfig,
userPreferencesStorage: userPreferencesStorage,
userAccountStorage: userAccountStorage,
localStorage: local,
).dependencies();
if (local.termsAgreed) {
... ...
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:get/get.dart';
import '../controllers/bind_partner_controller.dart';
... ... @@ -5,6 +6,8 @@ import '../controllers/bind_partner_controller.dart';
class BindPartnerBinding extends Bindings {
@override
void dependencies() {
Get.put(BindPartnerController());
Get.put(BindPartnerController(
Get.find<UserApi>(),
));
}
}
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
class BindPartnerController extends GetxController {
BindPartnerController(this._userApi);
final UserApi _userApi;
final TextEditingController partnerIdController = TextEditingController();
final RxString partnerIdInput = ''.obs;
final RxBool isSubmitting = false.obs;
final RxString myInviteCode = 'AC1192'.obs;
final RxString myInviteCode = ''.obs;
@override
void onInit() {
... ... @@ -19,6 +26,9 @@ class BindPartnerController extends GetxController {
partnerIdController.addListener(() {
partnerIdInput.value = partnerIdController.text.trim();
});
final userPrefs = Get.find<UserPreferencesStorage>();
myInviteCode.value = userPrefs.preferences.value.meUserInfo?.pairCode ?? '';
}
@override
... ... @@ -35,6 +45,19 @@ class BindPartnerController extends GetxController {
if (!canSubmit || isSubmitting.value) return;
isSubmitting.value = true;
try {
final result = await _userApi.bindPartner(partnerIdInput.value);
if (result is AppFailure) {
return;
} else {
final partnerResult = await _userApi.getPartnerUserInfo();
if (partnerResult is AppSuccess<BoundUserInfoResponse>) {
final partner = partnerResult.data.partnerUserInfo;
if (partner != null) {
await Get.find<UserPreferencesStorage>()
.updatePartnerUserInfo(partner);
}
}
}
final userStateService = Get.find<UserStateService>();
final partnerId = partnerIdInput.value;
if (!userStateService.isVip) {
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
... ... @@ -11,7 +15,15 @@ class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<HomeController>(() => HomeController(), fenix: true);
Get.lazyPut<TodayController>(() => TodayController(), fenix: true);
Get.lazyPut<TodayController>(
() => TodayController(
Get.find<UserApi>(),
Get.find<HealthApi>(),
Get.find<UserStateService>(),
Get.find<HealthKitUploadService>(),
),
fenix: true,
);
Get.lazyPut<TrendController>(() => TrendController(), fenix: true);
Get.lazyPut<HrvController>(() => HrvController(), fenix: true);
Get.lazyPut<ActivityController>(() => ActivityController(), fenix: true);
... ...
... ... @@ -8,13 +8,21 @@ class HomeController extends GetxController {
final selectedIndex = 0.obs;
/// 当前选中的日期(Today tab 使用)
final selectedDate = DateTime.now().obs;
final selectedDate = _dateOnly(DateTime.now()).obs;
void changeTab(int index) {
selectedIndex.value = index;
}
void changeDate(DateTime date) {
selectedDate.value = date;
final normalizedDate = _dateOnly(date);
final currentDate = selectedDate.value;
if (currentDate == normalizedDate) return;
selectedDate.value = normalizedDate;
}
static DateTime _dateOnly(DateTime date) {
return DateTime(date.year, date.month, date.day);
}
}
... ...
import 'dart:async';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/data/models/health/health_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_upload_models.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
/// 每日行动 item 数据模型
class DailyActionItem {
final String label;
final String value;
final String unit;
final String? subValue;
final String? subUnit;
const DailyActionItem({
required this.label,
required this.value,
required this.unit,
this.subValue,
this.subUnit,
});
}
/// HRV 趋势数据点
class HrvDataPoint {
final double hour; // 0.0 ~ 24.0
... ... @@ -42,70 +37,446 @@ class HrvAnnotation {
}
class TodayController extends GetxController {
final UserStateService userStateService = Get.find<UserStateService>();
TodayController(
this._userApi,
this._healthApi,
this._userStateService,
this._healthKitUploadService,
);
final UserApi _userApi;
final HealthApi _healthApi;
final UserStateService _userStateService;
final HealthKitUploadService _healthKitUploadService;
UserStateService get userStateService => _userStateService;
final isLoadingToday = false.obs;
final showHealthDataAuthCard = true.obs;
final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs;
// ── 压力状态 ─────────────────────────────
final stressLabel = '状态正常'.obs;
// ── HRV 表盘引导 Banner ───────────────────
final showHrvAdBanner = true.obs;
final showPartnerAdBanner = true.obs;
void dismissHrvAdBanner() => showHrvAdBanner.value = false;
void dismissPartnerAdBanner() => showPartnerAdBanner.value = false;
// ── HRV 数字 + 心率 ───────────────────────
final avgHrv = 46.obs;
final restingHeartRate = 63.obs;
final avgHrv = '--'.obs;
final restingHeartRate = '--'.obs;
// ── 睡眠卡片 ──────────────────────────────
final sleepHours = '--'.obs;
final sleepMinutes = '--'.obs;
final sleepQuality = '--'.obs;
final sleepAverageHeartRate = '--'.obs;
final sleepProgress = 0.0.obs;
// ── 健身卡片 ──────────────────────────────
final activityCalories = '--'.obs;
final activityExerciseMinutes = '--'.obs;
final activityStandHours = '--'.obs;
final activityMoveProgress = 0.0.obs;
final activityExerciseProgress = 0.0.obs;
final activityStandProgress = 0.0.obs;
int? _activityMoveTarget;
int? _activityStandTarget;
// ── HRV 趋势图 ────────────────────────────
final List<HrvDataPoint> hrvChartData = const [
HrvDataPoint(hour: 0, hrv: 30),
HrvDataPoint(hour: 1, hrv: 45),
HrvDataPoint(hour: 2, hrv: 40),
HrvDataPoint(hour: 3, hrv: 55),
HrvDataPoint(hour: 4, hrv: 50),
HrvDataPoint(hour: 5, hrv: 60),
HrvDataPoint(hour: 6, hrv: 65),
HrvDataPoint(hour: 7, hrv: 70),
HrvDataPoint(hour: 8, hrv: 62),
HrvDataPoint(hour: 9, hrv: 58),
HrvDataPoint(hour: 10, hrv: 75),
HrvDataPoint(hour: 11, hrv: 80),
HrvDataPoint(hour: 12, hrv: 72),
HrvDataPoint(hour: 13, hrv: 68),
HrvDataPoint(hour: 14, hrv: 55),
HrvDataPoint(hour: 15, hrv: 50),
HrvDataPoint(hour: 16, hrv: 45),
HrvDataPoint(hour: 17, hrv: 42),
HrvDataPoint(hour: 18, hrv: 38),
];
final List<HrvAnnotation> hrvAnnotations = const [
HrvAnnotation(
hour: 11,
hrv: 80,
stateLabel: '状态优秀',
detail: 'HRV 23ms · 11:28',
),
];
// ── 每日行动 ──────────────────────────────
final List<DailyActionItem> dailyActions = const [
DailyActionItem(
label: '睡眠',
value: '7',
unit: 'h',
subValue: '32',
subUnit: 'min',
),
DailyActionItem(
label: '健身',
value: '45',
unit: 'min',
),
DailyActionItem(
label: '步数',
value: '8,234',
unit: '步',
),
];
final hrvChartData = <HrvDataPoint>[].obs;
final stressChartData = <HrvDataPoint>[].obs;
final hrvAnnotations = <HrvAnnotation>[].obs;
Future<void> requestHealthAuthorization() async {
try {
await _healthKitUploadService.requestClientAuthorization();
await _refreshHealthAuthorizationState();
} catch (error) {
debugPrint('Health authorization skipped: $error');
}
}
@override
void onInit() {
super.onInit();
unawaited(loadTodayData());
}
Future<void> loadTodayData() async {
if (isLoadingToday.value) return;
isLoadingToday.value = true;
try {
await Future.wait([
_refreshUserGreeting(),
// _refreshHealthAuthorizationState(),
_refreshTodayHealthData(),
]);
} catch (error, stackTrace) {
AppLogger.e('TodayController.loadTodayData failed', error, stackTrace);
} finally {
isLoadingToday.value = false;
}
}
Future<void> _refreshUserGreeting() async {
final result = await _userApi.getUserInfo(errorHandlingPolicy: null);
if (result case AppSuccess<UserInfoResponse>(data: final user)) {
final name = user.nickname?.trim();
stressSubtitle.value = name == null || name.isEmpty
? 'Hi, 你今日的综合压力状态'
: 'Hi, $name 今日的综合压力状态';
}
}
Future<void> _refreshHealthAuthorizationState() async {
final serverAuth =
await _healthApi.checkServerHealthAuth(errorHandlingPolicy: null);
final hasServerAuth = switch (serverAuth) {
AppSuccess<HealthAuthResponse>(data: final auth) =>
auth.scope?.trim().isNotEmpty == true,
_ => false,
};
bool hasClientAuth = false;
try {
hasClientAuth = await _healthKitUploadService.isHealthAuthorized();
} catch (error, stackTrace) {
AppLogger.w('Health authorization check failed', error, stackTrace);
}
showHealthDataAuthCard.value = !(hasServerAuth || hasClientAuth);
}
Future<void> _refreshTodayHealthData() async {
final startDate = _todayDateKey();
const dateRangeType = 1;
final todayResultFuture = _healthApi.getTodayData(
isOther: false,
errorHandlingPolicy: null,
);
final latestHrvResultFuture = _healthApi.getLatestHrvData(
errorHandlingPolicy: null,
);
final hrvStatisticsResultFuture = _healthApi.getHrvStatistics(
isOther: false,
dateRangeType: dateRangeType,
startDate: startDate,
errorHandlingPolicy: null,
);
final sleepStatisticsResultFuture = _healthApi.getSleepStateStatistics(
isOther: false,
dateRangeType: dateRangeType,
startDate: startDate,
errorHandlingPolicy: null,
);
final activityStatisticsResultFuture = _healthApi.getActivityBurnStatistics(
isOther: false,
dateRangeType: dateRangeType,
startDate: startDate,
errorHandlingPolicy: null,
);
final todayResult = await todayResultFuture;
final latestHrvResult = await latestHrvResultFuture;
final hrvStatisticsResult = await hrvStatisticsResultFuture;
final sleepStatisticsResult = await sleepStatisticsResultFuture;
final activityStatisticsResult = await activityStatisticsResultFuture;
if (todayResult case AppSuccess<TodayStatusData>(data: final today)) {
_applyTodayData(today);
} else if (todayResult case AppFailure(error: final error)) {
AppLogger.w('HealthApi.getTodayData failed: $error');
}
if (latestHrvResult case AppSuccess<LatestHrvData>(data: final latestHrv)) {
_applyLatestHrvData(latestHrv);
}
if (hrvStatisticsResult
case AppSuccess<HrvStatisticsData>(data: final hrvStatistics)) {
_applyHrvStatistics(hrvStatistics);
}
if (sleepStatisticsResult
case AppSuccess<SleepStatisticsData>(data: final sleepStatistics)) {
_applySleepStatistics(sleepStatistics);
}
if (activityStatisticsResult
case AppSuccess<ActivityBurnStatisticsData>(
data: final activityStatistics
)) {
_applyActivityStatistics(activityStatistics);
}
}
void _applyTodayData(TodayStatusData today) {
final recent = today.recentData;
if (recent?.heartRate != null) {
restingHeartRate.value = _formatMetric(recent!.heartRate);
if (sleepAverageHeartRate.value == '--') {
sleepAverageHeartRate.value = _formatMetric(recent.heartRate);
}
}
_applySleepDuration(today.sleepDuration);
_applyActivityRecentData(recent);
final hrvPoints = _buildHrvChartData(today.hrvDataList);
hrvChartData.assignAll(hrvPoints);
stressChartData.assignAll(
hrvPoints.map((point) {
return HrvDataPoint(
hour: point.hour,
hrv: _stressScoreFromHrv(point.hrv),
);
}),
);
final hrvValues = hrvPoints.map((point) => point.hrv).toList();
if (hrvValues.isNotEmpty) {
final average = hrvValues.reduce((value, element) => value + element) /
hrvValues.length;
avgHrv.value = _formatMetric(average);
final latest = _latestTodayHrv(today.hrvDataList);
final latestStatus = latest?.hrvStatus;
if (latestStatus != null) {
stressLabel.value = latestStatus.title;
}
_updateLatestHrvAnnotation(latest);
}
}
void _applyLatestHrvData(LatestHrvData latestHrv) {
final userHrv = latestHrv.userHrv;
if (avgHrv.value == '--' && userHrv != null) {
avgHrv.value = _formatMetric(userHrv);
}
if (userHrv != null) {
stressLabel.value = _hrvStatusTitle(
value: userHrv,
baseline: latestHrv.userHrvBaseline,
);
}
}
void _applyHrvStatistics(HrvStatisticsData data) {
if (avgHrv.value == '--' && data.avgHrv != null) {
avgHrv.value = _formatMetric(data.avgHrv);
}
if (data.avgRestingHeartRate != null) {
restingHeartRate.value = _formatMetric(data.avgRestingHeartRate);
}
if (data.avgSleepingHeartRate != null) {
sleepAverageHeartRate.value = _formatMetric(data.avgSleepingHeartRate);
}
}
void _applySleepStatistics(SleepStatisticsData data) {
if (sleepHours.value == '--' && data.avgSleepDuration != null) {
_applySleepDuration(data.avgSleepDuration);
}
final deepPercentage = data.deepPercentage;
if (deepPercentage != null) {
sleepQuality.value = _sleepQualityFromDeepPercentage(deepPercentage);
}
}
void _applyActivityStatistics(ActivityBurnStatisticsData data) {
_activityMoveTarget = data.activityTargetInfo?.move ?? _activityMoveTarget;
_activityStandTarget =
data.activityTargetInfo?.stand ?? _activityStandTarget;
if (activityCalories.value == '--' && data.totalCaloriesBurned != null) {
activityCalories.value = _formatMetric(data.totalCaloriesBurned);
}
if (activityStandHours.value == '--' && data.totalStand != null) {
activityStandHours.value = _formatMetric(data.totalStand);
}
final moveValue = _parseMetric(activityCalories.value);
final standValue = _parseMetric(activityStandHours.value);
if (moveValue != null) {
activityMoveProgress.value = _progress(
moveValue,
(_activityMoveTarget ?? 400).toDouble(),
);
}
if (standValue != null) {
activityStandProgress.value = _progress(
standValue,
(_activityStandTarget ?? 12).toDouble(),
);
}
}
void _applySleepDuration(int? rawDuration) {
final minutes = _normalizeDurationMinutes(rawDuration);
if (minutes == null) {
sleepHours.value = '--';
sleepMinutes.value = '--';
sleepQuality.value = '--';
sleepProgress.value = 0;
return;
}
sleepHours.value = '${minutes ~/ 60}';
sleepMinutes.value = '${minutes % 60}';
sleepQuality.value = _sleepQualityFromDuration(minutes);
sleepProgress.value = _progress(minutes.toDouble(), 8 * 60);
}
void _applyActivityRecentData(TodayStatusRecentData? recent) {
activityCalories.value = _formatMetric(recent?.move);
activityExerciseMinutes.value = _formatMetric(recent?.exercise);
activityStandHours.value = _formatMetric(recent?.stand);
activityMoveProgress.value = _progress(
(recent?.move ?? 0).toDouble(),
(_activityMoveTarget ?? 400).toDouble(),
);
activityExerciseProgress.value = _progress(
(recent?.exercise ?? 0).toDouble(),
30,
);
activityStandProgress.value = _progress(
(recent?.stand ?? 0).toDouble(),
(_activityStandTarget ?? 12).toDouble(),
);
}
List<HrvDataPoint> _buildHrvChartData(List<TodayHrvData>? dataList) {
final points = <HrvDataPoint>[];
for (final item in dataList ?? const <TodayHrvData>[]) {
final time = item.time;
final value = item.value;
if (time == null || value == null) continue;
points.add(HrvDataPoint(hour: _hourFromApiTime(time), hrv: value));
}
points.sort((a, b) => a.hour.compareTo(b.hour));
return points;
}
TodayHrvData? _latestTodayHrv(List<TodayHrvData>? dataList) {
TodayHrvData? latest;
for (final item in dataList ?? const <TodayHrvData>[]) {
if (item.value == null) continue;
final latestTime = latest?.time ?? -1;
final itemTime = item.time ?? -1;
if (latest == null || itemTime >= latestTime) {
latest = item;
}
}
return latest;
}
void _updateLatestHrvAnnotation(TodayHrvData? latest) {
if (latest?.time == null || latest?.value == null) {
hrvAnnotations.clear();
return;
}
final hour = _hourFromApiTime(latest!.time!);
hrvAnnotations.assignAll([
HrvAnnotation(
hour: hour,
hrv: latest.value!,
stateLabel: latest.hrvStatus?.title ?? stressLabel.value,
detail: 'HRV ${_formatMetric(latest.value)}ms · ${_formatHour(hour)}',
),
]);
}
String _hrvStatusTitle({required double value, double? baseline}) {
if ((baseline ?? 0) > 0) {
if (value >= 1.2 * baseline!) return HrvStatus.energetic.title;
if (value <= 0.8 * baseline) return HrvStatus.overload.title;
return HrvStatus.normal.title;
}
if (value >= 128) return HrvStatus.energetic.title;
if (value <= 30) return HrvStatus.overload.title;
return HrvStatus.normal.title;
}
double _stressScoreFromHrv(double hrv) {
return (100 - hrv).clamp(0, 100).toDouble();
}
int? _normalizeDurationMinutes(int? rawDuration) {
if (rawDuration == null || rawDuration <= 0) return null;
if (rawDuration > 24 * 60 * 60) {
return (rawDuration / 60000).round();
}
if (rawDuration > 24 * 60) {
return (rawDuration / 60).round();
}
return rawDuration;
}
String _sleepQualityFromDuration(int minutes) {
if (minutes >= 7 * 60 && minutes <= 9 * 60) return '优秀';
if (minutes >= 6 * 60 && minutes < 10 * 60) return '良好';
if (minutes >= 5 * 60) return '一般';
return '偏少';
}
String _sleepQualityFromDeepPercentage(double percentage) {
if (percentage >= 25) return '优秀';
if (percentage >= 18) return '良好';
if (percentage >= 12) return '一般';
return '偏少';
}
double _progress(double value, double target) {
if (target <= 0) return 0;
return (value / target).clamp(0, 1).toDouble();
}
double? _parseMetric(String value) {
if (value == '--') return null;
return double.tryParse(value.replaceAll(',', ''));
}
double _hourFromApiTime(int time) {
if (time >= 1000000000000) {
final date = DateTime.fromMillisecondsSinceEpoch(time);
return date.hour + date.minute / 60;
}
if (time >= 1000000000) {
final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);
return date.hour + date.minute / 60;
}
if (time > 24) {
final hour = (time ~/ 100).clamp(0, 23);
final minute = (time % 100).clamp(0, 59);
return hour + minute / 60;
}
return time.toDouble().clamp(0, 24).toDouble();
}
String _formatMetric(num? value) {
if (value == null) return '--';
if (value % 1 == 0) return '${value.toInt()}';
return value.toStringAsFixed(1);
}
String _formatHour(double hour) {
final totalMinutes = (hour * 60).round();
final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
final m = (totalMinutes % 60).toString().padLeft(2, '0');
return '$h:$m';
}
int _todayDateKey() {
final now = DateTime.now();
return now.year * 10000 + now.month * 100 + now.day - 2;
}
}
... ...
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../controllers/home_controller.dart';
import '../../controllers/today_controller.dart';
import '../../widgets/today/today_date_strip.dart';
import '../../widgets/today/today_health_data_auth_card.dart';
import '../../widgets/today/today_hrv_ad_banner.dart';
import '../../widgets/today/today_hrv_number_card.dart';
import '../../widgets/today/today_hrv_chart_card.dart';
import '../../widgets/today/today_actions_card.dart';
import '../../widgets/today/today_sleep_activity_cards.dart';
import '../../widgets/today/premium_card.dart';
class TodayTab extends GetView<TodayController> {
class TodayTab extends StatefulWidget {
const TodayTab({super.key});
@override
State<TodayTab> createState() => _TodayTabState();
}
class _TodayTabState extends State<TodayTab> {
static const _bgColor = Color(0xFFF2F2F7);
static const _h1 = Color(0xFF0F0F11);
static const _topBarHeight = 48.0;
static const _weekCalendarHeight = 58.0;
static const _topContentHeight = _topBarHeight + _weekCalendarHeight;
static const _weekRowsHeight = 50.0;
static const _selectedDayWidth = 32.0;
final TodayController controller = Get.find<TodayController>();
final HomeController homeController = Get.find<HomeController>();
late final DateTime _firstSelectableDay;
late final DateTime _lastSelectableDay;
late final int _dayPageCount;
late final PageController _pageController;
late final Worker _selectedDateWorker;
late DateTime _focusedDay;
final ValueNotifier<double> _scrollOffset = ValueNotifier<double>(0);
@override
void initState() {
super.initState();
final today = DateUtils.dateOnly(DateTime.now());
_firstSelectableDay = DateTime(today.year - 1);
_lastSelectableDay = today;
_dayPageCount =
_lastSelectableDay.difference(_firstSelectableDay).inDays + 1;
final initialSelectedDay =
_clampSelectableDay(homeController.selectedDate.value);
_focusedDay = initialSelectedDay;
homeController.changeDate(initialSelectedDay);
_pageController = PageController(
initialPage: _pageIndexForDay(initialSelectedDay),
);
_selectedDateWorker = ever<DateTime>(
homeController.selectedDate,
_handleSelectedDateChanged,
);
}
@override
void dispose() {
_selectedDateWorker.dispose();
_pageController.dispose();
_scrollOffset.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final homeCtrl = Get.find<HomeController>();
final topPadding = MediaQuery.paddingOf(context).top;
final pinnedHeaderHeight = topPadding + _topContentHeight;
return Container(
color: _bgColor,
child: Stack(
children: [
CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: 323,
child: PageView.builder(
itemCount: 10,
itemBuilder: (context, index) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFFC5B0FF),
const Color(0xFFF5F2FF)
],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('Page $index'),
Text('Page $index'),
Text('Page $index'),
Text(
'Hi, 你今日的综合压力状态',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF78787D),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Text(
'状态正常',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF0F0F11),
fontSize: 28,
fontWeight: FontWeight.w500,
),
),
Container(
width: 220,
height: 14,
margin: EdgeInsets.only(bottom: 36),
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(7)),
),
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
child: Opacity(
opacity: 0.50,
child: Container(
width: 20,
height: 14,
decoration: ShapeDecoration(
color: const Color(0xFFFF5279),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(7)),
),
),
),
),
Positioned(
left: 24,
top: 0,
child: Opacity(
opacity: 0.50,
child: Container(
width: 20,
height: 14,
decoration: ShapeDecoration(
color: const Color(0xFFFF9A6E),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(7)),
),
),
),
),
Positioned(
left: 48,
top: 0,
child: Container(
width: 148,
height: 14,
decoration: ShapeDecoration(
color: const Color(0xFF7B9BFB),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(7)),
),
),
),
Positioned(
left: 200,
top: 0,
child: Opacity(
opacity: 0.50,
child: Container(
width: 20,
height: 14,
decoration: ShapeDecoration(
color: const Color(0xFF3BD39C),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(7)),
),
),
),
),
],
),
)
],
),
);
},
),
),
),
// ── 各内容卡片 ──
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Obx(() {
final isVip = Get.find<UserPreferencesStorage>()
.preferences
.value
.vipInfo
?.isVip ??
false;
if (isVip) return const SizedBox.shrink();
return const PremiumCard();
}),
// 1. HRV 表盘引导 Banner
const TodayHrvAdBanner(),
const SizedBox(height: 12),
// 3. HRV + 心率数字卡
const TodayHrvNumberCard(),
const SizedBox(height: 12),
// 4. HRV 趋势图
const TodayHrvChartCard(),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'立即测量HRV',
style: TextStyle(
color: const Color(0xFF0F0F11),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
// 5. 每日行动
const TodayActionsCard(),
// 底部安全间距(留给 TabBar 高度)
const SizedBox(height: 180),
]),
),
),
],
NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: PageView.builder(
controller: _pageController,
itemCount: _dayPageCount,
onPageChanged: _onDayPageChanged,
itemBuilder: (context, index) {
return _buildDayScrollView(context, pinnedHeaderHeight);
},
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 106 + MediaQuery.of(context).padding.top,
child: Container(
color: const Color(0xFFDDD2FF).withValues(alpha: 0.9),
height: pinnedHeaderHeight,
child: ValueListenableBuilder<double>(
valueListenable: _scrollOffset,
builder: (context, offset, child) {
final opacity =
(offset / _topContentHeight).clamp(0.0, 1).toDouble();
return Container(
color: const Color(0xFFDDD2FF).withValues(alpha: opacity),
);
},
),
),
Positioned(
top: MediaQuery.of(context).padding.top,
top: topPadding,
left: 0,
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 48,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
'今日',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
),
child: Obx(
() => Column(
mainAxisSize: MainAxisSize.min,
children: [
_TopDateBar(
title: _dateTitle,
showBackToToday: !_isSelectedToday,
onBackToToday: _backToToday,
),
_buildWeekCalendar(),
],
),
),
),
],
),
);
}
Widget _buildDayScrollView(
BuildContext context,
double pinnedHeaderHeight,
) {
return CustomScrollView(
primary: false,
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Container(
height: 323 + pinnedHeaderHeight,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFC5B0FF),
Color(0xFFF5F2FF),
],
),
),
child: Container(
margin: EdgeInsets.only(top: pinnedHeaderHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Obx(
() => Text(
controller.stressSubtitle.value,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 14,
fontWeight: FontWeight.w500,
),
const Spacer(),
// Pro 折扣徽章
Container(
width: 95,
height: 28.6,
decoration: BoxDecoration(
color: const Color(0xFFFFDF51),
borderRadius: BorderRadius.circular(27.6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Pro 皇冠图标
const Icon(
Icons.workspace_premium,
size: 20,
color: Color(0xFF0F0F11),
),
const SizedBox(width: 2),
const Text(
'20%优惠',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF0F0F11),
),
),
],
),
),
),
Obx(
() => Text(
controller.stressLabel.value,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 28,
fontWeight: FontWeight.w500,
),
],
),
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Obx(
() => controller.showHealthDataAuthCard.value
? TodayHealthDataAuthCard(
onAuthorizeTap: controller.requestHealthAuthorization,
)
: const SizedBox.shrink(),
),
Obx(() {
return Get.find<UserStateService>().isVip
? const SizedBox.shrink()
: const PremiumCard();
}),
const TodayHrvAdBanner(),
const TodayPartnerAdBanner(),
const TodayHrvNumberCard(),
const SizedBox(height: 12),
const TodayHrvChartCard(),
const SizedBox(height: 12),
const TodaySleepCard(),
const TodayActivityCard(),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'立即测量HRV',
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
Container(
alignment: Alignment.center,
margin: EdgeInsets.only(
bottom: 16 + MediaQuery.paddingOf(context).bottom,
top: 28,
),
child: Image.asset(
'assets/images/common/ic_bottom_slogan.png',
width: 223,
height: 35,
),
TableCalendar(
),
]),
),
),
],
);
}
Widget _buildWeekCalendar() {
return SizedBox(
height: _weekCalendarHeight,
child: Padding(
padding: const EdgeInsets.fromLTRB(13, 4, 18, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SizedBox(
height: _weekRowsHeight,
child: TableCalendar(
calendarFormat: CalendarFormat.week,
headerVisible: false,
focusedDay: DateTime.now(),
firstDay: DateTime(2026),
lastDay: DateTime(2027),
daysOfWeekHeight: _weekRowsHeight / 2,
rowHeight: _weekRowsHeight / 2,
focusedDay: _focusedDay,
firstDay: _firstSelectableDay,
lastDay: _lastSelectableDay,
currentDay: _lastSelectableDay,
startingDayOfWeek: StartingDayOfWeek.monday,
availableCalendarFormats: const {
CalendarFormat.week: '',
},
availableGestures: AvailableGestures.horizontalSwipe,
enabledDayPredicate: (day) => !_isAfterToday(day),
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
onDaySelected: _onDaySelected,
onPageChanged: _onCalendarPageChanged,
calendarStyle: const CalendarStyle(
cellMargin: EdgeInsets.zero,
cellPadding: EdgeInsets.zero,
isTodayHighlighted: false,
outsideDaysVisible: true,
),
daysOfWeekStyle: const DaysOfWeekStyle(
decoration: BoxDecoration(),
),
calendarBuilders: CalendarBuilders(
dowBuilder: (context, day) => _buildWeekdayCell(day),
defaultBuilder: (context, day, focusedDay) =>
_buildDateCell(day),
disabledBuilder: (context, day, focusedDay) =>
_buildDateCell(day),
outsideBuilder: (context, day, focusedDay) =>
_buildDateCell(day),
selectedBuilder: (context, day, focusedDay) =>
_buildDateCell(day, selected: true),
),
),
],
),
),
const SizedBox(width: 3),
Container(
width: 1,
height: 20,
margin: const EdgeInsets.only(top: 15),
color: context.colors.textPrimary.withValues(alpha: 0.2),
),
const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Image.asset(
'assets/images/common/ic_calendar.png',
width: 20,
height: 20,
color: context.colors.textPrimary.withValues(alpha: 0.6),
),
),
],
),
),
);
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) return false;
final offset = notification.metrics.pixels.clamp(0.0, double.infinity);
if (_scrollOffset.value != offset) {
_scrollOffset.value = offset.toDouble();
}
return false;
}
Widget _buildWeekdayCell(DateTime day) {
final selected = isSameDay(_selectedDay, day);
return Center(
child: Container(
width: _selectedDayWidth,
height: _weekRowsHeight / 2,
alignment: Alignment.center,
decoration: selected
? BoxDecoration(
color: context.colors.primary,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(22),
),
)
: null,
child: Text(
isSameDay(day, _lastSelectableDay) ? '今' : _weekdayText(day),
style: TextStyle(
color: _calendarTextColor(day, selected: selected),
fontSize: 12,
fontWeight: FontWeight.w500,
),
],
),
),
);
}
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
Widget _buildDateCell(DateTime day, {bool selected = false}) {
return Center(
child: Container(
width: _selectedDayWidth,
height: _weekRowsHeight / 2,
alignment: Alignment.center,
decoration: selected
? BoxDecoration(
color: context.colors.primary,
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(22),
),
)
: null,
child: Text(
'${day.day}',
style: TextStyle(
color: _calendarTextColor(day, selected: selected),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
);
}
void _handleSelectedDateChanged(DateTime selectedDate) {
final normalizedDay = _clampSelectableDay(selectedDate);
if (selectedDate != normalizedDay) {
homeController.changeDate(normalizedDay);
return;
}
if (mounted) {
setState(() {
_focusedDay = normalizedDay;
});
}
_syncPagerToSelectedDay(normalizedDay);
}
void _onDayPageChanged(int page) {
_selectDate(_dateForPage(page));
}
void _onDaySelected(DateTime selectedDay, DateTime focusedDay) {
_selectDate(selectedDay, focusedDay: focusedDay);
}
void _onCalendarPageChanged(DateTime focusedDay) {
final selectedWeekdayOffset = _selectedDay.weekday - DateTime.monday;
final nextSelectedDay = _weekStart(focusedDay).add(
Duration(days: selectedWeekdayOffset),
);
_selectDate(nextSelectedDay, focusedDay: focusedDay);
}
void _backToToday() {
_selectDate(_lastSelectableDay);
}
void _selectDate(DateTime selectedDay, {DateTime? focusedDay}) {
final normalizedSelectedDay = _clampSelectableDay(selectedDay);
final normalizedFocusedDay = _clampSelectableDay(
focusedDay ?? normalizedSelectedDay,
);
setState(() {
_focusedDay = normalizedFocusedDay;
});
homeController.changeDate(normalizedSelectedDay);
}
void _syncPagerToSelectedDay(DateTime selectedDay) {
final targetPage = _pageIndexForDay(selectedDay);
if (!_pageController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_syncPagerToSelectedDay(selectedDay);
});
return;
}
final currentPage =
_pageController.page?.round() ?? _pageController.initialPage;
if (currentPage == targetPage) return;
_pageController.animateToPage(
targetPage,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
}
DateTime _dateForPage(int page) {
return _firstSelectableDay.add(Duration(days: page));
}
int _pageIndexForDay(DateTime day) {
final dayIndex =
_clampSelectableDay(day).difference(_firstSelectableDay).inDays;
return dayIndex.clamp(0, _dayPageCount - 1).toInt();
}
DateTime _clampSelectableDay(DateTime day) {
final normalizedDay = DateUtils.dateOnly(day);
if (normalizedDay.isBefore(_firstSelectableDay)) return _firstSelectableDay;
if (normalizedDay.isAfter(_lastSelectableDay)) return _lastSelectableDay;
return normalizedDay;
}
DateTime _weekStart(DateTime day) {
final normalizedDay = DateUtils.dateOnly(day);
return normalizedDay.subtract(Duration(days: normalizedDay.weekday - 1));
}
DateTime get _selectedDay =>
_clampSelectableDay(homeController.selectedDate.value);
String get _dateTitle {
final today = _lastSelectableDay;
if (isSameDay(_selectedDay, today)) return '今日';
if (isSameDay(_selectedDay, today.subtract(const Duration(days: 1)))) {
return '昨日';
}
if (isSameDay(_selectedDay, today.add(const Duration(days: 1)))) {
return '明日';
}
return '${_selectedDay.month}${_selectedDay.day}日';
}
bool get _isSelectedToday => isSameDay(_selectedDay, _lastSelectableDay);
bool _isAfterToday(DateTime day) {
final normalizedDay = DateUtils.dateOnly(day);
return normalizedDay.isAfter(_lastSelectableDay);
}
Color _calendarTextColor(DateTime day, {required bool selected}) {
if (selected) return Colors.white;
return context.colors.textPrimary
.withValues(alpha: _isAfterToday(day) ? 0.2 : 0.6);
}
String _weekdayText(DateTime day) {
switch (day.weekday) {
case DateTime.monday:
return '一';
case DateTime.tuesday:
return '二';
case DateTime.wednesday:
return '三';
case DateTime.thursday:
return '四';
case DateTime.friday:
return '五';
case DateTime.saturday:
return '六';
case DateTime.sunday:
default:
return '日';
}
}
}
class _TopDateBar extends StatelessWidget {
const _TopDateBar({
required this.title,
required this.showBackToToday,
required this.onBackToToday,
});
final String title;
final bool showBackToToday;
final VoidCallback onBackToToday;
@override
Widget build(BuildContext context) {
return SizedBox(
height: _TodayTabState._topBarHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
if (showBackToToday) ...[
const SizedBox(width: 12),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onBackToToday,
child: SizedBox(
height: 24,
child: Row(
children: [
Image.asset(
'assets/images/common/ic_back_to_today.png',
width: 12,
height: 12,
color: context.colors.primary,
),
const SizedBox(width: 4),
Text(
'回今天',
style: TextStyle(
color: context.colors.primary,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
],
const Spacer(),
Obx(() {
return Get.find<UserStateService>().isVip
? const SizedBox.shrink()
: Container(
height: 29,
constraints: const BoxConstraints(minWidth: 95),
padding: const EdgeInsets.symmetric(horizontal: 7),
decoration: BoxDecoration(
color: const Color(0xFFFFDF51),
borderRadius: BorderRadius.circular(27.6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/common/ic_pro.png',
width: 20,
height: 20,
color: context.colors.textPrimary,
),
const SizedBox(width: 2),
Text(
'20%优惠',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: context.colors.textPrimary,
),
),
],
),
);
}),
],
),
),
);
}
}
... ...
... ... @@ -17,7 +17,8 @@ class TrendTab extends StatefulWidget {
State<TrendTab> createState() => _TrendTabState();
}
class _TrendTabState extends State<TrendTab> with SingleTickerProviderStateMixin {
class _TrendTabState extends State<TrendTab>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
late final TrendController _trendController;
late final Worker _rxWorker;
... ... @@ -36,7 +37,7 @@ class _TrendTabState extends State<TrendTab> with SingleTickerProviderStateMixin
void initState() {
super.initState();
_trendController = Get.find<TrendController>();
// 初始化 TabController
_tabController = TabController(
length: 3,
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -11,8 +12,6 @@ class DfTabBar extends StatelessWidget {
required this.onTap,
});
static const _brandColor = Color(0xFF845EEE);
static const _unselectedColor = Color(0xFF0F0F11);
static const _selectedBgColor = Color(0xFFF3F3F3);
static const _tabIcons = [
... ... @@ -22,6 +21,13 @@ class DfTabBar extends StatelessWidget {
'assets/images/tabbar/icon_my.png',
];
static const _selectedTabIcons = [
'assets/images/tabbar/icon_today_selected.png',
'assets/images/tabbar/icon_trend_selected.png',
'assets/images/tabbar/icon_friends_selected.png',
'assets/images/tabbar/icon_my_selected.png',
];
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
... ... @@ -83,14 +89,12 @@ class DfTabBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
_tabIcons[i],
isSelected
? _selectedTabIcons[i]
: _tabIcons[i],
width: 24,
height: 24,
gaplessPlayback: true,
color: isSelected
? _brandColor
: _unselectedColor,
colorBlendMode: BlendMode.srcIn,
),
const SizedBox(height: 2),
Text(
... ... @@ -99,8 +103,8 @@ class DfTabBar extends StatelessWidget {
fontSize: 9,
fontWeight: FontWeight.w500,
color: isSelected
? _brandColor
: _unselectedColor,
? context.colors.primary
: context.colors.textSecondary,
height: 1.4,
),
),
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
/// Figma: 每日行动 — 睡眠/健身/步数卡片,横向可滑动
class TodayActionsCard extends GetView<TodayController> {
const TodayActionsCard({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'每日行动',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF0F0F11),
),
),
),
// 横向滑动卡片列表
SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: controller.dailyActions.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, i) {
final item = controller.dailyActions[i];
return _ActionCard(item: item);
},
),
),
],
);
}
}
class _ActionCard extends StatelessWidget {
final DailyActionItem item;
const _ActionCard({required this.item});
static const _brandColor = Color(0xFF845EEE);
static const _h1 = Color(0xFF0F0F11);
static const _h2 = Color(0xFF666666);
@override
Widget build(BuildContext context) {
return Container(
width: 140,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
item.label,
style: const TextStyle(
fontSize: 13,
color: _brandColor,
fontWeight: FontWeight.w500,
),
),
const Icon(Icons.chevron_right, size: 16, color: _h2),
],
),
const Spacer(),
// 数值
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
item.value,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: _h1,
height: 1,
),
),
const SizedBox(width: 3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
item.unit,
style: const TextStyle(
fontSize: 12,
color: _h2,
),
),
),
if (item.subValue != null) ...[
const SizedBox(width: 2),
Text(
item.subValue!,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: _h1,
height: 1,
),
),
const SizedBox(width: 3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
item.subUnit ?? '',
style: const TextStyle(
fontSize: 12,
color: _h2,
),
),
),
],
],
),
],
),
);
}
}
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
class TodayHealthDataAuthCard extends StatelessWidget {
const TodayHealthDataAuthCard({
super.key,
this.onAuthorizeTap,
});
final VoidCallback? onAuthorizeTap;
@override
Widget build(BuildContext context) {
final colors = context.colors;
final l10n = context.l10n;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 0),
decoration: BoxDecoration(
color: context.theme.cardColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/common/ic_apple_health.webp',
width: 56,
height: 53,
),
const SizedBox(height: 12),
Text(
l10n.todayHealthDataAuthTitle,
textAlign: TextAlign.center,
style: TextStyle(
color: colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 5),
Text(
l10n.todayHealthDataAuthDescription,
style: TextStyle(
color: colors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.35,
),
),
const SizedBox(height: 16),
Divider(
height: 1,
thickness: 1,
color: colors.border,
),
const SizedBox(height: 20),
GestureDetector(
onTap: onAuthorizeTap,
behavior: HitTestBehavior.opaque,
child: Text(
l10n.todayHealthDataAuthAction,
style: TextStyle(
color: colors.primary,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
const SizedBox(height: 20),
],
),
);
}
}
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
/// Figma: 添加 HRV 主题表盘引导 Banner,右上角关闭按钮
class TodayHrvAdBanner extends GetView<TodayController> {
const TodayHrvAdBanner({super.key});
... ... @@ -12,63 +13,169 @@ class TodayHrvAdBanner extends GetView<TodayController> {
return Obx(() {
if (!controller.showHrvAdBanner.value) return const SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: const Color(0xFF1E1B2E),
borderRadius: BorderRadius.circular(16),
return _TodayGuideBanner(
title: '点击添加HRV主题表盘',
subtitle: '时刻掌握自身健康波动',
right: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/common/ic_chevron_right.png',
width: 20,
height: 20,
color: context.colors.primary,
),
),
clipBehavior: Clip.hardEdge,
child: Stack(
children: [
// 内容区
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 48, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'点击添加HRV主题表盘',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 4),
Text(
'时刻掌握自身健康波动',
style: TextStyle(
fontSize: 13,
color: Color(0xFF999999),
),
),
],
),
onClose: controller.dismissHrvAdBanner,
);
});
}
}
/// Figma: 添加亲密联系人 Banner,右上角关闭按钮
class TodayPartnerAdBanner extends GetView<TodayController> {
const TodayPartnerAdBanner({super.key});
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Obx(() {
final shouldShow = controller.showPartnerAdBanner.value &&
!controller.userStateService.isBound;
if (!shouldShow) return const SizedBox.shrink();
return _TodayGuideBanner(
title: '添加亲密联系人',
subtitle: '多一个人关注你的健康',
right: GestureDetector(
onTap: () => Get.toNamed(AppRoutes.bindPartner),
behavior: HitTestBehavior.opaque,
child: Container(
width: 72,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(23),
),
// 关闭按钮
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: controller.dismissHrvAdBanner,
child: Container(
width: 24,
height: 24,
decoration: const BoxDecoration(
color: Color(0x66000000),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
size: 14,
color: Colors.white,
),
),
child: Text(
'加好友',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
height: 17 / 12,
),
),
],
),
),
onClose: controller.dismissPartnerAdBanner,
);
});
}
}
class _TodayGuideBanner extends StatelessWidget {
const _TodayGuideBanner({
required this.title,
required this.subtitle,
required this.onClose,
this.right,
});
final String title;
final String subtitle;
final VoidCallback onClose;
final Widget? right;
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Container(
height: 65,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colors.brandBackgroundLight,
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.hardEdge,
child: Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 100,
height: double.infinity,
child: ColoredBox(
color: colors.chartPink.withValues(alpha: 0.72),
child: Center(
child: Text(
'插图',
style: TextStyle(
fontSize: 12,
color: colors.chartPink,
height: 1.2,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.textPrimary,
height: 20 / 14,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colors.textSecondary,
height: 17 / 12,
),
),
],
),
),
if (right != null) ...[
const SizedBox(width: 8),
right!,
],
const SizedBox(width: 20),
],
),
Positioned(
top: 6,
right: 6,
child: GestureDetector(
onTap: onClose,
behavior: HitTestBehavior.opaque,
child: Image.asset('assets/images/common/ic_close_round.png',
width: 13.7, height: 13.7),
),
),
],
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -18,322 +19,342 @@ class TodayHrvChartCard extends GetView<TodayController> {
static const _h3 = Color(0xFF999999);
static const _h5 = Color(0xFFCCCCCC);
/// 实时压力 mock:按小时 0~18,Y 轴 0~80
static const List<double> _mockStressByHour = [
18,
22,
28,
35,
32,
40,
48,
55,
62,
58,
52,
45,
38,
42,
50,
56,
64,
58,
46,
];
@override
Widget build(BuildContext context) {
final spots =
controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList();
return Obx(() {
final hrvSpots =
controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList();
final stressPoints = controller.stressChartData.toList();
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'今日HRV趋势',
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
const Icon(Icons.info_outline, size: 14, color: _h3),
const Spacer(),
],
),
const SizedBox(width: 14),
SizedBox(
height: 174,
child: LineChart(
LineChartData(
minX: 0,
maxX: 18,
minY: 0,
maxY: 80,
// clipData: const FlClipData.all(),
gridData: FlGridData(
show: true,
drawVerticalLine: true,
drawHorizontalLine: false,
verticalInterval: 6,
getDrawingVerticalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: false,
)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 6,
getTitlesWidget: (val, _) {
final labels = {
0.0: '00:00',
6.0: '06:00',
12.0: '12:00',
18.0: '18:00',
};
final label = labels[val];
if (label == null) return const SizedBox.shrink();
return Text(
label,
style: const TextStyle(
fontSize: 10,
color: _h3,
),
);
},
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 14),
SizedBox(
height: 174,
child: hrvSpots.isEmpty
? const _EmptyChart(text: '暂无HRV数据')
: LineChart(_hrvLineChartData(hrvSpots)),
),
Padding(
padding: const EdgeInsets.only(bottom: 14, top: 16),
child: Row(
children: [
const Text(
'实时压力',
style: TextStyle(
color: _h2,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: const Color(0xFFD9D9D9),
barWidth: 2,
dotData: FlDotData(
getDotPainter: (spot, percent, barData, index) =>
FlDotCirclePainter(
radius: 4,
color: Colors.white,
strokeWidth: 3,
strokeColor: _getColor(spot.y),
),
))
const SizedBox(width: 4),
const Icon(Icons.info_outline, size: 14, color: _h3),
const Spacer(),
],
lineTouchData: LineTouchData(
getTouchedSpotIndicator: (barData, spotIndexes) {
return spotIndexes.map((spotIndex) {
// final spot = barData.spots[spotIndex];
return TouchedSpotIndicatorData(
FlLine(
color: Color(0xFFB0B0B6),
strokeWidth: 2,
),
FlDotData(
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 5,
color: Colors.white,
strokeWidth: 3.5,
strokeColor: _getColor(spot.y),
);
},
),
);
}).toList();
},
touchTooltipData: LineTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
getTooltipColor: (touchedSpot) => Color(0xFFF3F3F3),
tooltipPadding: EdgeInsets.only(
left: 12, right: 12, top: 6, bottom: 5),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
final flSpot = barSpot;
return LineTooltipItem(
'',
TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: _getTooltipChildren(flSpot),
textAlign: TextAlign.start,
);
}).toList();
})),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 14, top: 16),
child: Row(
children: [
const Text(
'实时压力',
style: TextStyle(
color: _h2,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
const Icon(Icons.info_outline, size: 14, color: _h3),
const Spacer(),
],
SizedBox(
height: 174,
child: stressPoints.isEmpty
? const _EmptyChart(text: '暂无压力数据')
: _buildStressChart(stressPoints),
),
],
),
);
});
}
Widget _buildHeader(BuildContext context) {
return Row(
children: [
Text(
'今日HRV趋势',
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
SizedBox(
height: 174,
child: Stack(
children: [
BarChart(
BarChartData(
minY: 0,
maxY: 100,
groupsSpace: 1,
alignment: BarChartAlignment.start,
barGroups: List.generate(
_mockStressByHour.length * 5,
(i) => BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY:
_mockStressByHour[i % _mockStressByHour.length],
width: 2,
color: _getColor(_mockStressByHour[
i % _mockStressByHour.length]),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(2),
topRight: Radius.circular(2),
),
),
],
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
const SizedBox(width: 4),
Image.asset(
'assets/images/common/ic_info.png',
width: 14,
height: 14,
color: context.colors.textTertiary,
),
const Spacer(),
Text(
'更多',
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
Image.asset(
'assets/images/common/ic_more_gray.png',
width: 16,
height: 16,
color: context.colors.textTertiary,
),
],
);
}
LineChartData _hrvLineChartData(List<FlSpot> spots) {
return LineChartData(
minX: 0,
maxX: _maxChartHour(spots),
minY: 0,
maxY: 80,
gridData: FlGridData(
show: true,
drawVerticalLine: true,
drawHorizontalLine: false,
verticalInterval: 6,
getDrawingVerticalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 6,
getTitlesWidget: (val, _) {
final label = _timeLabel(val);
if (label == null) return const SizedBox.shrink();
return Text(
label,
style: const TextStyle(fontSize: 10, color: _h3),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: const Color(0xFFD9D9D9),
barWidth: 2,
dotData: FlDotData(
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: Colors.white,
strokeWidth: 3,
strokeColor: _getColor(spot.y),
);
},
),
)
],
lineTouchData: LineTouchData(
getTouchedSpotIndicator: (barData, spotIndexes) {
return spotIndexes.map((spotIndex) {
return TouchedSpotIndicatorData(
const FlLine(
color: Color(0xFFB0B0B6),
strokeWidth: 2,
),
FlDotData(
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 5,
color: Colors.white,
strokeWidth: 3.5,
strokeColor: _getColor(spot.y),
);
},
),
);
}).toList();
},
touchTooltipData: LineTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
tooltipPadding: const EdgeInsets.only(
left: 12,
right: 12,
top: 6,
bottom: 5,
),
getTooltipItems: (touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
return LineTooltipItem(
'',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: _getTooltipChildren(barSpot),
textAlign: TextAlign.start,
);
}).toList();
},
),
),
);
}
Widget _buildStressChart(List<HrvDataPoint> stressPoints) {
return Stack(
children: [
BarChart(
BarChartData(
minY: 0,
maxY: 100,
groupsSpace: 1,
alignment: BarChartAlignment.start,
barGroups: List.generate(
stressPoints.length,
(i) {
final point = stressPoints[i];
return BarChartGroupData(
x: (point.hour * 10).round(),
barRods: [
BarChartRodData(
toY: point.hrv,
width: 2,
color: _getColor(point.hrv),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(2),
topRight: Radius.circular(2),
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (val, _) {
return Text(
'${val.toInt()}',
style: const TextStyle(fontSize: 10, color: _h3),
);
},
)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 60,
getTitlesWidget: (val, meta) {
final labels = {
0: '00:00',
33: '06:00',
66: '12:00',
99: '18:00',
};
final label = labels[val];
if (label == null) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(
label,
style:
const TextStyle(fontSize: 10, color: _h3),
),
);
},
),
],
);
},
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _h5,
strokeWidth: 1,
dashArray: [2, 2],
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (val, _) {
if (val < 0 || val > 100) {
return const SizedBox.shrink();
}
return Text(
'${val.toInt()}',
style: const TextStyle(fontSize: 10, color: _h3),
);
},
),
),
leftTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 60,
getTitlesWidget: (val, meta) {
final label = _timeLabel(val / 10);
if (label == null) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(
label,
style: const TextStyle(fontSize: 10, color: _h3),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
getTooltipColor: (touchedSpot) => Color(0xFFF3F3F3),
tooltipPadding: EdgeInsets.only(
left: 12, right: 12, top: 6, bottom: 5),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'',
TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: _getTooltip(rod),
textAlign: TextAlign.start,
);
})),
),
);
},
),
Container(
width: 74,
height: 160,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
colors: [
const Color(0x4C835DED),
const Color(0x00845EEE)
],
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 8,
tooltipBorder: BorderSide.none,
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
tooltipPadding: const EdgeInsets.only(
left: 12,
right: 12,
top: 6,
bottom: 5,
),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
)
],
children: _getTooltip(rod),
textAlign: TextAlign.start,
);
},
),
),
),
],
),
),
Container(
width: 74,
height: 160,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
colors: [Color(0x4C835DED), Color(0x00845EEE)],
),
),
)
],
);
}
double _maxChartHour(List<FlSpot> spots) {
final maxHour = spots.fold<double>(
0,
(maxValue, spot) => spot.x > maxValue ? spot.x : maxValue,
);
return ((maxHour / 6).ceil() * 6).clamp(6, 24).toDouble();
}
String? _timeLabel(double value) {
return switch (value) {
0 => '00:00',
6 => '06:00',
12 => '12:00',
18 => '18:00',
24 => '24:00',
_ => null,
};
}
Color _getColor(double value) {
if (value > 60) {
return const Color(0xFF3BD49D);
... ... @@ -364,10 +385,6 @@ class TodayHrvChartCard extends GetView<TodayController> {
List<TextSpan>? _getTooltip(BarChartRodData rod) {
return [
// WidgetSpan(
// child: Image.asset('assets/images/home/today/ic_tooltip_dot.png',
// width: 12, height: 12),
// ),
TextSpan(
text: _getStatus(rod.toY),
style: TextStyle(
... ... @@ -383,8 +400,8 @@ class TodayHrvChartCard extends GetView<TodayController> {
),
),
TextSpan(
text: '${rod.toY}ms.${rod.fromY}:00',
style: TextStyle(
text: '压力 ${_formatNumber(rod.toY)}',
style: const TextStyle(
color: Color(0xFF78787D),
fontWeight: FontWeight.bold,
),
... ... @@ -394,10 +411,6 @@ class TodayHrvChartCard extends GetView<TodayController> {
List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot) {
return [
// WidgetSpan(
// child: Image.asset('assets/images/home/today/ic_tooltip_dot.png',
// width: 12, height: 12),
// ),
TextSpan(
text: _getStatus(flSpot.y),
style: TextStyle(
... ... @@ -413,12 +426,44 @@ class TodayHrvChartCard extends GetView<TodayController> {
),
),
TextSpan(
text: '${flSpot.y}ms.${flSpot.x.toInt()}:00',
style: TextStyle(
text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
style: const TextStyle(
color: Color(0xFF78787D),
fontWeight: FontWeight.bold,
),
),
];
}
String _formatNumber(double value) {
if (value % 1 == 0) return '${value.toInt()}';
return value.toStringAsFixed(1);
}
String _formatHour(double hour) {
final totalMinutes = (hour * 60).round();
final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
final m = (totalMinutes % 60).toString().padLeft(2, '0');
return '$h:$m';
}
}
class _EmptyChart extends StatelessWidget {
const _EmptyChart({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
text,
style: const TextStyle(
color: Color(0xFF999999),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
);
}
}
... ...
... ... @@ -21,21 +21,15 @@ class TodayHrvNumberCard extends GetView<TodayController> {
Expanded(
child: _NumberItem(
label: '该日平均HRV',
value: '${controller.avgHrv.value}',
value: controller.avgHrv.value,
unit: 'ms',
),
),
// 分割线
// Container(
// width: 1,
// height: 40,
// color: const Color(0xFFE0E0E0),
// ),
// 心率
Expanded(
child: _NumberItem(
label: '静息心率',
value: '${controller.restingHeartRate.value}',
value: controller.restingHeartRate.value,
unit: 'bpm',
),
),
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
class TodaySleepCard extends GetView<TodayController> {
const TodaySleepCard({super.key});
@override
Widget build(BuildContext context) {
return Obx(
() => _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_sleep_stroke.png',
iconColor: context.colors.primary,
title: '睡眠',
actionText: '查看睡眠报告',
progressValues: [controller.sleepProgress.value],
progressColors: [context.colors.primary],
metrics: [
_MetricData(
label: '时长',
labelColor: context.colors.primary,
valueSpans: [
_MetricValueSpan(controller.sleepHours.value, '小时'),
_MetricValueSpan(controller.sleepMinutes.value, '分钟'),
],
),
_MetricData(
label: '质量',
labelColor: const Color(0xFF7B9BFB),
valueSpans: [
_MetricValueSpan(
controller.sleepQuality.value,
'',
valueColor: _qualityColor(controller.sleepQuality.value),
),
],
),
_MetricData(
label: '平均心率',
labelColor: context.colors.textSecondary,
valueSpans: [
_MetricValueSpan(controller.sleepAverageHeartRate.value, 'bpm'),
],
),
],
),
);
}
Color _qualityColor(String quality) {
return switch (quality) {
'优秀' => const Color(0xFF3BD49D),
'良好' => const Color(0xFF7B9BFB),
'一般' => const Color(0xFFFF9A6E),
'偏少' => const Color(0xFFFF5279),
_ => const Color(0xFF0F0F11),
};
}
}
class TodayActivityCard extends GetView<TodayController> {
const TodayActivityCard({super.key});
@override
Widget build(BuildContext context) {
return Obx(
() => _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_exercise.png',
iconColor: const Color(0xFFFF5279),
title: '健身',
actionText: '查看健身报告',
progressValues: [
controller.activityMoveProgress.value,
controller.activityExerciseProgress.value,
controller.activityStandProgress.value,
],
progressColors: const [
Color(0xFFFF5279),
Color(0xFF3BD49D),
Color(0xFF7B9BFB),
],
metrics: [
_MetricData(
label: '活动',
labelColor: const Color(0xFFFF5279),
valueSpans: [
_MetricValueSpan(controller.activityCalories.value, '千卡'),
],
),
_MetricData(
label: '锻炼',
labelColor: const Color(0xFF3BD49D),
valueSpans: [
_MetricValueSpan(
controller.activityExerciseMinutes.value,
'分钟',
),
],
),
_MetricData(
label: '站立',
labelColor: const Color(0xFF7B9BFB),
valueSpans: [
_MetricValueSpan(controller.activityStandHours.value, '小时'),
],
),
],
),
);
}
}
class _TodaySummaryCard extends StatelessWidget {
const _TodaySummaryCard({
required this.iconAsset,
required this.iconColor,
required this.title,
required this.actionText,
required this.metrics,
required this.progressValues,
required this.progressColors,
});
final String iconAsset;
final Color iconColor;
final String title;
final String actionText;
final List<_MetricData> metrics;
final List<double> progressValues;
final List<Color> progressColors;
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
children: [
Image.asset(
iconAsset,
width: 16,
height: 16,
color: iconColor,
),
const SizedBox(width: 4),
Text(
title,
style: TextStyle(
color: colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
),
),
const Spacer(),
Text(
actionText,
style: TextStyle(
color: colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 17 / 12,
),
),
Image.asset(
'assets/images/common/ic_more_gray.png',
width: 16,
height: 16,
color: colors.textTertiary,
),
],
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final metric in metrics)
Expanded(child: _MetricBlock(metric: metric)),
],
),
),
const SizedBox(width: 4),
Container(
width: 50,
height: 50,
color: Colors.red,
),
const SizedBox(width: 6),
],
),
],
),
);
}
}
class _MetricBlock extends StatelessWidget {
const _MetricBlock({required this.metric});
final _MetricData metric;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
metric.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: metric.labelColor,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 17 / 12,
),
),
const SizedBox(height: 5),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final span in metric.valueSpans) _MetricValue(value: span),
],
),
),
],
),
);
}
}
class _MetricValue extends StatelessWidget {
const _MetricValue({required this.value});
final _MetricValueSpan value;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value.value,
style: TextStyle(
color: value.valueColor ?? context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 19 / 16,
),
),
if (value.unit.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 1, bottom: 1),
child: Text(
value.unit,
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 17 / 12,
),
),
),
if (value.unit.isNotEmpty) const SizedBox(width: 4),
],
);
}
}
class _MetricData {
const _MetricData({
required this.label,
required this.labelColor,
required this.valueSpans,
});
final String label;
final Color labelColor;
final List<_MetricValueSpan> valueSpans;
}
class _MetricValueSpan {
const _MetricValueSpan(
this.value,
this.unit, {
this.valueColor,
});
final String value;
final String unit;
final Color? valueColor;
}
... ...
import 'dart:async';
import 'package:doublefeel_flutter/core/constants/app_const.dart';
import 'package:doublefeel_flutter/l10n/gen/app_localizations.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/data/models/vip/vip_info.dart';
... ... @@ -14,6 +19,7 @@ class LoginController extends GetxController {
final UserApi _userApi = Get.find<UserApi>();
final VipApi _vipApi = Get.find<VipApi>();
final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>();
final UserAccountStorage _userAccount = Get.find<UserAccountStorage>();
final UserStateService _userStateService = Get.find<UserStateService>();
final phoneController = TextEditingController();
... ... @@ -27,6 +33,7 @@ class LoginController extends GetxController {
Timer? _countdownTimer;
final isLoggingIn = false.obs;
final hasSentCode = false.obs;
final termsChecked = false.obs;
... ... @@ -36,7 +43,7 @@ class LoginController extends GetxController {
void onPhoneLoginPressed() {
if (!termsChecked.value) {
Get.snackbar('提示', '请先阅读并同意《用户协议》与《隐私协议》');
AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
return;
}
Get.toNamed(AppRoutes.phoneLogin);
... ... @@ -44,10 +51,10 @@ class LoginController extends GetxController {
void onAppleLoginPressed() {
if (!termsChecked.value) {
Get.snackbar('提示', '请先阅读并同意《用户协议》与《隐私协议》');
AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
return;
}
Get.snackbar('提示', 'Apple 登录功能待接入');
//todo: implement apple login
}
void onDebugPressed() {
... ... @@ -57,20 +64,14 @@ class LoginController extends GetxController {
void openUserTerms() {
Get.toNamed(
AppRoutes.webview,
parameters: {
'url':
'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html'
},
parameters: {'url': AppConst.userTerms},
);
}
void openPrivacyPolicy() {
Get.toNamed(
AppRoutes.webview,
parameters: {
'url':
'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html'
},
parameters: {'url': AppConst.privacyPolicy},
);
}
... ... @@ -109,7 +110,7 @@ class LoginController extends GetxController {
if (!canRequestCode) return;
if (cleanPhone.length < 11) {
Get.snackbar('提示', '手机号格式错误');
AppToast.show(AppLocalizations.of(Get.context!)!.phoneLoginInvalidPhone);
return;
}
... ... @@ -119,12 +120,14 @@ class LoginController extends GetxController {
isSendingCode.value = false;
if (result is AppSuccess<void>) {
Get.snackbar('提示', '发送成功');
AppToast.show(
AppLocalizations.of(Get.context!)!.phoneLoginCodeSentSuccess);
_startCountdown();
}
}
void _startCountdown() {
hasSentCode.value = true;
_countdownTimer?.cancel();
countdownSeconds.value = 60;
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
... ... @@ -141,11 +144,11 @@ class LoginController extends GetxController {
if (!canLogin) return;
if (cleanPhone.length < 11) {
Get.snackbar('提示', '手机号格式错误');
AppToast.show(AppLocalizations.of(Get.context!)!.phoneLoginInvalidPhone);
return;
}
if (codeInput.value.isEmpty) {
Get.snackbar('提示', '验证码格式错误');
AppToast.show(AppLocalizations.of(Get.context!)!.phoneLoginInvalidCode);
return;
}
... ... @@ -209,11 +212,24 @@ class LoginController extends GetxController {
await _userStateService.onLogin();
// 用户登录成功即代表同意了协议,持久化到本地供冷启动时 SDK 初始化判断使用
await Get.find<LocalStorage>().setTermsAgreed(true);
isLoggingIn.value = false;
if (isRegister) {
Get.offAllNamed(AppRoutes.userOnboarding);
} else {
// 根据引导完成状态决定跳转目标
final userId = me.id ?? 0;
if (_userAccount.hasCompletedOnboarding(userId)) {
// 该账号已完成引导,直接进主页
Get.offAllNamed(AppRoutes.home);
} else {
// 新用户或未完成引导,进入引导页(支持断点续做)
final resumeStage = _userAccount.onboardingResumeStage(userId);
Get.offAllNamed(
AppRoutes.userOnboarding,
arguments:
resumeStage != null ? {'resumeStage': resumeStage} : null,
);
}
} else {
isLoggingIn.value = false;
... ...
... ... @@ -5,6 +5,7 @@ import '../../../../core/config/app_environment.dart';
import '../../../../core/config/app_environment_config.dart';
import '../../../../core/constants/app_const.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/util/app_toast.dart';
class DebugEnvironmentView extends StatelessWidget {
const DebugEnvironmentView({super.key});
... ... @@ -66,10 +67,8 @@ class DebugEnvironmentView extends StatelessWidget {
await environmentConfig.setEnvironment(value);
dioClient.refreshBaseUrl();
Get.snackbar(
'环境已切换',
environmentConfig.serverBaseUrl,
snackPosition: SnackPosition.BOTTOM,
AppToast.show(
'环境已切换: ${environmentConfig.serverBaseUrl}',
);
},
),
... ...
import 'dart:math' as math;
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
... ... @@ -12,10 +16,6 @@ import '../controllers/login_controller.dart';
class LoginView extends GetView<LoginController> {
const LoginView({super.key});
static const _designHeight = 812.0;
static const _brandColor = Color(0xFF845EEE);
static const _titleColor = Color(0xFF0F0F11);
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
... ... @@ -31,168 +31,57 @@ class LoginView extends GetView<LoginController> {
body: LayoutBuilder(
builder: (context, constraints) {
final screenSize = MediaQuery.sizeOf(context);
var bottomInset = MediaQuery.paddingOf(context).bottom;
bottomInset = bottomInset > 0 ? bottomInset : 34;
final width = constraints.maxWidth.isFinite
? constraints.maxWidth
: screenSize.width;
final height = constraints.maxHeight.isFinite
? constraints.maxHeight
: screenSize.height;
final buttonWidth = math.min(280.0, math.max(0.0, width - 64.0));
double top(double value) => height * value / _designHeight;
final rightOffset = (width - buttonWidth) / 2;
double bottom(double value) => bottomInset + value;
final l10n = context.l10n;
return Stack(
fit: StackFit.expand,
children: [
// Background Gradient
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
colors: [
Color(0xFFECE3FF),
Color(0xFFEDE4FF),
Color(0xFFEEE6FF),
Color(0xFFF9F6FF),
const Color(0xFFE6DAFF),
const Color(0xFFECE4FF),
const Color(0xFFEEE6FF),
const Color(0xFFF8F6FF)
],
stops: [0, 0.33173, 0.74038, 1],
),
),
),
// Beautiful custom illustration (Instead of red text placeholder)
Positioned(
top: top(140),
left: 0,
right: 0,
child: Center(
child: SizedBox(
width: 220,
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
// Base glow circle
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
_brandColor.withValues(alpha: 0.25),
Colors.transparent,
],
),
),
),
// Premium geometric shapes with glassmorphism glow
Positioned(
top: 20,
left: 30,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.5),
border: Border.all(
color: Colors.white.withValues(alpha: 0.6),
width: 1.5,
),
),
),
),
Positioned(
bottom: 30,
right: 40,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.6),
Colors.white.withValues(alpha: 0.2),
],
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8),
width: 1.5,
),
),
),
),
// Heart inside glass sphere
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.8),
Colors.white.withValues(alpha: 0.1),
],
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.9),
width: 2,
),
boxShadow: [
BoxShadow(
color: _brandColor.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: const Center(
child: Icon(
Icons.favorite_rounded,
color: _brandColor,
size: 54,
),
),
),
],
),
),
),
),
// Welcome texts
Positioned(
top: top(420),
bottom: bottom(253),
left: 0,
right: 0,
child: const Column(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'欢迎使用DoubleFeel',
textAlign: TextAlign.center,
style: TextStyle(
color: _titleColor,
fontSize: 28,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
Image.asset(
'assets/images/common/ic_double_feel_text.png',
width: 160,
height: 42,
),
SizedBox(height: 16),
SizedBox(height: 8),
Text(
'开启压力预警与健康陪伴之旅\n让爱与关心从不缺席',
l10n.loginSlogan,
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF666666),
fontSize: 14,
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.4,
height: 1.50,
letterSpacing: 6,
),
),
],
... ... @@ -201,13 +90,13 @@ class LoginView extends GetView<LoginController> {
// Phone login button
Positioned(
top: top(560),
bottom: bottom(145),
left: 0,
right: 0,
child: Center(
child: _LoginButton(
width: buttonWidth,
label: '手机号登录/注册',
label: l10n.loginWithPhone,
onPressed: controller.onPhoneLoginPressed,
),
),
... ... @@ -215,22 +104,69 @@ class LoginView extends GetView<LoginController> {
// Apple login button
Positioned(
top: top(628),
bottom: bottom(85),
left: 0,
right: 0,
child: Center(
child: _LoginButton(
width: buttonWidth,
label: '通过Apple登录',
icon: Icons.apple,
label: l10n.loginWithApple,
onPressed: controller.onAppleLoginPressed,
icon: Image.asset(
'assets/images/common/ic_apple.png',
width: 24,
height: 24,
color: context.colors.textPrimary,
),
buttonStyle: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: context.colors.textPrimary,
disabledBackgroundColor:
context.colors.textPrimary.withValues(alpha: 0.5),
disabledForegroundColor: context.colors.textPrimary,
elevation: 0,
shadowColor: Colors.transparent,
padding: EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
),
),
if (Get.find<LocalStorage>().lastLoginMethod.isNotEmpty)
Positioned(
right: rightOffset - 4,
bottom: Get.find<LocalStorage>().lastLoginMethod == 'apple'
? bottom(117)
: bottom(200),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: ShapeDecoration(
color: const Color(0xFFFF9A6E),
shape: RoundedRectangleBorder(
side: BorderSide(width: 1, color: Colors.white),
borderRadius: BorderRadius.circular(25),
),
),
child: Text(
l10n.loginLastUsed,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
// Terms agreement text + checkbox
Positioned(
top: top(712),
bottom: bottom(40),
left: 0,
right: 0,
child: const _AgreementText(),
... ... @@ -240,12 +176,13 @@ class LoginView extends GetView<LoginController> {
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.paddingOf(context).bottom + 12,
bottom: bottom(0),
child: Center(
child: TextButton(
onPressed: controller.onDebugPressed,
style: TextButton.styleFrom(
foregroundColor: _titleColor.withValues(alpha: 0.55),
foregroundColor: context.colors.textSecondary
.withValues(alpha: 0.55),
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
... ... @@ -274,42 +211,46 @@ class _LoginButton extends StatelessWidget {
required this.label,
required this.onPressed,
this.icon,
this.buttonStyle,
});
final double width;
final String label;
final IconData? icon;
final Image? icon;
final ButtonStyle? buttonStyle;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: 52,
height: 48,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: LoginView._brandColor,
foregroundColor: Colors.white,
disabledBackgroundColor: LoginView._brandColor.withValues(alpha: 0.5),
disabledForegroundColor: Colors.white,
elevation: 0,
shadowColor: Colors.transparent,
padding: EdgeInsets.symmetric(horizontal: icon == null ? 24 : 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(26),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
style: buttonStyle ??
ElevatedButton.styleFrom(
backgroundColor: context.colors.primary,
foregroundColor: Colors.white,
disabledBackgroundColor:
context.colors.primary.withValues(alpha: 0.5),
disabledForegroundColor: Colors.white,
elevation: 0,
shadowColor: Colors.transparent,
padding: EdgeInsets.symmetric(horizontal: icon == null ? 24 : 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 24),
icon!,
const SizedBox(width: 8),
],
Flexible(
... ... @@ -329,18 +270,18 @@ class _LoginButton extends StatelessWidget {
class _AgreementText extends GetView<LoginController> {
const _AgreementText();
static const _mutedColor = Color(0xFF78787D);
static const _linkColor = Color(0xFF14121E);
@override
Widget build(BuildContext context) {
const baseStyle = TextStyle(
color: _mutedColor,
final l10n = AppLocalizations.of(context)!;
final baseStyle = TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.2,
);
const linkStyle = TextStyle(
final linkStyle = TextStyle(
color: _linkColor,
fontSize: 12,
fontWeight: FontWeight.w600,
... ... @@ -362,7 +303,9 @@ class _AgreementText extends GetView<LoginController> {
isChecked
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
color: isChecked ? LoginView._brandColor : _mutedColor,
color: isChecked
? context.colors.primary
: context.colors.textSecondary,
size: 18,
),
),
... ... @@ -372,16 +315,16 @@ class _AgreementText extends GetView<LoginController> {
TextSpan(
style: baseStyle,
children: [
const TextSpan(text: '我已阅读并同意'),
TextSpan(text: l10n.loginAgreementPrefix),
TextSpan(
text: '《用户协议》',
text: l10n.loginTerms,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = controller.openUserTerms,
),
const TextSpan(text: '与'),
TextSpan(text: l10n.loginAgreementAnd),
TextSpan(
text: '《隐私协议》',
text: l10n.loginPrivacy,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = controller.openPrivacyPolicy,
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
... ... @@ -7,13 +9,9 @@ import '../controllers/login_controller.dart';
class PhoneLoginView extends GetView<LoginController> {
const PhoneLoginView({super.key});
static const _designHeight = 812.0;
static const _brandColor = Color(0xFF845EEE);
static const _titleColor = Color(0xFF2C2020);
static const _subtitleColor = Color(0xFF908B91);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
... ... @@ -23,146 +21,104 @@ class PhoneLoginView extends GetView<LoginController> {
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: Colors.white,
body: LayoutBuilder(
builder: (context, constraints) {
final screenSize = MediaQuery.sizeOf(context);
final width = constraints.maxWidth.isFinite
? constraints.maxWidth
: screenSize.width;
final height = constraints.maxHeight.isFinite
? constraints.maxHeight
: screenSize.height;
double top(double value) => height * value / _designHeight;
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: SizedBox(
height: height,
width: width,
child: Stack(
fit: StackFit.expand,
children: [
// Background Gradient
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFECE3FF),
Color(0xFFEDE4FF),
Color(0xFFEEE6FF),
Color(0xFFF9F6FF),
],
stops: [0, 0.33173, 0.74038, 1],
),
),
),
// Top Back Button
Positioned(
top: top(48),
left: 12,
child: IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: _titleColor,
size: 22,
),
onPressed: () {
Get.back();
},
),
),
// Top logo text & stylized icon
Positioned(
top: top(62),
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
resizeToAvoidBottomInset: false,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.25, 0.75, 1.0],
colors: [
Color(0xFFE6DBFF),
Color(0xFFEDE4FF),
Color(0xFFEEE6FF),
Color(0xFFF9F6FF),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
// 主体内容
SafeArea(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 顶部栏:返回按钮 + 角色插图
Stack(
clipBehavior: Clip.none,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: _brandColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.white,
size: 14,
),
),
const SizedBox(width: 8),
const Text(
'Double Feel',
style: TextStyle(
color: _titleColor,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
// 返回按钮
Align(
alignment: Alignment.centerLeft,
child: IconButton(
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: context.colors.textPrimary,
size: 20,
),
onPressed: Get.back,
),
),
// // 右上角角色插图
// Positioned(
// right: 16,
// top: -8,
// child: Image.asset(
// '',
// width: 113,
// height: 140,
// fit: BoxFit.contain,
// ),
// ),
],
),
),
// Headers
Positioned(
top: top(147),
left: 30,
right: 30,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'还没有Feel牌吗?',
style: TextStyle(
color: _titleColor,
fontSize: 26,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 7),
const Text(
'注册/登录手机号,成为Double Feel的一员吧~',
style: TextStyle(
color: _subtitleColor,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
),
),
const SizedBox(height: 44),
// Card with input fields
Positioned(
top: top(240),
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 24,
offset: const Offset(0, 8),
// 标题区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.phoneLoginHello,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: context.colors.textPrimary,
height: 1.4,
),
),
const SizedBox(height: 4),
Text(
l10n.phoneLoginWelcome,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: context.colors.textPrimary,
height: 1.4,
),
),
],
),
),
const SizedBox(height: 28),
// 输入区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
// Phone Number Field
// 手机号输入框
Obx(() {
final phoneNotEmpty =
controller.phoneInput.value.isNotEmpty;
... ... @@ -175,198 +131,250 @@ class PhoneLoginView extends GetView<LoginController> {
RegExp(r'[0-9\s]')),
_PhoneTextInputFormatter(),
],
style: const TextStyle(
color: _titleColor,
fontSize: 15,
fontWeight: FontWeight.w500,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
hintText: '请输入手机号',
hintStyle: const TextStyle(
color: _subtitleColor,
fontSize: 14,
hintText: l10n.phoneLoginPhoneHint,
hintStyle: TextStyle(
color: context.colors.textTertiary,
fontSize: 16,
),
counterText: '',
filled: true,
fillColor: const Color(0xFFF7F6FA),
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide(
color: context.colors.primary,
width: 1,
),
),
suffixIcon: phoneNotEmpty
? GestureDetector(
onTap: () {
controller.phoneController.clear();
},
child: const Icon(
onTap:
controller.phoneController.clear,
child: Icon(
Icons.cancel,
color: _subtitleColor,
size: 20,
color: context.colors.textTertiary,
size: 18,
),
)
: null,
),
);
}),
const SizedBox(height: 16),
// Verification Code Field Row
// 验证码输入框(内嵌发送按钮)
Obx(() {
final countdown =
controller.countdownSeconds.value;
final isSending = controller.isSendingCode.value;
final isCounting = countdown > 0;
final hasSent = controller.hasSentCode.value;
String btnText = '发送验证码';
String btnText = hasSent
? l10n.phoneLoginResend
: l10n.phoneLoginSendCode;
if (isSending) {
btnText = '发送中';
btnText = l10n.phoneLoginSending;
} else if (isCounting) {
btnText = '已发送 ($countdown)';
btnText =
l10n.phoneLoginSentCountdown(countdown);
}
final bool canSend = controller.canRequestCode;
return Row(
children: [
Expanded(
child: TextField(
controller: controller.codeController,
keyboardType: TextInputType.text,
maxLength: 8,
style: const TextStyle(
color: _titleColor,
fontSize: 15,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
hintText: '请输入验证码',
hintStyle: const TextStyle(
color: _subtitleColor,
fontSize: 14,
),
counterText: '',
filled: true,
fillColor: const Color(0xFFF7F6FA),
contentPadding:
const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
return TextField(
controller: controller.codeController,
keyboardType: TextInputType.text,
maxLength: 8,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
hintText: l10n.phoneLoginCodeHint,
hintStyle: TextStyle(
color: context.colors.textTertiary,
fontSize: 16,
),
counterText: '',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide(
color: context.colors.primary,
width: 1,
),
),
const SizedBox(width: 12),
SizedBox(
height: 52,
width: 110,
child: ElevatedButton(
onPressed: canSend
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: TextButton(
onPressed: controller.canRequestCode
? controller.requestVerifyCode
: null,
style: ElevatedButton.styleFrom(
backgroundColor: _brandColor,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(
0xFFECE9F6), // subtle tint
disabledForegroundColor: isCounting
? _brandColor
: _subtitleColor,
elevation: 0,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(16),
),
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
foregroundColor: context.colors.primary,
disabledForegroundColor:
context.colors.textTertiary,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
child: Text(btnText),
),
),
],
suffixIconConstraints:
const BoxConstraints(minWidth: 0),
),
);
}),
const SizedBox(height: 24),
// Info label
const Text(
'未注册的手机号验证通过后将自动注册',
const SizedBox(height: 56),
// 提示文字
Text(
l10n.phoneLoginAutoRegisterHint,
style: TextStyle(
color: Color(0xFFCCCCCC),
color: context.colors.textTertiary,
fontSize: 12,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Immediate Login Button
// 立即登录按钮
Obx(() {
final canLogin = controller.canLogin;
final isLoggingIn =
controller.isLoggingIn.value;
final isLoggingIn = controller.isLoggingIn.value;
return SizedBox(
width: double.infinity,
height: 56,
height: 48,
child: ElevatedButton(
onPressed:
canLogin ? controller.login : null,
onPressed: controller.canLogin
? controller.login
: null,
style: ElevatedButton.styleFrom(
backgroundColor: _brandColor,
backgroundColor: context.colors.primary,
foregroundColor: Colors.white,
disabledBackgroundColor:
_brandColor.withValues(alpha: 0.4),
disabledBackgroundColor: context
.colors.primary
.withValues(alpha: 0.4),
disabledForegroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
child: isLoggingIn
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const _SpinningLoader(),
const SizedBox(width: 8),
Text(l10n.phoneLoginLoggingIn),
],
)
: const Text('立即登录'),
: Text(l10n.loginBtn),
),
);
}),
const SizedBox(height: 12),
],
),
),
),
],
],
),
),
),
);
},
],
),
),
),
);
}
}
// ── 旋转 Loading 图标 ────────────────────────────────────────────
class _SpinningLoader extends StatefulWidget {
const _SpinningLoader();
@override
State<_SpinningLoader> createState() => _SpinningLoaderState();
}
class _SpinningLoaderState extends State<_SpinningLoader>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: _ctrl,
child: Image.asset(
'assets/images/common/ic_loading_ios_style.png',
width: 24,
height: 24,
),
);
}
}
// ── 手机号格式化 ───────────────────────────────────────────────────
class _PhoneTextInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
... ...
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
... ... @@ -33,9 +34,15 @@ class UserOnboardingController extends GetxController {
@override
void onInit() {
totalPages = Get.arguments?['type'] == 'MembershipOfferPage'
? (Get.arguments?['needBindSuccessGuide'] == true ? 2 : 1)
: 9;
if (Get.arguments?['type'] == 'MembershipOfferPage') {
totalPages = (Get.arguments?['needBindSuccessGuide'] == true ? 2 : 1);
} else {
final argument = Get.arguments?['resumeStage'];
if (argument != null && argument is int) {
currentPageIndex.value = argument.clamp(0, totalPages - 1);
}
totalPages = 9;
}
super.onInit();
}
... ... @@ -82,7 +89,8 @@ class UserOnboardingController extends GetxController {
if (Get.key.currentState?.canPop() ?? false) {
Get.back();
} else {
SystemNavigator.pop();
// SystemNavigator.pop();
Get.offAllNamed(AppRoutes.home);
}
}
... ... @@ -91,6 +99,16 @@ class UserOnboardingController extends GetxController {
if (!await prepareContinue(pageIndex)) {
return;
}
// 保存当前页进度(中途退出后可断点续做)
final userId =
Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
0;
if (userId > 0) {
await Get.find<UserAccountStorage>()
.saveOnboardingStage(userId, pageIndex);
}
if (pageIndex >= totalPages - 1) {
finishOnboarding();
return;
... ... @@ -113,6 +131,14 @@ class UserOnboardingController extends GetxController {
}
void finishOnboarding() {
// 标记当前账号引导已全部完成
final userId =
Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
0;
if (userId > 0) {
Get.find<UserAccountStorage>().markOnboardingCompleted(userId);
}
if (Get.arguments?['type'] == 'MembershipOfferPage') {
Get.offAllNamed(AppRoutes.home);
} else {
... ...
... ... @@ -18,4 +18,9 @@ abstract final class AppConst {
static const String appKeyRongCloudDev = 'k51hidwqkz8bb';
static const String obsSceneAvatar = 'avatar';
static const String userTerms =
'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE.html';
static const String privacyPolicy =
'https://cdn.doublefeel.cn/doublefeel/protocol/DoubleFeel%E9%9A%90%E7%A7%81%E5%8D%8F%E8%AE%AE.html';
}
... ...
... ... @@ -6,4 +6,5 @@ abstract final class StorageConst {
static const String appSettingsPrefsName = 'app_settings';
static const String termsAgreedKey = 'terms_agreed';
static const String lastLoginMethodKey = 'last_login_method';
}
... ...
import 'package:get/get.dart';
import '../constants/network_const.dart';
import '../util/app_toast.dart';
import '../logging/app_logger.dart';
import '../services/user_state_service.dart';
import 'app_error.dart';
... ... @@ -53,6 +54,6 @@ class AppErrorHandler {
if (message == null || message.isEmpty) {
return;
}
AppLogger.i('Toast (placeholder): $message');
AppToast.show(message);
}
}
... ...
import '../../error/http_error_handling_policy.dart';
import '../../result/app_result.dart';
import '../../result/safe_call.dart';
import '../../../data/models/enums/app_enums.dart';
... ... @@ -18,30 +19,44 @@ class HealthApi {
ApiPaths.huaweiAuth,
data: HealthAuthRequest(code: code).toJson(),
);
return HealthAuthResponse.fromJson(response.data as Map<String, dynamic>);
return HealthAuthResponse.fromJson(
response.data as Map<String, dynamic>);
},
);
}
Future<AppResult<HealthAuthResponse>> checkServerHealthAuth() {
Future<AppResult<HealthAuthResponse>> checkServerHealthAuth({
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.huaweiAuth);
return HealthAuthResponse.fromJson(response.data as Map<String, dynamic>);
return HealthAuthResponse.fromJson(
response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
Future<AppResult<LatestHrvData>> getLatestHrvData() {
Future<AppResult<LatestHrvData>> getLatestHrvData({
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.healthLatestHrv);
return LatestHrvData.fromJson(response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
Future<AppResult<TodayStatusData>> getTodayData({required bool isOther}) {
Future<AppResult<TodayStatusData>> getTodayData({
required bool isOther,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(
... ... @@ -50,20 +65,29 @@ class HealthApi {
);
return TodayStatusData.fromJson(response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
Future<AppResult<PkCurrentMonthData>> getCurrentMonthPkData() {
Future<AppResult<PkCurrentMonthData>> getCurrentMonthPkData({
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.healthPkInfo);
return PkCurrentMonthData.fromJson(response.data as Map<String, dynamic>);
return PkCurrentMonthData.fromJson(
response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
Future<AppResult<HealthDataLatestUploadRecordList>>
getCommonHealthDataLatestUploadRecordList() {
getCommonHealthDataLatestUploadRecordList({
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.healthUploadCommon);
... ... @@ -71,6 +95,7 @@ class HealthApi {
response.data as Map<String, dynamic>,
);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
... ... @@ -88,7 +113,10 @@ class HealthApi {
}
Future<AppResult<HealthDataLatestUploadRecord>>
getSleepStateHealthDataLatestUploadRecord() {
getSleepStateHealthDataLatestUploadRecord({
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.healthUploadSleep);
... ... @@ -96,6 +124,7 @@ class HealthApi {
response.data as Map<String, dynamic>,
);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
... ... @@ -116,6 +145,8 @@ class HealthApi {
required bool isOther,
required int dateRangeType,
required int startDate,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
... ... @@ -127,8 +158,10 @@ class HealthApi {
'start_date': startDate,
},
);
return SleepStatisticsData.fromJson(response.data as Map<String, dynamic>);
return SleepStatisticsData.fromJson(
response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
... ... @@ -136,6 +169,8 @@ class HealthApi {
required bool isOther,
required int dateRangeType,
required int startDate,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
... ... @@ -151,6 +186,7 @@ class HealthApi {
response.data as Map<String, dynamic>,
);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
... ... @@ -158,6 +194,8 @@ class HealthApi {
required bool isOther,
required int dateRangeType,
required int startDate,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
... ... @@ -169,8 +207,10 @@ class HealthApi {
'start_date': startDate,
},
);
return HrvStatisticsData.fromJson(response.data as Map<String, dynamic>);
return HrvStatisticsData.fromJson(
response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
... ... @@ -187,6 +227,8 @@ class HealthApi {
Future<AppResult<PulseTypeResponse>> getLatestPulseTypeResult({
required bool isOther,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
... ... @@ -194,8 +236,10 @@ class HealthApi {
ApiPaths.healthPulseLatest,
queryParameters: {'is_other': isOther ? 1 : 0},
);
return PulseTypeResponse.fromJson(response.data as Map<String, dynamic>);
return PulseTypeResponse.fromJson(
response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
}
... ...
import '../../constants/app_const.dart';
import '../../error/http_error_handling_policy.dart';
import '../../result/app_result.dart';
import '../../result/safe_call.dart';
import '../../../data/models/user/user_models.dart';
... ... @@ -79,6 +80,8 @@ class UserApi {
Future<AppResult<UserInfoResponse>> getUserInfo({
String? bindCode,
String? accessToken,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
... ... @@ -91,11 +94,14 @@ class UserApi {
);
return UserInfoResponse.fromJson(response.data as Map<String, dynamic>);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
Future<AppResult<BoundUserInfoResponse>> getPartnerUserInfo({
String? accessToken,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
... ... @@ -107,6 +113,7 @@ class UserApi {
response.data as Map<String, dynamic>,
);
},
errorHandlingPolicy: errorHandlingPolicy,
);
}
... ...
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors_extension.dart';
/// Class managing the Application ThemeData for both Light and Dark themes.
/// Automatically hooks up our custom Figma colors system as a ThemeExtension.
class AppTheme {
AppTheme._();
static const systemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
);
/// The standard light theme configuration.
static ThemeData get lightTheme {
final colors = AppColorsExtension.light();
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primaryColor: colors.primary,
scaffoldBackgroundColor: colors.backgroundLight,
// Clean modern AppBar theme using Figma colors
appBarTheme: AppBarTheme(
backgroundColor: colors.backgroundLight,
... ... @@ -27,6 +36,7 @@ class AppTheme {
fontSize: 18,
fontWeight: FontWeight.w600,
),
systemOverlayStyle: systemUiOverlayStyle,
),
// Configure default ColorScheme using Figma specs
... ... @@ -55,7 +65,6 @@ class AppTheme {
brightness: Brightness.dark,
primaryColor: colors.primary,
scaffoldBackgroundColor: colors.backgroundLight,
appBarTheme: AppBarTheme(
backgroundColor: colors.backgroundLight,
elevation: 0,
... ... @@ -67,8 +76,8 @@ class AppTheme {
fontSize: 18,
fontWeight: FontWeight.w600,
),
systemOverlayStyle: systemUiOverlayStyle,
),
colorScheme: ColorScheme.dark(
primary: colors.primary,
secondary: colors.primary,
... ... @@ -78,7 +87,6 @@ class AppTheme {
onSurface: colors.textPrimary,
outline: colors.border,
),
extensions: [
colors,
],
... ... @@ -89,6 +97,7 @@ class AppTheme {
/// Helper extension to easily access custom Figma colors inside widgets
/// by using `context.colors.<semanticName>` instead of verbose lookups.
extension AppThemeContextExtension on BuildContext {
AppColorsExtension get colors => Theme.of(this).extension<AppColorsExtension>()!;
AppColorsExtension get colors =>
Theme.of(this).extension<AppColorsExtension>()!;
ThemeData get theme => Theme.of(this);
}
... ...
import 'package:fluttertoast/fluttertoast.dart';
/// App-wide short toast. Prefer this over calling [Fluttertoast] directly.
abstract final class AppToast {
AppToast._();
static void show(
String message, {
ToastGravity gravity = ToastGravity.CENTER,
}) {
if (message.isEmpty) return;
Fluttertoast.showToast(msg: message, gravity: gravity);
}
}
... ...
... ... @@ -39,4 +39,11 @@ class LocalStorage {
Future<void> setTermsAgreed(bool value) async {
await sharedPreferences.setBool(StorageConst.termsAgreedKey, value);
}
String get lastLoginMethod =>
sharedPreferences.getString(StorageConst.lastLoginMethodKey) ?? '';
Future<void> setLastLoginMethod(String value) async {
await sharedPreferences.setString(StorageConst.lastLoginMethodKey, value);
}
}
... ...
import 'package:shared_preferences/shared_preferences.dart';
/// 账号级持久化存储。
///
/// 与 [UserPreferencesStorage] 的区别:
/// - [UserPreferencesStorage] 存储会话数据,退登时 clear() 会全部清除。
/// - [UserAccountStorage] 存储账号元数据,退登不清除,按 userId 隔离。
///
/// 适合存储:新手引导进度、账号历史记录等跨会话数据。
class UserAccountStorage {
UserAccountStorage(this._prefs);
final SharedPreferences _prefs;
// ─── Keys ──────────────────────────────────────────────────────────────────
/// Onboarding 阶段 key,按 userId 隔离
static String _onboardingKey(int userId) =>
'account_onboarding_stage_$userId';
/// Onboarding 已全部完成的哨兵值
static const int _kOnboardingCompleted = -1;
// ─── Onboarding 阶段 ───────────────────────────────────────────────────────
//
// 存储规则:
// null(key 不存在) → 从未开始
// 0 ~ N(页码) → 进行中,记录上次停留的页码
// -1(kCompleted) → 已全部完成
//
// 状态机:
// null ──[进入引导]──▶ 0 ──[翻页]──▶ 1 ... N ──[完成]──▶ -1
// ↑ │
// └───────────────────[resetOnboarding]───────────────────┘
/// 是否已完成全部引导流程。
bool hasCompletedOnboarding(int userId) =>
_prefs.getInt(_onboardingKey(userId)) == _kOnboardingCompleted;
/// 是否已开始过引导(包括进行中和已完成)。
bool hasStartedOnboarding(int userId) =>
_prefs.getInt(_onboardingKey(userId)) != null;
/// 中途退出时上次停留的页码;null 表示从未开始或已完成。
int? onboardingResumeStage(int userId) {
final v = _prefs.getInt(_onboardingKey(userId));
if (v == null || v == _kOnboardingCompleted) return null;
return v;
}
/// 每次翻页时调用,保存当前页码进度。
Future<void> saveOnboardingStage(int userId, int pageIndex) =>
_prefs.setInt(_onboardingKey(userId), pageIndex);
/// 引导全部完成时调用。
Future<void> markOnboardingCompleted(int userId) =>
_prefs.setInt(_onboardingKey(userId), _kOnboardingCompleted);
}
... ...