Commit d1a92435b3d71dc5fdcdfe5790b040491d3b895b

Authored by 刘宏哲
1 parent 704e3916

feat(app): bug fixed

import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
... ... @@ -30,6 +31,11 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
static const _bottomAxisLabelHeight = 20.0;
static const _axisHeight =
_chartHeight - _topAxisLabelHeight - _bottomAxisLabelHeight;
static const _bottomTitleStyle = TextStyle(
color: _h3,
fontSize: 10,
fontWeight: FontWeight.w400,
);
@override
Widget build(BuildContext context) {
... ... @@ -195,14 +201,17 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
getTitlesWidget: (value, meta) {
final label = _bottomLabel(value, start, axis.maxX);
if (label == null) return const SizedBox.shrink();
final isFirstLabel = value == 0;
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
label,
style: const TextStyle(
color: _h3,
fontSize: 10,
fontWeight: FontWeight.w400,
child: Transform.translate(
offset: Offset(
isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0,
0,
),
child: Text(
label,
style: _bottomTitleStyle,
),
),
);
... ... @@ -409,6 +418,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
final time = start.add(Duration(minutes: minutes));
return DateFormat('HH:mm').format(time);
}
double _textWidth(String text, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: ui.TextDirection.ltr,
maxLines: 1,
)..layout();
return painter.width;
}
}
class _HeartRateAxis {
... ...
... ... @@ -204,13 +204,13 @@ class _MonthRingCard extends StatelessWidget {
Widget build(BuildContext context) {
final hasData = report.hasData;
return Container(
height: 493,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 14),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
... ... @@ -274,7 +274,7 @@ class _MonthRingCard extends StatelessWidget {
],
),
const SizedBox(height: 10),
Expanded(child: _MonthGrid(report: report)),
_MonthGrid(report: report),
],
),
);
... ... @@ -383,9 +383,10 @@ class _MonthGrid extends StatelessWidget {
dateOf: (day) => day.date,
mainAxisSpacing: 8,
crossAxisSpacing: 11,
childAspectRatio: 0.62,
shrinkWrap: false,
mainAxisExtent: 46,
shrinkWrap: true,
dayBuilder: (context, day) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ActivityBurnRing(report: day, size: 32),
const SizedBox(height: 4),
... ... @@ -660,6 +661,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.start,
children: [
TextSpan(
text: _tooltipDate(report.date),
... ...
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_duration_text.dart';
... ... @@ -28,7 +29,7 @@ class ActivityBurnSummaryCard extends StatelessWidget {
);
return Container(
height: 90,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
padding: EdgeInsets.only(left: 20.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
... ... @@ -36,6 +37,7 @@ class ActivityBurnSummaryCard extends StatelessWidget {
child: Row(
children: [
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityMove,
color: _active,
... ... @@ -43,7 +45,9 @@ class ActivityBurnSummaryCard extends StatelessWidget {
unit: context.l10n.reportUnitKcal,
),
),
SizedBox(width: 13.w),
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityExercise,
color: _exercise,
... ... @@ -51,7 +55,9 @@ class ActivityBurnSummaryCard extends StatelessWidget {
unit: exercise.unit,
),
),
SizedBox(width: 13.w),
Expanded(
flex: 1,
child: _MetricColumn(
title: context.l10n.activityStand,
color: _stand,
... ... @@ -80,47 +86,53 @@ class _MetricColumn extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
const SizedBox(height: 3),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 26,
fontWeight: FontWeight.w600,
height: 1.1,
),
return Align(
alignment: Alignment.centerLeft,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
unit,
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
color: Color(0xFF0F0F11),
fontSize: 26,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
),
],
),
],
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
unit,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
],
),
],
),
);
}
}
... ...
... ... @@ -205,13 +205,13 @@ class _RingOverviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 229,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 14),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
... ... @@ -266,7 +266,7 @@ class _RingOverviewCard extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
... ... @@ -638,6 +638,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.start,
children: [
TextSpan(
text: _tooltipDate(report.date),
... ...
... ... @@ -64,8 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
queryForType: controller.queryForType,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
onDateChanged: controller.changeDate,
onQueryChanged: controller.changeQuery,
),
),
),
... ...
... ... @@ -50,7 +50,8 @@ class FriendHealthCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore);
final sleepQualityLevel =
sleepQualityLevelFromSleepEvaluate(sleepQualityScore);
return GestureDetector(
onTap: onTap,
... ...
... ... @@ -35,6 +35,16 @@ mixin HealthTrendControl on GetxController {
selectedPeriod.value = nextPeriod;
}
void changeQuery(ReportPeriod period, DateTime date) {
final typeIndex = normalizeTypeIndex(selectedTypeIndex.value);
final nextPeriod = normalizePeriod(typeIndex, period);
final nextDate = ReportDateRangeConfig.clampDate(date);
_periodsByType[typeIndex] = nextPeriod;
_datesByType[typeIndex] = nextDate;
selectedPeriod.value = nextPeriod;
selectedDate.value = nextDate;
}
void changeDate(DateTime date) {
final typeIndex = normalizeTypeIndex(selectedTypeIndex.value);
final nextDate = ReportDateRangeConfig.clampDate(date);
... ...
... ... @@ -22,8 +22,7 @@ class HealthTrendContent extends StatefulWidget {
required this.selectedTypeIndex,
this.refreshToken = 0,
required this.onTypeChanged,
required this.onPeriodChanged,
required this.onDateChanged,
required this.onQueryChanged,
this.queryForType,
});
... ... @@ -31,8 +30,7 @@ class HealthTrendContent extends StatefulWidget {
final int selectedTypeIndex;
final int refreshToken;
final ValueChanged<int> onTypeChanged;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
final void Function(ReportPeriod period, DateTime date) onQueryChanged;
final HealthReportQuery Function(int typeIndex)? queryForType;
@override
... ... @@ -123,8 +121,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
isSelected: widget.selectedTypeIndex == 0,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
onDateChanged: widget.onDateChanged,
onQueryChanged: widget.onQueryChanged,
),
),
_KeepAliveWrapper(
... ... @@ -134,8 +131,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
isSelected: widget.selectedTypeIndex == 1,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
onDateChanged: widget.onDateChanged,
onQueryChanged: widget.onQueryChanged,
),
),
_KeepAliveWrapper(
... ... @@ -145,8 +141,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
isSelected: widget.selectedTypeIndex == 2,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
onDateChanged: widget.onDateChanged,
onQueryChanged: widget.onQueryChanged,
),
),
],
... ... @@ -166,8 +161,7 @@ class _HrvTrendSection extends StatefulWidget {
required this.isSelected,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
required this.onDateChanged,
required this.onQueryChanged,
});
final HealthReportQuery query;
... ... @@ -175,8 +169,7 @@ class _HrvTrendSection extends StatefulWidget {
final bool isSelected;
final bool isVip;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
final void Function(ReportPeriod period, DateTime date) onQueryChanged;
@override
State<_HrvTrendSection> createState() => _HrvTrendSectionState();
... ... @@ -187,6 +180,7 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
late final Worker _periodWorker;
late final Worker _dateWorker;
var _syncingExternalQuery = false;
var _queryNotificationScheduled = false;
@override
void initState() {
... ... @@ -194,10 +188,10 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
_logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId);
_logic.initializeQuery(widget.query.period, widget.query.date);
_periodWorker = ever(_logic.selectedPeriod, (period) {
if (!_syncingExternalQuery) widget.onPeriodChanged(period);
_scheduleQueryNotification();
});
_dateWorker = ever(_logic.selectedDate, (date) {
if (!_syncingExternalQuery) widget.onDateChanged(date);
_scheduleQueryNotification();
});
_logic.loadReport();
}
... ... @@ -223,6 +217,23 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
}
}
void _scheduleQueryNotification() {
if (_syncingExternalQuery ||
!widget.isSelected ||
_queryNotificationScheduled) {
return;
}
_queryNotificationScheduled = true;
Future.microtask(() {
_queryNotificationScheduled = false;
if (!mounted || _syncingExternalQuery || !widget.isSelected) return;
widget.onQueryChanged(
_logic.selectedPeriod.value,
_logic.selectedDate.value,
);
});
}
@override
void dispose() {
_periodWorker.dispose();
... ... @@ -246,8 +257,7 @@ class _ActivityBurnTrendSection extends StatefulWidget {
required this.isSelected,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
required this.onDateChanged,
required this.onQueryChanged,
});
final HealthReportQuery query;
... ... @@ -255,8 +265,7 @@ class _ActivityBurnTrendSection extends StatefulWidget {
final bool isSelected;
final bool isVip;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
final void Function(ReportPeriod period, DateTime date) onQueryChanged;
@override
State<_ActivityBurnTrendSection> createState() =>
... ... @@ -268,6 +277,7 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
late final Worker _periodWorker;
late final Worker _dateWorker;
var _syncingExternalQuery = false;
var _queryNotificationScheduled = false;
@override
void initState() {
... ... @@ -277,10 +287,10 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
);
_logic.initializeQuery(widget.query.period, widget.query.date);
_periodWorker = ever(_logic.selectedPeriod, (period) {
if (!_syncingExternalQuery) widget.onPeriodChanged(period);
_scheduleQueryNotification();
});
_dateWorker = ever(_logic.selectedDate, (date) {
if (!_syncingExternalQuery) widget.onDateChanged(date);
_scheduleQueryNotification();
});
_logic.loadReport();
}
... ... @@ -306,6 +316,23 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
}
}
void _scheduleQueryNotification() {
if (_syncingExternalQuery ||
!widget.isSelected ||
_queryNotificationScheduled) {
return;
}
_queryNotificationScheduled = true;
Future.microtask(() {
_queryNotificationScheduled = false;
if (!mounted || _syncingExternalQuery || !widget.isSelected) return;
widget.onQueryChanged(
_logic.selectedPeriod.value,
_logic.selectedDate.value,
);
});
}
@override
void dispose() {
_periodWorker.dispose();
... ... @@ -329,8 +356,7 @@ class _SleepTrendSection extends StatefulWidget {
required this.isSelected,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
required this.onDateChanged,
required this.onQueryChanged,
});
final HealthReportQuery query;
... ... @@ -338,8 +364,7 @@ class _SleepTrendSection extends StatefulWidget {
final bool isSelected;
final bool isVip;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
final void Function(ReportPeriod period, DateTime date) onQueryChanged;
@override
State<_SleepTrendSection> createState() => _SleepTrendSectionState();
... ... @@ -350,6 +375,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
late final Worker _periodWorker;
late final Worker _dateWorker;
var _syncingExternalQuery = false;
var _queryNotificationScheduled = false;
@override
void initState() {
... ... @@ -359,10 +385,10 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
);
_logic.initializeQuery(widget.query.period, widget.query.date);
_periodWorker = ever(_logic.selectedPeriod, (period) {
if (!_syncingExternalQuery) widget.onPeriodChanged(period);
_scheduleQueryNotification();
});
_dateWorker = ever(_logic.selectedDate, (date) {
if (!_syncingExternalQuery) widget.onDateChanged(date);
_scheduleQueryNotification();
});
_logic.loadReport();
}
... ... @@ -388,6 +414,23 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
}
}
void _scheduleQueryNotification() {
if (_syncingExternalQuery ||
!widget.isSelected ||
_queryNotificationScheduled) {
return;
}
_queryNotificationScheduled = true;
Future.microtask(() {
_queryNotificationScheduled = false;
if (!mounted || _syncingExternalQuery || !widget.isSelected) return;
widget.onQueryChanged(
_logic.selectedPeriod.value,
_logic.selectedDate.value,
);
});
}
@override
void dispose() {
_periodWorker.dispose();
... ...
... ... @@ -37,8 +37,7 @@ class _HomeTrendBody extends GetView<TrendController> {
selectedTypeIndex: controller.selectedTypeIndex.value,
refreshToken: controller.refreshToken.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
onDateChanged: controller.changeDate,
onQueryChanged: controller.changeQuery,
),
);
}
... ...
... ... @@ -33,24 +33,6 @@ class HrvReportLogic extends ReportPeriodLogic {
];
@override
Future<void> selectPeriod(
ReportPeriod period, {
bool? forceRefresh,
}) {
final nextPeriod = normalizePeriod(period);
if (selectedPeriod.value == ReportPeriod.year &&
(nextPeriod == ReportPeriod.week || nextPeriod == ReportPeriod.month)) {
final now = DateTime.now();
final selectedYear = selectedDate.value.year;
final date = selectedYear == now.year
? DateTime(now.year, now.month, now.day)
: DateTime(selectedYear);
return selectQuery(nextPeriod, date, forceRefresh: forceRefresh);
}
return super.selectPeriod(nextPeriod, forceRefresh: forceRefresh);
}
@override
Future<void> loadReport() async {
isLoading.value = true;
try {
... ...
... ... @@ -108,9 +108,9 @@ class WeeklyHrvReport implements HrvPeriodReport {
daysWithData.length;
@override
int countFor(HrvStressLevel level) =>
distributionCounts?[level] ??
daysWithData.where((day) => day.level == level).length;
int countFor(HrvStressLevel level) => distributionCounts == null
? daysWithData.where((day) => day.level == level).length
: distributionCounts![level] ?? 0;
@override
int get relaxedDays =>
... ... @@ -186,9 +186,9 @@ class MonthlyHrvReport implements HrvPeriodReport {
daysWithData.length;
@override
int countFor(HrvStressLevel level) =>
distributionCounts?[level] ??
daysWithData.where((day) => day.level == level).length;
int countFor(HrvStressLevel level) => distributionCounts == null
? daysWithData.where((day) => day.level == level).length
: distributionCounts![level] ?? 0;
@override
int get relaxedDays =>
... ... @@ -266,9 +266,9 @@ class YearlyHrvReport implements HrvPeriodReport {
daysWithData.length;
@override
int countFor(HrvStressLevel level) =>
distributionCounts?[level] ??
daysWithData.where((day) => day.level == level).length;
int countFor(HrvStressLevel level) => distributionCounts == null
? daysWithData.where((day) => day.level == level).length
: distributionCounts![level] ?? 0;
@override
int get relaxedDays =>
... ...
import 'package:flutter/material.dart';
class HrvDistributionBarSegment {
const HrvDistributionBarSegment({
required this.color,
required this.flex,
});
final Color color;
final int flex;
}
class HrvDistributionBar extends StatelessWidget {
const HrvDistributionBar({
super.key,
required this.segments,
required this.backgroundColor,
required this.radius,
});
final List<HrvDistributionBarSegment> segments;
final Color backgroundColor;
final double radius;
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: CustomPaint(
painter: _HrvDistributionBarPainter(
segments: segments,
backgroundColor: backgroundColor,
radius: radius,
),
),
);
}
}
class _HrvDistributionBarPainter extends CustomPainter {
const _HrvDistributionBarPainter({
required this.segments,
required this.backgroundColor,
required this.radius,
});
final List<HrvDistributionBarSegment> segments;
final Color backgroundColor;
final double radius;
static const borderWidth = 1.0;
static const overlapHeight = 16.0;
@override
void paint(Canvas canvas, Size size) {
final clip = RRect.fromRectAndRadius(
Offset.zero & size,
Radius.circular(radius),
);
canvas.save();
canvas.clipRRect(clip);
if (segments.isEmpty) {
_drawLayeredSegment(
canvas,
Offset.zero & size,
color: backgroundColor,
radius: radius,
);
canvas.restore();
return;
}
final visibleHeights = _visibleSegmentHeightsFor(size.height, segments);
var visibleTop = 0.0;
for (var index = 0; index < segments.length; index++) {
final segment = segments[index];
final isBottomSegment = index == segments.length - 1;
final visibleBottom = visibleTop + visibleHeights[index];
final drawBottom = visibleBottom + (isBottomSegment ? 0 : overlapHeight);
_drawLayeredSegment(
canvas,
Rect.fromLTRB(0, visibleTop, size.width, drawBottom),
color: segment.color,
radius: radius,
);
visibleTop = visibleBottom;
}
canvas.restore();
}
static List<double> _visibleSegmentHeightsFor(
double height,
List<HrvDistributionBarSegment> segments,
) {
final totalDays =
segments.fold<int>(0, (sum, segment) => sum + segment.flex);
if (totalDays == 0) return const [];
final dayHeight = height / totalDays;
return [
for (final segment in segments) segment.flex * dayHeight,
];
}
void _drawLayeredSegment(
Canvas canvas,
Rect rect, {
required Color color,
required double radius,
}) {
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius));
canvas.drawRRect(
rrect,
Paint()
..color = color
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rrect.deflate(borderWidth / 2),
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = borderWidth,
);
}
@override
bool shouldRepaint(covariant _HrvDistributionBarPainter oldDelegate) {
if (backgroundColor != oldDelegate.backgroundColor ||
radius != oldDelegate.radius ||
segments.length != oldDelegate.segments.length) {
return true;
}
for (var i = 0; i < segments.length; i++) {
final segment = segments[i];
final oldSegment = oldDelegate.segments[i];
if (segment.color != oldSegment.color ||
segment.flex != oldSegment.flex) {
return true;
}
}
return false;
}
}
... ...
... ... @@ -8,6 +8,7 @@ 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';
class HrvWeekReportView extends StatelessWidget {
const HrvWeekReportView({
... ... @@ -643,58 +644,90 @@ class _DistributionCard extends StatelessWidget {
final HrvPeriodReport report;
final bool showExample;
final VoidCallback? onTap;
static const _cardHeight = 425.0;
static const _barRight = 45.0;
static const _barWidth = 42.0;
static const _barHeight = 220.0;
@override
Widget build(BuildContext context) {
return _Card(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ReportTitle(
title: HealthReportSubjectScope.titleOf(
context,
context.l10n.hrvStressDistribution,
),
showExample: showExample,
),
const SizedBox(height: 17),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.hrvValidDays,
style: const TextStyle(
color: Color(0xFFB0B0B6), fontSize: 11)),
const SizedBox(height: 2),
_ValueWithUnit(
value: '${report.validDays}',
unit: context.l10n.reportUnitDay,
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _cardHeight),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
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(0xFFB0B0B6),
fontSize: 12,
),
const SizedBox(height: 18),
_DistributionGrid(report: report),
],
),
const SizedBox(height: 2),
_ValueWithUnit(
value: '${report.validDays}',
unit: context.l10n.reportUnitDay,
),
const SizedBox(height: 28),
SizedBox(
width: 216,
child: _DistributionGrid(report: report),
),
const SizedBox(height: 20),
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,
),
),
],
),
],
),
),
Positioned(
top: 0,
right: _barRight,
width: _barWidth,
child: Container(
alignment: Alignment.topCenter,
padding: EdgeInsets.only(top: 50.h),
child: SizedBox(
height: _barHeight,
child: IgnorePointer(
child: _DistributionBar(report: report),
),
),
),
_DistributionBar(report: report),
const SizedBox(width: 20),
],
),
const Divider(height: 29, color: Color(0xFFF3F3F3)),
Row(
children: [
Expanded(
child: _ExtremeHrv(
title: context.l10n.hrvLowest, day: report.minDay)),
Expanded(
child: _ExtremeHrv(
title: context.l10n.hrvHighest, day: report.maxDay)),
],
),
],
),
],
),
),
);
}
... ... @@ -710,14 +743,13 @@ class _DistributionGrid extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
const spacing = 16.0;
final itemWidth = (constraints.maxWidth - spacing) / 2;
return Wrap(
spacing: spacing,
runSpacing: 13,
runSpacing: 16,
children: [
for (final level in HrvStressLevel.values)
SizedBox(
width: itemWidth,
width: 100,
child: _DistributionItem(level: level, report: report),
),
],
... ... @@ -743,24 +775,25 @@ class _DistributionItem extends StatelessWidget {
children: [
Row(children: [
Container(
width: 9,
height: 9,
width: 12,
height: 12,
decoration: BoxDecoration(
color: Color(level.colorValue), shape: BoxShape.circle)),
const SizedBox(width: 3),
const SizedBox(width: 4),
Expanded(
child: Text(
level.label,
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
]),
const SizedBox(height: 3),
const SizedBox(height: 8),
Text(
report.hasData
? '$count${context.l10n.reportUnitDay}'
... ... @@ -769,10 +802,16 @@ class _DistributionItem extends StatelessWidget {
color: report.hasData
? const Color(0xFF0F0F11)
: const Color(0xFFB0B0B6),
fontSize: report.hasData ? 20 : 14,
fontWeight: FontWeight.w500)),
fontSize: report.hasData ? 20 : 18,
fontWeight: FontWeight.w500,
height: 1.2)),
const SizedBox(height: 1),
Text('$percent%',
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10)),
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 12,
height: 1.2,
)),
],
);
}
... ... @@ -785,170 +824,22 @@ class _DistributionBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
const radius = 7.0;
final segments = [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
_DistributionBarSegment(
HrvDistributionBarSegment(
color: Color(level.colorValue),
flex: report.countFor(level),
),
];
final hasSegments = report.hasData && segments.isNotEmpty;
final height = hasSegments
? _DistributionBarPainter.visibleHeightFor(segments)
: 180.0;
final paintSize = Size(44.w, height);
return SizedBox.fromSize(
size: const Size(33, 180),
child: OverflowBox(
alignment: Alignment.bottomLeft,
minWidth: paintSize.width,
maxWidth: paintSize.width,
minHeight: paintSize.height,
maxHeight: paintSize.height,
child: Transform.translate(
offset: const Offset(0, -12),
child: SizedBox.fromSize(
size: paintSize,
child: CustomPaint(
painter: _DistributionBarPainter(
segments: hasSegments ? segments : const [],
backgroundColor: const Color(0xFFF3F3F3),
radius: radius,
),
),
),
),
),
return HrvDistributionBar(
segments: report.hasData ? segments : const [],
backgroundColor: const Color(0xFFF3F3F3),
radius: 8,
);
}
}
class _DistributionBarSegment {
const _DistributionBarSegment({required this.color, required this.flex});
final Color color;
final int flex;
}
class _DistributionBarPainter extends CustomPainter {
const _DistributionBarPainter({
required this.segments,
required this.backgroundColor,
required this.radius,
});
final List<_DistributionBarSegment> segments;
final Color backgroundColor;
final double radius;
static const maxVisibleHeight = 220.0;
static const _borderWidth = 1.0;
static const _overlapHeight = 16.0;
static const _weekDayCount = 7;
static double visibleHeightFor(List<_DistributionBarSegment> segments) {
if (segments.isEmpty) return 0;
final totalFlex =
segments.fold<int>(0, (sum, segment) => sum + segment.flex);
return maxVisibleHeight * totalFlex / _weekDayCount;
}
@override
void paint(Canvas canvas, Size size) {
final clip = RRect.fromRectAndRadius(
Offset.zero & size,
Radius.circular(radius),
);
canvas.save();
canvas.clipRRect(clip);
final total = segments.fold<int>(0, (sum, segment) => sum + segment.flex);
if (total == 0) {
canvas.drawRRect(
clip,
Paint()
..color = backgroundColor
..style = PaintingStyle.fill,
);
canvas.restore();
return;
}
final visibleHeights = _visibleSegmentHeightsFor(size.height, segments);
var bottom = 0.0;
for (var index = 0; index < segments.length; index++) {
final segment = segments[index];
final isBottomSegment = index == segments.length - 1;
final drawTop = index == 0 ? 0.0 : bottom - _overlapHeight;
final drawBottom = drawTop +
visibleHeights[index] +
(isBottomSegment ? 0 : _overlapHeight);
_drawLayeredSegment(
canvas,
Rect.fromLTRB(0, drawTop, size.width, drawBottom),
color: segment.color,
radius: radius,
);
bottom = drawBottom;
}
canvas.restore();
}
static List<double> _visibleSegmentHeightsFor(
double visibleHeight,
List<_DistributionBarSegment> segments,
) {
final totalFlex =
segments.fold<int>(0, (sum, segment) => sum + segment.flex);
final dayHeight = visibleHeight / totalFlex;
return [
for (final segment in segments) segment.flex * dayHeight,
];
}
void _drawLayeredSegment(
Canvas canvas,
Rect rect, {
required Color color,
required double radius,
}) {
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius));
canvas.drawRRect(
rrect,
Paint()
..color = color
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rrect.deflate(_borderWidth / 2),
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = _borderWidth,
);
}
@override
bool shouldRepaint(covariant _DistributionBarPainter oldDelegate) {
if (backgroundColor != oldDelegate.backgroundColor ||
radius != oldDelegate.radius ||
segments.length != oldDelegate.segments.length) {
return true;
}
for (var i = 0; i < segments.length; i++) {
final segment = segments[i];
final oldSegment = oldDelegate.segments[i];
if (segment.color != oldSegment.color ||
segment.flex != oldSegment.flex) {
return true;
}
}
return false;
}
}
class _ExtremeHrv extends StatelessWidget {
const _ExtremeHrv({required this.title, required this.day});
... ... @@ -961,15 +852,15 @@ class _ExtremeHrv extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
const SizedBox(height: 5),
_ValueWithUnit(
value: day == null ? '-' : '${day!.averageHrv!.round()}',
unit: 'ms'),
const SizedBox(height: 3),
const SizedBox(height: 2),
Text(
day == null ? '-月-日' : reportMonthDay(day!.date),
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10),
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 12),
),
],
);
... ... @@ -989,10 +880,10 @@ class _ValueWithUnit extends StatelessWidget {
children: [
Text(value,
style: const TextStyle(
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
fontSize: 26, fontWeight: FontWeight.w600, height: 1)),
Padding(
padding: const EdgeInsets.only(left: 3, bottom: 2),
child: Text(unit, style: const TextStyle(fontSize: 10)),
child: Text(unit, style: const TextStyle(fontSize: 12)),
),
],
);
... ... @@ -1000,10 +891,15 @@ class _ValueWithUnit extends StatelessWidget {
}
class _ReportTitle extends StatelessWidget {
const _ReportTitle({required this.title, required this.showExample});
const _ReportTitle({
required this.title,
required this.showExample,
this.fontSize = 15,
});
final String title;
final bool showExample;
final double fontSize;
@override
Widget build(BuildContext context) {
... ... @@ -1011,7 +907,7 @@ class _ReportTitle extends StatelessWidget {
children: [
Text(
showExample ? context.l10n.reportExampleTitle(title) : title,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600),
),
if (showExample) ...[
Image.asset(
... ...
... ... @@ -7,6 +7,7 @@ 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';
class HrvYearReportView extends StatelessWidget {
const HrvYearReportView({super.key, required this.report});
... ... @@ -655,32 +656,26 @@ class _LevelItem extends StatelessWidget {
class _StackedBar extends StatelessWidget {
const _StackedBar({required this.report});
final YearlyHrvReport report;
static const _height = 180.0;
@override
Widget build(BuildContext context) {
return Container(
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: 180,
clipBehavior: Clip.antiAlias,
decoration:
BoxDecoration(color: _grid, borderRadius: BorderRadius.circular(7)),
child: report.hasData
? Column(
children: [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
Expanded(
flex: report.countFor(level),
child: Container(
decoration: BoxDecoration(
color: Color(level.colorValue),
border: Border.all(color: Colors.white, width: .5),
),
),
),
],
)
: null,
height: _height,
child: HrvDistributionBar(
segments: report.hasData ? segments : const [],
backgroundColor: _grid,
radius: 7,
),
);
}
}
... ...
... ... @@ -16,7 +16,7 @@ abstract class ReportPeriodLogic {
final selectedPeriod = ReportPeriod.day.obs;
final selectedDate = _today().obs;
final isLoading = false.obs;
final Map<ReportPeriod, DateTime> _datesByPeriod = {};
var _anchorDate = _today();
List<ReportPeriod> get supportedPeriods => ReportPeriod.values;
... ... @@ -49,9 +49,10 @@ abstract class ReportPeriodLogic {
void initializeQuery(ReportPeriod period, DateTime date) {
final nextPeriod = normalizePeriod(period);
_seedPeriodDates(date);
final nextDate = normalizeDate(nextPeriod, date);
_anchorDate = _anchorDateFor(nextPeriod, date);
selectedPeriod.value = nextPeriod;
selectedDate.value = _datesByPeriod[nextPeriod]!;
selectedDate.value = nextDate;
}
Future<void> selectQuery(
... ... @@ -62,15 +63,16 @@ abstract class ReportPeriodLogic {
final nextPeriod = normalizePeriod(period);
final nextDate = normalizeDate(nextPeriod, date);
final shouldRefresh = forceRefresh ?? this.forceRefresh;
if (!shouldRefresh &&
selectedPeriod.value == nextPeriod &&
selectedDate.value == nextDate) {
final sameQuery =
selectedPeriod.value == nextPeriod && selectedDate.value == nextDate;
if (!shouldRefresh && sameQuery) {
return Future.value();
}
_datesByPeriod[selectedPeriod.value] = selectedDate.value;
if (!sameQuery) {
_anchorDate = _anchorDateFor(nextPeriod, date);
}
selectedPeriod.value = nextPeriod;
selectedDate.value = nextDate;
_datesByPeriod[nextPeriod] = nextDate;
return loadReport();
}
... ... @@ -130,11 +132,9 @@ abstract class ReportPeriodLogic {
if (!shouldRefresh && selectedPeriod.value == nextPeriod) {
return Future.value();
}
_datesByPeriod[selectedPeriod.value] = selectedDate.value;
final nextDate = normalizeDate(nextPeriod, _anchorDate);
selectedPeriod.value = nextPeriod;
selectedDate.value = _datesByPeriod[nextPeriod] ??
normalizeDate(nextPeriod, selectedDate.value);
_datesByPeriod[nextPeriod] = selectedDate.value;
selectedDate.value = nextDate;
return loadReport();
}
... ... @@ -144,9 +144,9 @@ abstract class ReportPeriodLogic {
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
final nextDate = ReportDateRangeConfig.clampDate(date);
_anchorDate = _anchorDateFor(ReportPeriod.day, nextDate);
if (!shouldRefresh && selectedDate.value == nextDate) return Future.value();
selectedDate.value = nextDate;
_datesByPeriod[selectedPeriod.value] = nextDate;
return loadReport();
}
... ... @@ -160,9 +160,9 @@ abstract class ReportPeriodLogic {
ReportDateRangeConfig.firstWeekStart(),
ReportDateRangeConfig.lastWeekStart(),
);
_anchorDate = _anchorDateFor(ReportPeriod.week, nextWeekStart);
if (!shouldRefresh && weekStart == nextWeekStart) return Future.value();
selectedDate.value = nextWeekStart;
_datesByPeriod[selectedPeriod.value] = nextWeekStart;
return loadReport();
}
... ... @@ -177,9 +177,9 @@ abstract class ReportPeriodLogic {
ReportDateRangeConfig.firstMonth(),
ReportDateRangeConfig.lastMonth(),
);
_anchorDate = _anchorDateFor(ReportPeriod.month, nextMonth);
if (!shouldRefresh && monthStart == nextMonth) return Future.value();
selectedDate.value = nextMonth;
_datesByPeriod[selectedPeriod.value] = nextMonth;
return loadReport();
}
... ... @@ -195,8 +195,9 @@ abstract class ReportPeriodLogic {
if (!shouldRefresh && selectedDate.value.year == nextYear) {
return Future.value();
}
selectedDate.value = DateTime(nextYear);
_datesByPeriod[selectedPeriod.value] = selectedDate.value;
final nextDate = DateTime(nextYear);
_anchorDate = _anchorDateFor(ReportPeriod.year, nextDate);
selectedDate.value = nextDate;
return loadReport();
}
... ... @@ -223,16 +224,16 @@ abstract class ReportPeriodLogic {
Future<void> previousDay() {
final candidate = _previousCandidate;
if (_isBeforeRange(candidate)) return Future.value();
_anchorDate = _anchorDateFor(selectedPeriod.value, candidate);
selectedDate.value = candidate;
_datesByPeriod[selectedPeriod.value] = candidate;
return loadReport();
}
Future<void> nextDay() {
final candidate = _nextCandidate;
if (_isAfterRange(candidate)) return Future.value();
_anchorDate = _anchorDateFor(selectedPeriod.value, candidate);
selectedDate.value = candidate;
_datesByPeriod[selectedPeriod.value] = candidate;
return loadReport();
}
... ... @@ -240,10 +241,17 @@ abstract class ReportPeriodLogic {
void dispose() {}
void _seedPeriodDates(DateTime date) {
for (final period in supportedPeriods) {
_datesByPeriod[period] = normalizeDate(period, date);
}
DateTime _anchorDateFor(ReportPeriod period, DateTime date) {
final normalizedPeriod = normalizePeriod(period);
final normalizedDate = normalizeDate(normalizedPeriod, date);
return switch (normalizedPeriod) {
ReportPeriod.day => normalizedDate,
ReportPeriod.week => ReportDateRangeConfig.clampDate(
normalizedDate.add(const Duration(days: 3)),
),
ReportPeriod.month => normalizedDate,
ReportPeriod.year => normalizedDate,
};
}
DateTime get _previousCandidate => switch (selectedPeriod.value) {
... ...
... ... @@ -10,6 +10,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget {
this.mainAxisSpacing = 8,
this.crossAxisSpacing = 8,
this.childAspectRatio = 1,
this.mainAxisExtent,
this.shrinkWrap = true,
});
... ... @@ -20,6 +21,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget {
final double mainAxisSpacing;
final double crossAxisSpacing;
final double childAspectRatio;
final double? mainAxisExtent;
final bool shrinkWrap;
@override
... ... @@ -43,6 +45,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget {
mainAxisSpacing: mainAxisSpacing,
crossAxisSpacing: crossAxisSpacing,
childAspectRatio: childAspectRatio,
mainAxisExtent: mainAxisExtent,
),
itemBuilder: (context, index) {
final day = cells[index];
... ...
... ... @@ -55,6 +55,15 @@ SleepQualityLevel sleepQualityLevelFromScore(int? score) {
return SleepQualityLevel.poor;
}
SleepQualityLevel sleepQualityLevelFromSleepEvaluate(int? sleepEvaluate) {
return switch (sleepEvaluate) {
1 => SleepQualityLevel.excellent,
2 => SleepQualityLevel.good,
3 => SleepQualityLevel.poor,
_ => SleepQualityLevel.unknown,
};
}
class SleepDuration {
final int minutes;
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -29,7 +30,7 @@ class SleepHeartRateCard extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
height: 358,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
padding: EdgeInsets.fromLTRB(20, 20.h, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
... ...
... ... @@ -100,6 +100,7 @@ class _SummaryColumn extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
... ...
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors_extension.dart';
... ... @@ -6,6 +7,9 @@ import 'app_colors_extension.dart';
/// Automatically hooks up our custom Figma colors system as a ThemeExtension.
class AppTheme {
AppTheme._();
static String? get _platformFontFamily =>
defaultTargetPlatform == TargetPlatform.iOS ? 'PingFang SC' : null;
static const systemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
... ... @@ -20,6 +24,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
fontFamily: _platformFontFamily,
brightness: Brightness.light,
primaryColor: colors.primary,
scaffoldBackgroundColor: colors.backgroundPage,
... ... @@ -34,6 +39,7 @@ class AppTheme {
iconTheme: IconThemeData(color: colors.textPrimary),
actionsIconTheme: IconThemeData(color: colors.textPrimary),
titleTextStyle: TextStyle(
fontFamily: _platformFontFamily,
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w500,
... ... @@ -64,6 +70,7 @@ class AppTheme {
final colors = AppColorsExtension.dark();
return ThemeData(
useMaterial3: true,
fontFamily: _platformFontFamily,
brightness: Brightness.dark,
primaryColor: colors.primary,
scaffoldBackgroundColor: colors.backgroundLight,
... ... @@ -76,6 +83,7 @@ class AppTheme {
iconTheme: IconThemeData(color: colors.textPrimary),
actionsIconTheme: IconThemeData(color: colors.textPrimary),
titleTextStyle: TextStyle(
fontFamily: _platformFontFamily,
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w500,
... ...