Commit a039c9c7f6290222ce6838ac0b06819382862d17

Authored by 刘宏哲
1 parent 8d5a6c65

feat(app): update ui

... ... @@ -257,12 +257,10 @@ class _ActivityBurnRingPainter extends CustomPainter {
final rect = Rect.fromCircle(center: center, radius: radius);
final safeProgress = math.max(0, progress);
final hasOverflow = safeProgress > 1;
final baseProgress = hasOverflow ? 1.0 : safeProgress;
final overflowProgress = safeProgress - 1;
final overflowLap = overflowProgress % 1;
final visibleOverflowLap =
hasOverflow && overflowLap == 0 ? 1.0 : overflowLap;
final hasCompletedLap = safeProgress >= 1;
final remainder = safeProgress % 1;
final visibleProgress = hasCompletedLap ? remainder : safeProgress;
final isExactLap = hasCompletedLap && remainder == 0;
final progressPaint = Paint()
..color = progressColor
... ... @@ -276,7 +274,9 @@ class _ActivityBurnRingPainter extends CustomPainter {
..strokeWidth = width
..strokeCap = StrokeCap.butt;
if (baseProgress >= 1) {
// Bottom layer: once a lap is complete, keep a solid full ring below the
// animated current lap.
if (hasCompletedLap) {
canvas.drawArc(
rect,
startAngle,
... ... @@ -284,40 +284,64 @@ class _ActivityBurnRingPainter extends CustomPainter {
false,
fullLapPaint,
);
} else {
final baseEndAngle = startAngle + math.pi * 2 * baseProgress;
_drawRingHeadShadow(
}
final endAngle = startAngle + math.pi * 2 * visibleProgress;
// Head layer: its shadow is painted first, then the colored current lap.
// For an exact lap there is no partial arc, so paint a colored head at the
// 12 o'clock endpoint instead.
_drawRingHeadShadow(
canvas,
center: center,
radius: radius,
width: width,
angle: endAngle,
forwardOnly: isExactLap,
);
if (isExactLap) {
_drawRingEndCap(
canvas,
center: center,
radius: radius,
width: width,
angle: baseEndAngle,
angle: endAngle,
color: progressColor,
);
} else {
canvas.drawArc(
rect,
startAngle,
math.pi * 2 * baseProgress,
math.pi * 2 * visibleProgress,
false,
progressPaint,
);
}
}
if (!hasOverflow || visibleOverflowLap <= 0) return;
final overflowEndAngle = startAngle + math.pi * 2 * visibleOverflowLap;
_drawRingHeadShadow(
canvas,
center: center,
radius: radius,
width: width,
angle: overflowEndAngle,
void _drawRingEndCap(
Canvas canvas, {
required Offset center,
required double radius,
required double width,
required double angle,
required Color color,
}) {
final headCenter = Offset(
center.dx + math.cos(angle) * radius,
center.dy + math.sin(angle) * radius,
);
// Only paint the forward half of the cap. The completed ring below already
// provides the trailing half; using a full circle makes the head look like
// a separate colored dot at exactly 1.0x, 2.0x, etc.
canvas.drawArc(
rect,
startAngle,
math.pi * 2 * visibleOverflowLap,
false,
progressPaint,
Rect.fromCircle(center: headCenter, radius: width / 2),
angle,
math.pi,
true,
Paint()
..color = color
..style = PaintingStyle.fill,
);
}
... ... @@ -327,6 +351,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
required double radius,
required double width,
required double angle,
bool forwardOnly = false,
}) {
final headCenter = Offset(
center.dx + math.cos(angle) * radius,
... ... @@ -340,6 +365,29 @@ class _ActivityBurnRingPainter extends CustomPainter {
canvas.save();
canvas.clipPath(ringClip);
if (forwardOnly) {
final radial = Offset(math.cos(angle), math.sin(angle));
final extent = width * 2;
final forwardClip = Path()
..moveTo(
headCenter.dx - radial.dx * extent,
headCenter.dy - radial.dy * extent,
)
..lineTo(
headCenter.dx + radial.dx * extent,
headCenter.dy + radial.dy * extent,
)
..lineTo(
headCenter.dx + radial.dx * extent + tangent.dx * extent,
headCenter.dy + radial.dy * extent + tangent.dy * extent,
)
..lineTo(
headCenter.dx - radial.dx * extent + tangent.dx * extent,
headCenter.dy - radial.dy * extent + tangent.dy * extent,
)
..close();
canvas.clipPath(forwardClip);
}
canvas.drawCircle(
headCenter + tangent * 2,
width / 2,
... ...
... ... @@ -27,7 +27,7 @@ class PrivacySettingsController extends GetxController {
isUpdatingEnableAddWithUCode.value = true;
try {
final result = await _userApi.updateOwnerUserInfo(
enableAddWithUcode: enabled ? 0 : 1,
enableAddWithUcode: enabled ? 1 : 0,
);
if (result is AppSuccess<UserInfoResponse>) {
await _userPreferencesStorage.updateMeUserInfo(result.data);
... ...
... ... @@ -97,6 +97,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
averageDurationSeconds: data.avgSleepDuration,
targetDurationSeconds: data.sleepTargetDuration,
averageScore: data.avgSleepScore,
averageSleepEvaluate: data.avgSleepEvaluate?.toInt(),
previousAverageDurationSeconds: data.qoqAvgSleepDuration,
previousAverageScore: data.qoqSleepScore,
earliestSleepOverride: _sleepInfoReport(
... ... @@ -141,6 +142,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
averageDurationSeconds: data.avgSleepDuration,
targetDurationSeconds: data.sleepTargetDuration,
averageScore: data.avgSleepScore,
averageSleepEvaluate: data.avgSleepEvaluate?.toInt(),
previousAverageDurationSeconds: data.qoqAvgSleepDuration,
previousAverageScore: data.qoqSleepScore,
earliestSleepOverride: _sleepInfoReport(
... ... @@ -183,6 +185,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
? report.duration
: SleepDuration(minutes: durationSeconds ~/ 60),
qualityScore: score?.toInt() ?? report.qualityScore,
sleepEvaluate: report.sleepEvaluate,
heartRate: report.heartRate,
);
}
... ... @@ -202,6 +205,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
date: date,
duration: report.duration,
qualityScore: report.qualityScore,
sleepEvaluate: report.sleepEvaluate,
heartRate: SleepHeartRateSummary(
averageBpm: heartRate.averageBpm,
maxBpm: heartRate.maxBpm,
... ... @@ -260,9 +264,11 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
timeKey: trend?.timeKey,
totalTime: trend?.totalTime ?? data.avgSleepDuration,
score: trend?.score ?? data.avgSleepScore,
sleepEvaluate: trend?.sleepEvaluate ?? data.avgSleepEvaluate,
);
if (dailyTrend.totalTime == null &&
dailyTrend.score == null &&
dailyTrend.sleepEvaluate == null &&
asleepTime == null &&
points.isEmpty &&
data.avgHr == null &&
... ... @@ -364,6 +370,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
sleepDurationSeconds: sleepDurationSeconds,
targetDurationSeconds: targetDurationSeconds,
qualityScore: trend?.score?.toInt(),
sleepEvaluate: trend?.sleepEvaluate?.toInt(),
heartRate: _heartRateSummary(
points,
asleepTime,
... ... @@ -479,6 +486,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
sleepDurationSeconds: 405 * 60,
targetDurationSeconds: 28800,
qualityScore: 86,
sleepEvaluate: 1,
heartRate: _buildHeartRateSummary(
points: points,
sleepStart: sleepStart,
... ... @@ -530,6 +538,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
qualityScore: [82, 88, 0, 79, 91, 86, 84][i] == 0
? null
: [82, 88, 0, 79, 91, 86, 84][i],
sleepEvaluate: [2, 1, null, 2, 1, 1, 2][i],
heartRate: _buildHeartRateSummary(
points: points,
sleepStart: sleepStart,
... ... @@ -543,6 +552,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
weekStart: normalizedStart,
weekEnd: normalizedStart.add(const Duration(days: 6)),
days: days,
averageSleepEvaluate: 1,
);
}
... ... @@ -626,6 +636,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
date: date,
duration: SleepDuration(minutes: durationMinutes[i]),
qualityScore: qualityScores[i],
sleepEvaluate: qualityScores[i] > 85 ? 1 : 2,
heartRate: _buildHeartRateSummary(
points: points,
sleepStart: sleepStart,
... ... @@ -639,6 +650,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
monthStart: normalizedStart,
monthEnd: monthEnd,
days: days,
averageSleepEvaluate: 1,
);
}
}
... ...
... ... @@ -48,13 +48,6 @@ extension SleepQualityLevelText on SleepQualityLevel {
}
}
SleepQualityLevel sleepQualityLevelFromScore(int? score) {
if (score == null) return SleepQualityLevel.unknown;
if (score > 85) return SleepQualityLevel.excellent;
if (score >= 60) return SleepQualityLevel.good;
return SleepQualityLevel.poor;
}
SleepQualityLevel sleepQualityLevelFromSleepEvaluate(int? sleepEvaluate) {
return switch (sleepEvaluate) {
1 => SleepQualityLevel.excellent,
... ... @@ -168,6 +161,7 @@ class SleepReport {
final num? sleepDurationSeconds;
final num? targetDurationSeconds;
final int? qualityScore;
final int? sleepEvaluate;
final SleepHeartRateSummary heartRate;
const SleepReport({
... ... @@ -176,6 +170,7 @@ class SleepReport {
this.sleepDurationSeconds,
this.targetDurationSeconds,
this.qualityScore,
this.sleepEvaluate,
this.heartRate = const SleepHeartRateSummary(),
});
... ... @@ -189,7 +184,7 @@ class SleepReport {
hasSleepData ? heartRate : const SleepHeartRateSummary();
SleepQualityLevel get qualityLevel =>
sleepQualityLevelFromScore(validQualityScore);
sleepQualityLevelFromSleepEvaluate(sleepEvaluate);
factory SleepReport.empty(DateTime date) {
return SleepReport(date: date);
... ... @@ -204,6 +199,7 @@ class SleepReport {
sleepDurationSeconds: json['sleepDurationSeconds'] as num?,
targetDurationSeconds: json['targetDurationSeconds'] as num?,
qualityScore: json['qualityScore'] as int?,
sleepEvaluate: json['sleepEvaluate'] as int?,
heartRate: json['heartRate'] == null
? const SleepHeartRateSummary()
: SleepHeartRateSummary.fromJson(
... ... @@ -218,6 +214,7 @@ class SleepReport {
'sleepDurationSeconds': sleepDurationSeconds,
'targetDurationSeconds': targetDurationSeconds,
'qualityScore': qualityScore,
'sleepEvaluate': sleepEvaluate,
'heartRate': heartRate.toJson(),
};
}
... ... @@ -230,6 +227,7 @@ class WeeklySleepReport {
this.averageDurationSeconds,
this.targetDurationSeconds,
this.averageScore,
this.averageSleepEvaluate,
this.previousAverageDurationSeconds,
this.previousAverageScore,
this.earliestSleepOverride,
... ... @@ -244,6 +242,7 @@ class WeeklySleepReport {
final num? averageDurationSeconds;
final num? targetDurationSeconds;
final num? averageScore;
final int? averageSleepEvaluate;
final num? previousAverageDurationSeconds;
final num? previousAverageScore;
final SleepReport? earliestSleepOverride;
... ... @@ -294,7 +293,7 @@ class WeeklySleepReport {
}
SleepQualityLevel get averageQualityLevel =>
sleepQualityLevelFromScore(averageQualityScore);
sleepQualityLevelFromSleepEvaluate(averageSleepEvaluate);
double? get durationComparisonPercent => _comparisonPercent(
_computedAverageDurationSeconds,
... ... @@ -360,6 +359,7 @@ class MonthlySleepReport {
this.averageDurationSeconds,
this.targetDurationSeconds,
this.averageScore,
this.averageSleepEvaluate,
this.previousAverageDurationSeconds,
this.previousAverageScore,
this.earliestSleepOverride,
... ... @@ -374,6 +374,7 @@ class MonthlySleepReport {
final num? averageDurationSeconds;
final num? targetDurationSeconds;
final num? averageScore;
final int? averageSleepEvaluate;
final num? previousAverageDurationSeconds;
final num? previousAverageScore;
final SleepReport? earliestSleepOverride;
... ... @@ -424,7 +425,7 @@ class MonthlySleepReport {
}
SleepQualityLevel get averageQualityLevel =>
sleepQualityLevelFromScore(averageQualityScore);
sleepQualityLevelFromSleepEvaluate(averageSleepEvaluate);
double? get durationComparisonPercent => _comparisonPercent(
_computedAverageDurationSeconds,
... ...
... ... @@ -520,21 +520,6 @@ class _DurationChartState extends State<_DurationChart> {
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
if (_selectedIndex != null && _selectedOffset != null)
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) =>
ChartSelectionLineOverlay(
offset: Offset.zero,
color: SleepWeekReportView.h3,
bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
plotLeft: 0,
plotRight: 0,
lineX: _selectedOffset!.dx,
lineTop: _fixedTooltipBottom,
),
),
),
_ChartPlotFrame(
maxY: _maxY,
interval: 2,
... ... @@ -566,15 +551,25 @@ class _DurationChartState extends State<_DurationChart> {
),
onPointerUp: (_) => _onSelected(null, null),
onPointerCancel: (_) => _onSelected(null, null),
child: BarChart(
_chartData(
constraints.maxWidth,
constraints.maxHeight,
),
),
child: BarChart(_chartData(constraints.maxWidth)),
),
),
),
if (_selectedIndex != null && _selectedOffset != null)
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) =>
ChartSelectionLineOverlay(
offset: Offset.zero,
color: SleepWeekReportView.h3,
bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
plotLeft: 0,
plotRight: 0,
lineX: _selectedOffset!.dx,
lineTop: -_barTooltipMargin,
),
),
),
if (!hasData) const _WaitingDataText(),
],
),
... ... @@ -583,7 +578,7 @@ class _DurationChartState extends State<_DurationChart> {
);
}
BarChartData _chartData(double plotWidth, double chartHeight) {
BarChartData _chartData(double plotWidth) {
return BarChartData(
minY: 0,
maxY: _maxY,
... ... @@ -624,11 +619,7 @@ class _DurationChartState extends State<_DurationChart> {
),
),
BarChartRodData(
toY: _tooltipAnchorValue(
maxY: _maxY,
chartHeight: chartHeight,
tooltipMargin: _barTooltipMargin,
),
toY: _maxY,
width: 0,
color: Colors.transparent,
),
... ... @@ -738,21 +729,6 @@ class _QualityChartState extends State<_QualityChart> {
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
if (_selectedIndex != null && _selectedOffset != null)
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) =>
ChartSelectionLineOverlay(
offset: Offset.zero,
color: SleepWeekReportView.h3,
bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
plotLeft: 0,
plotRight: 0,
lineX: _selectedOffset!.dx,
lineTop: _fixedTooltipBottom,
),
),
),
_ChartPlotFrame(
maxY: 100,
interval: 25,
... ... @@ -778,15 +754,25 @@ class _QualityChartState extends State<_QualityChart> {
),
onPointerUp: (_) => _onSelected(null, null),
onPointerCancel: (_) => _onSelected(null, null),
child: BarChart(
_chartData(
constraints.maxWidth,
constraints.maxHeight,
),
),
child: BarChart(_chartData(constraints.maxWidth)),
),
),
),
if (_selectedIndex != null && _selectedOffset != null)
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) =>
ChartSelectionLineOverlay(
offset: Offset.zero,
color: SleepWeekReportView.h3,
bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
plotLeft: 0,
plotRight: 0,
lineX: _selectedOffset!.dx,
lineTop: -_barTooltipMargin,
),
),
),
if (!hasData) const _WaitingDataText(),
],
),
... ... @@ -795,7 +781,7 @@ class _QualityChartState extends State<_QualityChart> {
);
}
BarChartData _chartData(double plotWidth, double chartHeight) {
BarChartData _chartData(double plotWidth) {
return BarChartData(
minY: 0,
maxY: 100,
... ... @@ -835,11 +821,7 @@ class _QualityChartState extends State<_QualityChart> {
),
),
BarChartRodData(
toY: _tooltipAnchorValue(
maxY: 100,
chartHeight: chartHeight,
tooltipMargin: _barTooltipMargin,
),
toY: 100,
width: 0,
color: Colors.transparent,
),
... ... @@ -964,14 +946,14 @@ class _BedtimeChartState extends State<_BedtimeChart> {
constraints.maxWidth,
_selectedDayIndex!,
),
lineTop: _fixedTooltipBottom,
lineTop: _bedtimeChartTopInset,
),
),
_ChartPlotFrame(
maxY: axis.maxHour.toDouble(),
minY: axis.minHour.toDouble(),
interval: 1,
topInset: 16,
topInset: _bedtimeChartTopInset,
insetChildHorizontally: false,
rightLabel: _timeAxisLabel,
child: LayoutBuilder(
... ... @@ -991,7 +973,6 @@ class _BedtimeChartState extends State<_BedtimeChart> {
_chartData(
axis,
constraints.maxWidth,
constraints.maxHeight,
),
),
),
... ... @@ -1015,7 +996,6 @@ class _BedtimeChartState extends State<_BedtimeChart> {
LineChartData _chartData(
_BedtimeAxis axis,
double chartWidth,
double chartHeight,
) {
final points = <FlSpot>[
for (var i = 0; i < widget.days.length; i++)
... ... @@ -1049,16 +1029,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
);
final anchorData = LineChartBarData(
spots: [
for (final point in points)
FlSpot(
point.x,
_tooltipAnchorValue(
maxY: _tooltipChartMaxY,
chartHeight: chartHeight,
tooltipMargin: _tooltipMargin,
chartTopInset: 16,
),
),
for (final point in points) FlSpot(point.x, _tooltipChartMaxY),
],
color: Colors.transparent,
barWidth: 0,
... ... @@ -1160,22 +1131,9 @@ const _weekBarGap = 28.0;
const _monthHorizontalInsets = 46.0;
const _tooltipChartMaxY = 110.0;
const _tooltipMargin = 16.0;
const _bedtimeTooltipMargin = 0.0;
const _barTooltipMargin = 6.0;
const _fixedTooltipBottom = 64.0;
double _tooltipAnchorValue({
required double maxY,
required double chartHeight,
required double tooltipMargin,
double chartTopInset = 0,
}) {
final plotHeight = chartHeight - _ChartPlotFrame.bottomTitleHeight;
if (plotHeight <= 0) return maxY;
final anchorY = (_fixedTooltipBottom - chartTopInset + tooltipMargin)
.clamp(0, plotHeight);
return (maxY * (1 - anchorY / plotHeight)).clamp(0, maxY);
}
const _bedtimeChartTopInset = 16.0;
double _barGroupsSpace(double plotWidth, int count, bool compact) {
if (count <= 1) return 0;
... ... @@ -1351,7 +1309,7 @@ LineTouchTooltipData _lineTooltipData({
tooltipBorder: BorderSide.none,
getTooltipColor: (_) => SleepWeekReportView.grid,
tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
tooltipMargin: 16,
tooltipMargin: _bedtimeTooltipMargin,
fitInsideHorizontally: true,
fitInsideVertically: false,
showOnTopOfTheChartBoxArea: false,
... ...
... ... @@ -127,8 +127,8 @@ class FriendHealthData {
/// Latest HRV value used by the friend health card.
final double? latestHrv;
/// Real-time stress data (may be an empty map `{}`)
final Map<String, dynamic>? realtimeStress;
/// Real-time stress samples returned by the API (may be an empty list `[]`).
final List<FriendRealtimeStressItem>? realtimeStress;
/// Sleep quality score
final int? sleepEvaluate;
... ... @@ -145,7 +145,13 @@ class FriendHealthData {
comprehensiveStressScore: _parseInt(json['comprehensive_stress_score']),
comprehensiveStressState: _parseInt(json['comprehensive_stress_state']),
latestHrv: _parseDouble(json['latest_hrv']),
realtimeStress: json['realtime_stress'] as Map<String, dynamic>?,
realtimeStress: (json['realtime_stress'] as List<dynamic>?)
?.map(
(item) => FriendRealtimeStressItem.fromJson(
item as Map<String, dynamic>,
),
)
.toList(),
sleepEvaluate: _parseInt(json['sleep_evaluate']),
totalSteps: _parseInt(json['total_steps']),
lastDataTime: _parseNum(json['last_data_time']),
... ... @@ -162,7 +168,10 @@ class FriendHealthData {
val['comprehensive_stress_state'] = comprehensiveStressState;
}
if (latestHrv != null) val['latest_hrv'] = latestHrv;
if (realtimeStress != null) val['realtime_stress'] = realtimeStress;
if (realtimeStress != null) {
val['realtime_stress'] =
realtimeStress!.map((item) => item.toJson()).toList();
}
if (sleepEvaluate != null) val['sleep_evaluate'] = sleepEvaluate;
if (totalSteps != null) val['total_steps'] = totalSteps;
if (lastDataTime != null) val['last_data_time'] = lastDataTime;
... ... @@ -170,6 +179,45 @@ class FriendHealthData {
}
}
class FriendRealtimeStressItem {
const FriendRealtimeStressItem({
this.time,
this.value,
this.state,
this.isAsleep,
});
/// Unix timestamp in seconds.
final num? time;
/// Real-time stress score.
final double? value;
/// Stress state indicator.
final int? state;
/// Whether the sample was recorded while asleep (0 = no, 1 = yes).
final int? isAsleep;
factory FriendRealtimeStressItem.fromJson(Map<String, dynamic> json) {
return FriendRealtimeStressItem(
time: _parseNum(json['time']),
value: _parseDouble(json['value']),
state: _parseInt(json['state']),
isAsleep: _parseInt(json['is_asleep']),
);
}
Map<String, dynamic> toJson() {
final val = <String, dynamic>{};
if (time != null) val['time'] = time;
if (value != null) val['value'] = value;
if (state != null) val['state'] = state;
if (isAsleep != null) val['is_asleep'] = isAsleep;
return val;
}
}
int? _parseInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
... ...
... ... @@ -190,20 +190,31 @@ class AsleepTimeTrendList {
}
class SleepTrendList {
SleepTrendList({this.timeKey, this.totalTime, this.score});
SleepTrendList({
this.timeKey,
this.totalTime,
this.score,
this.sleepEvaluate,
});
factory SleepTrendList.fromJson(Map<String, dynamic> json) => SleepTrendList(
timeKey: json['time_key'],
totalTime: _parseNum(json['total_time']),
score: _parseNum(json['score']),
sleepEvaluate: _parseNum(json['sleep_evaluate']),
);
Object? timeKey;
num? totalTime;
num? score;
num? sleepEvaluate;
Map<String, dynamic> toJson() =>
{'time_key': timeKey, 'total_time': totalTime, 'score': score};
Map<String, dynamic> toJson() => {
'time_key': timeKey,
'total_time': totalTime,
'score': score,
'sleep_evaluate': sleepEvaluate,
};
}
num? _parseNum(Object? value) {
... ...