Commit d2045d296fe24d86fe7c171524158e5372b7a26e

Authored by 刘宏哲
1 parent b74687e3

feat(app): add pk

Showing 48 changed files with 2381 additions and 1035 deletions

Too many changes to show.

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

import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:get/get.dart';
class AccountSettingsController extends GetxController {
final UserPreferencesStorage _userPrefs = Get.find<UserPreferencesStorage>();
final UserStateService _userStateService = Get.find<UserStateService>();
final LocalStorage _localStorage = Get.find<LocalStorage>();
UserPreferencesStorage get userPrefs => _userPrefs;
... ... @@ -21,4 +24,12 @@ class AccountSettingsController extends GetxController {
void openOnboarding() {
Get.offAllNamed(AppRoutes.userOnboarding);
}
void openChangePhone() {
if (_localStorage.lastLoginMethod != 'phone') {
AppToast.show('仅支持手机号登录的账号更换手机号');
return;
}
Get.toNamed(Routes.CHANGE_PHONE);
}
}
... ...
... ... @@ -45,7 +45,11 @@ class AccountSettingsView extends GetView<AccountSettingsController> {
return Column(
children: [
SizedBox(height: 16.dp),
_AccountCard(user: user, onLogout: controller.logout),
_AccountCard(
user: user,
onChangePhone: controller.openChangePhone,
onLogout: controller.logout,
),
SizedBox(height: 17.dp),
GestureDetector(
behavior: HitTestBehavior.opaque,
... ... @@ -77,10 +81,12 @@ class AccountSettingsView extends GetView<AccountSettingsController> {
class _AccountCard extends StatelessWidget {
const _AccountCard({
required this.user,
required this.onChangePhone,
required this.onLogout,
});
final UserInfoResponse? user;
final VoidCallback onChangePhone;
final VoidCallback onLogout;
@override
... ... @@ -94,32 +100,36 @@ class _AccountCard extends StatelessWidget {
),
child: Column(
children: [
SizedBox(
height: 54.dp,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.dp),
child: Row(
children: [
Text(
'手机号',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChangePhone,
child: SizedBox(
height: 54.dp,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.dp),
child: Row(
children: [
Text(
'手机号',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
),
const Spacer(),
Text(
_formatPhone(user?.telephone),
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
const Spacer(),
Text(
_formatPhone(user?.telephone),
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.25,
),
),
),
],
],
),
),
),
),
... ...
... ... @@ -3,18 +3,17 @@ 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';
class ActivityBurnHeartRateZoneCard extends StatelessWidget {
const ActivityBurnHeartRateZoneCard({
super.key,
required this.summary,
});
const ActivityBurnHeartRateZoneCard({super.key, required this.summary});
final ActivityBurnHeartRateSummary summary;
... ... @@ -39,75 +38,79 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
Widget build(BuildContext context) {
final start = _startTime;
final axis = _HeartRateAxis.fromSummary(summary, start);
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,
),
style: TextStyle(
color: _h1,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
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,
),
),
),
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)
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: Center(
child: Text(
context.l10n.hrvTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
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,
),
),
),
),
),
],
);
},
],
);
},
),
),
),
],
],
),
),
);
}
... ... @@ -122,9 +125,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
maxX: axis.maxX,
minY: axis.minY,
maxY: axis.chartMaxY,
gridData: FlGridData(
show: false,
),
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
titlesData: FlTitlesData(
... ... @@ -174,10 +175,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0,
0,
),
child: Text(
label,
style: _bottomTitleStyle,
),
child: Text(label, style: _bottomTitleStyle),
),
);
},
... ... @@ -192,10 +190,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
LineChartBarData _emptyPlaceholderBar(_HeartRateAxis axis) {
return LineChartBarData(
spots: [
FlSpot(0, axis.minY),
FlSpot(axis.maxX, axis.minY),
],
spots: [FlSpot(0, axis.minY), FlSpot(axis.maxX, axis.minY)],
color: Colors.transparent,
barWidth: 0,
dotData: const FlDotData(show: false),
... ... @@ -215,7 +210,8 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
List<ActivityBurnHeartRatePoint> _points(DateTime start, DateTime end) {
return summary.points
.where(
(point) => !point.time.isBefore(start) && !point.time.isAfter(end))
(point) => !point.time.isBefore(start) && !point.time.isAfter(end),
)
.toList();
}
... ... @@ -277,10 +273,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
];
}
List<FlSpot> _splitByZoneBoundaries(
FlSpot start,
FlSpot end,
) {
List<FlSpot> _splitByZoneBoundaries(FlSpot start, FlSpot end) {
if (start.y == end.y) return [start, end];
final minY = math.min(start.y, end.y);
... ... @@ -290,12 +283,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
for (final boundary in _zoneBoundaries) {
if (boundary <= minY || boundary >= maxY) continue;
final t = (boundary - start.y) / (end.y - start.y);
crossings.add(
FlSpot(
start.x + (end.x - start.x) * t,
boundary,
),
);
crossings.add(FlSpot(start.x + (end.x - start.x) * t, boundary));
}
crossings.sort((a, b) => a.x.compareTo(b.x));
... ... @@ -325,10 +313,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
}
FlSpot _spot(ActivityBurnHeartRatePoint point, DateTime start) {
return FlSpot(
point.time.difference(start).inMinutes.toDouble(),
point.bpm,
);
return FlSpot(point.time.difference(start).inMinutes.toDouble(), point.bpm);
}
String? _bottomLabel(
... ...
... ... @@ -3,10 +3,12 @@ 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/utils/platform_compact.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../../r.dart';
import '../../../widget/friend_interact_click_view.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
... ... @@ -17,14 +19,14 @@ import 'activity_burn_ring.dart';
import 'activity_burn_trend_lines.dart';
String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) {
DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,
DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,
DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,
DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,
DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,
DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,
_ => context.l10n.activityWeeklyWeekdaySunday,
};
DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,
DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,
DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,
DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,
DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,
DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,
_ => context.l10n.activityWeeklyWeekdaySunday,
};
class ActivityBurnMonthReportView extends StatelessWidget {
const ActivityBurnMonthReportView({
... ... @@ -134,7 +136,9 @@ class _MonthlySummary extends StatelessWidget {
children: [
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityWeeklyExerciseTitle),
context,
context.l10n.activityWeeklyExerciseTitle,
),
value: exercise.value,
unit: exercise.unit,
color: isOhos()
... ... @@ -144,7 +148,9 @@ class _MonthlySummary extends StatelessWidget {
const SizedBox(width: 34),
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityWeeklyStandTitle),
context,
context.l10n.activityWeeklyStandTitle,
),
value: stand.value,
unit: stand.unit,
color: ActivityBurnMonthReportView._stand,
... ... @@ -157,8 +163,8 @@ class _MonthlySummary extends StatelessWidget {
}
String _totalBurnTitle(BuildContext context) {
final scope =
context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
final scope = context
.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
return scope == null || scope.isSelf
? context.l10n.activityWeeklySelfTitle
: context.l10n.activityWeeklyOtherTitle;
... ... @@ -330,10 +336,7 @@ class _RingStat extends StatelessWidget {
children: [
Row(
children: [
ActivityBurnRing(
report: report,
size: 12,
),
ActivityBurnRing(report: report, size: 12),
const SizedBox(width: 5),
Text(
label,
... ... @@ -485,167 +488,175 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
final hasChartData = _hasChartData;
final maxY = _maxY;
final comparisonPercent = widget.report.activeEnergyComparisonPercent;
return Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context, context.l10n.activityCalorieTrend),
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasData
? widget.report.averageDailyActiveEnergy.toString()
: '-',
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 18,
fontWeight: FontWeight.w700,
),
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.monthlyActivityBurn,
child: Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.activityCalorieTrend,
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasData
? widget.report.averageDailyActiveEnergy.toString()
: '-',
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 12,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
if (comparisonPercent != null) ...[
Image.asset(
comparisonPercent >= 0
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnMonthReportView._h1,
fontSize: 12,
),
),
),
const SizedBox(width: 2),
],
Text(
_comparisonText(context, comparisonPercent),
style: TextStyle(
color: ActivityBurnMonthReportView._h2,
fontSize: 12,
),
),
const Spacer(),
_TrendLegend(
color: const Color(0xFFFFC0CE),
label: context.l10n.activityTrendAverage,
),
const SizedBox(width: 12),
_TrendLegend(
color: const Color(0xFF96E9CB),
label: context.l10n.activityTrendGoal,
dashed: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
),
const SizedBox(height: 2),
Row(
children: [
Positioned(
left: 0,
right: 0,
top: _chartTopInset,
bottom: 0,
child: ActivityBurnTrendPlotFrame(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
child: LayoutBuilder(
builder: (context, constraints) => Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
onPointerMove: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
onPointerUp: (_) => _scheduleTooltipDismissal(),
onPointerCancel: (_) => _scheduleTooltipDismissal(),
child: Transform.translate(
offset: const Offset(
(_xAxisLeftInset - _xAxisRightInset) / 2,
0,
if (comparisonPercent != null) ...[
Image.asset(
comparisonPercent >= 0
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
),
const SizedBox(width: 2),
],
Text(
_comparisonText(context, comparisonPercent),
style: TextStyle(
color: ActivityBurnMonthReportView._h2,
fontSize: 12,
),
),
const Spacer(),
_TrendLegend(
color: const Color(0xFFFFC0CE),
label: context.l10n.activityTrendAverage,
),
const SizedBox(width: 12),
_TrendLegend(
color: const Color(0xFF96E9CB),
label: context.l10n.activityTrendGoal,
dashed: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: _chartTopInset,
bottom: 0,
child: ActivityBurnTrendPlotFrame(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy
.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
child: LayoutBuilder(
builder: (context, constraints) => Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
onPointerMove: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
child: BarChart(
_chartData(maxY, constraints.maxWidth),
onPointerUp: (_) => _scheduleTooltipDismissal(),
onPointerCancel: (_) => _scheduleTooltipDismissal(),
child: Transform.translate(
offset: const Offset(
(_xAxisLeftInset - _xAxisRightInset) / 2,
0,
),
child: BarChart(
_chartData(maxY, constraints.maxWidth),
),
),
),
),
),
),
),
if (_touchedIndex != null)
Positioned.fill(
top: _chartTopInset,
child: LayoutBuilder(
builder: (context, constraints) {
return ChartSelectionLineOverlay(
offset: Offset.zero,
color: ActivityBurnMonthReportView._h3,
bottomTitleHeight:
ActivityBurnTrendPlotFrame.bottomTitleHeight,
tooltipMargin: _tooltipMargin,
plotLeft: _xAxisLeftInset,
plotRight: _xAxisRightInset,
lineX: _lineXForIndex(constraints.maxWidth),
lineTop:
_tooltipBottomForHeight(constraints.maxHeight),
);
},
if (_touchedIndex != null)
Positioned.fill(
top: _chartTopInset,
child: LayoutBuilder(
builder: (context, constraints) {
return ChartSelectionLineOverlay(
offset: Offset.zero,
color: ActivityBurnMonthReportView._h3,
bottomTitleHeight:
ActivityBurnTrendPlotFrame.bottomTitleHeight,
tooltipMargin: _tooltipMargin,
plotLeft: _xAxisLeftInset,
plotRight: _xAxisRightInset,
lineX: _lineXForIndex(constraints.maxWidth),
lineTop: _tooltipBottomForHeight(
constraints.maxHeight,
),
);
},
),
),
),
if (!hasChartData)
Positioned(
left: _xAxisLeftInset,
right: _xAxisRightInset,
top: _chartTopInset,
bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
child: Center(
child: Text(
context.l10n.activityTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
if (!hasChartData)
Positioned(
left: _xAxisLeftInset,
right: _xAxisRightInset,
top: _chartTopInset,
bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
child: Center(
child: Text(
context.l10n.activityTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
),
),
],
],
),
),
),
],
],
),
),
);
}
... ... @@ -655,9 +666,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
.map((day) => day.activeEnergy?.value ?? 0)
.fold<int>(0, (max, value) => value > max ? value : max);
final maxReference = math.max(maxValue, widget.report.activeEnergyGoal);
return _roundUpToFiveOrZero(maxReference)
.clamp(100, double.infinity)
.toDouble();
return _roundUpToFiveOrZero(
maxReference,
).clamp(100, double.infinity).toDouble();
}
bool get _hasChartData =>
... ... @@ -716,15 +727,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
if (chartWidth <= 0) return null;
final groupsSpace = _groupsSpace(chartWidth, count);
const chartLeft = _xAxisLeftInset;
final index =
((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace)).round();
final index = ((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace))
.round();
return index.clamp(0, count - 1).toInt();
}
void _selectTooltipAt(double? dx, double plotWidth) {
_tooltipDismissTimer?.cancel();
final touchedIndex = _indexForTouch(dx, plotWidth);
final hasTouchedData = touchedIndex != null &&
final hasTouchedData =
touchedIndex != null &&
(widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0;
final nextIndex = hasTouchedData ? touchedIndex : null;
if (_touchedIndex == nextIndex) return;
... ... @@ -739,16 +751,16 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
BarChartData _chartData(double maxY, double plotWidth) {
final hasData = widget.report.hasData;
final chartWidth =
math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset);
final chartWidth = math.max(
0.0,
plotWidth - _xAxisLeftInset - _xAxisRightInset,
);
return BarChartData(
minY: 0,
maxY: maxY,
alignment: BarChartAlignment.center,
groupsSpace: _groupsSpace(chartWidth, widget.report.days.length),
gridData: FlGridData(
show: false,
),
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: hasData,
... ... @@ -762,10 +774,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
_scheduleTooltipDismissal();
return;
}
_selectTooltipAt(
event.localPosition?.dx,
plotWidth,
);
_selectTooltipAt(event.localPosition?.dx, plotWidth);
},
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 4,
... ... @@ -810,7 +819,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
reservedSize: 36,
interval: activityBurnTrendYInterval(maxY),
getTitlesWidget: (value, meta) {
final isRegularTick = value % activityBurnTrendYInterval(maxY) == 0;
final isRegularTick =
value % activityBurnTrendYInterval(maxY) == 0;
final isMax = (value - maxY).abs() < 0.001;
if (!isRegularTick && !isMax) return const SizedBox.shrink();
return Transform.translate(
... ... @@ -873,8 +883,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
maxY,
),
width: _barWidth,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: _barColor,
),
// Anchor the tooltip at a constant height, independent of the
... ... @@ -897,8 +908,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
final count = widget.report.days.length;
if (count <= 7) return true;
final lastIndex = count - 1;
return List.generate(7, (tick) => (tick * lastIndex / 6).round())
.contains(index);
return List.generate(
7,
(tick) => (tick * lastIndex / 6).round(),
).contains(index);
}
}
... ...
... ... @@ -3,10 +3,12 @@ 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/utils/platform_compact.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../../r.dart';
import '../../../widget/friend_interact_click_view.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
... ... @@ -16,14 +18,14 @@ import 'activity_burn_ring.dart';
import 'activity_burn_trend_lines.dart';
String _weekdayLabel(BuildContext context, int weekday) => switch (weekday) {
DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,
DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,
DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,
DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,
DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,
DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,
_ => context.l10n.activityWeeklyWeekdaySunday,
};
DateTime.monday => context.l10n.activityWeeklyWeekdayMonday,
DateTime.tuesday => context.l10n.activityWeeklyWeekdayTuesday,
DateTime.wednesday => context.l10n.activityWeeklyWeekdayWednesday,
DateTime.thursday => context.l10n.activityWeeklyWeekdayThursday,
DateTime.friday => context.l10n.activityWeeklyWeekdayFriday,
DateTime.saturday => context.l10n.activityWeeklyWeekdaySaturday,
_ => context.l10n.activityWeeklyWeekdaySunday,
};
class ActivityBurnWeekReportView extends StatelessWidget {
const ActivityBurnWeekReportView({
... ... @@ -135,7 +137,9 @@ class _WeeklySummary extends StatelessWidget {
children: [
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityWeeklyExerciseTitle),
context,
context.l10n.activityWeeklyExerciseTitle,
),
value: exercise.value,
unit: exercise.unit,
color: isOhos()
... ... @@ -145,7 +149,9 @@ class _WeeklySummary extends StatelessWidget {
const SizedBox(width: 34),
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityWeeklyStandTitle),
context,
context.l10n.activityWeeklyStandTitle,
),
value: stand.value,
unit: stand.unit,
color: ActivityBurnWeekReportView._stand,
... ... @@ -158,8 +164,8 @@ class _WeeklySummary extends StatelessWidget {
}
String _totalBurnTitle(BuildContext context) {
final scope =
context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
final scope = context
.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>();
return scope == null || scope.isSelf
? context.l10n.activityWeeklySelfTitle
: context.l10n.activityWeeklyOtherTitle;
... ... @@ -294,9 +300,7 @@ class _RingOverviewCard extends StatelessWidget {
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final day in report.days) _DayRing(report: day),
],
children: [for (final day in report.days) _DayRing(report: day)],
),
],
),
... ... @@ -327,10 +331,7 @@ class _RingStat extends StatelessWidget {
children: [
Row(
children: [
ActivityBurnRing(
report: report,
size: 12,
),
ActivityBurnRing(report: report, size: 12),
const SizedBox(width: 5),
Text(
label,
... ... @@ -506,169 +507,175 @@ class _ActivityBurnEnergyTrendCardState
final hasChartData = _hasChartData;
final maxY = _maxY;
final comparisonPercent = widget.report.activeEnergyComparisonPercent;
return Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context, context.l10n.activityCalorieTrend),
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasData
? widget.report.averageDailyActiveEnergy.toString()
: '-',
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 18,
fontWeight: FontWeight.w700,
),
return FriendInteractCardOverlay(
showInteraction: !HealthReportSubjectScope.isViewingSelf(context),
interactionType: FriendInteractionType.weeklyActivityBurn,
child: Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.activityCalorieTrend,
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasData
? widget.report.averageDailyActiveEnergy.toString()
: '-',
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 12,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
if (comparisonPercent != null) ...[
Image.asset(
comparisonPercent >= 0
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
context.l10n.activityKcalDailyAverage,
style: const TextStyle(
color: ActivityBurnWeekReportView._h1,
fontSize: 12,
),
),
),
const SizedBox(width: 2),
],
Text(
_comparisonText(context, comparisonPercent),
style: const TextStyle(
color: ActivityBurnWeekReportView._h2,
fontSize: 12,
),
),
const Spacer(),
_TrendLegend(
color: const Color(0xFFFFC0CE),
label: context.l10n.activityTrendAverage,
),
const SizedBox(width: 12),
_TrendLegend(
color: const Color(0xFF96E9CB),
label: context.l10n.activityTrendGoal,
dashed: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
),
const SizedBox(height: 2),
Row(
children: [
Positioned(
left: 0,
right: 0,
top: _chartTopInset,
bottom: 0,
child: ActivityBurnTrendPlotFrame(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
child: LayoutBuilder(
builder: (context, constraints) => Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
onPointerMove: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
onPointerUp: (_) => _scheduleTooltipDismissal(),
onPointerCancel: (_) => _scheduleTooltipDismissal(),
child: Transform.translate(
offset: const Offset(
(_xAxisLeftInset - _xAxisRightInset) / 2,
0,
),
child: BarChart(
_chartData(maxY, constraints.maxWidth),
),
),
),
),
if (comparisonPercent != null) ...[
Image.asset(
comparisonPercent >= 0
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
),
const SizedBox(width: 2),
],
Text(
_comparisonText(context, comparisonPercent),
style: const TextStyle(
color: ActivityBurnWeekReportView._h2,
fontSize: 12,
),
),
if (_touchedIndex != null)
Positioned.fill(
const Spacer(),
_TrendLegend(
color: const Color(0xFFFFC0CE),
label: context.l10n.activityTrendAverage,
),
const SizedBox(width: 12),
_TrendLegend(
color: const Color(0xFF96E9CB),
label: context.l10n.activityTrendGoal,
dashed: true,
),
],
),
const SizedBox(height: 8),
Expanded(
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: _chartTopInset,
child: LayoutBuilder(
builder: (context, constraints) {
return ChartSelectionLineOverlay(
offset: Offset.zero,
color: ActivityBurnWeekReportView._h3,
bottomTitleHeight:
ActivityBurnTrendPlotFrame.bottomTitleHeight,
tooltipMargin: _tooltipMargin,
plotLeft: _xAxisLeftInset,
plotRight: _xAxisRightInset,
lineX: _lineXForIndex(
bottom: 0,
child: ActivityBurnTrendPlotFrame(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy
.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
child: LayoutBuilder(
builder: (context, constraints) => Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
lineTop:
_tooltipBottomForHeight(constraints.maxHeight),
);
},
onPointerMove: (event) => _selectTooltipAt(
event.localPosition.dx,
constraints.maxWidth,
),
onPointerUp: (_) => _scheduleTooltipDismissal(),
onPointerCancel: (_) => _scheduleTooltipDismissal(),
child: Transform.translate(
offset: const Offset(
(_xAxisLeftInset - _xAxisRightInset) / 2,
0,
),
child: BarChart(
_chartData(maxY, constraints.maxWidth),
),
),
),
),
),
),
if (!hasChartData)
Positioned(
left: _xAxisLeftInset,
right: _xAxisRightInset,
top: _chartTopInset,
bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
child: Center(
child: Text(
context.l10n.activityTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
if (_touchedIndex != null)
Positioned.fill(
top: _chartTopInset,
child: LayoutBuilder(
builder: (context, constraints) {
return ChartSelectionLineOverlay(
offset: Offset.zero,
color: ActivityBurnWeekReportView._h3,
bottomTitleHeight:
ActivityBurnTrendPlotFrame.bottomTitleHeight,
tooltipMargin: _tooltipMargin,
plotLeft: _xAxisLeftInset,
plotRight: _xAxisRightInset,
lineX: _lineXForIndex(constraints.maxWidth),
lineTop: _tooltipBottomForHeight(
constraints.maxHeight,
),
);
},
),
),
if (!hasChartData)
Positioned(
left: _xAxisLeftInset,
right: _xAxisRightInset,
top: _chartTopInset,
bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
child: Center(
child: Text(
context.l10n.activityTrendAwaitingData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
),
),
],
],
),
),
),
],
],
),
),
);
}
... ... @@ -678,9 +685,9 @@ class _ActivityBurnEnergyTrendCardState
.map((day) => day.activeEnergy?.value ?? 0)
.fold<int>(0, (max, value) => value > max ? value : max);
final maxReference = math.max(maxValue, widget.report.activeEnergyGoal);
return _roundUpToFiveOrZero(maxReference)
.clamp(100, double.infinity)
.toDouble();
return _roundUpToFiveOrZero(
maxReference,
).clamp(100, double.infinity).toDouble();
}
bool get _hasChartData =>
... ... @@ -753,16 +760,18 @@ class _ActivityBurnEnergyTrendCardState
if (chartWidth <= 0) return null;
final groupsSpace = _groupsSpace(chartWidth, count);
const chartLeft = _xAxisLeftInset;
final index = ((dx - chartLeft - widget.report.barWidth / 2) /
(widget.report.barWidth + groupsSpace))
.round();
final index =
((dx - chartLeft - widget.report.barWidth / 2) /
(widget.report.barWidth + groupsSpace))
.round();
return index.clamp(0, count - 1).toInt();
}
void _selectTooltipAt(double? dx, double plotWidth) {
_tooltipDismissTimer?.cancel();
final touchedIndex = _indexForTouch(dx, plotWidth);
final hasTouchedData = touchedIndex != null &&
final hasTouchedData =
touchedIndex != null &&
(widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0;
final nextIndex = hasTouchedData ? touchedIndex : null;
if (_touchedIndex == nextIndex) return;
... ... @@ -779,16 +788,16 @@ class _ActivityBurnEnergyTrendCardState
BarChartData _chartData(double maxY, double plotWidth) {
final hasData = widget.report.hasData;
final chartWidth =
math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset);
final chartWidth = math.max(
0.0,
plotWidth - _xAxisLeftInset - _xAxisRightInset,
);
return BarChartData(
minY: 0,
maxY: maxY,
alignment: BarChartAlignment.center,
groupsSpace: _groupsSpace(chartWidth, widget.report.days.length),
gridData: FlGridData(
show: false,
),
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: hasData,
... ... @@ -802,10 +811,7 @@ class _ActivityBurnEnergyTrendCardState
_scheduleTooltipDismissal();
return;
}
_selectTooltipAt(
event.localPosition?.dx,
plotWidth,
);
_selectTooltipAt(event.localPosition?.dx, plotWidth);
},
touchTooltipData: BarTouchTooltipData(
tooltipRoundedRadius: 4,
... ... @@ -850,7 +856,8 @@ class _ActivityBurnEnergyTrendCardState
reservedSize: 36,
interval: activityBurnTrendYInterval(maxY),
getTitlesWidget: (value, meta) {
final isRegularTick = value % activityBurnTrendYInterval(maxY) == 0;
final isRegularTick =
value % activityBurnTrendYInterval(maxY) == 0;
final isMax = (value - maxY).abs() < 0.001;
if (!isRegularTick && !isMax) return const SizedBox.shrink();
return Transform.translate(
... ... @@ -939,8 +946,10 @@ class _ActivityBurnEnergyTrendCardState
final count = widget.report.days.length;
if (count <= 7) return true;
final lastIndex = count - 1;
return List.generate(7, (tick) => (tick * lastIndex / 6).round())
.contains(index);
return List.generate(
7,
(tick) => (tick * lastIndex / 6).round(),
).contains(index);
}
}
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/app/modules/purchase/purchase_route_args.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
... ... @@ -137,6 +140,12 @@ class BindPartnerController extends GetxController {
break;
case AppSuccess(data: final friendItem):
_platformHostApi.refreshWatchAppAndWidgets();
// The add-friend flow can be opened outside the Friends tab (for
// example from Today). Refresh the home-owned list so PK appears as
// soon as the new friend is available.
if (Get.isRegistered<FriendsController>()) {
unawaited(Get.find<FriendsController>().loadFriends());
}
Get.to(() => _BindSuccessPage(friendItem, isShowAppBarBack));
ta.track('success_add_doubelfeel_friend');
break;
... ...
import 'package:doublefeel_flutter/app/modules/change_phone/controllers/change_phone_controller.dart';
import 'package:doublefeel_flutter/app/modules/change_phone/services/change_phone_gateway.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:get/get.dart';
class ChangePhoneBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ChangePhoneGateway>(
() => const PlaceholderChangePhoneGateway());
Get.lazyPut<ChangePhoneController>(
() => ChangePhoneController(
userPreferencesStorage: Get.find<UserPreferencesStorage>(),
localStorage: Get.find<LocalStorage>(),
gateway: Get.find<ChangePhoneGateway>(),
),
);
}
}
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/modules/change_phone/services/change_phone_gateway.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
enum ChangePhoneStep { verifyOldPhone, bindNewPhone }
class ChangePhoneController extends GetxController {
ChangePhoneController({
required UserPreferencesStorage userPreferencesStorage,
required LocalStorage localStorage,
required ChangePhoneGateway gateway,
}) : _userPreferencesStorage = userPreferencesStorage,
_localStorage = localStorage,
_gateway = gateway;
final UserPreferencesStorage _userPreferencesStorage;
final LocalStorage _localStorage;
final ChangePhoneGateway _gateway;
final phoneController = TextEditingController();
final codeController = TextEditingController();
final codeFocusNode = FocusNode();
final step = ChangePhoneStep.verifyOldPhone.obs;
final phoneInput = ''.obs;
final codeInput = ''.obs;
final isRequestingCode = false.obs;
final isSubmitting = false.obs;
final countdownSeconds = 0.obs;
Timer? _countdownTimer;
String get cleanPhone => phoneInput.value.replaceAll(RegExp(r'\s+'), '');
bool get isPhoneValid => RegExp(r'^\d{11}$').hasMatch(cleanPhone);
bool get canRequestCode =>
isPhoneValid && !isRequestingCode.value && countdownSeconds.value == 0;
bool get canSubmit =>
isPhoneValid && codeInput.value.isNotEmpty && !isSubmitting.value;
bool get isOldPhoneStep => step.value == ChangePhoneStep.verifyOldPhone;
bool get canChangePhone => _localStorage.lastLoginMethod == 'phone';
String get maskedOldPhone {
final phone = _userPreferencesStorage
.preferences.value.meUserInfo?.telephone
?.replaceAll(RegExp(r'\s+'), '');
if (phone == null || phone.length != 11) return '123****1212';
return '${phone.substring(0, 3)}****${phone.substring(7)}';
}
@override
void onInit() {
super.onInit();
phoneController.addListener(_onPhoneChanged);
codeController.addListener(() => codeInput.value = codeController.text);
}
@override
void onReady() {
super.onReady();
if (!canChangePhone) {
AppToast.show('仅支持手机号登录的账号更换手机号');
Get.back();
}
}
Future<void> requestVerificationCode() async {
if (!canRequestCode) return;
isRequestingCode.value = true;
try {
await _gateway.requestVerificationCode(
phone: cleanPhone,
isOldPhone: isOldPhoneStep,
);
_startCountdown();
codeFocusNode.requestFocus();
} on UnsupportedError {
AppToast.show('手机号更换服务暂未开通');
} finally {
isRequestingCode.value = false;
}
}
Future<void> submit() async {
if (!canSubmit) return;
isSubmitting.value = true;
try {
if (isOldPhoneStep) {
await _gateway.verifyOldPhone(
phone: cleanPhone,
verificationCode: codeInput.value,
);
_showNewPhoneStep();
} else {
await _gateway.bindNewPhone(
phone: cleanPhone,
verificationCode: codeInput.value,
);
Get.back();
}
} on UnsupportedError {
AppToast.show('手机号更换服务暂未开通');
} finally {
isSubmitting.value = false;
}
}
void clearPhone() => phoneController.clear();
void openHelp() => Get.toNamed(Routes.HELP);
void _onPhoneChanged() {
final newPhone = phoneController.text;
if (phoneInput.value != newPhone &&
(countdownSeconds.value > 0 || codeInput.value.isNotEmpty)) {
_resetVerificationState();
}
phoneInput.value = newPhone;
}
void _showNewPhoneStep() {
step.value = ChangePhoneStep.bindNewPhone;
phoneController.clear();
codeController.clear();
_resetVerificationState();
}
void _resetVerificationState() {
_countdownTimer?.cancel();
countdownSeconds.value = 0;
codeController.clear();
}
void _startCountdown() {
_countdownTimer?.cancel();
countdownSeconds.value = 60;
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (countdownSeconds.value <= 1) {
countdownSeconds.value = 0;
timer.cancel();
} else {
countdownSeconds.value--;
}
});
}
@override
void onClose() {
_countdownTimer?.cancel();
phoneController.dispose();
codeController.dispose();
codeFocusNode.dispose();
super.onClose();
}
}
... ...
/// Boundary for the phone-change APIs.
///
/// Replace [PlaceholderChangePhoneGateway] in the binding with the real
/// implementation once the server endpoints are available. Keeping the
/// contract here makes the page and its interaction states usable meanwhile.
abstract interface class ChangePhoneGateway {
Future<void> requestVerificationCode({
required String phone,
required bool isOldPhone,
});
Future<void> verifyOldPhone({
required String phone,
required String verificationCode,
});
Future<void> bindNewPhone({
required String phone,
required String verificationCode,
});
}
/// Temporary API implementation that deliberately does not report success.
///
/// TODO(server): replace these methods with the change-phone endpoints. This
/// prevents the UI from claiming that a phone number was changed before an API
/// exists to persist and verify it.
class PlaceholderChangePhoneGateway implements ChangePhoneGateway {
const PlaceholderChangePhoneGateway();
@override
Future<void> bindNewPhone({
required String phone,
required String verificationCode,
}) =>
throw UnsupportedError('Change-phone API is not configured.');
@override
Future<void> requestVerificationCode({
required String phone,
required bool isOldPhone,
}) =>
throw UnsupportedError('Change-phone API is not configured.');
@override
Future<void> verifyOldPhone({
required String phone,
required String verificationCode,
}) =>
throw UnsupportedError('Change-phone API is not configured.');
}
... ...
import 'package:doublefeel_flutter/app/modules/change_phone/controllers/change_phone_controller.dart';
import 'package:doublefeel_flutter/core/theme/app_colors.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
class ChangePhoneView extends GetView<ChangePhoneController> {
const ChangePhoneView({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: AppColors.backgroundPage,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: AppColors.backgroundPage,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.backgroundPage,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 44.dp,
child: Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: Get.back,
padding: EdgeInsets.only(left: 16.dp),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
icon: Image.asset(
R.assetsImagesNavBackIcon,
width: 28.dp,
height: 28.dp,
),
),
),
),
Expanded(
child: Obx(() {
final oldPhoneStep = controller.isOldPhoneStep;
return SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 28.dp),
child: Column(
children: [
SizedBox(height: 16.dp),
Text(
oldPhoneStep ? '原手机号验证' : '新手机号绑定',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 24.dp,
fontWeight: FontWeight.w600,
height: 1.35,
),
),
if (oldPhoneStep) ...[
SizedBox(height: 4.dp),
SizedBox(
height: 40.dp,
child: Text(
'请输入该账号绑定的原手机号${controller.maskedOldPhone},完成手机验证',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14.dp,
fontWeight: FontWeight.w400,
height: 1.45,
),
),
),
SizedBox(height: 55.dp),
] else
SizedBox(height: 103.dp),
_PhoneField(controller: controller),
SizedBox(height: 8.dp),
_CodeField(controller: controller),
SizedBox(height: 68.dp),
_SubmitButton(controller: controller),
if (oldPhoneStep) ...[
SizedBox(height: 12.dp),
TextButton(
onPressed: controller.openHelp,
style: TextButton.styleFrom(
foregroundColor: AppColors.chartBlue,
minimumSize: Size.zero,
padding: EdgeInsets.symmetric(
horizontal: 8.dp,
vertical: 4.dp,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'现手机已不可用?',
style: TextStyle(
fontSize: 12.dp,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
);
}),
),
],
),
),
),
);
}
}
class _PhoneField extends StatelessWidget {
const _PhoneField({required this.controller});
final ChangePhoneController controller;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52.dp,
child: Obx(() => TextField(
controller: controller.phoneController,
keyboardType: TextInputType.phone,
maxLength: 11,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w400,
),
decoration: _inputDecoration(
hintText: '',
prefix: Padding(
padding: EdgeInsets.only(right: 12.dp),
child: Text(
'+86',
style: TextStyle(
color: AppColors.textTertiary,
fontSize: 16.dp,
fontWeight: FontWeight.w400,
),
),
),
suffix: controller.phoneInput.value.isEmpty
? null
: IconButton(
onPressed: controller.clearPhone,
icon: Icon(
Icons.cancel,
color: const Color(0xFFB0B0B6),
size: 24.dp,
),
),
),
)),
);
}
}
class _CodeField extends StatelessWidget {
const _CodeField({required this.controller});
final ChangePhoneController controller;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52.dp,
child: Obx(() {
final seconds = controller.countdownSeconds.value;
final requesting = controller.isRequestingCode.value;
final buttonText = requesting
? '发送中'
: seconds > 0
? '重新发送(${seconds}s)'
: '发送验证码';
return TextField(
controller: controller.codeController,
focusNode: controller.codeFocusNode,
keyboardType: TextInputType.number,
maxLength: 6,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16.dp,
fontWeight: FontWeight.w400,
),
decoration: _inputDecoration(
hintText: '输入验证码',
suffix: controller.phoneInput.value.isEmpty
? null
: TextButton(
onPressed: controller.canRequestCode
? controller.requestVerificationCode
: null,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: EdgeInsets.symmetric(horizontal: 12.dp),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: AppColors.primary,
disabledForegroundColor: AppColors.textTertiary,
),
child: Text(
buttonText,
style: TextStyle(
fontSize: 14.dp,
fontWeight: FontWeight.w500,
),
),
),
),
);
}),
);
}
}
class _SubmitButton extends StatelessWidget {
const _SubmitButton({required this.controller});
final ChangePhoneController controller;
@override
Widget build(BuildContext context) {
return Obx(() => SizedBox(
width: double.infinity,
height: 48.dp,
child: ElevatedButton(
onPressed: controller.canSubmit ? controller.submit : null,
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: AppColors.primary,
disabledBackgroundColor: AppColors.primary.withValues(alpha: 0.4),
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24.dp),
),
textStyle:
TextStyle(fontSize: 16.dp, fontWeight: FontWeight.w600),
),
child: const Text('确定'),
),
));
}
}
InputDecoration _inputDecoration({
required String hintText,
Widget? prefix,
Widget? suffix,
}) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
);
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(color: AppColors.textTertiary, fontSize: 16),
counterText: '',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 0),
prefixIcon: prefix == null ? null : Center(child: prefix),
prefixIconConstraints: const BoxConstraints(minWidth: 0),
suffixIcon: suffix,
suffixIconConstraints: const BoxConstraints(minWidth: 0),
border: border,
enabledBorder: border,
focusedBorder: border,
);
}
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -65,6 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
onQueryChanged: controller.changeQuery,
friendAvatarUrl: controller.friendItem?.avatar,
),
),
),
... ... @@ -87,10 +87,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
}
class _FriendTrendTitle extends StatelessWidget {
const _FriendTrendTitle({
required this.title,
required this.avatarUrl,
});
const _FriendTrendTitle({required this.title, required this.avatarUrl});
final String title;
final String? avatarUrl;
... ...
... ... @@ -29,6 +29,7 @@ class HealthTrendContent extends StatefulWidget {
required this.onTypeChanged,
required this.onQueryChanged,
this.queryForType,
this.friendAvatarUrl,
});
final HealthReportQuery query;
... ... @@ -41,6 +42,7 @@ class HealthTrendContent extends StatefulWidget {
final ValueChanged<int> onTypeChanged;
final void Function(ReportPeriod period, DateTime date) onQueryChanged;
final HealthReportQuery Function(int typeIndex)? queryForType;
final String? friendAvatarUrl;
@override
State<HealthTrendContent> createState() => _HealthTrendContentState();
... ... @@ -110,12 +112,14 @@ class _HealthTrendContentState extends State<HealthTrendContent>
final vipInfo = preferences.vipInfo;
final isVip = vipInfo?.isVip ?? false;
void openPurchase(String channelType) => Get.toNamed(
Routes.PURCHASE,
arguments: {IntentKeys.channelType: channelType},
);
Routes.PURCHASE,
arguments: {IntentKeys.channelType: channelType},
);
return HealthReportSubjectScope(
isSelf: widget.query.isSelf,
friendUserId: widget.query.targetUserId,
friendAvatarUrl: widget.friendAvatarUrl,
child: Column(
children: [
TrendTypeTabBar(tabController: _tabController),
... ... @@ -322,11 +326,11 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
@override
Widget build(BuildContext context) => HrvReportView(
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
loadingIndicator: const _HealthTrendLoadingIndicator(),
);
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
loadingIndicator: const _HealthTrendLoadingIndicator(),
);
}
class _ActivityBurnTrendSection extends StatefulWidget {
... ... @@ -422,12 +426,12 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
@override
Widget build(BuildContext context) => ActivityBurnReportView(
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
loadingIndicator: const _HealthTrendLoadingIndicator(),
tooltipDismissDelayMs: 4000,
);
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
loadingIndicator: const _HealthTrendLoadingIndicator(),
tooltipDismissDelayMs: 4000,
);
}
class _SleepTrendSection extends StatefulWidget {
... ... @@ -462,9 +466,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
@override
void initState() {
super.initState();
_logic = SleepReportLogic(
initialTargetUserId: widget.query.targetUserId,
);
_logic = SleepReportLogic(initialTargetUserId: widget.query.targetUserId);
_appReviewPromptLogic = AppReviewPromptLogic(
storage: Get.find<LocalStorage>(),
userIdProvider: () => Get.find<UserStateService>().userId,
... ... @@ -527,14 +529,14 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
@override
Widget build(BuildContext context) => SleepReportView(
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
onReportViewed: widget.isSelected
? _appReviewPromptLogic.recordPromptEligibility
: null,
loadingIndicator: const _HealthTrendLoadingIndicator(),
);
logic: _logic,
isVip: widget.isVip,
onSubscribe: widget.onSubscribe,
onReportViewed: widget.isSelected
? _appReviewPromptLogic.recordPromptEligibility
: null,
loadingIndicator: const _HealthTrendLoadingIndicator(),
);
}
class _KeepAliveWrapper extends StatefulWidget {
... ...
import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:get/get.dart';
import '../../friends/controllers/friends_controller.dart';
import '../../pk/controllers/pk_controller.dart';
import '../controllers/home_controller.dart';
import '../controllers/app_home_controller.dart';
import '../controllers/trend/trend_controller.dart';
... ... @@ -13,6 +16,14 @@ import '../controllers/trend/trend_controller.dart';
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.put<FriendsController>(FriendsController());
Get.put<PkController>(
PkController(
Get.find<FriendApi>(),
Get.find<UserPreferencesStorage>(),
Get.find<HealthApi>(),
),
);
Get.put<HomeController>(HomeController());
Get.put<MyController>(
MyController(
... ... @@ -22,7 +33,6 @@ class HomeBinding extends Bindings {
),
);
Get.put<TrendController>(TrendController(Get.find<FriendApi>()));
Get.put<FriendsController>(FriendsController());
Get.put<AppHomeController>(AppHomeController());
}
}
... ...
import '../../pk/controllers/pk_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/core/constants/flutter_bridge_method_name.dart';
... ... @@ -23,23 +24,47 @@ import 'trend/trend_controller.dart';
enum TrendEntrySource { bottomTab, jump }
enum HomeTab { today, trend, friends, pk, my }
class HomeController extends GetxController {
static const trendTabIndex = 1;
static const friendsTabIndex = 2;
static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel');
final LocalStorage _storage = Get.find<LocalStorage>();
final UserStateService userStateService = Get.find<UserStateService>();
final AppPlatformHostApi _platformHostApi = AppPlatformHostApi();
/// 当前选中的底部 tab 索引
final selectedIndex = 0.obs;
/// 当前选中的底部 Tab;展示位置由 [visibleTabs] 动态计算。
final selectedTab = HomeTab.today.obs;
final lastFlutterUrl = ''.obs;
var isFromOnboard = false;
late final Worker _friendsWorker;
bool get hasFriends => Get.find<FriendsController>().friends.isNotEmpty;
List<HomeTab> get visibleTabs => [
HomeTab.today,
HomeTab.trend,
HomeTab.friends,
if (hasFriends) HomeTab.pk,
HomeTab.my,
];
int get currentSelectedIndex => visibleTabs.indexOf(selectedTab.value);
@override
void onInit() {
super.onInit();
_friendsWorker = ever(
Get.find<FriendsController>().friends,
(_) {
// If the last friend is deleted while PK is open, PK is removed from
// the tab bar. Return to the Friends tab instead of changing the
// meaning of the current index to My.
if (!hasFriends && selectedTab.value == HomeTab.pk) {
selectedTab.value = HomeTab.friends;
}
},
);
_mainChannel.setMethodCallHandler(_handleNativeMethodCall);
isFromOnboard = Get.arguments?['isFromOnboard'] ?? false;
SchedulerBinding.instance.addPostFrameCallback((_) async {
... ... @@ -61,6 +86,7 @@ class HomeController extends GetxController {
@override
void onClose() {
_friendsWorker.dispose();
_mainChannel.setMethodCallHandler(null);
super.onClose();
}
... ... @@ -141,33 +167,41 @@ class HomeController extends GetxController {
int index, {
TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab,
}) {
if (selectedIndex.value == index) return;
selectedIndex.value = index;
final tabs = visibleTabs;
if (index < 0 || index >= tabs.length) return;
selectTab(tabs[index], trendEntrySource: trendEntrySource);
}
void selectTab(
HomeTab tab, {
TrendEntrySource trendEntrySource = TrendEntrySource.bottomTab,
}) {
if (!visibleTabs.contains(tab) || selectedTab.value == tab) return;
selectedTab.value = tab;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (selectedIndex.value != index) return;
if (selectedTab.value != tab) return;
final trendController = Get.find<TrendController>();
final friendsController = Get.find<FriendsController>();
if (index == trendTabIndex &&
if (tab == HomeTab.trend &&
trendEntrySource == TrendEntrySource.bottomTab) {
trendController.selectSelf(refresh: false);
}
trendController.setPageVisible(
index == trendTabIndex,
tab == HomeTab.trend,
resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab,
);
friendsController.setPageVisible(index == friendsTabIndex);
switch (index) {
case 0:
Get.find<AppHomeController>().refreshOnShow();
break;
case 3:
ta.track('enter_doublefeel_my_page');
break;
friendsController.setPageVisible(tab == HomeTab.friends);
if (tab == HomeTab.today) {
Get.find<AppHomeController>().refreshOnShow();
} else if (tab == HomeTab.pk) {
Get.find<PkController>().refreshData();
} else if (tab == HomeTab.my) {
ta.track('enter_doublefeel_my_page');
}
});
}
... ... @@ -182,7 +216,7 @@ class HomeController extends GetxController {
if (fromTodayTab) {
Get.find<TrendController>().selectSelf();
}
changeTab(trendTabIndex, trendEntrySource: TrendEntrySource.jump);
selectTab(HomeTab.trend, trendEntrySource: TrendEntrySource.jump);
}
int _dateKey(DateTime date) =>
... ... @@ -256,7 +290,7 @@ class HomeController extends GetxController {
void _switchHomeTab(String? tab, Map<String, String> params) {
switch (tab) {
case 'today':
changeTab(0);
selectTab(HomeTab.today);
final scrollToLatestHrv =
params['scroll_to_latest_hrv']?.toLowerCase() == 'true';
if (scrollToLatestHrv) {
... ... @@ -271,14 +305,17 @@ class HomeController extends GetxController {
if (trendType != null) {
openTrend(trendType, fromTodayTab: false);
} else {
changeTab(trendTabIndex, trendEntrySource: TrendEntrySource.jump);
selectTab(HomeTab.trend, trendEntrySource: TrendEntrySource.jump);
}
break;
case 'friends':
changeTab(friendsTabIndex);
selectTab(HomeTab.friends);
break;
case 'pk':
selectTab(HomeTab.pk);
break;
case 'my':
changeTab(3);
selectTab(HomeTab.my);
break;
default:
break;
... ...
... ... @@ -9,18 +9,27 @@ import '../widgets/df_tab_bar.dart';
import '../../friends/views/friends_tab.dart';
import 'tabs/home_trend_view.dart';
import 'tabs/app_home_tab.dart';
import 'tabs/home_pk_view.dart';
import 'tabs/my_tab.dart';
class HomePage extends GetView<HomeController> {
const HomePage({super.key});
static const _tabs = [
static const _tabsWithoutPk = [
AppHomeTab(),
HomeTrendView(),
FriendsTab(),
MyTab(),
];
static const _tabsWithPk = [
AppHomeTab(),
HomeTrendView(),
FriendsTab(),
HomePkView(),
MyTab(),
];
@override
Widget build(BuildContext context) {
return _HomeReviewPromptGate(
... ... @@ -30,18 +39,17 @@ class HomePage extends GetView<HomeController> {
extendBody: true, // 让内容延伸到 bottomNavigationBar 下方
extendBodyBehindAppBar: true,
body: Obx(
() => IndexedStack(
index: controller.selectedIndex.value,
children: _tabs,
),
),
bottomNavigationBar: Obx(
() => DfTabBar(
selectedIndex: controller.selectedIndex.value,
onTap: (index) => controller.changeTab(index),
),
),
body: Obx(() {
final hasFriends = controller.hasFriends;
final tabs = hasFriends ? _tabsWithPk : _tabsWithoutPk;
final selectedIndex = controller.currentSelectedIndex;
return IndexedStack(index: selectedIndex, children: tabs);
}),
bottomNavigationBar: Obx(() => DfTabBar(
selectedIndex: controller.currentSelectedIndex,
showPk: controller.hasFriends,
onTap: controller.changeTab,
)),
),
);
}
... ...
import '../../controllers/home_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../pk/controllers/pk_controller.dart';
import '../../../pk/views/pk_view.dart';
class HomePkView extends GetView<PkController> {
const HomePkView({super.key});
@override
Widget build(BuildContext context) => Obx(() {
if (controller.selectedFriend.value == null) {
return ColoredBox(
color: const Color(0xFFF5F2FF),
child: Center(
child: controller.isLoading.value
? const CircularProgressIndicator()
: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
controller.loadFailed.value
? '加载失败,请重试'
: '添加好友后即可查看健康PK',
),
const SizedBox(height: 12),
TextButton(
onPressed: controller.refreshData,
child: const Text('刷新'),
),
],
),
),
);
}
return PkView(
data: controller.data,
friendUserId: controller.selectedFriend.value?.friendUserId,
loadFailed: controller.loadFailed.value ||
(controller.isPartnerSelected && controller.pkLoadFailed.value),
onBack: () => Get.find<HomeController>().selectTab(HomeTab.today),
onRefresh: controller.refreshData,
onSwitchFriend: controller.showFriendListBottomSheet,
);
});
}
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -15,10 +14,7 @@ class HomeTrendView extends GetView<TrendController> {
return const _TrendPageBackground(
child: Column(
children: [
SafeArea(
bottom: false,
child: _HomeTrendHeader(),
),
SafeArea(bottom: false, child: _HomeTrendHeader()),
Expanded(child: _HomeTrendBody()),
],
),
... ... @@ -39,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> {
refreshToken: controller.refreshToken.value,
onTypeChanged: controller.changeType,
onQueryChanged: controller.changeQuery,
friendAvatarUrl: controller.targetFriendInfo.value?.avatar,
),
);
}
... ... @@ -121,10 +118,7 @@ class _TrendHeaderAvatar extends StatelessWidget {
height: 36,
decoration: ShapeDecoration(
image: imageUrl?.isNotEmpty == true
? DecorationImage(
image: NetworkImage(imageUrl!),
fit: BoxFit.cover,
)
? DecorationImage(image: NetworkImage(imageUrl!), fit: BoxFit.cover)
: null,
shape: RoundedRectangleBorder(
side: const BorderSide(width: 0.78, color: Colors.white),
... ... @@ -139,11 +133,7 @@ class _TrendHeaderAvatar extends StatelessWidget {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 44,
height: 44,
child: Center(child: avatar),
),
child: SizedBox(width: 44, height: 44, child: Center(child: avatar)),
);
}
}
... ... @@ -153,11 +143,7 @@ class _AvatarFallback extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Icon(
Icons.person_rounded,
color: Colors.white,
size: 22,
);
return const Icon(Icons.person_rounded, color: Colors.white, size: 22);
}
}
... ...
... ... @@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
class DfTabBar extends StatelessWidget {
final int selectedIndex;
final bool showPk;
final ValueChanged<int> onTap;
const DfTabBar({
super.key,
required this.selectedIndex,
required this.showPk,
required this.onTap,
});
... ... @@ -19,6 +21,7 @@ class DfTabBar extends StatelessWidget {
'assets/images/tabbar/icon_today.png',
'assets/images/tabbar/icon_trend.png',
'assets/images/tabbar/icon_friends.png',
'assets/images/tabbar/icon_pk.png',
'assets/images/tabbar/icon_my.png',
];
... ... @@ -26,6 +29,7 @@ class DfTabBar extends StatelessWidget {
'assets/images/tabbar/icon_today_selected.png',
'assets/images/tabbar/icon_trend_selected.png',
'assets/images/tabbar/icon_friends_selected.png',
'assets/images/tabbar/icon_pk_selected.png',
'assets/images/tabbar/icon_my_selected.png',
];
... ... @@ -36,8 +40,10 @@ class DfTabBar extends StatelessWidget {
l10n.tabToday,
l10n.tabTrend,
l10n.tabFriends,
'PK',
l10n.tabMy,
];
final tabIndexes = showPk ? List.generate(5, (index) => index) : [0, 1, 2, 4];
return SafeArea(
top: false,
... ... @@ -60,7 +66,7 @@ class DfTabBar extends StatelessWidget {
padding: const EdgeInsets.all(4),
child: LayoutBuilder(
builder: (context, constraints) {
final tabWidth = constraints.maxWidth / _tabIcons.length;
final tabWidth = constraints.maxWidth / tabIndexes.length;
return Stack(
clipBehavior: Clip.none,
children: [
... ... @@ -80,27 +86,33 @@ class DfTabBar extends StatelessWidget {
),
),
Row(
children: List.generate(_tabIcons.length, (i) {
final isSelected = selectedIndex == i;
children: List.generate(tabIndexes.length, (index) {
final tabIndex = tabIndexes[index];
final isSelected = selectedIndex == index;
return Expanded(
child: RepaintBoundary(
child: GestureDetector(
onTap: () => onTap(i),
onTap: () => onTap(index),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
isSelected
? _selectedTabIcons[i]
: _tabIcons[i],
? _selectedTabIcons[tabIndex]
: _tabIcons[tabIndex],
color: tabIndex == 3
? (isSelected
? context.colors.primary
: const Color(0xFF0F0F11))
: null,
width: 24,
height: 24,
gaplessPlayback: true,
),
const SizedBox(height: 0.5),
Text(
tabLabels[i],
tabLabels[tabIndex],
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
... ...
... ... @@ -13,8 +13,10 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
required this.selectedFriend,
required this.onSelectSelf,
required this.onSelectFriend,
this.showSelf = true,
});
final bool showSelf;
final ScrollController scrollController;
final RxList<FriendItem> friendsList;
final Rxn<FriendItem> selectedFriend;
... ... @@ -77,7 +79,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
.meUserInfo;
final selfNickname = self?.nickname?.trim();
final currentSelectedId = selectedFriend.value?.friendUserId;
final itemCount = friendsList.length + 1;
final itemCount = friendsList.length + (showSelf ? 1 : 0);
return ListView.separated(
controller: scrollController,
... ... @@ -85,7 +87,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
itemCount: itemCount,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
if (index == 0) {
if (showSelf && index == 0) {
return _BottomSheetUserRow(
name: selfNickname?.isNotEmpty == true
? selfNickname!
... ... @@ -102,7 +104,7 @@ class TrendFriendSelectBottomSheet extends StatelessWidget {
);
}
final friend = friendsList[index - 1];
final friend = friendsList[index - (showSelf ? 1 : 0)];
final name = _friendName(context, friend);
final nickname = friend.friendNickname?.trim();
return _BottomSheetUserRow(
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import '../models/hrv_report_models.dart';
import 'hrv_distribution_bar.dart';
class HrvStressDistributionCard extends StatelessWidget {
const HrvStressDistributionCard({
super.key,
required this.report,
required this.title,
this.showExample = false,
this.trailing,
this.footer,
this.onTap,
});
final HrvPeriodReport report;
final String title;
final bool showExample;
final Widget? trailing;
final Widget? footer;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final content = Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Title(
title: title,
showExample: showExample,
trailing: trailing,
),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.hrvValidDays,
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 12,
),
),
const SizedBox(height: 2),
_ValueWithUnit(
value: '${report.validDays}',
unit: context.l10n.hrvDistributionDayUnit,
),
const SizedBox(height: 28),
_DistributionGrid(report: report),
],
),
),
_DistributionBar(report: report),
const SizedBox(width: 16),
],
),
if (footer != null) ...[
const SizedBox(height: 20),
const Divider(height: 1, color: Color(0xFFF3F3F3)),
const SizedBox(height: 20),
footer!,
],
],
),
);
if (onTap == null) return content;
return Stack(
children: [
content,
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
),
),
],
);
}
}
class _Title extends StatelessWidget {
const _Title({
required this.title,
required this.showExample,
this.trailing,
});
final String title;
final bool showExample;
final Widget? trailing;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(title,
style:
const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
if (showExample)
Image.asset(
R.assetsImagesTrendProIcon,
width: 46.w,
height: 13.h,
fit: BoxFit.contain,
),
if (trailing != null) ...[
const Spacer(),
trailing!,
],
],
);
}
class _DistributionGrid extends StatelessWidget {
const _DistributionGrid({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
const spacing = 14.0;
final itemWidth = (constraints.maxWidth - spacing) / 2;
return Wrap(
spacing: spacing,
runSpacing: 19,
children: [
for (final level in HrvStressLevel.values)
SizedBox(
width: itemWidth,
child: _DistributionItem(level: level, report: report),
),
],
);
},
);
}
class _DistributionItem extends StatelessWidget {
const _DistributionItem({required this.level, required this.report});
final HrvStressLevel level;
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
final count = report.countFor(level);
final percent =
report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Color(level.colorValue),
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
level.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
],
),
const SizedBox(height: 8),
if (report.hasData)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$count',
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 26,
fontWeight: FontWeight.w500,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 2),
child: Text(
context.l10n.hrvDistributionDayUnit,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
)
else
Text(
context.l10n.hrvDistributionAwaitingData,
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 1),
Text(
'$percent%',
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 12,
height: 1.2,
),
),
],
);
}
}
class _ValueWithUnit extends StatelessWidget {
const _ValueWithUnit({required this.value, required this.unit});
final String value;
final String unit;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: const TextStyle(
fontSize: 26, fontWeight: FontWeight.w600, height: 1),
),
Padding(
padding: const EdgeInsets.only(left: 3, bottom: 2),
child: Text(unit, style: const TextStyle(fontSize: 12)),
),
],
);
}
class _DistributionBar extends StatelessWidget {
const _DistributionBar({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
final segments = [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
HrvDistributionBarSegment(
color: Color(level.colorValue),
flex: report.countFor(level),
),
];
return SizedBox(
width: 42,
height: 220,
child: IgnorePointer(
child: HrvDistributionBar(
segments: report.hasData ? segments : const [],
backgroundColor: const Color(0xFFF3F3F3),
radius: 7,
),
),
);
}
}
... ...
... ... @@ -4,14 +4,16 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import '../../../../core/utils/app_time_formatter.dart';
import '../../../../r.dart';
import '../../../widget/friend_interact_click_view.dart';
import '../../report_common/utils/report_localization.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../models/hrv_report_models.dart';
import 'hrv_distribution_bar.dart';
import 'hrv_stress_distribution_card.dart';
class HrvWeekReportView extends StatelessWidget {
const HrvWeekReportView({
... ... @@ -31,7 +33,8 @@ class HrvWeekReportView extends StatelessWidget {
Widget build(BuildContext context) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final data = report ??
final data =
report ??
WeeklyHrvReport.empty(
today.subtract(Duration(days: today.weekday - 1)),
);
... ... @@ -95,9 +98,28 @@ class _HrvPeriodReportView extends StatelessWidget {
tooltipDismissDelayMs: tooltipDismissDelayMs,
),
const SizedBox(height: 12),
_DistributionCard(
HrvStressDistributionCard(
report: report,
title: showExample
? context.l10n.reportExampleTitle(
HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvStressDistribution,
),
)
: HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvStressDistribution,
),
showExample: showExample,
trailing: HealthReportSubjectScope.isViewingSelf(context)
? null
: FriendInteractClickView(
interactionType: isMonth
? FriendInteractionType.monthStressTrend
: FriendInteractionType.weekStressTrend,
),
footer: _ExtremeHrvSection(report: report),
onTap: showExample ? onSubscribe : null,
),
],
... ... @@ -134,6 +156,7 @@ class _DailyStressCard extends StatelessWidget {
context.l10n.hrvDailyStressTrend,
),
showExample: showExample,
isMonth: isMonth,
),
const SizedBox(height: 18),
SizedBox(
... ... @@ -311,11 +334,14 @@ class _HrvBarChartState extends State<_HrvBarChart> {
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
... ... @@ -345,8 +371,9 @@ class _HrvBarChartState extends State<_HrvBarChart> {
// Keep individual empty days selectable so their
// localized date and empty-state message are visible.
final nextIndex = touchedIndex;
final nextOffset =
nextIndex == null ? null : response?.spot?.offset;
final nextOffset = nextIndex == null
? null
: response?.spot?.offset;
if (_touchedIndex != nextIndex ||
_touchedOffset != nextOffset) {
setState(() {
... ... @@ -538,27 +565,38 @@ class _TrendMetric extends StatelessWidget {
final isGoodChange = _isGoodChange(difference);
final comparison = hasData
? isMonth
? _monthComparisonText(context, difference!)
: _weekComparisonText(context, difference!)
? _monthComparisonText(context, difference!)
: _weekComparisonText(context, difference!)
: _unavailableComparisonText(context);
final comparisonColor = _comparisonColor(difference);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(color: Color(0xFF78787D), fontSize: 12)),
Text(
label,
style: const TextStyle(color: Color(0xFF78787D), fontSize: 12),
),
const SizedBox(height: 3),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(context.l10n.hrvTrendDayValue(value),
style: const TextStyle(
fontSize: 26, fontWeight: FontWeight.w600, height: 1)),
Text(
context.l10n.hrvTrendDayValue(value),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w600,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 2),
child: Text(context.l10n.hrvTrendDayUnit,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500)),
child: Text(
context.l10n.hrvTrendDayUnit,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
... ... @@ -591,16 +629,16 @@ class _TrendMetric extends StatelessWidget {
return difference == 0
? context.l10n.hrvTrendSameAsLastWeek
: difference > 0
? context.l10n.hrvTrendMoreDaysThanLastWeek(difference)
: context.l10n.hrvTrendFewerDaysThanLastWeek(difference.abs());
? context.l10n.hrvTrendMoreDaysThanLastWeek(difference)
: context.l10n.hrvTrendFewerDaysThanLastWeek(difference.abs());
}
String _monthComparisonText(BuildContext context, int difference) {
return difference == 0
? context.l10n.hrvTrendSameAsLastMonth
: difference > 0
? context.l10n.hrvTrendMoreDaysThanLastMonth(difference)
: context.l10n.hrvTrendFewerDaysThanLastMonth(difference.abs());
? context.l10n.hrvTrendMoreDaysThanLastMonth(difference)
: context.l10n.hrvTrendFewerDaysThanLastMonth(difference.abs());
}
String _unavailableComparisonText(BuildContext context) {
... ... @@ -662,11 +700,7 @@ class _StressAxisText extends StatelessWidget {
return Text(
text,
maxLines: 1,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 10,
height: 1,
),
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10, height: 1),
);
}
}
... ... @@ -768,226 +802,22 @@ String _weekAxisLabel(BuildContext context, int weekday) {
};
}
class _DistributionCard extends StatelessWidget {
const _DistributionCard({
required this.report,
required this.showExample,
this.onTap,
});
class _ExtremeHrvSection extends StatelessWidget {
const _ExtremeHrvSection({required this.report});
final HrvPeriodReport report;
final bool showExample;
final VoidCallback? onTap;
static const _barRight = 25.0;
static const _barWidth = 42.0;
@override
Widget build(BuildContext context) {
return _Card(
onTap: onTap,
padding: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_topView(context),
const Divider(height: 1, color: Color(0xFFF3F3F3)),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _ExtremeHrv(
title: context.l10n.hrvLowest,
day: report.minDay,
),
),
Expanded(
child: _ExtremeHrv(
title: context.l10n.hrvHighest,
day: report.maxDay,
),
),
],
),
],
),
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: _ExtremeHrv(title: context.l10n.hrvLowest, day: report.minDay),
),
);
}
Widget _topView(BuildContext context) {
return Stack(
children: [
Positioned(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ReportTitle(
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvStressDistribution,
),
showExample: showExample,
fontSize: 16,
),
const SizedBox(height: 20),
Text(
context.l10n.hrvValidDays,
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 12,
),
),
const SizedBox(height: 2),
_ValueWithUnit(
value: '${report.validDays}',
unit: context.l10n.hrvDistributionDayUnit,
),
const SizedBox(height: 28),
SizedBox(
width: 230,
child: _DistributionGrid(report: report),
),
const SizedBox(height: 20),
],
)),
Positioned(
top: 0,
width: _barWidth,
right: _barRight,
bottom: 25.h,
child: Container(
alignment: Alignment.topCenter,
padding: EdgeInsets.only(top: 25.h),
child: IgnorePointer(child: _DistributionBar(report: report))),
),
],
);
}
}
class _DistributionGrid extends StatelessWidget {
const _DistributionGrid({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
const spacing = 14.0;
return Wrap(
spacing: spacing,
runSpacing: 19,
children: [
for (final level in HrvStressLevel.values)
SizedBox(
width: 105,
child: _DistributionItem(level: level, report: report),
),
],
);
},
);
}
}
class _DistributionItem extends StatelessWidget {
const _DistributionItem({required this.level, required this.report});
final HrvStressLevel level;
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
final count = report.countFor(level);
final percent =
report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Color(level.colorValue), shape: BoxShape.circle)),
const SizedBox(width: 4),
Expanded(
child: Text(
level.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
]),
const SizedBox(height: 8),
if (report.hasData)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('$count',
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 26,
fontWeight: FontWeight.w500,
height: 1)),
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 2),
child: Text(context.l10n.hrvDistributionDayUnit,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 12,
fontWeight: FontWeight.w500)),
),
],
)
else
Text(context.l10n.hrvDistributionAwaitingData,
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 15,
fontWeight: FontWeight.w500,
height: 1.2)),
const SizedBox(height: 1),
Text('$percent%',
style: const TextStyle(
color: Color(0xFF78787D),
fontSize: 12,
height: 1.2,
)),
],
);
}
}
class _DistributionBar extends StatelessWidget {
const _DistributionBar({required this.report});
final HrvPeriodReport report;
@override
Widget build(BuildContext context) {
final segments = [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
HrvDistributionBarSegment(
color: Color(level.colorValue),
flex: report.countFor(level),
),
];
return HrvDistributionBar(
segments: report.hasData ? segments : const [],
backgroundColor: const Color(0xFFF3F3F3),
radius: 8,
);
}
Expanded(
child: _ExtremeHrv(title: context.l10n.hrvHighest, day: report.maxDay),
),
],
);
}
class _ExtremeHrv extends StatelessWidget {
... ... @@ -1001,12 +831,15 @@ class _ExtremeHrv extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
Text(
title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
const SizedBox(height: 5),
_ValueWithUnit(
value: day == null ? '-' : '${day!.averageHrv!.round()}',
unit: 'ms'),
value: day == null ? '-' : '${day!.averageHrv!.round()}',
unit: 'ms',
),
const SizedBox(height: 2),
Text(
day == null
... ... @@ -1039,9 +872,14 @@ class _ValueWithUnit extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: const TextStyle(
fontSize: 26, fontWeight: FontWeight.w600, height: 1)),
Text(
value,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w600,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(left: 3, bottom: 2),
child: Text(unit, style: const TextStyle(fontSize: 12)),
... ... @@ -1055,20 +893,21 @@ class _ReportTitle extends StatelessWidget {
const _ReportTitle({
required this.title,
required this.showExample,
this.fontSize = 15,
required this.isMonth,
});
final String title;
final bool showExample;
final double fontSize;
final bool isMonth;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
showExample ? context.l10n.reportExampleTitle(title) : title,
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
if (showExample) ...[
Image.asset(
... ... @@ -1078,6 +917,14 @@ class _ReportTitle extends StatelessWidget {
fit: BoxFit.contain,
),
],
if (!HealthReportSubjectScope.isViewingSelf(context)) ...[
const Spacer(),
FriendInteractClickView(
interactionType: isMonth
? FriendInteractionType.monthStressTrend
: FriendInteractionType.weekStressTrend,
),
],
],
);
}
... ... @@ -1100,7 +947,9 @@ class _Card extends StatelessWidget {
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(16)),
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: child,
);
if (onTap == null) return content;
... ...
... ... @@ -2,14 +2,16 @@ import 'dart:async';
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/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../../report_common/utils/report_localization.dart';
import '../models/hrv_report_models.dart';
import 'hrv_distribution_bar.dart';
import 'hrv_stress_distribution_card.dart';
class HrvYearReportView extends StatelessWidget {
const HrvYearReportView({
... ... @@ -31,7 +33,18 @@ class HrvYearReportView extends StatelessWidget {
tooltipDismissDelayMs: tooltipDismissDelayMs,
),
const SizedBox(height: 12),
_YearDistributionCard(report: data),
HrvStressDistributionCard(
report: data,
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvStressDistribution,
),
trailing: HealthReportSubjectScope.isViewingSelf(context)
? null
: const FriendInteractClickView(
interactionType: FriendInteractionType.yearStressTrend,
),
),
const SizedBox(height: 12),
_DailyDistributionCard(report: data),
],
... ... @@ -55,12 +68,11 @@ class _YearTrendCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
_ReportTitle(
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvMonthlyStressTrend,
),
style: _titleStyle,
),
const SizedBox(height: 18),
SizedBox(
... ... @@ -165,12 +177,14 @@ class _YearBarChartState extends State<_YearBarChart> {
BarChartGroupData(
x: month,
groupVertically: true,
showingTooltipIndicators:
_touchedMonth == month ? [1] : [],
showingTooltipIndicators: _touchedMonth == month
? [1]
: [],
barRods: [
BarChartRodData(
toY: _barHeight(
widget.report.stressScoreForMonth(month)),
widget.report.stressScoreForMonth(month),
),
width: 8,
color: _colorForMonth(month),
borderRadius: const BorderRadius.vertical(
... ... @@ -192,10 +206,8 @@ class _YearBarChartState extends State<_YearBarChart> {
show: true,
drawVerticalLine: false,
horizontalInterval: 25,
getDrawingHorizontalLine: (_) => const FlLine(
color: _grid,
dashArray: [2, 2],
),
getDrawingHorizontalLine: (_) =>
const FlLine(color: _grid, dashArray: [2, 2]),
),
extraLinesData: ExtraLinesData(
extraLinesOnTop: false,
... ... @@ -210,11 +222,14 @@ class _YearBarChartState extends State<_YearBarChart> {
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
... ... @@ -241,11 +256,13 @@ class _YearBarChartState extends State<_YearBarChart> {
event.localPosition?.dx,
constraints.maxWidth,
);
final hasTouchedData = month != null &&
final hasTouchedData =
month != null &&
widget.report.stressScoreForMonth(month) != null;
final nextMonth = hasTouchedData ? month : null;
final nextOffset =
nextMonth == null ? null : response?.spot?.offset;
final nextOffset = nextMonth == null
? null
: response?.spot?.offset;
if (_touchedMonth != nextMonth ||
_touchedOffset != nextOffset) {
setState(() {
... ... @@ -302,8 +319,10 @@ class _YearBarChartState extends State<_YearBarChart> {
),
),
if (!widget.report.hasData)
Text(context.l10n.hrvTrendAwaitingData,
style: const TextStyle(color: _h3, fontSize: 11)),
Text(
context.l10n.hrvTrendAwaitingData,
style: const TextStyle(color: _h3, fontSize: 11),
),
],
);
}
... ... @@ -394,17 +413,15 @@ String _tooltipMonth(BuildContext context, int month) {
}
class _MonthExtreme extends StatelessWidget {
const _MonthExtreme({
required this.label,
required this.months,
});
const _MonthExtreme({required this.label, required this.months});
final String label;
final List<int> months;
@override
Widget build(BuildContext context) {
final separator =
Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', ';
final separator = Localizations.localeOf(context).languageCode == 'zh'
? ','
: ', ';
final value = months.isEmpty
? context.l10n.hrvEmptyMonthPlaceholder
: months.map((month) => _tooltipMonth(context, month)).join(separator);
... ... @@ -475,7 +492,8 @@ class _YearXAxisLabels extends StatelessWidget {
children: [
for (var index = 0; index < groupCount; index++)
Positioned(
left: constraints.maxWidth * ((index + 0.5) / groupCount) -
left:
constraints.maxWidth * ((index + 0.5) / groupCount) -
labelWidth / 2,
top: 6,
width: labelWidth,
... ... @@ -492,69 +510,6 @@ class _YearXAxisLabels extends StatelessWidget {
}
}
class _YearDistributionCard extends StatelessWidget {
const _YearDistributionCard({required this.report});
final YearlyHrvReport report;
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvStressDistribution,
),
style: _titleStyle,
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.hrvValidDays,
style: const TextStyle(color: _h2, fontSize: 11)),
const SizedBox(height: 3),
_Number(
value: '${report.validDays}',
unit: context.l10n.hrvDistributionDayUnit,
),
const SizedBox(height: 18),
LayoutBuilder(
builder: (context, constraints) {
const spacing = 16.0;
final itemWidth = (constraints.maxWidth - spacing) / 2;
return Wrap(
spacing: spacing,
runSpacing: 13,
children: [
for (final level in HrvStressLevel.values)
SizedBox(
width: itemWidth,
child: _LevelItem(report: report, level: level),
),
],
);
},
),
],
),
),
_StackedBar(report: report),
const SizedBox(width: 20),
],
),
],
),
);
}
}
class _DailyDistributionCard extends StatelessWidget {
const _DailyDistributionCard({required this.report});
final YearlyHrvReport report;
... ... @@ -566,12 +521,12 @@ class _DailyDistributionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
HealthReportSubjectScope.titleOf(
_ReportTitle(
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvDailyStressDistribution,
),
style: _titleStyle,
trailingInset: 4,
),
const SizedBox(height: 20),
GridView.builder(
... ... @@ -606,18 +561,16 @@ class _DailyDistributionCard extends StatelessWidget {
}
({List<HrvDayReport> mostStressed, List<HrvDayReport> mostRelaxed})
_dailyStressExtremeDays(YearlyHrvReport report) {
_dailyStressExtremeDays(YearlyHrvReport report) {
final daysWithScore = report.days.where((day) {
return day.level != null && day.dailyComprehensiveStressScore != null;
});
final mostStressed = daysWithScore
.where((day) => _isStressedLevel(day.level!))
.toList()
..sort(_compareByDailyStressDescending);
final mostRelaxed = daysWithScore
.where((day) => !_isStressedLevel(day.level!))
.toList()
..sort(_compareByDailyStressAscending);
final mostStressed =
daysWithScore.where((day) => _isStressedLevel(day.level!)).toList()
..sort(_compareByDailyStressDescending);
final mostRelaxed =
daysWithScore.where((day) => !_isStressedLevel(day.level!)).toList()
..sort(_compareByDailyStressAscending);
return (
mostStressed: _takeTwoAndSortByDate(mostStressed),
... ... @@ -626,14 +579,16 @@ class _DailyDistributionCard extends StatelessWidget {
}
int _compareByDailyStressDescending(HrvDayReport a, HrvDayReport b) {
final valueComparison = b.dailyComprehensiveStressScore!
.compareTo(a.dailyComprehensiveStressScore!);
final valueComparison = b.dailyComprehensiveStressScore!.compareTo(
a.dailyComprehensiveStressScore!,
);
return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison;
}
int _compareByDailyStressAscending(HrvDayReport a, HrvDayReport b) {
final valueComparison = a.dailyComprehensiveStressScore!
.compareTo(b.dailyComprehensiveStressScore!);
final valueComparison = a.dailyComprehensiveStressScore!.compareTo(
b.dailyComprehensiveStressScore!,
);
return valueComparison == 0 ? a.date.compareTo(b.date) : valueComparison;
}
... ... @@ -661,8 +616,10 @@ class _MonthDots extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_tooltipMonth(context, month),
style: const TextStyle(fontSize: 8)),
Text(
_tooltipMonth(context, month),
style: const TextStyle(fontSize: 8),
),
const SizedBox(height: 8),
SizedBox(
width: 80,
... ... @@ -694,121 +651,51 @@ class _DateExtreme extends StatelessWidget {
@override
Widget build(BuildContext context) {
final separator =
Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', ';
final separator = Localizations.localeOf(context).languageCode == 'zh'
? ','
: ', ';
final value = days.isEmpty
? context.l10n.hrvEmptyMonthDayPlaceholder
: days
.take(2)
.map((day) => reportMonthDay(day.date, context: context))
.join(separator);
.take(2)
.map((day) => reportMonthDay(day.date, context: context))
.join(separator);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: _h2, fontSize: 10)),
const SizedBox(height: 6),
Text(value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
);
}
}
class _LevelItem extends StatelessWidget {
const _LevelItem({required this.report, required this.level});
final YearlyHrvReport report;
final HrvStressLevel level;
@override
Widget build(BuildContext context) {
final count = report.countFor(level);
final percent =
report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: Color(level.colorValue), shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(level.label,
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w500)),
]),
const SizedBox(height: 3),
if (report.hasData)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('$count',
style: const TextStyle(
color: _h1,
fontSize: 26,
fontWeight: FontWeight.w500,
height: 1)),
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 2),
child: Text(context.l10n.hrvDistributionDayUnit,
style: const TextStyle(
color: _h1, fontSize: 12, fontWeight: FontWeight.w500)),
),
],
)
else
Text(context.l10n.hrvDistributionAwaitingData,
style: const TextStyle(
color: _h2, fontSize: 15, fontWeight: FontWeight.w500)),
Text('$percent%', style: const TextStyle(color: _h2, fontSize: 9)),
Text(
value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
],
);
}
}
class _StackedBar extends StatelessWidget {
const _StackedBar({required this.report});
final YearlyHrvReport report;
static const _height = 180.0;
class _ReportTitle extends StatelessWidget {
const _ReportTitle({required this.title, this.trailingInset = 0});
@override
Widget build(BuildContext context) {
final segments = [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
HrvDistributionBarSegment(
color: Color(level.colorValue),
flex: report.countFor(level),
),
];
return SizedBox(
width: 33,
height: _height,
child: HrvDistributionBar(
segments: report.hasData ? segments : const [],
backgroundColor: _grid,
radius: 7,
),
);
}
}
final String title;
final double trailingInset;
class _Number extends StatelessWidget {
const _Number({required this.value, required this.unit});
final String value;
final String unit;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: const TextStyle(
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
Padding(
padding: const EdgeInsets.only(left: 3, bottom: 2),
child: Text(unit, style: const TextStyle(fontSize: 10))),
],
);
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(title, style: _titleStyle),
if (!HealthReportSubjectScope.isViewingSelf(context)) ...[
const Spacer(),
Padding(
padding: EdgeInsets.only(right: trailingInset),
child: const FriendInteractClickView(
interactionType: FriendInteractionType.yearStressTrend,
),
),
],
],
);
}
class _Card extends StatelessWidget {
... ... @@ -820,12 +707,14 @@ class _Card extends StatelessWidget {
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: child,
);
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: child,
);
}
const _titleStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600);
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
enum FriendInteractionAction { miss, stick, punch }
/// Friend-interaction bottom sheet. It accepts independent callbacks so each
/// host page can handle the three actions without coupling to a controller.
class FriendInteractionDialog extends StatelessWidget {
const FriendInteractionDialog({
super.key,
required this.avatar,
required this.steps,
this.onAction,
this.onMiss,
this.onPoke,
this.onPunch,
});
final ImageProvider? avatar;
final String steps;
/// Compatibility callback for callers that prefer one typed action handler.
final ValueChanged<FriendInteractionAction>? onAction;
final VoidCallback? onMiss;
final VoidCallback? onPoke;
final VoidCallback? onPunch;
static Future<T?> show<T>(
BuildContext context, {
required ImageProvider? avatar,
required String steps,
ValueChanged<FriendInteractionAction>? onAction,
VoidCallback? onMiss,
VoidCallback? onPoke,
VoidCallback? onPunch,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
backgroundColor: _surface,
barrierColor: Colors.black.withValues(alpha: .7),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => FriendInteractionDialog(
avatar: avatar,
steps: steps,
onAction: onAction,
onMiss: onMiss,
onPoke: onPoke,
onPunch: onPunch,
),
);
}
static const _surface = Color(0xFFF5F4FF);
@override
Widget build(BuildContext context) {
final isVip =
Get.find<UserPreferencesStorage>().preferences.value.vipInfo?.isVip ==
true;
return Material(
color: Colors.transparent,
child: SafeArea(
top: false,
child: SizedBox(
height: 333,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: -77,
left: 22,
child: Image.asset(
'assets/images/interact/ic_friend_interact.png',
width: 98,
height: 106,
),
),
Positioned(
top: -24,
left: 28,
child: Transform.rotate(
angle: -0.08,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 8,
),
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,
),
),
),
),
),
Positioned(
top: 17,
right: 28,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Color(0xFF845EEE)),
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 20,
height: 20,
),
),
),
Positioned(top: 42, left: 0, right: 0, child: _buildAvatar()),
Positioned(
top: 143,
left: 0,
right: 0,
child: Text(
context.l10n.interactTodaySteps,
textAlign: TextAlign.center,
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,
),
),
],
),
),
),
Positioned(
top: 213,
left: 0,
right: 0,
child: FriendInteractActionButtonsView(
isVip: isVip,
onMiss: _handleMiss,
onPoke: _handlePoke,
onPunch: _handlePunch,
),
),
],
),
),
),
);
}
Widget _buildAvatar() => Center(
child: Container(
width: 90,
height: 90,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: avatar == null
? const Icon(Icons.person, color: Color(0xFF908B91))
: Image(image: avatar!, fit: BoxFit.cover),
),
);
void _handleMiss() {
onAction?.call(FriendInteractionAction.miss);
onMiss?.call();
}
void _handlePoke() {
onAction?.call(FriendInteractionAction.stick);
onPoke?.call();
}
void _handlePunch() {
onAction?.call(FriendInteractionAction.punch);
onPunch?.call();
}
}
/// Reusable three-action group for any friend-interaction entry point.
///
/// Miss and Punch are Pro actions. Their callbacks can therefore open a
/// membership flow, while Poke can be handled directly by the host page.
class FriendInteractActionButtonsView extends StatelessWidget {
const FriendInteractActionButtonsView({
super.key,
required this.isVip,
this.onMiss,
this.onPoke,
this.onPunch,
});
final bool isVip;
final VoidCallback? onMiss;
final VoidCallback? onPoke;
final VoidCallback? onPunch;
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_FriendInteractActionButton(
label: context.l10n.interactActionMiss,
iconAsset: 'assets/images/interact/ic_friend_interact_miss.png',
showProBadge: !isVip,
onTap: isVip ? onMiss : _openPurchase,
),
const SizedBox(width: 12),
_FriendInteractActionButton(
label: context.l10n.interactActionStick,
iconAsset: 'assets/images/interact/ic_friend_interact_poke.png',
isPrimary: true,
onTap: onPoke,
),
const SizedBox(width: 12),
_FriendInteractActionButton(
label: context.l10n.interactActionPunch,
iconAsset: 'assets/images/interact/ic_friend_interact_punch.png',
showProBadge: !isVip,
onTap: isVip ? onPunch : _openPurchase,
),
],
);
void _openPurchase() => Get.toNamed(Routes.PURCHASE);
}
class _FriendInteractActionButton extends StatelessWidget {
const _FriendInteractActionButton({
required this.label,
required this.iconAsset,
required this.onTap,
this.isPrimary = false,
this.showProBadge = false,
});
final String label;
final String iconAsset;
final VoidCallback? onTap;
final bool isPrimary;
final bool showProBadge;
@override
Widget build(BuildContext context) {
final iconSize = isPrimary ? 68.0 : 52.0;
final buttonSize = isPrimary ? const Size(116, 78) : const Size(90, 56);
final buttonTop = isPrimary ? 34.0 : 30.0;
return SizedBox(
width: buttonSize.width,
height: isPrimary ? 112 : 93,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
Positioned(
top: buttonTop,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(isPrimary ? 24 : 16),
child: Ink(
width: buttonSize.width,
height: buttonSize.height,
decoration: BoxDecoration(
color: const Color(0xFF896CDC),
borderRadius: BorderRadius.circular(isPrimary ? 24 : 16),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: isPrimary ? 12 : 9),
child: Text(
label,
style: TextStyle(
color: Colors.white,
fontSize: isPrimary ? 20 : 16,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
),
),
),
),
),
Image.asset(iconAsset, width: iconSize, height: iconSize),
if (showProBadge)
Positioned(
left: 21,
top: 43,
child: Image.asset(
R.assetsImagesTrendProIcon,
width: 46,
height: 13,
fit: BoxFit.contain,
),
),
],
),
);
}
}
... ...
# PK 页面
- `PkView` 是独立内容 View,可嵌入主页或单独放入 Scaffold。传入 `PkData`、`onSwitchFriend`、`onBack`、`onRefresh` 和可选 `onPoke`
- `HomePkView` / `PkController` 管理主页入口与好友选择,复用趋势页好友弹层(`showSelf: false`)。默认不改变趋势页的选择。
- `PkData.result` 根据双方积分决定胜、负、平;`monthlyResult` 根据胜利天数独立决定。缺失值不视为平局,月历缺失日期显示空圈。
- 已接入 `HealthApi.getCurrentMonthPkData`:绑定另一半的双方积分、步数、活动消耗、站立小时数、胜利天数及每日积分。左侧映射 `pair_user_*`,右侧映射 `user_*`;默认选中另一半。当前接口没有好友参数,普通好友只显示好友接口提供的健康数据,不套用另一半的战绩。
- 请求结果按绑定对象及加载日期隔离,月历过滤跨月、未来及不完整记录。站立时间沿用现有活动报告的小时单位。
- `onPoke` 暂未接入:现有互动接口只有绑定双方通用动作,尚未确认 PK 数据互动参数及任意好友的目标参数,主页按钮仍禁用。
- Figma 导出图片保存在 `assets/images/pk/`,不依赖临时远程资源。`preview_friend` / `preview_self` 仅供设计测试使用。
设计状态测试:`flutter test test/app/modules/pk/pk_view_test.dart`
可选设置 `PK_FONT_PATH` 为本机中文字体文件、`PK_CAPTURE_DIR` 为截图目录,并加 `--update-goldens`,导出三种状态的预览图。
... ...
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../core/network/api/friend_api.dart';
import '../../../../core/result/app_result.dart';
import '../../../../data/local/user_preferences_storage.dart';
import '../../../../data/models/friend/friend_models.dart';
import '../../home/widgets/trend/trend_friend_select_bottom_sheet.dart';
import '../models/pk_data.dart';
import '../models/pk_data_mapper.dart';
import '../../../../core/network/api/health_api.dart';
import '../../../../data/models/health/health_models.dart';
class PkController extends GetxController {
PkController(this._friendApi, this._preferences, this._healthApi);
final HealthApi _healthApi;
final pkLoadFailed = false.obs;
final _partnerPk = Rxn<PkCurrentMonthData>();
int? _pkPartnerId;
DateTime? _pkLoadedAt;
final FriendApi _friendApi;
final UserPreferencesStorage _preferences;
final friends = <FriendItem>[].obs;
final selectedFriend = Rxn<FriendItem>();
final selfHealth = Rxn<FriendHealthData>();
final isLoading = false.obs;
final loadFailed = false.obs;
bool get isPartnerSelected =>
selectedFriend.value?.friendUserId != null &&
selectedFriend.value?.friendUserId ==
_preferences.preferences.value.partnerUserInfo?.id;
PkData get data {
final friend = selectedFriend.value;
final me = _preferences.preferences.value.meUserInfo;
final remark = friend?.remarkName?.trim();
final base = PkData(
month: DateTime.now(),
left: PkPlayer(
name: remark?.isNotEmpty == true
? remark!
: friend?.friendNickname ?? '好友',
subtitle: remark?.isNotEmpty == true
? '(${friend?.friendNickname ?? '好友'})'
: '',
avatarUrl: friend?.avatar,
steps: friend?.healthData?.totalSteps,
calories: friend?.healthData?.totalMove,
),
right: PkPlayer(
name: me?.nickname ?? '我',
subtitle: '(我)',
avatarUrl: me?.avatar,
steps: selfHealth.value?.totalSteps,
calories: selfHealth.value?.totalMove,
),
);
final now = DateTime.now();
final loaded = _pkLoadedAt;
final pk = _partnerPk.value;
if (pk != null &&
_pkPartnerId != null &&
friend?.friendUserId == _pkPartnerId &&
_preferences.preferences.value.partnerUserInfo?.id == _pkPartnerId &&
loaded?.year == now.year &&
loaded?.month == now.month &&
loaded?.day == now.day) {
return mapPartnerPkData(
pk,
partner: base.left,
self: base.right,
now: now,
);
}
return base;
}
Future<void> refreshData() async {
if (isLoading.value) return;
isLoading.value = true;
try {
final partner = _preferences.preferences.value.partnerUserInfo;
final requestedAt = DateTime.now();
final (result, pkResult) = await (
_friendApi.friendList(true, withSelfHealthData: true),
partner?.id == null
? Future<AppResult<PkCurrentMonthData>?>.value(null)
: _healthApi.getCurrentMonthPkData(),
).wait;
if (isClosed) return;
switch (result) {
case AppSuccess(:final data):
final id = selectedFriend.value?.friendUserId;
friends.assignAll(
data.list.where((friend) => friend.friendUserId != null),
);
if (partner?.id != null &&
!friends.any((friend) => friend.friendUserId == partner!.id)) {
friends.insert(
0,
FriendItem(
friendUserId: partner!.id,
friendNickname: partner.nickname,
avatar: partner.avatar,
),
);
}
selectedFriend.value =
friends.firstWhereOrNull((friend) => friend.friendUserId == id) ??
friends.firstWhereOrNull(
(friend) => friend.friendUserId == partner?.id,
) ??
friends.firstOrNull;
selfHealth.value = data.healthData;
loadFailed.value = false;
case AppFailure():
loadFailed.value = true;
}
if (selectedFriend.value == null &&
partner?.id != null &&
_preferences.preferences.value.partnerUserInfo?.id == partner?.id) {
final item = FriendItem(
friendUserId: partner!.id,
friendNickname: partner.nickname,
avatar: partner.avatar,
);
if (!friends.any((friend) => friend.friendUserId == partner.id)) {
friends.insert(0, item);
}
selectedFriend.value = item;
}
if (_preferences.preferences.value.partnerUserInfo?.id == partner?.id) {
switch (pkResult) {
case AppSuccess<PkCurrentMonthData>(:final data):
_pkPartnerId = partner?.id;
_pkLoadedAt = requestedAt;
_partnerPk.value = data;
pkLoadFailed.value = false;
case AppFailure<PkCurrentMonthData>():
pkLoadFailed.value = true;
case null:
_partnerPk.value = null;
_pkPartnerId = null;
pkLoadFailed.value = false;
}
}
} finally {
if (!isClosed) isLoading.value = false;
}
}
void showFriendListBottomSheet() {
unawaited(refreshData());
if (friends.isEmpty) return;
Get.bottomSheet(
DraggableScrollableSheet(
maxChildSize: .6,
initialChildSize: .6,
expand: false,
snap: true,
builder: (context, scrollController) => TrendFriendSelectBottomSheet(
scrollController: scrollController,
friendsList: friends,
selectedFriend: selectedFriend,
onSelectSelf: () {},
showSelf: false,
onSelectFriend: (friend) => selectedFriend.value = friend,
),
),
barrierColor: Colors.black.withValues(alpha: .7),
enableDrag: true,
isScrollControlled: true,
persistent: false,
);
}
}
... ...
/// PK results are supplied by the caller; missing scores are not a draw.
enum PkResult { win, loss, draw }
class PkPlayer {
const PkPlayer({
required this.name,
this.subtitle = '',
this.avatarUrl,
this.avatarAsset,
this.score,
this.steps,
this.calories,
this.standingHours,
this.winningDays,
});
final String name;
final String subtitle;
final String? avatarUrl;
final String? avatarAsset;
final int? score, steps, calories, winningDays;
final double? standingHours;
}
class PkDay {
const PkDay(this.leftScore, this.rightScore);
final int leftScore, rightScore;
double get leftFraction =>
leftScore + rightScore == 0 ? 0.5 : leftScore / (leftScore + rightScore);
}
class PkData {
const PkData({
required this.left,
required this.right,
required this.month,
this.days = const {},
});
final PkPlayer left, right;
final DateTime month;
/// Day of month -> result. Missing days render as unrecorded.
final Map<int, PkDay> days;
PkResult? get result => compare(left.score, right.score);
PkResult? get monthlyResult => compare(left.winningDays, right.winningDays);
static PkResult? compare(int? left, int? right) {
if (left == null || right == null) return null;
return left > right
? PkResult.win
: left < right
? PkResult.loss
: PkResult.draw;
}
}
... ...
import '../../../../data/models/health/health_models.dart';
import 'pk_data.dart';
/// The existing PK endpoint compares the signed-in user with their partner.
/// Its partner fields belong on the left, user fields on the right.
PkData mapPartnerPkData(
PkCurrentMonthData source, {
required PkPlayer partner,
required PkPlayer self,
required DateTime now,
}) {
final todayDate = pkRecordDate(source.todayRecord?.date);
final today = todayDate == null ||
(todayDate.year == now.year &&
todayDate.month == now.month &&
todayDate.day == now.day)
? source.todayRecord
: null;
final days = <int, PkDay>{};
for (final record in source.monthRecordList ?? const <PkRecord>[]) {
final date = pkRecordDate(record.date);
if (date == null ||
date.year != now.year ||
date.month != now.month ||
date.day > now.day ||
record.partnerScore == null ||
record.userScore == null) {
continue;
}
days[date.day] = PkDay(record.partnerScore!, record.userScore!);
}
if (today?.partnerScore != null && today?.userScore != null) {
days[now.day] = PkDay(today!.partnerScore!, today.userScore!);
}
return PkData(
month: DateTime(now.year, now.month),
days: days,
left: PkPlayer(
name: partner.name,
subtitle: partner.subtitle,
avatarUrl: partner.avatarUrl,
avatarAsset: partner.avatarAsset,
score: today?.partnerScore,
steps: today?.partnerSteps ?? partner.steps,
calories: today?.partnerMove ?? partner.calories,
standingHours: today?.partnerStand?.toDouble(),
winningDays: source.partnerWinAmount,
),
right: PkPlayer(
name: self.name,
subtitle: self.subtitle,
avatarUrl: self.avatarUrl,
avatarAsset: self.avatarAsset,
score: today?.userScore,
steps: today?.userSteps ?? self.steps,
calories: today?.userMove ?? self.calories,
standingHours: today?.userStand?.toDouble(),
winningDays: source.userWinAmount,
),
);
}
/// Accept the date encodings used by health records: yyyyMMdd or Unix time.
DateTime? pkRecordDate(int? value) {
if (value == null || value <= 0) return null;
if (value >= 10000101 && value <= 99991231) {
final year = value ~/ 10000, month = value ~/ 100 % 100, day = value % 100;
final date = DateTime(year, month, day);
return date.year == year && date.month == month && date.day == day
? date
: null;
}
if (value < 1000000000 || value > 8640000000000000) return null;
return DateTime.fromMillisecondsSinceEpoch(
value >= 1000000000000 ? value : value * 1000,
);
}
... ...