Commit a039c9c7f6290222ce6838ac0b06819382862d17

Authored by 刘宏哲
1 parent 8d5a6c65

feat(app): update ui

@@ -257,12 +257,10 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -257,12 +257,10 @@ class _ActivityBurnRingPainter extends CustomPainter {
257 257
258 final rect = Rect.fromCircle(center: center, radius: radius); 258 final rect = Rect.fromCircle(center: center, radius: radius);
259 final safeProgress = math.max(0, progress); 259 final safeProgress = math.max(0, progress);
260 - final hasOverflow = safeProgress > 1;  
261 - final baseProgress = hasOverflow ? 1.0 : safeProgress;  
262 - final overflowProgress = safeProgress - 1;  
263 - final overflowLap = overflowProgress % 1;  
264 - final visibleOverflowLap =  
265 - hasOverflow && overflowLap == 0 ? 1.0 : overflowLap; 260 + final hasCompletedLap = safeProgress >= 1;
  261 + final remainder = safeProgress % 1;
  262 + final visibleProgress = hasCompletedLap ? remainder : safeProgress;
  263 + final isExactLap = hasCompletedLap && remainder == 0;
266 264
267 final progressPaint = Paint() 265 final progressPaint = Paint()
268 ..color = progressColor 266 ..color = progressColor
@@ -276,7 +274,9 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -276,7 +274,9 @@ class _ActivityBurnRingPainter extends CustomPainter {
276 ..strokeWidth = width 274 ..strokeWidth = width
277 ..strokeCap = StrokeCap.butt; 275 ..strokeCap = StrokeCap.butt;
278 276
279 - if (baseProgress >= 1) { 277 + // Bottom layer: once a lap is complete, keep a solid full ring below the
  278 + // animated current lap.
  279 + if (hasCompletedLap) {
280 canvas.drawArc( 280 canvas.drawArc(
281 rect, 281 rect,
282 startAngle, 282 startAngle,
@@ -284,40 +284,64 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -284,40 +284,64 @@ class _ActivityBurnRingPainter extends CustomPainter {
284 false, 284 false,
285 fullLapPaint, 285 fullLapPaint,
286 ); 286 );
287 - } else {  
288 - final baseEndAngle = startAngle + math.pi * 2 * baseProgress;  
289 - _drawRingHeadShadow( 287 + }
  288 +
  289 + final endAngle = startAngle + math.pi * 2 * visibleProgress;
  290 +
  291 + // Head layer: its shadow is painted first, then the colored current lap.
  292 + // For an exact lap there is no partial arc, so paint a colored head at the
  293 + // 12 o'clock endpoint instead.
  294 + _drawRingHeadShadow(
  295 + canvas,
  296 + center: center,
  297 + radius: radius,
  298 + width: width,
  299 + angle: endAngle,
  300 + forwardOnly: isExactLap,
  301 + );
  302 + if (isExactLap) {
  303 + _drawRingEndCap(
290 canvas, 304 canvas,
291 center: center, 305 center: center,
292 radius: radius, 306 radius: radius,
293 width: width, 307 width: width,
294 - angle: baseEndAngle, 308 + angle: endAngle,
  309 + color: progressColor,
295 ); 310 );
  311 + } else {
296 canvas.drawArc( 312 canvas.drawArc(
297 rect, 313 rect,
298 startAngle, 314 startAngle,
299 - math.pi * 2 * baseProgress, 315 + math.pi * 2 * visibleProgress,
300 false, 316 false,
301 progressPaint, 317 progressPaint,
302 ); 318 );
303 } 319 }
  320 + }
304 321
305 - if (!hasOverflow || visibleOverflowLap <= 0) return;  
306 -  
307 - final overflowEndAngle = startAngle + math.pi * 2 * visibleOverflowLap;  
308 - _drawRingHeadShadow(  
309 - canvas,  
310 - center: center,  
311 - radius: radius,  
312 - width: width,  
313 - angle: overflowEndAngle, 322 + void _drawRingEndCap(
  323 + Canvas canvas, {
  324 + required Offset center,
  325 + required double radius,
  326 + required double width,
  327 + required double angle,
  328 + required Color color,
  329 + }) {
  330 + final headCenter = Offset(
  331 + center.dx + math.cos(angle) * radius,
  332 + center.dy + math.sin(angle) * radius,
314 ); 333 );
  334 + // Only paint the forward half of the cap. The completed ring below already
  335 + // provides the trailing half; using a full circle makes the head look like
  336 + // a separate colored dot at exactly 1.0x, 2.0x, etc.
315 canvas.drawArc( 337 canvas.drawArc(
316 - rect,  
317 - startAngle,  
318 - math.pi * 2 * visibleOverflowLap,  
319 - false,  
320 - progressPaint, 338 + Rect.fromCircle(center: headCenter, radius: width / 2),
  339 + angle,
  340 + math.pi,
  341 + true,
  342 + Paint()
  343 + ..color = color
  344 + ..style = PaintingStyle.fill,
321 ); 345 );
322 } 346 }
323 347
@@ -327,6 +351,7 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -327,6 +351,7 @@ class _ActivityBurnRingPainter extends CustomPainter {
327 required double radius, 351 required double radius,
328 required double width, 352 required double width,
329 required double angle, 353 required double angle,
  354 + bool forwardOnly = false,
330 }) { 355 }) {
331 final headCenter = Offset( 356 final headCenter = Offset(
332 center.dx + math.cos(angle) * radius, 357 center.dx + math.cos(angle) * radius,
@@ -340,6 +365,29 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -340,6 +365,29 @@ class _ActivityBurnRingPainter extends CustomPainter {
340 365
341 canvas.save(); 366 canvas.save();
342 canvas.clipPath(ringClip); 367 canvas.clipPath(ringClip);
  368 + if (forwardOnly) {
  369 + final radial = Offset(math.cos(angle), math.sin(angle));
  370 + final extent = width * 2;
  371 + final forwardClip = Path()
  372 + ..moveTo(
  373 + headCenter.dx - radial.dx * extent,
  374 + headCenter.dy - radial.dy * extent,
  375 + )
  376 + ..lineTo(
  377 + headCenter.dx + radial.dx * extent,
  378 + headCenter.dy + radial.dy * extent,
  379 + )
  380 + ..lineTo(
  381 + headCenter.dx + radial.dx * extent + tangent.dx * extent,
  382 + headCenter.dy + radial.dy * extent + tangent.dy * extent,
  383 + )
  384 + ..lineTo(
  385 + headCenter.dx - radial.dx * extent + tangent.dx * extent,
  386 + headCenter.dy - radial.dy * extent + tangent.dy * extent,
  387 + )
  388 + ..close();
  389 + canvas.clipPath(forwardClip);
  390 + }
343 canvas.drawCircle( 391 canvas.drawCircle(
344 headCenter + tangent * 2, 392 headCenter + tangent * 2,
345 width / 2, 393 width / 2,
@@ -27,7 +27,7 @@ class PrivacySettingsController extends GetxController { @@ -27,7 +27,7 @@ class PrivacySettingsController extends GetxController {
27 isUpdatingEnableAddWithUCode.value = true; 27 isUpdatingEnableAddWithUCode.value = true;
28 try { 28 try {
29 final result = await _userApi.updateOwnerUserInfo( 29 final result = await _userApi.updateOwnerUserInfo(
30 - enableAddWithUcode: enabled ? 0 : 1, 30 + enableAddWithUcode: enabled ? 1 : 0,
31 ); 31 );
32 if (result is AppSuccess<UserInfoResponse>) { 32 if (result is AppSuccess<UserInfoResponse>) {
33 await _userPreferencesStorage.updateMeUserInfo(result.data); 33 await _userPreferencesStorage.updateMeUserInfo(result.data);
@@ -97,6 +97,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -97,6 +97,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
97 averageDurationSeconds: data.avgSleepDuration, 97 averageDurationSeconds: data.avgSleepDuration,
98 targetDurationSeconds: data.sleepTargetDuration, 98 targetDurationSeconds: data.sleepTargetDuration,
99 averageScore: data.avgSleepScore, 99 averageScore: data.avgSleepScore,
  100 + averageSleepEvaluate: data.avgSleepEvaluate?.toInt(),
100 previousAverageDurationSeconds: data.qoqAvgSleepDuration, 101 previousAverageDurationSeconds: data.qoqAvgSleepDuration,
101 previousAverageScore: data.qoqSleepScore, 102 previousAverageScore: data.qoqSleepScore,
102 earliestSleepOverride: _sleepInfoReport( 103 earliestSleepOverride: _sleepInfoReport(
@@ -141,6 +142,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -141,6 +142,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
141 averageDurationSeconds: data.avgSleepDuration, 142 averageDurationSeconds: data.avgSleepDuration,
142 targetDurationSeconds: data.sleepTargetDuration, 143 targetDurationSeconds: data.sleepTargetDuration,
143 averageScore: data.avgSleepScore, 144 averageScore: data.avgSleepScore,
  145 + averageSleepEvaluate: data.avgSleepEvaluate?.toInt(),
144 previousAverageDurationSeconds: data.qoqAvgSleepDuration, 146 previousAverageDurationSeconds: data.qoqAvgSleepDuration,
145 previousAverageScore: data.qoqSleepScore, 147 previousAverageScore: data.qoqSleepScore,
146 earliestSleepOverride: _sleepInfoReport( 148 earliestSleepOverride: _sleepInfoReport(
@@ -183,6 +185,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -183,6 +185,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
183 ? report.duration 185 ? report.duration
184 : SleepDuration(minutes: durationSeconds ~/ 60), 186 : SleepDuration(minutes: durationSeconds ~/ 60),
185 qualityScore: score?.toInt() ?? report.qualityScore, 187 qualityScore: score?.toInt() ?? report.qualityScore,
  188 + sleepEvaluate: report.sleepEvaluate,
186 heartRate: report.heartRate, 189 heartRate: report.heartRate,
187 ); 190 );
188 } 191 }
@@ -202,6 +205,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -202,6 +205,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
202 date: date, 205 date: date,
203 duration: report.duration, 206 duration: report.duration,
204 qualityScore: report.qualityScore, 207 qualityScore: report.qualityScore,
  208 + sleepEvaluate: report.sleepEvaluate,
205 heartRate: SleepHeartRateSummary( 209 heartRate: SleepHeartRateSummary(
206 averageBpm: heartRate.averageBpm, 210 averageBpm: heartRate.averageBpm,
207 maxBpm: heartRate.maxBpm, 211 maxBpm: heartRate.maxBpm,
@@ -260,9 +264,11 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -260,9 +264,11 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
260 timeKey: trend?.timeKey, 264 timeKey: trend?.timeKey,
261 totalTime: trend?.totalTime ?? data.avgSleepDuration, 265 totalTime: trend?.totalTime ?? data.avgSleepDuration,
262 score: trend?.score ?? data.avgSleepScore, 266 score: trend?.score ?? data.avgSleepScore,
  267 + sleepEvaluate: trend?.sleepEvaluate ?? data.avgSleepEvaluate,
263 ); 268 );
264 if (dailyTrend.totalTime == null && 269 if (dailyTrend.totalTime == null &&
265 dailyTrend.score == null && 270 dailyTrend.score == null &&
  271 + dailyTrend.sleepEvaluate == null &&
266 asleepTime == null && 272 asleepTime == null &&
267 points.isEmpty && 273 points.isEmpty &&
268 data.avgHr == null && 274 data.avgHr == null &&
@@ -364,6 +370,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -364,6 +370,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
364 sleepDurationSeconds: sleepDurationSeconds, 370 sleepDurationSeconds: sleepDurationSeconds,
365 targetDurationSeconds: targetDurationSeconds, 371 targetDurationSeconds: targetDurationSeconds,
366 qualityScore: trend?.score?.toInt(), 372 qualityScore: trend?.score?.toInt(),
  373 + sleepEvaluate: trend?.sleepEvaluate?.toInt(),
367 heartRate: _heartRateSummary( 374 heartRate: _heartRateSummary(
368 points, 375 points,
369 asleepTime, 376 asleepTime,
@@ -479,6 +486,7 @@ class MockSleepReportDataSource implements SleepReportDataSource { @@ -479,6 +486,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
479 sleepDurationSeconds: 405 * 60, 486 sleepDurationSeconds: 405 * 60,
480 targetDurationSeconds: 28800, 487 targetDurationSeconds: 28800,
481 qualityScore: 86, 488 qualityScore: 86,
  489 + sleepEvaluate: 1,
482 heartRate: _buildHeartRateSummary( 490 heartRate: _buildHeartRateSummary(
483 points: points, 491 points: points,
484 sleepStart: sleepStart, 492 sleepStart: sleepStart,
@@ -530,6 +538,7 @@ class MockSleepReportDataSource implements SleepReportDataSource { @@ -530,6 +538,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
530 qualityScore: [82, 88, 0, 79, 91, 86, 84][i] == 0 538 qualityScore: [82, 88, 0, 79, 91, 86, 84][i] == 0
531 ? null 539 ? null
532 : [82, 88, 0, 79, 91, 86, 84][i], 540 : [82, 88, 0, 79, 91, 86, 84][i],
  541 + sleepEvaluate: [2, 1, null, 2, 1, 1, 2][i],
533 heartRate: _buildHeartRateSummary( 542 heartRate: _buildHeartRateSummary(
534 points: points, 543 points: points,
535 sleepStart: sleepStart, 544 sleepStart: sleepStart,
@@ -543,6 +552,7 @@ class MockSleepReportDataSource implements SleepReportDataSource { @@ -543,6 +552,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
543 weekStart: normalizedStart, 552 weekStart: normalizedStart,
544 weekEnd: normalizedStart.add(const Duration(days: 6)), 553 weekEnd: normalizedStart.add(const Duration(days: 6)),
545 days: days, 554 days: days,
  555 + averageSleepEvaluate: 1,
546 ); 556 );
547 } 557 }
548 558
@@ -626,6 +636,7 @@ class MockSleepReportDataSource implements SleepReportDataSource { @@ -626,6 +636,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
626 date: date, 636 date: date,
627 duration: SleepDuration(minutes: durationMinutes[i]), 637 duration: SleepDuration(minutes: durationMinutes[i]),
628 qualityScore: qualityScores[i], 638 qualityScore: qualityScores[i],
  639 + sleepEvaluate: qualityScores[i] > 85 ? 1 : 2,
629 heartRate: _buildHeartRateSummary( 640 heartRate: _buildHeartRateSummary(
630 points: points, 641 points: points,
631 sleepStart: sleepStart, 642 sleepStart: sleepStart,
@@ -639,6 +650,7 @@ class MockSleepReportDataSource implements SleepReportDataSource { @@ -639,6 +650,7 @@ class MockSleepReportDataSource implements SleepReportDataSource {
639 monthStart: normalizedStart, 650 monthStart: normalizedStart,
640 monthEnd: monthEnd, 651 monthEnd: monthEnd,
641 days: days, 652 days: days,
  653 + averageSleepEvaluate: 1,
642 ); 654 );
643 } 655 }
644 } 656 }
@@ -48,13 +48,6 @@ extension SleepQualityLevelText on SleepQualityLevel { @@ -48,13 +48,6 @@ extension SleepQualityLevelText on SleepQualityLevel {
48 } 48 }
49 } 49 }
50 50
51 -SleepQualityLevel sleepQualityLevelFromScore(int? score) {  
52 - if (score == null) return SleepQualityLevel.unknown;  
53 - if (score > 85) return SleepQualityLevel.excellent;  
54 - if (score >= 60) return SleepQualityLevel.good;  
55 - return SleepQualityLevel.poor;  
56 -}  
57 -  
58 SleepQualityLevel sleepQualityLevelFromSleepEvaluate(int? sleepEvaluate) { 51 SleepQualityLevel sleepQualityLevelFromSleepEvaluate(int? sleepEvaluate) {
59 return switch (sleepEvaluate) { 52 return switch (sleepEvaluate) {
60 1 => SleepQualityLevel.excellent, 53 1 => SleepQualityLevel.excellent,
@@ -168,6 +161,7 @@ class SleepReport { @@ -168,6 +161,7 @@ class SleepReport {
168 final num? sleepDurationSeconds; 161 final num? sleepDurationSeconds;
169 final num? targetDurationSeconds; 162 final num? targetDurationSeconds;
170 final int? qualityScore; 163 final int? qualityScore;
  164 + final int? sleepEvaluate;
171 final SleepHeartRateSummary heartRate; 165 final SleepHeartRateSummary heartRate;
172 166
173 const SleepReport({ 167 const SleepReport({
@@ -176,6 +170,7 @@ class SleepReport { @@ -176,6 +170,7 @@ class SleepReport {
176 this.sleepDurationSeconds, 170 this.sleepDurationSeconds,
177 this.targetDurationSeconds, 171 this.targetDurationSeconds,
178 this.qualityScore, 172 this.qualityScore,
  173 + this.sleepEvaluate,
179 this.heartRate = const SleepHeartRateSummary(), 174 this.heartRate = const SleepHeartRateSummary(),
180 }); 175 });
181 176
@@ -189,7 +184,7 @@ class SleepReport { @@ -189,7 +184,7 @@ class SleepReport {
189 hasSleepData ? heartRate : const SleepHeartRateSummary(); 184 hasSleepData ? heartRate : const SleepHeartRateSummary();
190 185
191 SleepQualityLevel get qualityLevel => 186 SleepQualityLevel get qualityLevel =>
192 - sleepQualityLevelFromScore(validQualityScore); 187 + sleepQualityLevelFromSleepEvaluate(sleepEvaluate);
193 188
194 factory SleepReport.empty(DateTime date) { 189 factory SleepReport.empty(DateTime date) {
195 return SleepReport(date: date); 190 return SleepReport(date: date);
@@ -204,6 +199,7 @@ class SleepReport { @@ -204,6 +199,7 @@ class SleepReport {
204 sleepDurationSeconds: json['sleepDurationSeconds'] as num?, 199 sleepDurationSeconds: json['sleepDurationSeconds'] as num?,
205 targetDurationSeconds: json['targetDurationSeconds'] as num?, 200 targetDurationSeconds: json['targetDurationSeconds'] as num?,
206 qualityScore: json['qualityScore'] as int?, 201 qualityScore: json['qualityScore'] as int?,
  202 + sleepEvaluate: json['sleepEvaluate'] as int?,
207 heartRate: json['heartRate'] == null 203 heartRate: json['heartRate'] == null
208 ? const SleepHeartRateSummary() 204 ? const SleepHeartRateSummary()
209 : SleepHeartRateSummary.fromJson( 205 : SleepHeartRateSummary.fromJson(
@@ -218,6 +214,7 @@ class SleepReport { @@ -218,6 +214,7 @@ class SleepReport {
218 'sleepDurationSeconds': sleepDurationSeconds, 214 'sleepDurationSeconds': sleepDurationSeconds,
219 'targetDurationSeconds': targetDurationSeconds, 215 'targetDurationSeconds': targetDurationSeconds,
220 'qualityScore': qualityScore, 216 'qualityScore': qualityScore,
  217 + 'sleepEvaluate': sleepEvaluate,
221 'heartRate': heartRate.toJson(), 218 'heartRate': heartRate.toJson(),
222 }; 219 };
223 } 220 }
@@ -230,6 +227,7 @@ class WeeklySleepReport { @@ -230,6 +227,7 @@ class WeeklySleepReport {
230 this.averageDurationSeconds, 227 this.averageDurationSeconds,
231 this.targetDurationSeconds, 228 this.targetDurationSeconds,
232 this.averageScore, 229 this.averageScore,
  230 + this.averageSleepEvaluate,
233 this.previousAverageDurationSeconds, 231 this.previousAverageDurationSeconds,
234 this.previousAverageScore, 232 this.previousAverageScore,
235 this.earliestSleepOverride, 233 this.earliestSleepOverride,
@@ -244,6 +242,7 @@ class WeeklySleepReport { @@ -244,6 +242,7 @@ class WeeklySleepReport {
244 final num? averageDurationSeconds; 242 final num? averageDurationSeconds;
245 final num? targetDurationSeconds; 243 final num? targetDurationSeconds;
246 final num? averageScore; 244 final num? averageScore;
  245 + final int? averageSleepEvaluate;
247 final num? previousAverageDurationSeconds; 246 final num? previousAverageDurationSeconds;
248 final num? previousAverageScore; 247 final num? previousAverageScore;
249 final SleepReport? earliestSleepOverride; 248 final SleepReport? earliestSleepOverride;
@@ -294,7 +293,7 @@ class WeeklySleepReport { @@ -294,7 +293,7 @@ class WeeklySleepReport {
294 } 293 }
295 294
296 SleepQualityLevel get averageQualityLevel => 295 SleepQualityLevel get averageQualityLevel =>
297 - sleepQualityLevelFromScore(averageQualityScore); 296 + sleepQualityLevelFromSleepEvaluate(averageSleepEvaluate);
298 297
299 double? get durationComparisonPercent => _comparisonPercent( 298 double? get durationComparisonPercent => _comparisonPercent(
300 _computedAverageDurationSeconds, 299 _computedAverageDurationSeconds,
@@ -360,6 +359,7 @@ class MonthlySleepReport { @@ -360,6 +359,7 @@ class MonthlySleepReport {
360 this.averageDurationSeconds, 359 this.averageDurationSeconds,
361 this.targetDurationSeconds, 360 this.targetDurationSeconds,
362 this.averageScore, 361 this.averageScore,
  362 + this.averageSleepEvaluate,
363 this.previousAverageDurationSeconds, 363 this.previousAverageDurationSeconds,
364 this.previousAverageScore, 364 this.previousAverageScore,
365 this.earliestSleepOverride, 365 this.earliestSleepOverride,
@@ -374,6 +374,7 @@ class MonthlySleepReport { @@ -374,6 +374,7 @@ class MonthlySleepReport {
374 final num? averageDurationSeconds; 374 final num? averageDurationSeconds;
375 final num? targetDurationSeconds; 375 final num? targetDurationSeconds;
376 final num? averageScore; 376 final num? averageScore;
  377 + final int? averageSleepEvaluate;
377 final num? previousAverageDurationSeconds; 378 final num? previousAverageDurationSeconds;
378 final num? previousAverageScore; 379 final num? previousAverageScore;
379 final SleepReport? earliestSleepOverride; 380 final SleepReport? earliestSleepOverride;
@@ -424,7 +425,7 @@ class MonthlySleepReport { @@ -424,7 +425,7 @@ class MonthlySleepReport {
424 } 425 }
425 426
426 SleepQualityLevel get averageQualityLevel => 427 SleepQualityLevel get averageQualityLevel =>
427 - sleepQualityLevelFromScore(averageQualityScore); 428 + sleepQualityLevelFromSleepEvaluate(averageSleepEvaluate);
428 429
429 double? get durationComparisonPercent => _comparisonPercent( 430 double? get durationComparisonPercent => _comparisonPercent(
430 _computedAverageDurationSeconds, 431 _computedAverageDurationSeconds,
@@ -520,21 +520,6 @@ class _DurationChartState extends State<_DurationChart> { @@ -520,21 +520,6 @@ class _DurationChartState extends State<_DurationChart> {
520 alignment: Alignment.center, 520 alignment: Alignment.center,
521 clipBehavior: Clip.none, 521 clipBehavior: Clip.none,
522 children: [ 522 children: [
523 - if (_selectedIndex != null && _selectedOffset != null)  
524 - Positioned.fill(  
525 - child: LayoutBuilder(  
526 - builder: (context, constraints) =>  
527 - ChartSelectionLineOverlay(  
528 - offset: Offset.zero,  
529 - color: SleepWeekReportView.h3,  
530 - bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,  
531 - plotLeft: 0,  
532 - plotRight: 0,  
533 - lineX: _selectedOffset!.dx,  
534 - lineTop: _fixedTooltipBottom,  
535 - ),  
536 - ),  
537 - ),  
538 _ChartPlotFrame( 523 _ChartPlotFrame(
539 maxY: _maxY, 524 maxY: _maxY,
540 interval: 2, 525 interval: 2,
@@ -566,15 +551,25 @@ class _DurationChartState extends State<_DurationChart> { @@ -566,15 +551,25 @@ class _DurationChartState extends State<_DurationChart> {
566 ), 551 ),
567 onPointerUp: (_) => _onSelected(null, null), 552 onPointerUp: (_) => _onSelected(null, null),
568 onPointerCancel: (_) => _onSelected(null, null), 553 onPointerCancel: (_) => _onSelected(null, null),
569 - child: BarChart(  
570 - _chartData(  
571 - constraints.maxWidth,  
572 - constraints.maxHeight,  
573 - ),  
574 - ), 554 + child: BarChart(_chartData(constraints.maxWidth)),
575 ), 555 ),
576 ), 556 ),
577 ), 557 ),
  558 + if (_selectedIndex != null && _selectedOffset != null)
  559 + Positioned.fill(
  560 + child: LayoutBuilder(
  561 + builder: (context, constraints) =>
  562 + ChartSelectionLineOverlay(
  563 + offset: Offset.zero,
  564 + color: SleepWeekReportView.h3,
  565 + bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
  566 + plotLeft: 0,
  567 + plotRight: 0,
  568 + lineX: _selectedOffset!.dx,
  569 + lineTop: -_barTooltipMargin,
  570 + ),
  571 + ),
  572 + ),
578 if (!hasData) const _WaitingDataText(), 573 if (!hasData) const _WaitingDataText(),
579 ], 574 ],
580 ), 575 ),
@@ -583,7 +578,7 @@ class _DurationChartState extends State<_DurationChart> { @@ -583,7 +578,7 @@ class _DurationChartState extends State<_DurationChart> {
583 ); 578 );
584 } 579 }
585 580
586 - BarChartData _chartData(double plotWidth, double chartHeight) { 581 + BarChartData _chartData(double plotWidth) {
587 return BarChartData( 582 return BarChartData(
588 minY: 0, 583 minY: 0,
589 maxY: _maxY, 584 maxY: _maxY,
@@ -624,11 +619,7 @@ class _DurationChartState extends State<_DurationChart> { @@ -624,11 +619,7 @@ class _DurationChartState extends State<_DurationChart> {
624 ), 619 ),
625 ), 620 ),
626 BarChartRodData( 621 BarChartRodData(
627 - toY: _tooltipAnchorValue(  
628 - maxY: _maxY,  
629 - chartHeight: chartHeight,  
630 - tooltipMargin: _barTooltipMargin,  
631 - ), 622 + toY: _maxY,
632 width: 0, 623 width: 0,
633 color: Colors.transparent, 624 color: Colors.transparent,
634 ), 625 ),
@@ -738,21 +729,6 @@ class _QualityChartState extends State<_QualityChart> { @@ -738,21 +729,6 @@ class _QualityChartState extends State<_QualityChart> {
738 alignment: Alignment.center, 729 alignment: Alignment.center,
739 clipBehavior: Clip.none, 730 clipBehavior: Clip.none,
740 children: [ 731 children: [
741 - if (_selectedIndex != null && _selectedOffset != null)  
742 - Positioned.fill(  
743 - child: LayoutBuilder(  
744 - builder: (context, constraints) =>  
745 - ChartSelectionLineOverlay(  
746 - offset: Offset.zero,  
747 - color: SleepWeekReportView.h3,  
748 - bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,  
749 - plotLeft: 0,  
750 - plotRight: 0,  
751 - lineX: _selectedOffset!.dx,  
752 - lineTop: _fixedTooltipBottom,  
753 - ),  
754 - ),  
755 - ),  
756 _ChartPlotFrame( 732 _ChartPlotFrame(
757 maxY: 100, 733 maxY: 100,
758 interval: 25, 734 interval: 25,
@@ -778,15 +754,25 @@ class _QualityChartState extends State<_QualityChart> { @@ -778,15 +754,25 @@ class _QualityChartState extends State<_QualityChart> {
778 ), 754 ),
779 onPointerUp: (_) => _onSelected(null, null), 755 onPointerUp: (_) => _onSelected(null, null),
780 onPointerCancel: (_) => _onSelected(null, null), 756 onPointerCancel: (_) => _onSelected(null, null),
781 - child: BarChart(  
782 - _chartData(  
783 - constraints.maxWidth,  
784 - constraints.maxHeight,  
785 - ),  
786 - ), 757 + child: BarChart(_chartData(constraints.maxWidth)),
787 ), 758 ),
788 ), 759 ),
789 ), 760 ),
  761 + if (_selectedIndex != null && _selectedOffset != null)
  762 + Positioned.fill(
  763 + child: LayoutBuilder(
  764 + builder: (context, constraints) =>
  765 + ChartSelectionLineOverlay(
  766 + offset: Offset.zero,
  767 + color: SleepWeekReportView.h3,
  768 + bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
  769 + plotLeft: 0,
  770 + plotRight: 0,
  771 + lineX: _selectedOffset!.dx,
  772 + lineTop: -_barTooltipMargin,
  773 + ),
  774 + ),
  775 + ),
790 if (!hasData) const _WaitingDataText(), 776 if (!hasData) const _WaitingDataText(),
791 ], 777 ],
792 ), 778 ),
@@ -795,7 +781,7 @@ class _QualityChartState extends State<_QualityChart> { @@ -795,7 +781,7 @@ class _QualityChartState extends State<_QualityChart> {
795 ); 781 );
796 } 782 }
797 783
798 - BarChartData _chartData(double plotWidth, double chartHeight) { 784 + BarChartData _chartData(double plotWidth) {
799 return BarChartData( 785 return BarChartData(
800 minY: 0, 786 minY: 0,
801 maxY: 100, 787 maxY: 100,
@@ -835,11 +821,7 @@ class _QualityChartState extends State<_QualityChart> { @@ -835,11 +821,7 @@ class _QualityChartState extends State<_QualityChart> {
835 ), 821 ),
836 ), 822 ),
837 BarChartRodData( 823 BarChartRodData(
838 - toY: _tooltipAnchorValue(  
839 - maxY: 100,  
840 - chartHeight: chartHeight,  
841 - tooltipMargin: _barTooltipMargin,  
842 - ), 824 + toY: 100,
843 width: 0, 825 width: 0,
844 color: Colors.transparent, 826 color: Colors.transparent,
845 ), 827 ),
@@ -964,14 +946,14 @@ class _BedtimeChartState extends State<_BedtimeChart> { @@ -964,14 +946,14 @@ class _BedtimeChartState extends State<_BedtimeChart> {
964 constraints.maxWidth, 946 constraints.maxWidth,
965 _selectedDayIndex!, 947 _selectedDayIndex!,
966 ), 948 ),
967 - lineTop: _fixedTooltipBottom, 949 + lineTop: _bedtimeChartTopInset,
968 ), 950 ),
969 ), 951 ),
970 _ChartPlotFrame( 952 _ChartPlotFrame(
971 maxY: axis.maxHour.toDouble(), 953 maxY: axis.maxHour.toDouble(),
972 minY: axis.minHour.toDouble(), 954 minY: axis.minHour.toDouble(),
973 interval: 1, 955 interval: 1,
974 - topInset: 16, 956 + topInset: _bedtimeChartTopInset,
975 insetChildHorizontally: false, 957 insetChildHorizontally: false,
976 rightLabel: _timeAxisLabel, 958 rightLabel: _timeAxisLabel,
977 child: LayoutBuilder( 959 child: LayoutBuilder(
@@ -991,7 +973,6 @@ class _BedtimeChartState extends State<_BedtimeChart> { @@ -991,7 +973,6 @@ class _BedtimeChartState extends State<_BedtimeChart> {
991 _chartData( 973 _chartData(
992 axis, 974 axis,
993 constraints.maxWidth, 975 constraints.maxWidth,
994 - constraints.maxHeight,  
995 ), 976 ),
996 ), 977 ),
997 ), 978 ),
@@ -1015,7 +996,6 @@ class _BedtimeChartState extends State<_BedtimeChart> { @@ -1015,7 +996,6 @@ class _BedtimeChartState extends State<_BedtimeChart> {
1015 LineChartData _chartData( 996 LineChartData _chartData(
1016 _BedtimeAxis axis, 997 _BedtimeAxis axis,
1017 double chartWidth, 998 double chartWidth,
1018 - double chartHeight,  
1019 ) { 999 ) {
1020 final points = <FlSpot>[ 1000 final points = <FlSpot>[
1021 for (var i = 0; i < widget.days.length; i++) 1001 for (var i = 0; i < widget.days.length; i++)
@@ -1049,16 +1029,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { @@ -1049,16 +1029,7 @@ class _BedtimeChartState extends State<_BedtimeChart> {
1049 ); 1029 );
1050 final anchorData = LineChartBarData( 1030 final anchorData = LineChartBarData(
1051 spots: [ 1031 spots: [
1052 - for (final point in points)  
1053 - FlSpot(  
1054 - point.x,  
1055 - _tooltipAnchorValue(  
1056 - maxY: _tooltipChartMaxY,  
1057 - chartHeight: chartHeight,  
1058 - tooltipMargin: _tooltipMargin,  
1059 - chartTopInset: 16,  
1060 - ),  
1061 - ), 1032 + for (final point in points) FlSpot(point.x, _tooltipChartMaxY),
1062 ], 1033 ],
1063 color: Colors.transparent, 1034 color: Colors.transparent,
1064 barWidth: 0, 1035 barWidth: 0,
@@ -1160,22 +1131,9 @@ const _weekBarGap = 28.0; @@ -1160,22 +1131,9 @@ const _weekBarGap = 28.0;
1160 const _monthHorizontalInsets = 46.0; 1131 const _monthHorizontalInsets = 46.0;
1161 1132
1162 const _tooltipChartMaxY = 110.0; 1133 const _tooltipChartMaxY = 110.0;
1163 -const _tooltipMargin = 16.0; 1134 +const _bedtimeTooltipMargin = 0.0;
1164 const _barTooltipMargin = 6.0; 1135 const _barTooltipMargin = 6.0;
1165 -const _fixedTooltipBottom = 64.0;  
1166 -  
1167 -double _tooltipAnchorValue({  
1168 - required double maxY,  
1169 - required double chartHeight,  
1170 - required double tooltipMargin,  
1171 - double chartTopInset = 0,  
1172 -}) {  
1173 - final plotHeight = chartHeight - _ChartPlotFrame.bottomTitleHeight;  
1174 - if (plotHeight <= 0) return maxY;  
1175 - final anchorY = (_fixedTooltipBottom - chartTopInset + tooltipMargin)  
1176 - .clamp(0, plotHeight);  
1177 - return (maxY * (1 - anchorY / plotHeight)).clamp(0, maxY);  
1178 -} 1136 +const _bedtimeChartTopInset = 16.0;
1179 1137
1180 double _barGroupsSpace(double plotWidth, int count, bool compact) { 1138 double _barGroupsSpace(double plotWidth, int count, bool compact) {
1181 if (count <= 1) return 0; 1139 if (count <= 1) return 0;
@@ -1351,7 +1309,7 @@ LineTouchTooltipData _lineTooltipData({ @@ -1351,7 +1309,7 @@ LineTouchTooltipData _lineTooltipData({
1351 tooltipBorder: BorderSide.none, 1309 tooltipBorder: BorderSide.none,
1352 getTooltipColor: (_) => SleepWeekReportView.grid, 1310 getTooltipColor: (_) => SleepWeekReportView.grid,
1353 tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6), 1311 tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
1354 - tooltipMargin: 16, 1312 + tooltipMargin: _bedtimeTooltipMargin,
1355 fitInsideHorizontally: true, 1313 fitInsideHorizontally: true,
1356 fitInsideVertically: false, 1314 fitInsideVertically: false,
1357 showOnTopOfTheChartBoxArea: false, 1315 showOnTopOfTheChartBoxArea: false,
@@ -127,8 +127,8 @@ class FriendHealthData { @@ -127,8 +127,8 @@ class FriendHealthData {
127 /// Latest HRV value used by the friend health card. 127 /// Latest HRV value used by the friend health card.
128 final double? latestHrv; 128 final double? latestHrv;
129 129
130 - /// Real-time stress data (may be an empty map `{}`)  
131 - final Map<String, dynamic>? realtimeStress; 130 + /// Real-time stress samples returned by the API (may be an empty list `[]`).
  131 + final List<FriendRealtimeStressItem>? realtimeStress;
132 132
133 /// Sleep quality score 133 /// Sleep quality score
134 final int? sleepEvaluate; 134 final int? sleepEvaluate;
@@ -145,7 +145,13 @@ class FriendHealthData { @@ -145,7 +145,13 @@ class FriendHealthData {
145 comprehensiveStressScore: _parseInt(json['comprehensive_stress_score']), 145 comprehensiveStressScore: _parseInt(json['comprehensive_stress_score']),
146 comprehensiveStressState: _parseInt(json['comprehensive_stress_state']), 146 comprehensiveStressState: _parseInt(json['comprehensive_stress_state']),
147 latestHrv: _parseDouble(json['latest_hrv']), 147 latestHrv: _parseDouble(json['latest_hrv']),
148 - realtimeStress: json['realtime_stress'] as Map<String, dynamic>?, 148 + realtimeStress: (json['realtime_stress'] as List<dynamic>?)
  149 + ?.map(
  150 + (item) => FriendRealtimeStressItem.fromJson(
  151 + item as Map<String, dynamic>,
  152 + ),
  153 + )
  154 + .toList(),
149 sleepEvaluate: _parseInt(json['sleep_evaluate']), 155 sleepEvaluate: _parseInt(json['sleep_evaluate']),
150 totalSteps: _parseInt(json['total_steps']), 156 totalSteps: _parseInt(json['total_steps']),
151 lastDataTime: _parseNum(json['last_data_time']), 157 lastDataTime: _parseNum(json['last_data_time']),
@@ -162,7 +168,10 @@ class FriendHealthData { @@ -162,7 +168,10 @@ class FriendHealthData {
162 val['comprehensive_stress_state'] = comprehensiveStressState; 168 val['comprehensive_stress_state'] = comprehensiveStressState;
163 } 169 }
164 if (latestHrv != null) val['latest_hrv'] = latestHrv; 170 if (latestHrv != null) val['latest_hrv'] = latestHrv;
165 - if (realtimeStress != null) val['realtime_stress'] = realtimeStress; 171 + if (realtimeStress != null) {
  172 + val['realtime_stress'] =
  173 + realtimeStress!.map((item) => item.toJson()).toList();
  174 + }
166 if (sleepEvaluate != null) val['sleep_evaluate'] = sleepEvaluate; 175 if (sleepEvaluate != null) val['sleep_evaluate'] = sleepEvaluate;
167 if (totalSteps != null) val['total_steps'] = totalSteps; 176 if (totalSteps != null) val['total_steps'] = totalSteps;
168 if (lastDataTime != null) val['last_data_time'] = lastDataTime; 177 if (lastDataTime != null) val['last_data_time'] = lastDataTime;
@@ -170,6 +179,45 @@ class FriendHealthData { @@ -170,6 +179,45 @@ class FriendHealthData {
170 } 179 }
171 } 180 }
172 181
  182 +class FriendRealtimeStressItem {
  183 + const FriendRealtimeStressItem({
  184 + this.time,
  185 + this.value,
  186 + this.state,
  187 + this.isAsleep,
  188 + });
  189 +
  190 + /// Unix timestamp in seconds.
  191 + final num? time;
  192 +
  193 + /// Real-time stress score.
  194 + final double? value;
  195 +
  196 + /// Stress state indicator.
  197 + final int? state;
  198 +
  199 + /// Whether the sample was recorded while asleep (0 = no, 1 = yes).
  200 + final int? isAsleep;
  201 +
  202 + factory FriendRealtimeStressItem.fromJson(Map<String, dynamic> json) {
  203 + return FriendRealtimeStressItem(
  204 + time: _parseNum(json['time']),
  205 + value: _parseDouble(json['value']),
  206 + state: _parseInt(json['state']),
  207 + isAsleep: _parseInt(json['is_asleep']),
  208 + );
  209 + }
  210 +
  211 + Map<String, dynamic> toJson() {
  212 + final val = <String, dynamic>{};
  213 + if (time != null) val['time'] = time;
  214 + if (value != null) val['value'] = value;
  215 + if (state != null) val['state'] = state;
  216 + if (isAsleep != null) val['is_asleep'] = isAsleep;
  217 + return val;
  218 + }
  219 +}
  220 +
173 int? _parseInt(dynamic value) { 221 int? _parseInt(dynamic value) {
174 if (value == null) return null; 222 if (value == null) return null;
175 if (value is int) return value; 223 if (value is int) return value;
@@ -190,20 +190,31 @@ class AsleepTimeTrendList { @@ -190,20 +190,31 @@ class AsleepTimeTrendList {
190 } 190 }
191 191
192 class SleepTrendList { 192 class SleepTrendList {
193 - SleepTrendList({this.timeKey, this.totalTime, this.score}); 193 + SleepTrendList({
  194 + this.timeKey,
  195 + this.totalTime,
  196 + this.score,
  197 + this.sleepEvaluate,
  198 + });
194 199
195 factory SleepTrendList.fromJson(Map<String, dynamic> json) => SleepTrendList( 200 factory SleepTrendList.fromJson(Map<String, dynamic> json) => SleepTrendList(
196 timeKey: json['time_key'], 201 timeKey: json['time_key'],
197 totalTime: _parseNum(json['total_time']), 202 totalTime: _parseNum(json['total_time']),
198 score: _parseNum(json['score']), 203 score: _parseNum(json['score']),
  204 + sleepEvaluate: _parseNum(json['sleep_evaluate']),
199 ); 205 );
200 206
201 Object? timeKey; 207 Object? timeKey;
202 num? totalTime; 208 num? totalTime;
203 num? score; 209 num? score;
  210 + num? sleepEvaluate;
204 211
205 - Map<String, dynamic> toJson() =>  
206 - {'time_key': timeKey, 'total_time': totalTime, 'score': score}; 212 + Map<String, dynamic> toJson() => {
  213 + 'time_key': timeKey,
  214 + 'total_time': totalTime,
  215 + 'score': score,
  216 + 'sleep_evaluate': sleepEvaluate,
  217 + };
207 } 218 }
208 219
209 num? _parseNum(Object? value) { 220 num? _parseNum(Object? value) {