Showing
22 changed files
with
1231 additions
and
363 deletions
| @@ -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 |
-
Please register or login to post a comment