Commit 04eefd5a3b7a3eb5bf2a98224825e3e515de4aec

Authored by 刘宏哲
1 parent 6bb68ed8

feat(app): add friends module

Showing 46 changed files with 2288 additions and 818 deletions

Too many changes to show.

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

@@ -8,6 +8,7 @@ import com.doublefeel.app.pigeon.platform.AppleProductInfo @@ -8,6 +8,7 @@ import com.doublefeel.app.pigeon.platform.AppleProductInfo
8 import com.doublefeel.app.pigeon.platform.AppleProductPaymentResult 8 import com.doublefeel.app.pigeon.platform.AppleProductPaymentResult
9 import com.doublefeel.app.pigeon.platform.AppleSignInModel 9 import com.doublefeel.app.pigeon.platform.AppleSignInModel
10 import com.doublefeel.app.pigeon.platform.PlatformHostApi 10 import com.doublefeel.app.pigeon.platform.PlatformHostApi
  11 +import com.doublefeel.app.pigeon.platform.WatchAppOtherInfo
11 import kotlin.math.max 12 import kotlin.math.max
12 import kotlin.math.min 13 import kotlin.math.min
13 14
@@ -84,4 +85,12 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl @@ -84,4 +85,12 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl
84 85
85 override fun performRestore(callback: (Result<Boolean>) -> Unit) { 86 override fun performRestore(callback: (Result<Boolean>) -> Unit) {
86 } 87 }
  88 +
  89 + override fun updateWatchOtherUserInfo(otherInfo: WatchAppOtherInfo?) {
  90 +
  91 + }
  92 +
  93 + override fun refreshWatchSurface() {
  94 +
  95 + }
87 } 96 }
@@ -51,7 +51,7 @@ class WearEngineHostApiImpl( @@ -51,7 +51,7 @@ class WearEngineHostApiImpl(
51 return sendTextMessage(jsonPayload) 51 return sendTextMessage(jsonPayload)
52 } 52 }
53 53
54 - override fun pickImageAndRemoveBackground(): String? {  
55 - return null 54 + override fun removeBackground(originImagePath: String, callback: (Result<String?>) -> Unit) {
  55 +
56 } 56 }
57 } 57 }
  1 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
3 import '../../report_common/controllers/report_period_logic.dart'; 4 import '../../report_common/controllers/report_period_logic.dart';
@@ -7,22 +8,33 @@ import '../data/activity_burn_report_repository.dart'; @@ -7,22 +8,33 @@ import '../data/activity_burn_report_repository.dart';
7 import '../models/activity_burn_report_models.dart'; 8 import '../models/activity_burn_report_models.dart';
8 9
9 class ActivityBurnReportLogic extends ReportPeriodLogic { 10 class ActivityBurnReportLogic extends ReportPeriodLogic {
10 - ActivityBurnReportLogic({int? initialTargetUserId}) { 11 + ActivityBurnReportLogic({
  12 + int? initialTargetUserId,
  13 + ActivityBurnReportRepository? repository,
  14 + }) : repository = repository ??
  15 + ActivityBurnReportRepositoryImpl(
  16 + dataSource:
  17 + ApiActivityBurnReportDataSource(Get.find<HealthApi>()),
  18 + ) {
11 targetUserId.value = initialTargetUserId; 19 targetUserId.value = initialTargetUserId;
12 } 20 }
13 21
14 final targetUserId = RxnInt(); 22 final targetUserId = RxnInt();
15 23
16 - final ActivityBurnReportRepository repository =  
17 - const ActivityBurnReportRepositoryImpl(  
18 - dataSource: MockActivityBurnReportDataSource(),  
19 - ); 24 + final ActivityBurnReportRepository repository;
20 25
21 final report = Rxn<ActivityBurnReport>(); 26 final report = Rxn<ActivityBurnReport>();
22 final weeklyReport = Rxn<WeeklyActivityBurnReport>(); 27 final weeklyReport = Rxn<WeeklyActivityBurnReport>();
23 final monthlyReport = Rxn<MonthlyActivityBurnReport>(); 28 final monthlyReport = Rxn<MonthlyActivityBurnReport>();
24 29
25 @override 30 @override
  31 + List<ReportPeriod> get supportedPeriods => const [
  32 + ReportPeriod.day,
  33 + ReportPeriod.week,
  34 + ReportPeriod.month,
  35 + ];
  36 +
  37 + @override
26 Future<void> loadReport() async { 38 Future<void> loadReport() async {
27 isLoading.value = true; 39 isLoading.value = true;
28 try { 40 try {
  1 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  3 +import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart';
  4 +
1 import '../models/activity_burn_report_models.dart'; 5 import '../models/activity_burn_report_models.dart';
2 6
3 abstract class ActivityBurnReportDataSource { 7 abstract class ActivityBurnReportDataSource {
@@ -17,6 +21,224 @@ abstract class ActivityBurnReportDataSource { @@ -17,6 +21,224 @@ abstract class ActivityBurnReportDataSource {
17 }); 21 });
18 } 22 }
19 23
  24 +class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
  25 + const ApiActivityBurnReportDataSource(this._healthApi);
  26 +
  27 + static const _weekDateRangeType = 0;
  28 + static const _monthDateRangeType = 1;
  29 + static const _dayDateRangeType = 3;
  30 +
  31 + final HealthApi _healthApi;
  32 +
  33 + @override
  34 + Future<ActivityBurnReport> fetchDailyReport(
  35 + DateTime date, {
  36 + int? targetUserId,
  37 + }) async {
  38 + final day = _normalizeDate(date);
  39 + final result = await _healthApi.getActivityBurnStatistics(
  40 + _dayDateRangeType,
  41 + _dateKey(day),
  42 + queryUserId: targetUserId,
  43 + );
  44 + return switch (result) {
  45 + AppSuccess(:final data) => _dailyReport(day, data),
  46 + AppFailure() => ActivityBurnReport.empty(day),
  47 + };
  48 + }
  49 +
  50 + @override
  51 + Future<WeeklyActivityBurnReport> fetchWeeklyReport(
  52 + DateTime weekStart, {
  53 + int? targetUserId,
  54 + }) async {
  55 + final start = _normalizeDate(weekStart);
  56 + final result = await _healthApi.getActivityBurnStatistics(
  57 + _weekDateRangeType,
  58 + _dateKey(start),
  59 + queryUserId: targetUserId,
  60 + );
  61 + return switch (result) {
  62 + AppSuccess(:final data) => WeeklyActivityBurnReport(
  63 + weekStart: start,
  64 + weekEnd: start.add(const Duration(days: 6)),
  65 + days: _periodReports(start, 7, data),
  66 + totalActiveEnergyOverride: data.totalMove?.round(),
  67 + totalExerciseMinutesOverride: data.totalExercise?.round(),
  68 + totalStandHoursOverride: data.totalStand?.round(),
  69 + averageDailyActiveEnergyOverride: data.avgMove?.round(),
  70 + activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
  71 + ),
  72 + AppFailure() => WeeklyActivityBurnReport.empty(start),
  73 + };
  74 + }
  75 +
  76 + @override
  77 + Future<MonthlyActivityBurnReport> fetchMonthlyReport(
  78 + DateTime monthStart, {
  79 + int? targetUserId,
  80 + }) async {
  81 + final start = DateTime(monthStart.year, monthStart.month);
  82 + final end = DateTime(start.year, start.month + 1, 0);
  83 + final result = await _healthApi.getActivityBurnStatistics(
  84 + _monthDateRangeType,
  85 + _dateKey(start),
  86 + queryUserId: targetUserId,
  87 + );
  88 + return switch (result) {
  89 + AppSuccess(:final data) => MonthlyActivityBurnReport(
  90 + monthStart: start,
  91 + monthEnd: end,
  92 + days: _periodReports(start, end.day, data),
  93 + totalActiveEnergyOverride: data.totalMove?.round(),
  94 + totalExerciseMinutesOverride: data.totalExercise?.round(),
  95 + totalStandHoursOverride: data.totalStand?.round(),
  96 + averageDailyActiveEnergyOverride: data.avgMove?.round(),
  97 + activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
  98 + ),
  99 + AppFailure() => MonthlyActivityBurnReport.empty(start),
  100 + };
  101 + }
  102 +
  103 + ActivityBurnReport _dailyReport(
  104 + DateTime date,
  105 + ActivityBurnStatisticsDataV2 data,
  106 + ) {
  107 + final target = data.activityTargetInfo;
  108 + final overallValues = _dailyOverallValues(date, data.overallList);
  109 + final points = <ActivityBurnHeartRatePoint>[
  110 + for (final item in data.hrList ?? const <HrList>[])
  111 + if (_parseDateTime(item.dataTime) case final time?)
  112 + if (item.value case final bpm?)
  113 + if (bpm > 0 &&
  114 + !time.isBefore(date) &&
  115 + time.isBefore(date.add(const Duration(days: 1))))
  116 + ActivityBurnHeartRatePoint(time: time, bpm: bpm.toDouble()),
  117 + ]..sort((a, b) => a.time.compareTo(b.time));
  118 + final sleepTimes = <DateTime>[
  119 + for (final item in data.sleepTimeList ?? const <SleepTimeList>[])
  120 + if (_parseDateTime(item.fromTime, fallbackDate: date) case final from?)
  121 + from,
  122 + for (final item in data.sleepTimeList ?? const <SleepTimeList>[])
  123 + if (_parseDateTime(item.toTime, fallbackDate: date) case final to?) to,
  124 + ]..sort();
  125 +
  126 + return ActivityBurnReport(
  127 + date: date,
  128 + activeEnergy:
  129 + _metric(overallValues?.move ?? data.totalMove, target?.move),
  130 + exerciseMinutes: _metric(
  131 + overallValues?.exercise ?? data.totalExercise,
  132 + target?.exercise,
  133 + ),
  134 + standHours: _metric(
  135 + overallValues?.stand ?? data.totalStand,
  136 + target?.stand,
  137 + ),
  138 + heartRate: ActivityBurnHeartRateSummary(
  139 + startTime: date,
  140 + endTime: points.isEmpty ? null : points.last.time,
  141 + sleepStartTime: sleepTimes.isEmpty ? null : sleepTimes.first,
  142 + sleepEndTime: sleepTimes.isEmpty ? null : sleepTimes.last,
  143 + points: points,
  144 + ),
  145 + );
  146 + }
  147 +
  148 + Value? _dailyOverallValues(
  149 + DateTime date,
  150 + List<OverallList>? overallList,
  151 + ) {
  152 + final items = overallList ?? const <OverallList>[];
  153 + for (final item in items) {
  154 + final time = _parseDateTime(item.timeKey);
  155 + if (time != null && _normalizeDate(time) == date) return item.value;
  156 + }
  157 + if (items.length == 1) return items.first.value;
  158 + return null;
  159 + }
  160 +
  161 + List<ActivityBurnReport> _periodReports(
  162 + DateTime start,
  163 + int dayCount,
  164 + ActivityBurnStatisticsDataV2 data,
  165 + ) {
  166 + final target = data.activityTargetInfo;
  167 + final valuesByDate = <DateTime, Value>{
  168 + for (final item in data.overallList ?? const <OverallList>[])
  169 + if (_parseDateTime(item.timeKey) case final time?)
  170 + if (item.value case final value?) _normalizeDate(time): value,
  171 + };
  172 + final moveByDate = <DateTime, num>{
  173 + for (final item in data.moveTrendList ?? const <MoveTrendList>[])
  174 + if (_parseDateTime(item.timeKey) case final time?)
  175 + if (item.value case final value?) _normalizeDate(time): value,
  176 + };
  177 +
  178 + return [
  179 + for (var i = 0; i < dayCount; i++)
  180 + _periodDay(
  181 + start.add(Duration(days: i)),
  182 + valuesByDate,
  183 + moveByDate,
  184 + target,
  185 + ),
  186 + ];
  187 + }
  188 +
  189 + ActivityBurnReport _periodDay(
  190 + DateTime date,
  191 + Map<DateTime, Value> valuesByDate,
  192 + Map<DateTime, num> moveByDate,
  193 + ActivityTargetInfo? target,
  194 + ) {
  195 + final values = valuesByDate[date];
  196 + final move = values?.move ?? moveByDate[date];
  197 + if (values == null && move == null) return ActivityBurnReport.empty(date);
  198 + return ActivityBurnReport(
  199 + date: date,
  200 + activeEnergy: _metric(move, target?.move),
  201 + exerciseMinutes: _metric(values?.exercise, target?.exercise),
  202 + standHours: _metric(values?.stand, target?.stand),
  203 + );
  204 + }
  205 +
  206 + ActivityBurnMetric? _metric(num? value, num? goal) {
  207 + if (value == null) return null;
  208 + return ActivityBurnMetric(value: value.round(), goal: goal?.round() ?? 0);
  209 + }
  210 +
  211 + DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) {
  212 + if (value is num) {
  213 + if (value >= 1000000000) {
  214 + final milliseconds =
  215 + value >= 1000000000000 ? value.toInt() : (value * 1000).toInt();
  216 + return DateTime.fromMillisecondsSinceEpoch(milliseconds);
  217 + }
  218 + if (fallbackDate != null && value >= 0 && value < 86400) {
  219 + return fallbackDate.add(Duration(seconds: value.round()));
  220 + }
  221 + }
  222 + final raw = value?.toString();
  223 + if (raw == null || raw.isEmpty) return null;
  224 + final parsed = DateTime.tryParse(raw);
  225 + if (parsed != null) return parsed;
  226 + final digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
  227 + if (digits.length < 8) return null;
  228 + final year = int.tryParse(digits.substring(0, 4));
  229 + final month = int.tryParse(digits.substring(4, 6));
  230 + final day = int.tryParse(digits.substring(6, 8));
  231 + if (year == null || month == null || day == null) return null;
  232 + return DateTime(year, month, day);
  233 + }
  234 +
  235 + DateTime _normalizeDate(DateTime date) =>
  236 + DateTime(date.year, date.month, date.day);
  237 +
  238 + int _dateKey(DateTime date) =>
  239 + date.year * 10000 + date.month * 100 + date.day;
  240 +}
  241 +
20 class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource { 242 class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
21 const MockActivityBurnReportDataSource(); 243 const MockActivityBurnReportDataSource();
22 244
@@ -74,37 +74,64 @@ class WeeklyActivityBurnReport { @@ -74,37 +74,64 @@ class WeeklyActivityBurnReport {
74 required this.weekStart, 74 required this.weekStart,
75 required this.weekEnd, 75 required this.weekEnd,
76 required this.days, 76 required this.days,
  77 + this.totalActiveEnergyOverride,
  78 + this.totalExerciseMinutesOverride,
  79 + this.totalStandHoursOverride,
  80 + this.averageDailyActiveEnergyOverride,
  81 + this.activeEnergyGoalOverride,
77 }); 82 });
78 83
79 final DateTime weekStart; 84 final DateTime weekStart;
80 final DateTime weekEnd; 85 final DateTime weekEnd;
81 final List<ActivityBurnReport> days; 86 final List<ActivityBurnReport> days;
  87 + final int? totalActiveEnergyOverride;
  88 + final int? totalExerciseMinutesOverride;
  89 + final int? totalStandHoursOverride;
  90 + final int? averageDailyActiveEnergyOverride;
  91 + final int? activeEnergyGoalOverride;
82 92
83 List<ActivityBurnReport> get daysWithData => 93 List<ActivityBurnReport> get daysWithData =>
84 days.where((report) => report.hasData).toList(); 94 days.where((report) => report.hasData).toList();
85 95
86 - bool get hasData => daysWithData.isNotEmpty;  
87 -  
88 - int get totalActiveEnergy => daysWithData.fold( 96 + bool get hasData =>
  97 + daysWithData.isNotEmpty ||
  98 + totalActiveEnergyOverride != null ||
  99 + totalExerciseMinutesOverride != null ||
  100 + totalStandHoursOverride != null;
  101 +
  102 + int get totalActiveEnergy =>
  103 + totalActiveEnergyOverride ??
  104 + daysWithData.fold(
89 0, 105 0,
90 (sum, report) => sum + (report.activeEnergy?.value ?? 0), 106 (sum, report) => sum + (report.activeEnergy?.value ?? 0),
91 ); 107 );
92 108
93 - int get totalExerciseMinutes => daysWithData.fold( 109 + int get totalExerciseMinutes =>
  110 + totalExerciseMinutesOverride ??
  111 + daysWithData.fold(
94 0, 112 0,
95 (sum, report) => sum + (report.exerciseMinutes?.value ?? 0), 113 (sum, report) => sum + (report.exerciseMinutes?.value ?? 0),
96 ); 114 );
97 115
98 - int get totalStandHours => daysWithData.fold( 116 + int get totalStandHours =>
  117 + totalStandHoursOverride ??
  118 + daysWithData.fold(
99 0, 119 0,
100 (sum, report) => sum + (report.standHours?.value ?? 0), 120 (sum, report) => sum + (report.standHours?.value ?? 0),
101 ); 121 );
102 122
103 int get averageDailyActiveEnergy { 123 int get averageDailyActiveEnergy {
  124 + if (averageDailyActiveEnergyOverride case final value?) return value;
104 if (daysWithData.isEmpty) return 0; 125 if (daysWithData.isEmpty) return 0;
105 return (totalActiveEnergy / daysWithData.length).round(); 126 return (totalActiveEnergy / daysWithData.length).round();
106 } 127 }
107 128
  129 + int get activeEnergyGoal =>
  130 + activeEnergyGoalOverride ??
  131 + days
  132 + .map((report) => report.activeEnergy?.goal ?? 0)
  133 + .firstWhere((goal) => goal > 0, orElse: () => 0);
  134 +
108 int get perfectRingDays => daysWithData 135 int get perfectRingDays => daysWithData
109 .where((report) => 136 .where((report) =>
110 (report.activeEnergy?.progress ?? 0) >= 1 && 137 (report.activeEnergy?.progress ?? 0) >= 1 &&
@@ -141,37 +168,64 @@ class MonthlyActivityBurnReport { @@ -141,37 +168,64 @@ class MonthlyActivityBurnReport {
141 required this.monthStart, 168 required this.monthStart,
142 required this.monthEnd, 169 required this.monthEnd,
143 required this.days, 170 required this.days,
  171 + this.totalActiveEnergyOverride,
  172 + this.totalExerciseMinutesOverride,
  173 + this.totalStandHoursOverride,
  174 + this.averageDailyActiveEnergyOverride,
  175 + this.activeEnergyGoalOverride,
144 }); 176 });
145 177
146 final DateTime monthStart; 178 final DateTime monthStart;
147 final DateTime monthEnd; 179 final DateTime monthEnd;
148 final List<ActivityBurnReport> days; 180 final List<ActivityBurnReport> days;
  181 + final int? totalActiveEnergyOverride;
  182 + final int? totalExerciseMinutesOverride;
  183 + final int? totalStandHoursOverride;
  184 + final int? averageDailyActiveEnergyOverride;
  185 + final int? activeEnergyGoalOverride;
149 186
150 List<ActivityBurnReport> get daysWithData => 187 List<ActivityBurnReport> get daysWithData =>
151 days.where((report) => report.hasData).toList(); 188 days.where((report) => report.hasData).toList();
152 189
153 - bool get hasData => daysWithData.isNotEmpty;  
154 -  
155 - int get totalActiveEnergy => daysWithData.fold( 190 + bool get hasData =>
  191 + daysWithData.isNotEmpty ||
  192 + totalActiveEnergyOverride != null ||
  193 + totalExerciseMinutesOverride != null ||
  194 + totalStandHoursOverride != null;
  195 +
  196 + int get totalActiveEnergy =>
  197 + totalActiveEnergyOverride ??
  198 + daysWithData.fold(
156 0, 199 0,
157 (sum, report) => sum + (report.activeEnergy?.value ?? 0), 200 (sum, report) => sum + (report.activeEnergy?.value ?? 0),
158 ); 201 );
159 202
160 - int get totalExerciseMinutes => daysWithData.fold( 203 + int get totalExerciseMinutes =>
  204 + totalExerciseMinutesOverride ??
  205 + daysWithData.fold(
161 0, 206 0,
162 (sum, report) => sum + (report.exerciseMinutes?.value ?? 0), 207 (sum, report) => sum + (report.exerciseMinutes?.value ?? 0),
163 ); 208 );
164 209
165 - int get totalStandHours => daysWithData.fold( 210 + int get totalStandHours =>
  211 + totalStandHoursOverride ??
  212 + daysWithData.fold(
166 0, 213 0,
167 (sum, report) => sum + (report.standHours?.value ?? 0), 214 (sum, report) => sum + (report.standHours?.value ?? 0),
168 ); 215 );
169 216
170 int get averageDailyActiveEnergy { 217 int get averageDailyActiveEnergy {
  218 + if (averageDailyActiveEnergyOverride case final value?) return value;
171 if (daysWithData.isEmpty) return 0; 219 if (daysWithData.isEmpty) return 0;
172 return (totalActiveEnergy / daysWithData.length).round(); 220 return (totalActiveEnergy / daysWithData.length).round();
173 } 221 }
174 222
  223 + int get activeEnergyGoal =>
  224 + activeEnergyGoalOverride ??
  225 + days
  226 + .map((report) => report.activeEnergy?.goal ?? 0)
  227 + .firstWhere((goal) => goal > 0, orElse: () => 0);
  228 +
175 int get perfectRingDays => daysWithData 229 int get perfectRingDays => daysWithData
176 .where((report) => 230 .where((report) =>
177 (report.activeEnergy?.progress ?? 0) >= 1 && 231 (report.activeEnergy?.progress ?? 0) >= 1 &&
@@ -108,7 +108,7 @@ class _DailyReportContent extends StatelessWidget { @@ -108,7 +108,7 @@ class _DailyReportContent extends StatelessWidget {
108 Widget build(BuildContext context) { 108 Widget build(BuildContext context) {
109 return Column( 109 return Column(
110 children: [ 110 children: [
111 - ActivityBurnRing(report: report), 111 + ActivityBurnRing(report: report, animate: true),
112 const SizedBox(height: 24), 112 const SizedBox(height: 24),
113 ActivityBurnSummaryCard(report: report), 113 ActivityBurnSummaryCard(report: report),
114 const SizedBox(height: 12), 114 const SizedBox(height: 12),
@@ -132,6 +132,8 @@ class _DateSwitcher extends StatelessWidget { @@ -132,6 +132,8 @@ class _DateSwitcher extends StatelessWidget {
132 label: logic.dateLabel, 132 label: logic.dateLabel,
133 onPrevious: logic.previousDay, 133 onPrevious: logic.previousDay,
134 onNext: logic.nextDay, 134 onNext: logic.nextDay,
  135 + canPrevious: logic.canGoPrevious,
  136 + canNext: logic.canGoNext,
135 onTapLabel: () => _showDatePicker(context), 137 onTapLabel: () => _showDatePicker(context),
136 ), 138 ),
137 ); 139 );
@@ -2,11 +2,13 @@ import 'dart:math' as math; @@ -2,11 +2,13 @@ import 'dart:math' as math;
2 2
3 import 'package:fl_chart/fl_chart.dart'; 3 import 'package:fl_chart/fl_chart.dart';
4 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
  5 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 import 'package:intl/intl.dart'; 6 import 'package:intl/intl.dart';
6 7
7 import '../../../../r.dart'; 8 import '../../../../r.dart';
8 import '../../report_common/widgets/health_report_subject_scope.dart'; 9 import '../../report_common/widgets/health_report_subject_scope.dart';
9 import '../models/activity_burn_report_models.dart'; 10 import '../models/activity_burn_report_models.dart';
  11 +import 'activity_burn_trend_lines.dart';
10 12
11 class ActivityBurnHeartRateZoneCard extends StatelessWidget { 13 class ActivityBurnHeartRateZoneCard extends StatelessWidget {
12 const ActivityBurnHeartRateZoneCard({ 14 const ActivityBurnHeartRateZoneCard({
@@ -19,11 +21,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -19,11 +21,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
19 static const _h1 = Color(0xFF0F0F11); 21 static const _h1 = Color(0xFF0F0F11);
20 static const _h3 = Color(0xFFB0B0B6); 22 static const _h3 = Color(0xFFB0B0B6);
21 static const _brand = Color(0xFF845EEE); 23 static const _brand = Color(0xFF845EEE);
22 - static const _grid = Color(0xFFF3F3F3);  
23 static const _active = Color(0xFFFF5279); 24 static const _active = Color(0xFFFF5279);
24 static const _warm = Color(0xFFFF9A6E); 25 static const _warm = Color(0xFFFF9A6E);
25 static const _stand = Color(0xFF7B9BFB); 26 static const _stand = Color(0xFF7B9BFB);
26 static const _exercise = Color(0xFF3BD49D); 27 static const _exercise = Color(0xFF3BD49D);
  28 + static const _chartHeight = 197.0;
  29 + static const _topAxisLabelHeight = 14.0;
  30 + static const _bottomAxisLabelHeight = 20.0;
  31 + static const _axisHeight =
  32 + _chartHeight - _topAxisLabelHeight - _bottomAxisLabelHeight;
27 33
28 @override 34 @override
29 Widget build(BuildContext context) { 35 Widget build(BuildContext context) {
@@ -36,7 +42,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -36,7 +42,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
36 padding: const EdgeInsets.fromLTRB(20, 20, 14, 16), 42 padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
37 decoration: BoxDecoration( 43 decoration: BoxDecoration(
38 color: Colors.white, 44 color: Colors.white,
39 - borderRadius: BorderRadius.circular(12), 45 + borderRadius: BorderRadius.circular(16),
40 ), 46 ),
41 child: Column( 47 child: Column(
42 crossAxisAlignment: CrossAxisAlignment.start, 48 crossAxisAlignment: CrossAxisAlignment.start,
@@ -44,7 +50,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -44,7 +50,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
44 Text( 50 Text(
45 HealthReportSubjectScope.titleOf( 51 HealthReportSubjectScope.titleOf(
46 context, 52 context,
47 - summary.hasData ? '心率区间' : '实时心率', 53 + summary.hasData
  54 + ? context.l10n.activityHeartRateZone
  55 + : context.l10n.activityRealtimeHeartRate,
48 ), 56 ),
49 style: TextStyle( 57 style: TextStyle(
50 color: _h1, 58 color: _h1,
@@ -54,19 +62,31 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -54,19 +62,31 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
54 ), 62 ),
55 ), 63 ),
56 const SizedBox(height: 20), 64 const SizedBox(height: 20),
57 - Expanded( 65 + SizedBox(
  66 + height: _chartHeight,
58 child: LayoutBuilder( 67 child: LayoutBuilder(
59 builder: (context, constraints) { 68 builder: (context, constraints) {
60 return Stack( 69 return Stack(
61 alignment: Alignment.center, 70 alignment: Alignment.center,
62 children: [ 71 children: [
  72 + Positioned(
  73 + left: 0,
  74 + right: 6,
  75 + top: 0,
  76 + bottom: 20,
  77 + child: ActivityBurnHorizontalGridLines(
  78 + minY: axis.minY,
  79 + maxY: axis.chartMaxY,
  80 + values: axis.ticks,
  81 + ),
  82 + ),
63 Positioned.fill( 83 Positioned.fill(
64 - top: 18, 84 + top: 0,
65 child: LineChart(_chartData()), 85 child: LineChart(_chartData()),
66 ), 86 ),
67 if (sleepRange != null) 87 if (sleepRange != null)
68 Positioned( 88 Positioned(
69 - top: 4, 89 + top: 0,
70 left: _sleepIconLeft( 90 left: _sleepIconLeft(
71 sleepRange, 91 sleepRange,
72 axis.maxX, 92 axis.maxX,
@@ -79,9 +99,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -79,9 +99,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
79 ), 99 ),
80 ), 100 ),
81 if (!summary.hasData) 101 if (!summary.hasData)
82 - const Text(  
83 - '暂无本日数据',  
84 - style: TextStyle( 102 + Text(
  103 + context.l10n.reportNoDataToday,
  104 + style: const TextStyle(
85 color: Color(0xFFA1A0A5), 105 color: Color(0xFFA1A0A5),
86 fontSize: 12, 106 fontSize: 12,
87 fontWeight: FontWeight.w400, 107 fontWeight: FontWeight.w400,
@@ -121,16 +141,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -121,16 +141,9 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
121 minX: 0, 141 minX: 0,
122 maxX: axis.maxX, 142 maxX: axis.maxX,
123 minY: axis.minY, 143 minY: axis.minY,
124 - maxY: axis.maxY, 144 + maxY: axis.chartMaxY,
125 gridData: FlGridData( 145 gridData: FlGridData(
126 - show: true,  
127 - drawVerticalLine: false,  
128 - horizontalInterval: 20,  
129 - getDrawingHorizontalLine: (_) => const FlLine(  
130 - color: _grid,  
131 - strokeWidth: 1,  
132 - dashArray: [2, 2],  
133 - ), 146 + show: false,
134 ), 147 ),
135 borderData: FlBorderData(show: false), 148 borderData: FlBorderData(show: false),
136 lineTouchData: const LineTouchData(enabled: false), 149 lineTouchData: const LineTouchData(enabled: false),
@@ -141,21 +154,24 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -141,21 +154,24 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
141 sideTitles: SideTitles( 154 sideTitles: SideTitles(
142 showTitles: points.isNotEmpty, 155 showTitles: points.isNotEmpty,
143 reservedSize: 28, 156 reservedSize: 28,
144 - interval: 20, 157 + interval: 1,
145 getTitlesWidget: (value, meta) { 158 getTitlesWidget: (value, meta) {
146 if (!axis.shouldShowYLabel(value)) { 159 if (!axis.shouldShowYLabel(value)) {
147 return const SizedBox.shrink(); 160 return const SizedBox.shrink();
148 } 161 }
149 return Transform.translate( 162 return Transform.translate(
150 - offset: const Offset(0, -6),  
151 - child: Text(  
152 - axis.formatYLabel(value),  
153 - maxLines: 1,  
154 - softWrap: false,  
155 - style: const TextStyle(  
156 - color: _h3,  
157 - fontSize: 10,  
158 - fontWeight: FontWeight.w400, 163 + offset: const Offset(0, -8),
  164 + child: Padding(
  165 + padding: const EdgeInsets.only(left: 8),
  166 + child: Text(
  167 + axis.formatYLabel(value),
  168 + maxLines: 1,
  169 + softWrap: false,
  170 + style: const TextStyle(
  171 + color: _h3,
  172 + fontSize: 10,
  173 + fontWeight: FontWeight.w400,
  174 + ),
159 ), 175 ),
160 ), 176 ),
161 ); 177 );
@@ -256,7 +272,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -256,7 +272,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
256 if (points.length < 2) return const []; 272 if (points.length < 2) return const [];
257 273
258 final segments = <_HeartRateSegment>[]; 274 final segments = <_HeartRateSegment>[];
259 - final maxHr = _maxHeartRate(summary.userAge);  
260 Color? currentColor; 275 Color? currentColor;
261 var currentSpots = <FlSpot>[]; 276 var currentSpots = <FlSpot>[];
262 var previousPoint = points.first; 277 var previousPoint = points.first;
@@ -265,13 +280,12 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -265,13 +280,12 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
265 final splitSpots = _splitByZoneBoundaries( 280 final splitSpots = _splitByZoneBoundaries(
266 _spot(previousPoint, start), 281 _spot(previousPoint, start),
267 _spot(point, start), 282 _spot(point, start),
268 - maxHr,  
269 ); 283 );
270 284
271 for (var i = 0; i < splitSpots.length - 1; i++) { 285 for (var i = 0; i < splitSpots.length - 1; i++) {
272 final from = splitSpots[i]; 286 final from = splitSpots[i];
273 final to = splitSpots[i + 1]; 287 final to = splitSpots[i + 1];
274 - final color = _zoneColor(_zoneLevel((from.y + to.y) / 2, maxHr)); 288 + final color = _zoneColor(_zoneLevel((from.y + to.y) / 2));
275 289
276 if (currentColor == color && currentSpots.isNotEmpty) { 290 if (currentColor == color && currentSpots.isNotEmpty) {
277 currentSpots.add(to); 291 currentSpots.add(to);
@@ -312,7 +326,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -312,7 +326,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
312 List<FlSpot> _splitByZoneBoundaries( 326 List<FlSpot> _splitByZoneBoundaries(
313 FlSpot start, 327 FlSpot start,
314 FlSpot end, 328 FlSpot end,
315 - double maxHr,  
316 ) { 329 ) {
317 if (start.y == end.y) return [start, end]; 330 if (start.y == end.y) return [start, end];
318 331
@@ -320,7 +333,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -320,7 +333,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
320 final maxY = math.max(start.y, end.y); 333 final maxY = math.max(start.y, end.y);
321 final crossings = <FlSpot>[]; 334 final crossings = <FlSpot>[];
322 335
323 - for (final boundary in _zoneBoundaries(maxHr)) { 336 + for (final boundary in _zoneBoundaries) {
324 if (boundary <= minY || boundary >= maxY) continue; 337 if (boundary <= minY || boundary >= maxY) continue;
325 final t = (boundary - start.y) / (end.y - start.y); 338 final t = (boundary - start.y) / (end.y - start.y);
326 crossings.add( 339 crossings.add(
@@ -335,28 +348,14 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -335,28 +348,14 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
335 return [start, ...crossings, end]; 348 return [start, ...crossings, end];
336 } 349 }
337 350
338 - int _zoneLevel(double bpm, double maxHr) {  
339 - final zoneRatio = maxHr <= 0 ? 0 : bpm / maxHr;  
340 - if (bpm >= 160 || zoneRatio >= 0.9) return 3;  
341 - if (bpm >= 140 || zoneRatio >= 0.8) return 2;  
342 - if (zoneRatio >= 0.6) return 1; 351 + int _zoneLevel(double bpm) {
  352 + if (bpm >= 170) return 3;
  353 + if (bpm >= 151) return 2;
  354 + if (bpm >= 108) return 1;
343 return 0; 355 return 0;
344 } 356 }
345 357
346 - List<double> _zoneBoundaries(double maxHr) {  
347 - final boundaries = [  
348 - 160.0,  
349 - 140.0,  
350 - maxHr * 0.9,  
351 - maxHr * 0.8,  
352 - maxHr * 0.6,  
353 - ]..sort();  
354 - return [  
355 - for (var i = 0; i < boundaries.length; i++)  
356 - if (i == 0 || (boundaries[i] - boundaries[i - 1]).abs() > 0.001)  
357 - boundaries[i],  
358 - ];  
359 - } 358 + static const _zoneBoundaries = [108.0, 151.0, 170.0];
360 359
361 Color _zoneColor(int zone) { 360 Color _zoneColor(int zone) {
362 switch (zone) { 361 switch (zone) {
@@ -378,11 +377,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -378,11 +377,6 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
378 ); 377 );
379 } 378 }
380 379
381 - double _maxHeartRate(int? userAge) {  
382 - final age = userAge == null || userAge <= 0 ? 30 : userAge;  
383 - return 208 - 0.7 * age;  
384 - }  
385 -  
386 String? _bottomLabel(double value, DateTime start, double maxX) { 380 String? _bottomLabel(double value, DateTime start, double maxX) {
387 final rounded = value.round(); 381 final rounded = value.round();
388 if (rounded % 360 != 0 || rounded < 0 || rounded > maxX.round()) { 382 if (rounded % 360 != 0 || rounded < 0 || rounded > maxX.round()) {
@@ -407,12 +401,38 @@ class _HeartRateAxis { @@ -407,12 +401,38 @@ class _HeartRateAxis {
407 final double maxX; 401 final double maxX;
408 final double minY; 402 final double minY;
409 final double maxY; 403 final double maxY;
  404 + double get chartMaxY {
  405 + final range = maxY - minY;
  406 + if (range <= 0) return maxY;
  407 + return maxY +
  408 + range *
  409 + ActivityBurnHeartRateZoneCard._topAxisLabelHeight /
  410 + ActivityBurnHeartRateZoneCard._axisHeight;
  411 + }
  412 +
  413 + List<double> get ticks {
  414 + final values = <double>[minY];
  415 + for (var value = minY + 20; value < maxY; value += 20) {
  416 + values.add(value);
  417 + }
  418 + if ((values.last - maxY).abs() > 0.001) values.add(maxY);
  419 + return values;
  420 + }
410 421
411 factory _HeartRateAxis.fromSummary( 422 factory _HeartRateAxis.fromSummary(
412 ActivityBurnHeartRateSummary summary, 423 ActivityBurnHeartRateSummary summary,
413 DateTime start, 424 DateTime start,
414 ) { 425 ) {
415 - if (!summary.hasData) { 426 + final dayEnd = start.add(const Duration(days: 1));
  427 + final points = summary.points
  428 + .where(
  429 + (point) =>
  430 + point.bpm > 0 &&
  431 + !point.time.isBefore(start) &&
  432 + point.time.isBefore(dayEnd),
  433 + )
  434 + .toList();
  435 + if (points.isEmpty) {
416 return _HeartRateAxis( 436 return _HeartRateAxis(
417 endTime: start.add(const Duration(hours: 18)), 437 endTime: start.add(const Duration(hours: 18)),
418 maxX: 1080, 438 maxX: 1080,
@@ -421,30 +441,24 @@ class _HeartRateAxis { @@ -421,30 +441,24 @@ class _HeartRateAxis {
421 ); 441 );
422 } 442 }
423 443
424 - final values = summary.points.map((point) => point.bpm).toList();  
425 - final minBpm = values.reduce(math.min);  
426 - final maxBpm = values.reduce(math.max);  
427 - final coveredEnd = summary.endTime ?? summary.points.last.time; 444 + final coveredEnd = points.last.time;
428 final coveredMinutes = coveredEnd.difference(start).inMinutes; 445 final coveredMinutes = coveredEnd.difference(start).inMinutes;
429 final maxMinutes = _nextSixHourTick(coveredMinutes); 446 final maxMinutes = _nextSixHourTick(coveredMinutes);
430 -  
431 - var maxY = _roundUpToFiveOrTen(maxBpm);  
432 - if (maxY <= minBpm) {  
433 - maxY = minBpm + 20;  
434 - } 447 + final heartRates = points.map((point) => point.bpm);
  448 + final minY = heartRates.reduce(math.min);
  449 + var maxY = _roundUpToFiveOrTen(heartRates.reduce(math.max));
  450 + if (maxY <= minY) maxY = minY + 5;
435 451
436 return _HeartRateAxis( 452 return _HeartRateAxis(
437 endTime: start.add(Duration(minutes: maxMinutes)), 453 endTime: start.add(Duration(minutes: maxMinutes)),
438 maxX: maxMinutes.toDouble(), 454 maxX: maxMinutes.toDouble(),
439 - minY: minBpm, 455 + minY: minY,
440 maxY: maxY, 456 maxY: maxY,
441 ); 457 );
442 } 458 }
443 459
444 bool shouldShowYLabel(double value) { 460 bool shouldShowYLabel(double value) {
445 - if (value < minY || value > maxY) return false;  
446 - final offset = value - minY;  
447 - return (offset / 20 - (offset / 20).round()).abs() < 0.001; 461 + return ticks.any((tick) => (tick - value).abs() < 0.001);
448 } 462 }
449 463
450 String formatYLabel(double value) { 464 String formatYLabel(double value) {
@@ -456,12 +470,10 @@ class _HeartRateAxis { @@ -456,12 +470,10 @@ class _HeartRateAxis {
456 const tickMinutes = 360; 470 const tickMinutes = 360;
457 final clamped = coveredMinutes.clamp(0, 1440); 471 final clamped = coveredMinutes.clamp(0, 1440);
458 if (clamped == 0) return tickMinutes; 472 if (clamped == 0) return tickMinutes;
459 - return (clamped / tickMinutes).ceil().clamp(1, 4) * tickMinutes; 473 + return (clamped ~/ tickMinutes + 1).clamp(1, 4) * tickMinutes;
460 } 474 }
461 475
462 - static double _roundUpToFiveOrTen(double value) {  
463 - return (value / 5).ceil() * 5;  
464 - } 476 + static double _roundUpToFiveOrTen(double value) => (value / 5).ceil() * 5.0;
465 } 477 }
466 478
467 class _HeartRateSegment { 479 class _HeartRateSegment {
1 import 'package:fl_chart/fl_chart.dart'; 1 import 'package:fl_chart/fl_chart.dart';
2 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
3 -import 'package:intl/intl.dart'; 3 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 4
5 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 5 import '../../report_common/widgets/chart_selection_line_overlay.dart';
6 import '../../report_common/widgets/health_report_subject_scope.dart'; 6 import '../../report_common/widgets/health_report_subject_scope.dart';
  7 +import '../../report_common/utils/report_localization.dart';
7 import '../../report_common/widgets/report_month_calendar_grid.dart'; 8 import '../../report_common/widgets/report_month_calendar_grid.dart';
8 import '../models/activity_burn_report_models.dart'; 9 import '../models/activity_burn_report_models.dart';
9 import 'activity_burn_ring.dart'; 10 import 'activity_burn_ring.dart';
  11 +import 'activity_burn_trend_lines.dart';
10 12
11 class ActivityBurnMonthReportView extends StatelessWidget { 13 class ActivityBurnMonthReportView extends StatelessWidget {
12 const ActivityBurnMonthReportView({ 14 const ActivityBurnMonthReportView({
@@ -54,7 +56,8 @@ class _MonthlySummary extends StatelessWidget { @@ -54,7 +56,8 @@ class _MonthlySummary extends StatelessWidget {
54 crossAxisAlignment: CrossAxisAlignment.start, 56 crossAxisAlignment: CrossAxisAlignment.start,
55 children: [ 57 children: [
56 Text( 58 Text(
57 - HealthReportSubjectScope.titleOf(context, '活动总消耗'), 59 + HealthReportSubjectScope.titleOf(
  60 + context, context.l10n.activityTotalBurn),
58 style: const TextStyle( 61 style: const TextStyle(
59 color: ActivityBurnMonthReportView._active, 62 color: ActivityBurnMonthReportView._active,
60 fontSize: 12, 63 fontSize: 12,
@@ -76,11 +79,11 @@ class _MonthlySummary extends StatelessWidget { @@ -76,11 +79,11 @@ class _MonthlySummary extends StatelessWidget {
76 ), 79 ),
77 ), 80 ),
78 const SizedBox(width: 4), 81 const SizedBox(width: 4),
79 - const Padding(  
80 - padding: EdgeInsets.only(bottom: 5), 82 + Padding(
  83 + padding: const EdgeInsets.only(bottom: 5),
81 child: Text( 84 child: Text(
82 - '千卡',  
83 - style: TextStyle( 85 + context.l10n.reportUnitKcal,
  86 + style: const TextStyle(
84 color: ActivityBurnMonthReportView._h1, 87 color: ActivityBurnMonthReportView._h1,
85 fontSize: 12, 88 fontSize: 12,
86 fontWeight: FontWeight.w500, 89 fontWeight: FontWeight.w500,
@@ -90,9 +93,9 @@ class _MonthlySummary extends StatelessWidget { @@ -90,9 +93,9 @@ class _MonthlySummary extends StatelessWidget {
90 ], 93 ],
91 ) 94 )
92 else 95 else
93 - const Text(  
94 - '等待数据',  
95 - style: TextStyle( 96 + Text(
  97 + context.l10n.reportWaitingForData,
  98 + style: const TextStyle(
96 color: ActivityBurnMonthReportView._h1, 99 color: ActivityBurnMonthReportView._h1,
97 fontSize: 24, 100 fontSize: 24,
98 fontWeight: FontWeight.w600, 101 fontWeight: FontWeight.w600,
@@ -102,16 +105,18 @@ class _MonthlySummary extends StatelessWidget { @@ -102,16 +105,18 @@ class _MonthlySummary extends StatelessWidget {
102 Row( 105 Row(
103 children: [ 106 children: [
104 _SummaryMetric( 107 _SummaryMetric(
105 - title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'), 108 + title: HealthReportSubjectScope.titleOf(
  109 + context, context.l10n.activityExerciseTotalDuration),
106 value: hasData ? report.totalExerciseMinutes.toString() : '-', 110 value: hasData ? report.totalExerciseMinutes.toString() : '-',
107 - unit: '分钟', 111 + unit: context.l10n.reportUnitMinute,
108 color: ActivityBurnMonthReportView._exercise, 112 color: ActivityBurnMonthReportView._exercise,
109 ), 113 ),
110 const SizedBox(width: 34), 114 const SizedBox(width: 34),
111 _SummaryMetric( 115 _SummaryMetric(
112 - title: HealthReportSubjectScope.titleOf(context, '站立总时长'), 116 + title: HealthReportSubjectScope.titleOf(
  117 + context, context.l10n.activityStandTotalDuration),
113 value: hasData ? report.totalStandHours.toString() : '-', 118 value: hasData ? report.totalStandHours.toString() : '-',
114 - unit: '小时', 119 + unit: context.l10n.reportUnitHour,
115 color: ActivityBurnMonthReportView._stand, 120 color: ActivityBurnMonthReportView._stand,
116 ), 121 ),
117 ], 122 ],
@@ -199,7 +204,7 @@ class _MonthRingCard extends StatelessWidget { @@ -199,7 +204,7 @@ class _MonthRingCard extends StatelessWidget {
199 children: [ 204 children: [
200 _RingStat( 205 _RingStat(
201 color: ActivityBurnMonthReportView._active, 206 color: ActivityBurnMonthReportView._active,
202 - label: '完美合环', 207 + label: context.l10n.activityPerfectRings,
203 value: report.perfectRingDays, 208 value: report.perfectRingDays,
204 hasData: hasData, 209 hasData: hasData,
205 report: ActivityBurnReport( 210 report: ActivityBurnReport(
@@ -212,7 +217,7 @@ class _MonthRingCard extends StatelessWidget { @@ -212,7 +217,7 @@ class _MonthRingCard extends StatelessWidget {
212 const SizedBox(width: 34), 217 const SizedBox(width: 34),
213 _RingStat( 218 _RingStat(
214 color: ActivityBurnMonthReportView._active, 219 color: ActivityBurnMonthReportView._active,
215 - label: '合上活动圆环', 220 + label: context.l10n.activityCloseMoveRing,
216 value: report.activeRingDays, 221 value: report.activeRingDays,
217 hasData: hasData, 222 hasData: hasData,
218 report: ActivityBurnReport( 223 report: ActivityBurnReport(
@@ -227,7 +232,7 @@ class _MonthRingCard extends StatelessWidget { @@ -227,7 +232,7 @@ class _MonthRingCard extends StatelessWidget {
227 children: [ 232 children: [
228 _RingStat( 233 _RingStat(
229 color: ActivityBurnMonthReportView._exercise, 234 color: ActivityBurnMonthReportView._exercise,
230 - label: '合上锻炼圆环', 235 + label: context.l10n.activityCloseExerciseRing,
231 value: report.exerciseRingDays, 236 value: report.exerciseRingDays,
232 hasData: hasData, 237 hasData: hasData,
233 report: ActivityBurnReport( 238 report: ActivityBurnReport(
@@ -238,7 +243,7 @@ class _MonthRingCard extends StatelessWidget { @@ -238,7 +243,7 @@ class _MonthRingCard extends StatelessWidget {
238 const SizedBox(width: 34), 243 const SizedBox(width: 34),
239 _RingStat( 244 _RingStat(
240 color: ActivityBurnMonthReportView._stand, 245 color: ActivityBurnMonthReportView._stand,
241 - label: '合上站立圆环', 246 + label: context.l10n.activityCloseStandRing,
242 value: report.standRingDays, 247 value: report.standRingDays,
243 hasData: hasData, 248 hasData: hasData,
244 report: ActivityBurnReport( 249 report: ActivityBurnReport(
@@ -249,16 +254,11 @@ class _MonthRingCard extends StatelessWidget { @@ -249,16 +254,11 @@ class _MonthRingCard extends StatelessWidget {
249 ], 254 ],
250 ), 255 ),
251 const SizedBox(height: 22), 256 const SizedBox(height: 22),
252 - const Row( 257 + Row(
253 mainAxisAlignment: MainAxisAlignment.spaceBetween, 258 mainAxisAlignment: MainAxisAlignment.spaceBetween,
254 children: [ 259 children: [
255 - _WeekdayLabel('一'),  
256 - _WeekdayLabel('二'),  
257 - _WeekdayLabel('三'),  
258 - _WeekdayLabel('四'),  
259 - _WeekdayLabel('五'),  
260 - _WeekdayLabel('六'),  
261 - _WeekdayLabel('日'), 260 + for (var weekday = 1; weekday <= 7; weekday++)
  261 + _WeekdayLabel(reportWeekdayLabel(weekday)),
262 ], 262 ],
263 ), 263 ),
264 const SizedBox(height: 10), 264 const SizedBox(height: 10),
@@ -318,9 +318,9 @@ class _RingStat extends StatelessWidget { @@ -318,9 +318,9 @@ class _RingStat extends StatelessWidget {
318 fontWeight: FontWeight.w600, 318 fontWeight: FontWeight.w600,
319 ), 319 ),
320 ), 320 ),
321 - const TextSpan(  
322 - text: ' 天',  
323 - style: TextStyle( 321 + TextSpan(
  322 + text: ' ${context.l10n.reportUnitDay}',
  323 + style: const TextStyle(
324 color: ActivityBurnMonthReportView._h1, 324 color: ActivityBurnMonthReportView._h1,
325 fontSize: 14, 325 fontSize: 14,
326 fontWeight: FontWeight.w500, 326 fontWeight: FontWeight.w500,
@@ -407,25 +407,27 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -407,25 +407,27 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
407 @override 407 @override
408 Widget build(BuildContext context) { 408 Widget build(BuildContext context) {
409 final hasData = widget.report.hasData; 409 final hasData = widget.report.hasData;
  410 + final maxY = _maxY;
410 return Container( 411 return Container(
411 - height: 294,  
412 - padding: const EdgeInsets.fromLTRB(20, 18, 14, 14), 412 + height: 344,
  413 + padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
413 decoration: BoxDecoration( 414 decoration: BoxDecoration(
414 color: Colors.white, 415 color: Colors.white,
415 - borderRadius: BorderRadius.circular(12), 416 + borderRadius: BorderRadius.circular(16),
416 ), 417 ),
417 child: Column( 418 child: Column(
418 crossAxisAlignment: CrossAxisAlignment.start, 419 crossAxisAlignment: CrossAxisAlignment.start,
419 children: [ 420 children: [
420 Text( 421 Text(
421 - HealthReportSubjectScope.titleOf(context, '热量消耗趋势'), 422 + HealthReportSubjectScope.titleOf(
  423 + context, context.l10n.activityCalorieTrend),
422 style: const TextStyle( 424 style: const TextStyle(
423 color: ActivityBurnMonthReportView._h1, 425 color: ActivityBurnMonthReportView._h1,
424 fontSize: 16, 426 fontSize: 16,
425 fontWeight: FontWeight.w600, 427 fontWeight: FontWeight.w600,
426 ), 428 ),
427 ), 429 ),
428 - const SizedBox(height: 14), 430 + const SizedBox(height: 20),
429 Row( 431 Row(
430 crossAxisAlignment: CrossAxisAlignment.end, 432 crossAxisAlignment: CrossAxisAlignment.end,
431 children: [ 433 children: [
@@ -440,9 +442,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -440,9 +442,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
440 ), 442 ),
441 ), 443 ),
442 const SizedBox(width: 4), 444 const SizedBox(width: 4),
443 - const Text(  
444 - '千卡/平均每日',  
445 - style: TextStyle( 445 + Text(
  446 + context.l10n.activityKcalDailyAverage,
  447 + style: const TextStyle(
446 color: ActivityBurnMonthReportView._h1, 448 color: ActivityBurnMonthReportView._h1,
447 fontSize: 12, 449 fontSize: 12,
448 ), 450 ),
@@ -450,19 +452,56 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -450,19 +452,56 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
450 ], 452 ],
451 ), 453 ),
452 const SizedBox(height: 2), 454 const SizedBox(height: 2),
453 - Text(  
454 - hasData ? '比上月少34%' : '比上周-',  
455 - style: TextStyle(  
456 - color: ActivityBurnMonthReportView._h2,  
457 - fontSize: 12,  
458 - ), 455 + Row(
  456 + children: [
  457 + Text(
  458 + hasData
  459 + ? context.l10n.activityComparedLastMonth
  460 + : context.l10n.activityComparedUnavailable,
  461 + style: TextStyle(
  462 + color: ActivityBurnMonthReportView._h2,
  463 + fontSize: 12,
  464 + ),
  465 + ),
  466 + const Spacer(),
  467 + _TrendLegend(
  468 + color: const Color(0xFFFFC0CE),
  469 + label: context.l10n.sleepAverage,
  470 + ),
  471 + const SizedBox(width: 12),
  472 + _TrendLegend(
  473 + color: const Color(0xFF96E9CB),
  474 + label: context.l10n.sleepTarget,
  475 + dashed: true,
  476 + ),
  477 + ],
459 ), 478 ),
460 const SizedBox(height: 8), 479 const SizedBox(height: 8),
461 Expanded( 480 Expanded(
462 child: Stack( 481 child: Stack(
463 alignment: Alignment.center, 482 alignment: Alignment.center,
464 children: [ 483 children: [
465 - BarChart(_chartData()), 484 + Positioned(
  485 + left: 0,
  486 + right: 6,
  487 + top: 0,
  488 + bottom: 32,
  489 + child: ActivityBurnTrendLines(
  490 + maxY: maxY,
  491 + average: widget.report.averageDailyActiveEnergy.toDouble(),
  492 + target: widget.report.activeEnergyGoal.toDouble(),
  493 + ),
  494 + ),
  495 + BarChart(_chartData(maxY)),
  496 + const Positioned(
  497 + left: 0,
  498 + right: 6,
  499 + bottom: 24,
  500 + child: SizedBox(
  501 + height: 1,
  502 + child: ActivityBurnAxisDashedLine(),
  503 + ),
  504 + ),
466 if (_touchedOffset != null) 505 if (_touchedOffset != null)
467 Positioned.fill( 506 Positioned.fill(
468 child: ChartSelectionLineOverlay( 507 child: ChartSelectionLineOverlay(
@@ -473,9 +512,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -473,9 +512,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
473 ), 512 ),
474 ), 513 ),
475 if (!hasData) 514 if (!hasData)
476 - const Text(  
477 - '等待数据',  
478 - style: TextStyle( 515 + Text(
  516 + context.l10n.reportWaitingForData,
  517 + style: const TextStyle(
479 color: ActivityBurnMonthReportView._h3, 518 color: ActivityBurnMonthReportView._h3,
480 fontSize: 12, 519 fontSize: 12,
481 ), 520 ),
@@ -488,20 +527,25 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -488,20 +527,25 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
488 ); 527 );
489 } 528 }
490 529
491 - BarChartData _chartData() { 530 + double get _maxY {
  531 + final maxValue = widget.report.days
  532 + .map((day) => day.activeEnergy?.value ?? 0)
  533 + .fold<int>(0, (max, value) => value > max ? value : max);
  534 + final referenceMax = [
  535 + maxValue,
  536 + widget.report.averageDailyActiveEnergy,
  537 + widget.report.activeEnergyGoal,
  538 + ].reduce((max, value) => value > max ? value : max);
  539 + return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble();
  540 + }
  541 +
  542 + BarChartData _chartData(double maxY) {
492 final hasData = widget.report.hasData; 543 final hasData = widget.report.hasData;
493 return BarChartData( 544 return BarChartData(
494 minY: 0, 545 minY: 0,
495 - maxY: 400, 546 + maxY: maxY,
496 gridData: FlGridData( 547 gridData: FlGridData(
497 - show: true,  
498 - drawVerticalLine: false,  
499 - horizontalInterval: 100,  
500 - getDrawingHorizontalLine: (_) => const FlLine(  
501 - color: Color(0xFFF3F3F3),  
502 - strokeWidth: 1,  
503 - dashArray: [2, 2],  
504 - ), 548 + show: false,
505 ), 549 ),
506 borderData: FlBorderData(show: false), 550 borderData: FlBorderData(show: false),
507 barTouchData: BarTouchData( 551 barTouchData: BarTouchData(
@@ -537,7 +581,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -537,7 +581,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
537 getTooltipItem: (group, groupIndex, rod, rodIndex) { 581 getTooltipItem: (group, groupIndex, rod, rodIndex) {
538 final report = widget.report.days[groupIndex]; 582 final report = widget.report.days[groupIndex];
539 return BarTooltipItem( 583 return BarTooltipItem(
540 - '${report.activeEnergy?.value ?? 0}千卡\n', 584 + '${report.activeEnergy?.value ?? 0}${context.l10n.reportUnitKcal}\n',
541 const TextStyle( 585 const TextStyle(
542 color: ActivityBurnMonthReportView._active, 586 color: ActivityBurnMonthReportView._active,
543 fontSize: 16, 587 fontSize: 16,
@@ -563,16 +607,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -563,16 +607,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
563 rightTitles: AxisTitles( 607 rightTitles: AxisTitles(
564 sideTitles: SideTitles( 608 sideTitles: SideTitles(
565 showTitles: true, 609 showTitles: true,
566 - reservedSize: 28, 610 + reservedSize: 36,
567 interval: 100, 611 interval: 100,
568 getTitlesWidget: (value, meta) { 612 getTitlesWidget: (value, meta) {
569 return Transform.translate( 613 return Transform.translate(
570 - offset: const Offset(0, -5),  
571 - child: Text(  
572 - value.toInt().toString(),  
573 - style: const TextStyle(  
574 - color: ActivityBurnMonthReportView._h3,  
575 - fontSize: 10, 614 + offset: const Offset(0, -8),
  615 + child: Padding(
  616 + padding: const EdgeInsets.only(left: 8),
  617 + child: Text(
  618 + value.toInt().toString(),
  619 + style: const TextStyle(
  620 + color: ActivityBurnMonthReportView._h3,
  621 + fontSize: 10,
  622 + ),
576 ), 623 ),
577 ), 624 ),
578 ); 625 );
@@ -582,7 +629,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -582,7 +629,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
582 bottomTitles: AxisTitles( 629 bottomTitles: AxisTitles(
583 sideTitles: SideTitles( 630 sideTitles: SideTitles(
584 showTitles: true, 631 showTitles: true,
585 - reservedSize: 20, 632 + reservedSize: 32,
586 interval: 5, 633 interval: 5,
587 getTitlesWidget: (value, meta) { 634 getTitlesWidget: (value, meta) {
588 final day = value.toInt() + 1; 635 final day = value.toInt() + 1;
@@ -592,7 +639,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -592,7 +639,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
592 return const SizedBox.shrink(); 639 return const SizedBox.shrink();
593 } 640 }
594 return Padding( 641 return Padding(
595 - padding: const EdgeInsets.only(top: 4), 642 + padding: const EdgeInsets.only(top: 14),
596 child: Text( 643 child: Text(
597 day.toString(), 644 day.toString(),
598 style: const TextStyle( 645 style: const TextStyle(
@@ -605,22 +652,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -605,22 +652,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
605 ), 652 ),
606 ), 653 ),
607 ), 654 ),
608 - extraLinesData: ExtraLinesData(  
609 - horizontalLines: [  
610 - HorizontalLine(  
611 - y: 320,  
612 - color: ActivityBurnMonthReportView._exercise,  
613 - strokeWidth: 1,  
614 - dashArray: [2, 2],  
615 - ),  
616 - ],  
617 - ), 655 + baselineY: 0,
618 barGroups: [ 656 barGroups: [
619 for (var i = 0; i < widget.report.days.length; i++) 657 for (var i = 0; i < widget.report.days.length; i++)
620 BarChartGroupData( 658 BarChartGroupData(
621 x: i, 659 x: i,
622 barRods: [ 660 barRods: [
623 BarChartRodData( 661 BarChartRodData(
  662 + fromY: 0,
624 toY: 663 toY:
625 (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(), 664 (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(),
626 width: 4, 665 width: 4,
@@ -635,7 +674,50 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -635,7 +674,50 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
635 } 674 }
636 675
637 String _tooltipDate(DateTime date) { 676 String _tooltipDate(DateTime date) {
638 - const weekdays = ['一', '二', '三', '四', '五', '六', '日'];  
639 - return '${DateFormat('M月d日').format(date)} 星期${weekdays[date.weekday - 1]}'; 677 + return l10n.reportDateWithWeekday(
  678 + reportMonthDay(date),
  679 + reportWeekdayLabel(date.weekday),
  680 + );
  681 + }
  682 +}
  683 +
  684 +class _TrendLegend extends StatelessWidget {
  685 + const _TrendLegend({
  686 + required this.color,
  687 + required this.label,
  688 + this.dashed = false,
  689 + });
  690 +
  691 + final Color color;
  692 + final String label;
  693 + final bool dashed;
  694 +
  695 + @override
  696 + Widget build(BuildContext context) {
  697 + return Row(
  698 + mainAxisSize: MainAxisSize.min,
  699 + children: [
  700 + SizedBox(
  701 + width: 19,
  702 + child: Row(
  703 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
  704 + children: dashed
  705 + ? List.generate(
  706 + 5,
  707 + (_) => Container(width: 2, height: 2, color: color),
  708 + )
  709 + : [Container(width: 19, height: 2, color: color)],
  710 + ),
  711 + ),
  712 + const SizedBox(width: 4),
  713 + Text(
  714 + label,
  715 + style: const TextStyle(
  716 + color: ActivityBurnMonthReportView._h2,
  717 + fontSize: 12,
  718 + ),
  719 + ),
  720 + ],
  721 + );
640 } 722 }
641 } 723 }
@@ -4,32 +4,134 @@ import 'package:flutter/material.dart'; @@ -4,32 +4,134 @@ import 'package:flutter/material.dart';
4 4
5 import '../models/activity_burn_report_models.dart'; 5 import '../models/activity_burn_report_models.dart';
6 6
7 -class ActivityBurnRing extends StatelessWidget { 7 +class ActivityBurnRing extends StatefulWidget {
8 const ActivityBurnRing({ 8 const ActivityBurnRing({
9 super.key, 9 super.key,
10 required this.report, 10 required this.report,
11 this.size = 200, 11 this.size = 200,
  12 + this.animate = false,
12 }); 13 });
13 14
14 final ActivityBurnReport? report; 15 final ActivityBurnReport? report;
15 final double size; 16 final double size;
  17 + final bool animate;
  18 +
  19 + @override
  20 + State<ActivityBurnRing> createState() => _ActivityBurnRingState();
  21 +}
  22 +
  23 +class _ActivityBurnRingState extends State<ActivityBurnRing>
  24 + with SingleTickerProviderStateMixin {
  25 + late final AnimationController _controller;
  26 + late Animation<_RingProgress> _animation;
  27 +
  28 + _RingProgress get _target => _RingProgress.fromReport(widget.report);
  29 +
  30 + @override
  31 + void initState() {
  32 + super.initState();
  33 + _controller = AnimationController(
  34 + vsync: this,
  35 + duration: const Duration(seconds: 1),
  36 + );
  37 + _animateTo(_target, from: const _RingProgress.zero());
  38 + }
  39 +
  40 + @override
  41 + void didUpdateWidget(covariant ActivityBurnRing oldWidget) {
  42 + super.didUpdateWidget(oldWidget);
  43 + final target = _target;
  44 + if (_RingProgress.fromReport(oldWidget.report) != target ||
  45 + oldWidget.animate != widget.animate) {
  46 + _animateTo(target, from: _animation.value);
  47 + }
  48 + }
  49 +
  50 + void _animateTo(_RingProgress target, {required _RingProgress from}) {
  51 + _animation = _RingProgressTween(begin: from, end: target).animate(
  52 + CurvedAnimation(parent: _controller, curve: Curves.easeInOutQuad),
  53 + );
  54 + _controller.duration =
  55 + widget.animate ? const Duration(seconds: 1) : Duration.zero;
  56 + _controller.forward(from: 0);
  57 + }
  58 +
  59 + @override
  60 + void dispose() {
  61 + _controller.dispose();
  62 + super.dispose();
  63 + }
16 64
17 @override 65 @override
18 Widget build(BuildContext context) { 66 Widget build(BuildContext context) {
19 return SizedBox( 67 return SizedBox(
20 - width: size,  
21 - height: size,  
22 - child: CustomPaint(  
23 - painter: _ActivityBurnRingPainter(report: report), 68 + width: widget.size,
  69 + height: widget.size,
  70 + child: AnimatedBuilder(
  71 + animation: _animation,
  72 + builder: (context, child) => CustomPaint(
  73 + painter: _ActivityBurnRingPainter(
  74 + progress: _animation.value,
  75 + hasData: widget.report?.hasData == true,
  76 + ),
  77 + ),
24 ), 78 ),
25 ); 79 );
26 } 80 }
27 } 81 }
28 82
  83 +class _RingProgress {
  84 + const _RingProgress({
  85 + required this.active,
  86 + required this.exercise,
  87 + required this.stand,
  88 + });
  89 +
  90 + const _RingProgress.zero()
  91 + : active = 0,
  92 + exercise = 0,
  93 + stand = 0;
  94 +
  95 + factory _RingProgress.fromReport(ActivityBurnReport? report) => _RingProgress(
  96 + active: report?.activeEnergy?.progress ?? 0,
  97 + exercise: report?.exerciseMinutes?.progress ?? 0,
  98 + stand: report?.standHours?.progress ?? 0,
  99 + );
  100 +
  101 + final double active;
  102 + final double exercise;
  103 + final double stand;
  104 +
  105 + @override
  106 + bool operator ==(Object other) =>
  107 + other is _RingProgress &&
  108 + active == other.active &&
  109 + exercise == other.exercise &&
  110 + stand == other.stand;
  111 +
  112 + @override
  113 + int get hashCode => Object.hash(active, exercise, stand);
  114 +}
  115 +
  116 +class _RingProgressTween extends Tween<_RingProgress> {
  117 + _RingProgressTween({required super.begin, required super.end});
  118 +
  119 + @override
  120 + _RingProgress lerp(double t) => _RingProgress(
  121 + active: begin!.active + (end!.active - begin!.active) * t,
  122 + exercise: begin!.exercise + (end!.exercise - begin!.exercise) * t,
  123 + stand: begin!.stand + (end!.stand - begin!.stand) * t,
  124 + );
  125 +}
  126 +
29 class _ActivityBurnRingPainter extends CustomPainter { 127 class _ActivityBurnRingPainter extends CustomPainter {
30 - const _ActivityBurnRingPainter({required this.report}); 128 + const _ActivityBurnRingPainter({
  129 + required this.progress,
  130 + required this.hasData,
  131 + });
31 132
32 - final ActivityBurnReport? report; 133 + final _RingProgress progress;
  134 + final bool hasData;
33 135
34 static const _active = Color(0xFFFF5279); 136 static const _active = Color(0xFFFF5279);
35 static const _exercise = Color(0xFF3BD49D); 137 static const _exercise = Color(0xFF3BD49D);
@@ -50,7 +152,7 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -50,7 +152,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
50 152
51 _drawEmptyRing(canvas, center: center, scale: scale); 153 _drawEmptyRing(canvas, center: center, scale: scale);
52 154
53 - if (report?.hasData != true) { 155 + if (!hasData) {
54 return; 156 return;
55 } 157 }
56 158
@@ -61,7 +163,7 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -61,7 +163,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
61 width: _ringWidth * scale, 163 width: _ringWidth * scale,
62 trackColor: _track, 164 trackColor: _track,
63 progressColor: _active, 165 progressColor: _active,
64 - progress: report?.activeEnergy?.progress ?? 0, 166 + progress: progress.active,
65 startAngle: startAngle, 167 startAngle: startAngle,
66 ); 168 );
67 _drawRing( 169 _drawRing(
@@ -71,7 +173,7 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -71,7 +173,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
71 width: _ringWidth * scale, 173 width: _ringWidth * scale,
72 trackColor: _innerTrack, 174 trackColor: _innerTrack,
73 progressColor: _exercise, 175 progressColor: _exercise,
74 - progress: report?.exerciseMinutes?.progress ?? 0, 176 + progress: progress.exercise,
75 startAngle: startAngle, 177 startAngle: startAngle,
76 ); 178 );
77 _drawRing( 179 _drawRing(
@@ -81,7 +183,7 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -81,7 +183,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
81 width: _ringWidth * scale, 183 width: _ringWidth * scale,
82 trackColor: _center, 184 trackColor: _center,
83 progressColor: _stand, 185 progressColor: _stand,
84 - progress: report?.standHours?.progress ?? 0, 186 + progress: progress.stand,
85 startAngle: startAngle, 187 startAngle: startAngle,
86 ); 188 );
87 } 189 }
@@ -169,6 +271,6 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -169,6 +271,6 @@ class _ActivityBurnRingPainter extends CustomPainter {
169 271
170 @override 272 @override
171 bool shouldRepaint(covariant _ActivityBurnRingPainter oldDelegate) { 273 bool shouldRepaint(covariant _ActivityBurnRingPainter oldDelegate) {
172 - return oldDelegate.report != report; 274 + return oldDelegate.progress != progress || oldDelegate.hasData != hasData;
173 } 275 }
174 } 276 }
1 import 'package:flutter/material.dart'; 1 import 'package:flutter/material.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
2 3
3 import '../models/activity_burn_report_models.dart'; 4 import '../models/activity_burn_report_models.dart';
4 5
@@ -27,26 +28,26 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -27,26 +28,26 @@ class ActivityBurnSummaryCard extends StatelessWidget {
27 children: [ 28 children: [
28 Expanded( 29 Expanded(
29 child: _MetricColumn( 30 child: _MetricColumn(
30 - title: '活动', 31 + title: context.l10n.activityMove,
31 color: _active, 32 color: _active,
32 value: report?.activeEnergy?.value, 33 value: report?.activeEnergy?.value,
33 - unit: '千卡', 34 + unit: context.l10n.reportUnitKcal,
34 ), 35 ),
35 ), 36 ),
36 Expanded( 37 Expanded(
37 child: _MetricColumn( 38 child: _MetricColumn(
38 - title: '锻炼', 39 + title: context.l10n.activityExercise,
39 color: _exercise, 40 color: _exercise,
40 value: report?.exerciseMinutes?.value, 41 value: report?.exerciseMinutes?.value,
41 - unit: '分钟', 42 + unit: context.l10n.reportUnitMinute,
42 ), 43 ),
43 ), 44 ),
44 Expanded( 45 Expanded(
45 child: _MetricColumn( 46 child: _MetricColumn(
46 - title: '站立', 47 + title: context.l10n.activityStand,
47 color: _stand, 48 color: _stand,
48 value: report?.standHours?.value, 49 value: report?.standHours?.value,
49 - unit: '小时', 50 + unit: context.l10n.reportUnitHour,
50 ), 51 ),
51 ), 52 ),
52 ], 53 ],
  1 +import 'package:flutter/material.dart';
  2 +
  3 +class ActivityBurnTrendLines extends StatelessWidget {
  4 + const ActivityBurnTrendLines({
  5 + super.key,
  6 + required this.maxY,
  7 + required this.average,
  8 + required this.target,
  9 + });
  10 +
  11 + final double maxY;
  12 + final double average;
  13 + final double target;
  14 +
  15 + @override
  16 + Widget build(BuildContext context) {
  17 + return IgnorePointer(
  18 + child: CustomPaint(
  19 + painter: _TrendLinesPainter(
  20 + maxY: maxY,
  21 + average: average,
  22 + target: target,
  23 + ),
  24 + ),
  25 + );
  26 + }
  27 +}
  28 +
  29 +class ActivityBurnAxisDashedLine extends StatelessWidget {
  30 + const ActivityBurnAxisDashedLine({super.key});
  31 +
  32 + @override
  33 + Widget build(BuildContext context) {
  34 + return const IgnorePointer(
  35 + child: CustomPaint(painter: _AxisDashedLinePainter()),
  36 + );
  37 + }
  38 +}
  39 +
  40 +class ActivityBurnHorizontalGridLines extends StatelessWidget {
  41 + const ActivityBurnHorizontalGridLines({
  42 + super.key,
  43 + required this.minY,
  44 + required this.maxY,
  45 + required this.values,
  46 + });
  47 +
  48 + final double minY;
  49 + final double maxY;
  50 + final List<double> values;
  51 +
  52 + @override
  53 + Widget build(BuildContext context) {
  54 + return IgnorePointer(
  55 + child: CustomPaint(
  56 + painter: _HorizontalGridLinesPainter(
  57 + minY: minY,
  58 + maxY: maxY,
  59 + values: values,
  60 + ),
  61 + ),
  62 + );
  63 + }
  64 +}
  65 +
  66 +class _TrendLinesPainter extends CustomPainter {
  67 + const _TrendLinesPainter({
  68 + required this.maxY,
  69 + required this.average,
  70 + required this.target,
  71 + });
  72 +
  73 + final double maxY;
  74 + final double average;
  75 + final double target;
  76 +
  77 + static const _gridColor = Color(0xFFF3F3F3);
  78 + static const _averageColor = Color(0xFFFFC0CE);
  79 + static const _targetColor = Color(0xFF96E9CB);
  80 +
  81 + @override
  82 + void paint(Canvas canvas, Size size) {
  83 + if (maxY <= 0 || size.isEmpty) return;
  84 +
  85 + for (var value = 100.0; value <= maxY; value += 100) {
  86 + _drawDashedLine(
  87 + canvas,
  88 + size.width,
  89 + _yFor(value, size.height),
  90 + color: _gridColor,
  91 + strokeWidth: 1,
  92 + );
  93 + }
  94 + if (average > 0) {
  95 + _drawSolidLine(
  96 + canvas,
  97 + size.width,
  98 + _yFor(average, size.height),
  99 + color: _averageColor,
  100 + );
  101 + }
  102 + if (target > 0) {
  103 + _drawDashedLine(
  104 + canvas,
  105 + size.width,
  106 + _yFor(target, size.height),
  107 + color: _targetColor,
  108 + strokeWidth: 2,
  109 + );
  110 + }
  111 + }
  112 +
  113 + double _yFor(double value, double height) =>
  114 + height * (1 - (value / maxY).clamp(0, 1));
  115 +
  116 + void _drawSolidLine(
  117 + Canvas canvas,
  118 + double width,
  119 + double y, {
  120 + required Color color,
  121 + }) {
  122 + canvas.drawLine(
  123 + Offset(0, y),
  124 + Offset(width, y),
  125 + Paint()
  126 + ..color = color
  127 + ..strokeWidth = 2,
  128 + );
  129 + }
  130 +
  131 + void _drawDashedLine(
  132 + Canvas canvas,
  133 + double width,
  134 + double y, {
  135 + required Color color,
  136 + required double strokeWidth,
  137 + }) {
  138 + final paint = Paint()
  139 + ..color = color
  140 + ..strokeWidth = strokeWidth;
  141 + for (var x = 0.0; x < width; x += 4) {
  142 + canvas.drawLine(Offset(x, y), Offset((x + 2).clamp(0, width), y), paint);
  143 + }
  144 + }
  145 +
  146 + @override
  147 + bool shouldRepaint(_TrendLinesPainter oldDelegate) =>
  148 + maxY != oldDelegate.maxY ||
  149 + average != oldDelegate.average ||
  150 + target != oldDelegate.target;
  151 +}
  152 +
  153 +class _AxisDashedLinePainter extends CustomPainter {
  154 + const _AxisDashedLinePainter();
  155 +
  156 + @override
  157 + void paint(Canvas canvas, Size size) {
  158 + final paint = Paint()
  159 + ..color = const Color(0xFFF3F3F3)
  160 + ..strokeWidth = 1;
  161 + for (var x = 0.0; x < size.width; x += 4) {
  162 + canvas.drawLine(
  163 + Offset(x, 0),
  164 + Offset((x + 2).clamp(0, size.width), 0),
  165 + paint,
  166 + );
  167 + }
  168 + }
  169 +
  170 + @override
  171 + bool shouldRepaint(_AxisDashedLinePainter oldDelegate) => false;
  172 +}
  173 +
  174 +class _HorizontalGridLinesPainter extends CustomPainter {
  175 + const _HorizontalGridLinesPainter({
  176 + required this.minY,
  177 + required this.maxY,
  178 + required this.values,
  179 + });
  180 +
  181 + final double minY;
  182 + final double maxY;
  183 + final List<double> values;
  184 +
  185 + @override
  186 + void paint(Canvas canvas, Size size) {
  187 + if (maxY <= minY || size.isEmpty) return;
  188 + final paint = Paint()
  189 + ..color = const Color(0xFFF3F3F3)
  190 + ..strokeWidth = 1;
  191 + for (final value in values) {
  192 + if (value < minY || value > maxY) continue;
  193 + final y = size.height * (1 - (value - minY) / (maxY - minY));
  194 + for (var x = 0.0; x < size.width; x += 4) {
  195 + canvas.drawLine(
  196 + Offset(x, y),
  197 + Offset((x + 2).clamp(0, size.width), y),
  198 + paint,
  199 + );
  200 + }
  201 + }
  202 + }
  203 +
  204 + @override
  205 + bool shouldRepaint(_HorizontalGridLinesPainter oldDelegate) =>
  206 + minY != oldDelegate.minY ||
  207 + maxY != oldDelegate.maxY ||
  208 + values != oldDelegate.values;
  209 +}
1 import 'package:fl_chart/fl_chart.dart'; 1 import 'package:fl_chart/fl_chart.dart';
2 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
3 -import 'package:intl/intl.dart'; 3 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 4
5 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 5 import '../../report_common/widgets/chart_selection_line_overlay.dart';
6 import '../../report_common/widgets/health_report_subject_scope.dart'; 6 import '../../report_common/widgets/health_report_subject_scope.dart';
  7 +import '../../report_common/utils/report_localization.dart';
7 import '../models/activity_burn_report_models.dart'; 8 import '../models/activity_burn_report_models.dart';
8 import 'activity_burn_ring.dart'; 9 import 'activity_burn_ring.dart';
  10 +import 'activity_burn_trend_lines.dart';
9 11
10 class ActivityBurnWeekReportView extends StatelessWidget { 12 class ActivityBurnWeekReportView extends StatelessWidget {
11 const ActivityBurnWeekReportView({ 13 const ActivityBurnWeekReportView({
@@ -53,7 +55,8 @@ class _WeeklySummary extends StatelessWidget { @@ -53,7 +55,8 @@ class _WeeklySummary extends StatelessWidget {
53 crossAxisAlignment: CrossAxisAlignment.start, 55 crossAxisAlignment: CrossAxisAlignment.start,
54 children: [ 56 children: [
55 Text( 57 Text(
56 - HealthReportSubjectScope.titleOf(context, '活动总消耗'), 58 + HealthReportSubjectScope.titleOf(
  59 + context, context.l10n.activityTotalBurn),
57 style: const TextStyle( 60 style: const TextStyle(
58 color: ActivityBurnWeekReportView._active, 61 color: ActivityBurnWeekReportView._active,
59 fontSize: 12, 62 fontSize: 12,
@@ -76,11 +79,11 @@ class _WeeklySummary extends StatelessWidget { @@ -76,11 +79,11 @@ class _WeeklySummary extends StatelessWidget {
76 ), 79 ),
77 ), 80 ),
78 const SizedBox(width: 4), 81 const SizedBox(width: 4),
79 - const Padding(  
80 - padding: EdgeInsets.only(bottom: 5), 82 + Padding(
  83 + padding: const EdgeInsets.only(bottom: 5),
81 child: Text( 84 child: Text(
82 - '千卡',  
83 - style: TextStyle( 85 + context.l10n.reportUnitKcal,
  86 + style: const TextStyle(
84 color: ActivityBurnWeekReportView._h1, 87 color: ActivityBurnWeekReportView._h1,
85 fontSize: 12, 88 fontSize: 12,
86 fontWeight: FontWeight.w500, 89 fontWeight: FontWeight.w500,
@@ -90,9 +93,9 @@ class _WeeklySummary extends StatelessWidget { @@ -90,9 +93,9 @@ class _WeeklySummary extends StatelessWidget {
90 ], 93 ],
91 ) 94 )
92 else 95 else
93 - const Text(  
94 - '等待数据',  
95 - style: TextStyle( 96 + Text(
  97 + context.l10n.reportWaitingForData,
  98 + style: const TextStyle(
96 color: ActivityBurnWeekReportView._h1, 99 color: ActivityBurnWeekReportView._h1,
97 fontSize: 24, 100 fontSize: 24,
98 fontWeight: FontWeight.w600, 101 fontWeight: FontWeight.w600,
@@ -103,18 +106,20 @@ class _WeeklySummary extends StatelessWidget { @@ -103,18 +106,20 @@ class _WeeklySummary extends StatelessWidget {
103 Row( 106 Row(
104 children: [ 107 children: [
105 _SummaryMetric( 108 _SummaryMetric(
106 - title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'), 109 + title: HealthReportSubjectScope.titleOf(
  110 + context, context.l10n.activityExerciseTotalDuration),
107 value: report.hasData 111 value: report.hasData
108 ? report.totalExerciseMinutes.toString() 112 ? report.totalExerciseMinutes.toString()
109 : '-', 113 : '-',
110 - unit: '分钟', 114 + unit: context.l10n.reportUnitMinute,
111 color: ActivityBurnWeekReportView._exercise, 115 color: ActivityBurnWeekReportView._exercise,
112 ), 116 ),
113 const SizedBox(width: 34), 117 const SizedBox(width: 34),
114 _SummaryMetric( 118 _SummaryMetric(
115 - title: HealthReportSubjectScope.titleOf(context, '站立总时长'), 119 + title: HealthReportSubjectScope.titleOf(
  120 + context, context.l10n.activityStandTotalDuration),
116 value: report.hasData ? report.totalStandHours.toString() : '-', 121 value: report.hasData ? report.totalStandHours.toString() : '-',
117 - unit: '小时', 122 + unit: context.l10n.reportUnitHour,
118 color: ActivityBurnWeekReportView._stand, 123 color: ActivityBurnWeekReportView._stand,
119 ), 124 ),
120 ], 125 ],
@@ -202,7 +207,7 @@ class _RingOverviewCard extends StatelessWidget { @@ -202,7 +207,7 @@ class _RingOverviewCard extends StatelessWidget {
202 children: [ 207 children: [
203 _RingStat( 208 _RingStat(
204 color: ActivityBurnWeekReportView._active, 209 color: ActivityBurnWeekReportView._active,
205 - label: '完美合环', 210 + label: context.l10n.activityPerfectRings,
206 value: report.perfectRingDays, 211 value: report.perfectRingDays,
207 hasData: report.hasData, 212 hasData: report.hasData,
208 report: ActivityBurnReport( 213 report: ActivityBurnReport(
@@ -215,7 +220,7 @@ class _RingOverviewCard extends StatelessWidget { @@ -215,7 +220,7 @@ class _RingOverviewCard extends StatelessWidget {
215 const SizedBox(width: 34), 220 const SizedBox(width: 34),
216 _RingStat( 221 _RingStat(
217 color: ActivityBurnWeekReportView._active, 222 color: ActivityBurnWeekReportView._active,
218 - label: '合上活动圆环', 223 + label: context.l10n.activityCloseMoveRing,
219 value: report.activeRingDays, 224 value: report.activeRingDays,
220 hasData: report.hasData, 225 hasData: report.hasData,
221 report: ActivityBurnReport( 226 report: ActivityBurnReport(
@@ -230,7 +235,7 @@ class _RingOverviewCard extends StatelessWidget { @@ -230,7 +235,7 @@ class _RingOverviewCard extends StatelessWidget {
230 children: [ 235 children: [
231 _RingStat( 236 _RingStat(
232 color: ActivityBurnWeekReportView._exercise, 237 color: ActivityBurnWeekReportView._exercise,
233 - label: '合上锻炼圆环', 238 + label: context.l10n.activityCloseExerciseRing,
234 value: report.exerciseRingDays, 239 value: report.exerciseRingDays,
235 hasData: report.hasData, 240 hasData: report.hasData,
236 report: ActivityBurnReport( 241 report: ActivityBurnReport(
@@ -241,7 +246,7 @@ class _RingOverviewCard extends StatelessWidget { @@ -241,7 +246,7 @@ class _RingOverviewCard extends StatelessWidget {
241 const SizedBox(width: 34), 246 const SizedBox(width: 34),
242 _RingStat( 247 _RingStat(
243 color: ActivityBurnWeekReportView._stand, 248 color: ActivityBurnWeekReportView._stand,
244 - label: '合上站立圆环', 249 + label: context.l10n.activityCloseStandRing,
245 value: report.standRingDays, 250 value: report.standRingDays,
246 hasData: report.hasData, 251 hasData: report.hasData,
247 report: ActivityBurnReport( 252 report: ActivityBurnReport(
@@ -313,9 +318,9 @@ class _RingStat extends StatelessWidget { @@ -313,9 +318,9 @@ class _RingStat extends StatelessWidget {
313 fontWeight: FontWeight.w600, 318 fontWeight: FontWeight.w600,
314 ), 319 ),
315 ), 320 ),
316 - const TextSpan(  
317 - text: ' 天',  
318 - style: TextStyle( 321 + TextSpan(
  322 + text: ' ${context.l10n.reportUnitDay}',
  323 + style: const TextStyle(
319 color: ActivityBurnWeekReportView._h1, 324 color: ActivityBurnWeekReportView._h1,
320 fontSize: 14, 325 fontSize: 14,
321 fontWeight: FontWeight.w500, 326 fontWeight: FontWeight.w500,
@@ -335,8 +340,6 @@ class _DayRing extends StatelessWidget { @@ -335,8 +340,6 @@ class _DayRing extends StatelessWidget {
335 340
336 final ActivityBurnReport report; 341 final ActivityBurnReport report;
337 342
338 - static const _weekdays = ['一', '二', '三', '四', '五', '六', '日'];  
339 -  
340 @override 343 @override
341 Widget build(BuildContext context) { 344 Widget build(BuildContext context) {
342 return SizedBox( 345 return SizedBox(
@@ -344,7 +347,7 @@ class _DayRing extends StatelessWidget { @@ -344,7 +347,7 @@ class _DayRing extends StatelessWidget {
344 child: Column( 347 child: Column(
345 children: [ 348 children: [
346 Text( 349 Text(
347 - _weekdays[report.date.weekday - 1], 350 + reportWeekdayLabel(report.date.weekday),
348 style: const TextStyle( 351 style: const TextStyle(
349 color: ActivityBurnWeekReportView._h3, 352 color: ActivityBurnWeekReportView._h3,
350 fontSize: 12, 353 fontSize: 12,
@@ -384,25 +387,27 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -384,25 +387,27 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
384 387
385 @override 388 @override
386 Widget build(BuildContext context) { 389 Widget build(BuildContext context) {
  390 + final maxY = _maxY;
387 return Container( 391 return Container(
388 - height: 294,  
389 - padding: const EdgeInsets.fromLTRB(20, 18, 14, 14), 392 + height: 344,
  393 + padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
390 decoration: BoxDecoration( 394 decoration: BoxDecoration(
391 color: Colors.white, 395 color: Colors.white,
392 - borderRadius: BorderRadius.circular(12), 396 + borderRadius: BorderRadius.circular(16),
393 ), 397 ),
394 child: Column( 398 child: Column(
395 crossAxisAlignment: CrossAxisAlignment.start, 399 crossAxisAlignment: CrossAxisAlignment.start,
396 children: [ 400 children: [
397 Text( 401 Text(
398 - HealthReportSubjectScope.titleOf(context, '热量消耗趋势'), 402 + HealthReportSubjectScope.titleOf(
  403 + context, context.l10n.activityCalorieTrend),
399 style: const TextStyle( 404 style: const TextStyle(
400 color: ActivityBurnWeekReportView._h1, 405 color: ActivityBurnWeekReportView._h1,
401 fontSize: 16, 406 fontSize: 16,
402 fontWeight: FontWeight.w600, 407 fontWeight: FontWeight.w600,
403 ), 408 ),
404 ), 409 ),
405 - const SizedBox(height: 14), 410 + const SizedBox(height: 20),
406 Row( 411 Row(
407 crossAxisAlignment: CrossAxisAlignment.end, 412 crossAxisAlignment: CrossAxisAlignment.end,
408 children: [ 413 children: [
@@ -417,9 +422,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -417,9 +422,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
417 ), 422 ),
418 ), 423 ),
419 const SizedBox(width: 4), 424 const SizedBox(width: 4),
420 - const Text(  
421 - '千卡/平均每日',  
422 - style: TextStyle( 425 + Text(
  426 + context.l10n.activityKcalDailyAverage,
  427 + style: const TextStyle(
423 color: ActivityBurnWeekReportView._h1, 428 color: ActivityBurnWeekReportView._h1,
424 fontSize: 12, 429 fontSize: 12,
425 ), 430 ),
@@ -427,19 +432,56 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -427,19 +432,56 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
427 ], 432 ],
428 ), 433 ),
429 const SizedBox(height: 2), 434 const SizedBox(height: 2),
430 - Text(  
431 - widget.report.hasData ? '比上周少34%' : '比上周-',  
432 - style: const TextStyle(  
433 - color: ActivityBurnWeekReportView._h2,  
434 - fontSize: 12,  
435 - ), 435 + Row(
  436 + children: [
  437 + Text(
  438 + widget.report.hasData
  439 + ? context.l10n.activityComparedLastWeek
  440 + : context.l10n.activityComparedUnavailable,
  441 + style: const TextStyle(
  442 + color: ActivityBurnWeekReportView._h2,
  443 + fontSize: 12,
  444 + ),
  445 + ),
  446 + const Spacer(),
  447 + _TrendLegend(
  448 + color: const Color(0xFFFFC0CE),
  449 + label: context.l10n.sleepAverage,
  450 + ),
  451 + const SizedBox(width: 12),
  452 + _TrendLegend(
  453 + color: const Color(0xFF96E9CB),
  454 + label: context.l10n.sleepTarget,
  455 + dashed: true,
  456 + ),
  457 + ],
436 ), 458 ),
437 const SizedBox(height: 8), 459 const SizedBox(height: 8),
438 Expanded( 460 Expanded(
439 child: Stack( 461 child: Stack(
440 alignment: Alignment.center, 462 alignment: Alignment.center,
441 children: [ 463 children: [
442 - BarChart(_chartData()), 464 + Positioned(
  465 + left: 0,
  466 + right: 6,
  467 + top: 0,
  468 + bottom: 42,
  469 + child: ActivityBurnTrendLines(
  470 + maxY: maxY,
  471 + average: widget.report.averageDailyActiveEnergy.toDouble(),
  472 + target: widget.report.activeEnergyGoal.toDouble(),
  473 + ),
  474 + ),
  475 + BarChart(_chartData(maxY)),
  476 + const Positioned(
  477 + left: 0,
  478 + right: 6,
  479 + bottom: 34,
  480 + child: SizedBox(
  481 + height: 1,
  482 + child: ActivityBurnAxisDashedLine(),
  483 + ),
  484 + ),
443 if (_touchedOffset != null) 485 if (_touchedOffset != null)
444 Positioned.fill( 486 Positioned.fill(
445 child: ChartSelectionLineOverlay( 487 child: ChartSelectionLineOverlay(
@@ -450,9 +492,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -450,9 +492,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
450 ), 492 ),
451 ), 493 ),
452 if (!widget.report.hasData) 494 if (!widget.report.hasData)
453 - const Text(  
454 - '等待数据',  
455 - style: TextStyle( 495 + Text(
  496 + context.l10n.reportWaitingForData,
  497 + style: const TextStyle(
456 color: Color(0xFFA1A0A5), 498 color: Color(0xFFA1A0A5),
457 fontSize: 12, 499 fontSize: 12,
458 ), 500 ),
@@ -465,25 +507,25 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -465,25 +507,25 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
465 ); 507 );
466 } 508 }
467 509
468 - BarChartData _chartData() {  
469 - final hasData = widget.report.hasData; 510 + double get _maxY {
470 final maxValue = widget.report.days 511 final maxValue = widget.report.days
471 .map((day) => day.activeEnergy?.value ?? 0) 512 .map((day) => day.activeEnergy?.value ?? 0)
472 .fold<int>(0, (max, value) => value > max ? value : max); 513 .fold<int>(0, (max, value) => value > max ? value : max);
473 - final maxY = ((maxValue / 100).ceil().clamp(4, 9) * 100).toDouble(); 514 + final referenceMax = [
  515 + maxValue,
  516 + widget.report.averageDailyActiveEnergy,
  517 + widget.report.activeEnergyGoal,
  518 + ].reduce((max, value) => value > max ? value : max);
  519 + return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble();
  520 + }
474 521
  522 + BarChartData _chartData(double maxY) {
  523 + final hasData = widget.report.hasData;
475 return BarChartData( 524 return BarChartData(
476 minY: 0, 525 minY: 0,
477 maxY: maxY, 526 maxY: maxY,
478 gridData: FlGridData( 527 gridData: FlGridData(
479 - show: true,  
480 - drawVerticalLine: false,  
481 - horizontalInterval: 100,  
482 - getDrawingHorizontalLine: (_) => const FlLine(  
483 - color: Color(0xFFF3F3F3),  
484 - strokeWidth: 1,  
485 - dashArray: [2, 2],  
486 - ), 528 + show: false,
487 ), 529 ),
488 borderData: FlBorderData(show: false), 530 borderData: FlBorderData(show: false),
489 barTouchData: BarTouchData( 531 barTouchData: BarTouchData(
@@ -519,7 +561,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -519,7 +561,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
519 getTooltipItem: (group, groupIndex, rod, rodIndex) { 561 getTooltipItem: (group, groupIndex, rod, rodIndex) {
520 final report = widget.report.days[groupIndex]; 562 final report = widget.report.days[groupIndex];
521 return BarTooltipItem( 563 return BarTooltipItem(
522 - '${report.activeEnergy?.value ?? 0}千卡\n', 564 + '${report.activeEnergy?.value ?? 0}${context.l10n.reportUnitKcal}\n',
523 const TextStyle( 565 const TextStyle(
524 color: ActivityBurnWeekReportView._active, 566 color: ActivityBurnWeekReportView._active,
525 fontSize: 16, 567 fontSize: 16,
@@ -545,16 +587,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -545,16 +587,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
545 rightTitles: AxisTitles( 587 rightTitles: AxisTitles(
546 sideTitles: SideTitles( 588 sideTitles: SideTitles(
547 showTitles: true, 589 showTitles: true,
548 - reservedSize: 28, 590 + reservedSize: 36,
549 interval: 100, 591 interval: 100,
550 getTitlesWidget: (value, meta) { 592 getTitlesWidget: (value, meta) {
551 return Transform.translate( 593 return Transform.translate(
552 - offset: const Offset(0, -5),  
553 - child: Text(  
554 - value.toInt().toString(),  
555 - style: const TextStyle(  
556 - color: ActivityBurnWeekReportView._h3,  
557 - fontSize: 10, 594 + offset: const Offset(0, -8),
  595 + child: Padding(
  596 + padding: const EdgeInsets.only(left: 8),
  597 + child: Text(
  598 + value.toInt().toString(),
  599 + style: const TextStyle(
  600 + color: ActivityBurnWeekReportView._h3,
  601 + fontSize: 10,
  602 + ),
558 ), 603 ),
559 ), 604 ),
560 ); 605 );
@@ -564,16 +609,15 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -564,16 +609,15 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
564 bottomTitles: AxisTitles( 609 bottomTitles: AxisTitles(
565 sideTitles: SideTitles( 610 sideTitles: SideTitles(
566 showTitles: true, 611 showTitles: true,
567 - reservedSize: 32, 612 + reservedSize: 42,
568 getTitlesWidget: (value, meta) { 613 getTitlesWidget: (value, meta) {
569 final index = value.toInt(); 614 final index = value.toInt();
570 if (index < 0 || index >= widget.report.days.length) { 615 if (index < 0 || index >= widget.report.days.length) {
571 return const SizedBox.shrink(); 616 return const SizedBox.shrink();
572 } 617 }
573 final day = widget.report.days[index]; 618 final day = widget.report.days[index];
574 - const weekdays = ['一', '二', '三', '四', '五', '六', '日'];  
575 return Padding( 619 return Padding(
576 - padding: const EdgeInsets.only(top: 4), 620 + padding: const EdgeInsets.only(top: 14),
577 child: Column( 621 child: Column(
578 children: [ 622 children: [
579 Text( 623 Text(
@@ -584,7 +628,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -584,7 +628,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
584 ), 628 ),
585 ), 629 ),
586 Text( 630 Text(
587 - weekdays[day.date.weekday - 1], 631 + reportWeekdayLabel(day.date.weekday),
588 style: const TextStyle( 632 style: const TextStyle(
589 color: ActivityBurnWeekReportView._h3, 633 color: ActivityBurnWeekReportView._h3,
590 fontSize: 10, 634 fontSize: 10,
@@ -597,22 +641,14 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -597,22 +641,14 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
597 ), 641 ),
598 ), 642 ),
599 ), 643 ),
600 - extraLinesData: ExtraLinesData(  
601 - horizontalLines: [  
602 - HorizontalLine(  
603 - y: 320,  
604 - color: ActivityBurnWeekReportView._exercise,  
605 - strokeWidth: 1,  
606 - dashArray: [2, 2],  
607 - ),  
608 - ],  
609 - ), 644 + baselineY: 0,
610 barGroups: [ 645 barGroups: [
611 for (var i = 0; i < widget.report.days.length; i++) 646 for (var i = 0; i < widget.report.days.length; i++)
612 BarChartGroupData( 647 BarChartGroupData(
613 x: i, 648 x: i,
614 barRods: [ 649 barRods: [
615 BarChartRodData( 650 BarChartRodData(
  651 + fromY: 0,
616 toY: 652 toY:
617 (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(), 653 (widget.report.days[i].activeEnergy?.value ?? 0).toDouble(),
618 width: 16, 654 width: 16,
@@ -628,7 +664,50 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -628,7 +664,50 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
628 } 664 }
629 665
630 String _tooltipDate(DateTime date) { 666 String _tooltipDate(DateTime date) {
631 - const weekdays = ['一', '二', '三', '四', '五', '六', '日'];  
632 - return '${DateFormat('M月d日').format(date)} 星期${weekdays[date.weekday - 1]}'; 667 + return l10n.reportDateWithWeekday(
  668 + reportMonthDay(date),
  669 + reportWeekdayLabel(date.weekday),
  670 + );
  671 + }
  672 +}
  673 +
  674 +class _TrendLegend extends StatelessWidget {
  675 + const _TrendLegend({
  676 + required this.color,
  677 + required this.label,
  678 + this.dashed = false,
  679 + });
  680 +
  681 + final Color color;
  682 + final String label;
  683 + final bool dashed;
  684 +
  685 + @override
  686 + Widget build(BuildContext context) {
  687 + return Row(
  688 + mainAxisSize: MainAxisSize.min,
  689 + children: [
  690 + SizedBox(
  691 + width: 19,
  692 + child: Row(
  693 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
  694 + children: dashed
  695 + ? List.generate(
  696 + 5,
  697 + (_) => Container(width: 2, height: 2, color: color),
  698 + )
  699 + : [Container(width: 19, height: 2, color: color)],
  700 + ),
  701 + ),
  702 + const SizedBox(width: 4),
  703 + Text(
  704 + label,
  705 + style: const TextStyle(
  706 + color: ActivityBurnWeekReportView._h2,
  707 + fontSize: 12,
  708 + ),
  709 + ),
  710 + ],
  711 + );
633 } 712 }
634 } 713 }
  1 +import 'package:doublefeel_flutter/app/routes/app_pages.dart';
1 import 'package:doublefeel_flutter/data/local/local_storage.dart'; 2 import 'package:doublefeel_flutter/data/local/local_storage.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 -import 'package:get/get.dart';  
4 4
5 import '../widgets/app_review_prompt_dialog.dart'; 5 import '../widgets/app_review_prompt_dialog.dart';
6 6
7 -class AppReviewPromptController extends GetxController {  
8 - AppReviewPromptController({LocalStorage? storage})  
9 - : _storage = storage ?? Get.find<LocalStorage>(); 7 +class AppReviewPromptLogic {
  8 + AppReviewPromptLogic({required LocalStorage storage}) : _storage = storage;
10 9
11 final LocalStorage _storage; 10 final LocalStorage _storage;
12 11
@@ -15,39 +14,49 @@ class AppReviewPromptController extends GetxController { @@ -15,39 +14,49 @@ class AppReviewPromptController extends GetxController {
15 static const _likedCooldown = Duration(days: 90); 14 static const _likedCooldown = Duration(days: 90);
16 static const _feedbackCooldown = Duration(days: 30); 15 static const _feedbackCooldown = Duration(days: 30);
17 16
18 - Future<void> maybeShowPrompt() async {  
19 - final context = Get.context;  
20 - if (_isShowing || context == null || !context.mounted) return; 17 + Future<void> maybeShowPrompt(BuildContext context) async {
  18 + if (_isShowing || !context.mounted) return;
21 19
22 final now = DateTime.now(); 20 final now = DateTime.now();
23 final nextShowAt = _storage.appReviewPromptNextShowAt; 21 final nextShowAt = _storage.appReviewPromptNextShowAt;
24 if (nextShowAt != null && now.isBefore(nextShowAt)) return; 22 if (nextShowAt != null && now.isBefore(nextShowAt)) return;
25 23
26 _isShowing = true; 24 _isShowing = true;
27 - final result = await Get.dialog<AppReviewPromptResult>(  
28 - const AppReviewPromptDialog(),  
29 - barrierColor: Colors.black.withValues(alpha: 0.7),  
30 - barrierDismissible: true,  
31 - ); 25 + try {
  26 + final result = await showDialog<AppReviewPromptResult>(
  27 + context: context,
  28 + barrierColor: Colors.black.withValues(alpha: 0.7),
  29 + barrierDismissible: false,
  30 + builder: (_) => const AppReviewPromptDialog(),
  31 + );
32 32
33 - switch (result) {  
34 - case AppReviewPromptResult.liked:  
35 - await _coolDown(_likedCooldown);  
36 - case AppReviewPromptResult.feedback:  
37 - await _coolDown(_feedbackCooldown);  
38 - await _showFeedbackPrompt();  
39 - case null:  
40 - await _coolDown(_feedbackCooldown); 33 + switch (result) {
  34 + case AppReviewPromptResult.liked:
  35 + await _coolDown(_likedCooldown);
  36 + case AppReviewPromptResult.feedback:
  37 + await _coolDown(_feedbackCooldown);
  38 + if (context.mounted) {
  39 + await _showFeedbackPrompt(context);
  40 + }
  41 + case null:
  42 + await _coolDown(_feedbackCooldown);
  43 + }
  44 + } finally {
  45 + _isShowing = false;
41 } 46 }
42 - _isShowing = false;  
43 } 47 }
44 48
45 - Future<void> _showFeedbackPrompt() async {  
46 - await Get.dialog<AppReviewPromptResult>(  
47 - const AppReviewFeedbackDialog(), 49 + Future<void> _showFeedbackPrompt(BuildContext context) async {
  50 + final result = await showDialog<AppReviewPromptResult>(
  51 + context: context,
48 barrierColor: Colors.black.withValues(alpha: 0.7), 52 barrierColor: Colors.black.withValues(alpha: 0.7),
49 - barrierDismissible: true, 53 + barrierDismissible: false,
  54 + builder: (_) => const AppReviewFeedbackDialog(),
50 ); 55 );
  56 +
  57 + if (result == AppReviewPromptResult.feedback && context.mounted) {
  58 + await Navigator.of(context).pushNamed(Routes.SUBMIT_FEEDBACK);
  59 + }
51 } 60 }
52 61
53 Future<void> _coolDown(Duration duration) { 62 Future<void> _coolDown(Duration duration) {
  1 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 -import 'package:get/get.dart';  
3 3
4 enum AppReviewPromptResult { 4 enum AppReviewPromptResult {
5 liked, 5 liked,
@@ -13,12 +13,13 @@ class AppReviewPromptDialog extends StatelessWidget { @@ -13,12 +13,13 @@ class AppReviewPromptDialog extends StatelessWidget {
13 Widget build(BuildContext context) { 13 Widget build(BuildContext context) {
14 return _ReviewPromptCard( 14 return _ReviewPromptCard(
15 height: 380, 15 height: 380,
16 - title: '喜欢 DoubleFeel 吗?',  
17 - message: '嗨~想知道 DoubleFeel 是否正在帮助你\n更了解自己的压力与睡眠状态 💜',  
18 - primaryText: '😍 很喜欢',  
19 - secondaryText: '我有意见',  
20 - onPrimaryTap: () => Get.back(result: AppReviewPromptResult.liked),  
21 - onSecondaryTap: () => Get.back(result: AppReviewPromptResult.feedback), 16 + title: context.l10n.appReviewPromptTitle,
  17 + message: context.l10n.appReviewPromptMessage,
  18 + primaryText: context.l10n.appReviewPromptLikeAction,
  19 + secondaryText: context.l10n.appReviewPromptFeedbackAction,
  20 + onPrimaryTap: () => Navigator.of(context).pop(AppReviewPromptResult.liked),
  21 + onSecondaryTap: () =>
  22 + Navigator.of(context).pop(AppReviewPromptResult.feedback),
22 ); 23 );
23 } 24 }
24 } 25 }
@@ -30,12 +31,13 @@ class AppReviewFeedbackDialog extends StatelessWidget { @@ -30,12 +31,13 @@ class AppReviewFeedbackDialog extends StatelessWidget {
30 Widget build(BuildContext context) { 31 Widget build(BuildContext context) {
31 return _ReviewPromptCard( 32 return _ReviewPromptCard(
32 height: 425, 33 height: 425,
33 - title: '很抱歉 DoubleFeel 没有带\n给你好的体验',  
34 - message: '愿意告诉我们遇到了什么问题吗?\n你的反馈可以帮助我们持续改进压力与健康体验 💜',  
35 - primaryText: '发送反馈',  
36 - secondaryText: '稍后再说',  
37 - onPrimaryTap: () => Get.back(result: AppReviewPromptResult.feedback),  
38 - onSecondaryTap: () => Get.back(), 34 + title: context.l10n.appReviewFeedbackTitle,
  35 + message: context.l10n.appReviewFeedbackMessage,
  36 + primaryText: context.l10n.appReviewFeedbackSendAction,
  37 + secondaryText: context.l10n.appReviewFeedbackLaterAction,
  38 + onPrimaryTap: () =>
  39 + Navigator.of(context).pop(AppReviewPromptResult.feedback),
  40 + onSecondaryTap: () => Navigator.of(context).pop(),
39 ); 41 );
40 } 42 }
41 } 43 }
@@ -68,102 +70,105 @@ class _ReviewPromptCard extends StatelessWidget { @@ -68,102 +70,105 @@ class _ReviewPromptCard extends StatelessWidget {
68 @override 70 @override
69 Widget build(BuildContext context) { 71 Widget build(BuildContext context) {
70 final isFeedback = height > 400; 72 final isFeedback = height > 400;
71 - return Center(  
72 - child: Material(  
73 - color: Colors.transparent,  
74 - child: Container(  
75 - width: _cardWidth,  
76 - height: height,  
77 - clipBehavior: Clip.antiAlias,  
78 - decoration: BoxDecoration(  
79 - borderRadius: BorderRadius.circular(16),  
80 - gradient: const LinearGradient(  
81 - begin: Alignment.topCenter,  
82 - end: Alignment.bottomCenter,  
83 - colors: [  
84 - Color(0xFFE6DBFF),  
85 - Color(0xFFEDE4FF),  
86 - Color(0xFFEEE6FF),  
87 - Color(0xFFF9F6FF),  
88 - ],  
89 - stops: [0, 0.25, 0.75, 1], 73 + return PopScope(
  74 + canPop: false,
  75 + child: Center(
  76 + child: Material(
  77 + color: Colors.transparent,
  78 + child: Container(
  79 + width: _cardWidth,
  80 + height: height,
  81 + clipBehavior: Clip.antiAlias,
  82 + decoration: BoxDecoration(
  83 + borderRadius: BorderRadius.circular(16),
  84 + gradient: const LinearGradient(
  85 + begin: Alignment.topCenter,
  86 + end: Alignment.bottomCenter,
  87 + colors: [
  88 + Color(0xFFE6DBFF),
  89 + Color(0xFFEDE4FF),
  90 + Color(0xFFEEE6FF),
  91 + Color(0xFFF9F6FF),
  92 + ],
  93 + stops: [0, 0.25, 0.75, 1],
  94 + ),
90 ), 95 ),
91 - ),  
92 - child: Stack(  
93 - children: [  
94 - const Positioned(  
95 - top: 0,  
96 - left: 0,  
97 - right: 0,  
98 - height: _illustrationHeight,  
99 - child: ColoredBox(  
100 - color: Color(0xFFF09AB8),  
101 - child: Center(  
102 - child: Text(  
103 - '插图占位',  
104 - style: TextStyle(  
105 - color: Colors.red,  
106 - fontSize: 14,  
107 - fontWeight: FontWeight.w400, 96 + child: Stack(
  97 + children: [
  98 + Positioned(
  99 + top: 0,
  100 + left: 0,
  101 + right: 0,
  102 + height: _illustrationHeight,
  103 + child: ColoredBox(
  104 + color: const Color(0xFFF09AB8),
  105 + child: Center(
  106 + child: Text(
  107 + context.l10n.appReviewIllustrationPlaceholder,
  108 + style: const TextStyle(
  109 + color: Colors.red,
  110 + fontSize: 14,
  111 + fontWeight: FontWeight.w400,
  112 + ),
108 ), 113 ),
109 ), 114 ),
110 ), 115 ),
111 ), 116 ),
112 - ),  
113 - Positioned(  
114 - top: isFeedback ? 162 : 163,  
115 - left: 38,  
116 - right: 38,  
117 - child: Text(  
118 - title,  
119 - textAlign: TextAlign.center,  
120 - style: const TextStyle(  
121 - color: _h1,  
122 - fontSize: 18,  
123 - height: 1.35,  
124 - fontWeight: FontWeight.w600, 117 + Positioned(
  118 + top: isFeedback ? 162 : 163,
  119 + left: 38,
  120 + right: 38,
  121 + child: Text(
  122 + title,
  123 + textAlign: TextAlign.center,
  124 + style: const TextStyle(
  125 + color: _h1,
  126 + fontSize: 18,
  127 + height: 1.35,
  128 + fontWeight: FontWeight.w600,
  129 + ),
125 ), 130 ),
126 ), 131 ),
127 - ),  
128 - Positioned(  
129 - top: isFeedback ? 222 : 195,  
130 - left: 20,  
131 - right: 20,  
132 - child: Text(  
133 - message,  
134 - textAlign: TextAlign.center,  
135 - style: const TextStyle(  
136 - color: _h2,  
137 - fontSize: 14,  
138 - height: 1.35,  
139 - fontWeight: FontWeight.w400, 132 + Positioned(
  133 + top: isFeedback ? 222 : 195,
  134 + left: 20,
  135 + right: 20,
  136 + child: Text(
  137 + message,
  138 + textAlign: TextAlign.center,
  139 + style: const TextStyle(
  140 + color: _h2,
  141 + fontSize: 14,
  142 + height: 1.35,
  143 + fontWeight: FontWeight.w400,
  144 + ),
140 ), 145 ),
141 ), 146 ),
142 - ),  
143 - Positioned(  
144 - top: isFeedback ? 305 : 260,  
145 - left: 40,  
146 - right: 40,  
147 - child: _PromptButton(  
148 - text: primaryText,  
149 - textColor: Colors.white,  
150 - fontWeight: FontWeight.w600,  
151 - backgroundColor: _brandColor,  
152 - onTap: onPrimaryTap, 147 + Positioned(
  148 + top: isFeedback ? 305 : 260,
  149 + left: 40,
  150 + right: 40,
  151 + child: _PromptButton(
  152 + text: primaryText,
  153 + textColor: Colors.white,
  154 + fontWeight: FontWeight.w600,
  155 + backgroundColor: _brandColor,
  156 + onTap: onPrimaryTap,
  157 + ),
153 ), 158 ),
154 - ),  
155 - Positioned(  
156 - top: isFeedback ? 357 : 312,  
157 - left: 40,  
158 - right: 40,  
159 - child: _PromptButton(  
160 - text: secondaryText,  
161 - textColor: _h2,  
162 - fontWeight: FontWeight.w400,  
163 - onTap: onSecondaryTap, 159 + Positioned(
  160 + top: isFeedback ? 357 : 312,
  161 + left: 40,
  162 + right: 40,
  163 + child: _PromptButton(
  164 + text: secondaryText,
  165 + textColor: _h2,
  166 + fontWeight: FontWeight.w400,
  167 + onTap: onSecondaryTap,
  168 + ),
164 ), 169 ),
165 - ),  
166 - ], 170 + ],
  171 + ),
167 ), 172 ),
168 ), 173 ),
169 ), 174 ),
  1 +import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
3 import '../controllers/add_friend_controller.dart'; 4 import '../controllers/add_friend_controller.dart';
@@ -5,6 +6,6 @@ import '../controllers/add_friend_controller.dart'; @@ -5,6 +6,6 @@ import '../controllers/add_friend_controller.dart';
5 class AddFriendBinding extends Bindings { 6 class AddFriendBinding extends Bindings {
6 @override 7 @override
7 void dependencies() { 8 void dependencies() {
8 - Get.put(AddFriendController()); 9 + Get.put(AddFriendController(Get.find<FriendApi>()));
9 } 10 }
10 } 11 }
@@ -22,7 +22,7 @@ class FriendHomeBinding extends Bindings { @@ -22,7 +22,7 @@ class FriendHomeBinding extends Bindings {
22 Get.find<AppleHealthUploadTool>(), 22 Get.find<AppleHealthUploadTool>(),
23 friendInfo: args, 23 friendInfo: args,
24 ), 24 ),
25 - tag: 'friend_${args.userId}', // 动态 tag,支持多个好友同时在路由栈中 25 + tag: 'friend_${args.friendUserId}', // 动态 tag,支持多个好友同时在路由栈中
26 ); 26 );
27 } 27 }
28 } 28 }
  1 +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
3 import '../controllers/friend_trend_controller.dart'; 4 import '../controllers/friend_trend_controller.dart';
@@ -6,15 +7,18 @@ class FriendTrendBinding extends Bindings { @@ -6,15 +7,18 @@ class FriendTrendBinding extends Bindings {
6 @override 7 @override
7 void dependencies() { 8 void dependencies() {
8 final arguments = Get.arguments; 9 final arguments = Get.arguments;
9 - if (arguments is! FriendTrendArguments) {  
10 - throw ArgumentError('FriendTrendView requires FriendTrendArguments');  
11 - } 10 + final trendArguments = arguments is FriendTrendArguments
  11 + ? arguments
  12 + : FriendTrendArguments(
  13 + friendItem: arguments is FriendItem ? arguments : null,
  14 + );
12 15
13 Get.lazyPut<FriendTrendController>( 16 Get.lazyPut<FriendTrendController>(
14 () => FriendTrendController( 17 () => FriendTrendController(
15 - userId: arguments.userId,  
16 - friendName: arguments.name,  
17 - avatarUrl: arguments.avatarUrl, 18 + friendItem: trendArguments.friendItem,
  19 + initialDate: trendArguments.initialDate,
  20 + initialPeriod: trendArguments.initialPeriod,
  21 + initialTypeIndex: trendArguments.initialTypeIndex,
18 ), 22 ),
19 ); 23 );
20 } 24 }
  1 +import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.dart';
1 import 'package:flutter/services.dart'; 3 import 'package:flutter/services.dart';
2 import 'package:flutter/widgets.dart'; 4 import 'package:flutter/widgets.dart';
3 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
4 6
5 -import '../models/add_friend_prompt_type.dart';  
6 -  
7 class AddFriendController extends GetxController { 7 class AddFriendController extends GetxController {
  8 + AddFriendController(this._friendApi);
  9 +
  10 + final FriendApi _friendApi;
  11 +
8 final TextEditingController friendCodeController = TextEditingController(); 12 final TextEditingController friendCodeController = TextEditingController();
9 final FocusNode friendCodeFocusNode = FocusNode(); 13 final FocusNode friendCodeFocusNode = FocusNode();
10 14
@@ -33,16 +37,16 @@ class AddFriendController extends GetxController { @@ -33,16 +37,16 @@ class AddFriendController extends GetxController {
33 Clipboard.setData(ClipboardData(text: myInviteCode.value)); 37 Clipboard.setData(ClipboardData(text: myInviteCode.value));
34 } 38 }
35 39
36 - Future<AddFriendPromptType?> submitFriendCode() async {  
37 - if (!canSubmit || isSubmitting.value) return null; 40 + Future<bool> submitFriendCode() async {
  41 + if (!canSubmit || isSubmitting.value) return false;
38 isSubmitting.value = true; 42 isSubmitting.value = true;
39 try { 43 try {
40 - final code = friendCodeInput.value;  
41 - if (code == myInviteCode.value) {  
42 - return AddFriendPromptType.selfId; 44 + final result = await _friendApi.addFriend(friendCodeInput.value);
  45 + if (result is AppSuccess<void>) {
  46 + Get.back(result: true);
  47 + return true;
43 } 48 }
44 -  
45 - return AddFriendPromptType.idNotFound; 49 + return false;
46 } finally { 50 } finally {
47 isSubmitting.value = false; 51 isSubmitting.value = false;
48 } 52 }
  1 +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
  4 +import '../../health_trend/controllers/health_trend_control.dart';
3 import '../../report_common/models/health_report_query.dart'; 5 import '../../report_common/models/health_report_query.dart';
  6 +import '../../report_common/models/report_period.dart';
4 7
5 class FriendTrendArguments { 8 class FriendTrendArguments {
6 const FriendTrendArguments({ 9 const FriendTrendArguments({
7 - required this.userId,  
8 - required this.name,  
9 - this.avatarUrl, 10 + required this.friendItem,
  11 + this.initialDate,
  12 + this.initialPeriod,
  13 + this.initialTypeIndex = 0,
10 }); 14 });
11 15
12 - final int userId;  
13 - final String name;  
14 - final String? avatarUrl; 16 + final FriendItem? friendItem;
  17 + final DateTime? initialDate;
  18 + final ReportPeriod? initialPeriod;
  19 + final int initialTypeIndex;
15 } 20 }
16 21
17 -class FriendTrendController extends GetxController { 22 +class FriendTrendController extends GetxController with HealthTrendControl {
18 FriendTrendController({ 23 FriendTrendController({
19 - required this.userId,  
20 - required this.friendName,  
21 - this.avatarUrl,  
22 - });  
23 -  
24 - final int userId;  
25 - final String friendName;  
26 - final String? avatarUrl;  
27 - final selectedTypeIndex = 0.obs; 24 + required this.friendItem,
  25 + DateTime? initialDate,
  26 + ReportPeriod? initialPeriod,
  27 + int initialTypeIndex = 0,
  28 + }) {
  29 + changeType(initialTypeIndex);
  30 + if (initialPeriod != null) changePeriod(initialPeriod);
  31 + if (initialDate != null) changeDate(initialDate);
  32 + }
28 33
29 - HealthReportQuery get query => HealthReportQuery(targetUserId: userId); 34 + final FriendItem? friendItem;
30 35
31 - void changeType(int index) {  
32 - if (index == selectedTypeIndex.value) return;  
33 - selectedTypeIndex.value = index;  
34 - } 36 + HealthReportQuery get query => HealthReportQuery(
  37 + targetUserId: friendItem?.id,
  38 + period: selectedPeriod.value,
  39 + date: selectedDate.value,
  40 + );
35 } 41 }
@@ -4,64 +4,125 @@ import 'package:doublefeel_flutter/app/actions/dialog_action.dart'; @@ -4,64 +4,125 @@ import 'package:doublefeel_flutter/app/actions/dialog_action.dart';
4 import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; 4 import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
5 import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart'; 5 import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart';
6 import 'package:doublefeel_flutter/app/utils/dialog_utils.dart'; 6 import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
  7 +import 'package:doublefeel_flutter/core/error/app_error.dart';
7 import 'package:doublefeel_flutter/core/logging/app_logger.dart'; 8 import 'package:doublefeel_flutter/core/logging/app_logger.dart';
  9 +import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
  10 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
  11 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  12 +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
  13 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  14 +import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
8 import 'package:get/get.dart'; 15 import 'package:get/get.dart';
9 16
  17 +import '../data/friends_repository.dart';
  18 +import '../data/self_health_repository.dart';
  19 +import '../models/friend_health_data.dart';
  20 +
10 class FriendsController extends GetxController { 21 class FriendsController extends GetxController {
  22 + FriendsController({
  23 + FriendsRepository? repository,
  24 + SelfHealthRepository? selfHealthRepository,
  25 + Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo,
  26 + }) : _repository =
  27 + repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
  28 + _selfHealthRepository = selfHealthRepository ??
  29 + SelfHealthRepositoryImpl(Get.find<HealthApi>()),
  30 + _updateWatchOtherUserInfo = updateWatchOtherUserInfo ??
  31 + ((info) => PlatformHostApi().updateWatchOtherUserInfo(info));
  32 +
11 static const maxFriends = 10; 33 static const maxFriends = 10;
12 34
  35 + final FriendsRepository _repository;
  36 + final SelfHealthRepository _selfHealthRepository;
  37 + final Future<void> Function(WatchAppOtherInfo info) _updateWatchOtherUserInfo;
13 final friends = <FriendHealthData>[].obs; 38 final friends = <FriendHealthData>[].obs;
14 final isLoading = false.obs; 39 final isLoading = false.obs;
  40 + final selfHealthData = Rxn<V2HealthData>();
  41 + final selfStressScore = Rxn<V2StressScore>();
  42 + final selfHealthUpdatedAt = Rxn<DateTime>();
  43 + final isSelfHealthLoading = false.obs;
  44 + Future<void>? _friendsRequest;
  45 + Future<void>? _selfHealthRequest;
15 46
16 bool get isFull => friends.length >= maxFriends; 47 bool get isFull => friends.length >= maxFriends;
17 48
18 @override 49 @override
19 void onInit() { 50 void onInit() {
20 super.onInit(); 51 super.onInit();
21 - unawaited(loadFriends()); 52 + unawaited(refreshData());
  53 + }
  54 +
  55 + Future<void> refreshData() {
  56 + return Future.wait([loadFriends(), loadSelfHealth()]);
22 } 57 }
23 58
24 - Future<void> loadFriends() async {  
25 - if (isLoading.value) return; 59 + Future<void> loadFriends() {
  60 + return _friendsRequest ??= _loadFriends();
  61 + }
  62 +
  63 + Future<void> _loadFriends() async {
26 isLoading.value = true; 64 isLoading.value = true;
27 try { 65 try {
28 - // TODO: Replace mock data with the friend list API when it is available.  
29 - await Future<void>.delayed(const Duration(milliseconds: 150));  
30 - friends.assignAll(const [  
31 - FriendHealthData(  
32 - userId: 10001,  
33 - name: '女朋友(不爱吃热干面)',  
34 - updatedAt: '更新于15:12',  
35 - sleepQualityScore: 92,  
36 - steps: '10254步',  
37 - statusText: '注意压力',  
38 - stressValue: 72,  
39 - isOnWatchFace: true,  
40 - ),  
41 - FriendHealthData(  
42 - userId: 10002,  
43 - name: 'John',  
44 - updatedAt: '更新于15:12',  
45 - sleepQualityScore: null,  
46 - steps: null,  
47 - statusText: '等待数据',  
48 - stressValue: null,  
49 - ),  
50 - ]); 66 + friends.assignAll(await _repository.getFriends());
51 } catch (error, stackTrace) { 67 } catch (error, stackTrace) {
52 AppLogger.e('FriendsController.loadFriends failed', error, stackTrace); 68 AppLogger.e('FriendsController.loadFriends failed', error, stackTrace);
53 } finally { 69 } finally {
54 isLoading.value = false; 70 isLoading.value = false;
  71 + _friendsRequest = null;
  72 + }
  73 + }
  74 +
  75 + Future<void> loadSelfHealth() {
  76 + return _selfHealthRequest ??= _loadSelfHealth();
  77 + }
  78 +
  79 + Future<void> _loadSelfHealth() async {
  80 + isSelfHealthLoading.value = true;
  81 + try {
  82 + final today = DateTime.now();
  83 + await Future.wait([
  84 + _loadSelfHealthData(today),
  85 + _loadSelfStressScore(today),
  86 + ]);
  87 + } finally {
  88 + isSelfHealthLoading.value = false;
  89 + _selfHealthRequest = null;
  90 + }
  91 + }
  92 +
  93 + Future<void> _loadSelfHealthData(DateTime date) async {
  94 + try {
  95 + selfHealthData.value = await _selfHealthRepository.getHealthData(date);
  96 + selfHealthUpdatedAt.value = DateTime.now();
  97 + } catch (error, stackTrace) {
  98 + AppLogger.e(
  99 + 'FriendsController.loadSelfHealthData failed',
  100 + error,
  101 + stackTrace,
  102 + );
  103 + }
  104 + }
  105 +
  106 + Future<void> _loadSelfStressScore(DateTime date) async {
  107 + try {
  108 + selfStressScore.value = await _selfHealthRepository.getStressScore(date);
  109 + selfHealthUpdatedAt.value = DateTime.now();
  110 + } catch (error, stackTrace) {
  111 + AppLogger.e(
  112 + 'FriendsController.loadSelfStressScore failed',
  113 + error,
  114 + stackTrace,
  115 + );
55 } 116 }
56 } 117 }
57 118
58 Future<void> showEditRemarkDialog(FriendHealthData friend) async { 119 Future<void> showEditRemarkDialog(FriendHealthData friend) async {
59 final result = await DialogUtils.showInputDialog( 120 final result = await DialogUtils.showInputDialog(
60 InputDialogMetaData( 121 InputDialogMetaData(
61 - title: '修改好友备注',  
62 - initialValue: friend.name,  
63 - hintText: '请输入昵称',  
64 - confirmText: '保存', 122 + title: l10n.friendsEditRemarkTitle,
  123 + initialValue: friend.remark ?? '',
  124 + hintText: l10n.friendsEditRemarkHint,
  125 + confirmText: l10n.friendsSave,
65 maxLength: 6, 126 maxLength: 6,
66 ), 127 ),
67 ); 128 );
@@ -73,77 +134,87 @@ class FriendsController extends GetxController { @@ -73,77 +134,87 @@ class FriendsController extends GetxController {
73 return; 134 return;
74 } 135 }
75 136
76 - final index = friends.indexOf(friend);  
77 - if (index < 0) return;  
78 - friends[index] = friend.copyWith(name: remark); 137 + final userId = friend.userId;
  138 + if (userId == null) return;
  139 + try {
  140 + await _repository.updateRemark(userId, remark);
  141 + final index = friends.indexOf(friend);
  142 + if (index >= 0) friends[index] = friend.copyWith(remark: remark);
  143 + } catch (error, stackTrace) {
  144 + if (error is AppError) {
  145 + AppToast.show(error.displayMessage);
  146 + } else {
  147 + AppLogger.e(
  148 + 'FriendsController.showEditRemarkDialog failed',
  149 + error,
  150 + stackTrace,
  151 + );
  152 + }
  153 + }
  154 + }
79 155
80 - // TODO: Submit the remark to the friend API when it is available. 156 + Future<void> applyWatchFaceSelection(FriendHealthData selectedFriend) async {
  157 + final friendUserId = selectedFriend.userId;
  158 + if (friendUserId == null) {
  159 + return;
  160 + }
  161 + try {
  162 + await _repository.selectWatchFaceFriend(friendUserId);
  163 + final updatedFriends = friends
  164 + .map(
  165 + (friend) => friend.userId == friendUserId
  166 + ? friend.copyWith(isOnWatchFace: true)
  167 + : friend,
  168 + )
  169 + .toList(growable: false);
  170 + friends.assignAll(updatedFriends);
  171 + await _updateWatchOtherUserInfo(
  172 + WatchAppOtherInfo(
  173 + userId: friendUserId,
  174 + nickname: selectedFriend.name,
  175 + markName: selectedFriend.remark,
  176 + ),
  177 + );
  178 + } catch (error, stackTrace) {
  179 + await loadFriends();
  180 + if (error is AppError) {
  181 + AppToast.show(error.displayMessage);
  182 + } else {
  183 + AppLogger.e(
  184 + 'FriendsController.applyWatchFaceSelection failed',
  185 + error,
  186 + stackTrace,
  187 + );
  188 + }
  189 + }
81 } 190 }
82 191
83 Future<void> showDeleteConfirmDialog(FriendHealthData friend) async { 192 Future<void> showDeleteConfirmDialog(FriendHealthData friend) async {
84 try { 193 try {
85 final dialogMetaData = DialogMetaData( 194 final dialogMetaData = DialogMetaData(
86 iconAsset: null, 195 iconAsset: null,
87 - title: '确认要和${friend.name}解除好友关系吗?',  
88 - message: '解除后你将无法查看对方的情绪、健康状态',  
89 - confirmText: '确认解除',  
90 - cancelText: '取消', 196 + title: l10n.friendsDeleteConfirmTitle(friend.displayName),
  197 + message: l10n.friendsDeleteConfirmMessage,
  198 + confirmText: l10n.friendsDeleteConfirmAction,
  199 + cancelText: l10n.cancel,
91 ); 200 );
92 - await DialogUtils.showCommonDialog(dialogMetaData); 201 + final result = await DialogUtils.showCommonDialog(dialogMetaData);
  202 + if (result.action != DialogAction.confirm) return;
  203 +
  204 + final userId = friend.userId;
  205 + if (userId == null) return;
  206 + await _repository.deleteFriend(userId);
  207 + await loadFriends();
93 } catch (error, stackTrace) { 208 } catch (error, stackTrace) {
94 - AppLogger.e(  
95 - 'FriendsController.showDeleteConfirmDialog failed',  
96 - error,  
97 - stackTrace,  
98 - ); 209 + if (error is AppError) {
  210 + AppToast.show(error.displayMessage);
  211 + } else {
  212 + AppLogger.e(
  213 + 'FriendsController.showDeleteConfirmDialog failed',
  214 + error,
  215 + stackTrace,
  216 + );
  217 + }
99 } 218 }
100 } 219 }
101 } 220 }
102 -  
103 -class FriendHealthData {  
104 - const FriendHealthData({  
105 - this.userId,  
106 - this.avatarUrl,  
107 - required this.name,  
108 - required this.updatedAt,  
109 - required this.sleepQualityScore,  
110 - required this.steps,  
111 - required this.statusText,  
112 - required this.stressValue,  
113 - this.isOnWatchFace = false,  
114 - });  
115 -  
116 - final int? userId;  
117 - final String? avatarUrl;  
118 - final String name;  
119 - final String updatedAt;  
120 - final int? sleepQualityScore;  
121 - final String? steps;  
122 - final String statusText;  
123 - final double? stressValue;  
124 - final bool isOnWatchFace;  
125 -  
126 - FriendHealthData copyWith({  
127 - int? userId,  
128 - String? avatarUrl,  
129 - String? name,  
130 - String? updatedAt,  
131 - int? sleepQualityScore,  
132 - String? steps,  
133 - String? statusText,  
134 - double? stressValue,  
135 - bool? isOnWatchFace,  
136 - }) {  
137 - return FriendHealthData(  
138 - userId: userId ?? this.userId,  
139 - avatarUrl: avatarUrl ?? this.avatarUrl,  
140 - name: name ?? this.name,  
141 - updatedAt: updatedAt ?? this.updatedAt,  
142 - sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,  
143 - steps: steps ?? this.steps,  
144 - statusText: statusText ?? this.statusText,  
145 - stressValue: stressValue ?? this.stressValue,  
146 - isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace,  
147 - );  
148 - }  
149 -}  
  1 +import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
3 -import 'friends_controller.dart'; 4 +import '../data/friends_repository.dart';
  5 +import '../models/friend_health_data.dart';
4 6
5 class SelectFriendLogic { 7 class SelectFriendLogic {
6 - SelectFriendLogic(this._friendsController); 8 + SelectFriendLogic({FriendsRepository? repository})
  9 + : _repository =
  10 + repository ?? FriendsRepositoryImpl(Get.find<FriendApi>());
7 11
8 - final FriendsController _friendsController; 12 + final FriendsRepository _repository;
  13 + final friends = <FriendHealthData>[].obs;
  14 + final isLoading = false.obs;
9 final selectedIndex = (-1).obs; 15 final selectedIndex = (-1).obs;
10 16
11 - List<FriendHealthData> get friends => _friendsController.friends;  
12 -  
13 Future<void> initialize() async { 17 Future<void> initialize() async {
14 - if (friends.isEmpty) {  
15 - await _friendsController.loadFriends(); 18 + if (isLoading.value) return;
  19 + isLoading.value = true;
  20 + try {
  21 + friends.assignAll(await _repository.getFriends());
  22 + final watchFaceIndex = friends.indexWhere(
  23 + (friend) => friend.isOnWatchFace,
  24 + );
  25 + selectedIndex.value = friends.isEmpty
  26 + ? -1
  27 + : watchFaceIndex < 0
  28 + ? 0
  29 + : watchFaceIndex;
  30 + } finally {
  31 + isLoading.value = false;
16 } 32 }
17 - final items = friends;  
18 - if (items.isEmpty) return;  
19 - final watchFaceIndex = items.indexWhere((friend) => friend.isOnWatchFace);  
20 - selectedIndex.value = watchFaceIndex < 0 ? 0 : watchFaceIndex;  
21 } 33 }
22 34
23 void selectFriendAt(int index) { 35 void selectFriendAt(int index) {
@@ -25,21 +37,16 @@ class SelectFriendLogic { @@ -25,21 +37,16 @@ class SelectFriendLogic {
25 selectedIndex.value = index; 37 selectedIndex.value = index;
26 } 38 }
27 39
28 - bool syncSelectedFriendToWatchFace() { 40 + Future<FriendHealthData?> syncSelectedFriendToWatchFace() async {
29 final index = selectedIndex.value; 41 final index = selectedIndex.value;
30 - if (index < 0 || index >= friends.length) return false;  
31 -  
32 - final updated = List.generate(  
33 - friends.length,  
34 - (itemIndex) => friends[itemIndex].copyWith(  
35 - isOnWatchFace: itemIndex == index,  
36 - ),  
37 - growable: false,  
38 - );  
39 - _friendsController.friends.assignAll(updated);  
40 -  
41 - // TODO: Submit selected watch-face friend to the friend API when available.  
42 - return true; 42 + if (index < 0 || index >= friends.length) return null;
  43 +
  44 + final selectedFriend = friends[index];
  45 + final userId = selectedFriend.userId;
  46 + if (userId == null) return null;
  47 +
  48 + await _repository.selectWatchFaceFriend(userId);
  49 + return selectedFriend;
43 } 50 }
44 51
45 void dispose() {} 52 void dispose() {}
  1 +import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
  2 +import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
  3 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  4 +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
  5 + as api_models;
  6 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  7 +import 'package:intl/intl.dart';
  8 +
  9 +import '../models/friend_health_data.dart';
  10 +
  11 +abstract class FriendsRepository {
  12 + Future<List<FriendHealthData>> getFriends();
  13 +
  14 + Future<void> updateRemark(int userId, String remark);
  15 +
  16 + Future<void> selectWatchFaceFriend(int userId);
  17 +
  18 + Future<void> deleteFriend(int userId);
  19 +}
  20 +
  21 +class FriendsRepositoryImpl implements FriendsRepository {
  22 + const FriendsRepositoryImpl(this._friendApi);
  23 +
  24 + final FriendApi _friendApi;
  25 +
  26 + @override
  27 + Future<List<FriendHealthData>> getFriends() async {
  28 + return switch (await _friendApi.friendList(true)) {
  29 + AppSuccess(:final data) => data.list.map(_mapFriend).toList(),
  30 + AppFailure(:final error) => throw error,
  31 + };
  32 + }
  33 +
  34 + @override
  35 + Future<void> deleteFriend(int userId) async {
  36 + switch (await _friendApi.deleteFriend(userId)) {
  37 + case AppSuccess():
  38 + return;
  39 + case AppFailure(:final error):
  40 + throw error;
  41 + }
  42 + }
  43 +
  44 + @override
  45 + Future<void> selectWatchFaceFriend(int userId) async {
  46 + switch (await _friendApi.updateFriend(userId, showInDial: 1)) {
  47 + case AppSuccess():
  48 + return;
  49 + case AppFailure(:final error):
  50 + throw error;
  51 + }
  52 + }
  53 +
  54 + @override
  55 + Future<void> updateRemark(int userId, String remark) async {
  56 + switch (await _friendApi.updateFriend(userId, remarkName: remark)) {
  57 + case AppSuccess():
  58 + return;
  59 + case AppFailure(:final error):
  60 + throw error;
  61 + }
  62 + }
  63 +
  64 + FriendHealthData _mapFriend(api_models.FriendItem friend) {
  65 + final healthData = friend.healthData;
  66 + final stressValue = healthData?.latestHrv;
  67 + final name = friend.remarkName?.trim();
  68 +
  69 + return FriendHealthData(
  70 + friendItem: friend,
  71 + userId: friend.friendUserId,
  72 + avatarUrl: friend.avatar,
  73 + name: name == null || name.isEmpty ? '未知好友' : name,
  74 + updatedAt: _updatedAt(friend.updateTime),
  75 + sleepQualityScore: healthData?.sleepEvaluate,
  76 + steps: healthData?.totalSteps == null
  77 + ? null
  78 + : l10n.friendsStepCount(healthData!.totalSteps!),
  79 + statusText: stressValue == null
  80 + ? l10n.friendsWaitingForData
  81 + : HrvStressLevel.fromRealtimeStress(stressValue).label,
  82 + stressValue: stressValue,
  83 + isOnWatchFace: friend.isShowInDial,
  84 + );
  85 + }
  86 +
  87 + String _updatedAt(int? timestamp) {
  88 + if (timestamp == null) return l10n.friendsWaitingForData;
  89 + final milliseconds =
  90 + timestamp < 1000000000000 ? timestamp * 1000 : timestamp;
  91 + final time = DateTime.fromMillisecondsSinceEpoch(milliseconds);
  92 + return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time));
  93 + }
  94 +}
  1 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  3 +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
  4 +import 'package:intl/intl.dart';
  5 +
  6 +abstract class SelfHealthRepository {
  7 + Future<V2HealthData> getHealthData(DateTime date);
  8 +
  9 + Future<V2StressScore> getStressScore(DateTime date);
  10 +}
  11 +
  12 +class SelfHealthRepositoryImpl implements SelfHealthRepository {
  13 + const SelfHealthRepositoryImpl(this._healthApi);
  14 +
  15 + final HealthApi _healthApi;
  16 +
  17 + @override
  18 + Future<V2HealthData> getHealthData(DateTime date) async {
  19 + final intDate = int.parse(DateFormat('yyyyMMdd').format(date));
  20 + return switch (await _healthApi.getV2HealthData(null, intDate)) {
  21 + AppSuccess(:final data) => data,
  22 + AppFailure(:final error) => throw error,
  23 + };
  24 + }
  25 +
  26 + @override
  27 + Future<V2StressScore> getStressScore(DateTime date) async {
  28 + final intDate = int.parse(DateFormat('yyyyMMdd').format(date));
  29 + return switch (await _healthApi.getV2StressScore(null, intDate)) {
  30 + AppSuccess(:final data) => data,
  31 + AppFailure(:final error) => throw error,
  32 + };
  33 + }
  34 +}
@@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
2 import 'package:doublefeel_flutter/app/widget/common_dialog_view.dart'; 2 import 'package:doublefeel_flutter/app/widget/common_dialog_view.dart';
3 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
4 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
  5 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 6
6 import '../models/add_friend_prompt_type.dart'; 7 import '../models/add_friend_prompt_type.dart';
7 8
@@ -15,29 +16,29 @@ class AddFriendPromptDialog extends StatelessWidget { @@ -15,29 +16,29 @@ class AddFriendPromptDialog extends StatelessWidget {
15 iconAsset: null, 16 iconAsset: null,
16 title: _titleFor(type), 17 title: _titleFor(type),
17 message: _messageFor(type), 18 message: _messageFor(type),
18 - confirmText: '我知道了', 19 + confirmText: l10n.friendsPromptGotIt,
19 ); 20 );
20 } 21 }
21 22
22 static String _titleFor(AddFriendPromptType type) { 23 static String _titleFor(AddFriendPromptType type) {
23 switch (type) { 24 switch (type) {
24 case AddFriendPromptType.idNotFound: 25 case AddFriendPromptType.idNotFound:
25 - return '该ID不存在'; 26 + return l10n.friendsPromptIdNotFoundTitle;
26 case AddFriendPromptType.alreadyFriend: 27 case AddFriendPromptType.alreadyFriend:
27 - return '已经是亲密联系人'; 28 + return l10n.friendsPromptAlreadyFriendTitle;
28 case AddFriendPromptType.selfId: 29 case AddFriendPromptType.selfId:
29 - return '不能添加自己'; 30 + return l10n.friendsPromptSelfIdTitle;
30 } 31 }
31 } 32 }
32 33
33 static String _messageFor(AddFriendPromptType type) { 34 static String _messageFor(AddFriendPromptType type) {
34 switch (type) { 35 switch (type) {
35 case AddFriendPromptType.idNotFound: 36 case AddFriendPromptType.idNotFound:
36 - return '这个ID不存在哦,请检查后重新输入'; 37 + return l10n.friendsPromptIdNotFoundMessage;
37 case AddFriendPromptType.alreadyFriend: 38 case AddFriendPromptType.alreadyFriend:
38 - return '你们已经是亲密联系人啦'; 39 + return l10n.friendsPromptAlreadyFriendMessage;
39 case AddFriendPromptType.selfId: 40 case AddFriendPromptType.selfId:
40 - return '请输入亲密联系人的ID'; 41 + return l10n.friendsPromptSelfIdMessage;
41 } 42 }
42 } 43 }
43 44
  1 +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
  2 + as api_models;
  3 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  4 +
  5 +class FriendHealthData {
  6 + const FriendHealthData({
  7 + required this.friendItem,
  8 + this.userId,
  9 + this.avatarUrl,
  10 + required this.name,
  11 + this.remark,
  12 + required this.updatedAt,
  13 + required this.sleepQualityScore,
  14 + required this.steps,
  15 + required this.statusText,
  16 + required this.stressValue,
  17 + this.isOnWatchFace = false,
  18 + });
  19 +
  20 + final api_models.FriendItem friendItem;
  21 + final int? userId;
  22 + final String? avatarUrl;
  23 + final String name;
  24 + final String? remark;
  25 + final String updatedAt;
  26 + final int? sleepQualityScore;
  27 + final String? steps;
  28 + final String statusText;
  29 + final double? stressValue;
  30 + final bool isOnWatchFace;
  31 +
  32 + String get displayName {
  33 + final value = remark?.trim();
  34 + if (value == null || value.isEmpty) return name;
  35 + return l10n.friendsRemarkedDisplayName(value, name);
  36 + }
  37 +
  38 + FriendHealthData copyWith({
  39 + api_models.FriendItem? friendItem,
  40 + int? userId,
  41 + String? avatarUrl,
  42 + String? name,
  43 + String? remark,
  44 + String? updatedAt,
  45 + int? sleepQualityScore,
  46 + String? steps,
  47 + String? statusText,
  48 + double? stressValue,
  49 + bool? isOnWatchFace,
  50 + }) {
  51 + return FriendHealthData(
  52 + friendItem: friendItem ?? this.friendItem,
  53 + userId: userId ?? this.userId,
  54 + avatarUrl: avatarUrl ?? this.avatarUrl,
  55 + name: name ?? this.name,
  56 + remark: remark ?? this.remark,
  57 + updatedAt: updatedAt ?? this.updatedAt,
  58 + sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,
  59 + steps: steps ?? this.steps,
  60 + statusText: statusText ?? this.statusText,
  61 + stressValue: stressValue ?? this.stressValue,
  62 + isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace,
  63 + );
  64 + }
  65 +}
1 import 'package:doublefeel_flutter/app/ext/font_ext.dart'; 1 import 'package:doublefeel_flutter/app/ext/font_ext.dart';
2 -import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';  
3 import 'package:doublefeel_flutter/core/theme/app_colors.dart'; 2 import 'package:doublefeel_flutter/core/theme/app_colors.dart';
4 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 3 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  4 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 import 'package:doublefeel_flutter/r.dart'; 5 import 'package:doublefeel_flutter/r.dart';
6 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
7 import 'package:get/get.dart'; 7 import 'package:get/get.dart';
8 8
9 import '../controllers/add_friend_controller.dart'; 9 import '../controllers/add_friend_controller.dart';
10 -import '../dialog/add_friend_prompt_dialog.dart';  
11 import '../widgets/primary_button.dart'; 10 import '../widgets/primary_button.dart';
12 11
13 class AddFriendView extends GetView<AddFriendController> { 12 class AddFriendView extends GetView<AddFriendController> {
@@ -91,8 +90,8 @@ class AddFriendView extends GetView<AddFriendController> { @@ -91,8 +90,8 @@ class AddFriendView extends GetView<AddFriendController> {
91 child: Column( 90 child: Column(
92 children: [ 91 children: [
93 SizedBox(height: 4), 92 SizedBox(height: 4),
94 - const Text(  
95 - '添加亲密联系人\n多一个人关注你的健康', 93 + Text(
  94 + context.l10n.bindPartnerTitle,
96 textAlign: TextAlign.center, 95 textAlign: TextAlign.center,
97 style: TextStyle( 96 style: TextStyle(
98 color: Color(0xFF141414), 97 color: Color(0xFF141414),
@@ -183,8 +182,8 @@ class AddFriendView extends GetView<AddFriendController> { @@ -183,8 +182,8 @@ class AddFriendView extends GetView<AddFriendController> {
183 padding: EdgeInsets.fromLTRB(32.dp, 28, 32.dp, 0), 182 padding: EdgeInsets.fromLTRB(32.dp, 28, 32.dp, 0),
184 child: Column( 183 child: Column(
185 children: [ 184 children: [
186 - const Text(  
187 - '我的ID', 185 + Text(
  186 + l10n.bindPartnerMyId,
188 textAlign: TextAlign.center, 187 textAlign: TextAlign.center,
189 style: TextStyle( 188 style: TextStyle(
190 color: AppColors.textPrimary, 189 color: AppColors.textPrimary,
@@ -196,7 +195,7 @@ class AddFriendView extends GetView<AddFriendController> { @@ -196,7 +195,7 @@ class AddFriendView extends GetView<AddFriendController> {
196 _MyInviteCode(controller: controller), 195 _MyInviteCode(controller: controller),
197 SizedBox(height: 26), 196 SizedBox(height: 26),
198 PrimaryButton( 197 PrimaryButton(
199 - label: '分享我的码', 198 + label: l10n.bindPartnerShareMyCode,
200 enabled: true, 199 enabled: true,
201 loading: false, 200 loading: false,
202 onTap: controller.copyMyInviteCode, 201 onTap: controller.copyMyInviteCode,
@@ -211,8 +210,8 @@ class AddFriendView extends GetView<AddFriendController> { @@ -211,8 +210,8 @@ class AddFriendView extends GetView<AddFriendController> {
211 padding: EdgeInsets.fromLTRB(32.dp, 46, 32.dp, 0), 210 padding: EdgeInsets.fromLTRB(32.dp, 46, 32.dp, 0),
212 child: Column( 211 child: Column(
213 children: [ 212 children: [
214 - const Text(  
215 - '亲密联系人的ID', 213 + Text(
  214 + l10n.bindPartnerContactId,
216 textAlign: TextAlign.center, 215 textAlign: TextAlign.center,
217 style: TextStyle( 216 style: TextStyle(
218 color: Color(0xFF141414), 217 color: Color(0xFF141414),
@@ -226,7 +225,7 @@ class AddFriendView extends GetView<AddFriendController> { @@ -226,7 +225,7 @@ class AddFriendView extends GetView<AddFriendController> {
226 SizedBox(height: 16), 225 SizedBox(height: 16),
227 Obx( 226 Obx(
228 () => PrimaryButton( 227 () => PrimaryButton(
229 - label: '添加', 228 + label: l10n.friendsAddAction,
230 enabled: controller.canSubmit && !controller.isSubmitting.value, 229 enabled: controller.canSubmit && !controller.isSubmitting.value,
231 loading: controller.isSubmitting.value, 230 loading: controller.isSubmitting.value,
232 backgroundColor: const Color(0xFFFF916A), 231 backgroundColor: const Color(0xFFFF916A),
@@ -265,8 +264,8 @@ class AddFriendView extends GetView<AddFriendController> { @@ -265,8 +264,8 @@ class AddFriendView extends GetView<AddFriendController> {
265 color: Color(0xFFE8DDFF), 264 color: Color(0xFFE8DDFF),
266 shape: BoxShape.circle, 265 shape: BoxShape.circle,
267 ), 266 ),
268 - child: const Text(  
269 - '或', 267 + child: Text(
  268 + l10n.bindPartnerOr,
270 style: TextStyle( 269 style: TextStyle(
271 color: AppColors.primary, 270 color: AppColors.primary,
272 fontSize: 14, 271 fontSize: 14,
@@ -292,20 +291,9 @@ class AddFriendView extends GetView<AddFriendController> { @@ -292,20 +291,9 @@ class AddFriendView extends GetView<AddFriendController> {
292 await Future.delayed(const Duration(milliseconds: 120)); 291 await Future.delayed(const Duration(milliseconds: 120));
293 } 292 }
294 293
295 - final promptType = await controller.submitFriendCode();  
296 - if (promptType == null) {  
297 - controller.friendCodeFocusNode.canRequestFocus = true;  
298 - return;  
299 - }  
300 -  
301 - try {  
302 - await DialogUtils.showCommonDialog(  
303 - AddFriendPromptDialog.metaDataFor(promptType),  
304 - barrierColor: Colors.black.withValues(alpha: 0.45),  
305 - );  
306 - } finally { 294 + final didAddFriend = await controller.submitFriendCode();
  295 + if (!didAddFriend) {
307 controller.friendCodeFocusNode.canRequestFocus = true; 296 controller.friendCodeFocusNode.canRequestFocus = true;
308 - _dismissKeyboard();  
309 } 297 }
310 } 298 }
311 299
@@ -384,10 +372,10 @@ class _FriendIdInput extends StatelessWidget { @@ -384,10 +372,10 @@ class _FriendIdInput extends StatelessWidget {
384 textAlign: TextAlign.center, 372 textAlign: TextAlign.center,
385 minLines: 1, 373 minLines: 1,
386 maxLines: 1, 374 maxLines: 1,
387 - decoration: const InputDecoration( 375 + decoration: InputDecoration(
388 border: InputBorder.none, 376 border: InputBorder.none,
389 - hintText: '输入ID',  
390 - hintStyle: TextStyle( 377 + hintText: context.l10n.friendsEnterId,
  378 + hintStyle: const TextStyle(
391 color: AppColors.disabled, 379 color: AppColors.disabled,
392 fontSize: 26, 380 fontSize: 26,
393 fontWeight: FontWeightExt.medium, 381 fontWeight: FontWeightExt.medium,
@@ -11,7 +11,7 @@ class FriendHomePage extends GetView<TodayController> { @@ -11,7 +11,7 @@ class FriendHomePage extends GetView<TodayController> {
11 11
12 /// 与 FriendHomeBinding 中注册的 tag 保持一致 12 /// 与 FriendHomeBinding 中注册的 tag 保持一致
13 @override 13 @override
14 - String? get tag => 'friend_${_args.userId}'; 14 + String? get tag => 'friend_${_args.friendUserId}';
15 15
16 @override 16 @override
17 Widget build(BuildContext context) { 17 Widget build(BuildContext context) {
  1 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 import 'package:flutter/services.dart'; 3 import 'package:flutter/services.dart';
3 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
@@ -29,12 +30,13 @@ class FriendTrendView extends GetView<FriendTrendController> { @@ -29,12 +30,13 @@ class FriendTrendView extends GetView<FriendTrendController> {
29 icon: const Icon(Icons.arrow_back_ios_new_rounded), 30 icon: const Icon(Icons.arrow_back_ios_new_rounded),
30 color: const Color(0xFF0F0F11), 31 color: const Color(0xFF0F0F11),
31 iconSize: 20, 32 iconSize: 20,
32 - tooltip: '返回', 33 + tooltip: context.l10n.friendsBack,
33 ), 34 ),
34 titleSpacing: 0, 35 titleSpacing: 0,
35 title: _FriendTrendTitle( 36 title: _FriendTrendTitle(
36 - title: '${controller.friendName}的趋势',  
37 - avatarUrl: controller.avatarUrl, 37 + title: context.l10n
  38 + .friendsTrendTitle(controller.friendItem?.remarkName ?? ""),
  39 + avatarUrl: controller.friendItem?.avatar ?? "",
38 ), 40 ),
39 ), 41 ),
40 body: Stack( 42 body: Stack(
@@ -58,6 +60,8 @@ class FriendTrendView extends GetView<FriendTrendController> { @@ -58,6 +60,8 @@ class FriendTrendView extends GetView<FriendTrendController> {
58 query: controller.query, 60 query: controller.query,
59 selectedTypeIndex: controller.selectedTypeIndex.value, 61 selectedTypeIndex: controller.selectedTypeIndex.value,
60 onTypeChanged: controller.changeType, 62 onTypeChanged: controller.changeType,
  63 + onPeriodChanged: controller.changePeriod,
  64 + onDateChanged: controller.changeDate,
61 ), 65 ),
62 ), 66 ),
63 ), 67 ),
1 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 1 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  2 +import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
  3 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
2 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 4 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
3 import 'package:doublefeel_flutter/r.dart'; 5 import 'package:doublefeel_flutter/r.dart';
  6 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:flutter/material.dart'; 7 import 'package:flutter/material.dart';
5 import 'package:get/get.dart'; 8 import 'package:get/get.dart';
  9 +import 'package:intl/intl.dart';
6 10
7 import '../controllers/friends_controller.dart'; 11 import '../controllers/friends_controller.dart';
8 -import '../controllers/friend_trend_controller.dart'; 12 +import '../models/friend_health_data.dart';
9 import '../widgets/friend_health_card.dart'; 13 import '../widgets/friend_health_card.dart';
10 -import 'select_friend_view.dart';  
11 14
12 class FriendsTab extends GetView<FriendsController> { 15 class FriendsTab extends GetView<FriendsController> {
13 const FriendsTab({super.key}); 16 const FriendsTab({super.key});
14 17
15 @override 18 @override
16 Widget build(BuildContext context) { 19 Widget build(BuildContext context) {
  20 + final userPreferences = Get.find<UserPreferencesStorage>();
17 return Scaffold( 21 return Scaffold(
18 - backgroundColor: const Color(0xFFF2F2F7), 22 + backgroundColor: Colors.white,
19 body: SafeArea( 23 body: SafeArea(
20 bottom: false, 24 bottom: false,
21 child: Column( 25 child: Column(
@@ -23,87 +27,133 @@ class FriendsTab extends GetView<FriendsController> { @@ -23,87 +27,133 @@ class FriendsTab extends GetView<FriendsController> {
23 children: [ 27 children: [
24 const _FriendsHeader(), 28 const _FriendsHeader(),
25 Expanded( 29 Expanded(
26 - child: Obx(  
27 - () {  
28 - final friends = controller.friends;  
29 - final hasFriends = friends.isNotEmpty;  
30 - final isFull = controller.isFull; 30 + child: ColoredBox(
  31 + color: const Color(0xFFF5F2FF),
  32 + child: Obx(
  33 + () {
  34 + final friends = controller.friends;
  35 + final hasFriends = friends.isNotEmpty;
  36 + final isFull = controller.isFull;
  37 + final healthData = controller.selfHealthData.value;
  38 + final stressScore = controller.selfStressScore.value;
  39 + final updatedAt = controller.selfHealthUpdatedAt.value;
  40 + final currentUser =
  41 + userPreferences.preferences.value.meUserInfo;
  42 + final isSelfInitialLoading =
  43 + controller.isSelfHealthLoading.value &&
  44 + healthData == null &&
  45 + stressScore == null;
  46 + final isFriendsInitialLoading =
  47 + controller.isLoading.value && !hasFriends;
  48 + final stressValue = stressScore?.state == 0
  49 + ? null
  50 + : stressScore?.comprehensiveScore?.toDouble();
31 51
32 - return Stack(  
33 - children: [  
34 - RefreshIndicator(  
35 - onRefresh: controller.loadFriends,  
36 - child: ListView(  
37 - padding: EdgeInsets.fromLTRB(  
38 - 16,  
39 - 0,  
40 - 16,  
41 - hasFriends ? 188.dp : 172,  
42 - ),  
43 - children: [  
44 - const FriendHealthCard(  
45 - name: '蓝胖子(我)',  
46 - updatedAt: '更新于15:12',  
47 - sleepQualityScore: 92,  
48 - steps: '8424步',  
49 - statusText: '注意压力',  
50 - stressValue: 72,  
51 - isSelf: true, 52 + return Stack(
  53 + children: [
  54 + RefreshIndicator(
  55 + onRefresh: controller.refreshData,
  56 + child: ListView(
  57 + physics: const AlwaysScrollableScrollPhysics(),
  58 + padding: EdgeInsets.fromLTRB(
  59 + 0,
  60 + 0,
  61 + 0,
  62 + hasFriends ? 188.dp : 172,
52 ), 63 ),
53 - if (!hasFriends)  
54 - const _EmptyFriendsView()  
55 - else ...[  
56 - const SizedBox(height: 12),  
57 - ...friends.map(  
58 - (friend) => Padding(  
59 - padding: const EdgeInsets.only(bottom: 12),  
60 - child: FriendHealthCard(  
61 - name: friend.name,  
62 - updatedAt: friend.updatedAt,  
63 - sleepQualityScore: friend.sleepQualityScore,  
64 - steps: friend.steps,  
65 - statusText: friend.statusText,  
66 - stressValue: friend.stressValue,  
67 - isOnWatchFace: friend.isOnWatchFace,  
68 - onTap: () => _openFriendTrend(friend),  
69 - onMoreSelected: (action) {  
70 - _handleFriendAction(action, friend);  
71 - },  
72 - ),  
73 - ),  
74 - ),  
75 - ],  
76 - if (!hasFriends) ...[  
77 - const SizedBox(height: 8),  
78 - _AddFriendButton(  
79 - enabled: !isFull,  
80 - onTap: isFull ? null : _handleAddFriend,  
81 - ),  
82 - if (isFull) const _FullFriendsTip(),  
83 - ],  
84 - ],  
85 - ),  
86 - ),  
87 - if (hasFriends)  
88 - Positioned(  
89 - left: 0,  
90 - right: 0,  
91 - bottom: _floatingAddButtonBottom(context),  
92 - child: Column(  
93 children: [ 64 children: [
94 - _AddFriendButton(  
95 - enabled: !isFull,  
96 - friendCount: friends.length,  
97 - maxFriends: FriendsController.maxFriends,  
98 - onTap: isFull ? null : _handleAddFriend, 65 + _SelfHealthCard(
  66 + name:
  67 + currentUser?.nickname?.trim().isNotEmpty ==
  68 + true
  69 + ? currentUser!.nickname!.trim()
  70 + : '-',
  71 + avatarUrl: currentUser?.avatar,
  72 + updatedAt: updatedAt == null
  73 + ? context.l10n.friendsWaitingForData
  74 + : context.l10n.friendsUpdatedAt(
  75 + DateFormat('HH:mm').format(updatedAt),
  76 + ),
  77 + sleepQualityScore:
  78 + _normalizedScore(healthData?.sleepScore),
  79 + steps: healthData?.steps == null
  80 + ? null
  81 + : context.l10n.friendsStepCount(
  82 + healthData!.steps!,
  83 + ),
  84 + statusText: stressValue == null
  85 + ? context.l10n.friendsWaitingForData
  86 + : HrvStressLevel.fromRealtimeStress(
  87 + stressValue,
  88 + ).label,
  89 + stressValue: stressValue,
  90 + isLoading: isSelfInitialLoading,
99 ), 91 ),
100 - if (isFull) const _FullFriendsTip(), 92 + if (isFriendsInitialLoading)
  93 + const _FriendsLoadingIndicator()
  94 + else if (!hasFriends)
  95 + const _EmptyFriendsView()
  96 + else ...[
  97 + const SizedBox(height: 12),
  98 + ...friends.map(
  99 + (friend) => Padding(
  100 + padding: const EdgeInsets.fromLTRB(
  101 + 16,
  102 + 0,
  103 + 16,
  104 + 12,
  105 + ),
  106 + child: FriendHealthCard(
  107 + name: friend.name,
  108 + remark: friend.remark,
  109 + avatarUrl: friend.avatarUrl,
  110 + updatedAt: friend.updatedAt,
  111 + sleepQualityScore:
  112 + friend.sleepQualityScore,
  113 + steps: friend.steps,
  114 + statusText: friend.statusText,
  115 + stressValue: friend.stressValue,
  116 + isOnWatchFace: friend.isOnWatchFace,
  117 + onTap: () => _openFriendHome(friend),
  118 + onMoreSelected: (action) {
  119 + _handleFriendAction(action, friend);
  120 + },
  121 + ),
  122 + ),
  123 + ),
  124 + ],
  125 + if (!hasFriends && !isFriendsInitialLoading) ...[
  126 + const SizedBox(height: 8),
  127 + _AddFriendButton(
  128 + enabled: !isFull,
  129 + onTap: isFull ? null : _handleAddFriend,
  130 + ),
  131 + if (isFull) const _FullFriendsTip(),
  132 + ],
101 ], 133 ],
102 ), 134 ),
103 ), 135 ),
104 - ],  
105 - );  
106 - }, 136 + if (hasFriends)
  137 + Positioned(
  138 + left: 0,
  139 + right: 0,
  140 + bottom: _floatingAddButtonBottom(context),
  141 + child: Column(
  142 + children: [
  143 + _AddFriendButton(
  144 + enabled: !isFull,
  145 + friendCount: friends.length,
  146 + maxFriends: FriendsController.maxFriends,
  147 + onTap: isFull ? null : _handleAddFriend,
  148 + ),
  149 + if (isFull) const _FullFriendsTip(),
  150 + ],
  151 + ),
  152 + ),
  153 + ],
  154 + );
  155 + },
  156 + ),
107 ), 157 ),
108 ), 158 ),
109 ], 159 ],
@@ -122,20 +172,14 @@ class FriendsTab extends GetView<FriendsController> { @@ -122,20 +172,14 @@ class FriendsTab extends GetView<FriendsController> {
122 } 172 }
123 173
124 Future<void> _handleAddFriend() async { 174 Future<void> _handleAddFriend() async {
125 - await Get.toNamed(Routes.ADD_FRIEND);  
126 - await controller.loadFriends(); 175 + final didAddFriend = await Get.toNamed(Routes.ADD_FRIEND);
  176 + if (didAddFriend == true) await controller.loadFriends();
127 } 177 }
128 178
129 - void _openFriendTrend(FriendHealthData friend) {  
130 - final userId = friend.userId;  
131 - if (userId == null) return; 179 + void _openFriendHome(FriendHealthData friend) {
132 Get.toNamed( 180 Get.toNamed(
133 - Routes.FRIEND_TREND,  
134 - arguments: FriendTrendArguments(  
135 - userId: userId,  
136 - name: friend.name,  
137 - avatarUrl: friend.avatarUrl,  
138 - ), 181 + Routes.FRIEND_HOME,
  182 + arguments: friend.friendItem,
139 ); 183 );
140 } 184 }
141 185
@@ -146,24 +190,87 @@ class FriendsTab extends GetView<FriendsController> { @@ -146,24 +190,87 @@ class FriendsTab extends GetView<FriendsController> {
146 case FriendCardAction.remove: 190 case FriendCardAction.remove:
147 controller.showDeleteConfirmDialog(friend); 191 controller.showDeleteConfirmDialog(friend);
148 case FriendCardAction.watchFace: 192 case FriendCardAction.watchFace:
149 - _showSelectFriendSheet(); 193 + controller.applyWatchFaceSelection(friend);
150 } 194 }
151 } 195 }
152 196
153 - void _showSelectFriendSheet() {  
154 - final context = Get.context;  
155 - if (context == null) return; 197 + int? _normalizedScore(double? value) {
  198 + if (value == null || !value.isFinite) return null;
  199 + return value.round().clamp(0, 100);
  200 + }
  201 +}
  202 +
  203 +class _SelfHealthCard extends StatelessWidget {
  204 + const _SelfHealthCard({
  205 + required this.name,
  206 + required this.avatarUrl,
  207 + required this.updatedAt,
  208 + required this.sleepQualityScore,
  209 + required this.steps,
  210 + required this.statusText,
  211 + required this.stressValue,
  212 + required this.isLoading,
  213 + });
  214 +
  215 + final String name;
  216 + final String? avatarUrl;
  217 + final String updatedAt;
  218 + final int? sleepQualityScore;
  219 + final String? steps;
  220 + final String statusText;
  221 + final double? stressValue;
  222 + final bool isLoading;
  223 +
  224 + @override
  225 + Widget build(BuildContext context) {
  226 + const borderRadius = BorderRadius.vertical(bottom: Radius.circular(12));
  227 + return Stack(
  228 + children: [
  229 + FriendHealthCard(
  230 + name: name,
  231 + avatarUrl: avatarUrl,
  232 + updatedAt: updatedAt,
  233 + sleepQualityScore: sleepQualityScore,
  234 + steps: steps,
  235 + statusText: statusText,
  236 + stressValue: stressValue,
  237 + isSelf: true,
  238 + borderRadius: borderRadius,
  239 + contentPadding: const EdgeInsets.fromLTRB(36, 20, 35, 20),
  240 + ),
  241 + if (isLoading)
  242 + const Positioned.fill(
  243 + child: DecoratedBox(
  244 + decoration: BoxDecoration(
  245 + color: Color(0xCCFFFFFF),
  246 + borderRadius: borderRadius,
  247 + ),
  248 + child: Center(
  249 + child: SizedBox.square(
  250 + dimension: 28,
  251 + child: CircularProgressIndicator(strokeWidth: 2.5),
  252 + ),
  253 + ),
  254 + ),
  255 + ),
  256 + ],
  257 + );
  258 + }
  259 +}
  260 +
  261 +class _FriendsLoadingIndicator extends StatelessWidget {
  262 + const _FriendsLoadingIndicator();
156 263
157 - final mediaQuery = MediaQuery.of(context);  
158 - Get.bottomSheet<void>(  
159 - SizedBox(  
160 - height: mediaQuery.size.height - mediaQuery.viewPadding.top,  
161 - child: SelectFriendView(friendsController: controller), 264 + @override
  265 + Widget build(BuildContext context) {
  266 + return const Padding(
  267 + padding: EdgeInsets.symmetric(vertical: 48),
  268 + child: Center(
  269 + child: SizedBox.square(
  270 + dimension: 24,
  271 + child: CircularProgressIndicator(strokeWidth: 2.5),
  272 + ),
162 ), 273 ),
163 - barrierColor: Colors.black.withValues(alpha: 0.7),  
164 - enableDrag: true,  
165 - isScrollControlled: true,  
166 - persistent: false,  
167 ); 274 );
168 } 275 }
169 } 276 }
@@ -179,9 +286,9 @@ class _FriendsHeader extends StatelessWidget { @@ -179,9 +286,9 @@ class _FriendsHeader extends StatelessWidget {
179 padding: const EdgeInsets.symmetric(horizontal: 16), 286 padding: const EdgeInsets.symmetric(horizontal: 16),
180 child: Row( 287 child: Row(
181 children: [ 288 children: [
182 - const Text(  
183 - '好友',  
184 - style: TextStyle( 289 + Text(
  290 + context.l10n.tabFriends,
  291 + style: const TextStyle(
185 color: Color(0xFF0F0F11), 292 color: Color(0xFF0F0F11),
186 fontSize: 24, 293 fontSize: 24,
187 fontWeight: FontWeight.w600, 294 fontWeight: FontWeight.w600,
@@ -237,9 +344,9 @@ class _EmptyFriendsView extends StatelessWidget { @@ -237,9 +344,9 @@ class _EmptyFriendsView extends StatelessWidget {
237 ), 344 ),
238 ), 345 ),
239 const SizedBox(height: 16), 346 const SizedBox(height: 16),
240 - const Text(  
241 - '添加亲密联系人,多一个人关注你的健康',  
242 - style: TextStyle( 347 + Text(
  348 + context.l10n.friendsAddCloseContactDescription,
  349 + style: const TextStyle(
243 color: Color(0xFF78787D), 350 color: Color(0xFF78787D),
244 fontSize: 12, 351 fontSize: 12,
245 fontWeight: FontWeight.w400, 352 fontWeight: FontWeight.w400,
@@ -256,12 +363,12 @@ class _FullFriendsTip extends StatelessWidget { @@ -256,12 +363,12 @@ class _FullFriendsTip extends StatelessWidget {
256 363
257 @override 364 @override
258 Widget build(BuildContext context) { 365 Widget build(BuildContext context) {
259 - return const Padding(  
260 - padding: EdgeInsets.only(top: 10), 366 + return Padding(
  367 + padding: const EdgeInsets.only(top: 10),
261 child: Center( 368 child: Center(
262 child: Text( 369 child: Text(
263 - '好友数量已达上限',  
264 - style: TextStyle( 370 + context.l10n.friendsLimitReached,
  371 + style: const TextStyle(
265 color: Color(0xFFB0B0B6), 372 color: Color(0xFFB0B0B6),
266 fontSize: 12, 373 fontSize: 12,
267 fontWeight: FontWeight.w400, 374 fontWeight: FontWeight.w400,
@@ -312,7 +419,7 @@ class _AddFriendButton extends StatelessWidget { @@ -312,7 +419,7 @@ class _AddFriendButton extends StatelessWidget {
312 ), 419 ),
313 const SizedBox(width: 6), 420 const SizedBox(width: 6),
314 Text( 421 Text(
315 - _buttonText, 422 + _buttonText(context),
316 style: const TextStyle( 423 style: const TextStyle(
317 color: Colors.white, 424 color: Colors.white,
318 fontSize: 16, 425 fontSize: 16,
@@ -328,10 +435,12 @@ class _AddFriendButton extends StatelessWidget { @@ -328,10 +435,12 @@ class _AddFriendButton extends StatelessWidget {
328 ); 435 );
329 } 436 }
330 437
331 - String get _buttonText { 438 + String _buttonText(BuildContext context) {
332 final count = friendCount; 439 final count = friendCount;
333 final max = maxFriends; 440 final max = maxFriends;
334 - if (count == null || max == null) return '添加亲密联系人';  
335 - return '添加亲密联系人($count/$max)'; 441 + if (count == null || max == null) {
  442 + return context.l10n.friendsAddCloseContact;
  443 + }
  444 + return context.l10n.friendsAddCloseContactWithCount(count, max);
336 } 445 }
337 } 446 }
1 import 'package:doublefeel_flutter/core/theme/app_colors.dart'; 1 import 'package:doublefeel_flutter/core/theme/app_colors.dart';
2 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 2 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  3 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
3 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
4 import 'package:get/get.dart'; 5 import 'package:get/get.dart';
5 6
6 -import '../controllers/friends_controller.dart';  
7 import '../controllers/select_friend_logic.dart'; 7 import '../controllers/select_friend_logic.dart';
8 import '../widgets/friend_health_card.dart'; 8 import '../widgets/friend_health_card.dart';
9 9
10 class SelectFriendView extends StatefulWidget { 10 class SelectFriendView extends StatefulWidget {
11 - const SelectFriendView({super.key, required this.friendsController});  
12 -  
13 - final FriendsController friendsController; 11 + const SelectFriendView({super.key});
14 12
15 @override 13 @override
16 State<SelectFriendView> createState() => _SelectFriendViewState(); 14 State<SelectFriendView> createState() => _SelectFriendViewState();
@@ -22,7 +20,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { @@ -22,7 +20,7 @@ class _SelectFriendViewState extends State<SelectFriendView> {
22 @override 20 @override
23 void initState() { 21 void initState() {
24 super.initState(); 22 super.initState();
25 - _logic = SelectFriendLogic(widget.friendsController); 23 + _logic = SelectFriendLogic();
26 _logic.initialize(); 24 _logic.initialize();
27 } 25 }
28 26
@@ -47,8 +45,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { @@ -47,8 +45,7 @@ class _SelectFriendViewState extends State<SelectFriendView> {
47 _SelectFriendHeader(onClose: () => Get.back<void>()), 45 _SelectFriendHeader(onClose: () => Get.back<void>()),
48 Expanded( 46 Expanded(
49 child: Obx(() { 47 child: Obx(() {
50 - if (widget.friendsController.isLoading.value &&  
51 - _logic.friends.isEmpty) { 48 + if (_logic.isLoading.value && _logic.friends.isEmpty) {
52 return const Center(child: CircularProgressIndicator()); 49 return const Center(child: CircularProgressIndicator());
53 } 50 }
54 51
@@ -65,6 +62,8 @@ class _SelectFriendViewState extends State<SelectFriendView> { @@ -65,6 +62,8 @@ class _SelectFriendViewState extends State<SelectFriendView> {
65 final isSelected = selectedIndex == index; 62 final isSelected = selectedIndex == index;
66 return FriendHealthCard( 63 return FriendHealthCard(
67 name: friend.name, 64 name: friend.name,
  65 + remark: friend.remark,
  66 + avatarUrl: friend.avatarUrl,
68 updatedAt: friend.updatedAt, 67 updatedAt: friend.updatedAt,
69 sleepQualityScore: friend.sleepQualityScore, 68 sleepQualityScore: friend.sleepQualityScore,
70 steps: friend.steps, 69 steps: friend.steps,
@@ -94,10 +93,9 @@ class _SelectFriendViewState extends State<SelectFriendView> { @@ -94,10 +93,9 @@ class _SelectFriendViewState extends State<SelectFriendView> {
94 ); 93 );
95 } 94 }
96 95
97 - void _syncSelectedFriend() {  
98 - if (_logic.syncSelectedFriendToWatchFace()) {  
99 - Get.back<void>();  
100 - } 96 + Future<void> _syncSelectedFriend() async {
  97 + final selectedFriend = await _logic.syncSelectedFriendToWatchFace();
  98 + if (selectedFriend != null) Get.back(result: selectedFriend);
101 } 99 }
102 } 100 }
103 101
@@ -130,11 +128,11 @@ class _SelectFriendHeader extends StatelessWidget { @@ -130,11 +128,11 @@ class _SelectFriendHeader extends StatelessWidget {
130 ), 128 ),
131 ), 129 ),
132 ), 130 ),
133 - const Expanded( 131 + Expanded(
134 child: Center( 132 child: Center(
135 child: Text( 133 child: Text(
136 - '选择好友',  
137 - style: TextStyle( 134 + context.l10n.friendsSelect,
  135 + style: const TextStyle(
138 color: AppColors.textPrimary, 136 color: AppColors.textPrimary,
139 fontSize: 16, 137 fontSize: 16,
140 fontWeight: FontWeight.w600, 138 fontWeight: FontWeight.w600,
@@ -167,9 +165,9 @@ class _SyncButton extends StatelessWidget { @@ -167,9 +165,9 @@ class _SyncButton extends StatelessWidget {
167 color: AppColors.primary, 165 color: AppColors.primary,
168 borderRadius: BorderRadius.circular(24.dp), 166 borderRadius: BorderRadius.circular(24.dp),
169 ), 167 ),
170 - child: const Text(  
171 - '选择并同步至表盘',  
172 - style: TextStyle( 168 + child: Text(
  169 + context.l10n.friendsSelectAndSync,
  170 + style: const TextStyle(
173 color: Colors.white, 171 color: Colors.white,
174 fontSize: 16, 172 fontSize: 16,
175 fontWeight: FontWeight.w600, 173 fontWeight: FontWeight.w600,
1 import 'dart:math' as math; 1 import 'dart:math' as math;
2 2
  3 +import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
3 import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart'; 4 import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart';
  5 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:doublefeel_flutter/r.dart'; 6 import 'package:doublefeel_flutter/r.dart';
5 import 'package:flutter/material.dart'; 7 import 'package:flutter/material.dart';
6 8
@@ -14,6 +16,8 @@ class FriendHealthCard extends StatelessWidget { @@ -14,6 +16,8 @@ class FriendHealthCard extends StatelessWidget {
14 const FriendHealthCard({ 16 const FriendHealthCard({
15 super.key, 17 super.key,
16 required this.name, 18 required this.name,
  19 + this.remark,
  20 + this.avatarUrl,
17 required this.updatedAt, 21 required this.updatedAt,
18 required this.sleepQualityScore, 22 required this.sleepQualityScore,
19 required this.steps, 23 required this.steps,
@@ -23,11 +27,15 @@ class FriendHealthCard extends StatelessWidget { @@ -23,11 +27,15 @@ class FriendHealthCard extends StatelessWidget {
23 this.isOnWatchFace = false, 27 this.isOnWatchFace = false,
24 this.isSelected = false, 28 this.isSelected = false,
25 this.showCheckbox = false, 29 this.showCheckbox = false,
  30 + this.borderRadius,
  31 + this.contentPadding,
26 this.onTap, 32 this.onTap,
27 this.onMoreSelected, 33 this.onMoreSelected,
28 }); 34 });
29 35
30 final String name; 36 final String name;
  37 + final String? remark;
  38 + final String? avatarUrl;
31 final String updatedAt; 39 final String updatedAt;
32 final int? sleepQualityScore; 40 final int? sleepQualityScore;
33 final String? steps; 41 final String? steps;
@@ -37,13 +45,14 @@ class FriendHealthCard extends StatelessWidget { @@ -37,13 +45,14 @@ class FriendHealthCard extends StatelessWidget {
37 final bool isOnWatchFace; 45 final bool isOnWatchFace;
38 final bool isSelected; 46 final bool isSelected;
39 final bool showCheckbox; 47 final bool showCheckbox;
  48 + final BorderRadius? borderRadius;
  49 + final EdgeInsetsGeometry? contentPadding;
40 final VoidCallback? onTap; 50 final VoidCallback? onTap;
41 final ValueChanged<FriendCardAction>? onMoreSelected; 51 final ValueChanged<FriendCardAction>? onMoreSelected;
42 52
43 @override 53 @override
44 Widget build(BuildContext context) { 54 Widget build(BuildContext context) {
45 final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore); 55 final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore);
46 - final hasData = sleepQualityScore != null || steps != null;  
47 56
48 return GestureDetector( 57 return GestureDetector(
49 onTap: onTap, 58 onTap: onTap,
@@ -54,10 +63,11 @@ class FriendHealthCard extends StatelessWidget { @@ -54,10 +63,11 @@ class FriendHealthCard extends StatelessWidget {
54 AnimatedContainer( 63 AnimatedContainer(
55 duration: const Duration(milliseconds: 180), 64 duration: const Duration(milliseconds: 180),
56 curve: Curves.easeOut, 65 curve: Curves.easeOut,
57 - padding: const EdgeInsets.fromLTRB(20, 20, 16, 20), 66 + padding:
  67 + contentPadding ?? const EdgeInsets.fromLTRB(20, 20, 16, 20),
58 decoration: BoxDecoration( 68 decoration: BoxDecoration(
59 color: Colors.white, 69 color: Colors.white,
60 - borderRadius: BorderRadius.circular(20), 70 + borderRadius: borderRadius ?? BorderRadius.circular(20),
61 border: Border.all( 71 border: Border.all(
62 color: 72 color:
63 isSelected ? const Color(0xFF845EEE) : Colors.transparent, 73 isSelected ? const Color(0xFF845EEE) : Colors.transparent,
@@ -68,9 +78,12 @@ class FriendHealthCard extends StatelessWidget { @@ -68,9 +78,12 @@ class FriendHealthCard extends StatelessWidget {
68 children: [ 78 children: [
69 _FriendCardHeader( 79 _FriendCardHeader(
70 name: name, 80 name: name,
  81 + remark: remark,
  82 + avatarUrl: avatarUrl,
71 updatedAt: updatedAt, 83 updatedAt: updatedAt,
72 isSelf: isSelf, 84 isSelf: isSelf,
73 - showMoreButton: !showCheckbox, 85 + isOnWatchFace: isOnWatchFace,
  86 + showMoreButton: !showCheckbox && !isSelf,
74 onMoreSelected: onMoreSelected, 87 onMoreSelected: onMoreSelected,
75 ), 88 ),
76 const SizedBox(height: 17), 89 const SizedBox(height: 17),
@@ -81,7 +94,7 @@ class FriendHealthCard extends StatelessWidget { @@ -81,7 +94,7 @@ class FriendHealthCard extends StatelessWidget {
81 child: _StatusFigure( 94 child: _StatusFigure(
82 statusText: statusText, 95 statusText: statusText,
83 stressValue: stressValue, 96 stressValue: stressValue,
84 - waiting: !hasData, 97 + waiting: stressValue == null,
85 ), 98 ),
86 ), 99 ),
87 const SizedBox(width: 18), 100 const SizedBox(width: 18),
@@ -91,8 +104,9 @@ class FriendHealthCard extends StatelessWidget { @@ -91,8 +104,9 @@ class FriendHealthCard extends StatelessWidget {
91 children: [ 104 children: [
92 _MetricTile( 105 _MetricTile(
93 iconPath: R.assetsImagesHealthFriendSleepQuality, 106 iconPath: R.assetsImagesHealthFriendSleepQuality,
94 - title: '睡眠质量',  
95 - value: sleepQualityLevel.shortText, 107 + title: context.l10n.friendsSleepQuality,
  108 + value:
  109 + _sleepQualityText(context, sleepQualityLevel),
96 valueColor: sleepQualityScore == null 110 valueColor: sleepQualityScore == null
97 ? null 111 ? null
98 : Color(sleepQualityLevel.qualityColorValue), 112 : Color(sleepQualityLevel.qualityColorValue),
@@ -100,7 +114,7 @@ class FriendHealthCard extends StatelessWidget { @@ -100,7 +114,7 @@ class FriendHealthCard extends StatelessWidget {
100 const SizedBox(height: 4), 114 const SizedBox(height: 4),
101 _MetricTile( 115 _MetricTile(
102 iconPath: R.assetsImagesHealthFriendSteps, 116 iconPath: R.assetsImagesHealthFriendSteps,
103 - title: '今日步数', 117 + title: context.l10n.friendsTodaySteps,
104 value: steps ?? '-', 118 value: steps ?? '-',
105 ), 119 ),
106 ], 120 ],
@@ -132,35 +146,75 @@ class FriendHealthCard extends StatelessWidget { @@ -132,35 +146,75 @@ class FriendHealthCard extends StatelessWidget {
132 ), 146 ),
133 ); 147 );
134 } 148 }
  149 +
  150 + String _sleepQualityText(
  151 + BuildContext context,
  152 + SleepQualityLevel level,
  153 + ) {
  154 + switch (level) {
  155 + case SleepQualityLevel.excellent:
  156 + return context.l10n.friendsSleepQualityExcellent;
  157 + case SleepQualityLevel.good:
  158 + return context.l10n.friendsSleepQualityNormal;
  159 + case SleepQualityLevel.poor:
  160 + return context.l10n.friendsSleepQualityAttention;
  161 + case SleepQualityLevel.unknown:
  162 + return '-';
  163 + }
  164 + }
135 } 165 }
136 166
137 class _FriendCardHeader extends StatelessWidget { 167 class _FriendCardHeader extends StatelessWidget {
138 const _FriendCardHeader({ 168 const _FriendCardHeader({
139 required this.name, 169 required this.name,
  170 + required this.remark,
  171 + required this.avatarUrl,
140 required this.updatedAt, 172 required this.updatedAt,
141 required this.isSelf, 173 required this.isSelf,
  174 + required this.isOnWatchFace,
142 required this.showMoreButton, 175 required this.showMoreButton,
143 this.onMoreSelected, 176 this.onMoreSelected,
144 }); 177 });
145 178
146 final String name; 179 final String name;
  180 + final String? remark;
  181 + final String? avatarUrl;
147 final String updatedAt; 182 final String updatedAt;
148 final bool isSelf; 183 final bool isSelf;
  184 + final bool isOnWatchFace;
149 final bool showMoreButton; 185 final bool showMoreButton;
150 final ValueChanged<FriendCardAction>? onMoreSelected; 186 final ValueChanged<FriendCardAction>? onMoreSelected;
151 187
152 @override 188 @override
153 Widget build(BuildContext context) { 189 Widget build(BuildContext context) {
  190 + final friendRemark = remark?.trim();
  191 + final hasRemark = friendRemark?.isNotEmpty == true;
  192 + final primaryName = !isSelf && hasRemark ? friendRemark! : name;
  193 + final suffix = isSelf
  194 + ? context.l10n.friendsMe
  195 + : hasRemark
  196 + ? name
  197 + : null;
  198 +
154 return Row( 199 return Row(
155 children: [ 200 children: [
156 - const _Avatar(), 201 + _Avatar(avatarUrl: avatarUrl),
157 const SizedBox(width: 12), 202 const SizedBox(width: 12),
158 Expanded( 203 Expanded(
159 child: Column( 204 child: Column(
160 crossAxisAlignment: CrossAxisAlignment.start, 205 crossAxisAlignment: CrossAxisAlignment.start,
161 children: [ 206 children: [
162 - Text(  
163 - name, 207 + Text.rich(
  208 + TextSpan(
  209 + children: [
  210 + TextSpan(text: primaryName),
  211 + if (suffix != null)
  212 + TextSpan(
  213 + text: context.l10n.friendsRemarkSuffix(suffix),
  214 + style: const TextStyle(color: Color(0xFF78787D)),
  215 + ),
  216 + ],
  217 + ),
164 maxLines: 1, 218 maxLines: 1,
165 overflow: TextOverflow.ellipsis, 219 overflow: TextOverflow.ellipsis,
166 style: const TextStyle( 220 style: const TextStyle(
@@ -183,7 +237,7 @@ class _FriendCardHeader extends StatelessWidget { @@ -183,7 +237,7 @@ class _FriendCardHeader extends StatelessWidget {
183 ], 237 ],
184 ), 238 ),
185 ), 239 ),
186 - if (showMoreButton && !isSelf) 240 + if (showMoreButton)
187 PopupMenuButton<FriendCardAction>( 241 PopupMenuButton<FriendCardAction>(
188 onSelected: onMoreSelected, 242 onSelected: onMoreSelected,
189 elevation: 10, 243 elevation: 10,
@@ -193,23 +247,26 @@ class _FriendCardHeader extends StatelessWidget { @@ -193,23 +247,26 @@ class _FriendCardHeader extends StatelessWidget {
193 shape: RoundedRectangleBorder( 247 shape: RoundedRectangleBorder(
194 borderRadius: BorderRadius.circular(12), 248 borderRadius: BorderRadius.circular(12),
195 ), 249 ),
196 - itemBuilder: (context) => const [ 250 + itemBuilder: (context) => [
197 PopupMenuItem( 251 PopupMenuItem(
198 height: 42, 252 height: 42,
199 value: FriendCardAction.remove, 253 value: FriendCardAction.remove,
200 - child: Center(child: Text('删除Ta')), 254 + child: Center(child: Text(context.l10n.friendsRemove)),
201 ), 255 ),
202 PopupMenuItem( 256 PopupMenuItem(
203 height: 42, 257 height: 42,
204 padding: EdgeInsets.symmetric(vertical: 0), 258 padding: EdgeInsets.symmetric(vertical: 0),
205 value: FriendCardAction.editRemark, 259 value: FriendCardAction.editRemark,
206 - child: Center(child: Text('修改备注')),  
207 - ),  
208 - PopupMenuItem(  
209 - height: 42,  
210 - value: FriendCardAction.watchFace,  
211 - child: Center(child: Text('在表盘显示')), 260 + child: Center(child: Text(context.l10n.friendsEditRemark)),
212 ), 261 ),
  262 + if (!isOnWatchFace)
  263 + PopupMenuItem(
  264 + height: 42,
  265 + value: FriendCardAction.watchFace,
  266 + child: Center(
  267 + child: Text(context.l10n.friendsShowOnWatchFace),
  268 + ),
  269 + ),
213 ], 270 ],
214 child: Padding( 271 child: Padding(
215 padding: const EdgeInsets.all(6), 272 padding: const EdgeInsets.all(6),
@@ -226,13 +283,17 @@ class _FriendCardHeader extends StatelessWidget { @@ -226,13 +283,17 @@ class _FriendCardHeader extends StatelessWidget {
226 } 283 }
227 284
228 class _Avatar extends StatelessWidget { 285 class _Avatar extends StatelessWidget {
229 - const _Avatar(); 286 + const _Avatar({this.avatarUrl});
  287 +
  288 + final String? avatarUrl;
230 289
231 @override 290 @override
232 Widget build(BuildContext context) { 291 Widget build(BuildContext context) {
  292 + final imageUrl = avatarUrl?.trim();
233 return Container( 293 return Container(
234 width: 36, 294 width: 36,
235 height: 36, 295 height: 36,
  296 + clipBehavior: Clip.antiAlias,
236 decoration: const BoxDecoration( 297 decoration: const BoxDecoration(
237 shape: BoxShape.circle, 298 shape: BoxShape.circle,
238 gradient: LinearGradient( 299 gradient: LinearGradient(
@@ -244,11 +305,26 @@ class _Avatar extends StatelessWidget { @@ -244,11 +305,26 @@ class _Avatar extends StatelessWidget {
244 ], 305 ],
245 ), 306 ),
246 ), 307 ),
247 - child: const Icon(  
248 - Icons.person_rounded,  
249 - size: 22,  
250 - color: Colors.white,  
251 - ), 308 + child: imageUrl?.isNotEmpty == true
  309 + ? Image.network(
  310 + imageUrl!,
  311 + fit: BoxFit.cover,
  312 + errorBuilder: (_, __, ___) => const _AvatarFallback(),
  313 + )
  314 + : const _AvatarFallback(),
  315 + );
  316 + }
  317 +}
  318 +
  319 +class _AvatarFallback extends StatelessWidget {
  320 + const _AvatarFallback();
  321 +
  322 + @override
  323 + Widget build(BuildContext context) {
  324 + return const Icon(
  325 + Icons.person_rounded,
  326 + size: 22,
  327 + color: Colors.white,
252 ); 328 );
253 } 329 }
254 } 330 }
@@ -266,6 +342,10 @@ class _StatusFigure extends StatelessWidget { @@ -266,6 +342,10 @@ class _StatusFigure extends StatelessWidget {
266 342
267 @override 343 @override
268 Widget build(BuildContext context) { 344 Widget build(BuildContext context) {
  345 + final stressLevel = stressValue == null
  346 + ? null
  347 + : HrvStressLevel.fromRealtimeStress(stressValue!);
  348 +
269 return SizedBox( 349 return SizedBox(
270 height: 144, 350 height: 144,
271 child: Column( 351 child: Column(
@@ -288,27 +368,12 @@ class _StatusFigure extends StatelessWidget { @@ -288,27 +368,12 @@ class _StatusFigure extends StatelessWidget {
288 Positioned( 368 Positioned(
289 left: 22, 369 left: 22,
290 top: 24, 370 top: 24,
291 - child: Container( 371 + child: Image.asset(
  372 + stressLevel?.realtimeStressIconPath ??
  373 + R.assetsImagesRealtimeStressNoDataIcon,
292 width: 80, 374 width: 80,
293 height: 80, 375 height: 80,
294 - decoration: BoxDecoration(  
295 - color: waiting  
296 - ? const Color(0xFFF3F3F3)  
297 - : const Color(0xFF5AE1A8),  
298 - shape: BoxShape.circle,  
299 - ),  
300 - child: Center(  
301 - child: Container(  
302 - width: 6,  
303 - height: 6,  
304 - decoration: BoxDecoration(  
305 - color: waiting  
306 - ? const Color(0xFFD0D0D2)  
307 - : const Color(0xFF0F0F11),  
308 - shape: BoxShape.circle,  
309 - ),  
310 - ),  
311 - ), 376 + fit: BoxFit.contain,
312 ), 377 ),
313 ), 378 ),
314 ], 379 ],
@@ -545,10 +610,10 @@ class _WatchFaceBadge extends StatelessWidget { @@ -545,10 +610,10 @@ class _WatchFaceBadge extends StatelessWidget {
545 bottomLeft: Radius.circular(12), 610 bottomLeft: Radius.circular(12),
546 ), 611 ),
547 ), 612 ),
548 - child: const Center( 613 + child: Center(
549 child: Text( 614 child: Text(
550 - '已在表盘显示Ta',  
551 - style: TextStyle( 615 + context.l10n.friendsShownOnWatchFace,
  616 + style: const TextStyle(
552 color: Colors.white, 617 color: Colors.white,
553 fontSize: 10, 618 fontSize: 10,
554 fontWeight: FontWeight.w500, 619 fontWeight: FontWeight.w500,
  1 +import 'package:get/get.dart';
  2 +
  3 +import '../../report_common/config/report_date_range_config.dart';
  4 +import '../../report_common/models/report_period.dart';
  5 +
  6 +mixin HealthTrendControl on GetxController {
  7 + final selectedTypeIndex = 0.obs;
  8 + final selectedPeriod = ReportPeriod.week.obs;
  9 + final selectedDate = ReportDateRangeConfig.clampDate(DateTime.now()).obs;
  10 +
  11 + void changeType(int index) {
  12 + final nextIndex = normalizeTypeIndex(index);
  13 + selectedTypeIndex.value = nextIndex;
  14 + selectedPeriod.value = normalizePeriod(nextIndex, selectedPeriod.value);
  15 + }
  16 +
  17 + void changePeriod(ReportPeriod period) {
  18 + selectedPeriod.value = normalizePeriod(selectedTypeIndex.value, period);
  19 + }
  20 +
  21 + void changeDate(DateTime date) {
  22 + selectedDate.value = ReportDateRangeConfig.clampDate(date);
  23 + }
  24 +
  25 + int normalizeTypeIndex(int index) => index >= 0 && index < 3 ? index : 0;
  26 +
  27 + ReportPeriod normalizePeriod(int typeIndex, ReportPeriod period) {
  28 + final periods = supportedPeriods(normalizeTypeIndex(typeIndex));
  29 + return periods.contains(period) ? period : periods.first;
  30 + }
  31 +
  32 + List<ReportPeriod> supportedPeriods(int typeIndex) {
  33 + return normalizeTypeIndex(typeIndex) == 0
  34 + ? const [ReportPeriod.week, ReportPeriod.month, ReportPeriod.year]
  35 + : const [ReportPeriod.day, ReportPeriod.week, ReportPeriod.month];
  36 + }
  37 +}
@@ -9,6 +9,7 @@ import '../../home/widgets/trend/trend_type_tab_bar.dart'; @@ -9,6 +9,7 @@ import '../../home/widgets/trend/trend_type_tab_bar.dart';
9 import '../../hrv_report/controllers/hrv_report_logic.dart'; 9 import '../../hrv_report/controllers/hrv_report_logic.dart';
10 import '../../hrv_report/views/hrv_report_view.dart'; 10 import '../../hrv_report/views/hrv_report_view.dart';
11 import '../../report_common/models/health_report_query.dart'; 11 import '../../report_common/models/health_report_query.dart';
  12 +import '../../report_common/models/report_period.dart';
12 import '../../report_common/widgets/health_report_subject_scope.dart'; 13 import '../../report_common/widgets/health_report_subject_scope.dart';
13 import '../../sleep_report/controllers/sleep_report_logic.dart'; 14 import '../../sleep_report/controllers/sleep_report_logic.dart';
14 import '../../sleep_report/views/sleep_report_view.dart'; 15 import '../../sleep_report/views/sleep_report_view.dart';
@@ -19,11 +20,15 @@ class HealthTrendContent extends StatefulWidget { @@ -19,11 +20,15 @@ class HealthTrendContent extends StatefulWidget {
19 required this.query, 20 required this.query,
20 required this.selectedTypeIndex, 21 required this.selectedTypeIndex,
21 required this.onTypeChanged, 22 required this.onTypeChanged,
  23 + required this.onPeriodChanged,
  24 + required this.onDateChanged,
22 }); 25 });
23 26
24 final HealthReportQuery query; 27 final HealthReportQuery query;
25 final int selectedTypeIndex; 28 final int selectedTypeIndex;
26 final ValueChanged<int> onTypeChanged; 29 final ValueChanged<int> onTypeChanged;
  30 + final ValueChanged<ReportPeriod> onPeriodChanged;
  31 + final ValueChanged<DateTime> onDateChanged;
27 32
28 @override 33 @override
29 State<HealthTrendContent> createState() => _HealthTrendContentState(); 34 State<HealthTrendContent> createState() => _HealthTrendContentState();
@@ -31,32 +36,50 @@ class HealthTrendContent extends StatefulWidget { @@ -31,32 +36,50 @@ class HealthTrendContent extends StatefulWidget {
31 36
32 class _HealthTrendContentState extends State<HealthTrendContent> 37 class _HealthTrendContentState extends State<HealthTrendContent>
33 with SingleTickerProviderStateMixin { 38 with SingleTickerProviderStateMixin {
  39 + static const _typeCount = 3;
  40 +
34 late final TabController _tabController; 41 late final TabController _tabController;
35 42
36 @override 43 @override
37 void initState() { 44 void initState() {
38 super.initState(); 45 super.initState();
  46 + final initialIndex = _normalizedTypeIndex(widget.selectedTypeIndex);
39 _tabController = TabController( 47 _tabController = TabController(
40 - length: 3, 48 + length: _typeCount,
41 vsync: this, 49 vsync: this,
42 - initialIndex: widget.selectedTypeIndex, 50 + initialIndex: initialIndex,
43 ); 51 );
44 _tabController.addListener(() { 52 _tabController.addListener(() {
45 if (!_tabController.indexIsChanging) { 53 if (!_tabController.indexIsChanging) {
46 widget.onTypeChanged(_tabController.index); 54 widget.onTypeChanged(_tabController.index);
47 } 55 }
48 }); 56 });
  57 + _correctInvalidTypeIndex(widget.selectedTypeIndex, initialIndex);
49 } 58 }
50 59
51 @override 60 @override
52 void didUpdateWidget(covariant HealthTrendContent oldWidget) { 61 void didUpdateWidget(covariant HealthTrendContent oldWidget) {
53 super.didUpdateWidget(oldWidget); 62 super.didUpdateWidget(oldWidget);
54 - if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex &&  
55 - _tabController.index != widget.selectedTypeIndex) {  
56 - _tabController.animateTo(widget.selectedTypeIndex); 63 + if (oldWidget.selectedTypeIndex != widget.selectedTypeIndex) {
  64 + final nextIndex = _normalizedTypeIndex(widget.selectedTypeIndex);
  65 + if (_tabController.index != nextIndex) {
  66 + _tabController.animateTo(nextIndex);
  67 + }
  68 + _correctInvalidTypeIndex(widget.selectedTypeIndex, nextIndex);
57 } 69 }
58 } 70 }
59 71
  72 + int _normalizedTypeIndex(int index) {
  73 + return index >= 0 && index < _typeCount ? index : 0;
  74 + }
  75 +
  76 + void _correctInvalidTypeIndex(int requestedIndex, int normalizedIndex) {
  77 + if (requestedIndex == normalizedIndex) return;
  78 + WidgetsBinding.instance.addPostFrameCallback((_) {
  79 + if (mounted) widget.onTypeChanged(normalizedIndex);
  80 + });
  81 + }
  82 +
60 @override 83 @override
61 void dispose() { 84 void dispose() {
62 _tabController.dispose(); 85 _tabController.dispose();
@@ -86,6 +109,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -86,6 +109,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
86 query: widget.query, 109 query: widget.query,
87 isVip: isVip, 110 isVip: isVip,
88 onSubscribe: openPurchase, 111 onSubscribe: openPurchase,
  112 + onPeriodChanged: widget.onPeriodChanged,
  113 + onDateChanged: widget.onDateChanged,
89 ), 114 ),
90 ), 115 ),
91 _KeepAliveWrapper( 116 _KeepAliveWrapper(
@@ -93,6 +118,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -93,6 +118,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
93 query: widget.query, 118 query: widget.query,
94 isVip: isVip, 119 isVip: isVip,
95 onSubscribe: openPurchase, 120 onSubscribe: openPurchase,
  121 + onPeriodChanged: widget.onPeriodChanged,
  122 + onDateChanged: widget.onDateChanged,
96 ), 123 ),
97 ), 124 ),
98 _KeepAliveWrapper( 125 _KeepAliveWrapper(
@@ -100,6 +127,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -100,6 +127,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
100 query: widget.query, 127 query: widget.query,
101 isVip: isVip, 128 isVip: isVip,
102 onSubscribe: openPurchase, 129 onSubscribe: openPurchase,
  130 + onPeriodChanged: widget.onPeriodChanged,
  131 + onDateChanged: widget.onDateChanged,
103 ), 132 ),
104 ), 133 ),
105 ], 134 ],
@@ -117,11 +146,15 @@ class _HrvTrendSection extends StatefulWidget { @@ -117,11 +146,15 @@ class _HrvTrendSection extends StatefulWidget {
117 required this.query, 146 required this.query,
118 required this.isVip, 147 required this.isVip,
119 required this.onSubscribe, 148 required this.onSubscribe,
  149 + required this.onPeriodChanged,
  150 + required this.onDateChanged,
120 }); 151 });
121 152
122 final HealthReportQuery query; 153 final HealthReportQuery query;
123 final bool isVip; 154 final bool isVip;
124 final VoidCallback onSubscribe; 155 final VoidCallback onSubscribe;
  156 + final ValueChanged<ReportPeriod> onPeriodChanged;
  157 + final ValueChanged<DateTime> onDateChanged;
125 158
126 @override 159 @override
127 State<_HrvTrendSection> createState() => _HrvTrendSectionState(); 160 State<_HrvTrendSection> createState() => _HrvTrendSectionState();
@@ -129,11 +162,21 @@ class _HrvTrendSection extends StatefulWidget { @@ -129,11 +162,21 @@ class _HrvTrendSection extends StatefulWidget {
129 162
130 class _HrvTrendSectionState extends State<_HrvTrendSection> { 163 class _HrvTrendSectionState extends State<_HrvTrendSection> {
131 late final HrvReportLogic _logic; 164 late final HrvReportLogic _logic;
  165 + late final Worker _periodWorker;
  166 + late final Worker _dateWorker;
  167 + var _syncingExternalQuery = false;
132 168
133 @override 169 @override
134 void initState() { 170 void initState() {
135 super.initState(); 171 super.initState();
136 _logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId); 172 _logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId);
  173 + _logic.initializeQuery(widget.query.period, widget.query.date);
  174 + _periodWorker = ever(_logic.selectedPeriod, (period) {
  175 + if (!_syncingExternalQuery) widget.onPeriodChanged(period);
  176 + });
  177 + _dateWorker = ever(_logic.selectedDate, (date) {
  178 + if (!_syncingExternalQuery) widget.onDateChanged(date);
  179 + });
137 _logic.loadReport(); 180 _logic.loadReport();
138 } 181 }
139 182
@@ -141,12 +184,24 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -141,12 +184,24 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
141 void didUpdateWidget(covariant _HrvTrendSection oldWidget) { 184 void didUpdateWidget(covariant _HrvTrendSection oldWidget) {
142 super.didUpdateWidget(oldWidget); 185 super.didUpdateWidget(oldWidget);
143 if (oldWidget.query != widget.query) { 186 if (oldWidget.query != widget.query) {
144 - _logic.updateTargetUserId(widget.query.targetUserId); 187 + _syncExternalQuery();
  188 + }
  189 + }
  190 +
  191 + Future<void> _syncExternalQuery() async {
  192 + _syncingExternalQuery = true;
  193 + _logic.targetUserId.value = widget.query.targetUserId;
  194 + try {
  195 + await _logic.selectQuery(widget.query.period, widget.query.date);
  196 + } finally {
  197 + _syncingExternalQuery = false;
145 } 198 }
146 } 199 }
147 200
148 @override 201 @override
149 void dispose() { 202 void dispose() {
  203 + _periodWorker.dispose();
  204 + _dateWorker.dispose();
150 _logic.dispose(); 205 _logic.dispose();
151 super.dispose(); 206 super.dispose();
152 } 207 }
@@ -164,11 +219,15 @@ class _ActivityBurnTrendSection extends StatefulWidget { @@ -164,11 +219,15 @@ class _ActivityBurnTrendSection extends StatefulWidget {
164 required this.query, 219 required this.query,
165 required this.isVip, 220 required this.isVip,
166 required this.onSubscribe, 221 required this.onSubscribe,
  222 + required this.onPeriodChanged,
  223 + required this.onDateChanged,
167 }); 224 });
168 225
169 final HealthReportQuery query; 226 final HealthReportQuery query;
170 final bool isVip; 227 final bool isVip;
171 final VoidCallback onSubscribe; 228 final VoidCallback onSubscribe;
  229 + final ValueChanged<ReportPeriod> onPeriodChanged;
  230 + final ValueChanged<DateTime> onDateChanged;
172 231
173 @override 232 @override
174 State<_ActivityBurnTrendSection> createState() => 233 State<_ActivityBurnTrendSection> createState() =>
@@ -177,6 +236,9 @@ class _ActivityBurnTrendSection extends StatefulWidget { @@ -177,6 +236,9 @@ class _ActivityBurnTrendSection extends StatefulWidget {
177 236
178 class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { 237 class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
179 late final ActivityBurnReportLogic _logic; 238 late final ActivityBurnReportLogic _logic;
  239 + late final Worker _periodWorker;
  240 + late final Worker _dateWorker;
  241 + var _syncingExternalQuery = false;
180 242
181 @override 243 @override
182 void initState() { 244 void initState() {
@@ -184,6 +246,13 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -184,6 +246,13 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
184 _logic = ActivityBurnReportLogic( 246 _logic = ActivityBurnReportLogic(
185 initialTargetUserId: widget.query.targetUserId, 247 initialTargetUserId: widget.query.targetUserId,
186 ); 248 );
  249 + _logic.initializeQuery(widget.query.period, widget.query.date);
  250 + _periodWorker = ever(_logic.selectedPeriod, (period) {
  251 + if (!_syncingExternalQuery) widget.onPeriodChanged(period);
  252 + });
  253 + _dateWorker = ever(_logic.selectedDate, (date) {
  254 + if (!_syncingExternalQuery) widget.onDateChanged(date);
  255 + });
187 _logic.loadReport(); 256 _logic.loadReport();
188 } 257 }
189 258
@@ -191,12 +260,24 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -191,12 +260,24 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
191 void didUpdateWidget(covariant _ActivityBurnTrendSection oldWidget) { 260 void didUpdateWidget(covariant _ActivityBurnTrendSection oldWidget) {
192 super.didUpdateWidget(oldWidget); 261 super.didUpdateWidget(oldWidget);
193 if (oldWidget.query != widget.query) { 262 if (oldWidget.query != widget.query) {
194 - _logic.updateTargetUserId(widget.query.targetUserId); 263 + _syncExternalQuery();
  264 + }
  265 + }
  266 +
  267 + Future<void> _syncExternalQuery() async {
  268 + _syncingExternalQuery = true;
  269 + _logic.targetUserId.value = widget.query.targetUserId;
  270 + try {
  271 + await _logic.selectQuery(widget.query.period, widget.query.date);
  272 + } finally {
  273 + _syncingExternalQuery = false;
195 } 274 }
196 } 275 }
197 276
198 @override 277 @override
199 void dispose() { 278 void dispose() {
  279 + _periodWorker.dispose();
  280 + _dateWorker.dispose();
200 _logic.dispose(); 281 _logic.dispose();
201 super.dispose(); 282 super.dispose();
202 } 283 }
@@ -214,11 +295,15 @@ class _SleepTrendSection extends StatefulWidget { @@ -214,11 +295,15 @@ class _SleepTrendSection extends StatefulWidget {
214 required this.query, 295 required this.query,
215 required this.isVip, 296 required this.isVip,
216 required this.onSubscribe, 297 required this.onSubscribe,
  298 + required this.onPeriodChanged,
  299 + required this.onDateChanged,
217 }); 300 });
218 301
219 final HealthReportQuery query; 302 final HealthReportQuery query;
220 final bool isVip; 303 final bool isVip;
221 final VoidCallback onSubscribe; 304 final VoidCallback onSubscribe;
  305 + final ValueChanged<ReportPeriod> onPeriodChanged;
  306 + final ValueChanged<DateTime> onDateChanged;
222 307
223 @override 308 @override
224 State<_SleepTrendSection> createState() => _SleepTrendSectionState(); 309 State<_SleepTrendSection> createState() => _SleepTrendSectionState();
@@ -226,6 +311,9 @@ class _SleepTrendSection extends StatefulWidget { @@ -226,6 +311,9 @@ class _SleepTrendSection extends StatefulWidget {
226 311
227 class _SleepTrendSectionState extends State<_SleepTrendSection> { 312 class _SleepTrendSectionState extends State<_SleepTrendSection> {
228 late final SleepReportLogic _logic; 313 late final SleepReportLogic _logic;
  314 + late final Worker _periodWorker;
  315 + late final Worker _dateWorker;
  316 + var _syncingExternalQuery = false;
229 317
230 @override 318 @override
231 void initState() { 319 void initState() {
@@ -233,6 +321,13 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -233,6 +321,13 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
233 _logic = SleepReportLogic( 321 _logic = SleepReportLogic(
234 initialTargetUserId: widget.query.targetUserId, 322 initialTargetUserId: widget.query.targetUserId,
235 ); 323 );
  324 + _logic.initializeQuery(widget.query.period, widget.query.date);
  325 + _periodWorker = ever(_logic.selectedPeriod, (period) {
  326 + if (!_syncingExternalQuery) widget.onPeriodChanged(period);
  327 + });
  328 + _dateWorker = ever(_logic.selectedDate, (date) {
  329 + if (!_syncingExternalQuery) widget.onDateChanged(date);
  330 + });
236 _logic.loadReport(); 331 _logic.loadReport();
237 } 332 }
238 333
@@ -240,12 +335,24 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -240,12 +335,24 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
240 void didUpdateWidget(covariant _SleepTrendSection oldWidget) { 335 void didUpdateWidget(covariant _SleepTrendSection oldWidget) {
241 super.didUpdateWidget(oldWidget); 336 super.didUpdateWidget(oldWidget);
242 if (oldWidget.query != widget.query) { 337 if (oldWidget.query != widget.query) {
243 - _logic.updateTargetUserId(widget.query.targetUserId); 338 + _syncExternalQuery();
  339 + }
  340 + }
  341 +
  342 + Future<void> _syncExternalQuery() async {
  343 + _syncingExternalQuery = true;
  344 + _logic.targetUserId.value = widget.query.targetUserId;
  345 + try {
  346 + await _logic.selectQuery(widget.query.period, widget.query.date);
  347 + } finally {
  348 + _syncingExternalQuery = false;
244 } 349 }
245 } 350 }
246 351
247 @override 352 @override
248 void dispose() { 353 void dispose() {
  354 + _periodWorker.dispose();
  355 + _dateWorker.dispose();
249 _logic.dispose(); 356 _logic.dispose();
250 super.dispose(); 357 super.dispose();
251 } 358 }
1 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 1 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  2 +import 'package:doublefeel_flutter/data/local/local_storage.dart';
  3 +import 'package:flutter/material.dart';
2 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
3 5
  6 +import '../../app_review_prompt/logic/app_review_prompt_logic.dart';
  7 +import '../../report_common/models/report_period.dart';
4 import 'trend/trend_controller.dart'; 8 import 'trend/trend_controller.dart';
5 9
6 class HomeController extends GetxController { 10 class HomeController extends GetxController {
7 static const trendTabIndex = 1; 11 static const trendTabIndex = 1;
8 12
  13 + final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic(
  14 + storage: Get.find<LocalStorage>(),
  15 + );
9 final UserStateService userStateService = Get.find<UserStateService>(); 16 final UserStateService userStateService = Get.find<UserStateService>();
10 17
11 /// 当前选中的底部 tab 索引 18 /// 当前选中的底部 tab 索引
12 final selectedIndex = 0.obs; 19 final selectedIndex = 0.obs;
13 20
  21 + Future<void> maybeShowAppReviewPrompt(BuildContext context) {
  22 + return _appReviewPromptLogic.maybeShowPrompt(context);
  23 + }
  24 +
14 void changeTab(int index) { 25 void changeTab(int index) {
15 selectedIndex.value = index; 26 selectedIndex.value = index;
16 } 27 }
17 28
18 - void openTrend(TrendType type) {  
19 - Get.find<TrendController>().selectType(type); 29 + void openTrend(TrendType type, {DateTime? date, ReportPeriod? period}) {
  30 + Get.find<TrendController>().selectType(type, date: date, period: period);
20 changeTab(trendTabIndex); 31 changeTab(trendTabIndex);
21 } 32 }
22 } 33 }
1 import 'dart:async'; 1 import 'dart:async';
2 -import 'dart:math';  
3 2
4 import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart'; 3 import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
5 import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; 4 import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart';
@@ -379,7 +378,7 @@ class TodayController extends GetxController { @@ -379,7 +378,7 @@ class TodayController extends GetxController {
379 if (isFriend) { 378 if (isFriend) {
380 Get.toNamed( 379 Get.toNamed(
381 Routes.FRIEND_TREND, 380 Routes.FRIEND_TREND,
382 - arguments: getFriendTrendArguments(), 381 + arguments: getFriendTrendArguments(TrendType.hrv),
383 ); 382 );
384 } else { 383 } else {
385 Get.find<HomeController>().openTrend(TrendType.hrv); 384 Get.find<HomeController>().openTrend(TrendType.hrv);
@@ -388,7 +387,10 @@ class TodayController extends GetxController { @@ -388,7 +387,10 @@ class TodayController extends GetxController {
388 387
389 toTrendSleepPage() { 388 toTrendSleepPage() {
390 if (isFriend) { 389 if (isFriend) {
391 - Get.toNamed(Routes.FRIEND_TREND, arguments: getFriendTrendArguments()); 390 + Get.toNamed(
  391 + Routes.FRIEND_TREND,
  392 + arguments: getFriendTrendArguments(TrendType.sleep),
  393 + );
392 } else { 394 } else {
393 Get.find<HomeController>().openTrend(TrendType.sleep); 395 Get.find<HomeController>().openTrend(TrendType.sleep);
394 } 396 }
@@ -396,23 +398,20 @@ class TodayController extends GetxController { @@ -396,23 +398,20 @@ class TodayController extends GetxController {
396 398
397 toTrendActivityPage() { 399 toTrendActivityPage() {
398 if (isFriend) { 400 if (isFriend) {
399 - Get.toNamed(Routes.FRIEND_TREND, arguments: getFriendTrendArguments()); 401 + Get.toNamed(
  402 + Routes.FRIEND_TREND,
  403 + arguments: getFriendTrendArguments(TrendType.activity),
  404 + );
400 } else { 405 } else {
401 Get.find<HomeController>().openTrend(TrendType.activity); 406 Get.find<HomeController>().openTrend(TrendType.activity);
402 } 407 }
403 } 408 }
404 409
405 - getFriendTrendArguments() {  
406 - var value = targetFriendInfo.value;  
407 - if (value != null && value.friendUserId != null) {  
408 - return FriendTrendArguments(  
409 - userId: value.friendUserId!,  
410 - name: value.remarkName ?? '',  
411 - avatarUrl: value.avatar,  
412 - );  
413 - } else {  
414 - return null;  
415 - } 410 + FriendTrendArguments getFriendTrendArguments(TrendType type) {
  411 + return FriendTrendArguments(
  412 + friendItem: targetFriendInfo.value,
  413 + initialTypeIndex: type.tabIndex,
  414 + );
416 } 415 }
417 416
418 void selectFriend(FriendItem friendInfo) { 417 void selectFriend(FriendItem friendInfo) {
1 import 'package:get/get.dart'; 1 import 'package:get/get.dart';
2 2
  3 +import '../../../health_trend/controllers/health_trend_control.dart';
3 import '../../../report_common/models/health_report_query.dart'; 4 import '../../../report_common/models/health_report_query.dart';
  5 +import '../../../report_common/models/report_period.dart';
4 6
5 enum TrendType { 7 enum TrendType {
6 hrv, 8 hrv,
@@ -12,24 +14,26 @@ enum TrendType { @@ -12,24 +14,26 @@ enum TrendType {
12 14
13 /// 趋势页顶层 Controller,仅负责: 15 /// 趋势页顶层 Controller,仅负责:
14 /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex) 16 /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex)
15 -class TrendController extends GetxController {  
16 - // 第1层类型 Tab(0 = HRV, 1 = 活动, 2 = 睡眠)  
17 - final selectedTypeIndex = 0.obs;  
18 - 17 +class TrendController extends GetxController with HealthTrendControl {
19 // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。 18 // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。
20 final targetUserId = RxnInt(); 19 final targetUserId = RxnInt();
21 20
22 HealthReportQuery get query => HealthReportQuery( 21 HealthReportQuery get query => HealthReportQuery(
23 targetUserId: targetUserId.value, 22 targetUserId: targetUserId.value,
  23 + period: selectedPeriod.value,
  24 + date: selectedDate.value,
24 ); 25 );
25 26
26 - void changeType(int index) {  
27 - if (index == selectedTypeIndex.value) return;  
28 - selectedTypeIndex.value = index; 27 + void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) {
  28 + changeType(type.tabIndex);
  29 + if (period != null) {
  30 + changePeriod(period);
  31 + }
  32 + if (date != null) {
  33 + changeDate(date);
  34 + }
29 } 35 }
30 36
31 - void selectType(TrendType type) => changeType(type.tabIndex);  
32 -  
33 void changeTargetUser(int? userId) { 37 void changeTargetUser(int? userId) {
34 if (userId == targetUserId.value) return; 38 if (userId == targetUserId.value) return;
35 targetUserId.value = userId; 39 targetUserId.value = userId;
@@ -20,23 +20,54 @@ class HomePage extends GetView<HomeController> { @@ -20,23 +20,54 @@ class HomePage extends GetView<HomeController> {
20 20
21 @override 21 @override
22 Widget build(BuildContext context) { 22 Widget build(BuildContext context) {
23 - return Scaffold(  
24 - // 不使用系统 AppBar,Today 页自带状态栏适配  
25 - extendBody: true, // 让内容延伸到 bottomNavigationBar 下方  
26 - extendBodyBehindAppBar: true,  
27 -  
28 - body: Obx(  
29 - () => IndexedStack(  
30 - index: controller.selectedIndex.value,  
31 - children: _tabs, 23 + return _HomeReviewPromptGate(
  24 + controller: controller,
  25 + child: Scaffold(
  26 + // 不使用系统 AppBar,Today 页自带状态栏适配
  27 + extendBody: true, // 让内容延伸到 bottomNavigationBar 下方
  28 + extendBodyBehindAppBar: true,
  29 +
  30 + body: Obx(
  31 + () => IndexedStack(
  32 + index: controller.selectedIndex.value,
  33 + children: _tabs,
  34 + ),
32 ), 35 ),
33 - ),  
34 - bottomNavigationBar: Obx(  
35 - () => DfTabBar(  
36 - selectedIndex: controller.selectedIndex.value,  
37 - onTap: controller.changeTab, 36 + bottomNavigationBar: Obx(
  37 + () => DfTabBar(
  38 + selectedIndex: controller.selectedIndex.value,
  39 + onTap: controller.changeTab,
  40 + ),
38 ), 41 ),
39 ), 42 ),
40 ); 43 );
41 } 44 }
42 } 45 }
  46 +
  47 +class _HomeReviewPromptGate extends StatefulWidget {
  48 + const _HomeReviewPromptGate({
  49 + required this.controller,
  50 + required this.child,
  51 + });
  52 +
  53 + final HomeController controller;
  54 + final Widget child;
  55 +
  56 + @override
  57 + State<_HomeReviewPromptGate> createState() => _HomeReviewPromptGateState();
  58 +}
  59 +
  60 +class _HomeReviewPromptGateState extends State<_HomeReviewPromptGate> {
  61 + @override
  62 + void initState() {
  63 + super.initState();
  64 +
  65 + WidgetsBinding.instance.addPostFrameCallback((_) {
  66 + if (!mounted) return;
  67 + widget.controller.maybeShowAppReviewPrompt(context);
  68 + });
  69 + }
  70 +
  71 + @override
  72 + Widget build(BuildContext context) => widget.child;
  73 +}
@@ -33,6 +33,8 @@ class _HomeTrendBody extends GetView<TrendController> { @@ -33,6 +33,8 @@ class _HomeTrendBody extends GetView<TrendController> {
33 query: controller.query, 33 query: controller.query,
34 selectedTypeIndex: controller.selectedTypeIndex.value, 34 selectedTypeIndex: controller.selectedTypeIndex.value,
35 onTypeChanged: controller.changeType, 35 onTypeChanged: controller.changeType,
  36 + onPeriodChanged: controller.changePeriod,
  37 + onDateChanged: controller.changeDate,
36 ), 38 ),
37 ); 39 );
38 } 40 }
1 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 1 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 4
4 /// 第1层:HRV心率 / 活动消耗 / 睡眠报告 Tab 5 /// 第1层:HRV心率 / 活动消耗 / 睡眠报告 Tab
@@ -40,10 +41,10 @@ class TrendTypeTabBar extends StatelessWidget { @@ -40,10 +41,10 @@ class TrendTypeTabBar extends StatelessWidget {
40 fontSize: 18, 41 fontSize: 18,
41 fontWeight: FontWeight.w500, 42 fontWeight: FontWeight.w500,
42 ), 43 ),
43 - tabs: const [  
44 - Tab(text: 'HRV心率'),  
45 - Tab(text: '活动消耗'),  
46 - Tab(text: '睡眠报告'), 44 + tabs: [
  45 + Tab(text: context.l10n.trendHrvHeartRate),
  46 + Tab(text: context.l10n.trendActivityBurn),
  47 + Tab(text: context.l10n.trendSleepReport),
47 ], 48 ],
48 ); 49 );
49 }, 50 },
  1 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
3 import '../../report_common/controllers/report_period_logic.dart'; 4 import '../../report_common/controllers/report_period_logic.dart';
@@ -7,7 +8,13 @@ import '../data/hrv_report_repository.dart'; @@ -7,7 +8,13 @@ import '../data/hrv_report_repository.dart';
7 import '../models/hrv_report_models.dart'; 8 import '../models/hrv_report_models.dart';
8 9
9 class HrvReportLogic extends ReportPeriodLogic { 10 class HrvReportLogic extends ReportPeriodLogic {
10 - HrvReportLogic({int? initialTargetUserId}) { 11 + HrvReportLogic({
  12 + int? initialTargetUserId,
  13 + HrvReportRepository? repository,
  14 + }) : repository = repository ??
  15 + HrvReportRepositoryImpl(
  16 + dataSource: ApiHrvReportDataSource(Get.find<HealthApi>()),
  17 + ) {
11 targetUserId.value = initialTargetUserId; 18 targetUserId.value = initialTargetUserId;
12 selectedPeriod.value = ReportPeriod.week; 19 selectedPeriod.value = ReportPeriod.week;
13 } 20 }
@@ -16,9 +23,31 @@ class HrvReportLogic extends ReportPeriodLogic { @@ -16,9 +23,31 @@ class HrvReportLogic extends ReportPeriodLogic {
16 final weeklyReport = Rxn<WeeklyHrvReport>(); 23 final weeklyReport = Rxn<WeeklyHrvReport>();
17 final monthlyReport = Rxn<MonthlyHrvReport>(); 24 final monthlyReport = Rxn<MonthlyHrvReport>();
18 final yearlyReport = Rxn<YearlyHrvReport>(); 25 final yearlyReport = Rxn<YearlyHrvReport>();
19 - final HrvReportRepository repository = const HrvReportRepositoryImpl(  
20 - dataSource: MockHrvReportDataSource(),  
21 - ); 26 + final HrvReportRepository repository;
  27 +
  28 + @override
  29 + List<ReportPeriod> get supportedPeriods => const [
  30 + ReportPeriod.week,
  31 + ReportPeriod.month,
  32 + ReportPeriod.year,
  33 + ];
  34 +
  35 + @override
  36 + Future<void> selectPeriod(
  37 + ReportPeriod period, {
  38 + bool? forceRefresh,
  39 + }) {
  40 + final nextPeriod = normalizePeriod(period);
  41 + if (selectedPeriod.value == ReportPeriod.year &&
  42 + (nextPeriod == ReportPeriod.week || nextPeriod == ReportPeriod.month)) {
  43 + final now = DateTime.now();
  44 + final selectedYear = selectedDate.value.year;
  45 + selectedDate.value = selectedYear == now.year
  46 + ? DateTime(now.year, now.month, now.day)
  47 + : DateTime(selectedYear);
  48 + }
  49 + return super.selectPeriod(nextPeriod, forceRefresh: forceRefresh);
  50 + }
22 51
23 @override 52 @override
24 Future<void> loadReport() async { 53 Future<void> loadReport() async {