Commit cb292aa9b069aa8725afeb5b4642649c555f2cc9

Authored by 刘宏哲
1 parent d2045d29

feat(app): change productChannel

5.39 KB | W: | H:

4.54 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin
... ... @@ -3,11 +3,9 @@ import 'dart:ui' as ui;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import '../../../widget/friend_interact_click_view.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_trend_lines.dart';
... ... @@ -38,79 +36,75 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
Widget build(BuildContext context) {
final start = _startTime;
final axis = _HeartRateAxis.fromSummary(summary, start);
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.dailyActivityBurn,
child: Container(
height: 273,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.activityHeartRateZone,
),
style: TextStyle(
color: _h1,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
return Container(
height: 273,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.activityHeartRateZone,
),
const SizedBox(height: 20),
SizedBox(
height: _chartHeight,
child: LayoutBuilder(
builder: (_, __) {
return Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
style: TextStyle(
color: _h1,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 20),
SizedBox(
height: _chartHeight,
child: LayoutBuilder(
builder: (_, __) {
return Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 20,
child: ActivityBurnHorizontalGridLines(
minY: axis.minY,
maxY: axis.chartMaxY,
values: axis.ticks,
),
),
Positioned.fill(
top: 0,
child: LineChart(_chartData(context)),
),
if (!summary.hasData)
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 20,
child: ActivityBurnHorizontalGridLines(
minY: axis.minY,
maxY: axis.chartMaxY,
values: axis.ticks,
),
),
Positioned.fill(
top: 0,
child: LineChart(_chartData(context)),
),
if (!summary.hasData)
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 20,
child: Center(
child: Text(
context.l10n.hrvTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
child: Center(
child: Text(
context.l10n.hrvTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
],
);
},
),
),
],
);
},
),
],
),
),
],
),
);
}
... ...
... ... @@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -491,6 +492,11 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.monthlyActivityBurn,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.monthlyActivityBurn,
period: FriendInteractionPeriod.month(widget.report.monthStart),
),
child: Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
... ...
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/app/modules/report_common/widgets/health_report_subject_scope.dart';
import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -7,10 +11,7 @@ import '../models/activity_burn_report_models.dart';
import 'activity_burn_duration_text.dart';
class ActivityBurnSummaryCard extends StatelessWidget {
const ActivityBurnSummaryCard({
super.key,
required this.report,
});
const ActivityBurnSummaryCard({super.key, required this.report});
final ActivityBurnReport? report;
... ... @@ -25,50 +26,74 @@ class ActivityBurnSummaryCard extends StatelessWidget {
context,
report?.exerciseMinutes?.value,
);
final stand = activityBurnHoursText(
context,
report?.standHours?.value,
);
return Container(
height: 90,
padding: EdgeInsets.only(left: 20.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityMove,
color: _active,
value: report?.activeEnergy?.value.toString() ?? '-',
unit: context.l10n.reportUnitKcal,
),
final stand = activityBurnHoursText(context, report?.standHours?.value);
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.dailyActivityBurn,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.dailyActivityBurn,
period: FriendInteractionPeriod.day(report?.date ?? DateTime.now()),
metrics: [
FriendInteractionMetric(
value: report?.activeEnergy?.value.toString() ?? '-',
unit: context.l10n.reportUnitKcal,
),
SizedBox(width: 13.w),
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityExercise,
color: isOhos() ? _ohosExercise : _exercise,
value: exercise.value,
unit: exercise.unit,
),
FriendInteractionMetric(
value: exercise.value,
unit: exercise.unit,
label: context.l10n.activityExercise,
),
SizedBox(width: 13.w),
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityStand,
color: _stand,
value: stand.value,
unit: stand.unit,
),
FriendInteractionMetric(
value: stand.value,
unit: stand.unit,
label: context.l10n.activityStand,
),
],
),
child: Container(
height: 90,
// Reserve the upper-right area used by FriendInteractCardOverlay so
// the interaction entry cannot cover the stand metric.
padding: EdgeInsets.only(left: 20.w, right: 56.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityMove,
color: _active,
value: report?.activeEnergy?.value.toString() ?? '-',
unit: context.l10n.reportUnitKcal,
),
),
SizedBox(width: 13.w),
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityExercise,
color: isOhos() ? _ohosExercise : _exercise,
value: exercise.value,
unit: exercise.unit,
),
),
SizedBox(width: 13.w),
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityStand,
color: _stand,
value: stand.value,
unit: stand.unit,
),
),
],
),
),
);
}
}
... ...
... ... @@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -417,6 +418,7 @@ class ActivityBurnTrendData {
required this.activeEnergyComparisonPercent,
required this.isMonth,
required this.barWidth,
required this.period,
});
factory ActivityBurnTrendData.week(WeeklyActivityBurnReport report) =>
... ... @@ -428,6 +430,7 @@ class ActivityBurnTrendData {
activeEnergyComparisonPercent: report.activeEnergyComparisonPercent,
isMonth: false,
barWidth: 16,
period: FriendInteractionPeriod.week(report.weekStart, report.weekEnd),
);
factory ActivityBurnTrendData.month(MonthlyActivityBurnReport report) =>
... ... @@ -439,6 +442,7 @@ class ActivityBurnTrendData {
activeEnergyComparisonPercent: report.activeEnergyComparisonPercent,
isMonth: true,
barWidth: 4,
period: FriendInteractionPeriod.month(report.monthStart),
);
final List<ActivityBurnReport> days;
... ... @@ -448,6 +452,7 @@ class ActivityBurnTrendData {
final int? activeEnergyComparisonPercent;
final bool isMonth;
final double barWidth;
final FriendInteractionPeriod period;
}
class ActivityBurnEnergyTrendCard extends StatefulWidget {
... ... @@ -510,6 +515,11 @@ class _ActivityBurnEnergyTrendCardState
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.weeklyActivityBurn,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.weeklyActivityBurn,
period: widget.report.period,
),
child: Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
... ...
... ... @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -37,6 +38,7 @@ class TodayHrvNumberCard extends StatelessWidget {
? HomeInteractRouteArguments.averageHrv(
context: context,
friendUserId: controller.friendUserId,
friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
date: controller.selectedDate.value,
value: hrvAvg,
)
... ... @@ -47,6 +49,7 @@ class TodayHrvNumberCard extends StatelessWidget {
? HomeInteractRouteArguments.restingHeartRate(
context: context,
friendUserId: controller.friendUserId,
friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
date: controller.selectedDate.value,
value: restingHeartRate,
)
... ... @@ -105,6 +108,7 @@ class _NumberItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final entry = interactionArguments;
return Padding(
padding: const EdgeInsets.only(left: 20, right: 2),
child: Column(
... ... @@ -152,16 +156,29 @@ class _NumberItem extends StatelessWidget {
),
if (interactionEnabled != null) ...[
const SizedBox(width: 8),
FriendInteractionButton(
enabled: interactionEnabled!,
arguments: interactionArguments,
padding: const EdgeInsets.only(
left: 8,
right: 20,
top: 8,
bottom: 0,
if (entry == null)
const FriendInteractClickView(
enabled: false,
padding: EdgeInsets.only(
left: 8,
right: 20,
top: 8,
bottom: 0,
),
)
else
FriendInteractClickView(
enabled: interactionEnabled!,
interactionContext:
FriendInteractionButton.interactionContextFor(entry),
content: FriendInteractionButton.contentFor(context, entry),
padding: const EdgeInsets.only(
left: 8,
right: 20,
top: 8,
bottom: 0,
),
),
),
],
],
),
... ...
... ... @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/arc_progress_widget.dart';
import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
... ... @@ -63,6 +64,7 @@ class TodaySleepCard extends StatelessWidget {
? HomeInteractRouteArguments.sleep(
context: context,
friendUserId: controller.friendUserId,
friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
date: controller.selectedDate.value,
durationSeconds: sleepDuration,
qualityScore: healthData?.sleepScore,
... ... @@ -338,6 +340,7 @@ class TodayActivityCard extends StatelessWidget {
? HomeInteractRouteArguments.fitness(
context: context,
friendUserId: controller.friendUserId,
friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
date: controller.selectedDate.value,
activeCalories: activity,
exerciseSeconds: exercise,
... ... @@ -482,6 +485,7 @@ class _TodaySummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.colors;
final entry = interactionArguments;
return Container(
margin: const EdgeInsets.only(bottom: 8),
... ... @@ -513,10 +517,15 @@ class _TodaySummaryCard extends StatelessWidget {
),
const Spacer(),
if (interactionEnabled != null) ...[
FriendInteractionButton(
enabled: interactionEnabled!,
arguments: interactionArguments,
),
if (entry == null)
const FriendInteractClickView(enabled: false)
else
FriendInteractClickView(
enabled: interactionEnabled!,
interactionContext:
FriendInteractionButton.interactionContextFor(entry),
content: FriendInteractionButton.contentFor(context, entry),
),
],
],
),
... ...
import 'dart:async';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
... ... @@ -88,11 +89,29 @@ class _HrvPeriodReportView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final interactionType = isMonth
? FriendInteractionType.monthStressTrend
: FriendInteractionType.weekStressTrend;
final interactionContent = FriendInteractionContent.forType(
context,
interactionType,
period: switch (report) {
WeeklyHrvReport(:final weekStart) => FriendInteractionPeriod.week(
weekStart,
weekStart.add(const Duration(days: 6)),
),
MonthlyHrvReport(:final monthStart) => FriendInteractionPeriod.month(
monthStart,
),
_ => null,
},
);
return Column(
children: [
_DailyStressCard(
report: report,
isMonth: isMonth,
interactionContent: interactionContent,
showExample: showExample,
onTap: showExample ? onSubscribe : null,
tooltipDismissDelayMs: tooltipDismissDelayMs,
... ... @@ -115,9 +134,8 @@ class _HrvPeriodReportView extends StatelessWidget {
trailing: HealthReportSubjectScope.isViewingSelf(context)
? null
: FriendInteractClickView(
interactionType: isMonth
? FriendInteractionType.monthStressTrend
: FriendInteractionType.weekStressTrend,
interactionType: interactionType,
content: interactionContent,
),
footer: _ExtremeHrvSection(report: report),
onTap: showExample ? onSubscribe : null,
... ... @@ -131,6 +149,7 @@ class _DailyStressCard extends StatelessWidget {
const _DailyStressCard({
required this.report,
required this.isMonth,
required this.interactionContent,
required this.showExample,
this.onTap,
required this.tooltipDismissDelayMs,
... ... @@ -138,6 +157,7 @@ class _DailyStressCard extends StatelessWidget {
final HrvPeriodReport report;
final bool isMonth;
final FriendInteractionContent interactionContent;
final bool showExample;
final VoidCallback? onTap;
final int tooltipDismissDelayMs;
... ... @@ -157,6 +177,7 @@ class _DailyStressCard extends StatelessWidget {
),
showExample: showExample,
isMonth: isMonth,
interactionContent: interactionContent,
),
const SizedBox(height: 18),
SizedBox(
... ... @@ -894,11 +915,13 @@ class _ReportTitle extends StatelessWidget {
required this.title,
required this.showExample,
required this.isMonth,
required this.interactionContent,
});
final String title;
final bool showExample;
final bool isMonth;
final FriendInteractionContent interactionContent;
@override
Widget build(BuildContext context) {
... ... @@ -923,6 +946,7 @@ class _ReportTitle extends StatelessWidget {
interactionType: isMonth
? FriendInteractionType.monthStressTrend
: FriendInteractionType.weekStressTrend,
content: interactionContent,
),
],
],
... ...
... ... @@ -2,6 +2,7 @@ import 'dart:async';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -41,8 +42,13 @@ class HrvYearReportView extends StatelessWidget {
),
trailing: HealthReportSubjectScope.isViewingSelf(context)
? null
: const FriendInteractClickView(
: FriendInteractClickView(
interactionType: FriendInteractionType.yearStressTrend,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.yearStressTrend,
period: FriendInteractionPeriod.year(DateTime(data.year)),
),
),
),
const SizedBox(height: 12),
... ... @@ -73,6 +79,7 @@ class _YearTrendCard extends StatelessWidget {
context,
context.l10n.hrvMonthlyStressTrend,
),
year: report.year,
),
const SizedBox(height: 18),
SizedBox(
... ... @@ -527,6 +534,7 @@ class _DailyDistributionCard extends StatelessWidget {
context.l10n.hrvDailyStressDistribution,
),
trailingInset: 4,
year: report.year,
),
const SizedBox(height: 20),
GridView.builder(
... ... @@ -675,9 +683,14 @@ class _DateExtreme extends StatelessWidget {
}
class _ReportTitle extends StatelessWidget {
const _ReportTitle({required this.title, this.trailingInset = 0});
const _ReportTitle({
required this.title,
required this.year,
this.trailingInset = 0,
});
final String title;
final int year;
final double trailingInset;
@override
... ... @@ -689,8 +702,13 @@ class _ReportTitle extends StatelessWidget {
const Spacer(),
Padding(
padding: EdgeInsets.only(right: trailingInset),
child: const FriendInteractClickView(
child: FriendInteractClickView(
interactionType: FriendInteractionType.yearStressTrend,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.yearStressTrend,
period: FriendInteractionPeriod.year(DateTime(year)),
),
),
),
],
... ...
... ... @@ -9,6 +9,7 @@ class HomeInteractRouteArguments {
static InteractRouteArguments? averageHrv({
required BuildContext context,
required int? friendUserId,
String? friendAvatarUrl,
required DateTime date,
required int value,
}) => value <= 0
... ... @@ -16,6 +17,7 @@ class HomeInteractRouteArguments {
: _create(
context: context,
friendUserId: friendUserId,
friendAvatarUrl: friendAvatarUrl,
date: date,
metricTitle: context.l10n.averageHrvForTheDay,
values: [
... ... @@ -30,6 +32,7 @@ class HomeInteractRouteArguments {
static InteractRouteArguments? restingHeartRate({
required BuildContext context,
required int? friendUserId,
String? friendAvatarUrl,
required DateTime date,
required int value,
}) => value <= 0
... ... @@ -37,6 +40,7 @@ class HomeInteractRouteArguments {
: _create(
context: context,
friendUserId: friendUserId,
friendAvatarUrl: friendAvatarUrl,
date: date,
metricTitle: context.l10n.restingHeartRate,
values: [
... ... @@ -51,6 +55,7 @@ class HomeInteractRouteArguments {
static InteractRouteArguments? sleep({
required BuildContext context,
required int? friendUserId,
String? friendAvatarUrl,
required DateTime date,
required int durationSeconds,
required double? qualityScore,
... ... @@ -61,6 +66,7 @@ class HomeInteractRouteArguments {
: _create(
context: context,
friendUserId: friendUserId,
friendAvatarUrl: friendAvatarUrl,
date: date,
metricTitle: context.l10n.sleep,
values: [
... ... @@ -93,6 +99,7 @@ class HomeInteractRouteArguments {
static InteractRouteArguments? fitness({
required BuildContext context,
required int? friendUserId,
String? friendAvatarUrl,
required DateTime date,
required int activeCalories,
required int exerciseSeconds,
... ... @@ -100,6 +107,7 @@ class HomeInteractRouteArguments {
}) => _create(
context: context,
friendUserId: friendUserId,
friendAvatarUrl: friendAvatarUrl,
date: date,
metricTitle: context.l10n.fitness,
values: [
... ... @@ -127,6 +135,7 @@ class HomeInteractRouteArguments {
static InteractRouteArguments? _create({
required BuildContext context,
required int? friendUserId,
String? friendAvatarUrl,
required DateTime date,
required String metricTitle,
required List<InteractDisplayValue> values,
... ... @@ -140,6 +149,7 @@ class HomeInteractRouteArguments {
title: '${_dateLabel(context, date)} · $metricTitle',
date: DateUtils.dateOnly(date),
values: values,
friendAvatarUrl: friendAvatarUrl,
);
}
... ...
... ... @@ -11,6 +11,7 @@ class InteractRouteArguments {
required this.title,
required this.date,
required this.values,
this.friendAvatarUrl,
});
final int friendUserId;
... ... @@ -18,6 +19,7 @@ class InteractRouteArguments {
final String title;
final DateTime date;
final List<InteractDisplayValue> values;
final String? friendAvatarUrl;
}
enum InteractPageSource { home }
... ...
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/widgets.dart';
import '../../report_common/utils/report_date_formatter.dart';
import '../../report_common/utils/report_localization.dart';
/// The display payload of a friend-interaction sheet.
///
/// The interaction type is an API contract; this class deliberately contains
/// only what the sheet needs to render. A host page can therefore provide its
/// own date-aware title and metrics without duplicating the sheet layout.
@immutable
class FriendInteractionContent {
const FriendInteractionContent({
required this.title,
this.metrics = const [],
});
final String title;
final List<FriendInteractionMetric> metrics;
/// A title-only presentation used by trend reports, where the design has no
/// numeric detail below the title. [period] supplies the date semantics
/// from the table (today/yesterday/a date, a week range, month, or year).
factory FriendInteractionContent.forType(
BuildContext context,
FriendInteractionType type, {
String? title,
FriendInteractionPeriod? period,
List<FriendInteractionMetric> metrics = const [],
}) => FriendInteractionContent(
title: title ?? _titleFor(context, type, period),
metrics: metrics,
);
static String _titleFor(
BuildContext context,
FriendInteractionType type,
FriendInteractionPeriod? period,
) {
if (type == FriendInteractionType.yearStressTrend) {
return context.l10n.interactSceneTitle(
InteractionSceneTitle.yearStressTrend,
year: period?.start.year,
);
}
final isCurrentPeriod = period?.isCurrent ?? false;
final title = switch (type) {
FriendInteractionType.defaultInteraction =>
context.l10n.interactTodaySteps,
FriendInteractionType.todayAverageHrv => context.l10n.interactSceneTitle(
InteractionSceneTitle.todayAverageHrv,
),
FriendInteractionType.todayRestingHeartRate =>
context.l10n.interactSceneTitle(
InteractionSceneTitle.todayRestingHeartRate,
),
FriendInteractionType.todaySleepStatus ||
FriendInteractionType.dailySleepStatus => context.l10n.interactSceneTitle(
InteractionSceneTitle.todaySleepStatus,
),
FriendInteractionType.todayFitnessStatus ||
FriendInteractionType.dailyActivityBurn =>
context.l10n.interactSceneTitle(
type == FriendInteractionType.todayFitnessStatus
? InteractionSceneTitle.todayFitnessStatus
: InteractionSceneTitle.todayActivityBurn,
),
FriendInteractionType.weekStressTrend => context.l10n.interactSceneTitle(
isCurrentPeriod
? InteractionSceneTitle.weekStressTrend
: InteractionSceneTitle.stressTrend,
),
FriendInteractionType.monthStressTrend => context.l10n.interactSceneTitle(
isCurrentPeriod
? InteractionSceneTitle.monthStressTrend
: InteractionSceneTitle.stressTrend,
),
FriendInteractionType.weeklyActivityBurn ||
FriendInteractionType.monthlyActivityBurn =>
context.l10n.interactSceneTitle(
type == FriendInteractionType.weeklyActivityBurn
? (isCurrentPeriod
? InteractionSceneTitle.weekActivityBurn
: InteractionSceneTitle.activityBurn)
: (isCurrentPeriod
? InteractionSceneTitle.monthActivityBurn
: InteractionSceneTitle.activityBurn),
),
FriendInteractionType.weeklySleepTime ||
FriendInteractionType.monthlySleepTime => context.l10n.interactSceneTitle(
type == FriendInteractionType.weeklySleepTime
? (isCurrentPeriod
? InteractionSceneTitle.weekSleepTime
: InteractionSceneTitle.sleepTime)
: (isCurrentPeriod
? InteractionSceneTitle.monthSleepTime
: InteractionSceneTitle.sleepTime),
),
FriendInteractionType.pkSteps => context.l10n.interactSceneTitle(
InteractionSceneTitle.pkSteps,
),
FriendInteractionType.pkCalories => context.l10n.interactSceneTitle(
InteractionSceneTitle.pkCalories,
),
FriendInteractionType.pkStandCount => context.l10n.interactSceneTitle(
InteractionSceneTitle.pkStandTime,
),
FriendInteractionType.pkMonthlyResult => context.l10n.interactSceneTitle(
InteractionSceneTitle.pkMonthlyResult,
),
FriendInteractionType.yearStressTrend => '',
};
final periodLabel = period?.labelFor(context);
return periodLabel == null || periodLabel.isEmpty
? title
: '$title ($periodLabel)';
}
}
/// Describes the selected report time without coupling the sheet to a page
/// controller. This covers every date form in the interaction-scene table.
@immutable
class FriendInteractionPeriod {
const FriendInteractionPeriod.day(this.start, {this.referenceDate})
: kind = FriendInteractionPeriodKind.day,
end = null,
periodName = null;
const FriendInteractionPeriod.week(
this.start,
this.end, {
this.periodName,
this.referenceDate,
}) : kind = FriendInteractionPeriodKind.week;
const FriendInteractionPeriod.month(
this.start, {
this.periodName,
this.referenceDate,
}) : kind = FriendInteractionPeriodKind.month,
end = null;
const FriendInteractionPeriod.year(
this.start, {
this.periodName,
this.referenceDate,
}) : kind = FriendInteractionPeriodKind.year,
end = null;
final FriendInteractionPeriodKind kind;
final DateTime start;
final DateTime? end;
/// Localized page label when one exists, e.g. “This week”. It is optional
/// because a locale-aware date range remains meaningful on its own.
final String? periodName;
final DateTime? referenceDate;
/// Whether this period is the current natural period relative to the
/// supplied reference date (or today). Historical periods intentionally use
/// neutral titles rather than “this week” or “this month”.
bool get isCurrent {
final reference = _dateOnly(referenceDate ?? DateTime.now());
final date = _dateOnly(start);
return switch (kind) {
FriendInteractionPeriodKind.day => date == reference,
FriendInteractionPeriodKind.week => _isCurrentWeek(date, reference),
FriendInteractionPeriodKind.month => _isCurrentMonth(date, reference),
FriendInteractionPeriodKind.year => date.year == reference.year,
};
}
String labelFor(BuildContext context) {
final reference = _dateOnly(referenceDate ?? DateTime.now());
final locale = reportLocaleName(context);
final date = _dateOnly(start);
return switch (kind) {
FriendInteractionPeriodKind.day => _dayLabel(
context,
date,
reference,
locale,
),
FriendInteractionPeriodKind.week => _join(
periodName ??
(_isCurrentWeek(date, reference)
? context.l10n.interactThisWeek
: null),
ReportDateFormatter.weekRange(
date,
_dateOnly(end ?? start),
locale,
referenceDate: reference,
),
),
FriendInteractionPeriodKind.month => _join(
periodName ??
(_isCurrentMonth(date, reference)
? context.l10n.interactThisMonth
: null),
ReportDateFormatter.weekRange(
date,
DateTime(date.year, date.month + 1, 0),
locale,
referenceDate: reference,
),
),
FriendInteractionPeriodKind.year => _join(
periodName,
ReportDateFormatter.year(date, locale),
),
};
}
static String _dayLabel(
BuildContext context,
DateTime date,
DateTime reference,
String locale,
) {
final difference = reference.difference(date).inDays;
if (difference == 0) return context.l10n.today;
if (difference == 1) return context.l10n.yesterday;
return date.year == reference.year
? ReportDateFormatter.monthDay(date, locale)
: ReportDateFormatter.yearMonthDay(date, locale);
}
static DateTime _dateOnly(DateTime value) =>
DateTime(value.year, value.month, value.day);
static bool _isCurrentWeek(DateTime date, DateTime reference) {
final currentWeekStart = reference.subtract(
Duration(days: reference.weekday - DateTime.monday),
);
return date == currentWeekStart;
}
static bool _isCurrentMonth(DateTime date, DateTime reference) =>
date.year == reference.year && date.month == reference.month;
static String _join(String? name, String date) =>
name == null || name.isEmpty ? date : '$name, $date';
}
enum FriendInteractionPeriodKind { day, week, month, year }
/// One horizontally arranged data item in [FriendInteractionContent].
@immutable
class FriendInteractionMetric {
const FriendInteractionMetric({
required this.value,
this.unit,
this.label,
this.isText = false,
});
final String value;
final String? unit;
/// Optional compact pill (for example, “Workout”, “Stand”, or “Quality”).
final String? label;
/// Textual states such as sleep quality use the smaller value treatment in
/// the Figma design instead of the large numeric treatment.
final bool isText;
}
... ...
... ... @@ -5,6 +5,8 @@ import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'friend_interaction_content.dart';
enum FriendInteractionAction { miss, stick, punch }
/// Friend-interaction bottom sheet. It accepts independent callbacks so each
... ... @@ -13,7 +15,7 @@ class FriendInteractionDialog extends StatelessWidget {
const FriendInteractionDialog({
super.key,
required this.avatar,
required this.steps,
required this.content,
this.onAction,
this.onMiss,
this.onPoke,
... ... @@ -21,7 +23,7 @@ class FriendInteractionDialog extends StatelessWidget {
});
final ImageProvider? avatar;
final String steps;
final FriendInteractionContent content;
/// Compatibility callback for callers that prefer one typed action handler.
final ValueChanged<FriendInteractionAction>? onAction;
... ... @@ -32,7 +34,7 @@ class FriendInteractionDialog extends StatelessWidget {
static Future<T?> show<T>(
BuildContext context, {
required ImageProvider? avatar,
required String steps,
required FriendInteractionContent content,
ValueChanged<FriendInteractionAction>? onAction,
VoidCallback? onMiss,
VoidCallback? onPoke,
... ... @@ -48,7 +50,7 @@ class FriendInteractionDialog extends StatelessWidget {
),
builder: (_) => FriendInteractionDialog(
avatar: avatar,
steps: steps,
content: content,
onAction: onAction,
onMiss: onMiss,
onPoke: onPoke,
... ... @@ -64,44 +66,48 @@ class FriendInteractionDialog extends StatelessWidget {
final isVip =
Get.find<UserPreferencesStorage>().preferences.value.vipInfo?.isVip ==
true;
final hasMetrics = content.metrics.isNotEmpty;
return Material(
color: Colors.transparent,
child: SafeArea(
top: false,
child: SizedBox(
height: 333,
// Figma's detail sheet is 367px tall. Its values occupy one
// baseline, so the actions keep the same 213px top offset.
height: hasMetrics ? 367 : 333,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: -77,
left: 22,
top: -70,
left: 13,
child: Image.asset(
'assets/images/interact/ic_friend_interact.png',
width: 98,
height: 106,
width: 100,
height: 80,
fit: BoxFit.contain,
),
),
Positioned(
top: -24,
left: 28,
top: -23,
left: 40,
child: Transform.rotate(
angle: -0.08,
angle: -0.0785,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 8,
),
width: 184,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFA084EF),
borderRadius: BorderRadius.circular(12),
),
child: Text(
context.l10n.interactActionSheetInvitation,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
child: Center(
child: Text(
context.l10n.interactActionSheetInvitation,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
... ... @@ -127,41 +133,23 @@ class FriendInteractionDialog extends StatelessWidget {
left: 0,
right: 0,
child: Text(
context.l10n.interactTodaySteps,
content.title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 16,
),
),
),
Positioned(
top: 163,
left: 0,
right: 0,
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: steps,
style: const TextStyle(
color: Color(0xFF845EEE),
fontSize: 28,
fontWeight: FontWeight.w600,
),
),
TextSpan(
text: context.l10n.interactStepsUnit,
style: const TextStyle(
color: Color(0xFF845EEE),
fontSize: 16,
),
),
],
),
if (content.metrics.isNotEmpty)
Positioned(
top: 163,
left: 20,
right: 20,
child: _FriendInteractionMetrics(metrics: content.metrics),
),
),
Positioned(
top: 213,
left: 0,
... ... @@ -181,17 +169,26 @@ class FriendInteractionDialog extends StatelessWidget {
}
Widget _buildAvatar() => Center(
child: Container(
child: SizedBox(
width: 90,
height: 90,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(
child: avatar == null
? const ColoredBox(
color: Color(0xFFF5F4FF),
child: Icon(Icons.person, color: Color(0xFF908B91)),
)
: Image(image: avatar!, fit: BoxFit.cover),
),
),
),
child: avatar == null
? const Icon(Icons.person, color: Color(0xFF908B91))
: Image(image: avatar!, fit: BoxFit.cover),
),
);
... ... @@ -211,6 +208,78 @@ class FriendInteractionDialog extends StatelessWidget {
}
}
class _FriendInteractionMetrics extends StatelessWidget {
const _FriendInteractionMetrics({required this.metrics});
final List<FriendInteractionMetric> metrics;
@override
Widget build(BuildContext context) => FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var index = 0; index < metrics.length; index++) ...[
_MetricInline(metric: metrics[index]),
if (index != metrics.length - 1) const SizedBox(width: 8),
],
],
),
);
}
class _MetricInline extends StatelessWidget {
const _MetricInline({required this.metric});
final FriendInteractionMetric metric;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
RichText(
text: TextSpan(
children: [
TextSpan(
text: metric.value,
style: TextStyle(
color: const Color(0xFF845EEE),
fontSize: metric.isText ? 24 : 28,
fontWeight: FontWeight.w500,
),
),
if (metric.unit?.isNotEmpty == true)
TextSpan(
text: metric.unit,
style: const TextStyle(color: Color(0xFF845EEE), fontSize: 16),
),
],
),
),
if (metric.label?.isNotEmpty == true)
Container(
margin: const EdgeInsets.only(left: 3),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFFE8E0FF),
borderRadius: BorderRadius.circular(8),
),
child: Text(
metric.label!,
style: const TextStyle(
color: Color(0xFF845EEE),
fontSize: 10,
height: 1.4,
),
),
),
],
);
}
/// Reusable three-action group for any friend-interaction entry point.
///
/// Miss and Punch are Pro actions. Their callbacks can therefore open a
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../widget/friend_interact_click_view.dart';
class FriendInteractionButton extends StatelessWidget {
const FriendInteractionButton({
super.key,
... ... @@ -23,23 +28,154 @@ class FriendInteractionButton extends StatelessWidget {
final EdgeInsets padding;
@override
Widget build(BuildContext context) => Opacity(
opacity: enabled ? 1 : 0.4,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled ? _openInteraction : null,
child: Padding(
padding: padding,
child: SizedBox(
width: size,
height: size,
child: Image.asset('assets/images/interact/icon_stick.png'),
),
),
),
Widget build(BuildContext context) {
final entry = arguments;
if (entry == null) return const SizedBox.shrink();
return FriendInteractClickView(
enabled: enabled,
size: size,
padding: padding,
interactionContext: interactionContextFor(entry),
content: contentFor(context, entry),
);
}
/// Adapts the legacy app-home card payload to the shared dialog contract.
static FriendInteractionContext interactionContextFor(
InteractRouteArguments entry,
) => FriendInteractionContext(
friendUserId: entry.friendUserId,
type: interactionTypeFor(entry),
avatarUrl: entry.friendAvatarUrl,
);
static FriendInteractionContent contentFor(
BuildContext context,
InteractRouteArguments entry,
) => FriendInteractionContent.forType(
context,
interactionTypeFor(entry),
period: FriendInteractionPeriod.day(entry.date),
metrics: _metricsFor(context, entry.values),
);
void _openInteraction() => Get.toNamed(Routes.INTERACT, arguments: arguments);
static FriendInteractionType interactionTypeFor(
InteractRouteArguments entry,
) {
final values = entry.values;
if (values.isEmpty) return FriendInteractionType.defaultInteraction;
return switch (values.first.type) {
InteractValueType.averageHrv => FriendInteractionType.todayAverageHrv,
InteractValueType.restingHeartRate =>
FriendInteractionType.todayRestingHeartRate,
InteractValueType.sleepDuration ||
InteractValueType.sleepQualityScore ||
InteractValueType.sleepQualityState ||
InteractValueType.averageSleepHeartRate =>
FriendInteractionType.todaySleepStatus,
InteractValueType.activeCalories ||
InteractValueType.exerciseDuration ||
InteractValueType.standDuration =>
FriendInteractionType.todayFitnessStatus,
};
}
static List<FriendInteractionMetric> _metricsFor(
BuildContext context,
List<InteractDisplayValue> values,
) {
InteractDisplayValue? valueOf(InteractValueType type) =>
values.where((value) => value.type == type).firstOrNull;
final first = values.firstOrNull;
if (first == null) return const [];
switch (first.type) {
case InteractValueType.averageHrv:
return [
FriendInteractionMetric(value: _number(first.value), unit: 'ms'),
];
case InteractValueType.restingHeartRate:
return [
FriendInteractionMetric(value: _number(first.value), unit: 'bpm'),
];
case InteractValueType.sleepDuration:
case InteractValueType.sleepQualityScore:
case InteractValueType.sleepQualityState:
case InteractValueType.averageSleepHeartRate:
final duration = valueOf(InteractValueType.sleepDuration);
final quality = valueOf(InteractValueType.sleepQualityState);
final heartRate = valueOf(InteractValueType.averageSleepHeartRate);
return [
if (duration != null) _sleepDurationMetric(context, duration.value),
if (quality != null)
FriendInteractionMetric(
value: _sleepQuality(context, quality.value.toInt()),
label: context.l10n.quality,
isText: true,
),
if (heartRate != null)
FriendInteractionMetric(
value: _number(heartRate.value),
unit: 'bpm',
label: context.l10n.averageHeartRate,
),
];
case InteractValueType.activeCalories:
case InteractValueType.exerciseDuration:
case InteractValueType.standDuration:
final calories = valueOf(InteractValueType.activeCalories);
final exercise = valueOf(InteractValueType.exerciseDuration);
final stand = valueOf(InteractValueType.standDuration);
return [
if (calories != null)
FriendInteractionMetric(
value: _number(calories.value),
unit: context.l10n.reportUnitKcal,
label: context.l10n.event,
),
if (exercise != null)
FriendInteractionMetric(
value: _number(exercise.value ~/ 60),
unit: context.l10n.activityUnitMinute,
label: context.l10n.exercise,
),
if (stand != null)
FriendInteractionMetric(
value: _number(stand.value),
unit: context.l10n.activityUnitHour,
label: context.l10n.standing,
),
];
}
}
static FriendInteractionMetric _sleepDurationMetric(
BuildContext context,
num raw,
) {
final totalMinutes = raw ~/ 60;
final hours = totalMinutes ~/ 60;
final minutes = totalMinutes % 60;
return FriendInteractionMetric(
value: '$hours',
unit:
'${context.l10n.sleepDailyUnitHour}'
'${minutes == 0 ? '' : '$minutes${context.l10n.sleepDailyUnitMinute}'}',
label: context.l10n.duration,
);
}
static String _sleepQuality(BuildContext context, int state) =>
switch (state) {
1 => context.l10n.sleepQualityExcellent,
2 => context.l10n.sleepQualityNormal,
3 => context.l10n.sleepQualityAttention,
_ => '--',
};
static String _number(num value) => value == value.roundToDouble()
? value.toInt().toString()
: value.toString();
}
class FriendInteractionFloatingEntry extends StatelessWidget {
... ...
import 'dart:math' as math;
import 'package:doublefeel_flutter/app/widget/friend_interact_click_view.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:flutter/material.dart';
... ... @@ -455,10 +456,11 @@ class _ScoreBar extends StatelessWidget {
}
class _PokeButton extends StatelessWidget {
const _PokeButton({this.onTap, this.interactionContext});
const _PokeButton({this.onTap, this.interactionContext, this.content});
final VoidCallback? onTap;
final FriendInteractionContext? interactionContext;
final FriendInteractionContent? content;
@override
Widget build(BuildContext context) => SizedBox(
... ... @@ -476,7 +478,10 @@ class _PokeButton extends StatelessWidget {
child: _asset('poke', 24, 24),
),
)
: FriendInteractClickView(interactionContext: interactionContext),
: FriendInteractClickView(
interactionContext: interactionContext,
content: content,
),
),
);
}
... ... @@ -531,6 +536,19 @@ class _MetricRow extends StatelessWidget {
_PokeButton(
onTap: onPoke == null ? null : () => onPoke!(icon),
interactionContext: interactionContext,
content: FriendInteractionContent.forType(
context,
interactionContext?.type ??
FriendInteractionType.defaultInteraction,
metrics: [
FriendInteractionMetric(
// The action is rendered next to, and sent to, the
// partner on the left side of the PK card.
value: left ?? '--',
unit: unit,
),
],
),
),
],
),
... ... @@ -607,6 +625,10 @@ class _MonthCard extends StatelessWidget {
_PokeButton(
onTap: onPoke == null ? null : () => onPoke!('monthly'),
interactionContext: _interactionContext,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.pkMonthlyResult,
),
),
],
),
... ...
... ... @@ -2,10 +2,8 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../widget/friend_interact_click_view.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/sleep_report_models.dart';
... ... @@ -31,129 +29,120 @@ class SleepHeartRateCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.dailySleepStatus,
child: Container(
height: 358,
padding: EdgeInsets.fromLTRB(20, 20.h, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.sleepHeartRateTitle,
),
style: const TextStyle(
color: _h1,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
return Container(
height: 358,
padding: EdgeInsets.fromLTRB(20, 20.h, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.sleepHeartRateTitle,
),
const SizedBox(height: 24),
_MetricRow(
summary: hasSleepData ? summary : const SleepHeartRateSummary(),
style: const TextStyle(
color: _h1,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
const SizedBox(height: 18),
Expanded(
child: Builder(
builder: (context) {
final xAxis = _heartRateXAxis;
final chartPoints = _chartPoints(xAxis);
final hasChartData = hasSleepData && chartPoints.isNotEmpty;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
if (hasChartData) ...[
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: LayoutBuilder(
builder: (context, constraints) => LineChart(
_chartData(
xAxis: xAxis,
chartPoints: chartPoints,
chartWidth: constraints.maxWidth,
),
),
const SizedBox(height: 24),
_MetricRow(
summary: hasSleepData ? summary : const SleepHeartRateSummary(),
),
const SizedBox(height: 18),
Expanded(
child: Builder(
builder: (context) {
final xAxis = _heartRateXAxis;
final chartPoints = _chartPoints(xAxis);
final hasChartData = hasSleepData && chartPoints.isNotEmpty;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
if (hasChartData) ...[
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: LayoutBuilder(
builder: (context, constraints) => LineChart(
_chartData(
xAxis: xAxis,
chartPoints: chartPoints,
chartWidth: constraints.maxWidth,
),
),
),
const Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateBoundaryGridLine(),
),
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateAxisLabels(
range: _HeartRateYAxisRange.fromPoints(chartPoints),
),
),
Positioned.fill(
child: _HeartRateTimeLabels(axis: xAxis),
),
const Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateBoundaryGridLine(),
),
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateAxisLabels(
range: _HeartRateYAxisRange.fromPoints(chartPoints),
),
] else ...[
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: LayoutBuilder(
builder: (context, constraints) => LineChart(
_chartData(
xAxis: xAxis,
chartPoints: chartPoints,
chartWidth: constraints.maxWidth,
),
),
Positioned.fill(child: _HeartRateTimeLabels(axis: xAxis)),
] else ...[
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: LayoutBuilder(
builder: (context, constraints) => LineChart(
_chartData(
xAxis: xAxis,
chartPoints: chartPoints,
chartWidth: constraints.maxWidth,
),
),
),
const Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateBoundaryGridLine(drawTop: true),
),
const Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateBoundaryGridLine(drawTop: true),
),
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateAxisLabels(
range: _HeartRateYAxisRange.fromPoints(chartPoints),
),
Positioned.fill(
top: _chartTopInset,
bottom: 20,
child: _HeartRateAxisLabels(
range: _HeartRateYAxisRange.fromPoints(chartPoints),
),
),
Positioned.fill(
child: _HeartRateTimeLabels(
axis: xAxis,
tickCount: 4,
),
),
Positioned.fill(
bottom: 20,
child: Center(
child: Text(
context.l10n.sleepPeriodAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
Positioned.fill(
child: _HeartRateTimeLabels(axis: xAxis, tickCount: 4),
),
Positioned.fill(
bottom: 20,
child: Center(
child: Text(
context.l10n.sleepPeriodAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
],
),
],
);
},
),
],
);
},
),
],
),
),
],
),
);
}
... ...
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../widget/friend_interact_click_view.dart';
... ... @@ -28,6 +29,26 @@ class SleepSummaryCard extends StatelessWidget {
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.dailySleepStatus,
content: FriendInteractionContent.forType(
context,
FriendInteractionType.dailySleepStatus,
period: FriendInteractionPeriod.day(report?.date ?? DateTime.now()),
metrics: [
FriendInteractionMetric(
value: duration?.hoursPart.toString() ?? '-',
unit: context.l10n.sleepDailyUnitHour,
),
FriendInteractionMetric(
value: duration?.minutesPart.toString() ?? '-',
unit: context.l10n.sleepDailyUnitMinute,
),
FriendInteractionMetric(
value: quality.shortText,
label: context.l10n.sleepDailyQualityTitle,
isText: true,
),
],
),
child: Container(
height: 90,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
... ...
import 'dart:async';
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
... ... @@ -51,6 +52,11 @@ class SleepWeekReportView extends StatelessWidget {
Widget build(BuildContext context) {
final weekly = report;
return _SleepPeriodTrendContent(
interactionType: FriendInteractionType.weeklySleepTime,
period: FriendInteractionPeriod.week(
weekly?.weekStart ?? DateTime.now(),
weekly?.weekEnd ?? DateTime.now(),
),
days: weekly?.days ?? const <SleepReport>[],
hasData: weekly?.hasData ?? false,
averageDuration: weekly?.averageDurationMinutes,
... ... @@ -85,6 +91,8 @@ class SleepMonthReportView extends StatelessWidget {
Widget build(BuildContext context) {
final monthly = report ?? MonthlySleepReport.empty(monthStart);
return _SleepPeriodTrendContent(
interactionType: FriendInteractionType.monthlySleepTime,
period: FriendInteractionPeriod.month(monthly.monthStart),
days: monthly.days,
hasData: monthly.hasData,
averageDuration: monthly.averageDurationMinutes,
... ... @@ -105,6 +113,8 @@ class SleepMonthReportView extends StatelessWidget {
class _SleepPeriodTrendContent extends StatelessWidget {
const _SleepPeriodTrendContent({
required this.interactionType,
required this.period,
required this.days,
required this.hasData,
required this.averageDuration,
... ... @@ -122,6 +132,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
});
final List<SleepReport> days;
final FriendInteractionType interactionType;
final FriendInteractionPeriod period;
final bool hasData;
final int? averageDuration;
final num? targetDurationSeconds;
... ... @@ -139,6 +151,11 @@ class _SleepPeriodTrendContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final compact = days.length > 7;
final interactionContent = FriendInteractionContent.forType(
context,
interactionType,
period: period,
);
return Column(
children: [
... ... @@ -153,6 +170,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
),
const SizedBox(height: 20),
_TrendCard(
interactionType: interactionType,
content: interactionContent,
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.sleepDurationTitle,
... ... @@ -177,6 +196,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
),
const SizedBox(height: 12),
_TrendCard(
interactionType: interactionType,
content: interactionContent,
height: 418,
title: HealthReportSubjectScope.titleOf(
context,
... ... @@ -201,6 +222,8 @@ class _SleepPeriodTrendContent extends StatelessWidget {
),
const SizedBox(height: 12),
_TrendCard(
interactionType: interactionType,
content: interactionContent,
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.sleepBedtime,
... ... @@ -446,6 +469,8 @@ class _TrendCard extends StatelessWidget {
required this.chart,
required this.leftMetric,
required this.rightMetric,
required this.content,
required this.interactionType,
});
final String title;
... ... @@ -453,12 +478,15 @@ class _TrendCard extends StatelessWidget {
final Widget chart;
final Widget leftMetric;
final Widget rightMetric;
final FriendInteractionContent content;
final FriendInteractionType interactionType;
@override
Widget build(BuildContext context) {
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.weeklySleepTime,
interactionType: interactionType,
content: content,
child: Container(
height: height,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_dialog.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_content.dart';
import 'package:doublefeel_flutter/app/modules/report_common/widgets/health_report_subject_scope.dart';
import 'package:doublefeel_flutter/core/network/api/interaction_api.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
... ... @@ -14,49 +15,73 @@ class FriendInteractClickView extends StatelessWidget {
this.onAction,
this.interactionContext,
this.interactionType = FriendInteractionType.defaultInteraction,
this.content,
this.enabled = true,
this.size = 24,
this.padding = EdgeInsets.zero,
});
final VoidCallback? onAction;
final FriendInteractionContext? interactionContext;
final FriendInteractionType interactionType;
final FriendInteractionContent? content;
final bool enabled;
final double size;
final EdgeInsets padding;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
onAction?.call();
final interaction = _interactionContextOf(context);
FriendInteractionDialog.show<void>(
context,
avatar: _avatar(interaction),
steps: '15627',
onMiss: interaction == null
? null
: () => unawaited(
_sendInteraction(interaction, FriendInteractionActionType.miss),
),
onPoke: interaction == null
? null
: () => unawaited(
_sendInteraction(interaction, FriendInteractionActionType.poke),
),
onPunch: interaction == null
? null
: () => unawaited(
_sendInteraction(
interaction,
FriendInteractionActionType.punch,
),
),
);
},
child: SizedBox(
width: 24.dp,
height: 24.dp,
child: Image.asset(
'assets/images/interact/ic_friend_interact_click.png',
width: double.infinity,
height: double.infinity,
Widget build(BuildContext context) => Opacity(
opacity: enabled ? 1 : .4,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
onAction?.call();
final interaction = _interactionContextOf(context);
FriendInteractionDialog.show<void>(
context,
avatar: _avatar(interaction),
content:
content ??
FriendInteractionContent.forType(context, interactionType),
onMiss: interaction == null
? null
: () => unawaited(
_sendInteraction(
interaction,
FriendInteractionActionType.miss,
),
),
onPoke: interaction == null
? null
: () => unawaited(
_sendInteraction(
interaction,
FriendInteractionActionType.poke,
),
),
onPunch: interaction == null
? null
: () => unawaited(
_sendInteraction(
interaction,
FriendInteractionActionType.punch,
),
),
);
}
: null,
child: Padding(
padding: padding,
child: SizedBox(
width: size.dp,
height: size.dp,
child: Image.asset(
'assets/images/interact/ic_friend_interact_click.png',
width: double.infinity,
height: double.infinity,
),
),
),
),
);
... ... @@ -99,11 +124,13 @@ class FriendInteractCardOverlay extends StatelessWidget {
required this.showInteraction,
required this.child,
this.interactionType = FriendInteractionType.defaultInteraction,
this.content,
});
final bool showInteraction;
final Widget child;
final FriendInteractionType interactionType;
final FriendInteractionContent? content;
@override
Widget build(BuildContext context) => Stack(
... ... @@ -113,7 +140,10 @@ class FriendInteractCardOverlay extends StatelessWidget {
Positioned(
top: 20,
right: 20,
child: FriendInteractClickView(interactionType: interactionType),
child: FriendInteractClickView(
interactionType: interactionType,
content: content,
),
),
],
);
... ...
import '../../../data/models/interaction/interaction_models.dart';
import '../../result/app_result.dart';
import '../../result/safe_call.dart';
import '../../../data/models/interaction/interaction_models.dart';
import '../api_paths.dart';
import '../dio_client.dart';
... ... @@ -35,4 +35,15 @@ class InteractionApi {
},
);
}
Future<AppResult<void>> getPkInfo(int queryUserId) {
return safeCall(
call: () async {
await _dioClient.dio.get(
ApiPaths.pkInfo,
queryParameters: {'query_user_id': queryUserId},
);
},
);
}
}
... ...
... ... @@ -70,6 +70,7 @@ abstract final class ApiPaths {
static const interactionRecords =
'/client/doublefeel/interaction/v2/records/';
static const interactionAction = '/client/doublefeel/interaction/v2/action/';
static const pkInfo = '/client/doublefeel/health/v2/pk_info/';
// Config
static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/';
... ...
... ... @@ -30,35 +30,212 @@ AppLocalizations currentAppLocalizations() {
return lookupAppLocalizations(locale);
}
/// Explicit dialog-title vocabulary. Keeping this typed prevents individual
/// reports from drifting away from the interaction-scene table.
enum InteractionSceneTitle {
todayAverageHrv,
todayRestingHeartRate,
todaySleepStatus,
todayFitnessStatus,
weekStressTrend,
monthStressTrend,
stressTrend,
yearStressTrend,
todayActivityBurn,
weekActivityBurn,
monthActivityBurn,
activityBurn,
weekSleepTime,
monthSleepTime,
sleepTime,
pkSteps,
pkCalories,
pkStandTime,
pkMonthlyResult,
}
/// Temporary compatibility getters while the checked-in localization output is
/// regenerated. Once `flutter gen-l10n` runs, the generated instance members
/// take precedence over these extension members.
extension InteractionAppLocalizations on AppLocalizations {
String interactSceneTitle(InteractionSceneTitle title, {int? year}) {
final zhHans = switch (title) {
InteractionSceneTitle.todayAverageHrv => '今日平均HRV',
InteractionSceneTitle.todayRestingHeartRate => '今日静息心率',
InteractionSceneTitle.todaySleepStatus => '今日睡眠情况',
InteractionSceneTitle.todayFitnessStatus => '今日健身情况',
InteractionSceneTitle.weekStressTrend => '本周压力趋势',
InteractionSceneTitle.monthStressTrend => '本月压力趋势',
InteractionSceneTitle.stressTrend => '压力趋势',
InteractionSceneTitle.yearStressTrend =>
'${year ?? DateTime.now().year}年压力趋势',
InteractionSceneTitle.todayActivityBurn => '今日活动消耗',
InteractionSceneTitle.weekActivityBurn => '本周活动消耗',
InteractionSceneTitle.monthActivityBurn => '本月活动消耗',
InteractionSceneTitle.activityBurn => '活动消耗',
InteractionSceneTitle.weekSleepTime => '本周入睡时间',
InteractionSceneTitle.monthSleepTime => '本月入睡时间',
InteractionSceneTitle.sleepTime => '入睡时间',
InteractionSceneTitle.pkSteps => 'PK-今日步数',
InteractionSceneTitle.pkCalories => 'PK-活动记录',
InteractionSceneTitle.pkStandTime => 'PK-站立时间',
InteractionSceneTitle.pkMonthlyResult => 'PK-本月胜率',
};
final english = switch (title) {
InteractionSceneTitle.todayAverageHrv => 'Today’s average HRV',
InteractionSceneTitle.todayRestingHeartRate =>
'Today’s resting heart rate',
InteractionSceneTitle.todaySleepStatus => 'Today’s sleep',
InteractionSceneTitle.todayFitnessStatus => 'Today’s fitness',
InteractionSceneTitle.weekStressTrend => 'Weekly stress trend',
InteractionSceneTitle.monthStressTrend => 'Monthly stress trend',
InteractionSceneTitle.stressTrend => 'Stress trend',
InteractionSceneTitle.yearStressTrend =>
'${year ?? DateTime.now().year} stress trend',
InteractionSceneTitle.todayActivityBurn => 'Today’s activity burn',
InteractionSceneTitle.weekActivityBurn => 'Weekly activity burn',
InteractionSceneTitle.monthActivityBurn => 'Monthly activity burn',
InteractionSceneTitle.activityBurn => 'Activity burn',
InteractionSceneTitle.weekSleepTime => 'Weekly sleep time',
InteractionSceneTitle.monthSleepTime => 'Monthly sleep time',
InteractionSceneTitle.sleepTime => 'Sleep time',
InteractionSceneTitle.pkSteps => 'PK - today’s steps',
InteractionSceneTitle.pkCalories => 'PK - activity record',
InteractionSceneTitle.pkStandTime => 'PK - standing time',
InteractionSceneTitle.pkMonthlyResult => 'PK - monthly win rate',
};
return switch (localeName) {
'zh' || 'zh_Hans' => zhHans,
'zh_Hant' =>
zhHans
.replaceAll('压力', '壓力')
.replaceAll('静息', '靜息')
.replaceAll('情况', '情況')
.replaceAll('活动', '活動')
.replaceAll('站立', '站立')
.replaceAll('胜率', '勝率'),
_ => english,
};
}
String get interactActionSheetInvitation => _interactionStrings.$1;
String get interactTodaySteps => _interactionStrings.$2;
String get interactStepsUnit => _interactionStrings.$3;
String get interactActionMiss => _interactionStrings.$4;
String get interactActionStick => _interactionStrings.$5;
String get interactActionPunch => _interactionStrings.$6;
String get interactThisWeek => switch (localeName) {
'zh' || 'zh_Hans' => '本周',
'zh_Hant' => '本週',
_ => 'This week',
};
String get interactThisMonth => switch (localeName) {
'zh' || 'zh_Hans' => '本月',
'zh_Hant' => '本月',
_ => 'This month',
};
(String, String, String, String, String, String) get _interactionStrings =>
switch (localeName) {
'zh' || 'zh_Hans' => ('快来和我互动吧~', '今日步数', '步', '想你', '戳一戳', '打一拳'),
'zh_Hant' => ('快來和我互動吧~', '今日步數', '步', '想你', '戳一戳', '打一拳'),
'ja' => ('一緒に交流しよう~', '今日の歩数', '歩', '会いたい', 'つんつん', 'パンチ'),
'ko' => ('나와 함께 소통해요~', '오늘의 걸음 수', '걸음', '보고 싶어', '콕 찌르기', '주먹'),
'de' => ('Lass uns interagieren~', 'Heutige Schritte', 'Schritte', 'Vermisse dich', 'Anstupsen', 'Boxen'),
'es' => ('¡Ven a interactuar conmigo!', 'Pasos de hoy', 'pasos', 'Te extraño', 'Toquecito', 'Puñetazo'),
'fil' => ('Makipag-ugnayan tayo~', 'Mga hakbang ngayon', 'hakbang', 'Miss kita', 'Tapikin', 'Suntok'),
'fr' => ('Viens interagir avec moi~', 'Pas du jour', 'pas', 'Tu me manques', 'Taquiner', 'Coup de poing'),
'hi' => ('मेरे साथ इंटरैक्ट करें~', 'आज के कदम', 'कदम', 'तुम्हारी याद आती है', 'हल्का धक्का', 'मुक्का'),
'it' => ('Vieni a interagire con me~', 'Passi di oggi', 'passi', 'Mi manchi', 'Stuzzica', 'Pugno'),
'nl' => ('Kom met me interacteren~', 'Stappen van vandaag', 'stappen', 'Ik mis je', 'Prikje', 'Stoot'),
'pt' || 'pt_BR' => ('Venha interagir comigo~', 'Passos de hoje', 'passos', 'Sinto sua falta', 'Cutucar', 'Soco'),
'pt_PT' => ('Vem interagir comigo~', 'Passos de hoje', 'passos', 'Tenho saudades tuas', 'Picar', 'Soco'),
'ru' => ('Давай пообщаемся~', 'Шаги за сегодня', 'шагов', 'Скучаю по тебе', 'Тыкнуть', 'Ударить'),
'tr' => ('Benimle etkileşime geç~', 'Bugünün adımları', 'adım', 'Seni özledim', 'Dürt', 'Yumruk'),
_ => ('Come interact with me~', "Today's steps", 'steps', 'Miss you', 'Poke', 'Punch'),
'de' => (
'Lass uns interagieren~',
'Heutige Schritte',
'Schritte',
'Vermisse dich',
'Anstupsen',
'Boxen',
),
'es' => (
'¡Ven a interactuar conmigo!',
'Pasos de hoy',
'pasos',
'Te extraño',
'Toquecito',
'Puñetazo',
),
'fil' => (
'Makipag-ugnayan tayo~',
'Mga hakbang ngayon',
'hakbang',
'Miss kita',
'Tapikin',
'Suntok',
),
'fr' => (
'Viens interagir avec moi~',
'Pas du jour',
'pas',
'Tu me manques',
'Taquiner',
'Coup de poing',
),
'hi' => (
'मेरे साथ इंटरैक्ट करें~',
'आज के कदम',
'कदम',
'तुम्हारी याद आती है',
'हल्का धक्का',
'मुक्का',
),
'it' => (
'Vieni a interagire con me~',
'Passi di oggi',
'passi',
'Mi manchi',
'Stuzzica',
'Pugno',
),
'nl' => (
'Kom met me interacteren~',
'Stappen van vandaag',
'stappen',
'Ik mis je',
'Prikje',
'Stoot',
),
'pt' || 'pt_BR' => (
'Venha interagir comigo~',
'Passos de hoje',
'passos',
'Sinto sua falta',
'Cutucar',
'Soco',
),
'pt_PT' => (
'Vem interagir comigo~',
'Passos de hoje',
'passos',
'Tenho saudades tuas',
'Picar',
'Soco',
),
'ru' => (
'Давай пообщаемся~',
'Шаги за сегодня',
'шагов',
'Скучаю по тебе',
'Тыкнуть',
'Ударить',
),
'tr' => (
'Benimle etkileşime geç~',
'Bugünün adımları',
'adım',
'Seni özledim',
'Dürt',
'Yumruk',
),
_ => (
'Come interact with me~',
"Today's steps",
'steps',
'Miss you',
'Poke',
'Punch',
),
};
}
... ...