Commit d2045d296fe24d86fe7c171524158e5372b7a26e

Authored by 刘宏哲
1 parent b74687e3

feat(app): add pk

Showing 48 changed files with 2381 additions and 1035 deletions

Too many changes to show.

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

1 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 1 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
2 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 2 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
  3 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  4 +import 'package:doublefeel_flutter/data/local/local_storage.dart';
3 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 5 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
4 import 'package:get/get.dart'; 6 import 'package:get/get.dart';
5 7
6 class AccountSettingsController extends GetxController { 8 class AccountSettingsController extends GetxController {
7 final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>(); 9 final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>();
8 final UserStateService _userStateService = Get.find<UserStateService>(); 10 final UserStateService _userStateService = Get.find<UserStateService>();
  11 + final LocalStorage _localStorage = Get.find<LocalStorage>();
9 12
10 UserPreferencesStorage get userPrefs => _userPrefs; 13 UserPreferencesStorage get userPrefs => _userPrefs;
11 14
@@ -21,4 +24,12 @@ class AccountSettingsController extends GetxController { @@ -21,4 +24,12 @@ class AccountSettingsController extends GetxController {
21 void openOnboarding() { 24 void openOnboarding() {
22 Get.offAllNamed(AppRoutes.userOnboarding); 25 Get.offAllNamed(AppRoutes.userOnboarding);
23 } 26 }
  27 +
  28 + void openChangePhone() {
  29 + if (_localStorage.lastLoginMethod != 'phone') {
  30 + AppToast.show('仅支持手机号登录的账号更换手机号');
  31 + return;
  32 + }
  33 + Get.toNamed(Routes.CHANGE_PHONE);
  34 + }
24 } 35 }
@@ -45,7 +45,11 @@ class AccountSettingsView extends GetView<AccountSettingsController> { @@ -45,7 +45,11 @@ class AccountSettingsView extends GetView<AccountSettingsController> {
45 return Column( 45 return Column(
46 children: [ 46 children: [
47 SizedBox(height: 16.dp), 47 SizedBox(height: 16.dp),
48 - _AccountCard(user: user, onLogout: controller.logout), 48 + _AccountCard(
  49 + user: user,
  50 + onChangePhone: controller.openChangePhone,
  51 + onLogout: controller.logout,
  52 + ),
49 SizedBox(height: 17.dp), 53 SizedBox(height: 17.dp),
50 GestureDetector( 54 GestureDetector(
51 behavior: HitTestBehavior.opaque, 55 behavior: HitTestBehavior.opaque,
@@ -77,10 +81,12 @@ class AccountSettingsView extends GetView<AccountSettingsController> { @@ -77,10 +81,12 @@ class AccountSettingsView extends GetView<AccountSettingsController> {
77 class _AccountCard extends StatelessWidget { 81 class _AccountCard extends StatelessWidget {
78 const _AccountCard({ 82 const _AccountCard({
79 required this.user, 83 required this.user,
  84 + required this.onChangePhone,
80 required this.onLogout, 85 required this.onLogout,
81 }); 86 });
82 87
83 final UserInfoResponse? user; 88 final UserInfoResponse? user;
  89 + final VoidCallback onChangePhone;
84 final VoidCallback onLogout; 90 final VoidCallback onLogout;
85 91
86 @override 92 @override
@@ -94,32 +100,36 @@ class _AccountCard extends StatelessWidget { @@ -94,32 +100,36 @@ class _AccountCard extends StatelessWidget {
94 ), 100 ),
95 child: Column( 101 child: Column(
96 children: [ 102 children: [
97 - SizedBox(  
98 - height: 54.dp,  
99 - child: Padding(  
100 - padding: EdgeInsets.symmetric(horizontal: 20.dp),  
101 - child: Row(  
102 - children: [  
103 - Text(  
104 - '手机号',  
105 - style: TextStyle(  
106 - color: AppColors.textPrimary,  
107 - fontSize: 14.dp,  
108 - fontWeight: FontWeight.w400,  
109 - height: 1.25, 103 + GestureDetector(
  104 + behavior: HitTestBehavior.opaque,
  105 + onTap: onChangePhone,
  106 + child: SizedBox(
  107 + height: 54.dp,
  108 + child: Padding(
  109 + padding: EdgeInsets.symmetric(horizontal: 20.dp),
  110 + child: Row(
  111 + children: [
  112 + Text(
  113 + '手机号',
  114 + style: TextStyle(
  115 + color: AppColors.textPrimary,
  116 + fontSize: 14.dp,
  117 + fontWeight: FontWeight.w400,
  118 + height: 1.25,
  119 + ),
110 ), 120 ),
111 - ),  
112 - const Spacer(),  
113 - Text(  
114 - _formatPhone(user?.telephone),  
115 - style: TextStyle(  
116 - color: AppColors.textSecondary,  
117 - fontSize: 14.dp,  
118 - fontWeight: FontWeight.w400,  
119 - height: 1.25, 121 + const Spacer(),
  122 + Text(
  123 + _formatPhone(user?.telephone),
  124 + style: TextStyle(
  125 + color: AppColors.textSecondary,
  126 + fontSize: 14.dp,
  127 + fontWeight: FontWeight.w400,
  128 + height: 1.25,
  129 + ),
120 ), 130 ),
121 - ),  
122 - ], 131 + ],
  132 + ),
123 ), 133 ),
124 ), 134 ),
125 ), 135 ),
@@ -3,18 +3,17 @@ import 'dart:ui' as ui; @@ -3,18 +3,17 @@ import 'dart:ui' as ui;
3 3
4 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
5 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
  6 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 7 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
7 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; 8 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
8 9
  10 +import '../../../widget/friend_interact_click_view.dart';
9 import '../../report_common/widgets/health_report_subject_scope.dart'; 11 import '../../report_common/widgets/health_report_subject_scope.dart';
10 import '../models/activity_burn_report_models.dart'; 12 import '../models/activity_burn_report_models.dart';
11 import 'activity_burn_trend_lines.dart'; 13 import 'activity_burn_trend_lines.dart';
12 14
13 class ActivityBurnHeartRateZoneCard extends StatelessWidget { 15 class ActivityBurnHeartRateZoneCard extends StatelessWidget {
14 - const ActivityBurnHeartRateZoneCard({  
15 - super.key,  
16 - required this.summary,  
17 - }); 16 + const ActivityBurnHeartRateZoneCard({super.key, required this.summary});
18 17
19 final ActivityBurnHeartRateSummary summary; 18 final ActivityBurnHeartRateSummary summary;
20 19
@@ -39,75 +38,79 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -39,75 +38,79 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
39 Widget build(BuildContext context) { 38 Widget build(BuildContext context) {
40 final start = _startTime; 39 final start = _startTime;
41 final axis = _HeartRateAxis.fromSummary(summary, start); 40 final axis = _HeartRateAxis.fromSummary(summary, start);
42 - return Container(  
43 - height: 273,  
44 - padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),  
45 - decoration: BoxDecoration(  
46 - color: Colors.white,  
47 - borderRadius: BorderRadius.circular(16),  
48 - ),  
49 - child: Column(  
50 - crossAxisAlignment: CrossAxisAlignment.start,  
51 - children: [  
52 - Text(  
53 - HealthReportSubjectScope.titleOf(  
54 - context,  
55 - context.l10n.activityHeartRateZone,  
56 - ),  
57 - style: TextStyle(  
58 - color: _h1,  
59 - fontSize: 16,  
60 - fontWeight: FontWeight.w600,  
61 - height: 1.2, 41 + return FriendInteractCardOverlay(
  42 + showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
  43 + interactionType: FriendInteractionType.dailyActivityBurn,
  44 + child: Container(
  45 + height: 273,
  46 + padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
  47 + decoration: BoxDecoration(
  48 + color: Colors.white,
  49 + borderRadius: BorderRadius.circular(16),
  50 + ),
  51 + child: Column(
  52 + crossAxisAlignment: CrossAxisAlignment.start,
  53 + children: [
  54 + Text(
  55 + HealthReportSubjectScope.titleOf(
  56 + context,
  57 + context.l10n.activityHeartRateZone,
  58 + ),
  59 + style: TextStyle(
  60 + color: _h1,
  61 + fontSize: 16,
  62 + fontWeight: FontWeight.w600,
  63 + height: 1.2,
  64 + ),
62 ), 65 ),
63 - ),  
64 - const SizedBox(height: 20),  
65 - SizedBox(  
66 - height: _chartHeight,  
67 - child: LayoutBuilder(  
68 - builder: (_, __) {  
69 - return Stack(  
70 - fit: StackFit.expand,  
71 - alignment: Alignment.center,  
72 - children: [  
73 - Positioned(  
74 - left: 0,  
75 - right: 6,  
76 - top: 0,  
77 - bottom: 20,  
78 - child: ActivityBurnHorizontalGridLines(  
79 - minY: axis.minY,  
80 - maxY: axis.chartMaxY,  
81 - values: axis.ticks,  
82 - ),  
83 - ),  
84 - Positioned.fill(  
85 - top: 0,  
86 - child: LineChart(_chartData(context)),  
87 - ),  
88 - if (!summary.hasData) 66 + const SizedBox(height: 20),
  67 + SizedBox(
  68 + height: _chartHeight,
  69 + child: LayoutBuilder(
  70 + builder: (_, __) {
  71 + return Stack(
  72 + fit: StackFit.expand,
  73 + alignment: Alignment.center,
  74 + children: [
89 Positioned( 75 Positioned(
90 left: 0, 76 left: 0,
91 right: 6, 77 right: 6,
92 top: 0, 78 top: 0,
93 bottom: 20, 79 bottom: 20,
94 - child: Center(  
95 - child: Text(  
96 - context.l10n.hrvTrendAwaitingData,  
97 - style: const TextStyle(  
98 - color: Color(0xFFA1A0A5),  
99 - fontSize: 12,  
100 - fontWeight: FontWeight.w400, 80 + child: ActivityBurnHorizontalGridLines(
  81 + minY: axis.minY,
  82 + maxY: axis.chartMaxY,
  83 + values: axis.ticks,
  84 + ),
  85 + ),
  86 + Positioned.fill(
  87 + top: 0,
  88 + child: LineChart(_chartData(context)),
  89 + ),
  90 + if (!summary.hasData)
  91 + Positioned(
  92 + left: 0,
  93 + right: 6,
  94 + top: 0,
  95 + bottom: 20,
  96 + child: Center(
  97 + child: Text(
  98 + context.l10n.hrvTrendAwaitingData,
  99 + style: const TextStyle(
  100 + color: Color(0xFFA1A0A5),
  101 + fontSize: 12,
  102 + fontWeight: FontWeight.w400,
  103 + ),
101 ), 104 ),
102 ), 105 ),
103 ), 106 ),
104 - ),  
105 - ],  
106 - );  
107 - }, 107 + ],
  108 + );
  109 + },
  110 + ),
108 ), 111 ),
109 - ),  
110 - ], 112 + ],
  113 + ),
111 ), 114 ),
112 ); 115 );
113 } 116 }
@@ -122,9 +125,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -122,9 +125,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
122 maxX: axis.maxX, 125 maxX: axis.maxX,
123 minY: axis.minY, 126 minY: axis.minY,
124 maxY: axis.chartMaxY, 127 maxY: axis.chartMaxY,
125 - gridData: FlGridData(  
126 - show: false,  
127 - ), 128 + gridData: FlGridData(show: false),
128 borderData: FlBorderData(show: false), 129 borderData: FlBorderData(show: false),
129 lineTouchData: const LineTouchData(enabled: false), 130 lineTouchData: const LineTouchData(enabled: false),
130 titlesData: FlTitlesData( 131 titlesData: FlTitlesData(
@@ -174,10 +175,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -174,10 +175,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
174 isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0, 175 isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0,
175 0, 176 0,
176 ), 177 ),
177 - child: Text(  
178 - label,  
179 - style: _bottomTitleStyle,  
180 - ), 178 + child: Text(label, style: _bottomTitleStyle),
181 ), 179 ),
182 ); 180 );
183 }, 181 },
@@ -192,10 +190,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -192,10 +190,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
192 190
193 LineChartBarData _emptyPlaceholderBar(_HeartRateAxis axis) { 191 LineChartBarData _emptyPlaceholderBar(_HeartRateAxis axis) {
194 return LineChartBarData( 192 return LineChartBarData(
195 - spots: [  
196 - FlSpot(0, axis.minY),  
197 - FlSpot(axis.maxX, axis.minY),  
198 - ], 193 + spots: [FlSpot(0, axis.minY), FlSpot(axis.maxX, axis.minY)],
199 color: Colors.transparent, 194 color: Colors.transparent,
200 barWidth: 0, 195 barWidth: 0,
201 dotData: const FlDotData(show: false), 196 dotData: const FlDotData(show: false),
@@ -215,7 +210,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -215,7 +210,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
215 List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) { 210 List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) {
216 return summary.points 211 return summary.points
217 .where( 212 .where(
218 - (point) => !point.time.isBefore(start) && !point.time.isAfter(end)) 213 + (point) => !point.time.isBefore(start) && !point.time.isAfter(end),
  214 + )
219 .toList(); 215 .toList();
220 } 216 }
221 217
@@ -277,10 +273,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -277,10 +273,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
277 ]; 273 ];
278 } 274 }
279 275
280 - List<FlSpot> _splitByZoneBoundaries(  
281 - FlSpot start,  
282 - FlSpot end,  
283 - ) { 276 + List<FlSpot> _splitByZoneBoundaries(FlSpot start, FlSpot end) {
284 if (start.y == end.y) return [start, end]; 277 if (start.y == end.y) return [start, end];
285 278
286 final minY = math.min(start.y, end.y); 279 final minY = math.min(start.y, end.y);
@@ -290,12 +283,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -290,12 +283,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
290 for (final boundary in _zoneBoundaries) { 283 for (final boundary in _zoneBoundaries) {
291 if (boundary <= minY || boundary >= maxY) continue; 284 if (boundary <= minY || boundary >= maxY) continue;
292 final t = (boundary - start.y) / (end.y - start.y); 285 final t = (boundary - start.y) / (end.y - start.y);
293 - crossings.add(  
294 - FlSpot(  
295 - start.x + (end.x - start.x) * t,  
296 - boundary,  
297 - ),  
298 - ); 286 + crossings.add(FlSpot(start.x + (end.x - start.x) * t, boundary));
299 } 287 }
300 288
301 crossings.sort((a, b) => a.x.compareTo(b.x)); 289 crossings.sort((a, b) => a.x.compareTo(b.x));
@@ -325,10 +313,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -325,10 +313,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
325 } 313 }
326 314
327 FlSpot _spot(ActivityBurnHeartRatePoint point, DateTime start) { 315 FlSpot _spot(ActivityBurnHeartRatePoint point, DateTime start) {
328 - return FlSpot(  
329 - point.time.difference(start).inMinutes.toDouble(),  
330 - point.bpm,  
331 - ); 316 + return FlSpot(point.time.difference(start).inMinutes.toDouble(), point.bpm);
332 } 317 }
333 318
334 String? _bottomLabel( 319 String? _bottomLabel(
@@ -3,10 +3,12 @@ import 'dart:math' as math; @@ -3,10 +3,12 @@ import 'dart:math' as math;
3 3
4 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
5 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
  6 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
6 import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; 7 import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
7 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 8 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
8 9
9 import '../../../../r.dart'; 10 import '../../../../r.dart';
  11 +import '../../../widget/friend_interact_click_view.dart';
10 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 12 import '../../report_common/widgets/chart_selection_line_overlay.dart';
11 import '../../report_common/widgets/health_report_subject_scope.dart'; 13 import '../../report_common/widgets/health_report_subject_scope.dart';
12 import '../../report_common/utils/report_localization.dart'; 14 import '../../report_common/utils/report_localization.dart';
@@ -17,14 +19,14 @@ import 'activity_burn_ring.dart'; @@ -17,14 +19,14 @@ import 'activity_burn_ring.dart';
17 import 'activity_burn_trend_lines.dart'; 19 import 'activity_burn_trend_lines.dart';
18 20
19 String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) { 21 String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) {
20 - DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,  
21 - DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,  
22 - DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,  
23 - DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,  
24 - DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,  
25 - DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,  
26 - _ => context.l10n.activityWeeklyWeekdaySunday,  
27 - }; 22 + DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,
  23 + DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,
  24 + DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,
  25 + DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,
  26 + DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,
  27 + DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,
  28 + _ => context.l10n.activityWeeklyWeekdaySunday,
  29 +};
28 30
29 class ActivityBurnMonthReportView extends StatelessWidget { 31 class ActivityBurnMonthReportView extends StatelessWidget {
30 const ActivityBurnMonthReportView({ 32 const ActivityBurnMonthReportView({
@@ -134,7 +136,9 @@ class _MonthlySummary extends StatelessWidget { @@ -134,7 +136,9 @@ class _MonthlySummary extends StatelessWidget {
134 children: [ 136 children: [
135 _SummaryMetric( 137 _SummaryMetric(
136 title: HealthReportSubjectScope.titleOf( 138 title: HealthReportSubjectScope.titleOf(
137 - context, context.l10n.activityWeeklyExerciseTitle), 139 + context,
  140 + context.l10n.activityWeeklyExerciseTitle,
  141 + ),
138 value: exercise.value, 142 value: exercise.value,
139 unit: exercise.unit, 143 unit: exercise.unit,
140 color: isOhos() 144 color: isOhos()
@@ -144,7 +148,9 @@ class _MonthlySummary extends StatelessWidget { @@ -144,7 +148,9 @@ class _MonthlySummary extends StatelessWidget {
144 const SizedBox(width: 34), 148 const SizedBox(width: 34),
145 _SummaryMetric( 149 _SummaryMetric(
146 title: HealthReportSubjectScope.titleOf( 150 title: HealthReportSubjectScope.titleOf(
147 - context, context.l10n.activityWeeklyStandTitle), 151 + context,
  152 + context.l10n.activityWeeklyStandTitle,
  153 + ),
148 value: stand.value, 154 value: stand.value,
149 unit: stand.unit, 155 unit: stand.unit,
150 color: ActivityBurnMonthReportView._stand, 156 color: ActivityBurnMonthReportView._stand,
@@ -157,8 +163,8 @@ class _MonthlySummary extends StatelessWidget { @@ -157,8 +163,8 @@ class _MonthlySummary extends StatelessWidget {
157 } 163 }
158 164
159 String _totalBurnTitle(BuildContext context) { 165 String _totalBurnTitle(BuildContext context) {
160 - final scope =  
161 - context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); 166 + final scope = context
  167 + .dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
162 return scope == null || scope.isSelf 168 return scope == null || scope.isSelf
163 ? context.l10n.activityWeeklySelfTitle 169 ? context.l10n.activityWeeklySelfTitle
164 : context.l10n.activityWeeklyOtherTitle; 170 : context.l10n.activityWeeklyOtherTitle;
@@ -330,10 +336,7 @@ class _RingStat extends StatelessWidget { @@ -330,10 +336,7 @@ class _RingStat extends StatelessWidget {
330 children: [ 336 children: [
331 Row( 337 Row(
332 children: [ 338 children: [
333 - ActivityBurnRing(  
334 - report: report,  
335 - size: 12,  
336 - ), 339 + ActivityBurnRing(report: report, size: 12),
337 const SizedBox(width: 5), 340 const SizedBox(width: 5),
338 Text( 341 Text(
339 label, 342 label,
@@ -485,167 +488,175 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -485,167 +488,175 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
485 final hasChartData = _hasChartData; 488 final hasChartData = _hasChartData;
486 final maxY = _maxY; 489 final maxY = _maxY;
487 final comparisonPercent = widget.report.activeEnergyComparisonPercent; 490 final comparisonPercent = widget.report.activeEnergyComparisonPercent;
488 - return Container(  
489 - height: 344,  
490 - padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),  
491 - decoration: BoxDecoration(  
492 - color: Colors.white,  
493 - borderRadius: BorderRadius.circular(16),  
494 - ),  
495 - child: Column(  
496 - crossAxisAlignment: CrossAxisAlignment.start,  
497 - children: [  
498 - Text(  
499 - HealthReportSubjectScope.titleOf(  
500 - context, context.l10n.activityCalorieTrend),  
501 - style: const TextStyle(  
502 - color: ActivityBurnMonthReportView._h1,  
503 - fontSize: 16,  
504 - fontWeight: FontWeight.w600,  
505 - ),  
506 - ),  
507 - const SizedBox(height: 10),  
508 - Row(  
509 - crossAxisAlignment: CrossAxisAlignment.baseline,  
510 - textBaseline: TextBaseline.alphabetic,  
511 - children: [  
512 - Text(  
513 - hasData  
514 - ? widget.report.averageDailyActiveEnergy.toString()  
515 - : '-',  
516 - style: const TextStyle(  
517 - color: ActivityBurnMonthReportView._h1,  
518 - fontSize: 18,  
519 - fontWeight: FontWeight.w700,  
520 - ), 491 + return FriendInteractCardOverlay(
  492 + showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
  493 + interactionType: FriendInteractionType.monthlyActivityBurn,
  494 + child: Container(
  495 + height: 344,
  496 + padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
  497 + decoration: BoxDecoration(
  498 + color: Colors.white,
  499 + borderRadius: BorderRadius.circular(16),
  500 + ),
  501 + child: Column(
  502 + crossAxisAlignment: CrossAxisAlignment.start,
  503 + children: [
  504 + Text(
  505 + HealthReportSubjectScope.titleOf(
  506 + context,
  507 + context.l10n.activityCalorieTrend,
521 ), 508 ),
522 - Padding(  
523 - padding: const EdgeInsets.only(left: 4),  
524 - child: Text(  
525 - context.l10n.activityKcalDailyAverage, 509 + style: const TextStyle(
  510 + color: ActivityBurnMonthReportView._h1,
  511 + fontSize: 16,
  512 + fontWeight: FontWeight.w600,
  513 + ),
  514 + ),
  515 + const SizedBox(height: 10),
  516 + Row(
  517 + crossAxisAlignment: CrossAxisAlignment.baseline,
  518 + textBaseline: TextBaseline.alphabetic,
  519 + children: [
  520 + Text(
  521 + hasData
  522 + ? widget.report.averageDailyActiveEnergy.toString()
  523 + : '-',
526 style: const TextStyle( 524 style: const TextStyle(
527 color: ActivityBurnMonthReportView._h1, 525 color: ActivityBurnMonthReportView._h1,
528 - fontSize: 12, 526 + fontSize: 18,
  527 + fontWeight: FontWeight.w700,
529 ), 528 ),
530 ), 529 ),
531 - ),  
532 - ],  
533 - ),  
534 - const SizedBox(height: 2),  
535 - Row(  
536 - children: [  
537 - if (comparisonPercent != null) ...[  
538 - Image.asset(  
539 - comparisonPercent >= 0  
540 - ? R.assetsImagesHealthTrendUp  
541 - : R.assetsImagesHealthTrendDown,  
542 - width: 12,  
543 - height: 12, 530 + Padding(
  531 + padding: const EdgeInsets.only(left: 4),
  532 + child: Text(
  533 + context.l10n.activityKcalDailyAverage,
  534 + style: const TextStyle(
  535 + color: ActivityBurnMonthReportView._h1,
  536 + fontSize: 12,
  537 + ),
  538 + ),
544 ), 539 ),
545 - const SizedBox(width: 2),  
546 ], 540 ],
547 - Text(  
548 - _comparisonText(context, comparisonPercent),  
549 - style: TextStyle(  
550 - color: ActivityBurnMonthReportView._h2,  
551 - fontSize: 12,  
552 - ),  
553 - ),  
554 - const Spacer(),  
555 - _TrendLegend(  
556 - color: const Color(0xFFFFC0CE),  
557 - label: context.l10n.activityTrendAverage,  
558 - ),  
559 - const SizedBox(width: 12),  
560 - _TrendLegend(  
561 - color: const Color(0xFF96E9CB),  
562 - label: context.l10n.activityTrendGoal,  
563 - dashed: true,  
564 - ),  
565 - ],  
566 - ),  
567 - const SizedBox(height: 8),  
568 - Expanded(  
569 - child: Stack(  
570 - alignment: Alignment.center,  
571 - clipBehavior: Clip.none, 541 + ),
  542 + const SizedBox(height: 2),
  543 + Row(
572 children: [ 544 children: [
573 - Positioned(  
574 - left: 0,  
575 - right: 0,  
576 - top: _chartTopInset,  
577 - bottom: 0,  
578 - child: ActivityBurnTrendPlotFrame(  
579 - maxY: maxY,  
580 - average: widget.report.averageDailyActiveEnergy.toDouble(),  
581 - target: widget.report.activeEnergyGoal.toDouble(),  
582 - child: LayoutBuilder(  
583 - builder: (context, constraints) => Listener(  
584 - behavior: HitTestBehavior.opaque,  
585 - onPointerDown: (event) => _selectTooltipAt(  
586 - event.localPosition.dx,  
587 - constraints.maxWidth,  
588 - ),  
589 - onPointerMove: (event) => _selectTooltipAt(  
590 - event.localPosition.dx,  
591 - constraints.maxWidth,  
592 - ),  
593 - onPointerUp: (_) => _scheduleTooltipDismissal(),  
594 - onPointerCancel: (_) => _scheduleTooltipDismissal(),  
595 - child: Transform.translate(  
596 - offset: const Offset(  
597 - (_xAxisLeftInset - _xAxisRightInset) / 2,  
598 - 0, 545 + if (comparisonPercent != null) ...[
  546 + Image.asset(
  547 + comparisonPercent >= 0
  548 + ? R.assetsImagesHealthTrendUp
  549 + : R.assetsImagesHealthTrendDown,
  550 + width: 12,
  551 + height: 12,
  552 + ),
  553 + const SizedBox(width: 2),
  554 + ],
  555 + Text(
  556 + _comparisonText(context, comparisonPercent),
  557 + style: TextStyle(
  558 + color: ActivityBurnMonthReportView._h2,
  559 + fontSize: 12,
  560 + ),
  561 + ),
  562 + const Spacer(),
  563 + _TrendLegend(
  564 + color: const Color(0xFFFFC0CE),
  565 + label: context.l10n.activityTrendAverage,
  566 + ),
  567 + const SizedBox(width: 12),
  568 + _TrendLegend(
  569 + color: const Color(0xFF96E9CB),
  570 + label: context.l10n.activityTrendGoal,
  571 + dashed: true,
  572 + ),
  573 + ],
  574 + ),
  575 + const SizedBox(height: 8),
  576 + Expanded(
  577 + child: Stack(
  578 + alignment: Alignment.center,
  579 + clipBehavior: Clip.none,
  580 + children: [
  581 + Positioned(
  582 + left: 0,
  583 + right: 0,
  584 + top: _chartTopInset,
  585 + bottom: 0,
  586 + child: ActivityBurnTrendPlotFrame(
  587 + maxY: maxY,
  588 + average: widget.report.averageDailyActiveEnergy
  589 + .toDouble(),
  590 + target: widget.report.activeEnergyGoal.toDouble(),
  591 + child: LayoutBuilder(
  592 + builder: (context, constraints) => Listener(
  593 + behavior: HitTestBehavior.opaque,
  594 + onPointerDown: (event) => _selectTooltipAt(
  595 + event.localPosition.dx,
  596 + constraints.maxWidth,
  597 + ),
  598 + onPointerMove: (event) => _selectTooltipAt(
  599 + event.localPosition.dx,
  600 + constraints.maxWidth,
599 ), 601 ),
600 - child: BarChart(  
601 - _chartData(maxY, constraints.maxWidth), 602 + onPointerUp: (_) => _scheduleTooltipDismissal(),
  603 + onPointerCancel: (_) => _scheduleTooltipDismissal(),
  604 + child: Transform.translate(
  605 + offset: const Offset(
  606 + (_xAxisLeftInset - _xAxisRightInset) / 2,
  607 + 0,
  608 + ),
  609 + child: BarChart(
  610 + _chartData(maxY, constraints.maxWidth),
  611 + ),
602 ), 612 ),
603 ), 613 ),
604 ), 614 ),
605 ), 615 ),
606 ), 616 ),
607 - ),  
608 - if (_touchedIndex != null)  
609 - Positioned.fill(  
610 - top: _chartTopInset,  
611 - child: LayoutBuilder(  
612 - builder: (context, constraints) {  
613 - return ChartSelectionLineOverlay(  
614 - offset: Offset.zero,  
615 - color: ActivityBurnMonthReportView._h3,  
616 - bottomTitleHeight:  
617 - ActivityBurnTrendPlotFrame.bottomTitleHeight,  
618 - tooltipMargin: _tooltipMargin,  
619 - plotLeft: _xAxisLeftInset,  
620 - plotRight: _xAxisRightInset,  
621 - lineX: _lineXForIndex(constraints.maxWidth),  
622 - lineTop:  
623 - _tooltipBottomForHeight(constraints.maxHeight),  
624 - );  
625 - }, 617 + if (_touchedIndex != null)
  618 + Positioned.fill(
  619 + top: _chartTopInset,
  620 + child: LayoutBuilder(
  621 + builder: (context, constraints) {
  622 + return ChartSelectionLineOverlay(
  623 + offset: Offset.zero,
  624 + color: ActivityBurnMonthReportView._h3,
  625 + bottomTitleHeight:
  626 + ActivityBurnTrendPlotFrame.bottomTitleHeight,
  627 + tooltipMargin: _tooltipMargin,
  628 + plotLeft: _xAxisLeftInset,
  629 + plotRight: _xAxisRightInset,
  630 + lineX: _lineXForIndex(constraints.maxWidth),
  631 + lineTop: _tooltipBottomForHeight(
  632 + constraints.maxHeight,
  633 + ),
  634 + );
  635 + },
  636 + ),
626 ), 637 ),
627 - ),  
628 - if (!hasChartData)  
629 - Positioned(  
630 - left: _xAxisLeftInset,  
631 - right: _xAxisRightInset,  
632 - top: _chartTopInset,  
633 - bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,  
634 - child: Center(  
635 - child: Text(  
636 - context.l10n.activityTrendAwaitingData,  
637 - style: const TextStyle(  
638 - color: Color(0xFFA1A0A5),  
639 - fontSize: 12,  
640 - fontWeight: FontWeight.w400, 638 + if (!hasChartData)
  639 + Positioned(
  640 + left: _xAxisLeftInset,
  641 + right: _xAxisRightInset,
  642 + top: _chartTopInset,
  643 + bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
  644 + child: Center(
  645 + child: Text(
  646 + context.l10n.activityTrendAwaitingData,
  647 + style: const TextStyle(
  648 + color: Color(0xFFA1A0A5),
  649 + fontSize: 12,
  650 + fontWeight: FontWeight.w400,
  651 + ),
641 ), 652 ),
642 ), 653 ),
643 ), 654 ),
644 - ),  
645 - ], 655 + ],
  656 + ),
646 ), 657 ),
647 - ),  
648 - ], 658 + ],
  659 + ),
649 ), 660 ),
650 ); 661 );
651 } 662 }
@@ -655,9 +666,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -655,9 +666,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
655 .map((day) => day.activeEnergy?.value ?? 0) 666 .map((day) => day.activeEnergy?.value ?? 0)
656 .fold<int>(0, (max, value) => value > max ? value : max); 667 .fold<int>(0, (max, value) => value > max ? value : max);
657 final maxReference = math.max(maxValue, widget.report.activeEnergyGoal); 668 final maxReference = math.max(maxValue, widget.report.activeEnergyGoal);
658 - return _roundUpToFiveOrZero(maxReference)  
659 - .clamp(100, double.infinity)  
660 - .toDouble(); 669 + return _roundUpToFiveOrZero(
  670 + maxReference,
  671 + ).clamp(100, double.infinity).toDouble();
661 } 672 }
662 673
663 bool get _hasChartData => 674 bool get _hasChartData =>
@@ -716,15 +727,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -716,15 +727,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
716 if (chartWidth <= 0) return null; 727 if (chartWidth <= 0) return null;
717 final groupsSpace = _groupsSpace(chartWidth, count); 728 final groupsSpace = _groupsSpace(chartWidth, count);
718 const chartLeft = _xAxisLeftInset; 729 const chartLeft = _xAxisLeftInset;
719 - final index =  
720 - ((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace)).round(); 730 + final index = ((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace))
  731 + .round();
721 return index.clamp(0, count - 1).toInt(); 732 return index.clamp(0, count - 1).toInt();
722 } 733 }
723 734
724 void _selectTooltipAt(double? dx, double plotWidth) { 735 void _selectTooltipAt(double? dx, double plotWidth) {
725 _tooltipDismissTimer?.cancel(); 736 _tooltipDismissTimer?.cancel();
726 final touchedIndex = _indexForTouch(dx, plotWidth); 737 final touchedIndex = _indexForTouch(dx, plotWidth);
727 - final hasTouchedData = touchedIndex != null && 738 + final hasTouchedData =
  739 + touchedIndex != null &&
728 (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0; 740 (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0;
729 final nextIndex = hasTouchedData ? touchedIndex : null; 741 final nextIndex = hasTouchedData ? touchedIndex : null;
730 if (_touchedIndex == nextIndex) return; 742 if (_touchedIndex == nextIndex) return;
@@ -739,16 +751,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -739,16 +751,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
739 751
740 BarChartData _chartData(double maxY, double plotWidth) { 752 BarChartData _chartData(double maxY, double plotWidth) {
741 final hasData = widget.report.hasData; 753 final hasData = widget.report.hasData;
742 - final chartWidth =  
743 - math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset); 754 + final chartWidth = math.max(
  755 + 0.0,
  756 + plotWidth - _xAxisLeftInset - _xAxisRightInset,
  757 + );
744 return BarChartData( 758 return BarChartData(
745 minY: 0, 759 minY: 0,
746 maxY: maxY, 760 maxY: maxY,
747 alignment: BarChartAlignment.center, 761 alignment: BarChartAlignment.center,
748 groupsSpace: _groupsSpace(chartWidth, widget.report.days.length), 762 groupsSpace: _groupsSpace(chartWidth, widget.report.days.length),
749 - gridData: FlGridData(  
750 - show: false,  
751 - ), 763 + gridData: FlGridData(show: false),
752 borderData: FlBorderData(show: false), 764 borderData: FlBorderData(show: false),
753 barTouchData: BarTouchData( 765 barTouchData: BarTouchData(
754 enabled: hasData, 766 enabled: hasData,
@@ -762,10 +774,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -762,10 +774,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
762 _scheduleTooltipDismissal(); 774 _scheduleTooltipDismissal();
763 return; 775 return;
764 } 776 }
765 - _selectTooltipAt(  
766 - event.localPosition?.dx,  
767 - plotWidth,  
768 - ); 777 + _selectTooltipAt(event.localPosition?.dx, plotWidth);
769 }, 778 },
770 touchTooltipData: BarTouchTooltipData( 779 touchTooltipData: BarTouchTooltipData(
771 tooltipRoundedRadius: 4, 780 tooltipRoundedRadius: 4,
@@ -810,7 +819,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -810,7 +819,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
810 reservedSize: 36, 819 reservedSize: 36,
811 interval: activityBurnTrendYInterval(maxY), 820 interval: activityBurnTrendYInterval(maxY),
812 getTitlesWidget: (value, meta) { 821 getTitlesWidget: (value, meta) {
813 - final isRegularTick = value % activityBurnTrendYInterval(maxY) == 0; 822 + final isRegularTick =
  823 + value % activityBurnTrendYInterval(maxY) == 0;
814 final isMax = (value - maxY).abs() < 0.001; 824 final isMax = (value - maxY).abs() < 0.001;
815 if (!isRegularTick && !isMax) return const SizedBox.shrink(); 825 if (!isRegularTick && !isMax) return const SizedBox.shrink();
816 return Transform.translate( 826 return Transform.translate(
@@ -873,8 +883,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -873,8 +883,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
873 maxY, 883 maxY,
874 ), 884 ),
875 width: _barWidth, 885 width: _barWidth,
876 - borderRadius:  
877 - const BorderRadius.vertical(top: Radius.circular(4)), 886 + borderRadius: const BorderRadius.vertical(
  887 + top: Radius.circular(4),
  888 + ),
878 color: _barColor, 889 color: _barColor,
879 ), 890 ),
880 // Anchor the tooltip at a constant height, independent of the 891 // Anchor the tooltip at a constant height, independent of the
@@ -897,8 +908,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -897,8 +908,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
897 final count = widget.report.days.length; 908 final count = widget.report.days.length;
898 if (count <= 7) return true; 909 if (count <= 7) return true;
899 final lastIndex = count - 1; 910 final lastIndex = count - 1;
900 - return List.generate(7, (tick) => (tick * lastIndex / 6).round())  
901 - .contains(index); 911 + return List.generate(
  912 + 7,
  913 + (tick) => (tick * lastIndex / 6).round(),
  914 + ).contains(index);
902 } 915 }
903 } 916 }
904 917
@@ -3,10 +3,12 @@ import 'dart:math' as math; @@ -3,10 +3,12 @@ import 'dart:math' as math;
3 3
4 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
5 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
  6 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
6 import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; 7 import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
7 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 8 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
8 9
9 import '../../../../r.dart'; 10 import '../../../../r.dart';
  11 +import '../../../widget/friend_interact_click_view.dart';
10 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 12 import '../../report_common/widgets/chart_selection_line_overlay.dart';
11 import '../../report_common/widgets/health_report_subject_scope.dart'; 13 import '../../report_common/widgets/health_report_subject_scope.dart';
12 import '../../report_common/utils/report_localization.dart'; 14 import '../../report_common/utils/report_localization.dart';
@@ -16,14 +18,14 @@ import 'activity_burn_ring.dart'; @@ -16,14 +18,14 @@ import 'activity_burn_ring.dart';
16 import 'activity_burn_trend_lines.dart'; 18 import 'activity_burn_trend_lines.dart';
17 19
18 String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) { 20 String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) {
19 - DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,  
20 - DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,  
21 - DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,  
22 - DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,  
23 - DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,  
24 - DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,  
25 - _ => context.l10n.activityWeeklyWeekdaySunday,  
26 - }; 21 + DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,
  22 + DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,
  23 + DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,
  24 + DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,
  25 + DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,
  26 + DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,
  27 + _ => context.l10n.activityWeeklyWeekdaySunday,
  28 +};
27 29
28 class ActivityBurnWeekReportView extends StatelessWidget { 30 class ActivityBurnWeekReportView extends StatelessWidget {
29 const ActivityBurnWeekReportView({ 31 const ActivityBurnWeekReportView({
@@ -135,7 +137,9 @@ class _WeeklySummary extends StatelessWidget { @@ -135,7 +137,9 @@ class _WeeklySummary extends StatelessWidget {
135 children: [ 137 children: [
136 _SummaryMetric( 138 _SummaryMetric(
137 title: HealthReportSubjectScope.titleOf( 139 title: HealthReportSubjectScope.titleOf(
138 - context, context.l10n.activityWeeklyExerciseTitle), 140 + context,
  141 + context.l10n.activityWeeklyExerciseTitle,
  142 + ),
139 value: exercise.value, 143 value: exercise.value,
140 unit: exercise.unit, 144 unit: exercise.unit,
141 color: isOhos() 145 color: isOhos()
@@ -145,7 +149,9 @@ class _WeeklySummary extends StatelessWidget { @@ -145,7 +149,9 @@ class _WeeklySummary extends StatelessWidget {
145 const SizedBox(width: 34), 149 const SizedBox(width: 34),
146 _SummaryMetric( 150 _SummaryMetric(
147 title: HealthReportSubjectScope.titleOf( 151 title: HealthReportSubjectScope.titleOf(
148 - context, context.l10n.activityWeeklyStandTitle), 152 + context,
  153 + context.l10n.activityWeeklyStandTitle,
  154 + ),
149 value: stand.value, 155 value: stand.value,
150 unit: stand.unit, 156 unit: stand.unit,
151 color: ActivityBurnWeekReportView._stand, 157 color: ActivityBurnWeekReportView._stand,
@@ -158,8 +164,8 @@ class _WeeklySummary extends StatelessWidget { @@ -158,8 +164,8 @@ class _WeeklySummary extends StatelessWidget {
158 } 164 }
159 165
160 String _totalBurnTitle(BuildContext context) { 166 String _totalBurnTitle(BuildContext context) {
161 - final scope =  
162 - context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); 167 + final scope = context
  168 + .dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
163 return scope == null || scope.isSelf 169 return scope == null || scope.isSelf
164 ? context.l10n.activityWeeklySelfTitle 170 ? context.l10n.activityWeeklySelfTitle
165 : context.l10n.activityWeeklyOtherTitle; 171 : context.l10n.activityWeeklyOtherTitle;
@@ -294,9 +300,7 @@ class _RingOverviewCard extends StatelessWidget { @@ -294,9 +300,7 @@ class _RingOverviewCard extends StatelessWidget {
294 const SizedBox(height: 20), 300 const SizedBox(height: 20),
295 Row( 301 Row(
296 mainAxisAlignment: MainAxisAlignment.spaceBetween, 302 mainAxisAlignment: MainAxisAlignment.spaceBetween,
297 - children: [  
298 - for (final day in report.days) _DayRing(report: day),  
299 - ], 303 + children: [for (final day in report.days) _DayRing(report: day)],
300 ), 304 ),
301 ], 305 ],
302 ), 306 ),
@@ -327,10 +331,7 @@ class _RingStat extends StatelessWidget { @@ -327,10 +331,7 @@ class _RingStat extends StatelessWidget {
327 children: [ 331 children: [
328 Row( 332 Row(
329 children: [ 333 children: [
330 - ActivityBurnRing(  
331 - report: report,  
332 - size: 12,  
333 - ), 334 + ActivityBurnRing(report: report, size: 12),
334 const SizedBox(width: 5), 335 const SizedBox(width: 5),
335 Text( 336 Text(
336 label, 337 label,
@@ -506,169 +507,175 @@ class _ActivityBurnEnergyTrendCardState @@ -506,169 +507,175 @@ class _ActivityBurnEnergyTrendCardState
506 final hasChartData = _hasChartData; 507 final hasChartData = _hasChartData;
507 final maxY = _maxY; 508 final maxY = _maxY;
508 final comparisonPercent = widget.report.activeEnergyComparisonPercent; 509 final comparisonPercent = widget.report.activeEnergyComparisonPercent;
509 - return Container(  
510 - height: 344,  
511 - padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),  
512 - decoration: BoxDecoration(  
513 - color: Colors.white,  
514 - borderRadius: BorderRadius.circular(16),  
515 - ),  
516 - child: Column(  
517 - crossAxisAlignment: CrossAxisAlignment.start,  
518 - children: [  
519 - Text(  
520 - HealthReportSubjectScope.titleOf(  
521 - context, context.l10n.activityCalorieTrend),  
522 - style: const TextStyle(  
523 - color: ActivityBurnWeekReportView._h1,  
524 - fontSize: 16,  
525 - fontWeight: FontWeight.w600,  
526 - ),  
527 - ),  
528 - const SizedBox(height: 10),  
529 - Row(  
530 - crossAxisAlignment: CrossAxisAlignment.baseline,  
531 - textBaseline: TextBaseline.alphabetic,  
532 - children: [  
533 - Text(  
534 - hasData  
535 - ? widget.report.averageDailyActiveEnergy.toString()  
536 - : '-',  
537 - style: const TextStyle(  
538 - color: ActivityBurnWeekReportView._h1,  
539 - fontSize: 18,  
540 - fontWeight: FontWeight.w700,  
541 - ), 510 + return FriendInteractCardOverlay(
  511 + showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
  512 + interactionType: FriendInteractionType.weeklyActivityBurn,
  513 + child: Container(
  514 + height: 344,
  515 + padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
  516 + decoration: BoxDecoration(
  517 + color: Colors.white,
  518 + borderRadius: BorderRadius.circular(16),
  519 + ),
  520 + child: Column(
  521 + crossAxisAlignment: CrossAxisAlignment.start,
  522 + children: [
  523 + Text(
  524 + HealthReportSubjectScope.titleOf(
  525 + context,
  526 + context.l10n.activityCalorieTrend,
542 ), 527 ),
543 - Padding(  
544 - padding: const EdgeInsets.only(left: 4),  
545 - child: Text(  
546 - context.l10n.activityKcalDailyAverage, 528 + style: const TextStyle(
  529 + color: ActivityBurnWeekReportView._h1,
  530 + fontSize: 16,
  531 + fontWeight: FontWeight.w600,
  532 + ),
  533 + ),
  534 + const SizedBox(height: 10),
  535 + Row(
  536 + crossAxisAlignment: CrossAxisAlignment.baseline,
  537 + textBaseline: TextBaseline.alphabetic,
  538 + children: [
  539 + Text(
  540 + hasData
  541 + ? widget.report.averageDailyActiveEnergy.toString()
  542 + : '-',
547 style: const TextStyle( 543 style: const TextStyle(
548 color: ActivityBurnWeekReportView._h1, 544 color: ActivityBurnWeekReportView._h1,
549 - fontSize: 12, 545 + fontSize: 18,
  546 + fontWeight: FontWeight.w700,
550 ), 547 ),
551 ), 548 ),
552 - ),  
553 - ],  
554 - ),  
555 - const SizedBox(height: 2),  
556 - Row(  
557 - children: [  
558 - if (comparisonPercent != null) ...[  
559 - Image.asset(  
560 - comparisonPercent >= 0  
561 - ? R.assetsImagesHealthTrendUp  
562 - : R.assetsImagesHealthTrendDown,  
563 - width: 12,  
564 - height: 12, 549 + Padding(
  550 + padding: const EdgeInsets.only(left: 4),
  551 + child: Text(
  552 + context.l10n.activityKcalDailyAverage,
  553 + style: const TextStyle(
  554 + color: ActivityBurnWeekReportView._h1,
  555 + fontSize: 12,
  556 + ),
  557 + ),
565 ), 558 ),
566 - const SizedBox(width: 2),  
567 ], 559 ],
568 - Text(  
569 - _comparisonText(context, comparisonPercent),  
570 - style: const TextStyle(  
571 - color: ActivityBurnWeekReportView._h2,  
572 - fontSize: 12,  
573 - ),  
574 - ),  
575 - const Spacer(),  
576 - _TrendLegend(  
577 - color: const Color(0xFFFFC0CE),  
578 - label: context.l10n.activityTrendAverage,  
579 - ),  
580 - const SizedBox(width: 12),  
581 - _TrendLegend(  
582 - color: const Color(0xFF96E9CB),  
583 - label: context.l10n.activityTrendGoal,  
584 - dashed: true,  
585 - ),  
586 - ],  
587 - ),  
588 - const SizedBox(height: 8),  
589 - Expanded(  
590 - child: Stack(  
591 - alignment: Alignment.center,  
592 - clipBehavior: Clip.none, 560 + ),
  561 + const SizedBox(height: 2),
  562 + Row(
593 children: [ 563 children: [
594 - Positioned(  
595 - left: 0,  
596 - right: 0,  
597 - top: _chartTopInset,  
598 - bottom: 0,  
599 - child: ActivityBurnTrendPlotFrame(  
600 - maxY: maxY,  
601 - average: widget.report.averageDailyActiveEnergy.toDouble(),  
602 - target: widget.report.activeEnergyGoal.toDouble(),  
603 - child: LayoutBuilder(  
604 - builder: (context, constraints) => Listener(  
605 - behavior: HitTestBehavior.opaque,  
606 - onPointerDown: (event) => _selectTooltipAt(  
607 - event.localPosition.dx,  
608 - constraints.maxWidth,  
609 - ),  
610 - onPointerMove: (event) => _selectTooltipAt(  
611 - event.localPosition.dx,  
612 - constraints.maxWidth,  
613 - ),  
614 - onPointerUp: (_) => _scheduleTooltipDismissal(),  
615 - onPointerCancel: (_) => _scheduleTooltipDismissal(),  
616 - child: Transform.translate(  
617 - offset: const Offset(  
618 - (_xAxisLeftInset - _xAxisRightInset) / 2,  
619 - 0,  
620 - ),  
621 - child: BarChart(  
622 - _chartData(maxY, constraints.maxWidth),  
623 - ),  
624 - ),  
625 - ),  
626 - ), 564 + if (comparisonPercent != null) ...[
  565 + Image.asset(
  566 + comparisonPercent >= 0
  567 + ? R.assetsImagesHealthTrendUp
  568 + : R.assetsImagesHealthTrendDown,
  569 + width: 12,
  570 + height: 12,
  571 + ),
  572 + const SizedBox(width: 2),
  573 + ],
  574 + Text(
  575 + _comparisonText(context, comparisonPercent),
  576 + style: const TextStyle(
  577 + color: ActivityBurnWeekReportView._h2,
  578 + fontSize: 12,
627 ), 579 ),
628 ), 580 ),
629 - if (_touchedIndex != null)  
630 - Positioned.fill( 581 + const Spacer(),
  582 + _TrendLegend(
  583 + color: const Color(0xFFFFC0CE),
  584 + label: context.l10n.activityTrendAverage,
  585 + ),
  586 + const SizedBox(width: 12),
  587 + _TrendLegend(
  588 + color: const Color(0xFF96E9CB),
  589 + label: context.l10n.activityTrendGoal,
  590 + dashed: true,
  591 + ),
  592 + ],
  593 + ),
  594 + const SizedBox(height: 8),
  595 + Expanded(
  596 + child: Stack(
  597 + alignment: Alignment.center,
  598 + clipBehavior: Clip.none,
  599 + children: [
  600 + Positioned(
  601 + left: 0,
  602 + right: 0,
631 top: _chartTopInset, 603 top: _chartTopInset,
632 - child: LayoutBuilder(  
633 - builder: (context, constraints) {  
634 - return ChartSelectionLineOverlay(  
635 - offset: Offset.zero,  
636 - color: ActivityBurnWeekReportView._h3,  
637 - bottomTitleHeight:  
638 - ActivityBurnTrendPlotFrame.bottomTitleHeight,  
639 - tooltipMargin: _tooltipMargin,  
640 - plotLeft: _xAxisLeftInset,  
641 - plotRight: _xAxisRightInset,  
642 - lineX: _lineXForIndex( 604 + bottom: 0,
  605 + child: ActivityBurnTrendPlotFrame(
  606 + maxY: maxY,
  607 + average: widget.report.averageDailyActiveEnergy
  608 + .toDouble(),
  609 + target: widget.report.activeEnergyGoal.toDouble(),
  610 + child: LayoutBuilder(
  611 + builder: (context, constraints) => Listener(
  612 + behavior: HitTestBehavior.opaque,
  613 + onPointerDown: (event) => _selectTooltipAt(
  614 + event.localPosition.dx,
643 constraints.maxWidth, 615 constraints.maxWidth,
644 ), 616 ),
645 - lineTop:  
646 - _tooltipBottomForHeight(constraints.maxHeight),  
647 - );  
648 - }, 617 + onPointerMove: (event) => _selectTooltipAt(
  618 + event.localPosition.dx,
  619 + constraints.maxWidth,
  620 + ),
  621 + onPointerUp: (_) => _scheduleTooltipDismissal(),
  622 + onPointerCancel: (_) => _scheduleTooltipDismissal(),
  623 + child: Transform.translate(
  624 + offset: const Offset(
  625 + (_xAxisLeftInset - _xAxisRightInset) / 2,
  626 + 0,
  627 + ),
  628 + child: BarChart(
  629 + _chartData(maxY, constraints.maxWidth),
  630 + ),
  631 + ),
  632 + ),
  633 + ),
649 ), 634 ),
650 ), 635 ),
651 - if (!hasChartData)  
652 - Positioned(  
653 - left: _xAxisLeftInset,  
654 - right: _xAxisRightInset,  
655 - top: _chartTopInset,  
656 - bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,  
657 - child: Center(  
658 - child: Text(  
659 - context.l10n.activityTrendAwaitingData,  
660 - style: const TextStyle(  
661 - color: Color(0xFFA1A0A5),  
662 - fontSize: 12,  
663 - fontWeight: FontWeight.w400, 636 + if (_touchedIndex != null)
  637 + Positioned.fill(
  638 + top: _chartTopInset,
  639 + child: LayoutBuilder(
  640 + builder: (context, constraints) {
  641 + return ChartSelectionLineOverlay(
  642 + offset: Offset.zero,
  643 + color: ActivityBurnWeekReportView._h3,
  644 + bottomTitleHeight:
  645 + ActivityBurnTrendPlotFrame.bottomTitleHeight,
  646 + tooltipMargin: _tooltipMargin,
  647 + plotLeft: _xAxisLeftInset,
  648 + plotRight: _xAxisRightInset,
  649 + lineX: _lineXForIndex(constraints.maxWidth),
  650 + lineTop: _tooltipBottomForHeight(
  651 + constraints.maxHeight,
  652 + ),
  653 + );
  654 + },
  655 + ),
  656 + ),
  657 + if (!hasChartData)
  658 + Positioned(
  659 + left: _xAxisLeftInset,
  660 + right: _xAxisRightInset,
  661 + top: _chartTopInset,
  662 + bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
  663 + child: Center(
  664 + child: Text(
  665 + context.l10n.activityTrendAwaitingData,
  666 + style: const TextStyle(
  667 + color: Color(0xFFA1A0A5),
  668 + fontSize: 12,
  669 + fontWeight: FontWeight.w400,
  670 + ),
664 ), 671 ),
665 ), 672 ),
666 ), 673 ),
667 - ),  
668 - ], 674 + ],
  675 + ),
669 ), 676 ),
670 - ),  
671 - ], 677 + ],
  678 + ),
672 ), 679 ),
673 ); 680 );
674 } 681 }
@@ -678,9 +685,9 @@ class _ActivityBurnEnergyTrendCardState @@ -678,9 +685,9 @@ class _ActivityBurnEnergyTrendCardState
678 .map((day) => day.activeEnergy?.value ?? 0) 685 .map((day) => day.activeEnergy?.value ?? 0)
679 .fold<int>(0, (max, value) => value > max ? value : max); 686 .fold<int>(0, (max, value) => value > max ? value : max);
680 final maxReference = math.max(maxValue, widget.report.activeEnergyGoal); 687 final maxReference = math.max(maxValue, widget.report.activeEnergyGoal);
681 - return _roundUpToFiveOrZero(maxReference)  
682 - .clamp(100, double.infinity)  
683 - .toDouble(); 688 + return _roundUpToFiveOrZero(
  689 + maxReference,
  690 + ).clamp(100, double.infinity).toDouble();
684 } 691 }
685 692
686 bool get _hasChartData => 693 bool get _hasChartData =>
@@ -753,16 +760,18 @@ class _ActivityBurnEnergyTrendCardState @@ -753,16 +760,18 @@ class _ActivityBurnEnergyTrendCardState
753 if (chartWidth <= 0) return null; 760 if (chartWidth <= 0) return null;
754 final groupsSpace = _groupsSpace(chartWidth, count); 761 final groupsSpace = _groupsSpace(chartWidth, count);
755 const chartLeft = _xAxisLeftInset; 762 const chartLeft = _xAxisLeftInset;
756 - final index = ((dx - chartLeft - widget.report.barWidth / 2) /  
757 - (widget.report.barWidth + groupsSpace))  
758 - .round(); 763 + final index =
  764 + ((dx - chartLeft - widget.report.barWidth / 2) /
  765 + (widget.report.barWidth + groupsSpace))
  766 + .round();
759 return index.clamp(0, count - 1).toInt(); 767 return index.clamp(0, count - 1).toInt();
760 } 768 }
761 769
762 void _selectTooltipAt(double? dx, double plotWidth) { 770 void _selectTooltipAt(double? dx, double plotWidth) {
763 _tooltipDismissTimer?.cancel(); 771 _tooltipDismissTimer?.cancel();
764 final touchedIndex = _indexForTouch(dx, plotWidth); 772 final touchedIndex = _indexForTouch(dx, plotWidth);
765 - final hasTouchedData = touchedIndex != null && 773 + final hasTouchedData =
  774 + touchedIndex != null &&
766 (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0; 775 (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0;
767 final nextIndex = hasTouchedData ? touchedIndex : null; 776 final nextIndex = hasTouchedData ? touchedIndex : null;
768 if (_touchedIndex == nextIndex) return; 777 if (_touchedIndex == nextIndex) return;
@@ -779,16 +788,16 @@ class _ActivityBurnEnergyTrendCardState @@ -779,16 +788,16 @@ class _ActivityBurnEnergyTrendCardState
779 788
780 BarChartData _chartData(double maxY, double plotWidth) { 789 BarChartData _chartData(double maxY, double plotWidth) {
781 final hasData = widget.report.hasData; 790 final hasData = widget.report.hasData;
782 - final chartWidth =  
783 - math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset); 791 + final chartWidth = math.max(
  792 + 0.0,
  793 + plotWidth - _xAxisLeftInset - _xAxisRightInset,
  794 + );
784 return BarChartData( 795 return BarChartData(
785 minY: 0, 796 minY: 0,
786 maxY: maxY, 797 maxY: maxY,
787 alignment: BarChartAlignment.center, 798 alignment: BarChartAlignment.center,
788 groupsSpace: _groupsSpace(chartWidth, widget.report.days.length), 799 groupsSpace: _groupsSpace(chartWidth, widget.report.days.length),
789 - gridData: FlGridData(  
790 - show: false,  
791 - ), 800 + gridData: FlGridData(show: false),
792 borderData: FlBorderData(show: false), 801 borderData: FlBorderData(show: false),
793 barTouchData: BarTouchData( 802 barTouchData: BarTouchData(
794 enabled: hasData, 803 enabled: hasData,
@@ -802,10 +811,7 @@ class _ActivityBurnEnergyTrendCardState @@ -802,10 +811,7 @@ class _ActivityBurnEnergyTrendCardState
802 _scheduleTooltipDismissal(); 811 _scheduleTooltipDismissal();
803 return; 812 return;
804 } 813 }
805 - _selectTooltipAt(  
806 - event.localPosition?.dx,  
807 - plotWidth,  
808 - ); 814 + _selectTooltipAt(event.localPosition?.dx, plotWidth);
809 }, 815 },
810 touchTooltipData: BarTouchTooltipData( 816 touchTooltipData: BarTouchTooltipData(
811 tooltipRoundedRadius: 4, 817 tooltipRoundedRadius: 4,
@@ -850,7 +856,8 @@ class _ActivityBurnEnergyTrendCardState @@ -850,7 +856,8 @@ class _ActivityBurnEnergyTrendCardState
850 reservedSize: 36, 856 reservedSize: 36,
851 interval: activityBurnTrendYInterval(maxY), 857 interval: activityBurnTrendYInterval(maxY),
852 getTitlesWidget: (value, meta) { 858 getTitlesWidget: (value, meta) {
853 - final isRegularTick = value % activityBurnTrendYInterval(maxY) == 0; 859 + final isRegularTick =
  860 + value % activityBurnTrendYInterval(maxY) == 0;
854 final isMax = (value - maxY).abs() < 0.001; 861 final isMax = (value - maxY).abs() < 0.001;
855 if (!isRegularTick && !isMax) return const SizedBox.shrink(); 862 if (!isRegularTick && !isMax) return const SizedBox.shrink();
856 return Transform.translate( 863 return Transform.translate(
@@ -939,8 +946,10 @@ class _ActivityBurnEnergyTrendCardState @@ -939,8 +946,10 @@ class _ActivityBurnEnergyTrendCardState
939 final count = widget.report.days.length; 946 final count = widget.report.days.length;
940 if (count <= 7) return true; 947 if (count <= 7) return true;
941 final lastIndex = count - 1; 948 final lastIndex = count - 1;
942 - return List.generate(7, (tick) => (tick * lastIndex / 6).round())  
943 - .contains(index); 949 + return List.generate(
  950 + 7,
  951 + (tick) => (tick * lastIndex / 6).round(),
  952 + ).contains(index);
944 } 953 }
945 } 954 }
946 955
  1 +import 'dart:async';
  2 +
1 import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart'; 3 import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
  4 +import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
2 import 'package:doublefeel_flutter/app/modules/purchase/purchase_route_args.dart'; 5 import 'package:doublefeel_flutter/app/modules/purchase/purchase_route_args.dart';
3 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart'; 6 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
4 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart'; 7 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
@@ -137,6 +140,12 @@ class BindPartnerController extends GetxController { @@ -137,6 +140,12 @@ class BindPartnerController extends GetxController {
137 break; 140 break;
138 case AppSuccess(data: final friendItem): 141 case AppSuccess(data: final friendItem):
139 _platformHostApi.refreshWatchAppAndWidgets(); 142 _platformHostApi.refreshWatchAppAndWidgets();
  143 + // The add-friend flow can be opened outside the Friends tab (for
  144 + // example from Today). Refresh the home-owned list so PK appears as
  145 + // soon as the new friend is available.
  146 + if (Get.isRegistered<FriendsController>()) {
  147 + unawaited(Get.find<FriendsController>().loadFriends());
  148 + }
140 Get.to(() => _BindSuccessPage(friendItem, isShowAppBarBack)); 149 Get.to(() => _BindSuccessPage(friendItem, isShowAppBarBack));
141 ta.track('success_add_doubelfeel_friend'); 150 ta.track('success_add_doubelfeel_friend');
142 break; 151 break;
  1 +import 'package:doublefeel_flutter/app/modules/change_phone/controllers/change_phone_controller.dart';
  2 +import 'package:doublefeel_flutter/app/modules/change_phone/services/change_phone_gateway.dart';
  3 +import 'package:doublefeel_flutter/data/local/local_storage.dart';
  4 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  5 +import 'package:get/get.dart';
  6 +
  7 +class ChangePhoneBinding extends Bindings {
  8 + @override
  9 + void dependencies() {
  10 + Get.lazyPut<ChangePhoneGateway>(
  11 + () => const PlaceholderChangePhoneGateway());
  12 + Get.lazyPut<ChangePhoneController>(
  13 + () => ChangePhoneController(
  14 + userPreferencesStorage: Get.find<UserPreferencesStorage>(),
  15 + localStorage: Get.find<LocalStorage>(),
  16 + gateway: Get.find<ChangePhoneGateway>(),
  17 + ),
  18 + );
  19 + }
  20 +}
  1 +import 'dart:async';
  2 +
  3 +import 'package:doublefeel_flutter/app/modules/change_phone/services/change_phone_gateway.dart';
  4 +import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  5 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  6 +import 'package:doublefeel_flutter/data/local/local_storage.dart';
  7 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  8 +import 'package:flutter/material.dart';
  9 +import 'package:get/get.dart';
  10 +
  11 +enum ChangePhoneStep { verifyOldPhone, bindNewPhone }
  12 +
  13 +class ChangePhoneController extends GetxController {
  14 + ChangePhoneController({
  15 + required UserPreferencesStorage userPreferencesStorage,
  16 + required LocalStorage localStorage,
  17 + required ChangePhoneGateway gateway,
  18 + }) : _userPreferencesStorage = userPreferencesStorage,
  19 + _localStorage = localStorage,
  20 + _gateway = gateway;
  21 +
  22 + final UserPreferencesStorage _userPreferencesStorage;
  23 + final LocalStorage _localStorage;
  24 + final ChangePhoneGateway _gateway;
  25 +
  26 + final phoneController = TextEditingController();
  27 + final codeController = TextEditingController();
  28 + final codeFocusNode = FocusNode();
  29 +
  30 + final step = ChangePhoneStep.verifyOldPhone.obs;
  31 + final phoneInput = ''.obs;
  32 + final codeInput = ''.obs;
  33 + final isRequestingCode = false.obs;
  34 + final isSubmitting = false.obs;
  35 + final countdownSeconds = 0.obs;
  36 + Timer? _countdownTimer;
  37 +
  38 + String get cleanPhone => phoneInput.value.replaceAll(RegExp(r'\s+'), '');
  39 +
  40 + bool get isPhoneValid => RegExp(r'^\d{11}$').hasMatch(cleanPhone);
  41 +
  42 + bool get canRequestCode =>
  43 + isPhoneValid && !isRequestingCode.value && countdownSeconds.value == 0;
  44 +
  45 + bool get canSubmit =>
  46 + isPhoneValid && codeInput.value.isNotEmpty && !isSubmitting.value;
  47 +
  48 + bool get isOldPhoneStep => step.value == ChangePhoneStep.verifyOldPhone;
  49 +
  50 + bool get canChangePhone => _localStorage.lastLoginMethod == 'phone';
  51 +
  52 + String get maskedOldPhone {
  53 + final phone = _userPreferencesStorage
  54 + .preferences.value.meUserInfo?.telephone
  55 + ?.replaceAll(RegExp(r'\s+'), '');
  56 + if (phone == null || phone.length != 11) return '123****1212';
  57 + return '${phone.substring(0, 3)}****${phone.substring(7)}';
  58 + }
  59 +
  60 + @override
  61 + void onInit() {
  62 + super.onInit();
  63 + phoneController.addListener(_onPhoneChanged);
  64 + codeController.addListener(() => codeInput.value = codeController.text);
  65 + }
  66 +
  67 + @override
  68 + void onReady() {
  69 + super.onReady();
  70 + if (!canChangePhone) {
  71 + AppToast.show('仅支持手机号登录的账号更换手机号');
  72 + Get.back();
  73 + }
  74 + }
  75 +
  76 + Future<void> requestVerificationCode() async {
  77 + if (!canRequestCode) return;
  78 +
  79 + isRequestingCode.value = true;
  80 + try {
  81 + await _gateway.requestVerificationCode(
  82 + phone: cleanPhone,
  83 + isOldPhone: isOldPhoneStep,
  84 + );
  85 + _startCountdown();
  86 + codeFocusNode.requestFocus();
  87 + } on UnsupportedError {
  88 + AppToast.show('手机号更换服务暂未开通');
  89 + } finally {
  90 + isRequestingCode.value = false;
  91 + }
  92 + }
  93 +
  94 + Future<void> submit() async {
  95 + if (!canSubmit) return;
  96 +
  97 + isSubmitting.value = true;
  98 + try {
  99 + if (isOldPhoneStep) {
  100 + await _gateway.verifyOldPhone(
  101 + phone: cleanPhone,
  102 + verificationCode: codeInput.value,
  103 + );
  104 + _showNewPhoneStep();
  105 + } else {
  106 + await _gateway.bindNewPhone(
  107 + phone: cleanPhone,
  108 + verificationCode: codeInput.value,
  109 + );
  110 + Get.back();
  111 + }
  112 + } on UnsupportedError {
  113 + AppToast.show('手机号更换服务暂未开通');
  114 + } finally {
  115 + isSubmitting.value = false;
  116 + }
  117 + }
  118 +
  119 + void clearPhone() => phoneController.clear();
  120 +
  121 + void openHelp() => Get.toNamed(Routes.HELP);
  122 +
  123 + void _onPhoneChanged() {
  124 + final newPhone = phoneController.text;
  125 + if (phoneInput.value != newPhone &&
  126 + (countdownSeconds.value > 0 || codeInput.value.isNotEmpty)) {
  127 + _resetVerificationState();
  128 + }
  129 + phoneInput.value = newPhone;
  130 + }
  131 +
  132 + void _showNewPhoneStep() {
  133 + step.value = ChangePhoneStep.bindNewPhone;
  134 + phoneController.clear();
  135 + codeController.clear();
  136 + _resetVerificationState();
  137 + }
  138 +
  139 + void _resetVerificationState() {
  140 + _countdownTimer?.cancel();
  141 + countdownSeconds.value = 0;
  142 + codeController.clear();
  143 + }
  144 +
  145 + void _startCountdown() {
  146 + _countdownTimer?.cancel();
  147 + countdownSeconds.value = 60;
  148 + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
  149 + if (countdownSeconds.value <= 1) {
  150 + countdownSeconds.value = 0;
  151 + timer.cancel();
  152 + } else {
  153 + countdownSeconds.value--;
  154 + }
  155 + });
  156 + }
  157 +
  158 + @override
  159 + void onClose() {
  160 + _countdownTimer?.cancel();
  161 + phoneController.dispose();
  162 + codeController.dispose();
  163 + codeFocusNode.dispose();
  164 + super.onClose();
  165 + }
  166 +}
  1 +/// Boundary for the phone-change APIs.
  2 +///
  3 +/// Replace [PlaceholderChangePhoneGateway] in the binding with the real
  4 +/// implementation once the server endpoints are available. Keeping the
  5 +/// contract here makes the page and its interaction states usable meanwhile.
  6 +abstract interface class ChangePhoneGateway {
  7 + Future<void> requestVerificationCode({
  8 + required String phone,
  9 + required bool isOldPhone,
  10 + });
  11 +
  12 + Future<void> verifyOldPhone({
  13 + required String phone,
  14 + required String verificationCode,
  15 + });
  16 +
  17 + Future<void> bindNewPhone({
  18 + required String phone,
  19 + required String verificationCode,
  20 + });
  21 +}
  22 +
  23 +/// Temporary API implementation that deliberately does not report success.
  24 +///
  25 +/// TODO(server): replace these methods with the change-phone endpoints. This
  26 +/// prevents the UI from claiming that a phone number was changed before an API
  27 +/// exists to persist and verify it.
  28 +class PlaceholderChangePhoneGateway implements ChangePhoneGateway {
  29 + const PlaceholderChangePhoneGateway();
  30 +
  31 + @override
  32 + Future<void> bindNewPhone({
  33 + required String phone,
  34 + required String verificationCode,
  35 + }) =>
  36 + throw UnsupportedError('Change-phone API is not configured.');
  37 +
  38 + @override
  39 + Future<void> requestVerificationCode({
  40 + required String phone,
  41 + required bool isOldPhone,
  42 + }) =>
  43 + throw UnsupportedError('Change-phone API is not configured.');
  44 +
  45 + @override
  46 + Future<void> verifyOldPhone({
  47 + required String phone,
  48 + required String verificationCode,
  49 + }) =>
  50 + throw UnsupportedError('Change-phone API is not configured.');
  51 +}
  1 +import 'package:doublefeel_flutter/app/modules/change_phone/controllers/change_phone_controller.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_colors.dart';
  3 +import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  4 +import 'package:doublefeel_flutter/r.dart';
  5 +import 'package:flutter/material.dart';
  6 +import 'package:flutter/services.dart';
  7 +import 'package:get/get.dart';
  8 +
  9 +class ChangePhoneView extends GetView<ChangePhoneController> {
  10 + const ChangePhoneView({super.key});
  11 +
  12 + @override
  13 + Widget build(BuildContext context) {
  14 + return AnnotatedRegion<SystemUiOverlayStyle>(
  15 + value: const SystemUiOverlayStyle(
  16 + statusBarColor: AppColors.backgroundPage,
  17 + statusBarIconBrightness: Brightness.dark,
  18 + statusBarBrightness: Brightness.light,
  19 + systemNavigationBarColor: AppColors.backgroundPage,
  20 + systemNavigationBarIconBrightness: Brightness.dark,
  21 + ),
  22 + child: Scaffold(
  23 + resizeToAvoidBottomInset: false,
  24 + backgroundColor: AppColors.backgroundPage,
  25 + body: SafeArea(
  26 + child: Column(
  27 + crossAxisAlignment: CrossAxisAlignment.stretch,
  28 + children: [
  29 + SizedBox(
  30 + height: 44.dp,
  31 + child: Align(
  32 + alignment: Alignment.centerLeft,
  33 + child: IconButton(
  34 + onPressed: Get.back,
  35 + padding: EdgeInsets.only(left: 16.dp),
  36 + splashColor: Colors.transparent,
  37 + highlightColor: Colors.transparent,
  38 + icon: Image.asset(
  39 + R.assetsImagesNavBackIcon,
  40 + width: 28.dp,
  41 + height: 28.dp,
  42 + ),
  43 + ),
  44 + ),
  45 + ),
  46 + Expanded(
  47 + child: Obx(() {
  48 + final oldPhoneStep = controller.isOldPhoneStep;
  49 + return SingleChildScrollView(
  50 + padding: EdgeInsets.symmetric(horizontal: 28.dp),
  51 + child: Column(
  52 + children: [
  53 + SizedBox(height: 16.dp),
  54 + Text(
  55 + oldPhoneStep ? '原手机号验证' : '新手机号绑定',
  56 + style: TextStyle(
  57 + color: AppColors.textPrimary,
  58 + fontSize: 24.dp,
  59 + fontWeight: FontWeight.w600,
  60 + height: 1.35,
  61 + ),
  62 + ),
  63 + if (oldPhoneStep) ...[
  64 + SizedBox(height: 4.dp),
  65 + SizedBox(
  66 + height: 40.dp,
  67 + child: Text(
  68 + '请输入该账号绑定的原手机号${controller.maskedOldPhone},完成手机验证',
  69 + textAlign: TextAlign.center,
  70 + style: TextStyle(
  71 + color: AppColors.textSecondary,
  72 + fontSize: 14.dp,
  73 + fontWeight: FontWeight.w400,
  74 + height: 1.45,
  75 + ),
  76 + ),
  77 + ),
  78 + SizedBox(height: 55.dp),
  79 + ] else
  80 + SizedBox(height: 103.dp),
  81 + _PhoneField(controller: controller),
  82 + SizedBox(height: 8.dp),
  83 + _CodeField(controller: controller),
  84 + SizedBox(height: 68.dp),
  85 + _SubmitButton(controller: controller),
  86 + if (oldPhoneStep) ...[
  87 + SizedBox(height: 12.dp),
  88 + TextButton(
  89 + onPressed: controller.openHelp,
  90 + style: TextButton.styleFrom(
  91 + foregroundColor: AppColors.chartBlue,
  92 + minimumSize: Size.zero,
  93 + padding: EdgeInsets.symmetric(
  94 + horizontal: 8.dp,
  95 + vertical: 4.dp,
  96 + ),
  97 + tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  98 + ),
  99 + child: Text(
  100 + '现手机已不可用?',
  101 + style: TextStyle(
  102 + fontSize: 12.dp,
  103 + fontWeight: FontWeight.w500,
  104 + ),
  105 + ),
  106 + ),
  107 + ],
  108 + ],
  109 + ),
  110 + );
  111 + }),
  112 + ),
  113 + ],
  114 + ),
  115 + ),
  116 + ),
  117 + );
  118 + }
  119 +}
  120 +
  121 +class _PhoneField extends StatelessWidget {
  122 + const _PhoneField({required this.controller});
  123 +
  124 + final ChangePhoneController controller;
  125 +
  126 + @override
  127 + Widget build(BuildContext context) {
  128 + return SizedBox(
  129 + height: 52.dp,
  130 + child: Obx(() => TextField(
  131 + controller: controller.phoneController,
  132 + keyboardType: TextInputType.phone,
  133 + maxLength: 11,
  134 + inputFormatters: [FilteringTextInputFormatter.digitsOnly],
  135 + style: TextStyle(
  136 + color: AppColors.textPrimary,
  137 + fontSize: 16.dp,
  138 + fontWeight: FontWeight.w400,
  139 + ),
  140 + decoration: _inputDecoration(
  141 + hintText: '',
  142 + prefix: Padding(
  143 + padding: EdgeInsets.only(right: 12.dp),
  144 + child: Text(
  145 + '+86',
  146 + style: TextStyle(
  147 + color: AppColors.textTertiary,
  148 + fontSize: 16.dp,
  149 + fontWeight: FontWeight.w400,
  150 + ),
  151 + ),
  152 + ),
  153 + suffix: controller.phoneInput.value.isEmpty
  154 + ? null
  155 + : IconButton(
  156 + onPressed: controller.clearPhone,
  157 + icon: Icon(
  158 + Icons.cancel,
  159 + color: const Color(0xFFB0B0B6),
  160 + size: 24.dp,
  161 + ),
  162 + ),
  163 + ),
  164 + )),
  165 + );
  166 + }
  167 +}
  168 +
  169 +class _CodeField extends StatelessWidget {
  170 + const _CodeField({required this.controller});
  171 +
  172 + final ChangePhoneController controller;
  173 +
  174 + @override
  175 + Widget build(BuildContext context) {
  176 + return SizedBox(
  177 + height: 52.dp,
  178 + child: Obx(() {
  179 + final seconds = controller.countdownSeconds.value;
  180 + final requesting = controller.isRequestingCode.value;
  181 + final buttonText = requesting
  182 + ? '发送中'
  183 + : seconds > 0
  184 + ? '重新发送(${seconds}s)'
  185 + : '发送验证码';
  186 + return TextField(
  187 + controller: controller.codeController,
  188 + focusNode: controller.codeFocusNode,
  189 + keyboardType: TextInputType.number,
  190 + maxLength: 6,
  191 + inputFormatters: [FilteringTextInputFormatter.digitsOnly],
  192 + style: TextStyle(
  193 + color: AppColors.textPrimary,
  194 + fontSize: 16.dp,
  195 + fontWeight: FontWeight.w400,
  196 + ),
  197 + decoration: _inputDecoration(
  198 + hintText: '输入验证码',
  199 + suffix: controller.phoneInput.value.isEmpty
  200 + ? null
  201 + : TextButton(
  202 + onPressed: controller.canRequestCode
  203 + ? controller.requestVerificationCode
  204 + : null,
  205 + style: TextButton.styleFrom(
  206 + minimumSize: Size.zero,
  207 + padding: EdgeInsets.symmetric(horizontal: 12.dp),
  208 + tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  209 + foregroundColor: AppColors.primary,
  210 + disabledForegroundColor: AppColors.textTertiary,
  211 + ),
  212 + child: Text(
  213 + buttonText,
  214 + style: TextStyle(
  215 + fontSize: 14.dp,
  216 + fontWeight: FontWeight.w500,
  217 + ),
  218 + ),
  219 + ),
  220 + ),
  221 + );
  222 + }),
  223 + );
  224 + }
  225 +}
  226 +
  227 +class _SubmitButton extends StatelessWidget {
  228 + const _SubmitButton({required this.controller});
  229 +
  230 + final ChangePhoneController controller;
  231 +
  232 + @override
  233 + Widget build(BuildContext context) {
  234 + return Obx(() => SizedBox(
  235 + width: double.infinity,
  236 + height: 48.dp,
  237 + child: ElevatedButton(
  238 + onPressed: controller.canSubmit ? controller.submit : null,
  239 + style: ElevatedButton.styleFrom(
  240 + elevation: 0,
  241 + backgroundColor: AppColors.primary,
  242 + disabledBackgroundColor: AppColors.primary.withValues(alpha: 0.4),
  243 + foregroundColor: Colors.white,
  244 + disabledForegroundColor: Colors.white,
  245 + shape: RoundedRectangleBorder(
  246 + borderRadius: BorderRadius.circular(24.dp),
  247 + ),
  248 + textStyle:
  249 + TextStyle(fontSize: 16.dp, fontWeight: FontWeight.w600),
  250 + ),
  251 + child: const Text('确定'),
  252 + ),
  253 + ));
  254 + }
  255 +}
  256 +
  257 +InputDecoration _inputDecoration({
  258 + required String hintText,
  259 + Widget? prefix,
  260 + Widget? suffix,
  261 +}) {
  262 + final border = OutlineInputBorder(
  263 + borderRadius: BorderRadius.circular(27),
  264 + borderSide: BorderSide.none,
  265 + );
  266 + return InputDecoration(
  267 + hintText: hintText,
  268 + hintStyle: const TextStyle(color: AppColors.textTertiary, fontSize: 16),
  269 + counterText: '',
  270 + filled: true,
  271 + fillColor: Colors.white,
  272 + contentPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 0),
  273 + prefixIcon: prefix == null ? null : Center(child: prefix),
  274 + prefixIconConstraints: const BoxConstraints(minWidth: 0),
  275 + suffixIcon: suffix,
  276 + suffixIconConstraints: const BoxConstraints(minWidth: 0),
  277 + border: border,
  278 + enabledBorder: border,
  279 + focusedBorder: border,
  280 + );
  281 +}
1 -import 'package:cached_network_image/cached_network_image.dart';  
2 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; 1 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
@@ -65,6 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> { @@ -65,6 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
65 selectedTypeIndex: controller.selectedTypeIndex.value, 64 selectedTypeIndex: controller.selectedTypeIndex.value,
66 onTypeChanged: controller.changeType, 65 onTypeChanged: controller.changeType,
67 onQueryChanged: controller.changeQuery, 66 onQueryChanged: controller.changeQuery,
  67 + friendAvatarUrl: controller.friendItem?.avatar,
68 ), 68 ),
69 ), 69 ),
70 ), 70 ),
@@ -87,10 +87,7 @@ class FriendTrendView extends GetView<FriendTrendController> { @@ -87,10 +87,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
87 } 87 }
88 88
89 class _FriendTrendTitle extends StatelessWidget { 89 class _FriendTrendTitle extends StatelessWidget {
90 - const _FriendTrendTitle({  
91 - required this.title,  
92 - required this.avatarUrl,  
93 - }); 90 + const _FriendTrendTitle({required this.title, required this.avatarUrl});
94 91
95 final String title; 92 final String title;
96 final String? avatarUrl; 93 final String? avatarUrl;
@@ -29,6 +29,7 @@ class HealthTrendContent extends StatefulWidget { @@ -29,6 +29,7 @@ class HealthTrendContent extends StatefulWidget {
29 required this.onTypeChanged, 29 required this.onTypeChanged,
30 required this.onQueryChanged, 30 required this.onQueryChanged,
31 this.queryForType, 31 this.queryForType,
  32 + this.friendAvatarUrl,
32 }); 33 });
33 34
34 final HealthReportQuery query; 35 final HealthReportQuery query;
@@ -41,6 +42,7 @@ class HealthTrendContent extends StatefulWidget { @@ -41,6 +42,7 @@ class HealthTrendContent extends StatefulWidget {
41 final ValueChanged<int> onTypeChanged; 42 final ValueChanged<int> onTypeChanged;
42 final void Function(ReportPeriod period, DateTime date) onQueryChanged; 43 final void Function(ReportPeriod period, DateTime date) onQueryChanged;
43 final HealthReportQuery Function(int typeIndex)? queryForType; 44 final HealthReportQuery Function(int typeIndex)? queryForType;
  45 + final String? friendAvatarUrl;
44 46
45 @override 47 @override
46 State<HealthTrendContent> createState() => _HealthTrendContentState(); 48 State<HealthTrendContent> createState() => _HealthTrendContentState();
@@ -110,12 +112,14 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -110,12 +112,14 @@ class _HealthTrendContentState extends State<HealthTrendContent>
110 final vipInfo = preferences.vipInfo; 112 final vipInfo = preferences.vipInfo;
111 final isVip = vipInfo?.isVip ?? false; 113 final isVip = vipInfo?.isVip ?? false;
112 void openPurchase(String channelType) => Get.toNamed( 114 void openPurchase(String channelType) => Get.toNamed(
113 - Routes.PURCHASE,  
114 - arguments: {IntentKeys.channelType: channelType},  
115 - ); 115 + Routes.PURCHASE,
  116 + arguments: {IntentKeys.channelType: channelType},
  117 + );
116 118
117 return HealthReportSubjectScope( 119 return HealthReportSubjectScope(
118 isSelf: widget.query.isSelf, 120 isSelf: widget.query.isSelf,
  121 + friendUserId: widget.query.targetUserId,
  122 + friendAvatarUrl: widget.friendAvatarUrl,
119 child: Column( 123 child: Column(
120 children: [ 124 children: [
121 TrendTypeTabBar(tabController: _tabController), 125 TrendTypeTabBar(tabController: _tabController),
@@ -322,11 +326,11 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -322,11 +326,11 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
322 326
323 @override 327 @override
324 Widget build(BuildContext context) => HrvReportView( 328 Widget build(BuildContext context) => HrvReportView(
325 - logic: _logic,  
326 - isVip: widget.isVip,  
327 - onSubscribe: widget.onSubscribe,  
328 - loadingIndicator: const _HealthTrendLoadingIndicator(),  
329 - ); 329 + logic: _logic,
  330 + isVip: widget.isVip,
  331 + onSubscribe: widget.onSubscribe,
  332 + loadingIndicator: const _HealthTrendLoadingIndicator(),
  333 + );
330 } 334 }
331 335
332 class _ActivityBurnTrendSection extends StatefulWidget { 336 class _ActivityBurnTrendSection extends StatefulWidget {
@@ -422,12 +426,12 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -422,12 +426,12 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
422 426
423 @override 427 @override
424 Widget build(BuildContext context) => ActivityBurnReportView( 428 Widget build(BuildContext context) => ActivityBurnReportView(
425 - logic: _logic,  
426 - isVip: widget.isVip,  
427 - onSubscribe: widget.onSubscribe,  
428 - loadingIndicator: const _HealthTrendLoadingIndicator(),  
429 - tooltipDismissDelayMs: 4000,  
430 - ); 429 + logic: _logic,
  430 + isVip: widget.isVip,
  431 + onSubscribe: widget.onSubscribe,
  432 + loadingIndicator: const _HealthTrendLoadingIndicator(),
  433 + tooltipDismissDelayMs: 4000,
  434 + );
431 } 435 }
432 436
433 class _SleepTrendSection extends StatefulWidget { 437 class _SleepTrendSection extends StatefulWidget {
@@ -462,9 +466,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -462,9 +466,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
462 @override 466 @override
463 void initState() { 467 void initState() {
464 super.initState(); 468 super.initState();
465 - _logic = SleepReportLogic(  
466 - initialTargetUserId: widget.query.targetUserId,  
467 - ); 469 + _logic = SleepReportLogic(initialTargetUserId: widget.query.targetUserId);
468 _appReviewPromptLogic = AppReviewPromptLogic( 470 _appReviewPromptLogic = AppReviewPromptLogic(
469 storage: Get.find<LocalStorage>(), 471 storage: Get.find<LocalStorage>(),
470 userIdProvider: () => Get.find<UserStateService>().userId, 472 userIdProvider: () => Get.find<UserStateService>().userId,
@@ -527,14 +529,14 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -527,14 +529,14 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
527 529
528 @override 530 @override
529 Widget build(BuildContext context) => SleepReportView( 531 Widget build(BuildContext context) => SleepReportView(
530 - logic: _logic,  
531 - isVip: widget.isVip,  
532 - onSubscribe: widget.onSubscribe,  
533 - onReportViewed: widget.isSelected  
534 - ? _appReviewPromptLogic.recordPromptEligibility  
535 - : null,  
536 - loadingIndicator: const _HealthTrendLoadingIndicator(),  
537 - ); 532 + logic: _logic,
  533 + isVip: widget.isVip,
  534 + onSubscribe: widget.onSubscribe,
  535 + onReportViewed: widget.isSelected
  536 + ? _appReviewPromptLogic.recordPromptEligibility
  537 + : null,
  538 + loadingIndicator: const _HealthTrendLoadingIndicator(),
  539 + );
538 } 540 }
539 541
540 class _KeepAliveWrapper extends StatefulWidget { 542 class _KeepAliveWrapper extends StatefulWidget {
1 import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart'; 1 import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
2 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 2 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
  3 +import 'package:doublefeel_flutter/core/network/api/health_api.dart';
3 import 'package:doublefeel_flutter/core/network/api/theme_api.dart'; 4 import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
4 import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 5 import 'package:doublefeel_flutter/core/network/api/user_api.dart';
5 import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; 6 import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
  7 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
6 import 'package:get/get.dart'; 8 import 'package:get/get.dart';
7 9
8 import '../../friends/controllers/friends_controller.dart'; 10 import '../../friends/controllers/friends_controller.dart';
  11 +import '../../pk/controllers/pk_controller.dart';
9 import '../controllers/home_controller.dart'; 12 import '../controllers/home_controller.dart';
10 import '../controllers/app_home_controller.dart'; 13 import '../controllers/app_home_controller.dart';
11 import '../controllers/trend/trend_controller.dart'; 14 import '../controllers/trend/trend_controller.dart';
@@ -13,6 +16,14 @@ import '../controllers/trend/trend_controller.dart'; @@ -13,6 +16,14 @@ import '../controllers/trend/trend_controller.dart';
13 class HomeBinding extends Bindings { 16 class HomeBinding extends Bindings {
14 @override 17 @override
15 void dependencies() { 18 void dependencies() {
  19 + Get.put<FriendsController>(FriendsController());
  20 + Get.put<PkController>(
  21 + PkController(
  22 + Get.find<FriendApi>(),
  23 + Get.find<UserPreferencesStorage>(),
  24 + Get.find<HealthApi>(),
  25 + ),
  26 + );
16 Get.put<HomeController>(HomeController()); 27 Get.put<HomeController>(HomeController());
17 Get.put<MyController>( 28 Get.put<MyController>(
18 MyController( 29 MyController(
@@ -22,7 +33,6 @@ class HomeBinding extends Bindings { @@ -22,7 +33,6 @@ class HomeBinding extends Bindings {
22 ), 33 ),
23 ); 34 );
24 Get.put<TrendController>(TrendController(Get.find<FriendApi>())); 35 Get.put<TrendController>(TrendController(Get.find<FriendApi>()));
25 - Get.put<FriendsController>(FriendsController());  
26 Get.put<AppHomeController>(AppHomeController()); 36 Get.put<AppHomeController>(AppHomeController());
27 } 37 }
28 } 38 }
  1 +import '../../pk/controllers/pk_controller.dart';
1 import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; 2 import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart';
2 import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; 3 import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
3 import 'package:doublefeel_flutter/core/constants/flutter_bridge_method_name.dart'; 4 import 'package:doublefeel_flutter/core/constants/flutter_bridge_method_name.dart';
@@ -23,23 +24,47 @@ import 'trend/trend_controller.dart'; @@ -23,23 +24,47 @@ import 'trend/trend_controller.dart';
23 24
24 enum TrendEntrySource { bottomTab, jump } 25 enum TrendEntrySource { bottomTab, jump }
25 26
  27 +enum HomeTab { today, trend, friends, pk, my }
  28 +
26 class HomeController extends GetxController { 29 class HomeController extends GetxController {
27 - static const trendTabIndex = 1;  
28 - static const friendsTabIndex = 2;  
29 static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel'); 30 static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel');
30 31
31 final LocalStorage _storage = Get.find<LocalStorage>(); 32 final LocalStorage _storage = Get.find<LocalStorage>();
32 final UserStateService userStateService = Get.find<UserStateService>(); 33 final UserStateService userStateService = Get.find<UserStateService>();
33 final AppPlatformHostApi _platformHostApi = AppPlatformHostApi(); 34 final AppPlatformHostApi _platformHostApi = AppPlatformHostApi();
34 35
35 - /// 当前选中的底部 tab 索引  
36 - final selectedIndex = 0.obs; 36 + /// 当前选中的底部 Tab;展示位置由 [visibleTabs] 动态计算。
  37 + final selectedTab = HomeTab.today.obs;
37 final lastFlutterUrl = ''.obs; 38 final lastFlutterUrl = ''.obs;
38 var isFromOnboard = false; 39 var isFromOnboard = false;
  40 + late final Worker _friendsWorker;
  41 +
  42 + bool get hasFriends => Get.find<FriendsController>().friends.isNotEmpty;
  43 +
  44 + List<HomeTab> get visibleTabs => [
  45 + HomeTab.today,
  46 + HomeTab.trend,
  47 + HomeTab.friends,
  48 + if (hasFriends) HomeTab.pk,
  49 + HomeTab.my,
  50 + ];
  51 +
  52 + int get currentSelectedIndex => visibleTabs.indexOf(selectedTab.value);
39 53
40 @override 54 @override
41 void onInit() { 55 void onInit() {
42 super.onInit(); 56 super.onInit();
  57 + _friendsWorker = ever(
  58 + Get.find<FriendsController>().friends,
  59 + (_) {
  60 + // If the last friend is deleted while PK is open, PK is removed from
  61 + // the tab bar. Return to the Friends tab instead of changing the
  62 + // meaning of the current index to My.
  63 + if (!hasFriends && selectedTab.value == HomeTab.pk) {
  64 + selectedTab.value = HomeTab.friends;
  65 + }
  66 + },
  67 + );
43 _mainChannel.setMethodCallHandler(_handleNativeMethodCall); 68 _mainChannel.setMethodCallHandler(_handleNativeMethodCall);
44 isFromOnboard = Get.arguments?['isFromOnboard'] ?? false; 69 isFromOnboard = Get.arguments?['isFromOnboard'] ?? false;
45 SchedulerBinding.instance.addPostFrameCallback((_) async { 70 SchedulerBinding.instance.addPostFrameCallback((_) async {
@@ -61,6 +86,7 @@ class HomeController extends GetxController { @@ -61,6 +86,7 @@ class HomeController extends GetxController {
61 86
62 @override 87 @override
63 void onClose() { 88 void onClose() {
  89 + _friendsWorker.dispose();
64 _mainChannel.setMethodCallHandler(null); 90 _mainChannel.setMethodCallHandler(null);
65 super.onClose(); 91 super.onClose();
66 } 92 }
@@ -141,33 +167,41 @@ class HomeController extends GetxController { @@ -141,33 +167,41 @@ class HomeController extends GetxController {
141 int index, { 167 int index, {
142 TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab, 168 TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab,
143 }) { 169 }) {
144 - if (selectedIndex.value == index) return;  
145 - selectedIndex.value = index; 170 + final tabs = visibleTabs;
  171 + if (index < 0 || index >= tabs.length) return;
  172 + selectTab(tabs[index], trendEntrySource: trendEntrySource);
  173 + }
  174 +
  175 + void selectTab(
  176 + HomeTab tab, {
  177 + TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab,
  178 + }) {
  179 + if (!visibleTabs.contains(tab) || selectedTab.value == tab) return;
  180 + selectedTab.value = tab;
146 181
147 WidgetsBinding.instance.addPostFrameCallback((_) { 182 WidgetsBinding.instance.addPostFrameCallback((_) {
148 - if (selectedIndex.value != index) return; 183 + if (selectedTab.value != tab) return;
149 184
150 final trendController = Get.find<TrendController>(); 185 final trendController = Get.find<TrendController>();
151 final friendsController = Get.find<FriendsController>(); 186 final friendsController = Get.find<FriendsController>();
152 187
153 - if (index == trendTabIndex && 188 + if (tab == HomeTab.trend &&
154 trendEntrySource == TrendEntrySource.bottomTab) { 189 trendEntrySource == TrendEntrySource.bottomTab) {
155 trendController.selectSelf(refresh: false); 190 trendController.selectSelf(refresh: false);
156 } 191 }
157 192
158 trendController.setPageVisible( 193 trendController.setPageVisible(
159 - index == trendTabIndex, 194 + tab == HomeTab.trend,
160 resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab, 195 resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab,
161 ); 196 );
162 - friendsController.setPageVisible(index == friendsTabIndex);  
163 -  
164 - switch (index) {  
165 - case 0:  
166 - Get.find<AppHomeController>().refreshOnShow();  
167 - break;  
168 - case 3:  
169 - ta.track('enter_doublefeel_my_page');  
170 - break; 197 + friendsController.setPageVisible(tab == HomeTab.friends);
  198 +
  199 + if (tab == HomeTab.today) {
  200 + Get.find<AppHomeController>().refreshOnShow();
  201 + } else if (tab == HomeTab.pk) {
  202 + Get.find<PkController>().refreshData();
  203 + } else if (tab == HomeTab.my) {
  204 + ta.track('enter_doublefeel_my_page');
171 } 205 }
172 }); 206 });
173 } 207 }
@@ -182,7 +216,7 @@ class HomeController extends GetxController { @@ -182,7 +216,7 @@ class HomeController extends GetxController {
182 if (fromTodayTab) { 216 if (fromTodayTab) {
183 Get.find<TrendController>().selectSelf(); 217 Get.find<TrendController>().selectSelf();
184 } 218 }
185 - changeTab(trendTabIndex, trendEntrySource: TrendEntrySource.jump); 219 + selectTab(HomeTab.trend, trendEntrySource: TrendEntrySource.jump);
186 } 220 }
187 221
188 int _dateKey(DateTime date) => 222 int _dateKey(DateTime date) =>
@@ -256,7 +290,7 @@ class HomeController extends GetxController { @@ -256,7 +290,7 @@ class HomeController extends GetxController {
256 void _switchHomeTab(String? tab, Map<String, String> params) { 290 void _switchHomeTab(String? tab, Map<String, String> params) {
257 switch (tab) { 291 switch (tab) {
258 case 'today': 292 case 'today':
259 - changeTab(0); 293 + selectTab(HomeTab.today);
260 final scrollToLatestHrv = 294 final scrollToLatestHrv =
261 params['scroll_to_latest_hrv']?.toLowerCase() == 'true'; 295 params['scroll_to_latest_hrv']?.toLowerCase() == 'true';
262 if (scrollToLatestHrv) { 296 if (scrollToLatestHrv) {
@@ -271,14 +305,17 @@ class HomeController extends GetxController { @@ -271,14 +305,17 @@ class HomeController extends GetxController {
271 if (trendType != null) { 305 if (trendType != null) {
272 openTrend(trendType, fromTodayTab: false); 306 openTrend(trendType, fromTodayTab: false);
273 } else { 307 } else {
274 - changeTab(trendTabIndex, trendEntrySource: TrendEntrySource.jump); 308 + selectTab(HomeTab.trend, trendEntrySource: TrendEntrySource.jump);
275 } 309 }
276 break; 310 break;
277 case 'friends': 311 case 'friends':
278 - changeTab(friendsTabIndex); 312 + selectTab(HomeTab.friends);
  313 + break;
  314 + case 'pk':
  315 + selectTab(HomeTab.pk);
279 break; 316 break;
280 case 'my': 317 case 'my':
281 - changeTab(3); 318 + selectTab(HomeTab.my);
282 break; 319 break;
283 default: 320 default:
284 break; 321 break;
@@ -9,18 +9,27 @@ import '../widgets/df_tab_bar.dart'; @@ -9,18 +9,27 @@ import '../widgets/df_tab_bar.dart';
9 import '../../friends/views/friends_tab.dart'; 9 import '../../friends/views/friends_tab.dart';
10 import 'tabs/home_trend_view.dart'; 10 import 'tabs/home_trend_view.dart';
11 import 'tabs/app_home_tab.dart'; 11 import 'tabs/app_home_tab.dart';
  12 +import 'tabs/home_pk_view.dart';
12 import 'tabs/my_tab.dart'; 13 import 'tabs/my_tab.dart';
13 14
14 class HomePage extends GetView<HomeController> { 15 class HomePage extends GetView<HomeController> {
15 const HomePage({super.key}); 16 const HomePage({super.key});
16 17
17 - static const _tabs = [ 18 + static const _tabsWithoutPk = [
18 AppHomeTab(), 19 AppHomeTab(),
19 HomeTrendView(), 20 HomeTrendView(),
20 FriendsTab(), 21 FriendsTab(),
21 MyTab(), 22 MyTab(),
22 ]; 23 ];
23 24
  25 + static const _tabsWithPk = [
  26 + AppHomeTab(),
  27 + HomeTrendView(),
  28 + FriendsTab(),
  29 + HomePkView(),
  30 + MyTab(),
  31 + ];
  32 +
24 @override 33 @override
25 Widget build(BuildContext context) { 34 Widget build(BuildContext context) {
26 return _HomeReviewPromptGate( 35 return _HomeReviewPromptGate(
@@ -30,18 +39,17 @@ class HomePage extends GetView<HomeController> { @@ -30,18 +39,17 @@ class HomePage extends GetView<HomeController> {
30 extendBody: true, // 让内容延伸到 bottomNavigationBar 下方 39 extendBody: true, // 让内容延伸到 bottomNavigationBar 下方
31 extendBodyBehindAppBar: true, 40 extendBodyBehindAppBar: true,
32 41
33 - body: Obx(  
34 - () => IndexedStack(  
35 - index: controller.selectedIndex.value,  
36 - children: _tabs,  
37 - ),  
38 - ),  
39 - bottomNavigationBar: Obx(  
40 - () => DfTabBar(  
41 - selectedIndex: controller.selectedIndex.value,  
42 - onTap: (index) => controller.changeTab(index),  
43 - ),  
44 - ), 42 + body: Obx(() {
  43 + final hasFriends = controller.hasFriends;
  44 + final tabs = hasFriends ? _tabsWithPk : _tabsWithoutPk;
  45 + final selectedIndex = controller.currentSelectedIndex;
  46 + return IndexedStack(index: selectedIndex, children: tabs);
  47 + }),
  48 + bottomNavigationBar: Obx(() => DfTabBar(
  49 + selectedIndex: controller.currentSelectedIndex,
  50 + showPk: controller.hasFriends,
  51 + onTap: controller.changeTab,
  52 + )),
45 ), 53 ),
46 ); 54 );
47 } 55 }
  1 +import '../../controllers/home_controller.dart';
  2 +import 'package:flutter/material.dart';
  3 +import 'package:get/get.dart';
  4 +import '../../../pk/controllers/pk_controller.dart';
  5 +import '../../../pk/views/pk_view.dart';
  6 +
  7 +class HomePkView extends GetView<PkController> {
  8 + const HomePkView({super.key});
  9 + @override
  10 + Widget build(BuildContext context) => Obx(() {
  11 + if (controller.selectedFriend.value == null) {
  12 + return ColoredBox(
  13 + color: const Color(0xFFF5F2FF),
  14 + child: Center(
  15 + child: controller.isLoading.value
  16 + ? const CircularProgressIndicator()
  17 + : Column(
  18 + mainAxisSize: MainAxisSize.min,
  19 + children: [
  20 + Text(
  21 + controller.loadFailed.value
  22 + ? '加载失败,请重试'
  23 + : '添加好友后即可查看健康PK',
  24 + ),
  25 + const SizedBox(height: 12),
  26 + TextButton(
  27 + onPressed: controller.refreshData,
  28 + child: const Text('刷新'),
  29 + ),
  30 + ],
  31 + ),
  32 + ),
  33 + );
  34 + }
  35 + return PkView(
  36 + data: controller.data,
  37 + friendUserId: controller.selectedFriend.value?.friendUserId,
  38 + loadFailed: controller.loadFailed.value ||
  39 + (controller.isPartnerSelected && controller.pkLoadFailed.value),
  40 + onBack: () => Get.find<HomeController>().selectTab(HomeTab.today),
  41 + onRefresh: controller.refreshData,
  42 + onSwitchFriend: controller.showFriendListBottomSheet,
  43 + );
  44 + });
  45 +}
1 -import 'package:cached_network_image/cached_network_image.dart';  
2 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 1 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
@@ -15,10 +14,7 @@ class HomeTrendView extends GetView<TrendController> { @@ -15,10 +14,7 @@ class HomeTrendView extends GetView<TrendController> {
15 return const _TrendPageBackground( 14 return const _TrendPageBackground(
16 child: Column( 15 child: Column(
17 children: [ 16 children: [
18 - SafeArea(  
19 - bottom: false,  
20 - child: _HomeTrendHeader(),  
21 - ), 17 + SafeArea(bottom: false, child: _HomeTrendHeader()),
22 Expanded(child: _HomeTrendBody()), 18 Expanded(child: _HomeTrendBody()),
23 ], 19 ],
24 ), 20 ),
@@ -39,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> { @@ -39,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> {
39 refreshToken: controller.refreshToken.value, 35 refreshToken: controller.refreshToken.value,
40 onTypeChanged: controller.changeType, 36 onTypeChanged: controller.changeType,
41 onQueryChanged: controller.changeQuery, 37 onQueryChanged: controller.changeQuery,
  38 + friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
42 ), 39 ),
43 ); 40 );
44 } 41 }
@@ -121,10 +118,7 @@ class _TrendHeaderAvatar extends StatelessWidget { @@ -121,10 +118,7 @@ class _TrendHeaderAvatar extends StatelessWidget {
121 height: 36, 118 height: 36,
122 decoration: ShapeDecoration( 119 decoration: ShapeDecoration(
123 image: imageUrl?.isNotEmpty == true 120 image: imageUrl?.isNotEmpty == true
124 - ? DecorationImage(  
125 - image: NetworkImage(imageUrl!),  
126 - fit: BoxFit.cover,  
127 - ) 121 + ? DecorationImage(image: NetworkImage(imageUrl!), fit: BoxFit.cover)
128 : null, 122 : null,
129 shape: RoundedRectangleBorder( 123 shape: RoundedRectangleBorder(
130 side: const BorderSide(width: 0.78, color: Colors.white), 124 side: const BorderSide(width: 0.78, color: Colors.white),
@@ -139,11 +133,7 @@ class _TrendHeaderAvatar extends StatelessWidget { @@ -139,11 +133,7 @@ class _TrendHeaderAvatar extends StatelessWidget {
139 return GestureDetector( 133 return GestureDetector(
140 behavior: HitTestBehavior.opaque, 134 behavior: HitTestBehavior.opaque,
141 onTap: onTap, 135 onTap: onTap,
142 - child: SizedBox(  
143 - width: 44,  
144 - height: 44,  
145 - child: Center(child: avatar),  
146 - ), 136 + child: SizedBox(width: 44, height: 44, child: Center(child: avatar)),
147 ); 137 );
148 } 138 }
149 } 139 }
@@ -153,11 +143,7 @@ class _AvatarFallback extends StatelessWidget { @@ -153,11 +143,7 @@ class _AvatarFallback extends StatelessWidget {
153 143
154 @override 144 @override
155 Widget build(BuildContext context) { 145 Widget build(BuildContext context) {
156 - return const Icon(  
157 - Icons.person_rounded,  
158 - color: Colors.white,  
159 - size: 22,  
160 - ); 146 + return const Icon(Icons.person_rounded, color: Colors.white, size: 22);
161 } 147 }
162 } 148 }
163 149
@@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; @@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
4 4
5 class DfTabBar extends StatelessWidget { 5 class DfTabBar extends StatelessWidget {
6 final int selectedIndex; 6 final int selectedIndex;
  7 + final bool showPk;
7 final ValueChanged<int> onTap; 8 final ValueChanged<int> onTap;
8 9
9 const DfTabBar({ 10 const DfTabBar({
10 super.key, 11 super.key,
11 required this.selectedIndex, 12 required this.selectedIndex,
  13 + required this.showPk,
12 required this.onTap, 14 required this.onTap,
13 }); 15 });
14 16
@@ -19,6 +21,7 @@ class DfTabBar extends StatelessWidget { @@ -19,6 +21,7 @@ class DfTabBar extends StatelessWidget {
19 'assets/images/tabbar/icon_today.png', 21 'assets/images/tabbar/icon_today.png',
20 'assets/images/tabbar/icon_trend.png', 22 'assets/images/tabbar/icon_trend.png',
21 'assets/images/tabbar/icon_friends.png', 23 'assets/images/tabbar/icon_friends.png',
  24 + 'assets/images/tabbar/icon_pk.png',
22 'assets/images/tabbar/icon_my.png', 25 'assets/images/tabbar/icon_my.png',
23 ]; 26 ];
24 27
@@ -26,6 +29,7 @@ class DfTabBar extends StatelessWidget { @@ -26,6 +29,7 @@ class DfTabBar extends StatelessWidget {
26 'assets/images/tabbar/icon_today_selected.png', 29 'assets/images/tabbar/icon_today_selected.png',
27 'assets/images/tabbar/icon_trend_selected.png', 30 'assets/images/tabbar/icon_trend_selected.png',
28 'assets/images/tabbar/icon_friends_selected.png', 31 'assets/images/tabbar/icon_friends_selected.png',
  32 + 'assets/images/tabbar/icon_pk_selected.png',
29 'assets/images/tabbar/icon_my_selected.png', 33 'assets/images/tabbar/icon_my_selected.png',
30 ]; 34 ];
31 35
@@ -36,8 +40,10 @@ class DfTabBar extends StatelessWidget { @@ -36,8 +40,10 @@ class DfTabBar extends StatelessWidget {
36 l10n.tabToday, 40 l10n.tabToday,
37 l10n.tabTrend, 41 l10n.tabTrend,
38 l10n.tabFriends, 42 l10n.tabFriends,
  43 + 'PK',
39 l10n.tabMy, 44 l10n.tabMy,
40 ]; 45 ];
  46 + final tabIndexes = showPk ? List.generate(5, (index) => index) : [0, 1, 2, 4];
41 47
42 return SafeArea( 48 return SafeArea(
43 top: false, 49 top: false,
@@ -60,7 +66,7 @@ class DfTabBar extends StatelessWidget { @@ -60,7 +66,7 @@ class DfTabBar extends StatelessWidget {
60 padding: const EdgeInsets.all(4), 66 padding: const EdgeInsets.all(4),
61 child: LayoutBuilder( 67 child: LayoutBuilder(
62 builder: (context, constraints) { 68 builder: (context, constraints) {
63 - final tabWidth = constraints.maxWidth / _tabIcons.length; 69 + final tabWidth = constraints.maxWidth / tabIndexes.length;
64 return Stack( 70 return Stack(
65 clipBehavior: Clip.none, 71 clipBehavior: Clip.none,
66 children: [ 72 children: [
@@ -80,27 +86,33 @@ class DfTabBar extends StatelessWidget { @@ -80,27 +86,33 @@ class DfTabBar extends StatelessWidget {
80 ), 86 ),
81 ), 87 ),
82 Row( 88 Row(
83 - children: List.generate(_tabIcons.length, (i) {  
84 - final isSelected = selectedIndex == i; 89 + children: List.generate(tabIndexes.length, (index) {
  90 + final tabIndex = tabIndexes[index];
  91 + final isSelected = selectedIndex == index;
85 return Expanded( 92 return Expanded(
86 child: RepaintBoundary( 93 child: RepaintBoundary(
87 child: GestureDetector( 94 child: GestureDetector(
88 - onTap: () => onTap(i), 95 + onTap: () => onTap(index),
89 behavior: HitTestBehavior.opaque, 96 behavior: HitTestBehavior.opaque,
90 child: Column( 97 child: Column(
91 mainAxisAlignment: MainAxisAlignment.center, 98 mainAxisAlignment: MainAxisAlignment.center,
92 children: [ 99 children: [
93 Image.asset( 100 Image.asset(
94 isSelected 101 isSelected
95 - ? _selectedTabIcons[i]  
96 - : _tabIcons[i], 102 + ? _selectedTabIcons[tabIndex]
  103 + : _tabIcons[tabIndex],
  104 + color: tabIndex == 3
  105 + ? (isSelected
  106 + ? context.colors.primary
  107 + : const Color(0xFF0F0F11))
  108 + : null,
97 width: 24, 109 width: 24,
98 height: 24, 110 height: 24,
99 gaplessPlayback: true, 111 gaplessPlayback: true,
100 ), 112 ),
101 const SizedBox(height: 0.5), 113 const SizedBox(height: 0.5),
102 Text( 114 Text(
103 - tabLabels[i], 115 + tabLabels[tabIndex],
104 style: TextStyle( 116 style: TextStyle(
105 fontSize: 9, 117 fontSize: 9,
106 fontWeight: FontWeight.w800, 118 fontWeight: FontWeight.w800,
@@ -13,8 +13,10 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { @@ -13,8 +13,10 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
13 required this.selectedFriend, 13 required this.selectedFriend,
14 required this.onSelectSelf, 14 required this.onSelectSelf,
15 required this.onSelectFriend, 15 required this.onSelectFriend,
  16 + this.showSelf = true,
16 }); 17 });
17 18
  19 + final bool showSelf;
18 final ScrollController scrollController; 20 final ScrollController scrollController;
19 final RxList<FriendItem> friendsList; 21 final RxList<FriendItem> friendsList;
20 final Rxn<FriendItem> selectedFriend; 22 final Rxn<FriendItem> selectedFriend;
@@ -77,7 +79,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { @@ -77,7 +79,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
77 .meUserInfo; 79 .meUserInfo;
78 final selfNickname = self?.nickname?.trim(); 80 final selfNickname = self?.nickname?.trim();
79 final currentSelectedId = selectedFriend.value?.friendUserId; 81 final currentSelectedId = selectedFriend.value?.friendUserId;
80 - final itemCount = friendsList.length + 1; 82 + final itemCount = friendsList.length + (showSelf ? 1 : 0);
81 83
82 return ListView.separated( 84 return ListView.separated(
83 controller: scrollController, 85 controller: scrollController,
@@ -85,7 +87,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { @@ -85,7 +87,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
85 itemCount: itemCount, 87 itemCount: itemCount,
86 separatorBuilder: (_, __) => const SizedBox(height: 8), 88 separatorBuilder: (_, __) => const SizedBox(height: 8),
87 itemBuilder: (context, index) { 89 itemBuilder: (context, index) {
88 - if (index == 0) { 90 + if (showSelf && index == 0) {
89 return _BottomSheetUserRow( 91 return _BottomSheetUserRow(
90 name: selfNickname?.isNotEmpty == true 92 name: selfNickname?.isNotEmpty == true
91 ? selfNickname! 93 ? selfNickname!
@@ -102,7 +104,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget { @@ -102,7 +104,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
102 ); 104 );
103 } 105 }
104 106
105 - final friend = friendsList[index - 1]; 107 + final friend = friendsList[index - (showSelf ? 1 : 0)];
106 final name = _friendName(context, friend); 108 final name = _friendName(context, friend);
107 final nickname = friend.friendNickname?.trim(); 109 final nickname = friend.friendNickname?.trim();
108 return _BottomSheetUserRow( 110 return _BottomSheetUserRow(
  1 +import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:doublefeel_flutter/r.dart';
  4 +import 'package:flutter/material.dart';
  5 +
  6 +import '../models/hrv_report_models.dart';
  7 +import 'hrv_distribution_bar.dart';
  8 +
  9 +class HrvStressDistributionCard extends StatelessWidget {
  10 + const HrvStressDistributionCard({
  11 + super.key,
  12 + required this.report,
  13 + required this.title,
  14 + this.showExample = false,
  15 + this.trailing,
  16 + this.footer,
  17 + this.onTap,
  18 + });
  19 +
  20 + final HrvPeriodReport report;
  21 + final String title;
  22 + final bool showExample;
  23 + final Widget? trailing;
  24 + final Widget? footer;
  25 + final VoidCallback? onTap;
  26 +
  27 + @override
  28 + Widget build(BuildContext context) {
  29 + final content = Container(
  30 + width: double.infinity,
  31 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
  32 + decoration: BoxDecoration(
  33 + color: Colors.white,
  34 + borderRadius: BorderRadius.circular(16),
  35 + ),
  36 + child: Column(
  37 + crossAxisAlignment: CrossAxisAlignment.start,
  38 + children: [
  39 + _Title(
  40 + title: title,
  41 + showExample: showExample,
  42 + trailing: trailing,
  43 + ),
  44 + const SizedBox(height: 20),
  45 + Row(
  46 + crossAxisAlignment: CrossAxisAlignment.end,
  47 + children: [
  48 + Expanded(
  49 + child: Column(
  50 + crossAxisAlignment: CrossAxisAlignment.start,
  51 + children: [
  52 + Text(
  53 + context.l10n.hrvValidDays,
  54 + style: const TextStyle(
  55 + color: Color(0xFF78787D),
  56 + fontSize: 12,
  57 + ),
  58 + ),
  59 + const SizedBox(height: 2),
  60 + _ValueWithUnit(
  61 + value: '${report.validDays}',
  62 + unit: context.l10n.hrvDistributionDayUnit,
  63 + ),
  64 + const SizedBox(height: 28),
  65 + _DistributionGrid(report: report),
  66 + ],
  67 + ),
  68 + ),
  69 + _DistributionBar(report: report),
  70 + const SizedBox(width: 16),
  71 + ],
  72 + ),
  73 + if (footer != null) ...[
  74 + const SizedBox(height: 20),
  75 + const Divider(height: 1, color: Color(0xFFF3F3F3)),
  76 + const SizedBox(height: 20),
  77 + footer!,
  78 + ],
  79 + ],
  80 + ),
  81 + );
  82 + if (onTap == null) return content;
  83 +
  84 + return Stack(
  85 + children: [
  86 + content,
  87 + Positioned.fill(
  88 + child: GestureDetector(
  89 + behavior: HitTestBehavior.opaque,
  90 + onTap: onTap,
  91 + ),
  92 + ),
  93 + ],
  94 + );
  95 + }
  96 +}
  97 +
  98 +class _Title extends StatelessWidget {
  99 + const _Title({
  100 + required this.title,
  101 + required this.showExample,
  102 + this.trailing,
  103 + });
  104 +
  105 + final String title;
  106 + final bool showExample;
  107 + final Widget? trailing;
  108 +
  109 + @override
  110 + Widget build(BuildContext context) => Row(
  111 + crossAxisAlignment: CrossAxisAlignment.center,
  112 + children: [
  113 + Text(title,
  114 + style:
  115 + const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
  116 + if (showExample)
  117 + Image.asset(
  118 + R.assetsImagesTrendProIcon,
  119 + width: 46.w,
  120 + height: 13.h,
  121 + fit: BoxFit.contain,
  122 + ),
  123 + if (trailing != null) ...[
  124 + const Spacer(),
  125 + trailing!,
  126 + ],
  127 + ],
  128 + );
  129 +}
  130 +
  131 +class _DistributionGrid extends StatelessWidget {
  132 + const _DistributionGrid({required this.report});
  133 +
  134 + final HrvPeriodReport report;
  135 +
  136 + @override
  137 + Widget build(BuildContext context) => LayoutBuilder(
  138 + builder: (context, constraints) {
  139 + const spacing = 14.0;
  140 + final itemWidth = (constraints.maxWidth - spacing) / 2;
  141 + return Wrap(
  142 + spacing: spacing,
  143 + runSpacing: 19,
  144 + children: [
  145 + for (final level in HrvStressLevel.values)
  146 + SizedBox(
  147 + width: itemWidth,
  148 + child: _DistributionItem(level: level, report: report),
  149 + ),
  150 + ],
  151 + );
  152 + },
  153 + );
  154 +}
  155 +
  156 +class _DistributionItem extends StatelessWidget {
  157 + const _DistributionItem({required this.level, required this.report});
  158 +
  159 + final HrvStressLevel level;
  160 + final HrvPeriodReport report;
  161 +
  162 + @override
  163 + Widget build(BuildContext context) {
  164 + final count = report.countFor(level);
  165 + final percent =
  166 + report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
  167 + return Column(
  168 + crossAxisAlignment: CrossAxisAlignment.start,
  169 + children: [
  170 + Row(
  171 + children: [
  172 + Container(
  173 + width: 12,
  174 + height: 12,
  175 + decoration: BoxDecoration(
  176 + color: Color(level.colorValue),
  177 + shape: BoxShape.circle,
  178 + ),
  179 + ),
  180 + const SizedBox(width: 4),
  181 + Expanded(
  182 + child: Text(
  183 + level.label,
  184 + maxLines: 1,
  185 + overflow: TextOverflow.ellipsis,
  186 + style: const TextStyle(
  187 + fontSize: 12,
  188 + fontWeight: FontWeight.w500,
  189 + height: 1.2,
  190 + ),
  191 + ),
  192 + ),
  193 + ],
  194 + ),
  195 + const SizedBox(height: 8),
  196 + if (report.hasData)
  197 + Row(
  198 + crossAxisAlignment: CrossAxisAlignment.end,
  199 + children: [
  200 + Text(
  201 + '$count',
  202 + style: const TextStyle(
  203 + color: Color(0xFF0F0F11),
  204 + fontSize: 26,
  205 + fontWeight: FontWeight.w500,
  206 + height: 1,
  207 + ),
  208 + ),
  209 + Padding(
  210 + padding: const EdgeInsets.only(left: 2, bottom: 2),
  211 + child: Text(
  212 + context.l10n.hrvDistributionDayUnit,
  213 + style: const TextStyle(
  214 + color: Color(0xFF0F0F11),
  215 + fontSize: 12,
  216 + fontWeight: FontWeight.w500,
  217 + ),
  218 + ),
  219 + ),
  220 + ],
  221 + )
  222 + else
  223 + Text(
  224 + context.l10n.hrvDistributionAwaitingData,
  225 + style: const TextStyle(
  226 + color: Color(0xFF78787D),
  227 + fontSize: 15,
  228 + fontWeight: FontWeight.w500,
  229 + ),
  230 + ),
  231 + const SizedBox(height: 1),
  232 + Text(
  233 + '$percent%',
  234 + style: const TextStyle(
  235 + color: Color(0xFF78787D),
  236 + fontSize: 12,
  237 + height: 1.2,
  238 + ),
  239 + ),
  240 + ],
  241 + );
  242 + }
  243 +}
  244 +
  245 +class _ValueWithUnit extends StatelessWidget {
  246 + const _ValueWithUnit({required this.value, required this.unit});
  247 +
  248 + final String value;
  249 + final String unit;
  250 +
  251 + @override
  252 + Widget build(BuildContext context) => Row(
  253 + crossAxisAlignment: CrossAxisAlignment.end,
  254 + children: [
  255 + Text(
  256 + value,
  257 + style: const TextStyle(
  258 + fontSize: 26, fontWeight: FontWeight.w600, height: 1),
  259 + ),
  260 + Padding(
  261 + padding: const EdgeInsets.only(left: 3, bottom: 2),
  262 + child: Text(unit, style: const TextStyle(fontSize: 12)),
  263 + ),
  264 + ],
  265 + );
  266 +}
  267 +
  268 +class _DistributionBar extends StatelessWidget {
  269 + const _DistributionBar({required this.report});
  270 +
  271 + final HrvPeriodReport report;
  272 +
  273 + @override
  274 + Widget build(BuildContext context) {
  275 + final segments = [
  276 + for (final level in HrvStressLevel.values)
  277 + if (report.countFor(level) > 0)
  278 + HrvDistributionBarSegment(
  279 + color: Color(level.colorValue),
  280 + flex: report.countFor(level),
  281 + ),
  282 + ];
  283 + return SizedBox(
  284 + width: 42,
  285 + height: 220,
  286 + child: IgnorePointer(
  287 + child: HrvDistributionBar(
  288 + segments: report.hasData ? segments : const [],
  289 + backgroundColor: const Color(0xFFF3F3F3),
  290 + radius: 7,
  291 + ),
  292 + ),
  293 + );
  294 + }
  295 +}
@@ -4,14 +4,16 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart'; @@ -4,14 +4,16 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart';
4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 import 'package:fl_chart/fl_chart.dart'; 5 import 'package:fl_chart/fl_chart.dart';
6 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
  7 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
7 8
8 import '../../../../core/utils/app_time_formatter.dart'; 9 import '../../../../core/utils/app_time_formatter.dart';
9 import '../../../../r.dart'; 10 import '../../../../r.dart';
  11 +import '../../../widget/friend_interact_click_view.dart';
10 import '../../report_common/utils/report_localization.dart'; 12 import '../../report_common/utils/report_localization.dart';
11 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 13 import '../../report_common/widgets/chart_selection_line_overlay.dart';
12 import '../../report_common/widgets/health_report_subject_scope.dart'; 14 import '../../report_common/widgets/health_report_subject_scope.dart';
13 import '../models/hrv_report_models.dart'; 15 import '../models/hrv_report_models.dart';
14 -import 'hrv_distribution_bar.dart'; 16 +import 'hrv_stress_distribution_card.dart';
15 17
16 class HrvWeekReportView extends StatelessWidget { 18 class HrvWeekReportView extends StatelessWidget {
17 const HrvWeekReportView({ 19 const HrvWeekReportView({
@@ -31,7 +33,8 @@ class HrvWeekReportView extends StatelessWidget { @@ -31,7 +33,8 @@ class HrvWeekReportView extends StatelessWidget {
31 Widget build(BuildContext context) { 33 Widget build(BuildContext context) {
32 final now = DateTime.now(); 34 final now = DateTime.now();
33 final today = DateTime(now.year, now.month, now.day); 35 final today = DateTime(now.year, now.month, now.day);
34 - final data = report ?? 36 + final data =
  37 + report ??
35 WeeklyHrvReport.empty( 38 WeeklyHrvReport.empty(
36 today.subtract(Duration(days: today.weekday - 1)), 39 today.subtract(Duration(days: today.weekday - 1)),
37 ); 40 );
@@ -95,9 +98,28 @@ class _HrvPeriodReportView extends StatelessWidget { @@ -95,9 +98,28 @@ class _HrvPeriodReportView extends StatelessWidget {
95 tooltipDismissDelayMs: tooltipDismissDelayMs, 98 tooltipDismissDelayMs: tooltipDismissDelayMs,
96 ), 99 ),
97 const SizedBox(height: 12), 100 const SizedBox(height: 12),
98 - _DistributionCard( 101 + HrvStressDistributionCard(
99 report: report, 102 report: report,
  103 + title: showExample
  104 + ? context.l10n.reportExampleTitle(
  105 + HealthReportSubjectScope.titleOf(
  106 + context,
  107 + context.l10n.hrvStressDistribution,
  108 + ),
  109 + )
  110 + : HealthReportSubjectScope.titleOf(
  111 + context,
  112 + context.l10n.hrvStressDistribution,
  113 + ),
100 showExample: showExample, 114 showExample: showExample,
  115 + trailing: HealthReportSubjectScope.isViewingSelf(context)
  116 + ? null
  117 + : FriendInteractClickView(
  118 + interactionType: isMonth
  119 + ? FriendInteractionType.monthStressTrend
  120 + : FriendInteractionType.weekStressTrend,
  121 + ),
  122 + footer: _ExtremeHrvSection(report: report),
101 onTap: showExample ? onSubscribe : null, 123 onTap: showExample ? onSubscribe : null,
102 ), 124 ),
103 ], 125 ],
@@ -134,6 +156,7 @@ class _DailyStressCard extends StatelessWidget { @@ -134,6 +156,7 @@ class _DailyStressCard extends StatelessWidget {
134 context.l10n.hrvDailyStressTrend, 156 context.l10n.hrvDailyStressTrend,
135 ), 157 ),
136 showExample: showExample, 158 showExample: showExample,
  159 + isMonth: isMonth,
137 ), 160 ),
138 const SizedBox(height: 18), 161 const SizedBox(height: 18),
139 SizedBox( 162 SizedBox(
@@ -311,11 +334,14 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -311,11 +334,14 @@ class _HrvBarChartState extends State<_HrvBarChart> {
311 ), 334 ),
312 titlesData: FlTitlesData( 335 titlesData: FlTitlesData(
313 topTitles: const AxisTitles( 336 topTitles: const AxisTitles(
314 - sideTitles: SideTitles(showTitles: false)), 337 + sideTitles: SideTitles(showTitles: false),
  338 + ),
315 rightTitles: const AxisTitles( 339 rightTitles: const AxisTitles(
316 - sideTitles: SideTitles(showTitles: false)), 340 + sideTitles: SideTitles(showTitles: false),
  341 + ),
317 leftTitles: const AxisTitles( 342 leftTitles: const AxisTitles(
318 - sideTitles: SideTitles(showTitles: false)), 343 + sideTitles: SideTitles(showTitles: false),
  344 + ),
319 bottomTitles: AxisTitles( 345 bottomTitles: AxisTitles(
320 sideTitles: SideTitles( 346 sideTitles: SideTitles(
321 showTitles: true, 347 showTitles: true,
@@ -345,8 +371,9 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -345,8 +371,9 @@ class _HrvBarChartState extends State<_HrvBarChart> {
345 // Keep individual empty days selectable so their 371 // Keep individual empty days selectable so their
346 // localized date and empty-state message are visible. 372 // localized date and empty-state message are visible.
347 final nextIndex = touchedIndex; 373 final nextIndex = touchedIndex;
348 - final nextOffset =  
349 - nextIndex == null ? null : response?.spot?.offset; 374 + final nextOffset = nextIndex == null
  375 + ? null
  376 + : response?.spot?.offset;
350 if (_touchedIndex != nextIndex || 377 if (_touchedIndex != nextIndex ||
351 _touchedOffset != nextOffset) { 378 _touchedOffset != nextOffset) {
352 setState(() { 379 setState(() {
@@ -538,27 +565,38 @@ class _TrendMetric extends StatelessWidget { @@ -538,27 +565,38 @@ class _TrendMetric extends StatelessWidget {
538 final isGoodChange = _isGoodChange(difference); 565 final isGoodChange = _isGoodChange(difference);
539 final comparison = hasData 566 final comparison = hasData
540 ? isMonth 567 ? isMonth
541 - ? _monthComparisonText(context, difference!)  
542 - : _weekComparisonText(context, difference!) 568 + ? _monthComparisonText(context, difference!)
  569 + : _weekComparisonText(context, difference!)
543 : _unavailableComparisonText(context); 570 : _unavailableComparisonText(context);
544 final comparisonColor = _comparisonColor(difference); 571 final comparisonColor = _comparisonColor(difference);
545 return Column( 572 return Column(
546 crossAxisAlignment: CrossAxisAlignment.start, 573 crossAxisAlignment: CrossAxisAlignment.start,
547 children: [ 574 children: [
548 - Text(label,  
549 - style: const TextStyle(color: Color(0xFF78787D), fontSize: 12)), 575 + Text(
  576 + label,
  577 + style: const TextStyle(color: Color(0xFF78787D), fontSize: 12),
  578 + ),
550 const SizedBox(height: 3), 579 const SizedBox(height: 3),
551 Row( 580 Row(
552 crossAxisAlignment: CrossAxisAlignment.end, 581 crossAxisAlignment: CrossAxisAlignment.end,
553 children: [ 582 children: [
554 - Text(context.l10n.hrvTrendDayValue(value),  
555 - style: const TextStyle(  
556 - fontSize: 26, fontWeight: FontWeight.w600, height: 1)), 583 + Text(
  584 + context.l10n.hrvTrendDayValue(value),
  585 + style: const TextStyle(
  586 + fontSize: 26,
  587 + fontWeight: FontWeight.w600,
  588 + height: 1,
  589 + ),
  590 + ),
557 Padding( 591 Padding(
558 padding: const EdgeInsets.only(left: 4, bottom: 2), 592 padding: const EdgeInsets.only(left: 4, bottom: 2),
559 - child: Text(context.l10n.hrvTrendDayUnit,  
560 - style: const TextStyle(  
561 - fontSize: 12, fontWeight: FontWeight.w500)), 593 + child: Text(
  594 + context.l10n.hrvTrendDayUnit,
  595 + style: const TextStyle(
  596 + fontSize: 12,
  597 + fontWeight: FontWeight.w500,
  598 + ),
  599 + ),
562 ), 600 ),
563 ], 601 ],
564 ), 602 ),
@@ -591,16 +629,16 @@ class _TrendMetric extends StatelessWidget { @@ -591,16 +629,16 @@ class _TrendMetric extends StatelessWidget {
591 return difference == 0 629 return difference == 0
592 ? context.l10n.hrvTrendSameAsLastWeek 630 ? context.l10n.hrvTrendSameAsLastWeek
593 : difference > 0 631 : difference > 0
594 - ? context.l10n.hrvTrendMoreDaysThanLastWeek(difference)  
595 - : context.l10n.hrvTrendFewerDaysThanLastWeek(difference.abs()); 632 + ? context.l10n.hrvTrendMoreDaysThanLastWeek(difference)
  633 + : context.l10n.hrvTrendFewerDaysThanLastWeek(difference.abs());
596 } 634 }
597 635
598 String _monthComparisonText(BuildContext context, int difference) { 636 String _monthComparisonText(BuildContext context, int difference) {
599 return difference == 0 637 return difference == 0
600 ? context.l10n.hrvTrendSameAsLastMonth 638 ? context.l10n.hrvTrendSameAsLastMonth
601 : difference > 0 639 : difference > 0
602 - ? context.l10n.hrvTrendMoreDaysThanLastMonth(difference)  
603 - : context.l10n.hrvTrendFewerDaysThanLastMonth(difference.abs()); 640 + ? context.l10n.hrvTrendMoreDaysThanLastMonth(difference)
  641 + : context.l10n.hrvTrendFewerDaysThanLastMonth(difference.abs());
604 } 642 }
605 643
606 String _unavailableComparisonText(BuildContext context) { 644 String _unavailableComparisonText(BuildContext context) {
@@ -662,11 +700,7 @@ class _StressAxisText extends StatelessWidget { @@ -662,11 +700,7 @@ class _StressAxisText extends StatelessWidget {
662 return Text( 700 return Text(
663 text, 701 text,
664 maxLines: 1, 702 maxLines: 1,
665 - style: const TextStyle(  
666 - color: Color(0xFFB0B0B6),  
667 - fontSize: 10,  
668 - height: 1,  
669 - ), 703 + style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10, height: 1),
670 ); 704 );
671 } 705 }
672 } 706 }
@@ -768,226 +802,22 @@ String _weekAxisLabel(BuildContext context, int weekday) { @@ -768,226 +802,22 @@ String _weekAxisLabel(BuildContext context, int weekday) {
768 }; 802 };
769 } 803 }
770 804
771 -class _DistributionCard extends StatelessWidget {  
772 - const _DistributionCard({  
773 - required this.report,  
774 - required this.showExample,  
775 - this.onTap,  
776 - }); 805 +class _ExtremeHrvSection extends StatelessWidget {
  806 + const _ExtremeHrvSection({required this.report});
777 807
778 final HrvPeriodReport report; 808 final HrvPeriodReport report;
779 - final bool showExample;  
780 - final VoidCallback? onTap;  
781 - static const _barRight = 25.0;  
782 - static const _barWidth = 42.0;  
783 809
784 @override 810 @override
785 - Widget build(BuildContext context) {  
786 - return _Card(  
787 - onTap: onTap,  
788 - padding: EdgeInsets.zero,  
789 - child: Padding(  
790 - padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),  
791 - child: Column(  
792 - crossAxisAlignment: CrossAxisAlignment.start,  
793 - children: [  
794 - _topView(context),  
795 - const Divider(height: 1, color: Color(0xFFF3F3F3)),  
796 - const SizedBox(height: 20),  
797 - Row(  
798 - children: [  
799 - Expanded(  
800 - child: _ExtremeHrv(  
801 - title: context.l10n.hrvLowest,  
802 - day: report.minDay,  
803 - ),  
804 - ),  
805 - Expanded(  
806 - child: _ExtremeHrv(  
807 - title: context.l10n.hrvHighest,  
808 - day: report.maxDay,  
809 - ),  
810 - ),  
811 - ],  
812 - ),  
813 - ],  
814 - ), 811 + Widget build(BuildContext context) => Row(
  812 + children: [
  813 + Expanded(
  814 + child: _ExtremeHrv(title: context.l10n.hrvLowest, day: report.minDay),
815 ), 815 ),
816 - );  
817 - }  
818 -  
819 - Widget _topView(BuildContext context) {  
820 - return Stack(  
821 - children: [  
822 - Positioned(  
823 - child: Column(  
824 - crossAxisAlignment: CrossAxisAlignment.start,  
825 - children: [  
826 - _ReportTitle(  
827 - title: HealthReportSubjectScope.titleOf(  
828 - context,  
829 - context.l10n.hrvStressDistribution,  
830 - ),  
831 - showExample: showExample,  
832 - fontSize: 16,  
833 - ),  
834 - const SizedBox(height: 20),  
835 - Text(  
836 - context.l10n.hrvValidDays,  
837 - style: const TextStyle(  
838 - color: Color(0xFF78787D),  
839 - fontSize: 12,  
840 - ),  
841 - ),  
842 - const SizedBox(height: 2),  
843 - _ValueWithUnit(  
844 - value: '${report.validDays}',  
845 - unit: context.l10n.hrvDistributionDayUnit,  
846 - ),  
847 - const SizedBox(height: 28),  
848 - SizedBox(  
849 - width: 230,  
850 - child: _DistributionGrid(report: report),  
851 - ),  
852 - const SizedBox(height: 20),  
853 - ],  
854 - )),  
855 - Positioned(  
856 - top: 0,  
857 - width: _barWidth,  
858 - right: _barRight,  
859 - bottom: 25.h,  
860 - child: Container(  
861 - alignment: Alignment.topCenter,  
862 - padding: EdgeInsets.only(top: 25.h),  
863 - child: IgnorePointer(child: _DistributionBar(report: report))),  
864 - ),  
865 - ],  
866 - );  
867 - }  
868 -}  
869 -  
870 -class _DistributionGrid extends StatelessWidget {  
871 - const _DistributionGrid({required this.report});  
872 -  
873 - final HrvPeriodReport report;  
874 -  
875 - @override  
876 - Widget build(BuildContext context) {  
877 - return LayoutBuilder(  
878 - builder: (context, constraints) {  
879 - const spacing = 14.0;  
880 - return Wrap(  
881 - spacing: spacing,  
882 - runSpacing: 19,  
883 - children: [  
884 - for (final level in HrvStressLevel.values)  
885 - SizedBox(  
886 - width: 105,  
887 - child: _DistributionItem(level: level, report: report),  
888 - ),  
889 - ],  
890 - );  
891 - },  
892 - );  
893 - }  
894 -}  
895 -  
896 -class _DistributionItem extends StatelessWidget {  
897 - const _DistributionItem({required this.level, required this.report});  
898 -  
899 - final HrvStressLevel level;  
900 - final HrvPeriodReport report;  
901 -  
902 - @override  
903 - Widget build(BuildContext context) {  
904 - final count = report.countFor(level);  
905 - final percent =  
906 - report.validDays == 0 ? 0 : (count / report.validDays * 100).round();  
907 - return Column(  
908 - crossAxisAlignment: CrossAxisAlignment.start,  
909 - children: [  
910 - Row(children: [  
911 - Container(  
912 - width: 12,  
913 - height: 12,  
914 - decoration: BoxDecoration(  
915 - color: Color(level.colorValue), shape: BoxShape.circle)),  
916 - const SizedBox(width: 4),  
917 - Expanded(  
918 - child: Text(  
919 - level.label,  
920 - maxLines: 1,  
921 - overflow: TextOverflow.ellipsis,  
922 - style: const TextStyle(  
923 - fontSize: 12,  
924 - fontWeight: FontWeight.w500,  
925 - height: 1.2,  
926 - ),  
927 - ),  
928 - ),  
929 - ]),  
930 - const SizedBox(height: 8),  
931 - if (report.hasData)  
932 - Row(  
933 - crossAxisAlignment: CrossAxisAlignment.end,  
934 - children: [  
935 - Text('$count',  
936 - style: const TextStyle(  
937 - color: Color(0xFF0F0F11),  
938 - fontSize: 26,  
939 - fontWeight: FontWeight.w500,  
940 - height: 1)),  
941 - Padding(  
942 - padding: const EdgeInsets.only(left: 2, bottom: 2),  
943 - child: Text(context.l10n.hrvDistributionDayUnit,  
944 - style: const TextStyle(  
945 - color: Color(0xFF0F0F11),  
946 - fontSize: 12,  
947 - fontWeight: FontWeight.w500)),  
948 - ),  
949 - ],  
950 - )  
951 - else  
952 - Text(context.l10n.hrvDistributionAwaitingData,  
953 - style: const TextStyle(  
954 - color: Color(0xFF78787D),  
955 - fontSize: 15,  
956 - fontWeight: FontWeight.w500,  
957 - height: 1.2)),  
958 - const SizedBox(height: 1),  
959 - Text('$percent%',  
960 - style: const TextStyle(  
961 - color: Color(0xFF78787D),  
962 - fontSize: 12,  
963 - height: 1.2,  
964 - )),  
965 - ],  
966 - );  
967 - }  
968 -}  
969 -  
970 -class _DistributionBar extends StatelessWidget {  
971 - const _DistributionBar({required this.report});  
972 -  
973 - final HrvPeriodReport report;  
974 -  
975 - @override  
976 - Widget build(BuildContext context) {  
977 - final segments = [  
978 - for (final level in HrvStressLevel.values)  
979 - if (report.countFor(level) > 0)  
980 - HrvDistributionBarSegment(  
981 - color: Color(level.colorValue),  
982 - flex: report.countFor(level),  
983 - ),  
984 - ];  
985 - return HrvDistributionBar(  
986 - segments: report.hasData ? segments : const [],  
987 - backgroundColor: const Color(0xFFF3F3F3),  
988 - radius: 8,  
989 - );  
990 - } 816 + Expanded(
  817 + child: _ExtremeHrv(title: context.l10n.hrvHighest, day: report.maxDay),
  818 + ),
  819 + ],
  820 + );
991 } 821 }
992 822
993 class _ExtremeHrv extends StatelessWidget { 823 class _ExtremeHrv extends StatelessWidget {
@@ -1001,12 +831,15 @@ class _ExtremeHrv extends StatelessWidget { @@ -1001,12 +831,15 @@ class _ExtremeHrv extends StatelessWidget {
1001 return Column( 831 return Column(
1002 crossAxisAlignment: CrossAxisAlignment.start, 832 crossAxisAlignment: CrossAxisAlignment.start,
1003 children: [ 833 children: [
1004 - Text(title,  
1005 - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), 834 + Text(
  835 + title,
  836 + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
  837 + ),
1006 const SizedBox(height: 5), 838 const SizedBox(height: 5),
1007 _ValueWithUnit( 839 _ValueWithUnit(
1008 - value: day == null ? '-' : '${day!.averageHrv!.round()}',  
1009 - unit: 'ms'), 840 + value: day == null ? '-' : '${day!.averageHrv!.round()}',
  841 + unit: 'ms',
  842 + ),
1010 const SizedBox(height: 2), 843 const SizedBox(height: 2),
1011 Text( 844 Text(
1012 day == null 845 day == null
@@ -1039,9 +872,14 @@ class _ValueWithUnit extends StatelessWidget { @@ -1039,9 +872,14 @@ class _ValueWithUnit extends StatelessWidget {
1039 return Row( 872 return Row(
1040 crossAxisAlignment: CrossAxisAlignment.end, 873 crossAxisAlignment: CrossAxisAlignment.end,
1041 children: [ 874 children: [
1042 - Text(value,  
1043 - style: const TextStyle(  
1044 - fontSize: 26, fontWeight: FontWeight.w600, height: 1)), 875 + Text(
  876 + value,
  877 + style: const TextStyle(
  878 + fontSize: 26,
  879 + fontWeight: FontWeight.w600,
  880 + height: 1,
  881 + ),
  882 + ),
1045 Padding( 883 Padding(
1046 padding: const EdgeInsets.only(left: 3, bottom: 2), 884 padding: const EdgeInsets.only(left: 3, bottom: 2),
1047 child: Text(unit, style: const TextStyle(fontSize: 12)), 885 child: Text(unit, style: const TextStyle(fontSize: 12)),
@@ -1055,20 +893,21 @@ class _ReportTitle extends StatelessWidget { @@ -1055,20 +893,21 @@ class _ReportTitle extends StatelessWidget {
1055 const _ReportTitle({ 893 const _ReportTitle({
1056 required this.title, 894 required this.title,
1057 required this.showExample, 895 required this.showExample,
1058 - this.fontSize = 15, 896 + required this.isMonth,
1059 }); 897 });
1060 898
1061 final String title; 899 final String title;
1062 final bool showExample; 900 final bool showExample;
1063 - final double fontSize; 901 + final bool isMonth;
1064 902
1065 @override 903 @override
1066 Widget build(BuildContext context) { 904 Widget build(BuildContext context) {
1067 return Row( 905 return Row(
  906 + crossAxisAlignment: CrossAxisAlignment.center,
1068 children: [ 907 children: [
1069 Text( 908 Text(
1070 showExample ? context.l10n.reportExampleTitle(title) : title, 909 showExample ? context.l10n.reportExampleTitle(title) : title,
1071 - style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600), 910 + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
1072 ), 911 ),
1073 if (showExample) ...[ 912 if (showExample) ...[
1074 Image.asset( 913 Image.asset(
@@ -1078,6 +917,14 @@ class _ReportTitle extends StatelessWidget { @@ -1078,6 +917,14 @@ class _ReportTitle extends StatelessWidget {
1078 fit: BoxFit.contain, 917 fit: BoxFit.contain,
1079 ), 918 ),
1080 ], 919 ],
  920 + if (!HealthReportSubjectScope.isViewingSelf(context)) ...[
  921 + const Spacer(),
  922 + FriendInteractClickView(
  923 + interactionType: isMonth
  924 + ? FriendInteractionType.monthStressTrend
  925 + : FriendInteractionType.weekStressTrend,
  926 + ),
  927 + ],
1081 ], 928 ],
1082 ); 929 );
1083 } 930 }
@@ -1100,7 +947,9 @@ class _Card extends StatelessWidget { @@ -1100,7 +947,9 @@ class _Card extends StatelessWidget {
1100 width: double.infinity, 947 width: double.infinity,
1101 padding: padding, 948 padding: padding,
1102 decoration: BoxDecoration( 949 decoration: BoxDecoration(
1103 - color: Colors.white, borderRadius: BorderRadius.circular(16)), 950 + color: Colors.white,
  951 + borderRadius: BorderRadius.circular(16),
  952 + ),
1104 child: child, 953 child: child,
1105 ); 954 );
1106 if (onTap == null) return content; 955 if (onTap == null) return content;
@@ -2,14 +2,16 @@ import 'dart:async'; @@ -2,14 +2,16 @@ import 'dart:async';
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/data/models/interaction/interaction_models.dart';
5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
6 7
  8 +import '../../../widget/friend_interact_click_view.dart';
7 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 9 import '../../report_common/widgets/chart_selection_line_overlay.dart';
8 import '../../report_common/widgets/health_report_subject_scope.dart'; 10 import '../../report_common/widgets/health_report_subject_scope.dart';
9 import '../../report_common/widgets/report_month_calendar_grid.dart'; 11 import '../../report_common/widgets/report_month_calendar_grid.dart';
10 import '../../report_common/utils/report_localization.dart'; 12 import '../../report_common/utils/report_localization.dart';
11 import '../models/hrv_report_models.dart'; 13 import '../models/hrv_report_models.dart';
12 -import 'hrv_distribution_bar.dart'; 14 +import 'hrv_stress_distribution_card.dart';
13 15
14 class HrvYearReportView extends StatelessWidget { 16 class HrvYearReportView extends StatelessWidget {
15 const HrvYearReportView({ 17 const HrvYearReportView({
@@ -31,7 +33,18 @@ class HrvYearReportView extends StatelessWidget { @@ -31,7 +33,18 @@ class HrvYearReportView extends StatelessWidget {
31 tooltipDismissDelayMs: tooltipDismissDelayMs, 33 tooltipDismissDelayMs: tooltipDismissDelayMs,
32 ), 34 ),
33 const SizedBox(height: 12), 35 const SizedBox(height: 12),
34 - _YearDistributionCard(report: data), 36 + HrvStressDistributionCard(
  37 + report: data,
  38 + title: HealthReportSubjectScope.titleOf(
  39 + context,
  40 + context.l10n.hrvStressDistribution,
  41 + ),
  42 + trailing: HealthReportSubjectScope.isViewingSelf(context)
  43 + ? null
  44 + : const FriendInteractClickView(
  45 + interactionType: FriendInteractionType.yearStressTrend,
  46 + ),
  47 + ),
35 const SizedBox(height: 12), 48 const SizedBox(height: 12),
36 _DailyDistributionCard(report: data), 49 _DailyDistributionCard(report: data),
37 ], 50 ],
@@ -55,12 +68,11 @@ class _YearTrendCard extends StatelessWidget { @@ -55,12 +68,11 @@ class _YearTrendCard extends StatelessWidget {
55 child: Column( 68 child: Column(
56 crossAxisAlignment: CrossAxisAlignment.start, 69 crossAxisAlignment: CrossAxisAlignment.start,
57 children: [ 70 children: [
58 - Text(  
59 - HealthReportSubjectScope.titleOf( 71 + _ReportTitle(
  72 + title: HealthReportSubjectScope.titleOf(
60 context, 73 context,
61 context.l10n.hrvMonthlyStressTrend, 74 context.l10n.hrvMonthlyStressTrend,
62 ), 75 ),
63 - style: _titleStyle,  
64 ), 76 ),
65 const SizedBox(height: 18), 77 const SizedBox(height: 18),
66 SizedBox( 78 SizedBox(
@@ -165,12 +177,14 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -165,12 +177,14 @@ class _YearBarChartState extends State<_YearBarChart> {
165 BarChartGroupData( 177 BarChartGroupData(
166 x: month, 178 x: month,
167 groupVertically: true, 179 groupVertically: true,
168 - showingTooltipIndicators:  
169 - _touchedMonth == month ? [1] : [], 180 + showingTooltipIndicators: _touchedMonth == month
  181 + ? [1]
  182 + : [],
170 barRods: [ 183 barRods: [
171 BarChartRodData( 184 BarChartRodData(
172 toY: _barHeight( 185 toY: _barHeight(
173 - widget.report.stressScoreForMonth(month)), 186 + widget.report.stressScoreForMonth(month),
  187 + ),
174 width: 8, 188 width: 8,
175 color: _colorForMonth(month), 189 color: _colorForMonth(month),
176 borderRadius: const BorderRadius.vertical( 190 borderRadius: const BorderRadius.vertical(
@@ -192,10 +206,8 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -192,10 +206,8 @@ class _YearBarChartState extends State<_YearBarChart> {
192 show: true, 206 show: true,
193 drawVerticalLine: false, 207 drawVerticalLine: false,
194 horizontalInterval: 25, 208 horizontalInterval: 25,
195 - getDrawingHorizontalLine: (_) => const FlLine(  
196 - color: _grid,  
197 - dashArray: [2, 2],  
198 - ), 209 + getDrawingHorizontalLine: (_) =>
  210 + const FlLine(color: _grid, dashArray: [2, 2]),
199 ), 211 ),
200 extraLinesData: ExtraLinesData( 212 extraLinesData: ExtraLinesData(
201 extraLinesOnTop: false, 213 extraLinesOnTop: false,
@@ -210,11 +222,14 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -210,11 +222,14 @@ class _YearBarChartState extends State<_YearBarChart> {
210 ), 222 ),
211 titlesData: FlTitlesData( 223 titlesData: FlTitlesData(
212 topTitles: const AxisTitles( 224 topTitles: const AxisTitles(
213 - sideTitles: SideTitles(showTitles: false)), 225 + sideTitles: SideTitles(showTitles: false),
  226 + ),
214 rightTitles: const AxisTitles( 227 rightTitles: const AxisTitles(
215 - sideTitles: SideTitles(showTitles: false)), 228 + sideTitles: SideTitles(showTitles: false),
  229 + ),
216 leftTitles: const AxisTitles( 230 leftTitles: const AxisTitles(
217 - sideTitles: SideTitles(showTitles: false)), 231 + sideTitles: SideTitles(showTitles: false),
  232 + ),
218 bottomTitles: AxisTitles( 233 bottomTitles: AxisTitles(
219 sideTitles: SideTitles( 234 sideTitles: SideTitles(
220 showTitles: true, 235 showTitles: true,
@@ -241,11 +256,13 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -241,11 +256,13 @@ class _YearBarChartState extends State<_YearBarChart> {
241 event.localPosition?.dx, 256 event.localPosition?.dx,
242 constraints.maxWidth, 257 constraints.maxWidth,
243 ); 258 );
244 - final hasTouchedData = month != null && 259 + final hasTouchedData =
  260 + month != null &&
245 widget.report.stressScoreForMonth(month) != null; 261 widget.report.stressScoreForMonth(month) != null;
246 final nextMonth = hasTouchedData ? month : null; 262 final nextMonth = hasTouchedData ? month : null;
247 - final nextOffset =  
248 - nextMonth == null ? null : response?.spot?.offset; 263 + final nextOffset = nextMonth == null
  264 + ? null
  265 + : response?.spot?.offset;
249 if (_touchedMonth != nextMonth || 266 if (_touchedMonth != nextMonth ||
250 _touchedOffset != nextOffset) { 267 _touchedOffset != nextOffset) {
251 setState(() { 268 setState(() {
@@ -302,8 +319,10 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -302,8 +319,10 @@ class _YearBarChartState extends State<_YearBarChart> {
302 ), 319 ),
303 ), 320 ),
304 if (!widget.report.hasData) 321 if (!widget.report.hasData)
305 - Text(context.l10n.hrvTrendAwaitingData,  
306 - style: const TextStyle(color: _h3, fontSize: 11)), 322 + Text(
  323 + context.l10n.hrvTrendAwaitingData,
  324 + style: const TextStyle(color: _h3, fontSize: 11),
  325 + ),
307 ], 326 ],
308 ); 327 );
309 } 328 }
@@ -394,17 +413,15 @@ String _tooltipMonth(BuildContext context, int month) { @@ -394,17 +413,15 @@ String _tooltipMonth(BuildContext context, int month) {
394 } 413 }
395 414
396 class _MonthExtreme extends StatelessWidget { 415 class _MonthExtreme extends StatelessWidget {
397 - const _MonthExtreme({  
398 - required this.label,  
399 - required this.months,  
400 - }); 416 + const _MonthExtreme({required this.label, required this.months});
401 final String label; 417 final String label;
402 final List<int> months; 418 final List<int> months;
403 419
404 @override 420 @override
405 Widget build(BuildContext context) { 421 Widget build(BuildContext context) {
406 - final separator =  
407 - Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', '; 422 + final separator = Localizations.localeOf(context).languageCode == 'zh'
  423 + ? ','
  424 + : ', ';
408 final value = months.isEmpty 425 final value = months.isEmpty
409 ? context.l10n.hrvEmptyMonthPlaceholder 426 ? context.l10n.hrvEmptyMonthPlaceholder
410 : months.map((month) => _tooltipMonth(context, month)).join(separator); 427 : months.map((month) => _tooltipMonth(context, month)).join(separator);
@@ -475,7 +492,8 @@ class _YearXAxisLabels extends StatelessWidget { @@ -475,7 +492,8 @@ class _YearXAxisLabels extends StatelessWidget {
475 children: [ 492 children: [
476 for (var index = 0; index < groupCount; index++) 493 for (var index = 0; index < groupCount; index++)
477 Positioned( 494 Positioned(
478 - left: constraints.maxWidth * ((index + 0.5) / groupCount) - 495 + left:
  496 + constraints.maxWidth * ((index + 0.5) / groupCount) -
479 labelWidth / 2, 497 labelWidth / 2,
480 top: 6, 498 top: 6,
481 width: labelWidth, 499 width: labelWidth,
@@ -492,69 +510,6 @@ class _YearXAxisLabels extends StatelessWidget { @@ -492,69 +510,6 @@ class _YearXAxisLabels extends StatelessWidget {
492 } 510 }
493 } 511 }
494 512
495 -class _YearDistributionCard extends StatelessWidget {  
496 - const _YearDistributionCard({required this.report});  
497 - final YearlyHrvReport report;  
498 -  
499 - @override  
500 - Widget build(BuildContext context) {  
501 - return _Card(  
502 - child: Column(  
503 - crossAxisAlignment: CrossAxisAlignment.start,  
504 - children: [  
505 - Text(  
506 - HealthReportSubjectScope.titleOf(  
507 - context,  
508 - context.l10n.hrvStressDistribution,  
509 - ),  
510 - style: _titleStyle,  
511 - ),  
512 - const SizedBox(height: 16),  
513 - Row(  
514 - crossAxisAlignment: CrossAxisAlignment.end,  
515 - children: [  
516 - Expanded(  
517 - child: Column(  
518 - crossAxisAlignment: CrossAxisAlignment.start,  
519 - children: [  
520 - Text(context.l10n.hrvValidDays,  
521 - style: const TextStyle(color: _h2, fontSize: 11)),  
522 - const SizedBox(height: 3),  
523 - _Number(  
524 - value: '${report.validDays}',  
525 - unit: context.l10n.hrvDistributionDayUnit,  
526 - ),  
527 - const SizedBox(height: 18),  
528 - LayoutBuilder(  
529 - builder: (context, constraints) {  
530 - const spacing = 16.0;  
531 - final itemWidth = (constraints.maxWidth - spacing) / 2;  
532 - return Wrap(  
533 - spacing: spacing,  
534 - runSpacing: 13,  
535 - children: [  
536 - for (final level in HrvStressLevel.values)  
537 - SizedBox(  
538 - width: itemWidth,  
539 - child: _LevelItem(report: report, level: level),  
540 - ),  
541 - ],  
542 - );  
543 - },  
544 - ),  
545 - ],  
546 - ),  
547 - ),  
548 - _StackedBar(report: report),  
549 - const SizedBox(width: 20),  
550 - ],  
551 - ),  
552 - ],  
553 - ),  
554 - );  
555 - }  
556 -}  
557 -  
558 class _DailyDistributionCard extends StatelessWidget { 513 class _DailyDistributionCard extends StatelessWidget {
559 const _DailyDistributionCard({required this.report}); 514 const _DailyDistributionCard({required this.report});
560 final YearlyHrvReport report; 515 final YearlyHrvReport report;
@@ -566,12 +521,12 @@ class _DailyDistributionCard extends StatelessWidget { @@ -566,12 +521,12 @@ class _DailyDistributionCard extends StatelessWidget {
566 child: Column( 521 child: Column(
567 crossAxisAlignment: CrossAxisAlignment.start, 522 crossAxisAlignment: CrossAxisAlignment.start,
568 children: [ 523 children: [
569 - Text(  
570 - HealthReportSubjectScope.titleOf( 524 + _ReportTitle(
  525 + title: HealthReportSubjectScope.titleOf(
571 context, 526 context,
572 context.l10n.hrvDailyStressDistribution, 527 context.l10n.hrvDailyStressDistribution,
573 ), 528 ),
574 - style: _titleStyle, 529 + trailingInset: 4,
575 ), 530 ),
576 const SizedBox(height: 20), 531 const SizedBox(height: 20),
577 GridView.builder( 532 GridView.builder(
@@ -606,18 +561,16 @@ class _DailyDistributionCard extends StatelessWidget { @@ -606,18 +561,16 @@ class _DailyDistributionCard extends StatelessWidget {
606 } 561 }
607 562
608 ({List<HrvDayReport> mostStressed, List<HrvDayReport> mostRelaxed}) 563 ({List<HrvDayReport> mostStressed, List<HrvDayReport> mostRelaxed})
609 - _dailyStressExtremeDays(YearlyHrvReport report) { 564 + _dailyStressExtremeDays(YearlyHrvReport report) {
610 final daysWithScore = report.days.where((day) { 565 final daysWithScore = report.days.where((day) {
611 return day.level != null && day.dailyComprehensiveStressScore != null; 566 return day.level != null && day.dailyComprehensiveStressScore != null;
612 }); 567 });
613 - final mostStressed = daysWithScore  
614 - .where((day) => _isStressedLevel(day.level!))  
615 - .toList()  
616 - ..sort(_compareByDailyStressDescending);  
617 - final mostRelaxed = daysWithScore  
618 - .where((day) => !_isStressedLevel(day.level!))  
619 - .toList()  
620 - ..sort(_compareByDailyStressAscending); 568 + final mostStressed =
  569 + daysWithScore.where((day) => _isStressedLevel(day.level!)).toList()
  570 + ..sort(_compareByDailyStressDescending);
  571 + final mostRelaxed =
  572 + daysWithScore.where((day) => !_isStressedLevel(day.level!)).toList()
  573 + ..sort(_compareByDailyStressAscending);
621 574
622 return ( 575 return (
623 mostStressed: _takeTwoAndSortByDate(mostStressed), 576 mostStressed: _takeTwoAndSortByDate(mostStressed),
@@ -626,14 +579,16 @@ class _DailyDistributionCard extends StatelessWidget { @@ -626,14 +579,16 @@ class _DailyDistributionCard extends StatelessWidget {
626 } 579 }
627 580
628 int _compareByDailyStressDescending(HrvDayReport a, HrvDayReport b) { 581 int _compareByDailyStressDescending(HrvDayReport a, HrvDayReport b) {
629 - final valueComparison = b.dailyComprehensiveStressScore!  
630 - .compareTo(a.dailyComprehensiveStressScore!); 582 + final valueComparison = b.dailyComprehensiveStressScore!.compareTo(
  583 + a.dailyComprehensiveStressScore!,
  584 + );
631 return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison; 585 return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison;
632 } 586 }
633 587
634 int _compareByDailyStressAscending(HrvDayReport a, HrvDayReport b) { 588 int _compareByDailyStressAscending(HrvDayReport a, HrvDayReport b) {
635 - final valueComparison = a.dailyComprehensiveStressScore!  
636 - .compareTo(b.dailyComprehensiveStressScore!); 589 + final valueComparison = a.dailyComprehensiveStressScore!.compareTo(
  590 + b.dailyComprehensiveStressScore!,
  591 + );
637 return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison; 592 return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison;
638 } 593 }
639 594
@@ -661,8 +616,10 @@ class _MonthDots extends StatelessWidget { @@ -661,8 +616,10 @@ class _MonthDots extends StatelessWidget {
661 return Column( 616 return Column(
662 crossAxisAlignment: CrossAxisAlignment.start, 617 crossAxisAlignment: CrossAxisAlignment.start,
663 children: [ 618 children: [
664 - Text(_tooltipMonth(context, month),  
665 - style: const TextStyle(fontSize: 8)), 619 + Text(
  620 + _tooltipMonth(context, month),
  621 + style: const TextStyle(fontSize: 8),
  622 + ),
666 const SizedBox(height: 8), 623 const SizedBox(height: 8),
667 SizedBox( 624 SizedBox(
668 width: 80, 625 width: 80,
@@ -694,121 +651,51 @@ class _DateExtreme extends StatelessWidget { @@ -694,121 +651,51 @@ class _DateExtreme extends StatelessWidget {
694 651
695 @override 652 @override
696 Widget build(BuildContext context) { 653 Widget build(BuildContext context) {
697 - final separator =  
698 - Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', '; 654 + final separator = Localizations.localeOf(context).languageCode == 'zh'
  655 + ? ','
  656 + : ', ';
699 final value = days.isEmpty 657 final value = days.isEmpty
700 ? context.l10n.hrvEmptyMonthDayPlaceholder 658 ? context.l10n.hrvEmptyMonthDayPlaceholder
701 : days 659 : days
702 - .take(2)  
703 - .map((day) => reportMonthDay(day.date, context: context))  
704 - .join(separator); 660 + .take(2)
  661 + .map((day) => reportMonthDay(day.date, context: context))
  662 + .join(separator);
705 return Column( 663 return Column(
706 crossAxisAlignment: CrossAxisAlignment.start, 664 crossAxisAlignment: CrossAxisAlignment.start,
707 children: [ 665 children: [
708 Text(label, style: const TextStyle(color: _h2, fontSize: 10)), 666 Text(label, style: const TextStyle(color: _h2, fontSize: 10)),
709 const SizedBox(height: 6), 667 const SizedBox(height: 6),
710 - Text(value,  
711 - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),  
712 - ],  
713 - );  
714 - }  
715 -}  
716 -  
717 -class _LevelItem extends StatelessWidget {  
718 - const _LevelItem({required this.report, required this.level});  
719 - final YearlyHrvReport report;  
720 - final HrvStressLevel level;  
721 -  
722 - @override  
723 - Widget build(BuildContext context) {  
724 - final count = report.countFor(level);  
725 - final percent =  
726 - report.validDays == 0 ? 0 : (count / report.validDays * 100).round();  
727 - return Column(  
728 - crossAxisAlignment: CrossAxisAlignment.start,  
729 - children: [  
730 - Row(children: [  
731 - Container(  
732 - width: 7,  
733 - height: 7,  
734 - decoration: BoxDecoration(  
735 - color: Color(level.colorValue), shape: BoxShape.circle)),  
736 - const SizedBox(width: 4),  
737 - Text(level.label,  
738 - style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w500)),  
739 - ]),  
740 - const SizedBox(height: 3),  
741 - if (report.hasData)  
742 - Row(  
743 - crossAxisAlignment: CrossAxisAlignment.end,  
744 - children: [  
745 - Text('$count',  
746 - style: const TextStyle(  
747 - color: _h1,  
748 - fontSize: 26,  
749 - fontWeight: FontWeight.w500,  
750 - height: 1)),  
751 - Padding(  
752 - padding: const EdgeInsets.only(left: 2, bottom: 2),  
753 - child: Text(context.l10n.hrvDistributionDayUnit,  
754 - style: const TextStyle(  
755 - color: _h1, fontSize: 12, fontWeight: FontWeight.w500)),  
756 - ),  
757 - ],  
758 - )  
759 - else  
760 - Text(context.l10n.hrvDistributionAwaitingData,  
761 - style: const TextStyle(  
762 - color: _h2, fontSize: 15, fontWeight: FontWeight.w500)),  
763 - Text('$percent%', style: const TextStyle(color: _h2, fontSize: 9)), 668 + Text(
  669 + value,
  670 + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
  671 + ),
764 ], 672 ],
765 ); 673 );
766 } 674 }
767 } 675 }
768 676
769 -class _StackedBar extends StatelessWidget {  
770 - const _StackedBar({required this.report});  
771 - final YearlyHrvReport report;  
772 - static const _height = 180.0; 677 +class _ReportTitle extends StatelessWidget {
  678 + const _ReportTitle({required this.title, this.trailingInset = 0});
773 679
774 - @override  
775 - Widget build(BuildContext context) {  
776 - final segments = [  
777 - for (final level in HrvStressLevel.values)  
778 - if (report.countFor(level) > 0)  
779 - HrvDistributionBarSegment(  
780 - color: Color(level.colorValue),  
781 - flex: report.countFor(level),  
782 - ),  
783 - ];  
784 - return SizedBox(  
785 - width: 33,  
786 - height: _height,  
787 - child: HrvDistributionBar(  
788 - segments: report.hasData ? segments : const [],  
789 - backgroundColor: _grid,  
790 - radius: 7,  
791 - ),  
792 - );  
793 - }  
794 -} 680 + final String title;
  681 + final double trailingInset;
795 682
796 -class _Number extends StatelessWidget {  
797 - const _Number({required this.value, required this.unit});  
798 - final String value;  
799 - final String unit;  
800 @override 683 @override
801 Widget build(BuildContext context) => Row( 684 Widget build(BuildContext context) => Row(
802 - crossAxisAlignment: CrossAxisAlignment.end,  
803 - children: [  
804 - Text(value,  
805 - style: const TextStyle(  
806 - fontSize: 25, fontWeight: FontWeight.w600, height: 1)),  
807 - Padding(  
808 - padding: const EdgeInsets.only(left: 3, bottom: 2),  
809 - child: Text(unit, style: const TextStyle(fontSize: 10))),  
810 - ],  
811 - ); 685 + crossAxisAlignment: CrossAxisAlignment.center,
  686 + children: [
  687 + Text(title, style: _titleStyle),
  688 + if (!HealthReportSubjectScope.isViewingSelf(context)) ...[
  689 + const Spacer(),
  690 + Padding(
  691 + padding: EdgeInsets.only(right: trailingInset),
  692 + child: const FriendInteractClickView(
  693 + interactionType: FriendInteractionType.yearStressTrend,
  694 + ),
  695 + ),
  696 + ],
  697 + ],
  698 + );
812 } 699 }
813 700
814 class _Card extends StatelessWidget { 701 class _Card extends StatelessWidget {
@@ -820,12 +707,14 @@ class _Card extends StatelessWidget { @@ -820,12 +707,14 @@ class _Card extends StatelessWidget {
820 final EdgeInsetsGeometry padding; 707 final EdgeInsetsGeometry padding;
821 @override 708 @override
822 Widget build(BuildContext context) => Container( 709 Widget build(BuildContext context) => Container(
823 - width: double.infinity,  
824 - padding: padding,  
825 - decoration: BoxDecoration(  
826 - color: Colors.white, borderRadius: BorderRadius.circular(16)),  
827 - child: child,  
828 - ); 710 + width: double.infinity,
  711 + padding: padding,
  712 + decoration: BoxDecoration(
  713 + color: Colors.white,
  714 + borderRadius: BorderRadius.circular(16),
  715 + ),
  716 + child: child,
  717 + );
829 } 718 }
830 719
831 const _titleStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600); 720 const _titleStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600);
  1 +import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  2 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  3 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  4 +import 'package:doublefeel_flutter/r.dart';
  5 +import 'package:flutter/material.dart';
  6 +import 'package:get/get.dart';
  7 +
  8 +enum FriendInteractionAction { miss, stick, punch }
  9 +
  10 +/// Friend-interaction bottom sheet. It accepts independent callbacks so each
  11 +/// host page can handle the three actions without coupling to a controller.
  12 +class FriendInteractionDialog extends StatelessWidget {
  13 + const FriendInteractionDialog({
  14 + super.key,
  15 + required this.avatar,
  16 + required this.steps,
  17 + this.onAction,
  18 + this.onMiss,
  19 + this.onPoke,
  20 + this.onPunch,
  21 + });
  22 +
  23 + final ImageProvider? avatar;
  24 + final String steps;
  25 +
  26 + /// Compatibility callback for callers that prefer one typed action handler.
  27 + final ValueChanged<FriendInteractionAction>? onAction;
  28 + final VoidCallback? onMiss;
  29 + final VoidCallback? onPoke;
  30 + final VoidCallback? onPunch;
  31 +
  32 + static Future<T?> show<T>(
  33 + BuildContext context, {
  34 + required ImageProvider? avatar,
  35 + required String steps,
  36 + ValueChanged<FriendInteractionAction>? onAction,
  37 + VoidCallback? onMiss,
  38 + VoidCallback? onPoke,
  39 + VoidCallback? onPunch,
  40 + }) {
  41 + return showModalBottomSheet<T>(
  42 + context: context,
  43 + isScrollControlled: true,
  44 + backgroundColor: _surface,
  45 + barrierColor: Colors.black.withValues(alpha: .7),
  46 + shape: const RoundedRectangleBorder(
  47 + borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  48 + ),
  49 + builder: (_) => FriendInteractionDialog(
  50 + avatar: avatar,
  51 + steps: steps,
  52 + onAction: onAction,
  53 + onMiss: onMiss,
  54 + onPoke: onPoke,
  55 + onPunch: onPunch,
  56 + ),
  57 + );
  58 + }
  59 +
  60 + static const _surface = Color(0xFFF5F4FF);
  61 +
  62 + @override
  63 + Widget build(BuildContext context) {
  64 + final isVip =
  65 + Get.find<UserPreferencesStorage>().preferences.value.vipInfo?.isVip ==
  66 + true;
  67 + return Material(
  68 + color: Colors.transparent,
  69 + child: SafeArea(
  70 + top: false,
  71 + child: SizedBox(
  72 + height: 333,
  73 + child: Stack(
  74 + clipBehavior: Clip.none,
  75 + children: [
  76 + Positioned(
  77 + top: -77,
  78 + left: 22,
  79 + child: Image.asset(
  80 + 'assets/images/interact/ic_friend_interact.png',
  81 + width: 98,
  82 + height: 106,
  83 + ),
  84 + ),
  85 + Positioned(
  86 + top: -24,
  87 + left: 28,
  88 + child: Transform.rotate(
  89 + angle: -0.08,
  90 + child: Container(
  91 + padding: const EdgeInsets.symmetric(
  92 + horizontal: 18,
  93 + vertical: 8,
  94 + ),
  95 + decoration: BoxDecoration(
  96 + color: const Color(0xFFA084EF),
  97 + borderRadius: BorderRadius.circular(12),
  98 + ),
  99 + child: Text(
  100 + context.l10n.interactActionSheetInvitation,
  101 + style: const TextStyle(
  102 + color: Colors.white,
  103 + fontSize: 18,
  104 + fontWeight: FontWeight.w600,
  105 + ),
  106 + ),
  107 + ),
  108 + ),
  109 + ),
  110 + Positioned(
  111 + top: 17,
  112 + right: 28,
  113 + child: IconButton(
  114 + onPressed: () => Navigator.of(context).pop(),
  115 + icon: const Icon(Icons.close, color: Color(0xFF845EEE)),
  116 + iconSize: 20,
  117 + padding: EdgeInsets.zero,
  118 + constraints: const BoxConstraints.tightFor(
  119 + width: 20,
  120 + height: 20,
  121 + ),
  122 + ),
  123 + ),
  124 + Positioned(top: 42, left: 0, right: 0, child: _buildAvatar()),
  125 + Positioned(
  126 + top: 143,
  127 + left: 0,
  128 + right: 0,
  129 + child: Text(
  130 + context.l10n.interactTodaySteps,
  131 + textAlign: TextAlign.center,
  132 + style: const TextStyle(
  133 + color: Color(0xFF0F0F11),
  134 + fontSize: 16,
  135 + ),
  136 + ),
  137 + ),
  138 + Positioned(
  139 + top: 163,
  140 + left: 0,
  141 + right: 0,
  142 + child: RichText(
  143 + textAlign: TextAlign.center,
  144 + text: TextSpan(
  145 + children: [
  146 + TextSpan(
  147 + text: steps,
  148 + style: const TextStyle(
  149 + color: Color(0xFF845EEE),
  150 + fontSize: 28,
  151 + fontWeight: FontWeight.w600,
  152 + ),
  153 + ),
  154 + TextSpan(
  155 + text: context.l10n.interactStepsUnit,
  156 + style: const TextStyle(
  157 + color: Color(0xFF845EEE),
  158 + fontSize: 16,
  159 + ),
  160 + ),
  161 + ],
  162 + ),
  163 + ),
  164 + ),
  165 + Positioned(
  166 + top: 213,
  167 + left: 0,
  168 + right: 0,
  169 + child: FriendInteractActionButtonsView(
  170 + isVip: isVip,
  171 + onMiss: _handleMiss,
  172 + onPoke: _handlePoke,
  173 + onPunch: _handlePunch,
  174 + ),
  175 + ),
  176 + ],
  177 + ),
  178 + ),
  179 + ),
  180 + );
  181 + }
  182 +
  183 + Widget _buildAvatar() => Center(
  184 + child: Container(
  185 + width: 90,
  186 + height: 90,
  187 + clipBehavior: Clip.antiAlias,
  188 + decoration: BoxDecoration(
  189 + shape: BoxShape.circle,
  190 + border: Border.all(color: Colors.white, width: 2),
  191 + ),
  192 + child: avatar == null
  193 + ? const Icon(Icons.person, color: Color(0xFF908B91))
  194 + : Image(image: avatar!, fit: BoxFit.cover),
  195 + ),
  196 + );
  197 +
  198 + void _handleMiss() {
  199 + onAction?.call(FriendInteractionAction.miss);
  200 + onMiss?.call();
  201 + }
  202 +
  203 + void _handlePoke() {
  204 + onAction?.call(FriendInteractionAction.stick);
  205 + onPoke?.call();
  206 + }
  207 +
  208 + void _handlePunch() {
  209 + onAction?.call(FriendInteractionAction.punch);
  210 + onPunch?.call();
  211 + }
  212 +}
  213 +
  214 +/// Reusable three-action group for any friend-interaction entry point.
  215 +///
  216 +/// Miss and Punch are Pro actions. Their callbacks can therefore open a
  217 +/// membership flow, while Poke can be handled directly by the host page.
  218 +class FriendInteractActionButtonsView extends StatelessWidget {
  219 + const FriendInteractActionButtonsView({
  220 + super.key,
  221 + required this.isVip,
  222 + this.onMiss,
  223 + this.onPoke,
  224 + this.onPunch,
  225 + });
  226 +
  227 + final bool isVip;
  228 + final VoidCallback? onMiss;
  229 + final VoidCallback? onPoke;
  230 + final VoidCallback? onPunch;
  231 +
  232 + @override
  233 + Widget build(BuildContext context) => Row(
  234 + mainAxisAlignment: MainAxisAlignment.center,
  235 + crossAxisAlignment: CrossAxisAlignment.end,
  236 + children: [
  237 + _FriendInteractActionButton(
  238 + label: context.l10n.interactActionMiss,
  239 + iconAsset: 'assets/images/interact/ic_friend_interact_miss.png',
  240 + showProBadge: !isVip,
  241 + onTap: isVip ? onMiss : _openPurchase,
  242 + ),
  243 + const SizedBox(width: 12),
  244 + _FriendInteractActionButton(
  245 + label: context.l10n.interactActionStick,
  246 + iconAsset: 'assets/images/interact/ic_friend_interact_poke.png',
  247 + isPrimary: true,
  248 + onTap: onPoke,
  249 + ),
  250 + const SizedBox(width: 12),
  251 + _FriendInteractActionButton(
  252 + label: context.l10n.interactActionPunch,
  253 + iconAsset: 'assets/images/interact/ic_friend_interact_punch.png',
  254 + showProBadge: !isVip,
  255 + onTap: isVip ? onPunch : _openPurchase,
  256 + ),
  257 + ],
  258 + );
  259 +
  260 + void _openPurchase() => Get.toNamed(Routes.PURCHASE);
  261 +}
  262 +
  263 +class _FriendInteractActionButton extends StatelessWidget {
  264 + const _FriendInteractActionButton({
  265 + required this.label,
  266 + required this.iconAsset,
  267 + required this.onTap,
  268 + this.isPrimary = false,
  269 + this.showProBadge = false,
  270 + });
  271 +
  272 + final String label;
  273 + final String iconAsset;
  274 + final VoidCallback? onTap;
  275 + final bool isPrimary;
  276 + final bool showProBadge;
  277 +
  278 + @override
  279 + Widget build(BuildContext context) {
  280 + final iconSize = isPrimary ? 68.0 : 52.0;
  281 + final buttonSize = isPrimary ? const Size(116, 78) : const Size(90, 56);
  282 + final buttonTop = isPrimary ? 34.0 : 30.0;
  283 +
  284 + return SizedBox(
  285 + width: buttonSize.width,
  286 + height: isPrimary ? 112 : 93,
  287 + child: Stack(
  288 + clipBehavior: Clip.none,
  289 + alignment: Alignment.topCenter,
  290 + children: [
  291 + Positioned(
  292 + top: buttonTop,
  293 + child: Material(
  294 + color: Colors.transparent,
  295 + child: InkWell(
  296 + onTap: onTap,
  297 + borderRadius: BorderRadius.circular(isPrimary ? 24 : 16),
  298 + child: Ink(
  299 + width: buttonSize.width,
  300 + height: buttonSize.height,
  301 + decoration: BoxDecoration(
  302 + color: const Color(0xFF896CDC),
  303 + borderRadius: BorderRadius.circular(isPrimary ? 24 : 16),
  304 + ),
  305 + child: Align(
  306 + alignment: Alignment.bottomCenter,
  307 + child: Padding(
  308 + padding: EdgeInsets.only(bottom: isPrimary ? 12 : 9),
  309 + child: Text(
  310 + label,
  311 + style: TextStyle(
  312 + color: Colors.white,
  313 + fontSize: isPrimary ? 20 : 16,
  314 + fontWeight: FontWeight.w600,
  315 + height: 1,
  316 + ),
  317 + ),
  318 + ),
  319 + ),
  320 + ),
  321 + ),
  322 + ),
  323 + ),
  324 + Image.asset(iconAsset, width: iconSize, height: iconSize),
  325 + if (showProBadge)
  326 + Positioned(
  327 + left: 21,
  328 + top: 43,
  329 + child: Image.asset(
  330 + R.assetsImagesTrendProIcon,
  331 + width: 46,
  332 + height: 13,
  333 + fit: BoxFit.contain,
  334 + ),
  335 + ),
  336 + ],
  337 + ),
  338 + );
  339 + }
  340 +}
  1 +# PK 页面
  2 +
  3 +- `PkView` 是独立内容 View,可嵌入主页或单独放入 Scaffold。传入 `PkData`、`onSwitchFriend`、`onBack`、`onRefresh` 和可选 `onPoke`
  4 +- `HomePkView` / `PkController` 管理主页入口与好友选择,复用趋势页好友弹层(`showSelf: false`)。默认不改变趋势页的选择。
  5 +- `PkData.result` 根据双方积分决定胜、负、平;`monthlyResult` 根据胜利天数独立决定。缺失值不视为平局,月历缺失日期显示空圈。
  6 +- 已接入 `HealthApi.getCurrentMonthPkData`:绑定另一半的双方积分、步数、活动消耗、站立小时数、胜利天数及每日积分。左侧映射 `pair_user_*`,右侧映射 `user_*`;默认选中另一半。当前接口没有好友参数,普通好友只显示好友接口提供的健康数据,不套用另一半的战绩。
  7 +- 请求结果按绑定对象及加载日期隔离,月历过滤跨月、未来及不完整记录。站立时间沿用现有活动报告的小时单位。
  8 +- `onPoke` 暂未接入:现有互动接口只有绑定双方通用动作,尚未确认 PK 数据互动参数及任意好友的目标参数,主页按钮仍禁用。
  9 +- Figma 导出图片保存在 `assets/images/pk/`,不依赖临时远程资源。`preview_friend` / `preview_self` 仅供设计测试使用。
  10 +
  11 +设计状态测试:`flutter test test/app/modules/pk/pk_view_test.dart`
  12 +可选设置 `PK_FONT_PATH` 为本机中文字体文件、`PK_CAPTURE_DIR` 为截图目录,并加 `--update-goldens`,导出三种状态的预览图。
  1 +import 'dart:async';
  2 +import 'package:flutter/material.dart';
  3 +import 'package:get/get.dart';
  4 +import '../../../../core/network/api/friend_api.dart';
  5 +import '../../../../core/result/app_result.dart';
  6 +import '../../../../data/local/user_preferences_storage.dart';
  7 +import '../../../../data/models/friend/friend_models.dart';
  8 +import '../../home/widgets/trend/trend_friend_select_bottom_sheet.dart';
  9 +import '../models/pk_data.dart';
  10 +import '../models/pk_data_mapper.dart';
  11 +import '../../../../core/network/api/health_api.dart';
  12 +import '../../../../data/models/health/health_models.dart';
  13 +
  14 +class PkController extends GetxController {
  15 + PkController(this._friendApi, this._preferences, this._healthApi);
  16 + final HealthApi _healthApi;
  17 + final pkLoadFailed = false.obs;
  18 + final _partnerPk = Rxn<PkCurrentMonthData>();
  19 + int? _pkPartnerId;
  20 + DateTime? _pkLoadedAt;
  21 + final FriendApi _friendApi;
  22 + final UserPreferencesStorage _preferences;
  23 + final friends = <FriendItem>[].obs;
  24 + final selectedFriend = Rxn<FriendItem>();
  25 + final selfHealth = Rxn<FriendHealthData>();
  26 + final isLoading = false.obs;
  27 + final loadFailed = false.obs;
  28 +
  29 + bool get isPartnerSelected =>
  30 + selectedFriend.value?.friendUserId != null &&
  31 + selectedFriend.value?.friendUserId ==
  32 + _preferences.preferences.value.partnerUserInfo?.id;
  33 +
  34 + PkData get data {
  35 + final friend = selectedFriend.value;
  36 + final me = _preferences.preferences.value.meUserInfo;
  37 + final remark = friend?.remarkName?.trim();
  38 + final base = PkData(
  39 + month: DateTime.now(),
  40 + left: PkPlayer(
  41 + name: remark?.isNotEmpty == true
  42 + ? remark!
  43 + : friend?.friendNickname ?? '好友',
  44 + subtitle: remark?.isNotEmpty == true
  45 + ? '(${friend?.friendNickname ?? '好友'})'
  46 + : '',
  47 + avatarUrl: friend?.avatar,
  48 + steps: friend?.healthData?.totalSteps,
  49 + calories: friend?.healthData?.totalMove,
  50 + ),
  51 + right: PkPlayer(
  52 + name: me?.nickname ?? '我',
  53 + subtitle: '(我)',
  54 + avatarUrl: me?.avatar,
  55 + steps: selfHealth.value?.totalSteps,
  56 + calories: selfHealth.value?.totalMove,
  57 + ),
  58 + );
  59 + final now = DateTime.now();
  60 + final loaded = _pkLoadedAt;
  61 + final pk = _partnerPk.value;
  62 + if (pk != null &&
  63 + _pkPartnerId != null &&
  64 + friend?.friendUserId == _pkPartnerId &&
  65 + _preferences.preferences.value.partnerUserInfo?.id == _pkPartnerId &&
  66 + loaded?.year == now.year &&
  67 + loaded?.month == now.month &&
  68 + loaded?.day == now.day) {
  69 + return mapPartnerPkData(
  70 + pk,
  71 + partner: base.left,
  72 + self: base.right,
  73 + now: now,
  74 + );
  75 + }
  76 + return base;
  77 + }
  78 +
  79 + Future<void> refreshData() async {
  80 + if (isLoading.value) return;
  81 + isLoading.value = true;
  82 + try {
  83 + final partner = _preferences.preferences.value.partnerUserInfo;
  84 + final requestedAt = DateTime.now();
  85 + final (result, pkResult) = await (
  86 + _friendApi.friendList(true, withSelfHealthData: true),
  87 + partner?.id == null
  88 + ? Future<AppResult<PkCurrentMonthData>?>.value(null)
  89 + : _healthApi.getCurrentMonthPkData(),
  90 + ).wait;
  91 + if (isClosed) return;
  92 + switch (result) {
  93 + case AppSuccess(:final data):
  94 + final id = selectedFriend.value?.friendUserId;
  95 + friends.assignAll(
  96 + data.list.where((friend) => friend.friendUserId != null),
  97 + );
  98 + if (partner?.id != null &&
  99 + !friends.any((friend) => friend.friendUserId == partner!.id)) {
  100 + friends.insert(
  101 + 0,
  102 + FriendItem(
  103 + friendUserId: partner!.id,
  104 + friendNickname: partner.nickname,
  105 + avatar: partner.avatar,
  106 + ),
  107 + );
  108 + }
  109 + selectedFriend.value =
  110 + friends.firstWhereOrNull((friend) => friend.friendUserId == id) ??
  111 + friends.firstWhereOrNull(
  112 + (friend) => friend.friendUserId == partner?.id,
  113 + ) ??
  114 + friends.firstOrNull;
  115 + selfHealth.value = data.healthData;
  116 + loadFailed.value = false;
  117 + case AppFailure():
  118 + loadFailed.value = true;
  119 + }
  120 + if (selectedFriend.value == null &&
  121 + partner?.id != null &&
  122 + _preferences.preferences.value.partnerUserInfo?.id == partner?.id) {
  123 + final item = FriendItem(
  124 + friendUserId: partner!.id,
  125 + friendNickname: partner.nickname,
  126 + avatar: partner.avatar,
  127 + );
  128 + if (!friends.any((friend) => friend.friendUserId == partner.id)) {
  129 + friends.insert(0, item);
  130 + }
  131 + selectedFriend.value = item;
  132 + }
  133 + if (_preferences.preferences.value.partnerUserInfo?.id == partner?.id) {
  134 + switch (pkResult) {
  135 + case AppSuccess<PkCurrentMonthData>(:final data):
  136 + _pkPartnerId = partner?.id;
  137 + _pkLoadedAt = requestedAt;
  138 + _partnerPk.value = data;
  139 + pkLoadFailed.value = false;
  140 + case AppFailure<PkCurrentMonthData>():
  141 + pkLoadFailed.value = true;
  142 + case null:
  143 + _partnerPk.value = null;
  144 + _pkPartnerId = null;
  145 + pkLoadFailed.value = false;
  146 + }
  147 + }
  148 + } finally {
  149 + if (!isClosed) isLoading.value = false;
  150 + }
  151 + }
  152 +
  153 + void showFriendListBottomSheet() {
  154 + unawaited(refreshData());
  155 + if (friends.isEmpty) return;
  156 + Get.bottomSheet(
  157 + DraggableScrollableSheet(
  158 + maxChildSize: .6,
  159 + initialChildSize: .6,
  160 + expand: false,
  161 + snap: true,
  162 + builder: (context, scrollController) => TrendFriendSelectBottomSheet(
  163 + scrollController: scrollController,
  164 + friendsList: friends,
  165 + selectedFriend: selectedFriend,
  166 + onSelectSelf: () {},
  167 + showSelf: false,
  168 + onSelectFriend: (friend) => selectedFriend.value = friend,
  169 + ),
  170 + ),
  171 + barrierColor: Colors.black.withValues(alpha: .7),
  172 + enableDrag: true,
  173 + isScrollControlled: true,
  174 + persistent: false,
  175 + );
  176 + }
  177 +}
  1 +/// PK results are supplied by the caller; missing scores are not a draw.
  2 +enum PkResult { win, loss, draw }
  3 +
  4 +class PkPlayer {
  5 + const PkPlayer({
  6 + required this.name,
  7 + this.subtitle = '',
  8 + this.avatarUrl,
  9 + this.avatarAsset,
  10 + this.score,
  11 + this.steps,
  12 + this.calories,
  13 + this.standingHours,
  14 + this.winningDays,
  15 + });
  16 + final String name;
  17 + final String subtitle;
  18 + final String? avatarUrl;
  19 + final String? avatarAsset;
  20 + final int? score, steps, calories, winningDays;
  21 + final double? standingHours;
  22 +}
  23 +
  24 +class PkDay {
  25 + const PkDay(this.leftScore, this.rightScore);
  26 + final int leftScore, rightScore;
  27 + double get leftFraction =>
  28 + leftScore + rightScore == 0 ? 0.5 : leftScore / (leftScore + rightScore);
  29 +}
  30 +
  31 +class PkData {
  32 + const PkData({
  33 + required this.left,
  34 + required this.right,
  35 + required this.month,
  36 + this.days = const {},
  37 + });
  38 + final PkPlayer left, right;
  39 + final DateTime month;
  40 +
  41 + /// Day of month -> result. Missing days render as unrecorded.
  42 + final Map<int, PkDay> days;
  43 + PkResult? get result => compare(left.score, right.score);
  44 + PkResult? get monthlyResult => compare(left.winningDays, right.winningDays);
  45 + static PkResult? compare(int? left, int? right) {
  46 + if (left == null || right == null) return null;
  47 + return left > right
  48 + ? PkResult.win
  49 + : left < right
  50 + ? PkResult.loss
  51 + : PkResult.draw;
  52 + }
  53 +}
  1 +import '../../../../data/models/health/health_models.dart';
  2 +import 'pk_data.dart';
  3 +
  4 +/// The existing PK endpoint compares the signed-in user with their partner.
  5 +/// Its partner fields belong on the left, user fields on the right.
  6 +PkData mapPartnerPkData(
  7 + PkCurrentMonthData source, {
  8 + required PkPlayer partner,
  9 + required PkPlayer self,
  10 + required DateTime now,
  11 +}) {
  12 + final todayDate = pkRecordDate(source.todayRecord?.date);
  13 + final today = todayDate == null ||
  14 + (todayDate.year == now.year &&
  15 + todayDate.month == now.month &&
  16 + todayDate.day == now.day)
  17 + ? source.todayRecord
  18 + : null;
  19 + final days = <int, PkDay>{};
  20 + for (final record in source.monthRecordList ?? const <PkRecord>[]) {
  21 + final date = pkRecordDate(record.date);
  22 + if (date == null ||
  23 + date.year != now.year ||
  24 + date.month != now.month ||
  25 + date.day > now.day ||
  26 + record.partnerScore == null ||
  27 + record.userScore == null) {
  28 + continue;
  29 + }
  30 + days[date.day] = PkDay(record.partnerScore!, record.userScore!);
  31 + }
  32 + if (today?.partnerScore != null && today?.userScore != null) {
  33 + days[now.day] = PkDay(today!.partnerScore!, today.userScore!);
  34 + }
  35 + return PkData(
  36 + month: DateTime(now.year, now.month),
  37 + days: days,
  38 + left: PkPlayer(
  39 + name: partner.name,
  40 + subtitle: partner.subtitle,
  41 + avatarUrl: partner.avatarUrl,
  42 + avatarAsset: partner.avatarAsset,
  43 + score: today?.partnerScore,
  44 + steps: today?.partnerSteps ?? partner.steps,
  45 + calories: today?.partnerMove ?? partner.calories,
  46 + standingHours: today?.partnerStand?.toDouble(),
  47 + winningDays: source.partnerWinAmount,
  48 + ),
  49 + right: PkPlayer(
  50 + name: self.name,
  51 + subtitle: self.subtitle,
  52 + avatarUrl: self.avatarUrl,
  53 + avatarAsset: self.avatarAsset,
  54 + score: today?.userScore,
  55 + steps: today?.userSteps ?? self.steps,
  56 + calories: today?.userMove ?? self.calories,
  57 + standingHours: today?.userStand?.toDouble(),
  58 + winningDays: source.userWinAmount,
  59 + ),
  60 + );
  61 +}
  62 +
  63 +/// Accept the date encodings used by health records: yyyyMMdd or Unix time.
  64 +DateTime? pkRecordDate(int? value) {
  65 + if (value == null || value <= 0) return null;
  66 + if (value >= 10000101 && value <= 99991231) {
  67 + final year = value ~/ 10000, month = value ~/ 100 % 100, day = value % 100;
  68 + final date = DateTime(year, month, day);
  69 + return date.year == year && date.month == month && date.day == day
  70 + ? date
  71 + : null;
  72 + }
  73 + if (value < 1000000000 || value > 8640000000000000) return null;
  74 + return DateTime.fromMillisecondsSinceEpoch(
  75 + value >= 1000000000000 ? value : value * 1000,
  76 + );
  77 +}