Commit cb292aa9b069aa8725afeb5b4642649c555f2cc9

Authored by 刘宏哲
1 parent d2045d29

feat(app): change productChannel

5.39 KB | W: | H:

4.54 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin
@@ -3,11 +3,9 @@ import 'dart:ui' as ui; @@ -3,11 +3,9 @@ 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';  
7 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
8 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; 7 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
9 8
10 -import '../../../widget/friend_interact_click_view.dart';  
11 import '../../report_common/widgets/health_report_subject_scope.dart'; 9 import '../../report_common/widgets/health_report_subject_scope.dart';
12 import '../models/activity_burn_report_models.dart'; 10 import '../models/activity_burn_report_models.dart';
13 import 'activity_burn_trend_lines.dart'; 11 import 'activity_burn_trend_lines.dart';
@@ -38,79 +36,75 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -38,79 +36,75 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
38 Widget build(BuildContext context) { 36 Widget build(BuildContext context) {
39 final start = _startTime; 37 final start = _startTime;
40 final axis = _HeartRateAxis.fromSummary(summary, start); 38 final axis = _HeartRateAxis.fromSummary(summary, start);
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 - ), 39 + return Container(
  40 + height: 273,
  41 + padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
  42 + decoration: BoxDecoration(
  43 + color: Colors.white,
  44 + borderRadius: BorderRadius.circular(16),
  45 + ),
  46 + child: Column(
  47 + crossAxisAlignment: CrossAxisAlignment.start,
  48 + children: [
  49 + Text(
  50 + HealthReportSubjectScope.titleOf(
  51 + context,
  52 + context.l10n.activityHeartRateZone,
65 ), 53 ),
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: [ 54 + style: TextStyle(
  55 + color: _h1,
  56 + fontSize: 16,
  57 + fontWeight: FontWeight.w600,
  58 + height: 1.2,
  59 + ),
  60 + ),
  61 + const SizedBox(height: 20),
  62 + SizedBox(
  63 + height: _chartHeight,
  64 + child: LayoutBuilder(
  65 + builder: (_, __) {
  66 + return Stack(
  67 + fit: StackFit.expand,
  68 + alignment: Alignment.center,
  69 + children: [
  70 + Positioned(
  71 + left: 0,
  72 + right: 6,
  73 + top: 0,
  74 + bottom: 20,
  75 + child: ActivityBurnHorizontalGridLines(
  76 + minY: axis.minY,
  77 + maxY: axis.chartMaxY,
  78 + values: axis.ticks,
  79 + ),
  80 + ),
  81 + Positioned.fill(
  82 + top: 0,
  83 + child: LineChart(_chartData(context)),
  84 + ),
  85 + if (!summary.hasData)
75 Positioned( 86 Positioned(
76 left: 0, 87 left: 0,
77 right: 6, 88 right: 6,
78 top: 0, 89 top: 0,
79 bottom: 20, 90 bottom: 20,
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 - ), 91 + child: Center(
  92 + child: Text(
  93 + context.l10n.hrvTrendAwaitingData,
  94 + style: const TextStyle(
  95 + color: Color(0xFFA1A0A5),
  96 + fontSize: 12,
  97 + fontWeight: FontWeight.w400,
104 ), 98 ),
105 ), 99 ),
106 ), 100 ),
107 - ],  
108 - );  
109 - },  
110 - ), 101 + ),
  102 + ],
  103 + );
  104 + },
111 ), 105 ),
112 - ],  
113 - ), 106 + ),
  107 + ],
114 ), 108 ),
115 ); 109 );
116 } 110 }
@@ -4,6 +4,7 @@ import 'dart:math' as math; @@ -4,6 +4,7 @@ import 'dart:math' as math;
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/data/models/interaction/interaction_models.dart';
  7 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
7 import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; 8 import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
8 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 9 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
9 10
@@ -491,6 +492,11 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -491,6 +492,11 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
491 return FriendInteractCardOverlay( 492 return FriendInteractCardOverlay(
492 showInteraction: !HealthReportSubjectScope.isViewingSelf(context), 493 showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
493 interactionType: FriendInteractionType.monthlyActivityBurn, 494 interactionType: FriendInteractionType.monthlyActivityBurn,
  495 + content: FriendInteractionContent.forType(
  496 + context,
  497 + FriendInteractionType.monthlyActivityBurn,
  498 + period: FriendInteractionPeriod.month(widget.report.monthStart),
  499 + ),
494 child: Container( 500 child: Container(
495 height: 344, 501 height: 344,
496 padding: const EdgeInsets.fromLTRB(20, 20, 14, 40), 502 padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
  1 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
  2 +import 'package:doublefeel_flutter/app/modules/report_common/widgets/health_report_subject_scope.dart';
  3 +import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
1 import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; 4 import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
2 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 5 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  6 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 7 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:flutter/material.dart'; 8 import 'package:flutter/material.dart';
5 9
@@ -7,10 +11,7 @@ import '../models/activity_burn_report_models.dart'; @@ -7,10 +11,7 @@ import '../models/activity_burn_report_models.dart';
7 import 'activity_burn_duration_text.dart'; 11 import 'activity_burn_duration_text.dart';
8 12
9 class ActivityBurnSummaryCard extends StatelessWidget { 13 class ActivityBurnSummaryCard extends StatelessWidget {
10 - const ActivityBurnSummaryCard({  
11 - super.key,  
12 - required this.report,  
13 - }); 14 + const ActivityBurnSummaryCard({super.key, required this.report});
14 15
15 final ActivityBurnReport? report; 16 final ActivityBurnReport? report;
16 17
@@ -25,50 +26,74 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -25,50 +26,74 @@ class ActivityBurnSummaryCard extends StatelessWidget {
25 context, 26 context,
26 report?.exerciseMinutes?.value, 27 report?.exerciseMinutes?.value,
27 ); 28 );
28 - final stand = activityBurnHoursText(  
29 - context,  
30 - report?.standHours?.value,  
31 - );  
32 - return Container(  
33 - height: 90,  
34 - padding: EdgeInsets.only(left: 20.w),  
35 - decoration: BoxDecoration(  
36 - color: Colors.white,  
37 - borderRadius: BorderRadius.circular(12),  
38 - ),  
39 - child: Row(  
40 - children: [  
41 - Expanded(  
42 - flex: 1,  
43 - child: _MetricColumn(  
44 - title: context.l10n.activityMove,  
45 - color: _active,  
46 - value: report?.activeEnergy?.value.toString() ?? '-',  
47 - unit: context.l10n.reportUnitKcal,  
48 - ), 29 + final stand = activityBurnHoursText(context, report?.standHours?.value);
  30 + return FriendInteractCardOverlay(
  31 + showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
  32 + interactionType: FriendInteractionType.dailyActivityBurn,
  33 + content: FriendInteractionContent.forType(
  34 + context,
  35 + FriendInteractionType.dailyActivityBurn,
  36 + period: FriendInteractionPeriod.day(report?.date ?? DateTime.now()),
  37 + metrics: [
  38 + FriendInteractionMetric(
  39 + value: report?.activeEnergy?.value.toString() ?? '-',
  40 + unit: context.l10n.reportUnitKcal,
49 ), 41 ),
50 - SizedBox(width: 13.w),  
51 - Expanded(  
52 - flex: 1,  
53 - child: _MetricColumn(  
54 - title: context.l10n.activityExercise,  
55 - color: isOhos() ? _ohosExercise : _exercise,  
56 - value: exercise.value,  
57 - unit: exercise.unit,  
58 - ), 42 + FriendInteractionMetric(
  43 + value: exercise.value,
  44 + unit: exercise.unit,
  45 + label: context.l10n.activityExercise,
59 ), 46 ),
60 - SizedBox(width: 13.w),  
61 - Expanded(  
62 - flex: 1,  
63 - child: _MetricColumn(  
64 - title: context.l10n.activityStand,  
65 - color: _stand,  
66 - value: stand.value,  
67 - unit: stand.unit,  
68 - ), 47 + FriendInteractionMetric(
  48 + value: stand.value,
  49 + unit: stand.unit,
  50 + label: context.l10n.activityStand,
69 ), 51 ),
70 ], 52 ],
71 ), 53 ),
  54 + child: Container(
  55 + height: 90,
  56 + // Reserve the upper-right area used by FriendInteractCardOverlay so
  57 + // the interaction entry cannot cover the stand metric.
  58 + padding: EdgeInsets.only(left: 20.w, right: 56.w),
  59 + decoration: BoxDecoration(
  60 + color: Colors.white,
  61 + borderRadius: BorderRadius.circular(12),
  62 + ),
  63 + child: Row(
  64 + children: [
  65 + Expanded(
  66 + flex: 1,
  67 + child: _MetricColumn(
  68 + title: context.l10n.activityMove,
  69 + color: _active,
  70 + value: report?.activeEnergy?.value.toString() ?? '-',
  71 + unit: context.l10n.reportUnitKcal,
  72 + ),
  73 + ),
  74 + SizedBox(width: 13.w),
  75 + Expanded(
  76 + flex: 1,
  77 + child: _MetricColumn(
  78 + title: context.l10n.activityExercise,
  79 + color: isOhos() ? _ohosExercise : _exercise,
  80 + value: exercise.value,
  81 + unit: exercise.unit,
  82 + ),
  83 + ),
  84 + SizedBox(width: 13.w),
  85 + Expanded(
  86 + flex: 1,
  87 + child: _MetricColumn(
  88 + title: context.l10n.activityStand,
  89 + color: _stand,
  90 + value: stand.value,
  91 + unit: stand.unit,
  92 + ),
  93 + ),
  94 + ],
  95 + ),
  96 + ),
72 ); 97 );
73 } 98 }
74 } 99 }
@@ -4,6 +4,7 @@ import 'dart:math' as math; @@ -4,6 +4,7 @@ import 'dart:math' as math;
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/data/models/interaction/interaction_models.dart';
  7 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
7 import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; 8 import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
8 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 9 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
9 10
@@ -417,6 +418,7 @@ class ActivityBurnTrendData { @@ -417,6 +418,7 @@ class ActivityBurnTrendData {
417 required this.activeEnergyComparisonPercent, 418 required this.activeEnergyComparisonPercent,
418 required this.isMonth, 419 required this.isMonth,
419 required this.barWidth, 420 required this.barWidth,
  421 + required this.period,
420 }); 422 });
421 423
422 factory ActivityBurnTrendData.week(WeeklyActivityBurnReport report) => 424 factory ActivityBurnTrendData.week(WeeklyActivityBurnReport report) =>
@@ -428,6 +430,7 @@ class ActivityBurnTrendData { @@ -428,6 +430,7 @@ class ActivityBurnTrendData {
428 activeEnergyComparisonPercent: report.activeEnergyComparisonPercent, 430 activeEnergyComparisonPercent: report.activeEnergyComparisonPercent,
429 isMonth: false, 431 isMonth: false,
430 barWidth: 16, 432 barWidth: 16,
  433 + period: FriendInteractionPeriod.week(report.weekStart, report.weekEnd),
431 ); 434 );
432 435
433 factory ActivityBurnTrendData.month(MonthlyActivityBurnReport report) => 436 factory ActivityBurnTrendData.month(MonthlyActivityBurnReport report) =>
@@ -439,6 +442,7 @@ class ActivityBurnTrendData { @@ -439,6 +442,7 @@ class ActivityBurnTrendData {
439 activeEnergyComparisonPercent: report.activeEnergyComparisonPercent, 442 activeEnergyComparisonPercent: report.activeEnergyComparisonPercent,
440 isMonth: true, 443 isMonth: true,
441 barWidth: 4, 444 barWidth: 4,
  445 + period: FriendInteractionPeriod.month(report.monthStart),
442 ); 446 );
443 447
444 final List<ActivityBurnReport> days; 448 final List<ActivityBurnReport> days;
@@ -448,6 +452,7 @@ class ActivityBurnTrendData { @@ -448,6 +452,7 @@ class ActivityBurnTrendData {
448 final int? activeEnergyComparisonPercent; 452 final int? activeEnergyComparisonPercent;
449 final bool isMonth; 453 final bool isMonth;
450 final double barWidth; 454 final double barWidth;
  455 + final FriendInteractionPeriod period;
451 } 456 }
452 457
453 class ActivityBurnEnergyTrendCard extends StatefulWidget { 458 class ActivityBurnEnergyTrendCard extends StatefulWidget {
@@ -510,6 +515,11 @@ class _ActivityBurnEnergyTrendCardState @@ -510,6 +515,11 @@ class _ActivityBurnEnergyTrendCardState
510 return FriendInteractCardOverlay( 515 return FriendInteractCardOverlay(
511 showInteraction: !HealthReportSubjectScope.isViewingSelf(context), 516 showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
512 interactionType: FriendInteractionType.weeklyActivityBurn, 517 interactionType: FriendInteractionType.weeklyActivityBurn,
  518 + content: FriendInteractionContent.forType(
  519 + context,
  520 + FriendInteractionType.weeklyActivityBurn,
  521 + period: widget.report.period,
  522 + ),
513 child: Container( 523 child: Container(
514 height: 344, 524 height: 344,
515 padding: const EdgeInsets.fromLTRB(20, 20, 14, 32), 525 padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
@@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/core/theme/app_theme.dart'; @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/core/theme/app_theme.dart';
2 import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart'; 2 import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
3 import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart'; 3 import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
4 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart'; 4 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
  5 +import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
6 import 'package:flutter/material.dart'; 7 import 'package:flutter/material.dart';
7 import 'package:get/get.dart'; 8 import 'package:get/get.dart';
@@ -37,6 +38,7 @@ class TodayHrvNumberCard extends StatelessWidget { @@ -37,6 +38,7 @@ class TodayHrvNumberCard extends StatelessWidget {
37 ? HomeInteractRouteArguments.averageHrv( 38 ? HomeInteractRouteArguments.averageHrv(
38 context: context, 39 context: context,
39 friendUserId: controller.friendUserId, 40 friendUserId: controller.friendUserId,
  41 + friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
40 date: controller.selectedDate.value, 42 date: controller.selectedDate.value,
41 value: hrvAvg, 43 value: hrvAvg,
42 ) 44 )
@@ -47,6 +49,7 @@ class TodayHrvNumberCard extends StatelessWidget { @@ -47,6 +49,7 @@ class TodayHrvNumberCard extends StatelessWidget {
47 ? HomeInteractRouteArguments.restingHeartRate( 49 ? HomeInteractRouteArguments.restingHeartRate(
48 context: context, 50 context: context,
49 friendUserId: controller.friendUserId, 51 friendUserId: controller.friendUserId,
  52 + friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
50 date: controller.selectedDate.value, 53 date: controller.selectedDate.value,
51 value: restingHeartRate, 54 value: restingHeartRate,
52 ) 55 )
@@ -105,6 +108,7 @@ class _NumberItem extends StatelessWidget { @@ -105,6 +108,7 @@ class _NumberItem extends StatelessWidget {
105 108
106 @override 109 @override
107 Widget build(BuildContext context) { 110 Widget build(BuildContext context) {
  111 + final entry = interactionArguments;
108 return Padding( 112 return Padding(
109 padding: const EdgeInsets.only(left: 20, right: 2), 113 padding: const EdgeInsets.only(left: 20, right: 2),
110 child: Column( 114 child: Column(
@@ -152,16 +156,29 @@ class _NumberItem extends StatelessWidget { @@ -152,16 +156,29 @@ class _NumberItem extends StatelessWidget {
152 ), 156 ),
153 if (interactionEnabled != null) ...[ 157 if (interactionEnabled != null) ...[
154 const SizedBox(width: 8), 158 const SizedBox(width: 8),
155 - FriendInteractionButton(  
156 - enabled: interactionEnabled!,  
157 - arguments: interactionArguments,  
158 - padding: const EdgeInsets.only(  
159 - left: 8,  
160 - right: 20,  
161 - top: 8,  
162 - bottom: 0, 159 + if (entry == null)
  160 + const FriendInteractClickView(
  161 + enabled: false,
  162 + padding: EdgeInsets.only(
  163 + left: 8,
  164 + right: 20,
  165 + top: 8,
  166 + bottom: 0,
  167 + ),
  168 + )
  169 + else
  170 + FriendInteractClickView(
  171 + enabled: interactionEnabled!,
  172 + interactionContext:
  173 + FriendInteractionButton.interactionContextFor(entry),
  174 + content: FriendInteractionButton.contentFor(context, entry),
  175 + padding: const EdgeInsets.only(
  176 + left: 8,
  177 + right: 20,
  178 + top: 8,
  179 + bottom: 0,
  180 + ),
163 ), 181 ),
164 - ),  
165 ], 182 ],
166 ], 183 ],
167 ), 184 ),
@@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
2 import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart'; 2 import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
3 import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart'; 3 import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
4 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart'; 4 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
  5 +import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
5 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/arc_progress_widget.dart'; 6 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/arc_progress_widget.dart';
6 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart'; 7 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart';
7 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 8 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
@@ -63,6 +64,7 @@ class TodaySleepCard extends StatelessWidget { @@ -63,6 +64,7 @@ class TodaySleepCard extends StatelessWidget {
63 ? HomeInteractRouteArguments.sleep( 64 ? HomeInteractRouteArguments.sleep(
64 context: context, 65 context: context,
65 friendUserId: controller.friendUserId, 66 friendUserId: controller.friendUserId,
  67 + friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
66 date: controller.selectedDate.value, 68 date: controller.selectedDate.value,
67 durationSeconds: sleepDuration, 69 durationSeconds: sleepDuration,
68 qualityScore: healthData?.sleepScore, 70 qualityScore: healthData?.sleepScore,
@@ -338,6 +340,7 @@ class TodayActivityCard extends StatelessWidget { @@ -338,6 +340,7 @@ class TodayActivityCard extends StatelessWidget {
338 ? HomeInteractRouteArguments.fitness( 340 ? HomeInteractRouteArguments.fitness(
339 context: context, 341 context: context,
340 friendUserId: controller.friendUserId, 342 friendUserId: controller.friendUserId,
  343 + friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
341 date: controller.selectedDate.value, 344 date: controller.selectedDate.value,
342 activeCalories: activity, 345 activeCalories: activity,
343 exerciseSeconds: exercise, 346 exerciseSeconds: exercise,
@@ -482,6 +485,7 @@ class _TodaySummaryCard extends StatelessWidget { @@ -482,6 +485,7 @@ class _TodaySummaryCard extends StatelessWidget {
482 @override 485 @override
483 Widget build(BuildContext context) { 486 Widget build(BuildContext context) {
484 final colors = context.colors; 487 final colors = context.colors;
  488 + final entry = interactionArguments;
485 489
486 return Container( 490 return Container(
487 margin: const EdgeInsets.only(bottom: 8), 491 margin: const EdgeInsets.only(bottom: 8),
@@ -513,10 +517,15 @@ class _TodaySummaryCard extends StatelessWidget { @@ -513,10 +517,15 @@ class _TodaySummaryCard extends StatelessWidget {
513 ), 517 ),
514 const Spacer(), 518 const Spacer(),
515 if (interactionEnabled != null) ...[ 519 if (interactionEnabled != null) ...[
516 - FriendInteractionButton(  
517 - enabled: interactionEnabled!,  
518 - arguments: interactionArguments,  
519 - ), 520 + if (entry == null)
  521 + const FriendInteractClickView(enabled: false)
  522 + else
  523 + FriendInteractClickView(
  524 + enabled: interactionEnabled!,
  525 + interactionContext:
  526 + FriendInteractionButton.interactionContextFor(entry),
  527 + content: FriendInteractionButton.contentFor(context, entry),
  528 + ),
520 ], 529 ],
521 ], 530 ],
522 ), 531 ),
1 import 'dart:async'; 1 import 'dart:async';
2 2
3 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 3 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  4 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 import 'package:fl_chart/fl_chart.dart'; 6 import 'package:fl_chart/fl_chart.dart';
6 import 'package:flutter/material.dart'; 7 import 'package:flutter/material.dart';
@@ -88,11 +89,29 @@ class _HrvPeriodReportView extends StatelessWidget { @@ -88,11 +89,29 @@ class _HrvPeriodReportView extends StatelessWidget {
88 89
89 @override 90 @override
90 Widget build(BuildContext context) { 91 Widget build(BuildContext context) {
  92 + final interactionType = isMonth
  93 + ? FriendInteractionType.monthStressTrend
  94 + : FriendInteractionType.weekStressTrend;
  95 + final interactionContent = FriendInteractionContent.forType(
  96 + context,
  97 + interactionType,
  98 + period: switch (report) {
  99 + WeeklyHrvReport(:final weekStart) => FriendInteractionPeriod.week(
  100 + weekStart,
  101 + weekStart.add(const Duration(days: 6)),
  102 + ),
  103 + MonthlyHrvReport(:final monthStart) => FriendInteractionPeriod.month(
  104 + monthStart,
  105 + ),
  106 + _ => null,
  107 + },
  108 + );
91 return Column( 109 return Column(
92 children: [ 110 children: [
93 _DailyStressCard( 111 _DailyStressCard(
94 report: report, 112 report: report,
95 isMonth: isMonth, 113 isMonth: isMonth,
  114 + interactionContent: interactionContent,
96 showExample: showExample, 115 showExample: showExample,
97 onTap: showExample ? onSubscribe : null, 116 onTap: showExample ? onSubscribe : null,
98 tooltipDismissDelayMs: tooltipDismissDelayMs, 117 tooltipDismissDelayMs: tooltipDismissDelayMs,
@@ -115,9 +134,8 @@ class _HrvPeriodReportView extends StatelessWidget { @@ -115,9 +134,8 @@ class _HrvPeriodReportView extends StatelessWidget {
115 trailing: HealthReportSubjectScope.isViewingSelf(context) 134 trailing: HealthReportSubjectScope.isViewingSelf(context)
116 ? null 135 ? null
117 : FriendInteractClickView( 136 : FriendInteractClickView(
118 - interactionType: isMonth  
119 - ? FriendInteractionType.monthStressTrend  
120 - : FriendInteractionType.weekStressTrend, 137 + interactionType: interactionType,
  138 + content: interactionContent,
121 ), 139 ),
122 footer: _ExtremeHrvSection(report: report), 140 footer: _ExtremeHrvSection(report: report),
123 onTap: showExample ? onSubscribe : null, 141 onTap: showExample ? onSubscribe : null,
@@ -131,6 +149,7 @@ class _DailyStressCard extends StatelessWidget { @@ -131,6 +149,7 @@ class _DailyStressCard extends StatelessWidget {
131 const _DailyStressCard({ 149 const _DailyStressCard({
132 required this.report, 150 required this.report,
133 required this.isMonth, 151 required this.isMonth,
  152 + required this.interactionContent,
134 required this.showExample, 153 required this.showExample,
135 this.onTap, 154 this.onTap,
136 required this.tooltipDismissDelayMs, 155 required this.tooltipDismissDelayMs,
@@ -138,6 +157,7 @@ class _DailyStressCard extends StatelessWidget { @@ -138,6 +157,7 @@ class _DailyStressCard extends StatelessWidget {
138 157
139 final HrvPeriodReport report; 158 final HrvPeriodReport report;
140 final bool isMonth; 159 final bool isMonth;
  160 + final FriendInteractionContent interactionContent;
141 final bool showExample; 161 final bool showExample;
142 final VoidCallback? onTap; 162 final VoidCallback? onTap;
143 final int tooltipDismissDelayMs; 163 final int tooltipDismissDelayMs;
@@ -157,6 +177,7 @@ class _DailyStressCard extends StatelessWidget { @@ -157,6 +177,7 @@ class _DailyStressCard extends StatelessWidget {
157 ), 177 ),
158 showExample: showExample, 178 showExample: showExample,
159 isMonth: isMonth, 179 isMonth: isMonth,
  180 + interactionContent: interactionContent,
160 ), 181 ),
161 const SizedBox(height: 18), 182 const SizedBox(height: 18),
162 SizedBox( 183 SizedBox(
@@ -894,11 +915,13 @@ class _ReportTitle extends StatelessWidget { @@ -894,11 +915,13 @@ class _ReportTitle extends StatelessWidget {
894 required this.title, 915 required this.title,
895 required this.showExample, 916 required this.showExample,
896 required this.isMonth, 917 required this.isMonth,
  918 + required this.interactionContent,
897 }); 919 });
898 920
899 final String title; 921 final String title;
900 final bool showExample; 922 final bool showExample;
901 final bool isMonth; 923 final bool isMonth;
  924 + final FriendInteractionContent interactionContent;
902 925
903 @override 926 @override
904 Widget build(BuildContext context) { 927 Widget build(BuildContext context) {
@@ -923,6 +946,7 @@ class _ReportTitle extends StatelessWidget { @@ -923,6 +946,7 @@ class _ReportTitle extends StatelessWidget {
923 interactionType: isMonth 946 interactionType: isMonth
924 ? FriendInteractionType.monthStressTrend 947 ? FriendInteractionType.monthStressTrend
925 : FriendInteractionType.weekStressTrend, 948 : FriendInteractionType.weekStressTrend,
  949 + content: interactionContent,
926 ), 950 ),
927 ], 951 ],
928 ], 952 ],
@@ -2,6 +2,7 @@ import 'dart:async'; @@ -2,6 +2,7 @@ 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/app/modules/interact/widgets/friend_interaction_content.dart';
5 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.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 8
@@ -41,8 +42,13 @@ class HrvYearReportView extends StatelessWidget { @@ -41,8 +42,13 @@ class HrvYearReportView extends StatelessWidget {
41 ), 42 ),
42 trailing: HealthReportSubjectScope.isViewingSelf(context) 43 trailing: HealthReportSubjectScope.isViewingSelf(context)
43 ? null 44 ? null
44 - : const FriendInteractClickView( 45 + : FriendInteractClickView(
45 interactionType: FriendInteractionType.yearStressTrend, 46 interactionType: FriendInteractionType.yearStressTrend,
  47 + content: FriendInteractionContent.forType(
  48 + context,
  49 + FriendInteractionType.yearStressTrend,
  50 + period: FriendInteractionPeriod.year(DateTime(data.year)),
  51 + ),
46 ), 52 ),
47 ), 53 ),
48 const SizedBox(height: 12), 54 const SizedBox(height: 12),
@@ -73,6 +79,7 @@ class _YearTrendCard extends StatelessWidget { @@ -73,6 +79,7 @@ class _YearTrendCard extends StatelessWidget {
73 context, 79 context,
74 context.l10n.hrvMonthlyStressTrend, 80 context.l10n.hrvMonthlyStressTrend,
75 ), 81 ),
  82 + year: report.year,
76 ), 83 ),
77 const SizedBox(height: 18), 84 const SizedBox(height: 18),
78 SizedBox( 85 SizedBox(
@@ -527,6 +534,7 @@ class _DailyDistributionCard extends StatelessWidget { @@ -527,6 +534,7 @@ class _DailyDistributionCard extends StatelessWidget {
527 context.l10n.hrvDailyStressDistribution, 534 context.l10n.hrvDailyStressDistribution,
528 ), 535 ),
529 trailingInset: 4, 536 trailingInset: 4,
  537 + year: report.year,
530 ), 538 ),
531 const SizedBox(height: 20), 539 const SizedBox(height: 20),
532 GridView.builder( 540 GridView.builder(
@@ -675,9 +683,14 @@ class _DateExtreme extends StatelessWidget { @@ -675,9 +683,14 @@ class _DateExtreme extends StatelessWidget {
675 } 683 }
676 684
677 class _ReportTitle extends StatelessWidget { 685 class _ReportTitle extends StatelessWidget {
678 - const _ReportTitle({required this.title, this.trailingInset = 0}); 686 + const _ReportTitle({
  687 + required this.title,
  688 + required this.year,
  689 + this.trailingInset = 0,
  690 + });
679 691
680 final String title; 692 final String title;
  693 + final int year;
681 final double trailingInset; 694 final double trailingInset;
682 695
683 @override 696 @override
@@ -689,8 +702,13 @@ class _ReportTitle extends StatelessWidget { @@ -689,8 +702,13 @@ class _ReportTitle extends StatelessWidget {
689 const Spacer(), 702 const Spacer(),
690 Padding( 703 Padding(
691 padding: EdgeInsets.only(right: trailingInset), 704 padding: EdgeInsets.only(right: trailingInset),
692 - child: const FriendInteractClickView( 705 + child: FriendInteractClickView(
693 interactionType: FriendInteractionType.yearStressTrend, 706 interactionType: FriendInteractionType.yearStressTrend,
  707 + content: FriendInteractionContent.forType(
  708 + context,
  709 + FriendInteractionType.yearStressTrend,
  710 + period: FriendInteractionPeriod.year(DateTime(year)),
  711 + ),
694 ), 712 ),
695 ), 713 ),
696 ], 714 ],
@@ -9,6 +9,7 @@ class HomeInteractRouteArguments { @@ -9,6 +9,7 @@ class HomeInteractRouteArguments {
9 static InteractRouteArguments? averageHrv({ 9 static InteractRouteArguments? averageHrv({
10 required BuildContext context, 10 required BuildContext context,
11 required int? friendUserId, 11 required int? friendUserId,
  12 + String? friendAvatarUrl,
12 required DateTime date, 13 required DateTime date,
13 required int value, 14 required int value,
14 }) => value <= 0 15 }) => value <= 0
@@ -16,6 +17,7 @@ class HomeInteractRouteArguments { @@ -16,6 +17,7 @@ class HomeInteractRouteArguments {
16 : _create( 17 : _create(
17 context: context, 18 context: context,
18 friendUserId: friendUserId, 19 friendUserId: friendUserId,
  20 + friendAvatarUrl: friendAvatarUrl,
19 date: date, 21 date: date,
20 metricTitle: context.l10n.averageHrvForTheDay, 22 metricTitle: context.l10n.averageHrvForTheDay,
21 values: [ 23 values: [
@@ -30,6 +32,7 @@ class HomeInteractRouteArguments { @@ -30,6 +32,7 @@ class HomeInteractRouteArguments {
30 static InteractRouteArguments? restingHeartRate({ 32 static InteractRouteArguments? restingHeartRate({
31 required BuildContext context, 33 required BuildContext context,
32 required int? friendUserId, 34 required int? friendUserId,
  35 + String? friendAvatarUrl,
33 required DateTime date, 36 required DateTime date,
34 required int value, 37 required int value,
35 }) => value <= 0 38 }) => value <= 0
@@ -37,6 +40,7 @@ class HomeInteractRouteArguments { @@ -37,6 +40,7 @@ class HomeInteractRouteArguments {
37 : _create( 40 : _create(
38 context: context, 41 context: context,
39 friendUserId: friendUserId, 42 friendUserId: friendUserId,
  43 + friendAvatarUrl: friendAvatarUrl,
40 date: date, 44 date: date,
41 metricTitle: context.l10n.restingHeartRate, 45 metricTitle: context.l10n.restingHeartRate,
42 values: [ 46 values: [
@@ -51,6 +55,7 @@ class HomeInteractRouteArguments { @@ -51,6 +55,7 @@ class HomeInteractRouteArguments {
51 static InteractRouteArguments? sleep({ 55 static InteractRouteArguments? sleep({
52 required BuildContext context, 56 required BuildContext context,
53 required int? friendUserId, 57 required int? friendUserId,
  58 + String? friendAvatarUrl,
54 required DateTime date, 59 required DateTime date,
55 required int durationSeconds, 60 required int durationSeconds,
56 required double? qualityScore, 61 required double? qualityScore,
@@ -61,6 +66,7 @@ class HomeInteractRouteArguments { @@ -61,6 +66,7 @@ class HomeInteractRouteArguments {
61 : _create( 66 : _create(
62 context: context, 67 context: context,
63 friendUserId: friendUserId, 68 friendUserId: friendUserId,
  69 + friendAvatarUrl: friendAvatarUrl,
64 date: date, 70 date: date,
65 metricTitle: context.l10n.sleep, 71 metricTitle: context.l10n.sleep,
66 values: [ 72 values: [
@@ -93,6 +99,7 @@ class HomeInteractRouteArguments { @@ -93,6 +99,7 @@ class HomeInteractRouteArguments {
93 static InteractRouteArguments? fitness({ 99 static InteractRouteArguments? fitness({
94 required BuildContext context, 100 required BuildContext context,
95 required int? friendUserId, 101 required int? friendUserId,
  102 + String? friendAvatarUrl,
96 required DateTime date, 103 required DateTime date,
97 required int activeCalories, 104 required int activeCalories,
98 required int exerciseSeconds, 105 required int exerciseSeconds,
@@ -100,6 +107,7 @@ class HomeInteractRouteArguments { @@ -100,6 +107,7 @@ class HomeInteractRouteArguments {
100 }) => _create( 107 }) => _create(
101 context: context, 108 context: context,
102 friendUserId: friendUserId, 109 friendUserId: friendUserId,
  110 + friendAvatarUrl: friendAvatarUrl,
103 date: date, 111 date: date,
104 metricTitle: context.l10n.fitness, 112 metricTitle: context.l10n.fitness,
105 values: [ 113 values: [
@@ -127,6 +135,7 @@ class HomeInteractRouteArguments { @@ -127,6 +135,7 @@ class HomeInteractRouteArguments {
127 static InteractRouteArguments? _create({ 135 static InteractRouteArguments? _create({
128 required BuildContext context, 136 required BuildContext context,
129 required int? friendUserId, 137 required int? friendUserId,
  138 + String? friendAvatarUrl,
130 required DateTime date, 139 required DateTime date,
131 required String metricTitle, 140 required String metricTitle,
132 required List<InteractDisplayValue> values, 141 required List<InteractDisplayValue> values,
@@ -140,6 +149,7 @@ class HomeInteractRouteArguments { @@ -140,6 +149,7 @@ class HomeInteractRouteArguments {
140 title: '${_dateLabel(context, date)} · $metricTitle', 149 title: '${_dateLabel(context, date)} · $metricTitle',
141 date: DateUtils.dateOnly(date), 150 date: DateUtils.dateOnly(date),
142 values: values, 151 values: values,
  152 + friendAvatarUrl: friendAvatarUrl,
143 ); 153 );
144 } 154 }
145 155
@@ -11,6 +11,7 @@ class InteractRouteArguments { @@ -11,6 +11,7 @@ class InteractRouteArguments {
11 required this.title, 11 required this.title,
12 required this.date, 12 required this.date,
13 required this.values, 13 required this.values,
  14 + this.friendAvatarUrl,
14 }); 15 });
15 16
16 final int friendUserId; 17 final int friendUserId;
@@ -18,6 +19,7 @@ class InteractRouteArguments { @@ -18,6 +19,7 @@ class InteractRouteArguments {
18 final String title; 19 final String title;
19 final DateTime date; 20 final DateTime date;
20 final List<InteractDisplayValue> values; 21 final List<InteractDisplayValue> values;
  22 + final String? friendAvatarUrl;
21 } 23 }
22 24
23 enum InteractPageSource { home } 25 enum InteractPageSource { home }
  1 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:flutter/widgets.dart';
  4 +
  5 +import '../../report_common/utils/report_date_formatter.dart';
  6 +import '../../report_common/utils/report_localization.dart';
  7 +
  8 +/// The display payload of a friend-interaction sheet.
  9 +///
  10 +/// The interaction type is an API contract; this class deliberately contains
  11 +/// only what the sheet needs to render. A host page can therefore provide its
  12 +/// own date-aware title and metrics without duplicating the sheet layout.
  13 +@immutable
  14 +class FriendInteractionContent {
  15 + const FriendInteractionContent({
  16 + required this.title,
  17 + this.metrics = const [],
  18 + });
  19 +
  20 + final String title;
  21 + final List<FriendInteractionMetric> metrics;
  22 +
  23 + /// A title-only presentation used by trend reports, where the design has no
  24 + /// numeric detail below the title. [period] supplies the date semantics
  25 + /// from the table (today/yesterday/a date, a week range, month, or year).
  26 + factory FriendInteractionContent.forType(
  27 + BuildContext context,
  28 + FriendInteractionType type, {
  29 + String? title,
  30 + FriendInteractionPeriod? period,
  31 + List<FriendInteractionMetric> metrics = const [],
  32 + }) => FriendInteractionContent(
  33 + title: title ?? _titleFor(context, type, period),
  34 + metrics: metrics,
  35 + );
  36 +
  37 + static String _titleFor(
  38 + BuildContext context,
  39 + FriendInteractionType type,
  40 + FriendInteractionPeriod? period,
  41 + ) {
  42 + if (type == FriendInteractionType.yearStressTrend) {
  43 + return context.l10n.interactSceneTitle(
  44 + InteractionSceneTitle.yearStressTrend,
  45 + year: period?.start.year,
  46 + );
  47 + }
  48 + final isCurrentPeriod = period?.isCurrent ?? false;
  49 + final title = switch (type) {
  50 + FriendInteractionType.defaultInteraction =>
  51 + context.l10n.interactTodaySteps,
  52 + FriendInteractionType.todayAverageHrv => context.l10n.interactSceneTitle(
  53 + InteractionSceneTitle.todayAverageHrv,
  54 + ),
  55 + FriendInteractionType.todayRestingHeartRate =>
  56 + context.l10n.interactSceneTitle(
  57 + InteractionSceneTitle.todayRestingHeartRate,
  58 + ),
  59 + FriendInteractionType.todaySleepStatus ||
  60 + FriendInteractionType.dailySleepStatus => context.l10n.interactSceneTitle(
  61 + InteractionSceneTitle.todaySleepStatus,
  62 + ),
  63 + FriendInteractionType.todayFitnessStatus ||
  64 + FriendInteractionType.dailyActivityBurn =>
  65 + context.l10n.interactSceneTitle(
  66 + type == FriendInteractionType.todayFitnessStatus
  67 + ? InteractionSceneTitle.todayFitnessStatus
  68 + : InteractionSceneTitle.todayActivityBurn,
  69 + ),
  70 + FriendInteractionType.weekStressTrend => context.l10n.interactSceneTitle(
  71 + isCurrentPeriod
  72 + ? InteractionSceneTitle.weekStressTrend
  73 + : InteractionSceneTitle.stressTrend,
  74 + ),
  75 + FriendInteractionType.monthStressTrend => context.l10n.interactSceneTitle(
  76 + isCurrentPeriod
  77 + ? InteractionSceneTitle.monthStressTrend
  78 + : InteractionSceneTitle.stressTrend,
  79 + ),
  80 + FriendInteractionType.weeklyActivityBurn ||
  81 + FriendInteractionType.monthlyActivityBurn =>
  82 + context.l10n.interactSceneTitle(
  83 + type == FriendInteractionType.weeklyActivityBurn
  84 + ? (isCurrentPeriod
  85 + ? InteractionSceneTitle.weekActivityBurn
  86 + : InteractionSceneTitle.activityBurn)
  87 + : (isCurrentPeriod
  88 + ? InteractionSceneTitle.monthActivityBurn
  89 + : InteractionSceneTitle.activityBurn),
  90 + ),
  91 + FriendInteractionType.weeklySleepTime ||
  92 + FriendInteractionType.monthlySleepTime => context.l10n.interactSceneTitle(
  93 + type == FriendInteractionType.weeklySleepTime
  94 + ? (isCurrentPeriod
  95 + ? InteractionSceneTitle.weekSleepTime
  96 + : InteractionSceneTitle.sleepTime)
  97 + : (isCurrentPeriod
  98 + ? InteractionSceneTitle.monthSleepTime
  99 + : InteractionSceneTitle.sleepTime),
  100 + ),
  101 + FriendInteractionType.pkSteps => context.l10n.interactSceneTitle(
  102 + InteractionSceneTitle.pkSteps,
  103 + ),
  104 + FriendInteractionType.pkCalories => context.l10n.interactSceneTitle(
  105 + InteractionSceneTitle.pkCalories,
  106 + ),
  107 + FriendInteractionType.pkStandCount => context.l10n.interactSceneTitle(
  108 + InteractionSceneTitle.pkStandTime,
  109 + ),
  110 + FriendInteractionType.pkMonthlyResult => context.l10n.interactSceneTitle(
  111 + InteractionSceneTitle.pkMonthlyResult,
  112 + ),
  113 + FriendInteractionType.yearStressTrend => '',
  114 + };
  115 + final periodLabel = period?.labelFor(context);
  116 + return periodLabel == null || periodLabel.isEmpty
  117 + ? title
  118 + : '$title ($periodLabel)';
  119 + }
  120 +}
  121 +
  122 +/// Describes the selected report time without coupling the sheet to a page
  123 +/// controller. This covers every date form in the interaction-scene table.
  124 +@immutable
  125 +class FriendInteractionPeriod {
  126 + const FriendInteractionPeriod.day(this.start, {this.referenceDate})
  127 + : kind = FriendInteractionPeriodKind.day,
  128 + end = null,
  129 + periodName = null;
  130 +
  131 + const FriendInteractionPeriod.week(
  132 + this.start,
  133 + this.end, {
  134 + this.periodName,
  135 + this.referenceDate,
  136 + }) : kind = FriendInteractionPeriodKind.week;
  137 +
  138 + const FriendInteractionPeriod.month(
  139 + this.start, {
  140 + this.periodName,
  141 + this.referenceDate,
  142 + }) : kind = FriendInteractionPeriodKind.month,
  143 + end = null;
  144 +
  145 + const FriendInteractionPeriod.year(
  146 + this.start, {
  147 + this.periodName,
  148 + this.referenceDate,
  149 + }) : kind = FriendInteractionPeriodKind.year,
  150 + end = null;
  151 +
  152 + final FriendInteractionPeriodKind kind;
  153 + final DateTime start;
  154 + final DateTime? end;
  155 +
  156 + /// Localized page label when one exists, e.g. “This week”. It is optional
  157 + /// because a locale-aware date range remains meaningful on its own.
  158 + final String? periodName;
  159 + final DateTime? referenceDate;
  160 +
  161 + /// Whether this period is the current natural period relative to the
  162 + /// supplied reference date (or today). Historical periods intentionally use
  163 + /// neutral titles rather than “this week” or “this month”.
  164 + bool get isCurrent {
  165 + final reference = _dateOnly(referenceDate ?? DateTime.now());
  166 + final date = _dateOnly(start);
  167 + return switch (kind) {
  168 + FriendInteractionPeriodKind.day => date == reference,
  169 + FriendInteractionPeriodKind.week => _isCurrentWeek(date, reference),
  170 + FriendInteractionPeriodKind.month => _isCurrentMonth(date, reference),
  171 + FriendInteractionPeriodKind.year => date.year == reference.year,
  172 + };
  173 + }
  174 +
  175 + String labelFor(BuildContext context) {
  176 + final reference = _dateOnly(referenceDate ?? DateTime.now());
  177 + final locale = reportLocaleName(context);
  178 + final date = _dateOnly(start);
  179 + return switch (kind) {
  180 + FriendInteractionPeriodKind.day => _dayLabel(
  181 + context,
  182 + date,
  183 + reference,
  184 + locale,
  185 + ),
  186 + FriendInteractionPeriodKind.week => _join(
  187 + periodName ??
  188 + (_isCurrentWeek(date, reference)
  189 + ? context.l10n.interactThisWeek
  190 + : null),
  191 + ReportDateFormatter.weekRange(
  192 + date,
  193 + _dateOnly(end ?? start),
  194 + locale,
  195 + referenceDate: reference,
  196 + ),
  197 + ),
  198 + FriendInteractionPeriodKind.month => _join(
  199 + periodName ??
  200 + (_isCurrentMonth(date, reference)
  201 + ? context.l10n.interactThisMonth
  202 + : null),
  203 + ReportDateFormatter.weekRange(
  204 + date,
  205 + DateTime(date.year, date.month + 1, 0),
  206 + locale,
  207 + referenceDate: reference,
  208 + ),
  209 + ),
  210 + FriendInteractionPeriodKind.year => _join(
  211 + periodName,
  212 + ReportDateFormatter.year(date, locale),
  213 + ),
  214 + };
  215 + }
  216 +
  217 + static String _dayLabel(
  218 + BuildContext context,
  219 + DateTime date,
  220 + DateTime reference,
  221 + String locale,
  222 + ) {
  223 + final difference = reference.difference(date).inDays;
  224 + if (difference == 0) return context.l10n.today;
  225 + if (difference == 1) return context.l10n.yesterday;
  226 + return date.year == reference.year
  227 + ? ReportDateFormatter.monthDay(date, locale)
  228 + : ReportDateFormatter.yearMonthDay(date, locale);
  229 + }
  230 +
  231 + static DateTime _dateOnly(DateTime value) =>
  232 + DateTime(value.year, value.month, value.day);
  233 +
  234 + static bool _isCurrentWeek(DateTime date, DateTime reference) {
  235 + final currentWeekStart = reference.subtract(
  236 + Duration(days: reference.weekday - DateTime.monday),
  237 + );
  238 + return date == currentWeekStart;
  239 + }
  240 +
  241 + static bool _isCurrentMonth(DateTime date, DateTime reference) =>
  242 + date.year == reference.year && date.month == reference.month;
  243 +
  244 + static String _join(String? name, String date) =>
  245 + name == null || name.isEmpty ? date : '$name, $date';
  246 +}
  247 +
  248 +enum FriendInteractionPeriodKind { day, week, month, year }
  249 +
  250 +/// One horizontally arranged data item in [FriendInteractionContent].
  251 +@immutable
  252 +class FriendInteractionMetric {
  253 + const FriendInteractionMetric({
  254 + required this.value,
  255 + this.unit,
  256 + this.label,
  257 + this.isText = false,
  258 + });
  259 +
  260 + final String value;
  261 + final String? unit;
  262 +
  263 + /// Optional compact pill (for example, “Workout”, “Stand”, or “Quality”).
  264 + final String? label;
  265 +
  266 + /// Textual states such as sleep quality use the smaller value treatment in
  267 + /// the Figma design instead of the large numeric treatment.
  268 + final bool isText;
  269 +}
@@ -5,6 +5,8 @@ import 'package:doublefeel_flutter/r.dart'; @@ -5,6 +5,8 @@ import 'package:doublefeel_flutter/r.dart';
5 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
6 import 'package:get/get.dart'; 6 import 'package:get/get.dart';
7 7
  8 +import 'friend_interaction_content.dart';
  9 +
8 enum FriendInteractionAction { miss, stick, punch } 10 enum FriendInteractionAction { miss, stick, punch }
9 11
10 /// Friend-interaction bottom sheet. It accepts independent callbacks so each 12 /// Friend-interaction bottom sheet. It accepts independent callbacks so each
@@ -13,7 +15,7 @@ class FriendInteractionDialog extends StatelessWidget { @@ -13,7 +15,7 @@ class FriendInteractionDialog extends StatelessWidget {
13 const FriendInteractionDialog({ 15 const FriendInteractionDialog({
14 super.key, 16 super.key,
15 required this.avatar, 17 required this.avatar,
16 - required this.steps, 18 + required this.content,
17 this.onAction, 19 this.onAction,
18 this.onMiss, 20 this.onMiss,
19 this.onPoke, 21 this.onPoke,
@@ -21,7 +23,7 @@ class FriendInteractionDialog extends StatelessWidget { @@ -21,7 +23,7 @@ class FriendInteractionDialog extends StatelessWidget {
21 }); 23 });
22 24
23 final ImageProvider? avatar; 25 final ImageProvider? avatar;
24 - final String steps; 26 + final FriendInteractionContent content;
25 27
26 /// Compatibility callback for callers that prefer one typed action handler. 28 /// Compatibility callback for callers that prefer one typed action handler.
27 final ValueChanged<FriendInteractionAction>? onAction; 29 final ValueChanged<FriendInteractionAction>? onAction;
@@ -32,7 +34,7 @@ class FriendInteractionDialog extends StatelessWidget { @@ -32,7 +34,7 @@ class FriendInteractionDialog extends StatelessWidget {
32 static Future<T?> show<T>( 34 static Future<T?> show<T>(
33 BuildContext context, { 35 BuildContext context, {
34 required ImageProvider? avatar, 36 required ImageProvider? avatar,
35 - required String steps, 37 + required FriendInteractionContent content,
36 ValueChanged<FriendInteractionAction>? onAction, 38 ValueChanged<FriendInteractionAction>? onAction,
37 VoidCallback? onMiss, 39 VoidCallback? onMiss,
38 VoidCallback? onPoke, 40 VoidCallback? onPoke,
@@ -48,7 +50,7 @@ class FriendInteractionDialog extends StatelessWidget { @@ -48,7 +50,7 @@ class FriendInteractionDialog extends StatelessWidget {
48 ), 50 ),
49 builder: (_) => FriendInteractionDialog( 51 builder: (_) => FriendInteractionDialog(
50 avatar: avatar, 52 avatar: avatar,
51 - steps: steps, 53 + content: content,
52 onAction: onAction, 54 onAction: onAction,
53 onMiss: onMiss, 55 onMiss: onMiss,
54 onPoke: onPoke, 56 onPoke: onPoke,
@@ -64,44 +66,48 @@ class FriendInteractionDialog extends StatelessWidget { @@ -64,44 +66,48 @@ class FriendInteractionDialog extends StatelessWidget {
64 final isVip = 66 final isVip =
65 Get.find<UserPreferencesStorage>().preferences.value.vipInfo?.isVip == 67 Get.find<UserPreferencesStorage>().preferences.value.vipInfo?.isVip ==
66 true; 68 true;
  69 + final hasMetrics = content.metrics.isNotEmpty;
67 return Material( 70 return Material(
68 color: Colors.transparent, 71 color: Colors.transparent,
69 child: SafeArea( 72 child: SafeArea(
70 top: false, 73 top: false,
71 child: SizedBox( 74 child: SizedBox(
72 - height: 333, 75 + // Figma's detail sheet is 367px tall. Its values occupy one
  76 + // baseline, so the actions keep the same 213px top offset.
  77 + height: hasMetrics ? 367 : 333,
73 child: Stack( 78 child: Stack(
74 clipBehavior: Clip.none, 79 clipBehavior: Clip.none,
75 children: [ 80 children: [
76 Positioned( 81 Positioned(
77 - top: -77,  
78 - left: 22, 82 + top: -70,
  83 + left: 13,
79 child: Image.asset( 84 child: Image.asset(
80 'assets/images/interact/ic_friend_interact.png', 85 'assets/images/interact/ic_friend_interact.png',
81 - width: 98,  
82 - height: 106, 86 + width: 100,
  87 + height: 80,
  88 + fit: BoxFit.contain,
83 ), 89 ),
84 ), 90 ),
85 Positioned( 91 Positioned(
86 - top: -24,  
87 - left: 28, 92 + top: -23,
  93 + left: 40,
88 child: Transform.rotate( 94 child: Transform.rotate(
89 - angle: -0.08, 95 + angle: -0.0785,
90 child: Container( 96 child: Container(
91 - padding: const EdgeInsets.symmetric(  
92 - horizontal: 18,  
93 - vertical: 8,  
94 - ), 97 + width: 184,
  98 + height: 44,
95 decoration: BoxDecoration( 99 decoration: BoxDecoration(
96 color: const Color(0xFFA084EF), 100 color: const Color(0xFFA084EF),
97 borderRadius: BorderRadius.circular(12), 101 borderRadius: BorderRadius.circular(12),
98 ), 102 ),
99 - child: Text(  
100 - context.l10n.interactActionSheetInvitation,  
101 - style: const TextStyle(  
102 - color: Colors.white,  
103 - fontSize: 18,  
104 - fontWeight: FontWeight.w600, 103 + child: Center(
  104 + child: Text(
  105 + context.l10n.interactActionSheetInvitation,
  106 + style: const TextStyle(
  107 + color: Colors.white,
  108 + fontSize: 18,
  109 + fontWeight: FontWeight.w600,
  110 + ),
105 ), 111 ),
106 ), 112 ),
107 ), 113 ),
@@ -127,41 +133,23 @@ class FriendInteractionDialog extends StatelessWidget { @@ -127,41 +133,23 @@ class FriendInteractionDialog extends StatelessWidget {
127 left: 0, 133 left: 0,
128 right: 0, 134 right: 0,
129 child: Text( 135 child: Text(
130 - context.l10n.interactTodaySteps, 136 + content.title,
131 textAlign: TextAlign.center, 137 textAlign: TextAlign.center,
  138 + maxLines: 1,
  139 + overflow: TextOverflow.ellipsis,
132 style: const TextStyle( 140 style: const TextStyle(
133 color: Color(0xFF0F0F11), 141 color: Color(0xFF0F0F11),
134 fontSize: 16, 142 fontSize: 16,
135 ), 143 ),
136 ), 144 ),
137 ), 145 ),
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 - ), 146 + if (content.metrics.isNotEmpty)
  147 + Positioned(
  148 + top: 163,
  149 + left: 20,
  150 + right: 20,
  151 + child: _FriendInteractionMetrics(metrics: content.metrics),
163 ), 152 ),
164 - ),  
165 Positioned( 153 Positioned(
166 top: 213, 154 top: 213,
167 left: 0, 155 left: 0,
@@ -181,17 +169,26 @@ class FriendInteractionDialog extends StatelessWidget { @@ -181,17 +169,26 @@ class FriendInteractionDialog extends StatelessWidget {
181 } 169 }
182 170
183 Widget _buildAvatar() => Center( 171 Widget _buildAvatar() => Center(
184 - child: Container( 172 + child: SizedBox(
185 width: 90, 173 width: 90,
186 height: 90, 174 height: 90,
187 - clipBehavior: Clip.antiAlias,  
188 - decoration: BoxDecoration(  
189 - shape: BoxShape.circle,  
190 - border: Border.all(color: Colors.white, width: 2), 175 + child: DecoratedBox(
  176 + decoration: BoxDecoration(
  177 + shape: BoxShape.circle,
  178 + border: Border.all(color: Colors.white, width: 2),
  179 + ),
  180 + child: Padding(
  181 + padding: const EdgeInsets.all(2),
  182 + child: ClipOval(
  183 + child: avatar == null
  184 + ? const ColoredBox(
  185 + color: Color(0xFFF5F4FF),
  186 + child: Icon(Icons.person, color: Color(0xFF908B91)),
  187 + )
  188 + : Image(image: avatar!, fit: BoxFit.cover),
  189 + ),
  190 + ),
191 ), 191 ),
192 - child: avatar == null  
193 - ? const Icon(Icons.person, color: Color(0xFF908B91))  
194 - : Image(image: avatar!, fit: BoxFit.cover),  
195 ), 192 ),
196 ); 193 );
197 194
@@ -211,6 +208,78 @@ class FriendInteractionDialog extends StatelessWidget { @@ -211,6 +208,78 @@ class FriendInteractionDialog extends StatelessWidget {
211 } 208 }
212 } 209 }
213 210
  211 +class _FriendInteractionMetrics extends StatelessWidget {
  212 + const _FriendInteractionMetrics({required this.metrics});
  213 +
  214 + final List<FriendInteractionMetric> metrics;
  215 +
  216 + @override
  217 + Widget build(BuildContext context) => FittedBox(
  218 + fit: BoxFit.scaleDown,
  219 + alignment: Alignment.center,
  220 + child: Row(
  221 + mainAxisSize: MainAxisSize.min,
  222 + crossAxisAlignment: CrossAxisAlignment.center,
  223 + children: [
  224 + for (var index = 0; index < metrics.length; index++) ...[
  225 + _MetricInline(metric: metrics[index]),
  226 + if (index != metrics.length - 1) const SizedBox(width: 8),
  227 + ],
  228 + ],
  229 + ),
  230 + );
  231 +}
  232 +
  233 +class _MetricInline extends StatelessWidget {
  234 + const _MetricInline({required this.metric});
  235 +
  236 + final FriendInteractionMetric metric;
  237 +
  238 + @override
  239 + Widget build(BuildContext context) => Row(
  240 + mainAxisSize: MainAxisSize.min,
  241 + crossAxisAlignment: CrossAxisAlignment.end,
  242 + children: [
  243 + RichText(
  244 + text: TextSpan(
  245 + children: [
  246 + TextSpan(
  247 + text: metric.value,
  248 + style: TextStyle(
  249 + color: const Color(0xFF845EEE),
  250 + fontSize: metric.isText ? 24 : 28,
  251 + fontWeight: FontWeight.w500,
  252 + ),
  253 + ),
  254 + if (metric.unit?.isNotEmpty == true)
  255 + TextSpan(
  256 + text: metric.unit,
  257 + style: const TextStyle(color: Color(0xFF845EEE), fontSize: 16),
  258 + ),
  259 + ],
  260 + ),
  261 + ),
  262 + if (metric.label?.isNotEmpty == true)
  263 + Container(
  264 + margin: const EdgeInsets.only(left: 3),
  265 + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
  266 + decoration: BoxDecoration(
  267 + color: const Color(0xFFE8E0FF),
  268 + borderRadius: BorderRadius.circular(8),
  269 + ),
  270 + child: Text(
  271 + metric.label!,
  272 + style: const TextStyle(
  273 + color: Color(0xFF845EEE),
  274 + fontSize: 10,
  275 + height: 1.4,
  276 + ),
  277 + ),
  278 + ),
  279 + ],
  280 + );
  281 +}
  282 +
214 /// Reusable three-action group for any friend-interaction entry point. 283 /// Reusable three-action group for any friend-interaction entry point.
215 /// 284 ///
216 /// Miss and Punch are Pro actions. Their callbacks can therefore open a 285 /// Miss and Punch are Pro actions. Their callbacks can therefore open a
1 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 1 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
2 import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart'; 2 import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
  3 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
  4 +import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
  5 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
3 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
4 import 'package:get/get.dart'; 7 import 'package:get/get.dart';
5 8
  9 +import '../../../widget/friend_interact_click_view.dart';
  10 +
6 class FriendInteractionButton extends StatelessWidget { 11 class FriendInteractionButton extends StatelessWidget {
7 const FriendInteractionButton({ 12 const FriendInteractionButton({
8 super.key, 13 super.key,
@@ -23,23 +28,154 @@ class FriendInteractionButton extends StatelessWidget { @@ -23,23 +28,154 @@ class FriendInteractionButton extends StatelessWidget {
23 final EdgeInsets padding; 28 final EdgeInsets padding;
24 29
25 @override 30 @override
26 - Widget build(BuildContext context) => Opacity(  
27 - opacity: enabled ? 1 : 0.4,  
28 - child: GestureDetector(  
29 - behavior: HitTestBehavior.opaque,  
30 - onTap: enabled ? _openInteraction : null,  
31 - child: Padding(  
32 - padding: padding,  
33 - child: SizedBox(  
34 - width: size,  
35 - height: size,  
36 - child: Image.asset('assets/images/interact/icon_stick.png'),  
37 - ),  
38 - ),  
39 - ), 31 + Widget build(BuildContext context) {
  32 + final entry = arguments;
  33 + if (entry == null) return const SizedBox.shrink();
  34 + return FriendInteractClickView(
  35 + enabled: enabled,
  36 + size: size,
  37 + padding: padding,
  38 + interactionContext: interactionContextFor(entry),
  39 + content: contentFor(context, entry),
  40 + );
  41 + }
  42 +
  43 + /// Adapts the legacy app-home card payload to the shared dialog contract.
  44 + static FriendInteractionContext interactionContextFor(
  45 + InteractRouteArguments entry,
  46 + ) => FriendInteractionContext(
  47 + friendUserId: entry.friendUserId,
  48 + type: interactionTypeFor(entry),
  49 + avatarUrl: entry.friendAvatarUrl,
  50 + );
  51 +
  52 + static FriendInteractionContent contentFor(
  53 + BuildContext context,
  54 + InteractRouteArguments entry,
  55 + ) => FriendInteractionContent.forType(
  56 + context,
  57 + interactionTypeFor(entry),
  58 + period: FriendInteractionPeriod.day(entry.date),
  59 + metrics: _metricsFor(context, entry.values),
40 ); 60 );
41 61
42 - void _openInteraction() => Get.toNamed(Routes.INTERACT, arguments: arguments); 62 + static FriendInteractionType interactionTypeFor(
  63 + InteractRouteArguments entry,
  64 + ) {
  65 + final values = entry.values;
  66 + if (values.isEmpty) return FriendInteractionType.defaultInteraction;
  67 + return switch (values.first.type) {
  68 + InteractValueType.averageHrv => FriendInteractionType.todayAverageHrv,
  69 + InteractValueType.restingHeartRate =>
  70 + FriendInteractionType.todayRestingHeartRate,
  71 + InteractValueType.sleepDuration ||
  72 + InteractValueType.sleepQualityScore ||
  73 + InteractValueType.sleepQualityState ||
  74 + InteractValueType.averageSleepHeartRate =>
  75 + FriendInteractionType.todaySleepStatus,
  76 + InteractValueType.activeCalories ||
  77 + InteractValueType.exerciseDuration ||
  78 + InteractValueType.standDuration =>
  79 + FriendInteractionType.todayFitnessStatus,
  80 + };
  81 + }
  82 +
  83 + static List<FriendInteractionMetric> _metricsFor(
  84 + BuildContext context,
  85 + List<InteractDisplayValue> values,
  86 + ) {
  87 + InteractDisplayValue? valueOf(InteractValueType type) =>
  88 + values.where((value) => value.type == type).firstOrNull;
  89 + final first = values.firstOrNull;
  90 + if (first == null) return const [];
  91 +
  92 + switch (first.type) {
  93 + case InteractValueType.averageHrv:
  94 + return [
  95 + FriendInteractionMetric(value: _number(first.value), unit: 'ms'),
  96 + ];
  97 + case InteractValueType.restingHeartRate:
  98 + return [
  99 + FriendInteractionMetric(value: _number(first.value), unit: 'bpm'),
  100 + ];
  101 + case InteractValueType.sleepDuration:
  102 + case InteractValueType.sleepQualityScore:
  103 + case InteractValueType.sleepQualityState:
  104 + case InteractValueType.averageSleepHeartRate:
  105 + final duration = valueOf(InteractValueType.sleepDuration);
  106 + final quality = valueOf(InteractValueType.sleepQualityState);
  107 + final heartRate = valueOf(InteractValueType.averageSleepHeartRate);
  108 + return [
  109 + if (duration != null) _sleepDurationMetric(context, duration.value),
  110 + if (quality != null)
  111 + FriendInteractionMetric(
  112 + value: _sleepQuality(context, quality.value.toInt()),
  113 + label: context.l10n.quality,
  114 + isText: true,
  115 + ),
  116 + if (heartRate != null)
  117 + FriendInteractionMetric(
  118 + value: _number(heartRate.value),
  119 + unit: 'bpm',
  120 + label: context.l10n.averageHeartRate,
  121 + ),
  122 + ];
  123 + case InteractValueType.activeCalories:
  124 + case InteractValueType.exerciseDuration:
  125 + case InteractValueType.standDuration:
  126 + final calories = valueOf(InteractValueType.activeCalories);
  127 + final exercise = valueOf(InteractValueType.exerciseDuration);
  128 + final stand = valueOf(InteractValueType.standDuration);
  129 + return [
  130 + if (calories != null)
  131 + FriendInteractionMetric(
  132 + value: _number(calories.value),
  133 + unit: context.l10n.reportUnitKcal,
  134 + label: context.l10n.event,
  135 + ),
  136 + if (exercise != null)
  137 + FriendInteractionMetric(
  138 + value: _number(exercise.value ~/ 60),
  139 + unit: context.l10n.activityUnitMinute,
  140 + label: context.l10n.exercise,
  141 + ),
  142 + if (stand != null)
  143 + FriendInteractionMetric(
  144 + value: _number(stand.value),
  145 + unit: context.l10n.activityUnitHour,
  146 + label: context.l10n.standing,
  147 + ),
  148 + ];
  149 + }
  150 + }
  151 +
  152 + static FriendInteractionMetric _sleepDurationMetric(
  153 + BuildContext context,
  154 + num raw,
  155 + ) {
  156 + final totalMinutes = raw ~/ 60;
  157 + final hours = totalMinutes ~/ 60;
  158 + final minutes = totalMinutes % 60;
  159 + return FriendInteractionMetric(
  160 + value: '$hours',
  161 + unit:
  162 + '${context.l10n.sleepDailyUnitHour}'
  163 + '${minutes == 0 ? '' : '$minutes${context.l10n.sleepDailyUnitMinute}'}',
  164 + label: context.l10n.duration,
  165 + );
  166 + }
  167 +
  168 + static String _sleepQuality(BuildContext context, int state) =>
  169 + switch (state) {
  170 + 1 => context.l10n.sleepQualityExcellent,
  171 + 2 => context.l10n.sleepQualityNormal,
  172 + 3 => context.l10n.sleepQualityAttention,
  173 + _ => '--',
  174 + };
  175 +
  176 + static String _number(num value) => value == value.roundToDouble()
  177 + ? value.toInt().toString()
  178 + : value.toString();
43 } 179 }
44 180
45 class FriendInteractionFloatingEntry extends StatelessWidget { 181 class FriendInteractionFloatingEntry extends StatelessWidget {
1 import 'dart:math' as math; 1 import 'dart:math' as math;
2 2
3 import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart'; 3 import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
  4 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
4 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; 5 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
5 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
6 7
@@ -455,10 +456,11 @@ class _ScoreBar extends StatelessWidget { @@ -455,10 +456,11 @@ class _ScoreBar extends StatelessWidget {
455 } 456 }
456 457
457 class _PokeButton extends StatelessWidget { 458 class _PokeButton extends StatelessWidget {
458 - const _PokeButton({this.onTap, this.interactionContext}); 459 + const _PokeButton({this.onTap, this.interactionContext, this.content});
459 460
460 final VoidCallback? onTap; 461 final VoidCallback? onTap;
461 final FriendInteractionContext? interactionContext; 462 final FriendInteractionContext? interactionContext;
  463 + final FriendInteractionContent? content;
462 464
463 @override 465 @override
464 Widget build(BuildContext context) => SizedBox( 466 Widget build(BuildContext context) => SizedBox(
@@ -476,7 +478,10 @@ class _PokeButton extends StatelessWidget { @@ -476,7 +478,10 @@ class _PokeButton extends StatelessWidget {
476 child: _asset('poke', 24, 24), 478 child: _asset('poke', 24, 24),
477 ), 479 ),
478 ) 480 )
479 - : FriendInteractClickView(interactionContext: interactionContext), 481 + : FriendInteractClickView(
  482 + interactionContext: interactionContext,
  483 + content: content,
  484 + ),
480 ), 485 ),
481 ); 486 );
482 } 487 }
@@ -531,6 +536,19 @@ class _MetricRow extends StatelessWidget { @@ -531,6 +536,19 @@ class _MetricRow extends StatelessWidget {
531 _PokeButton( 536 _PokeButton(
532 onTap: onPoke == null ? null : () => onPoke!(icon), 537 onTap: onPoke == null ? null : () => onPoke!(icon),
533 interactionContext: interactionContext, 538 interactionContext: interactionContext,
  539 + content: FriendInteractionContent.forType(
  540 + context,
  541 + interactionContext?.type ??
  542 + FriendInteractionType.defaultInteraction,
  543 + metrics: [
  544 + FriendInteractionMetric(
  545 + // The action is rendered next to, and sent to, the
  546 + // partner on the left side of the PK card.
  547 + value: left ?? '--',
  548 + unit: unit,
  549 + ),
  550 + ],
  551 + ),
534 ), 552 ),
535 ], 553 ],
536 ), 554 ),
@@ -607,6 +625,10 @@ class _MonthCard extends StatelessWidget { @@ -607,6 +625,10 @@ class _MonthCard extends StatelessWidget {
607 _PokeButton( 625 _PokeButton(
608 onTap: onPoke == null ? null : () => onPoke!('monthly'), 626 onTap: onPoke == null ? null : () => onPoke!('monthly'),
609 interactionContext: _interactionContext, 627 interactionContext: _interactionContext,
  628 + content: FriendInteractionContent.forType(
  629 + context,
  630 + FriendInteractionType.pkMonthlyResult,
  631 + ),
610 ), 632 ),
611 ], 633 ],
612 ), 634 ),
@@ -2,10 +2,8 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart'; @@ -2,10 +2,8 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart';
2 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; 2 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
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';  
6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
7 6
8 -import '../../../widget/friend_interact_click_view.dart';  
9 import '../../report_common/widgets/health_report_subject_scope.dart'; 7 import '../../report_common/widgets/health_report_subject_scope.dart';
10 import '../models/sleep_report_models.dart'; 8 import '../models/sleep_report_models.dart';
11 9
@@ -31,129 +29,120 @@ class SleepHeartRateCard extends StatelessWidget { @@ -31,129 +29,120 @@ class SleepHeartRateCard extends StatelessWidget {
31 29
32 @override 30 @override
33 Widget build(BuildContext context) { 31 Widget build(BuildContext context) {
34 - return FriendInteractCardOverlay(  
35 - showInteraction: !HealthReportSubjectScope.isViewingSelf(context),  
36 - interactionType: FriendInteractionType.dailySleepStatus,  
37 - child: Container(  
38 - height: 358,  
39 - padding: EdgeInsets.fromLTRB(20, 20.h, 20, 16),  
40 - decoration: BoxDecoration(  
41 - color: Colors.white,  
42 - borderRadius: BorderRadius.circular(16),  
43 - ),  
44 - child: Column(  
45 - crossAxisAlignment: CrossAxisAlignment.start,  
46 - children: [  
47 - Text(  
48 - HealthReportSubjectScope.titleOf(  
49 - context,  
50 - context.l10n.sleepHeartRateTitle,  
51 - ),  
52 - style: const TextStyle(  
53 - color: _h1,  
54 - fontSize: 16,  
55 - fontWeight: FontWeight.w600,  
56 - height: 1.2,  
57 - ), 32 + return Container(
  33 + height: 358,
  34 + padding: EdgeInsets.fromLTRB(20, 20.h, 20, 16),
  35 + decoration: BoxDecoration(
  36 + color: Colors.white,
  37 + borderRadius: BorderRadius.circular(16),
  38 + ),
  39 + child: Column(
  40 + crossAxisAlignment: CrossAxisAlignment.start,
  41 + children: [
  42 + Text(
  43 + HealthReportSubjectScope.titleOf(
  44 + context,
  45 + context.l10n.sleepHeartRateTitle,
58 ), 46 ),
59 - const SizedBox(height: 24),  
60 - _MetricRow(  
61 - summary: hasSleepData ? summary : const SleepHeartRateSummary(), 47 + style: const TextStyle(
  48 + color: _h1,
  49 + fontSize: 16,
  50 + fontWeight: FontWeight.w600,
  51 + height: 1.2,
62 ), 52 ),
63 - const SizedBox(height: 18),  
64 - Expanded(  
65 - child: Builder(  
66 - builder: (context) {  
67 - final xAxis = _heartRateXAxis;  
68 - final chartPoints = _chartPoints(xAxis);  
69 - final hasChartData = hasSleepData && chartPoints.isNotEmpty;  
70 -  
71 - return Stack(  
72 - alignment: Alignment.center,  
73 - clipBehavior: Clip.none,  
74 - children: [  
75 - if (hasChartData) ...[  
76 - Positioned.fill(  
77 - top: _chartTopInset,  
78 - bottom: 20,  
79 - child: LayoutBuilder(  
80 - builder: (context, constraints) => LineChart(  
81 - _chartData(  
82 - xAxis: xAxis,  
83 - chartPoints: chartPoints,  
84 - chartWidth: constraints.maxWidth,  
85 - ), 53 + ),
  54 + const SizedBox(height: 24),
  55 + _MetricRow(
  56 + summary: hasSleepData ? summary : const SleepHeartRateSummary(),
  57 + ),
  58 + const SizedBox(height: 18),
  59 + Expanded(
  60 + child: Builder(
  61 + builder: (context) {
  62 + final xAxis = _heartRateXAxis;
  63 + final chartPoints = _chartPoints(xAxis);
  64 + final hasChartData = hasSleepData && chartPoints.isNotEmpty;
  65 +
  66 + return Stack(
  67 + alignment: Alignment.center,
  68 + clipBehavior: Clip.none,
  69 + children: [
  70 + if (hasChartData) ...[
  71 + Positioned.fill(
  72 + top: _chartTopInset,
  73 + bottom: 20,
  74 + child: LayoutBuilder(
  75 + builder: (context, constraints) => LineChart(
  76 + _chartData(
  77 + xAxis: xAxis,
  78 + chartPoints: chartPoints,
  79 + chartWidth: constraints.maxWidth,
86 ), 80 ),
87 ), 81 ),
88 ), 82 ),
89 - const Positioned.fill(  
90 - top: _chartTopInset,  
91 - bottom: 20,  
92 - child: _HeartRateBoundaryGridLine(),  
93 - ),  
94 - Positioned.fill(  
95 - top: _chartTopInset,  
96 - bottom: 20,  
97 - child: _HeartRateAxisLabels(  
98 - range: _HeartRateYAxisRange.fromPoints(chartPoints),  
99 - ),  
100 - ),  
101 - Positioned.fill(  
102 - child: _HeartRateTimeLabels(axis: xAxis), 83 + ),
  84 + const Positioned.fill(
  85 + top: _chartTopInset,
  86 + bottom: 20,
  87 + child: _HeartRateBoundaryGridLine(),
  88 + ),
  89 + Positioned.fill(
  90 + top: _chartTopInset,
  91 + bottom: 20,
  92 + child: _HeartRateAxisLabels(
  93 + range: _HeartRateYAxisRange.fromPoints(chartPoints),
103 ), 94 ),
104 - ] else ...[  
105 - Positioned.fill(  
106 - top: _chartTopInset,  
107 - bottom: 20,  
108 - child: LayoutBuilder(  
109 - builder: (context, constraints) => LineChart(  
110 - _chartData(  
111 - xAxis: xAxis,  
112 - chartPoints: chartPoints,  
113 - chartWidth: constraints.maxWidth,  
114 - ), 95 + ),
  96 + Positioned.fill(child: _HeartRateTimeLabels(axis: xAxis)),
  97 + ] else ...[
  98 + Positioned.fill(
  99 + top: _chartTopInset,
  100 + bottom: 20,
  101 + child: LayoutBuilder(
  102 + builder: (context, constraints) => LineChart(
  103 + _chartData(
  104 + xAxis: xAxis,
  105 + chartPoints: chartPoints,
  106 + chartWidth: constraints.maxWidth,
115 ), 107 ),
116 ), 108 ),
117 ), 109 ),
118 - const Positioned.fill(  
119 - top: _chartTopInset,  
120 - bottom: 20,  
121 - child: _HeartRateBoundaryGridLine(drawTop: true), 110 + ),
  111 + const Positioned.fill(
  112 + top: _chartTopInset,
  113 + bottom: 20,
  114 + child: _HeartRateBoundaryGridLine(drawTop: true),
  115 + ),
  116 + Positioned.fill(
  117 + top: _chartTopInset,
  118 + bottom: 20,
  119 + child: _HeartRateAxisLabels(
  120 + range: _HeartRateYAxisRange.fromPoints(chartPoints),
122 ), 121 ),
123 - Positioned.fill(  
124 - top: _chartTopInset,  
125 - bottom: 20,  
126 - child: _HeartRateAxisLabels(  
127 - range: _HeartRateYAxisRange.fromPoints(chartPoints),  
128 - ),  
129 - ),  
130 - Positioned.fill(  
131 - child: _HeartRateTimeLabels(  
132 - axis: xAxis,  
133 - tickCount: 4,  
134 - ),  
135 - ),  
136 - Positioned.fill(  
137 - bottom: 20,  
138 - child: Center(  
139 - child: Text(  
140 - context.l10n.sleepPeriodAwaitingData,  
141 - style: const TextStyle(  
142 - color: Color(0xFFA1A0A5),  
143 - fontSize: 12,  
144 - fontWeight: FontWeight.w400,  
145 - ), 122 + ),
  123 + Positioned.fill(
  124 + child: _HeartRateTimeLabels(axis: xAxis, tickCount: 4),
  125 + ),
  126 + Positioned.fill(
  127 + bottom: 20,
  128 + child: Center(
  129 + child: Text(
  130 + context.l10n.sleepPeriodAwaitingData,
  131 + style: const TextStyle(
  132 + color: Color(0xFFA1A0A5),
  133 + fontSize: 12,
  134 + fontWeight: FontWeight.w400,
146 ), 135 ),
147 ), 136 ),
148 ), 137 ),
149 - ], 138 + ),
150 ], 139 ],
151 - );  
152 - },  
153 - ), 140 + ],
  141 + );
  142 + },
154 ), 143 ),
155 - ],  
156 - ), 144 + ),
  145 + ],
157 ), 146 ),
158 ); 147 );
159 } 148 }
1 import 'package:flutter/material.dart'; 1 import 'package:flutter/material.dart';
2 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; 2 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
  3 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 5
5 import '../../../widget/friend_interact_click_view.dart'; 6 import '../../../widget/friend_interact_click_view.dart';
@@ -28,6 +29,26 @@ class SleepSummaryCard extends StatelessWidget { @@ -28,6 +29,26 @@ class SleepSummaryCard extends StatelessWidget {
28 return FriendInteractCardOverlay( 29 return FriendInteractCardOverlay(
29 showInteraction: !HealthReportSubjectScope.isViewingSelf(context), 30 showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
30 interactionType: FriendInteractionType.dailySleepStatus, 31 interactionType: FriendInteractionType.dailySleepStatus,
  32 + content: FriendInteractionContent.forType(
  33 + context,
  34 + FriendInteractionType.dailySleepStatus,
  35 + period: FriendInteractionPeriod.day(report?.date ?? DateTime.now()),
  36 + metrics: [
  37 + FriendInteractionMetric(
  38 + value: duration?.hoursPart.toString() ?? '-',
  39 + unit: context.l10n.sleepDailyUnitHour,
  40 + ),
  41 + FriendInteractionMetric(
  42 + value: duration?.minutesPart.toString() ?? '-',
  43 + unit: context.l10n.sleepDailyUnitMinute,
  44 + ),
  45 + FriendInteractionMetric(
  46 + value: quality.shortText,
  47 + label: context.l10n.sleepDailyQualityTitle,
  48 + isText: true,
  49 + ),
  50 + ],
  51 + ),
31 child: Container( 52 child: Container(
32 height: 90, 53 height: 90,
33 padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), 54 padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
1 import 'dart:async'; 1 import 'dart:async';
2 2
3 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart'; 3 import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
  4 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
4 import 'package:doublefeel_flutter/r.dart'; 5 import 'package:doublefeel_flutter/r.dart';
5 import 'package:fl_chart/fl_chart.dart'; 6 import 'package:fl_chart/fl_chart.dart';
6 import 'package:flutter/material.dart'; 7 import 'package:flutter/material.dart';
@@ -51,6 +52,11 @@ class SleepWeekReportView extends StatelessWidget { @@ -51,6 +52,11 @@ class SleepWeekReportView extends StatelessWidget {
51 Widget build(BuildContext context) { 52 Widget build(BuildContext context) {
52 final weekly = report; 53 final weekly = report;
53 return _SleepPeriodTrendContent( 54 return _SleepPeriodTrendContent(
  55 + interactionType: FriendInteractionType.weeklySleepTime,
  56 + period: FriendInteractionPeriod.week(
  57 + weekly?.weekStart ?? DateTime.now(),
  58 + weekly?.weekEnd ?? DateTime.now(),
  59 + ),
54 days: weekly?.days ?? const <SleepReport>[], 60 days: weekly?.days ?? const <SleepReport>[],
55 hasData: weekly?.hasData ?? false, 61 hasData: weekly?.hasData ?? false,
56 averageDuration: weekly?.averageDurationMinutes, 62 averageDuration: weekly?.averageDurationMinutes,
@@ -85,6 +91,8 @@ class SleepMonthReportView extends StatelessWidget { @@ -85,6 +91,8 @@ class SleepMonthReportView extends StatelessWidget {
85 Widget build(BuildContext context) { 91 Widget build(BuildContext context) {
86 final monthly = report ?? MonthlySleepReport.empty(monthStart); 92 final monthly = report ?? MonthlySleepReport.empty(monthStart);
87 return _SleepPeriodTrendContent( 93 return _SleepPeriodTrendContent(
  94 + interactionType: FriendInteractionType.monthlySleepTime,
  95 + period: FriendInteractionPeriod.month(monthly.monthStart),
88 days: monthly.days, 96 days: monthly.days,
89 hasData: monthly.hasData, 97 hasData: monthly.hasData,
90 averageDuration: monthly.averageDurationMinutes, 98 averageDuration: monthly.averageDurationMinutes,
@@ -105,6 +113,8 @@ class SleepMonthReportView extends StatelessWidget { @@ -105,6 +113,8 @@ class SleepMonthReportView extends StatelessWidget {
105 113
106 class _SleepPeriodTrendContent extends StatelessWidget { 114 class _SleepPeriodTrendContent extends StatelessWidget {
107 const _SleepPeriodTrendContent({ 115 const _SleepPeriodTrendContent({
  116 + required this.interactionType,
  117 + required this.period,
108 required this.days, 118 required this.days,
109 required this.hasData, 119 required this.hasData,
110 required this.averageDuration, 120 required this.averageDuration,
@@ -122,6 +132,8 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -122,6 +132,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
122 }); 132 });
123 133
124 final List<SleepReport> days; 134 final List<SleepReport> days;
  135 + final FriendInteractionType interactionType;
  136 + final FriendInteractionPeriod period;
125 final bool hasData; 137 final bool hasData;
126 final int? averageDuration; 138 final int? averageDuration;
127 final num? targetDurationSeconds; 139 final num? targetDurationSeconds;
@@ -139,6 +151,11 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -139,6 +151,11 @@ class _SleepPeriodTrendContent extends StatelessWidget {
139 @override 151 @override
140 Widget build(BuildContext context) { 152 Widget build(BuildContext context) {
141 final compact = days.length > 7; 153 final compact = days.length > 7;
  154 + final interactionContent = FriendInteractionContent.forType(
  155 + context,
  156 + interactionType,
  157 + period: period,
  158 + );
142 159
143 return Column( 160 return Column(
144 children: [ 161 children: [
@@ -153,6 +170,8 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -153,6 +170,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
153 ), 170 ),
154 const SizedBox(height: 20), 171 const SizedBox(height: 20),
155 _TrendCard( 172 _TrendCard(
  173 + interactionType: interactionType,
  174 + content: interactionContent,
156 title: HealthReportSubjectScope.titleOf( 175 title: HealthReportSubjectScope.titleOf(
157 context, 176 context,
158 context.l10n.sleepDurationTitle, 177 context.l10n.sleepDurationTitle,
@@ -177,6 +196,8 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -177,6 +196,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
177 ), 196 ),
178 const SizedBox(height: 12), 197 const SizedBox(height: 12),
179 _TrendCard( 198 _TrendCard(
  199 + interactionType: interactionType,
  200 + content: interactionContent,
180 height: 418, 201 height: 418,
181 title: HealthReportSubjectScope.titleOf( 202 title: HealthReportSubjectScope.titleOf(
182 context, 203 context,
@@ -201,6 +222,8 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -201,6 +222,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
201 ), 222 ),
202 const SizedBox(height: 12), 223 const SizedBox(height: 12),
203 _TrendCard( 224 _TrendCard(
  225 + interactionType: interactionType,
  226 + content: interactionContent,
204 title: HealthReportSubjectScope.titleOf( 227 title: HealthReportSubjectScope.titleOf(
205 context, 228 context,
206 context.l10n.sleepBedtime, 229 context.l10n.sleepBedtime,
@@ -446,6 +469,8 @@ class _TrendCard extends StatelessWidget { @@ -446,6 +469,8 @@ class _TrendCard extends StatelessWidget {
446 required this.chart, 469 required this.chart,
447 required this.leftMetric, 470 required this.leftMetric,
448 required this.rightMetric, 471 required this.rightMetric,
  472 + required this.content,
  473 + required this.interactionType,
449 }); 474 });
450 475
451 final String title; 476 final String title;
@@ -453,12 +478,15 @@ class _TrendCard extends StatelessWidget { @@ -453,12 +478,15 @@ class _TrendCard extends StatelessWidget {
453 final Widget chart; 478 final Widget chart;
454 final Widget leftMetric; 479 final Widget leftMetric;
455 final Widget rightMetric; 480 final Widget rightMetric;
  481 + final FriendInteractionContent content;
  482 + final FriendInteractionType interactionType;
456 483
457 @override 484 @override
458 Widget build(BuildContext context) { 485 Widget build(BuildContext context) {
459 return FriendInteractCardOverlay( 486 return FriendInteractCardOverlay(
460 showInteraction: !HealthReportSubjectScope.isViewingSelf(context), 487 showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
461 - interactionType: FriendInteractionType.weeklySleepTime, 488 + interactionType: interactionType,
  489 + content: content,
462 child: Container( 490 child: Container(
463 height: height, 491 height: height,
464 padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), 492 padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
1 import 'dart:async'; 1 import 'dart:async';
2 2
3 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_dialog.dart'; 3 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_dialog.dart';
  4 +import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
4 import 'package:doublefeel_flutter/app/modules/report_common/widgets/health_report_subject_scope.dart'; 5 import 'package:doublefeel_flutter/app/modules/report_common/widgets/health_report_subject_scope.dart';
5 import 'package:doublefeel_flutter/core/network/api/interaction_api.dart'; 6 import 'package:doublefeel_flutter/core/network/api/interaction_api.dart';
6 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 7 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
@@ -14,49 +15,73 @@ class FriendInteractClickView extends StatelessWidget { @@ -14,49 +15,73 @@ class FriendInteractClickView extends StatelessWidget {
14 this.onAction, 15 this.onAction,
15 this.interactionContext, 16 this.interactionContext,
16 this.interactionType = FriendInteractionType.defaultInteraction, 17 this.interactionType = FriendInteractionType.defaultInteraction,
  18 + this.content,
  19 + this.enabled = true,
  20 + this.size = 24,
  21 + this.padding = EdgeInsets.zero,
17 }); 22 });
18 23
19 final VoidCallback? onAction; 24 final VoidCallback? onAction;
20 final FriendInteractionContext? interactionContext; 25 final FriendInteractionContext? interactionContext;
21 final FriendInteractionType interactionType; 26 final FriendInteractionType interactionType;
  27 + final FriendInteractionContent? content;
  28 + final bool enabled;
  29 + final double size;
  30 + final EdgeInsets padding;
22 31
23 @override 32 @override
24 - Widget build(BuildContext context) => GestureDetector(  
25 - behavior: HitTestBehavior.opaque,  
26 - onTap: () {  
27 - onAction?.call();  
28 - final interaction = _interactionContextOf(context);  
29 - FriendInteractionDialog.show<void>(  
30 - context,  
31 - avatar: _avatar(interaction),  
32 - steps: '15627',  
33 - onMiss: interaction == null  
34 - ? null  
35 - : () => unawaited(  
36 - _sendInteraction(interaction, FriendInteractionActionType.miss),  
37 - ),  
38 - onPoke: interaction == null  
39 - ? null  
40 - : () => unawaited(  
41 - _sendInteraction(interaction, FriendInteractionActionType.poke),  
42 - ),  
43 - onPunch: interaction == null  
44 - ? null  
45 - : () => unawaited(  
46 - _sendInteraction(  
47 - interaction,  
48 - FriendInteractionActionType.punch,  
49 - ),  
50 - ),  
51 - );  
52 - },  
53 - child: SizedBox(  
54 - width: 24.dp,  
55 - height: 24.dp,  
56 - child: Image.asset(  
57 - 'assets/images/interact/ic_friend_interact_click.png',  
58 - width: double.infinity,  
59 - height: double.infinity, 33 + Widget build(BuildContext context) => Opacity(
  34 + opacity: enabled ? 1 : .4,
  35 + child: GestureDetector(
  36 + behavior: HitTestBehavior.opaque,
  37 + onTap: enabled
  38 + ? () {
  39 + onAction?.call();
  40 + final interaction = _interactionContextOf(context);
  41 + FriendInteractionDialog.show<void>(
  42 + context,
  43 + avatar: _avatar(interaction),
  44 + content:
  45 + content ??
  46 + FriendInteractionContent.forType(context, interactionType),
  47 + onMiss: interaction == null
  48 + ? null
  49 + : () => unawaited(
  50 + _sendInteraction(
  51 + interaction,
  52 + FriendInteractionActionType.miss,
  53 + ),
  54 + ),
  55 + onPoke: interaction == null
  56 + ? null
  57 + : () => unawaited(
  58 + _sendInteraction(
  59 + interaction,
  60 + FriendInteractionActionType.poke,
  61 + ),
  62 + ),
  63 + onPunch: interaction == null
  64 + ? null
  65 + : () => unawaited(
  66 + _sendInteraction(
  67 + interaction,
  68 + FriendInteractionActionType.punch,
  69 + ),
  70 + ),
  71 + );
  72 + }
  73 + : null,
  74 + child: Padding(
  75 + padding: padding,
  76 + child: SizedBox(
  77 + width: size.dp,
  78 + height: size.dp,
  79 + child: Image.asset(
  80 + 'assets/images/interact/ic_friend_interact_click.png',
  81 + width: double.infinity,
  82 + height: double.infinity,
  83 + ),
  84 + ),
60 ), 85 ),
61 ), 86 ),
62 ); 87 );
@@ -99,11 +124,13 @@ class FriendInteractCardOverlay extends StatelessWidget { @@ -99,11 +124,13 @@ class FriendInteractCardOverlay extends StatelessWidget {
99 required this.showInteraction, 124 required this.showInteraction,
100 required this.child, 125 required this.child,
101 this.interactionType = FriendInteractionType.defaultInteraction, 126 this.interactionType = FriendInteractionType.defaultInteraction,
  127 + this.content,
102 }); 128 });
103 129
104 final bool showInteraction; 130 final bool showInteraction;
105 final Widget child; 131 final Widget child;
106 final FriendInteractionType interactionType; 132 final FriendInteractionType interactionType;
  133 + final FriendInteractionContent? content;
107 134
108 @override 135 @override
109 Widget build(BuildContext context) => Stack( 136 Widget build(BuildContext context) => Stack(
@@ -113,7 +140,10 @@ class FriendInteractCardOverlay extends StatelessWidget { @@ -113,7 +140,10 @@ class FriendInteractCardOverlay extends StatelessWidget {
113 Positioned( 140 Positioned(
114 top: 20, 141 top: 20,
115 right: 20, 142 right: 20,
116 - child: FriendInteractClickView(interactionType: interactionType), 143 + child: FriendInteractClickView(
  144 + interactionType: interactionType,
  145 + content: content,
  146 + ),
117 ), 147 ),
118 ], 148 ],
119 ); 149 );
  1 +import '../../../data/models/interaction/interaction_models.dart';
1 import '../../result/app_result.dart'; 2 import '../../result/app_result.dart';
2 import '../../result/safe_call.dart'; 3 import '../../result/safe_call.dart';
3 -import '../../../data/models/interaction/interaction_models.dart';  
4 import '../api_paths.dart'; 4 import '../api_paths.dart';
5 import '../dio_client.dart'; 5 import '../dio_client.dart';
6 6
@@ -35,4 +35,15 @@ class InteractionApi { @@ -35,4 +35,15 @@ class InteractionApi {
35 }, 35 },
36 ); 36 );
37 } 37 }
  38 +
  39 + Future<AppResult<void>> getPkInfo(int queryUserId) {
  40 + return safeCall(
  41 + call: () async {
  42 + await _dioClient.dio.get(
  43 + ApiPaths.pkInfo,
  44 + queryParameters: {'query_user_id': queryUserId},
  45 + );
  46 + },
  47 + );
  48 + }
38 } 49 }
@@ -70,6 +70,7 @@ abstract final class ApiPaths { @@ -70,6 +70,7 @@ abstract final class ApiPaths {
70 static const interactionRecords = 70 static const interactionRecords =
71 '/client/doublefeel/interaction/v2/records/'; 71 '/client/doublefeel/interaction/v2/records/';
72 static const interactionAction = '/client/doublefeel/interaction/v2/action/'; 72 static const interactionAction = '/client/doublefeel/interaction/v2/action/';
  73 + static const pkInfo = '/client/doublefeel/health/v2/pk_info/';
73 74
74 // Config 75 // Config
75 static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/'; 76 static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/';
@@ -30,35 +30,212 @@ AppLocalizations currentAppLocalizations() { @@ -30,35 +30,212 @@ AppLocalizations currentAppLocalizations() {
30 return lookupAppLocalizations(locale); 30 return lookupAppLocalizations(locale);
31 } 31 }
32 32
  33 +/// Explicit dialog-title vocabulary. Keeping this typed prevents individual
  34 +/// reports from drifting away from the interaction-scene table.
  35 +enum InteractionSceneTitle {
  36 + todayAverageHrv,
  37 + todayRestingHeartRate,
  38 + todaySleepStatus,
  39 + todayFitnessStatus,
  40 + weekStressTrend,
  41 + monthStressTrend,
  42 + stressTrend,
  43 + yearStressTrend,
  44 + todayActivityBurn,
  45 + weekActivityBurn,
  46 + monthActivityBurn,
  47 + activityBurn,
  48 + weekSleepTime,
  49 + monthSleepTime,
  50 + sleepTime,
  51 + pkSteps,
  52 + pkCalories,
  53 + pkStandTime,
  54 + pkMonthlyResult,
  55 +}
  56 +
33 /// Temporary compatibility getters while the checked-in localization output is 57 /// Temporary compatibility getters while the checked-in localization output is
34 /// regenerated. Once `flutter gen-l10n` runs, the generated instance members 58 /// regenerated. Once `flutter gen-l10n` runs, the generated instance members
35 /// take precedence over these extension members. 59 /// take precedence over these extension members.
36 extension InteractionAppLocalizations on AppLocalizations { 60 extension InteractionAppLocalizations on AppLocalizations {
  61 + String interactSceneTitle(InteractionSceneTitle title, {int? year}) {
  62 + final zhHans = switch (title) {
  63 + InteractionSceneTitle.todayAverageHrv => '今日平均HRV',
  64 + InteractionSceneTitle.todayRestingHeartRate => '今日静息心率',
  65 + InteractionSceneTitle.todaySleepStatus => '今日睡眠情况',
  66 + InteractionSceneTitle.todayFitnessStatus => '今日健身情况',
  67 + InteractionSceneTitle.weekStressTrend => '本周压力趋势',
  68 + InteractionSceneTitle.monthStressTrend => '本月压力趋势',
  69 + InteractionSceneTitle.stressTrend => '压力趋势',
  70 + InteractionSceneTitle.yearStressTrend =>
  71 + '${year ?? DateTime.now().year}年压力趋势',
  72 + InteractionSceneTitle.todayActivityBurn => '今日活动消耗',
  73 + InteractionSceneTitle.weekActivityBurn => '本周活动消耗',
  74 + InteractionSceneTitle.monthActivityBurn => '本月活动消耗',
  75 + InteractionSceneTitle.activityBurn => '活动消耗',
  76 + InteractionSceneTitle.weekSleepTime => '本周入睡时间',
  77 + InteractionSceneTitle.monthSleepTime => '本月入睡时间',
  78 + InteractionSceneTitle.sleepTime => '入睡时间',
  79 + InteractionSceneTitle.pkSteps => 'PK-今日步数',
  80 + InteractionSceneTitle.pkCalories => 'PK-活动记录',
  81 + InteractionSceneTitle.pkStandTime => 'PK-站立时间',
  82 + InteractionSceneTitle.pkMonthlyResult => 'PK-本月胜率',
  83 + };
  84 + final english = switch (title) {
  85 + InteractionSceneTitle.todayAverageHrv => 'Today’s average HRV',
  86 + InteractionSceneTitle.todayRestingHeartRate =>
  87 + 'Today’s resting heart rate',
  88 + InteractionSceneTitle.todaySleepStatus => 'Today’s sleep',
  89 + InteractionSceneTitle.todayFitnessStatus => 'Today’s fitness',
  90 + InteractionSceneTitle.weekStressTrend => 'Weekly stress trend',
  91 + InteractionSceneTitle.monthStressTrend => 'Monthly stress trend',
  92 + InteractionSceneTitle.stressTrend => 'Stress trend',
  93 + InteractionSceneTitle.yearStressTrend =>
  94 + '${year ?? DateTime.now().year} stress trend',
  95 + InteractionSceneTitle.todayActivityBurn => 'Today’s activity burn',
  96 + InteractionSceneTitle.weekActivityBurn => 'Weekly activity burn',
  97 + InteractionSceneTitle.monthActivityBurn => 'Monthly activity burn',
  98 + InteractionSceneTitle.activityBurn => 'Activity burn',
  99 + InteractionSceneTitle.weekSleepTime => 'Weekly sleep time',
  100 + InteractionSceneTitle.monthSleepTime => 'Monthly sleep time',
  101 + InteractionSceneTitle.sleepTime => 'Sleep time',
  102 + InteractionSceneTitle.pkSteps => 'PK - today’s steps',
  103 + InteractionSceneTitle.pkCalories => 'PK - activity record',
  104 + InteractionSceneTitle.pkStandTime => 'PK - standing time',
  105 + InteractionSceneTitle.pkMonthlyResult => 'PK - monthly win rate',
  106 + };
  107 + return switch (localeName) {
  108 + 'zh' || 'zh_Hans' => zhHans,
  109 + 'zh_Hant' =>
  110 + zhHans
  111 + .replaceAll('压力', '壓力')
  112 + .replaceAll('静息', '靜息')
  113 + .replaceAll('情况', '情況')
  114 + .replaceAll('活动', '活動')
  115 + .replaceAll('站立', '站立')
  116 + .replaceAll('胜率', '勝率'),
  117 + _ => english,
  118 + };
  119 + }
  120 +
37 String get interactActionSheetInvitation => _interactionStrings.$1; 121 String get interactActionSheetInvitation => _interactionStrings.$1;
38 String get interactTodaySteps => _interactionStrings.$2; 122 String get interactTodaySteps => _interactionStrings.$2;
39 String get interactStepsUnit => _interactionStrings.$3; 123 String get interactStepsUnit => _interactionStrings.$3;
40 String get interactActionMiss => _interactionStrings.$4; 124 String get interactActionMiss => _interactionStrings.$4;
41 String get interactActionStick => _interactionStrings.$5; 125 String get interactActionStick => _interactionStrings.$5;
42 String get interactActionPunch => _interactionStrings.$6; 126 String get interactActionPunch => _interactionStrings.$6;
43 - 127 + String get interactThisWeek => switch (localeName) {
  128 + 'zh' || 'zh_Hans' => '本周',
  129 + 'zh_Hant' => '本週',
  130 + _ => 'This week',
  131 + };
  132 + String get interactThisMonth => switch (localeName) {
  133 + 'zh' || 'zh_Hans' => '本月',
  134 + 'zh_Hant' => '本月',
  135 + _ => 'This month',
  136 + };
44 (String, String, String, String, String, String) get _interactionStrings => 137 (String, String, String, String, String, String) get _interactionStrings =>
45 switch (localeName) { 138 switch (localeName) {
46 'zh' || 'zh_Hans' => ('快来和我互动吧~', '今日步数', '步', '想你', '戳一戳', '打一拳'), 139 'zh' || 'zh_Hans' => ('快来和我互动吧~', '今日步数', '步', '想你', '戳一戳', '打一拳'),
47 'zh_Hant' => ('快來和我互動吧~', '今日步數', '步', '想你', '戳一戳', '打一拳'), 140 'zh_Hant' => ('快來和我互動吧~', '今日步數', '步', '想你', '戳一戳', '打一拳'),
48 'ja' => ('一緒に交流しよう~', '今日の歩数', '歩', '会いたい', 'つんつん', 'パンチ'), 141 'ja' => ('一緒に交流しよう~', '今日の歩数', '歩', '会いたい', 'つんつん', 'パンチ'),
49 'ko' => ('나와 함께 소통해요~', '오늘의 걸음 수', '걸음', '보고 싶어', '콕 찌르기', '주먹'), 142 'ko' => ('나와 함께 소통해요~', '오늘의 걸음 수', '걸음', '보고 싶어', '콕 찌르기', '주먹'),
50 - 'de' => ('Lass uns interagieren~', 'Heutige Schritte', 'Schritte', 'Vermisse dich', 'Anstupsen', 'Boxen'),  
51 - 'es' => ('¡Ven a interactuar conmigo!', 'Pasos de hoy', 'pasos', 'Te extraño', 'Toquecito', 'Puñetazo'),  
52 - 'fil' => ('Makipag-ugnayan tayo~', 'Mga hakbang ngayon', 'hakbang', 'Miss kita', 'Tapikin', 'Suntok'),  
53 - 'fr' => ('Viens interagir avec moi~', 'Pas du jour', 'pas', 'Tu me manques', 'Taquiner', 'Coup de poing'),  
54 - 'hi' => ('मेरे साथ इंटरैक्ट करें~', 'आज के कदम', 'कदम', 'तुम्हारी याद आती है', 'हल्का धक्का', 'मुक्का'),  
55 - 'it' => ('Vieni a interagire con me~', 'Passi di oggi', 'passi', 'Mi manchi', 'Stuzzica', 'Pugno'),  
56 - 'nl' => ('Kom met me interacteren~', 'Stappen van vandaag', 'stappen', 'Ik mis je', 'Prikje', 'Stoot'),  
57 - 'pt' || 'pt_BR' => ('Venha interagir comigo~', 'Passos de hoje', 'passos', 'Sinto sua falta', 'Cutucar', 'Soco'),  
58 - 'pt_PT' => ('Vem interagir comigo~', 'Passos de hoje', 'passos', 'Tenho saudades tuas', 'Picar', 'Soco'),  
59 - 'ru' => ('Давай пообщаемся~', 'Шаги за сегодня', 'шагов', 'Скучаю по тебе', 'Тыкнуть', 'Ударить'),  
60 - 'tr' => ('Benimle etkileşime geç~', 'Bugünün adımları', 'adım', 'Seni özledim', 'Dürt', 'Yumruk'),  
61 - _ => ('Come interact with me~', "Today's steps", 'steps', 'Miss you', 'Poke', 'Punch'), 143 + 'de' => (
  144 + 'Lass uns interagieren~',
  145 + 'Heutige Schritte',
  146 + 'Schritte',
  147 + 'Vermisse dich',
  148 + 'Anstupsen',
  149 + 'Boxen',
  150 + ),
  151 + 'es' => (
  152 + '¡Ven a interactuar conmigo!',
  153 + 'Pasos de hoy',
  154 + 'pasos',
  155 + 'Te extraño',
  156 + 'Toquecito',
  157 + 'Puñetazo',
  158 + ),
  159 + 'fil' => (
  160 + 'Makipag-ugnayan tayo~',
  161 + 'Mga hakbang ngayon',
  162 + 'hakbang',
  163 + 'Miss kita',
  164 + 'Tapikin',
  165 + 'Suntok',
  166 + ),
  167 + 'fr' => (
  168 + 'Viens interagir avec moi~',
  169 + 'Pas du jour',
  170 + 'pas',
  171 + 'Tu me manques',
  172 + 'Taquiner',
  173 + 'Coup de poing',
  174 + ),
  175 + 'hi' => (
  176 + 'मेरे साथ इंटरैक्ट करें~',
  177 + 'आज के कदम',
  178 + 'कदम',
  179 + 'तुम्हारी याद आती है',
  180 + 'हल्का धक्का',
  181 + 'मुक्का',
  182 + ),
  183 + 'it' => (
  184 + 'Vieni a interagire con me~',
  185 + 'Passi di oggi',
  186 + 'passi',
  187 + 'Mi manchi',
  188 + 'Stuzzica',
  189 + 'Pugno',
  190 + ),
  191 + 'nl' => (
  192 + 'Kom met me interacteren~',
  193 + 'Stappen van vandaag',
  194 + 'stappen',
  195 + 'Ik mis je',
  196 + 'Prikje',
  197 + 'Stoot',
  198 + ),
  199 + 'pt' || 'pt_BR' => (
  200 + 'Venha interagir comigo~',
  201 + 'Passos de hoje',
  202 + 'passos',
  203 + 'Sinto sua falta',
  204 + 'Cutucar',
  205 + 'Soco',
  206 + ),
  207 + 'pt_PT' => (
  208 + 'Vem interagir comigo~',
  209 + 'Passos de hoje',
  210 + 'passos',
  211 + 'Tenho saudades tuas',
  212 + 'Picar',
  213 + 'Soco',
  214 + ),
  215 + 'ru' => (
  216 + 'Давай пообщаемся~',
  217 + 'Шаги за сегодня',
  218 + 'шагов',
  219 + 'Скучаю по тебе',
  220 + 'Тыкнуть',
  221 + 'Ударить',
  222 + ),
  223 + 'tr' => (
  224 + 'Benimle etkileşime geç~',
  225 + 'Bugünün adımları',
  226 + 'adım',
  227 + 'Seni özledim',
  228 + 'Dürt',
  229 + 'Yumruk',
  230 + ),
  231 + _ => (
  232 + 'Come interact with me~',
  233 + "Today's steps",
  234 + 'steps',
  235 + 'Miss you',
  236 + 'Poke',
  237 + 'Punch',
  238 + ),
62 }; 239 };
63 } 240 }
64 241