From 38fc6849f946bdcfbd6583d023a0cd4b18d79b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:17:44 +0000 Subject: [PATCH 1/7] feat: add daily readiness score from Health Connect sleep & heart data Adds the read side of Health Connect and a home-screen readiness card: - IHealthConnectService: read permissions + sleep/RHR/HRV/HR queries - ReadinessCalculator: pure scoring vs personal 14-day baseline (sleep 0.5 / resting HR 0.3 / HRV 0.2, renormalized over available components; only adverse deviation penalized; 5-sample minimum) - ReadinessManager: daily baseline cache + 30-min snapshot TTL in settings storage; every failure degrades to noData, never throws - ReadinessCard on dashboard (self-hides without data) with details bottom sheet; opt-in toggle in profile Health Connect section - New READ_SLEEP / READ_HEART_RATE / READ_RESTING_HEART_RATE / READ_HEART_RATE_VARIABILITY manifest permissions https://claude.ai/code/session_01FTgsHTfbvXsxwTUe74UrYe --- .../android/app/src/main/AndroidManifest.xml | 4 + workout-logger/lib/main.dart | 11 + workout-logger/lib/models/models.dart | 138 +++++++ workout-logger/lib/screens/home_screen.dart | 2 + .../lib/screens/profile_screen.dart | 79 +++- .../lib/screens/widgets/profile_sections.dart | 41 +++ .../lib/screens/widgets/readiness_card.dart | 313 ++++++++++++++++ .../lib/services/health_connect_service.dart | 124 +++++++ .../health_connect_service_interface.dart | 19 + .../lib/services/interfaces/interfaces.dart | 1 + .../readiness_manager_interface.dart | 25 ++ .../lib/services/managers/managers.dart | 1 + .../services/managers/readiness_manager.dart | 259 +++++++++++++ .../lib/services/settings_provider.dart | 11 + .../services/utils/readiness_calculator.dart | 135 +++++++ .../test/health_sync_manager_test.dart | 22 ++ .../test/readiness_calculator_test.dart | 221 ++++++++++++ .../test/readiness_manager_test.dart | 341 ++++++++++++++++++ 18 files changed, 1743 insertions(+), 4 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/readiness_card.dart create mode 100644 workout-logger/lib/services/interfaces/readiness_manager_interface.dart create mode 100644 workout-logger/lib/services/managers/readiness_manager.dart create mode 100644 workout-logger/lib/services/utils/readiness_calculator.dart create mode 100644 workout-logger/test/readiness_calculator_test.dart create mode 100644 workout-logger/test/readiness_manager_test.dart diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 9fe9f1e..0c0b757 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + .value(value: _historyManager), ChangeNotifierProvider.value(value: _prManager), + ChangeNotifierProvider.value(value: _readinessManager), // GeminiAiService is the single AI backend instance. It's a ChangeNotifier // (settings UI watches isConfigured/model), so it's provided as such. // Consumers that should depend on the abstraction (the coach ViewModel, @@ -156,6 +162,7 @@ class _AppInitializerState extends State { final prManager = context.read(); final api = context.read(); final gemini = context.read(); + final readiness = context.read(); try { await provider.init(); @@ -176,6 +183,10 @@ class _AppInitializerState extends State { settings.lastSeenVersion != null && settings.lastSeenVersion != version; + // Fire-and-forget readiness refresh — must run after settings.init() + // so the opt-in flag is loaded; never blocks or fails app init. + readiness.refresh(); + // Fire-and-forget analytics in background. api.sendHeartbeat(); api.trackEvent('app_open'); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index bad5c88..3c02343 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -981,3 +981,141 @@ class PendingQuestions { .toList(), ); } + +// ==================== Readiness ==================== + +/// Coarse training-readiness classification derived from [ReadinessSnapshot]. +enum ReadinessBand { high, moderate, low } + +/// A single point-in-time health measurement read from Health Connect. +class HealthSample { + final DateTime time; + final double value; + + const HealthSample({required this.time, required this.value}); +} + +/// A sleep session interval read from Health Connect. +class SleepPeriod { + final DateTime start; + final DateTime end; + + const SleepPeriod({required this.start, required this.end}); + + int get minutes => end.difference(start).inMinutes; +} + +/// Rolling per-component averages used as the personal reference point +/// when scoring today's readiness. Recomputed at most once per day. +class ReadinessBaseline { + final String dateKey; // yyyy-MM-dd the baseline was computed for + final double? avgSleepMinutes; + final int sleepNights; + final double? avgRestingHr; + final int rhrDays; + final double? avgHrvMs; + final int hrvDays; + + const ReadinessBaseline({ + required this.dateKey, + this.avgSleepMinutes, + this.sleepNights = 0, + this.avgRestingHr, + this.rhrDays = 0, + this.avgHrvMs, + this.hrvDays = 0, + }); + + Map toJson() => { + 'dateKey': dateKey, + 'avgSleepMinutes': avgSleepMinutes, + 'sleepNights': sleepNights, + 'avgRestingHr': avgRestingHr, + 'rhrDays': rhrDays, + 'avgHrvMs': avgHrvMs, + 'hrvDays': hrvDays, + }; + + factory ReadinessBaseline.fromJson(Map json) => + ReadinessBaseline( + dateKey: json['dateKey'] as String, + avgSleepMinutes: (json['avgSleepMinutes'] as num?)?.toDouble(), + sleepNights: json['sleepNights'] as int? ?? 0, + avgRestingHr: (json['avgRestingHr'] as num?)?.toDouble(), + rhrDays: json['rhrDays'] as int? ?? 0, + avgHrvMs: (json['avgHrvMs'] as num?)?.toDouble(), + hrvDays: json['hrvDays'] as int? ?? 0, + ); +} + +/// One day's computed readiness with the per-component evidence behind it. +/// +/// Any component (sleep / resting HR / HRV) may be null when the data or a +/// reliable baseline is unavailable; [score] is null when no component could +/// be scored at all, in which case the UI hides readiness entirely. +class ReadinessSnapshot { + final String dateKey; // yyyy-MM-dd this snapshot describes + final int? score; // 0–100 overall, null = nothing scorable + final ReadinessBand? band; + final int? sleepMinutes; + final double? sleepBaselineMinutes; + final int? sleepScore; + final double? restingHr; + final double? rhrBaseline; + final int? rhrScore; + final double? hrvMs; + final double? hrvBaseline; + final int? hrvScore; + final DateTime computedAt; + + ReadinessSnapshot({ + required this.dateKey, + this.score, + this.band, + this.sleepMinutes, + this.sleepBaselineMinutes, + this.sleepScore, + this.restingHr, + this.rhrBaseline, + this.rhrScore, + this.hrvMs, + this.hrvBaseline, + this.hrvScore, + DateTime? computedAt, + }) : computedAt = computedAt ?? DateTime.now(); + + Map toJson() => { + 'dateKey': dateKey, + 'score': score, + 'band': band?.name, + 'sleepMinutes': sleepMinutes, + 'sleepBaselineMinutes': sleepBaselineMinutes, + 'sleepScore': sleepScore, + 'restingHr': restingHr, + 'rhrBaseline': rhrBaseline, + 'rhrScore': rhrScore, + 'hrvMs': hrvMs, + 'hrvBaseline': hrvBaseline, + 'hrvScore': hrvScore, + 'computedAt': computedAt.toIso8601String(), + }; + + factory ReadinessSnapshot.fromJson(Map json) => + ReadinessSnapshot( + dateKey: json['dateKey'] as String, + score: json['score'] as int?, + band: json['band'] != null + ? ReadinessBand.values.byName(json['band'] as String) + : null, + sleepMinutes: json['sleepMinutes'] as int?, + sleepBaselineMinutes: (json['sleepBaselineMinutes'] as num?)?.toDouble(), + sleepScore: json['sleepScore'] as int?, + restingHr: (json['restingHr'] as num?)?.toDouble(), + rhrBaseline: (json['rhrBaseline'] as num?)?.toDouble(), + rhrScore: json['rhrScore'] as int?, + hrvMs: (json['hrvMs'] as num?)?.toDouble(), + hrvBaseline: (json['hrvBaseline'] as num?)?.toDouble(), + hrvScore: json['hrvScore'] as int?, + computedAt: DateTime.parse(json['computedAt'] as String), + ); +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index b55a3e9..fc25b62 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -19,6 +19,7 @@ import 'analytics_screen.dart'; import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; import 'ai_coach_screen.dart'; +import 'widgets/readiness_card.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; @@ -205,6 +206,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 24), _buildStreakHero(context: context, provider: provider, homeState: homeState), const SizedBox(height: 16), + const ReadinessCard(), _buildStatsGrid(context, provider), const SizedBox(height: 16), _buildHeatmapCard(context, provider), diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index ebb0b9c..d987348 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -1,5 +1,6 @@ // profile_screen.dart — User preferences, data management, and about +import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -16,6 +17,7 @@ import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; +import '../services/managers/readiness_manager.dart'; import '../theme/app_theme.dart'; import 'widgets/profile_sections.dart'; @@ -32,6 +34,7 @@ class _ProfileScreenState extends State bool _isImporting = false; bool _isBackingUp = false; bool _isRequestingHcPermission = false; + bool _isRequestingReadinessPermission = false; String _appVersion = ''; @override @@ -62,21 +65,31 @@ class _ProfileScreenState extends State Future _reconcileHealthConnectState() async { if (!mounted) return; final settings = context.read(); - if (!settings.healthConnectEnabled) return; + if (!settings.healthConnectEnabled && !settings.readinessEnabled) return; try { final hc = context.read(); final available = await hc.isAvailable(); if (!available) { if (mounted) await settings.setHealthConnectEnabled(false); + if (mounted) await settings.setReadinessEnabled(false); return; } - final hasPerms = await hc.hasPermissions(); - if (!hasPerms) { - if (mounted) await settings.setHealthConnectEnabled(false); + if (settings.healthConnectEnabled) { + final hasPerms = await hc.hasPermissions(); + if (!hasPerms && mounted) { + await settings.setHealthConnectEnabled(false); + } + } + if (settings.readinessEnabled) { + final granted = await hc.grantedReadTypes(); + if (granted.isEmpty && mounted) { + await settings.setReadinessEnabled(false); + } } } catch (e) { debugPrint('HC reconciliation error: $e'); if (mounted) await settings.setHealthConnectEnabled(false); + if (mounted) await settings.setReadinessEnabled(false); } } @@ -124,6 +137,56 @@ class _ProfileScreenState extends State } } + Future _requestReadinessPermission() async { + setState(() => _isRequestingReadinessPermission = true); + try { + final hc = context.read(); + final available = await hc.isAvailable(); + if (!available) { + if (mounted) { + _showSnack( + 'Health Connect is not available on this device.', + AppColors.error, + ); + } + return; + } + + // Any single granted read type is enough — readiness components + // degrade independently when data is missing. + var granted = await hc.grantedReadTypes(); + if (granted.isEmpty) { + try { + await hc.requestReadPermissions(); + } catch (_) { + // Fall through to re-check below. + } + granted = await hc.grantedReadTypes(); + } + + if (!mounted) return; + if (granted.isNotEmpty) { + final settings = context.read(); + await settings.setReadinessEnabled(true); + if (!mounted) return; + // Compute the first snapshot right away so the home card appears. + unawaited(context.read().refresh(force: true)); + _showSnack('Readiness insights enabled!', AppColors.success); + } else { + _showSnack( + 'Open Health Connect → App permissions → RepForge and allow Sleep and Heart rate.', + AppColors.warning, + ); + } + } catch (e) { + if (mounted) { + _showSnack('Could not connect to Health Connect.', AppColors.error); + } + } finally { + if (mounted) setState(() => _isRequestingReadinessPermission = false); + } + } + Future _exportToFile() async { setState(() => _isExporting = true); try { @@ -296,6 +359,14 @@ class _ProfileScreenState extends State await settings.setHealthConnectEnabled(false); } }, + isReadinessLoading: _isRequestingReadinessPermission, + onReadinessToggle: (value) async { + if (value) { + await _requestReadinessPermission(); + } else { + await settings.setReadinessEnabled(false); + } + }, ), const SizedBox(height: AppSpacing.md), DataManagementSection( diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index d86bfd0..2b5858c 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -237,11 +237,15 @@ class HealthConnectSection extends StatelessWidget { required this.settings, required this.isLoading, required this.onToggle, + required this.isReadinessLoading, + required this.onReadinessToggle, }); final SettingsProvider settings; final bool isLoading; final Future Function(bool) onToggle; + final bool isReadinessLoading; + final Future Function(bool) onReadinessToggle; static const _hcColor = Color(0xFF00BFA5); @@ -303,6 +307,43 @@ class HealthConnectSection extends StatelessWidget { ], ), ], + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Readiness insights', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Reads sleep & heart data to score daily recovery', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: settings.readinessEnabled, + onChanged: + isReadinessLoading ? null : (v) => onReadinessToggle(v), + activeThumbColor: _hcColor, + activeTrackColor: _hcColor.withValues(alpha: 0.35), + ), + ], + ), ], ), ); diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart new file mode 100644 index 0000000..25c3fdc --- /dev/null +++ b/workout-logger/lib/screens/widgets/readiness_card.dart @@ -0,0 +1,313 @@ +// ReadinessCard — daily training-readiness summary on the dashboard. +// +// Self-hiding: renders nothing until ReadinessManager has a scored snapshot, +// so the dashboard needs no conditional logic and users without watch data +// (or with the feature disabled) never see an empty state. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/interfaces/readiness_manager_interface.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class ReadinessCard extends StatelessWidget { + const ReadinessCard({super.key}); + + // Per-component color thresholds, aligned with ReadinessCalculator bands. + static const int _goodScore = 75; + static const int _okScore = 50; + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snapshot = manager.snapshot; + if (manager.status != ReadinessStatus.ready || + snapshot == null || + snapshot.score == null || + snapshot.band == null) { + return const SizedBox.shrink(); + } + + final color = _bandColor(snapshot.band!); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + glowColor: color, + onTap: () => _showDetails(context, snapshot), + semanticsLabel: 'Readiness ${snapshot.score} out of 100', + child: Row( + children: [ + _ScoreRing(score: snapshot.score!, color: color), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headline(snapshot.band!), + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 3), + Text( + _subtitle(snapshot), + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 20, + ), + ], + ), + ), + ); + } + + static Color _bandColor(ReadinessBand band) => switch (band) { + ReadinessBand.high => AppColors.success, + ReadinessBand.moderate => AppColors.warning, + ReadinessBand.low => AppColors.error, + }; + + static String _headline(ReadinessBand band) => switch (band) { + ReadinessBand.high => 'Primed — good day to push', + ReadinessBand.moderate => 'Train as planned', + ReadinessBand.low => 'Take it easy today', + }; + + /// One line of evidence from the weakest available component. + static String _subtitle(ReadinessSnapshot s) { + final parts = <(int, String)>[ + if (s.sleepScore != null) + ( + s.sleepScore!, + 'Sleep ${_fmtSleep(s.sleepMinutes!)} vs ${_fmtSleep(s.sleepBaselineMinutes!.round())} avg' + ), + if (s.rhrScore != null) + ( + s.rhrScore!, + 'Resting HR ${s.restingHr!.round()} vs ${s.rhrBaseline!.round()} avg' + ), + if (s.hrvScore != null) + ( + s.hrvScore!, + 'HRV ${s.hrvMs!.round()}ms vs ${s.hrvBaseline!.round()}ms avg' + ), + ]; + parts.sort((a, b) => a.$1.compareTo(b.$1)); + return parts.first.$2; + } + + static String _fmtSleep(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return m == 0 ? '${h}h' : '${h}h ${m.toString().padLeft(2, '0')}m'; + } + + void _showDetails(BuildContext context, ReadinessSnapshot snapshot) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => _ReadinessDetailsSheet(snapshot: snapshot), + ); + } +} + +class _ScoreRing extends StatelessWidget { + const _ScoreRing({required this.score, required this.color}); + + final int score; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 52, + height: 52, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 52, + height: 52, + child: CircularProgressIndicator( + value: score / 100, + strokeWidth: 4, + strokeCap: StrokeCap.round, + backgroundColor: AppColors.glass3, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + Text( + '$score', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _ReadinessDetailsSheet extends StatelessWidget { + const _ReadinessDetailsSheet({required this.snapshot}); + + final ReadinessSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final time = TimeOfDay.fromDateTime(snapshot.computedAt).format(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Readiness · ${snapshot.score}', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + 'As of $time, from your watch via Health Connect', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 18), + if (snapshot.sleepScore != null) + _ComponentRow( + label: 'Sleep', + value: + '${ReadinessCard._fmtSleep(snapshot.sleepMinutes!)} · avg ${ReadinessCard._fmtSleep(snapshot.sleepBaselineMinutes!.round())}', + score: snapshot.sleepScore!, + ), + if (snapshot.rhrScore != null) + _ComponentRow( + label: 'Resting heart rate', + value: + '${snapshot.restingHr!.round()} bpm · avg ${snapshot.rhrBaseline!.round()} bpm', + score: snapshot.rhrScore!, + ), + if (snapshot.hrvScore != null) + _ComponentRow( + label: 'HRV (RMSSD)', + value: + '${snapshot.hrvMs!.round()} ms · avg ${snapshot.hrvBaseline!.round()} ms', + score: snapshot.hrvScore!, + ), + const SizedBox(height: 14), + Text( + 'Each factor compares last night and this morning to your own ' + '14-day average — only dips below your normal lower the score. ' + 'Accuracy improves after about 5 nights of watch data.', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} + +class _ComponentRow extends StatelessWidget { + const _ComponentRow({ + required this.label, + required this.value, + required this.score, + }); + + final String label; + final String value; + final int score; + + @override + Widget build(BuildContext context) { + final color = score >= ReadinessCard._goodScore + ? AppColors.success + : score >= ReadinessCard._okScore + ? AppColors.warning + : AppColors.error; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + value, + style: GoogleFonts.geistMono( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: score / 100, + minHeight: 5, + backgroundColor: AppColors.glass2, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 275b1e4..cb9601a 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -96,6 +96,130 @@ class HealthConnectService implements IHealthConnectService { } } + static final Map _readPermissions = { + HealthReadType.sleep: HealthDataType.sleepSession.readPermission, + HealthReadType.heartRate: HealthDataType.heartRate.readPermission, + HealthReadType.restingHeartRate: + HealthDataType.restingHeartRate.readPermission, + HealthReadType.hrv: HealthDataType.heartRateVariabilityRMSSD.readPermission, + }; + + @override + Future requestReadPermissions() async { + try { + _connector ??= await HealthConnector.create(); + final results = await _connector! + .requestPermissions(_readPermissions.values.toList()); + return results.any((r) => r.status == PermissionStatus.granted); + } catch (e) { + debugPrint('Health Connect requestReadPermissions failed: $e'); + return false; + } + } + + @override + Future> grantedReadTypes() async { + try { + _connector ??= await HealthConnector.create(); + final granted = {}; + for (final entry in _readPermissions.entries) { + final status = await _connector!.getPermissionStatus(entry.value); + if (status == PermissionStatus.granted) granted.add(entry.key); + } + return granted; + } catch (e) { + debugPrint('Health Connect grantedReadTypes failed: $e'); + return const {}; + } + } + + @override + Future> readSleepSessions( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.sleepSession.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + return response.records + .map((r) => SleepPeriod(start: r.startTime, end: r.endTime)) + .toList(); + } catch (e) { + debugPrint('Health Connect readSleepSessions failed: $e'); + return const []; + } + } + + @override + Future> readRestingHeartRate( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.restingHeartRate.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + return response.records + .map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute)) + .toList(); + } catch (e) { + debugPrint('Health Connect readRestingHeartRate failed: $e'); + return const []; + } + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.heartRateVariabilityRMSSD.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + return response.records + .map((r) => HealthSample(time: r.time, value: r.rmssd.inMilliseconds)) + .toList(); + } catch (e) { + debugPrint('Health Connect readHrvRmssd failed: $e'); + return const []; + } + } + + @override + Future> readHeartRateSamples( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.heartRate.readInTimeRange( + startTime: start, + endTime: end, + // Minute-level data over a narrow morning window; one page suffices. + pageSize: 5000, + ), + ); + return response.records + .map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute)) + .toList(); + } catch (e) { + debugPrint('Health Connect readHeartRateSamples failed: $e'); + return const []; + } + } + @override Future syncWorkoutSession(WorkoutSession session, {String? title}) async { try { diff --git a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart index 14b6c9a..ae62973 100644 --- a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart +++ b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart @@ -1,8 +1,27 @@ import '../../models/models.dart'; +/// Read-side Health Connect data categories used for readiness scoring. +enum HealthReadType { sleep, heartRate, restingHeartRate, hrv } + abstract class IHealthConnectService { Future isAvailable(); Future requestPermissions(); Future hasPermissions(); Future syncWorkoutSession(WorkoutSession session, {String? title}); + + /// Requests all readiness read permissions (sleep, HR, resting HR, HRV) + /// in one dialog. Returns true if at least one was granted — partial + /// grants are usable because readiness components are independent. + Future requestReadPermissions(); + + /// The subset of readiness read permissions currently granted. + Future> grantedReadTypes(); + + Future> readSleepSessions(DateTime start, DateTime end); + Future> readRestingHeartRate(DateTime start, DateTime end); + Future> readHrvRmssd(DateTime start, DateTime end); + + /// Raw heart-rate samples. Only used as a morning-RHR fallback over a + /// narrow window when no [readRestingHeartRate] records exist. + Future> readHeartRateSamples(DateTime start, DateTime end); } diff --git a/workout-logger/lib/services/interfaces/interfaces.dart b/workout-logger/lib/services/interfaces/interfaces.dart index 55819d6..ce56ee8 100644 --- a/workout-logger/lib/services/interfaces/interfaces.dart +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -8,3 +8,4 @@ export 'storage_service_interface.dart'; export 'ml_service_interface.dart'; export 'health_connect_service_interface.dart'; export 'health_sync_manager_interface.dart'; +export 'readiness_manager_interface.dart'; diff --git a/workout-logger/lib/services/interfaces/readiness_manager_interface.dart b/workout-logger/lib/services/interfaces/readiness_manager_interface.dart new file mode 100644 index 0000000..7ea4741 --- /dev/null +++ b/workout-logger/lib/services/interfaces/readiness_manager_interface.dart @@ -0,0 +1,25 @@ +// Readiness Manager Interface (Dependency Inversion Principle) +// +// Abstracts daily readiness computation from Health Connect sleep/heart data. +// UI widgets depend on this abstraction so the data source and scoring can be +// swapped or mocked in tests. + +import '../../models/models.dart'; + +enum ReadinessStatus { idle, loading, ready, noData } + +/// Contract for computing and caching the user's daily readiness score. +abstract class IReadinessManager { + ReadinessStatus get status; + + /// Today's readiness, or null when nothing has been computed yet. + ReadinessSnapshot? get snapshot; + + /// Recomputes today's readiness from Health Connect. + /// + /// - No-op when the readiness setting is disabled. + /// - Serves a same-day cached snapshot (within a freshness TTL) unless + /// [force] is true. + /// - Never throws: any failure results in [ReadinessStatus.noData]. + Future refresh({bool force = false}); +} diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index fb59b87..621badc 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -18,3 +18,4 @@ export 'analytics_manager.dart'; export 'program_manager.dart'; export 'health_sync_manager.dart'; export 'pr_manager.dart'; +export 'readiness_manager.dart'; diff --git a/workout-logger/lib/services/managers/readiness_manager.dart b/workout-logger/lib/services/managers/readiness_manager.dart new file mode 100644 index 0000000..2a90ae6 --- /dev/null +++ b/workout-logger/lib/services/managers/readiness_manager.dart @@ -0,0 +1,259 @@ +// Readiness Manager (Single Responsibility Principle) +// +// Owns the daily readiness slice of state: reads sleep/heart data from +// Health Connect, maintains a rolling 14-day personal baseline (recomputed +// at most once per day), scores today via ReadinessCalculator, and caches +// the result in settings storage so the home screen renders instantly. +// +// Failure policy: this feature is strictly additive — every error path +// degrades to ReadinessStatus.noData and never throws or blocks app init. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../models/models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/readiness_manager_interface.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../settings_provider.dart'; +import '../utils/readiness_calculator.dart'; + +class ReadinessManager extends ChangeNotifier implements IReadinessManager { + final IHealthConnectService _hc; + final IStorageService _storage; + final SettingsProvider _settings; + final ReadinessCalculator _calculator; + + static const _snapshotKey = 'readiness.snapshot'; + static const _baselineKey = 'readiness.baseline'; + static const _snapshotTtl = Duration(minutes: 30); + static const _baselineDays = 14; + + ReadinessStatus _status = ReadinessStatus.idle; + ReadinessSnapshot? _snapshot; + + ReadinessManager( + this._hc, + this._storage, + this._settings, { + ReadinessCalculator calculator = const ReadinessCalculator(), + }) : _calculator = calculator; + + @override + ReadinessStatus get status => _status; + + @override + ReadinessSnapshot? get snapshot => _snapshot; + + @override + Future refresh({bool force = false}) async { + if (!_settings.readinessEnabled) return; + + try { + final now = DateTime.now(); + final todayKey = ReadinessCalculator.dateKey(now); + + final cached = await _loadSnapshot(); + if (cached != null && cached.dateKey == todayKey) { + // Same-day cache renders immediately; skip the re-fetch inside TTL. + _snapshot = cached; + _status = ReadinessStatus.ready; + notifyListeners(); + if (!force && now.difference(cached.computedAt) < _snapshotTtl) return; + } + + final granted = await _hc.grantedReadTypes(); + if (granted.isEmpty) { + _setNoData(); + return; + } + + final baseline = await _baselineFor(todayKey, now, granted); + final snapshot = _calculator.compute( + today: now, + baseline: baseline, + lastNightSleepMinutes: await _lastNightSleepMinutes(now, granted), + todayRestingHr: await _todayRestingHr(now, granted), + todayHrvMs: await _todayHrv(now, granted), + ); + + if (snapshot.score == null) { + _setNoData(); + return; + } + + _snapshot = snapshot; + _status = ReadinessStatus.ready; + await _storage.saveSetting(_snapshotKey, jsonEncode(snapshot.toJson())); + notifyListeners(); + } catch (e) { + debugPrint('ReadinessManager: refresh failed: $e'); + _setNoData(); + } + } + + void _setNoData() { + _snapshot = null; + _status = ReadinessStatus.noData; + notifyListeners(); + } + + Future _loadSnapshot() async { + try { + final raw = await _storage.getSetting(_snapshotKey); + if (raw == null) return null; + return ReadinessSnapshot.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + return null; + } + } + + /// Returns the cached baseline when it was already computed today, + /// otherwise rebuilds it from the trailing [_baselineDays] window + /// (excluding last night / today, which are what we score). + Future _baselineFor( + String todayKey, + DateTime now, + Set granted, + ) async { + try { + final raw = await _storage.getSetting(_baselineKey); + if (raw != null) { + final cached = + ReadinessBaseline.fromJson(jsonDecode(raw) as Map); + if (cached.dateKey == todayKey) return cached; + } + } catch (_) { + // Corrupt cache — fall through to recompute. + } + + final day = DateTime(now.year, now.month, now.day); + final windowStart = day.subtract(const Duration(days: _baselineDays)); + + double? avgSleep; + var sleepNights = 0; + if (granted.contains(HealthReadType.sleep)) { + // End the window at yesterday 18:00 so last night isn't in its own baseline. + final periods = await _hc.readSleepSessions( + windowStart, + day.subtract(const Duration(hours: 6)), + ); + final nightly = _nightlySleepMinutes(periods); + sleepNights = nightly.length; + if (sleepNights > 0) { + avgSleep = nightly.reduce((a, b) => a + b) / sleepNights; + } + } + + double? avgRhr; + var rhrDays = 0; + if (granted.contains(HealthReadType.restingHeartRate)) { + final samples = await _hc.readRestingHeartRate(windowStart, day); + final daily = _dailyAverages(samples); + rhrDays = daily.length; + if (rhrDays > 0) avgRhr = daily.reduce((a, b) => a + b) / rhrDays; + } + + double? avgHrv; + var hrvDays = 0; + if (granted.contains(HealthReadType.hrv)) { + final samples = await _hc.readHrvRmssd(windowStart, day); + final daily = _dailyAverages(samples); + hrvDays = daily.length; + if (hrvDays > 0) avgHrv = daily.reduce((a, b) => a + b) / hrvDays; + } + + final baseline = ReadinessBaseline( + dateKey: todayKey, + avgSleepMinutes: avgSleep, + sleepNights: sleepNights, + avgRestingHr: avgRhr, + rhrDays: rhrDays, + avgHrvMs: avgHrv, + hrvDays: hrvDays, + ); + await _storage.saveSetting(_baselineKey, jsonEncode(baseline.toJson())); + return baseline; + } + + /// One value per night: the longest sleep period attributed to the day it + /// ends on, so split records don't count as separate nights. + List _nightlySleepMinutes(List periods) { + final byNight = {}; + for (final p in periods) { + final key = ReadinessCalculator.dateKey(p.end); + final minutes = p.minutes; + if (minutes > (byNight[key] ?? 0)) byNight[key] = minutes; + } + return byNight.values.map((m) => m.toDouble()).toList(); + } + + /// One average per calendar day a sample exists on. + List _dailyAverages(List samples) { + final sums = {}; + final counts = {}; + for (final s in samples) { + final key = ReadinessCalculator.dateKey(s.time); + sums[key] = (sums[key] ?? 0) + s.value; + counts[key] = (counts[key] ?? 0) + 1; + } + return sums.entries.map((e) => e.value / counts[e.key]!).toList(); + } + + Future _lastNightSleepMinutes( + DateTime now, + Set granted, + ) async { + if (!granted.contains(HealthReadType.sleep)) return null; + final day = DateTime(now.year, now.month, now.day); + final periods = await _hc.readSleepSessions( + day.subtract(const Duration(hours: 6)), + day.add(const Duration(hours: 12)), + ); + return _calculator.lastNightSleep(now, periods)?.minutes; + } + + /// Latest resting-HR record in the past 24h; falls back to the minimum + /// raw heart-rate sample between 02:00–10:00 today. The fallback is the + /// only minute-level query and only runs when no RHR record exists. + Future _todayRestingHr( + DateTime now, + Set granted, + ) async { + if (granted.contains(HealthReadType.restingHeartRate)) { + final samples = await _hc.readRestingHeartRate( + now.subtract(const Duration(hours: 24)), + now, + ); + if (samples.isNotEmpty) { + samples.sort((a, b) => a.time.compareTo(b.time)); + return samples.last.value; + } + } + if (granted.contains(HealthReadType.heartRate)) { + final day = DateTime(now.year, now.month, now.day); + final samples = await _hc.readHeartRateSamples( + day.add(const Duration(hours: 2)), + day.add(const Duration(hours: 10)), + ); + if (samples.isNotEmpty) { + return samples.map((s) => s.value).reduce((a, b) => a < b ? a : b); + } + } + return null; + } + + Future _todayHrv(DateTime now, Set granted) async { + if (!granted.contains(HealthReadType.hrv)) return null; + final samples = await _hc.readHrvRmssd( + now.subtract(const Duration(hours: 24)), + now, + ); + if (samples.isEmpty) return null; + samples.sort((a, b) => a.time.compareTo(b.time)); + return samples.last.value; + } +} diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index d295d65..164df92 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -12,6 +12,7 @@ class SettingsProvider extends ChangeNotifier { WeightUnit _weightUnit = WeightUnit.kg; double _weightIncrement = 2.5; bool _healthConnectEnabled = false; + bool _readinessEnabled = false; String? _userName; String? _lastSeenVersion; String _geminiApiKey = ''; @@ -24,6 +25,7 @@ class SettingsProvider extends ChangeNotifier { double get weightIncrement => _weightIncrement; String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; bool get healthConnectEnabled => _healthConnectEnabled; + bool get readinessEnabled => _readinessEnabled; String? get userName => _userName; String? get lastSeenVersion => _lastSeenVersion; String get geminiApiKey => _geminiApiKey; @@ -46,6 +48,9 @@ class SettingsProvider extends ChangeNotifier { final hcEnabled = await _storage.getSetting('healthConnectEnabled'); _healthConnectEnabled = hcEnabled == 'true'; + final readiness = await _storage.getSetting('readinessEnabled'); + _readinessEnabled = readiness == 'true'; + _userName = await _storage.getSetting('userName'); _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; @@ -101,6 +106,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setReadinessEnabled(bool enabled) async { + _readinessEnabled = enabled; + await _storage.saveSetting('readinessEnabled', enabled.toString()); + notifyListeners(); + } + Future setGeminiModel(String model) async { _geminiModel = model; await _storage.saveSetting('geminiModel', model); diff --git a/workout-logger/lib/services/utils/readiness_calculator.dart b/workout-logger/lib/services/utils/readiness_calculator.dart new file mode 100644 index 0000000..7087748 --- /dev/null +++ b/workout-logger/lib/services/utils/readiness_calculator.dart @@ -0,0 +1,135 @@ +// Readiness Calculator (pure, no I/O) +// +// Scores today's training readiness against the user's own rolling baseline. +// Each component (sleep, resting HR, HRV) is scored 0–100 independently and +// only penalizes adverse deviation — being at or better than baseline is 100. +// The overall score is a weighted average renormalized over the components +// that are actually available, so sleep-only users get a first-class score. + +import '../../models/models.dart'; + +class ReadinessCalculator { + const ReadinessCalculator(); + + /// Minimum baseline samples before a component participates in scoring. + static const int minBaselineSamples = 5; + + /// Component weights, renormalized over available components. + static const double sleepWeight = 0.5; + static const double rhrWeight = 0.3; + static const double hrvWeight = 0.2; + + /// Sleep under this many minutes is capped at [shortSleepMaxScore] + /// regardless of the user's baseline (guards chronically short baselines). + static const int shortSleepMinutes = 300; + static const int shortSleepMaxScore = 40; + + static const int highBandThreshold = 75; + static const int moderateBandThreshold = 50; + + /// Picks "last night's" sleep: the longest period overlapping the window + /// yesterday 18:00 → today 12:00 local. Returns null when nothing overlaps. + SleepPeriod? lastNightSleep(DateTime today, List periods) { + final day = DateTime(today.year, today.month, today.day); + final windowStart = day.subtract(const Duration(hours: 6)); // 18:00 prev day + final windowEnd = day.add(const Duration(hours: 12)); + + SleepPeriod? longest; + for (final p in periods) { + if (!p.end.isAfter(windowStart) || !p.start.isBefore(windowEnd)) continue; + if (longest == null || p.minutes > longest.minutes) longest = p; + } + return longest; + } + + ReadinessSnapshot compute({ + required DateTime today, + required ReadinessBaseline baseline, + int? lastNightSleepMinutes, + double? todayRestingHr, + double? todayHrvMs, + }) { + final sleepBaseline = + baseline.sleepNights >= minBaselineSamples ? baseline.avgSleepMinutes : null; + final rhrBaseline = + baseline.rhrDays >= minBaselineSamples ? baseline.avgRestingHr : null; + final hrvBaseline = + baseline.hrvDays >= minBaselineSamples ? baseline.avgHrvMs : null; + + final sleepScore = _sleepScore(lastNightSleepMinutes, sleepBaseline); + final rhrScore = _rhrScore(todayRestingHr, rhrBaseline); + final hrvScore = _hrvScore(todayHrvMs, hrvBaseline); + + int? score; + ReadinessBand? band; + var weighted = 0.0; + var totalWeight = 0.0; + if (sleepScore != null) { + weighted += sleepScore * sleepWeight; + totalWeight += sleepWeight; + } + if (rhrScore != null) { + weighted += rhrScore * rhrWeight; + totalWeight += rhrWeight; + } + if (hrvScore != null) { + weighted += hrvScore * hrvWeight; + totalWeight += hrvWeight; + } + if (totalWeight > 0) { + score = (weighted / totalWeight).round().clamp(0, 100); + band = score >= highBandThreshold + ? ReadinessBand.high + : score >= moderateBandThreshold + ? ReadinessBand.moderate + : ReadinessBand.low; + } + + return ReadinessSnapshot( + dateKey: dateKey(today), + score: score, + band: band, + sleepMinutes: sleepScore != null ? lastNightSleepMinutes : null, + sleepBaselineMinutes: sleepScore != null ? sleepBaseline : null, + sleepScore: sleepScore, + restingHr: rhrScore != null ? todayRestingHr : null, + rhrBaseline: rhrScore != null ? rhrBaseline : null, + rhrScore: rhrScore, + hrvMs: hrvScore != null ? todayHrvMs : null, + hrvBaseline: hrvScore != null ? hrvBaseline : null, + hrvScore: hrvScore, + ); + } + + // Every 10% of sleep below the personal average costs 20 points. + int? _sleepScore(int? minutes, double? avgMinutes) { + if (minutes == null || avgMinutes == null || avgMinutes <= 0) return null; + final ratio = minutes / avgMinutes; + var score = (100 - _adverse(1 - ratio) * 200).round().clamp(0, 100); + if (minutes < shortSleepMinutes && score > shortSleepMaxScore) { + score = shortSleepMaxScore; + } + return score; + } + + // Elevated resting HR is the penalty: +10% over baseline scores 50. + int? _rhrScore(double? rhr, double? avgRhr) { + if (rhr == null || avgRhr == null || avgRhr <= 0) return null; + final deviation = (rhr - avgRhr) / avgRhr; + return (100 - _adverse(deviation) * 500).round().clamp(0, 100); + } + + // Suppressed HRV is the penalty: −20% under baseline scores 50. + int? _hrvScore(double? hrv, double? avgHrv) { + if (hrv == null || avgHrv == null || avgHrv <= 0) return null; + final ratio = hrv / avgHrv; + return (100 - _adverse(1 - ratio) * 250).round().clamp(0, 100); + } + + double _adverse(double deviation) => deviation > 0 ? deviation : 0; + + static String dateKey(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; +} diff --git a/workout-logger/test/health_sync_manager_test.dart b/workout-logger/test/health_sync_manager_test.dart index 9597f8d..279432f 100644 --- a/workout-logger/test/health_sync_manager_test.dart +++ b/workout-logger/test/health_sync_manager_test.dart @@ -28,6 +28,28 @@ class _MockHcService implements IHealthConnectService { @override Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => false; + + @override + Future> grantedReadTypes() async => const {}; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => + const []; + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => + const []; + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => + const []; + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + const []; + @override Future syncWorkoutSession( WorkoutSession session, { diff --git a/workout-logger/test/readiness_calculator_test.dart b/workout-logger/test/readiness_calculator_test.dart new file mode 100644 index 0000000..0642e05 --- /dev/null +++ b/workout-logger/test/readiness_calculator_test.dart @@ -0,0 +1,221 @@ +// Unit tests for ReadinessCalculator (pure scoring logic) + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/utils/readiness_calculator.dart'; + +void main() { + const calc = ReadinessCalculator(); + final today = DateTime(2026, 6, 10, 8); // 08:00 local + + ReadinessBaseline baseline({ + double? sleep = 420, // 7h average + int sleepNights = 14, + double? rhr = 55, + int rhrDays = 14, + double? hrv = 60, + int hrvDays = 14, + }) => + ReadinessBaseline( + dateKey: '2026-06-10', + avgSleepMinutes: sleep, + sleepNights: sleepNights, + avgRestingHr: rhr, + rhrDays: rhrDays, + avgHrvMs: hrv, + hrvDays: hrvDays, + ); + + group('component formulas', () { + test('at-baseline values all score 100 and band is high', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 420, + todayRestingHr: 55, + todayHrvMs: 60, + ); + expect(s.sleepScore, 100); + expect(s.rhrScore, 100); + expect(s.hrvScore, 100); + expect(s.score, 100); + expect(s.band, ReadinessBand.high); + }); + + test('better-than-baseline values are not rewarded above 100', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 540, // way over average + todayRestingHr: 48, // lower (better) than baseline + todayHrvMs: 90, // higher (better) than baseline + ); + expect(s.score, 100); + }); + + test('sleep at 75% of average scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 315, // 420 * 0.75 + ); + expect(s.sleepScore, 50); + }); + + test('resting HR +10% over baseline scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + todayRestingHr: 60.5, // 55 * 1.10 + ); + expect(s.rhrScore, 50); + }); + + test('HRV −20% under baseline scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + todayHrvMs: 48, // 60 * 0.8 + ); + expect(s.hrvScore, 50); + }); + + test('extreme deviations clamp at 0', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 60, + todayRestingHr: 90, + todayHrvMs: 10, + ); + expect(s.sleepScore, 0); + expect(s.rhrScore, 0); + expect(s.hrvScore, 0); + expect(s.score, 0); + expect(s.band, ReadinessBand.low); + }); + + test('short absolute sleep is capped even with a short baseline', () { + // 280 min sleep vs a 290 min average would naively score ~93. + final s = calc.compute( + today: today, + baseline: baseline(sleep: 290), + lastNightSleepMinutes: 280, + ); + expect(s.sleepScore, ReadinessCalculator.shortSleepMaxScore); + }); + }); + + group('weighting and partial data', () { + test('weights renormalize: sleep-only score equals sleep score', () { + final s = calc.compute( + today: today, + baseline: baseline(rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 315, // sleep score 50 + ); + expect(s.score, 50); + expect(s.rhrScore, isNull); + expect(s.hrvScore, isNull); + }); + + test('sleep+RHR uses 0.5/0.3 weights renormalized', () { + final s = calc.compute( + today: today, + baseline: baseline(hrv: null, hrvDays: 0), + lastNightSleepMinutes: 315, // 50 + todayRestingHr: 55, // 100 + ); + // (50*0.5 + 100*0.3) / 0.8 = 68.75 → 69 + expect(s.score, 69); + expect(s.band, ReadinessBand.moderate); + }); + + test('component with fewer than 5 baseline samples is excluded', () { + final s = calc.compute( + today: today, + baseline: baseline(sleepNights: 4), + lastNightSleepMinutes: 100, // would tank the score if included + todayRestingHr: 55, + ); + expect(s.sleepScore, isNull); + expect(s.sleepMinutes, isNull); + expect(s.score, 100); // RHR only + }); + + test('no scorable components yields null score and band', () { + final s = calc.compute( + today: today, + baseline: const ReadinessBaseline(dateKey: '2026-06-10'), + lastNightSleepMinutes: 400, + ); + expect(s.score, isNull); + expect(s.band, isNull); + }); + }); + + group('bands', () { + test('75 is high and 74 is moderate', () { + // sleep ratio 0.875 → score 75 + final high = calc.compute( + today: today, + baseline: baseline(rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: (420 * 0.875).round(), + ); + expect(high.score, 75); + expect(high.band, ReadinessBand.high); + + final moderate = calc.compute( + today: today, + baseline: baseline(sleep: 400, rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 348, // ratio 0.87 → 74 + ); + expect(moderate.score, 74); + expect(moderate.band, ReadinessBand.moderate); + }); + + test('49 is low', () { + final s = calc.compute( + today: today, + baseline: baseline(sleep: 480, rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 358, // ratio ~0.746 → 49, above short-sleep cap + ); + expect(s.score, 49); + expect(s.band, ReadinessBand.low); + }); + }); + + group('lastNightSleep', () { + test('picks the longest period overlapping the night window', () { + final periods = [ + // 90-min nap yesterday afternoon — outside window + SleepPeriod( + start: DateTime(2026, 6, 9, 14), + end: DateTime(2026, 6, 9, 15, 30), + ), + // Main sleep 23:00–06:30 + SleepPeriod( + start: DateTime(2026, 6, 9, 23), + end: DateTime(2026, 6, 10, 6, 30), + ), + // Short morning doze + SleepPeriod( + start: DateTime(2026, 6, 10, 7), + end: DateTime(2026, 6, 10, 7, 45), + ), + ]; + final picked = calc.lastNightSleep(today, periods); + expect(picked, isNotNull); + expect(picked!.minutes, 450); + }); + + test('returns null when nothing overlaps the window', () { + final periods = [ + SleepPeriod( + start: DateTime(2026, 6, 7, 23), + end: DateTime(2026, 6, 8, 7), + ), + ]; + expect(calc.lastNightSleep(today, periods), isNull); + }); + }); +} diff --git a/workout-logger/test/readiness_manager_test.dart b/workout-logger/test/readiness_manager_test.dart new file mode 100644 index 0000000..1af1c7c --- /dev/null +++ b/workout-logger/test/readiness_manager_test.dart @@ -0,0 +1,341 @@ +// Unit tests for ReadinessManager (orchestration, caching, degradation) + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/interfaces/readiness_manager_interface.dart'; +import 'package:repforge/services/managers/readiness_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/utils/readiness_calculator.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +class _MockHcService implements IHealthConnectService { + Set granted; + List sleepPeriods; + List restingHr; + List hrv = const []; + List heartRate; + bool shouldThrow; + + int grantedCallCount = 0; + int sleepReadCount = 0; + int rhrReadCount = 0; + int hrvReadCount = 0; + int hrReadCount = 0; + + _MockHcService({ + this.granted = const {}, + this.sleepPeriods = const [], + this.restingHr = const [], + this.heartRate = const [], + this.shouldThrow = false, + }); + + void _maybeThrow() { + if (shouldThrow) throw Exception('mock HC error'); + } + + @override + Future isAvailable() async => true; + + @override + Future requestPermissions() async => true; + + @override + Future hasPermissions() async => true; + + @override + Future requestReadPermissions() async => granted.isNotEmpty; + + @override + Future> grantedReadTypes() async { + _maybeThrow(); + grantedCallCount++; + return granted; + } + + @override + Future> readSleepSessions(DateTime start, DateTime end) async { + _maybeThrow(); + sleepReadCount++; + return sleepPeriods + .where((p) => p.end.isAfter(start) && p.start.isBefore(end)) + .toList(); + } + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async { + _maybeThrow(); + rhrReadCount++; + return restingHr + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + _maybeThrow(); + hrvReadCount++; + return hrv + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async { + _maybeThrow(); + hrReadCount++; + return heartRate + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => + true; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/// 15 nights of 23:00–06:00 sleep (420 min each): 14 baseline nights plus +/// last night, which the manager scores against that baseline. +List _twoWeeksOfSleep(DateTime now) { + final day = DateTime(now.year, now.month, now.day); + return [ + for (var i = 0; i <= 14; i++) + SleepPeriod( + start: day.subtract(Duration(days: i)).subtract(const Duration(hours: 1)), + end: day.subtract(Duration(days: i)).add(const Duration(hours: 6)), + ), + ]; +} + +List _dailyRhr(DateTime now, double value, {double? todayValue}) { + final day = DateTime(now.year, now.month, now.day); + return [ + for (var i = 1; i <= 14; i++) + HealthSample(time: day.subtract(Duration(days: i, hours: -7)), value: value), + // "Today's" reading is stamped at test-setup time so it always falls + // inside the manager's trailing-24h query regardless of wall clock. + if (todayValue != null) HealthSample(time: now, value: todayValue), + ]; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late SettingsProvider settings; + + Future makeManager( + _MockHcService hc, { + bool enabled = true, + }) async { + storage = MockStorageService(); + settings = SettingsProvider(storage); + if (enabled) await storage.saveSetting('readinessEnabled', 'true'); + await settings.init(); + return ReadinessManager(hc, storage, settings); + } + + group('ReadinessManager.refresh', () { + test('is a no-op when the readiness setting is disabled', () async { + final hc = _MockHcService(granted: {HealthReadType.sleep}); + final manager = await makeManager(hc, enabled: false); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.idle); + expect(hc.grantedCallCount, 0); + }); + + test('goes to noData when no read permissions are granted', () async { + final hc = _MockHcService(); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + expect(manager.snapshot, isNull); + }); + + test('computes a sleep-only snapshot with partial permissions', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.ready); + final s = manager.snapshot!; + expect(s.sleepScore, isNotNull); + expect(s.rhrScore, isNull); + expect(s.hrvScore, isNull); + expect(s.score, isNotNull); + // Persisted for instant render next launch. + final cached = await storage.getSetting('readiness.snapshot'); + expect(cached, isNotNull); + }); + + test('serves the same-day cache inside the TTL without re-fetching', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + final fetchesAfterFirst = hc.sleepReadCount; + await manager.refresh(); + + expect(hc.sleepReadCount, fetchesAfterFirst); + expect(manager.status, ReadinessStatus.ready); + }); + + test('force=true bypasses the snapshot cache', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + final fetchesAfterFirst = hc.sleepReadCount; + await manager.refresh(force: true); + + expect(hc.sleepReadCount, greaterThan(fetchesAfterFirst)); + }); + + test('reuses the same-day baseline instead of recomputing', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + // First refresh: 1 baseline read + 1 last-night read. + expect(hc.sleepReadCount, 2); + await manager.refresh(force: true); + // Forced refresh re-reads last night only — baseline is cached for today. + expect(hc.sleepReadCount, 3); + }); + + test('uses latest resting HR record and skips the minute-level fallback', + () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: { + HealthReadType.restingHeartRate, + HealthReadType.heartRate, + }, + restingHr: _dailyRhr(now, 55, todayValue: 60.5), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.snapshot!.restingHr, 60.5); + expect(manager.snapshot!.rhrScore, 50); + expect(hc.hrReadCount, 0); + }); + + test('falls back to minimum morning heart rate when no RHR record today', + () async { + final now = DateTime.now(); + final day = DateTime(now.year, now.month, now.day); + final hc = _MockHcService( + granted: { + HealthReadType.restingHeartRate, + HealthReadType.heartRate, + }, + // Baseline records exist on past days but none in the last 24h. + restingHr: _dailyRhr(day.subtract(const Duration(days: 2)), 55), + heartRate: [ + HealthSample(time: day.add(const Duration(hours: 3)), value: 62), + HealthSample(time: day.add(const Duration(hours: 4)), value: 55), + HealthSample(time: day.add(const Duration(hours: 5)), value: 58), + ], + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(hc.hrReadCount, 1); + expect(manager.snapshot?.restingHr, 55); + }); + + test('goes to noData when permissions exist but no data is scorable', + () async { + final hc = _MockHcService(granted: {HealthReadType.sleep}); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + expect(manager.snapshot, isNull); + }); + + test('never throws: HC errors degrade to noData', () async { + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + shouldThrow: true, + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + }); + + test('ignores a corrupt cached snapshot', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + await storage.saveSetting('readiness.snapshot', 'not json'); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.ready); + }); + + test('discards a stale snapshot from a previous day', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + final yesterday = now.subtract(const Duration(days: 1)); + await storage.saveSetting( + 'readiness.snapshot', + jsonEncode( + ReadinessSnapshot( + dateKey: ReadinessCalculator.dateKey(yesterday), + score: 12, + band: ReadinessBand.low, + computedAt: yesterday, + ).toJson(), + ), + ); + + await manager.refresh(); + + expect(manager.snapshot!.dateKey, ReadinessCalculator.dateKey(now)); + expect(manager.snapshot!.score, isNot(12)); + }); + }); +} From 2757a90d90f503ee0f242a51b0076582a2000caf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:53:25 +0000 Subject: [PATCH 2/7] feat: upgrade growth model to robust curve-fitting with saturation detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single exponentially-weighted linear regression with a two-candidate fit chosen by weighted residual error: - Linear and logarithmic (y = a + b·ln(1+x)) candidates — the log curve captures the diminishing returns muscle growth actually follows instead of promising linear gains forever; only eligible with >=6 points over >=14 days and a 2% RSS margin to prevent flip-flopping - One Tukey-bisquare robust re-weighting pass per candidate so a single deload or cut-short session no longer tilts the trend - GrowthModel now carries curve type, coefficient, lastX and a weighted residual stdError; slope is the instantaneous per-day rate at the newest point, so all existing consumers stay semantically correct - New weeklyGrowthPercent (growth relative to current level) drives scale-independent plateau/decline detection: same thresholds work for a novice bench and a 10t weekly squat volume - recommendSets adds a deload branch (~10% back-off, plate-rounded) when volume is genuinely regressing; trend signals require r2 > 0.2 - predictTargetCompletion inverts the fitted curve (log-aware), caps predictions at 2 years; confidence interval now derived from stdError converted to days instead of an ad-hoc R² heuristic - Volume chart trend line now evaluates the model at day offsets instead of session indices (pre-existing mismatch); per-muscle trend arrows use relative weekly growth; mislabeled kg/session displays fixed to kg/week - AI coach growth payload exposes curve, weekly_growth_percent and a plateauing/declining/improving trend https://claude.ai/code/session_01FTgsHTfbvXsxwTUe74UrYe --- workout-logger/lib/models/models.dart | 52 +++- .../screens/widgets/analytics_overview.dart | 9 +- .../widgets/exercise_details_sheet.dart | 2 +- .../widgets/exercise_progress_view.dart | 20 +- .../lib/services/ai/coach_tool_service.dart | 10 +- workout-logger/lib/services/ml_service.dart | 269 +++++++++++++++--- workout-logger/test/ml_service_test.dart | 190 +++++++++++++ 7 files changed, 502 insertions(+), 50 deletions(-) diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 3c02343..4d655a4 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -1,5 +1,7 @@ // Data Models for Workout Logger App +import 'dart:math' show log, max; + import 'package:uuid/uuid.dart'; // Sentinel value for copyWith methods to distinguish "not provided" from "null" @@ -401,21 +403,61 @@ class SetRecommendation { // ==================== Growth Model ==================== +/// Functional form of a fitted growth curve. +/// +/// - [linear]: steady volume gains (typical for newer lifters / new exercises) +/// - [logarithmic]: diminishing returns, y = a + b·ln(1+x) — typical as an +/// exercise matures and progress saturates +enum GrowthCurve { linear, logarithmic } + class GrowthModel { - final double slope; // Growth rate per session - final double intercept; // Starting baseline + /// Instantaneous growth rate (volume per day) at the most recent data point. + /// For linear fits this equals the curve coefficient; for logarithmic fits + /// it is the tangent slope b/(1+lastX), which decays as training history grows. + final double slope; + final double intercept; // Curve intercept a final double r2; // Model fit quality (0-1) final DateTime lastTrained; + final GrowthCurve curve; + + /// Curve coefficient b. Equals [slope] for linear fits. + final double coefficient; + + /// x (days since first session) of the newest point used in training. + final double lastX; + + /// Weighted residual standard error in volume units (0 = unknown/perfect). + final double stdError; GrowthModel({ required this.slope, required this.intercept, required this.r2, required this.lastTrained, - }); + this.curve = GrowthCurve.linear, + double? coefficient, + this.lastX = 0, + this.stdError = 0, + }) : coefficient = coefficient ?? slope; + + double predict(num x) { + switch (curve) { + case GrowthCurve.linear: + return intercept + coefficient * x; + case GrowthCurve.logarithmic: + return intercept + coefficient * log(1 + max(0, x.toDouble())); + } + } + + /// Model's volume estimate at the newest training point ("today's level"). + double get currentEstimate => predict(lastX); - double predict(int sessionNumber) { - return slope * sessionNumber + intercept; + /// Expected volume growth over the next 7 days as a percentage of the + /// current level. The plateau/decline signal used by recommendations. + double get weeklyGrowthPercent { + final current = currentEstimate; + if (current <= 0) return 0; + return slope * 7 / current * 100; } } diff --git a/workout-logger/lib/screens/widgets/analytics_overview.dart b/workout-logger/lib/screens/widgets/analytics_overview.dart index 509b488..8c4065f 100644 --- a/workout-logger/lib/screens/widgets/analytics_overview.dart +++ b/workout-logger/lib/screens/widgets/analytics_overview.dart @@ -514,13 +514,16 @@ class _MuscleFocusRow extends StatelessWidget { if (model == null) { return (color: AppColors.textFaint, icon: Icons.remove_rounded); } - if (model.slope > 2) { + // Relative weekly growth so small muscles (low effective volume) use the + // same bar as large ones — +2 %/week is strong progress on any muscle. + final weekly = model.weeklyGrowthPercent; + if (weekly > 2) { return (color: AppColors.success, icon: Icons.trending_up_rounded); } - if (model.slope > 0) { + if (weekly > 0.5) { return (color: AppColors.secondary, icon: Icons.trending_up_rounded); } - if (model.slope < -2) { + if (weekly < -2) { return (color: AppColors.error, icon: Icons.trending_down_rounded); } return (color: AppColors.warning, icon: Icons.trending_flat_rounded); diff --git a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart index 5c1d51e..2ae2d08 100644 --- a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart @@ -231,7 +231,7 @@ class ExerciseDetailsSheet extends StatelessWidget { const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - '+${settings.toDisplay(growthModel.slope).toStringAsFixed(1)} ${settings.unitLabel} volume/session', + '+${settings.toDisplay(growthModel.slope * 7).toStringAsFixed(1)} ${settings.unitLabel} volume/week', style: const TextStyle( color: AppColors.success, fontSize: 13, diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart index 640a664..db7508a 100644 --- a/workout-logger/lib/screens/widgets/exercise_progress_view.dart +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -586,7 +586,7 @@ class _GrowthCard extends StatelessWidget { ), Text( isGrowing - ? '+${settings.toDisplay(model.slope.abs()).toStringAsFixed(1)} ${settings.unitLabel}/session' + ? '+${settings.toDisplay(model.slope.abs() * 7).toStringAsFixed(1)} ${settings.unitLabel}/week' : 'Volume trend is flat', style: GoogleFonts.geist( color: AppColors.textSoft, @@ -734,11 +734,20 @@ class _VolumeChart extends StatelessWidget { final settings = context.read(); final n = progression.length; + // Chart x is the session index, but the model is trained on days since + // the first session — map each index to its day offset before predicting. + double dayAt(int i) => progression[i] + .date + .difference(progression.first.date) + .inDays + .toDouble(); + final avgGapDays = n > 1 ? dayAt(n - 1) / (n - 1) : 7.0; + double rse = 0.0; if (growthModel != null && n >= 3) { double ssRes = 0.0; for (int i = 0; i < n; i++) { - final r = progression[i].volume - growthModel!.predict(i); + final r = progression[i].volume - growthModel!.predict(dayAt(i)); ssRes += r * r; } rse = sqrt(ssRes / (n - 2)); @@ -757,8 +766,9 @@ class _VolumeChart extends StatelessWidget { n + 2, (i) => FlSpot( i.toDouble(), - settings.toDisplay( - growthModel!.predict(i).clamp(0.0, double.infinity)), + settings.toDisplay(growthModel! + .predict(i < n ? dayAt(i) : dayAt(n - 1) + avgGapDays * (i - n + 1)) + .clamp(0.0, double.infinity)), ), ) : []; @@ -1493,7 +1503,7 @@ class _AskCoachButton extends StatelessWidget { if (!gemini.isConfigured) return const SizedBox.shrink(); final isPlateauing = - growthModel != null && growthModel!.slope <= 0; + growthModel != null && growthModel!.weeklyGrowthPercent < 0.5; final seed = isPlateauing ? 'I\'ve been plateauing on $exerciseName. How can I break through and start progressing again?' : 'How can I continue to progress on $exerciseName and make the most of my current momentum?'; diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index 61e93c1..9ff867c 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -300,13 +300,15 @@ class CoachToolService { 'growth': growth == null ? null : { - 'slope_per_session': _round(growth.slope), + 'slope_per_day': _round(growth.slope), + 'weekly_growth_percent': _round(growth.weeklyGrowthPercent), + 'curve': growth.curve.name, 'r2': _round(growth.r2), - 'trend': growth.slope > 0 + 'trend': growth.weeklyGrowthPercent > 0.5 ? 'improving' - : growth.slope < 0 + : growth.weeklyGrowthPercent < -2 ? 'declining' - : 'flat', + : 'plateauing', }, 'best_estimated_1rm': _roundOrNull(_wp.getBestOneRM(exercise.id)), 'last_session': lastLog == null diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index 1b75327..ffae2a4 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -4,13 +4,33 @@ import 'interfaces/ml_service_interface.dart'; export 'interfaces/ml_service_interface.dart' show DataPoint, MuscleRecoveryStatus; -/// Exponentially-weighted linear regression + double-progression recommendations -/// + per-muscle recovery scoring. +/// Growth modelling + double-progression recommendations + per-muscle +/// recovery scoring. +/// +/// Growth model: exponentially-weighted least squares fit of two candidate +/// curves — linear and logarithmic (saturating) — each refined with one +/// robust (Tukey bisquare) re-weighting pass so single outlier sessions +/// (deloads, cut-short workouts) don't tilt the trend. The better-fitting +/// curve wins; the logarithmic form captures the diminishing returns real +/// muscle growth follows, which a straight line systematically overshoots. class MLService implements IMLService { // Decay constant for recency weights. At λ=0.15, a session 10 sessions ago // carries exp(−1.5) ≈ 22 % of the weight of the most recent session. static const _lambda = 0.15; + // Logarithmic candidate is considered only with enough history for + // curvature to be identifiable; over short spans log ≈ linear. + static const _minPointsForLogCurve = 6; + static const _minSpanDaysForLogCurve = 14.0; + + // The log curve must beat linear by this fraction of weighted RSS to win, + // preventing flip-flopping between near-identical fits. + static const _logSelectionMargin = 0.02; + + // Robust pass: points beyond c·σ̂ get fully rejected by Tukey's bisquare. + static const _tukeyC = 4.685; + static const _minPointsForRobustPass = 5; + // Recovery time constants τ (hours) per muscle group. // Full recovery (~95 %) occurs at ≈ 3τ. static const _tauHours = { @@ -39,8 +59,9 @@ class MLService implements IMLService { return MLService.trainGrowthModelStatic(dataPoints); } - /// Exponentially-weighted least squares. - /// Weight for point i (0-indexed, n total): exp(−λ · (n−1−i)). + /// Fits linear and logarithmic candidates with exponential recency weights + /// (weight for point i of n: exp(−λ·(n−1−i))) plus one robust re-weighting + /// pass each, then selects the better curve by weighted residual error. static GrowthModel trainGrowthModelStatic(List dataPoints) { if (dataPoints.isEmpty) { return GrowthModel(slope: 0, intercept: 0, r2: 0, lastTrained: DateTime.now()); @@ -51,50 +72,149 @@ class MLService implements IMLService { intercept: dataPoints.first.y, r2: 1, lastTrained: DateTime.now(), + lastX: dataPoints.first.x, ); } final n = dataPoints.length; - final weights = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); + final recency = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); + final xs = dataPoints.map((p) => p.x).toList(); + final ys = dataPoints.map((p) => p.y).toList(); + final lastX = xs.reduce(max); + final spanDays = lastX - xs.reduce(min); + + final linear = _robustWeightedFit(xs, ys, recency); + + _Fit? logFit; + if (n >= _minPointsForLogCurve && spanDays >= _minSpanDaysForLogCurve) { + final logXs = xs.map((x) => log(1 + max(0.0, x))).toList(); + logFit = _robustWeightedFit(logXs, ys, recency); + } + + final useLog = logFit != null && + logFit.rss < linear.rss * (1 - _logSelectionMargin); + final fit = useLog ? logFit : linear; + final curve = useLog ? GrowthCurve.logarithmic : GrowthCurve.linear; + + // Instantaneous daily rate at the newest point: d/dx [a + b·ln(1+x)]. + final slope = useLog ? fit.slope / (1 + lastX) : fit.slope; + + return GrowthModel( + slope: slope, + intercept: fit.intercept, + r2: fit.r2.clamp(0.0, 1.0), + lastTrained: DateTime.now(), + curve: curve, + coefficient: fit.slope, + lastX: lastX, + stdError: fit.stdError, + ); + } + + /// Weighted least squares with one Tukey-bisquare re-weighting pass. + /// + /// The robust pass estimates residual scale via the weighted MAD, then + /// refits with outliers down-weighted by (1 − (r/cσ̂)²)², so a single + /// deload or cut-short session cannot tilt the trend. Skipped for tiny + /// samples or when residuals are too uniform to identify outliers. + static _Fit _robustWeightedFit( + List xs, + List ys, + List recency, + ) { + var fit = _weightedLeastSquares(xs, ys, recency); + + if (xs.length < _minPointsForRobustPass) return fit; + + final residuals = [ + for (var i = 0; i < xs.length; i++) + (ys[i] - (fit.intercept + fit.slope * xs[i])).abs(), + ]; + final mad = _median(residuals); + if (mad <= 0) return fit; + final scale = 1.4826 * mad; // MAD → σ̂ for normal residuals + + final robust = []; + for (var i = 0; i < xs.length; i++) { + final u = residuals[i] / (_tukeyC * scale); + final tukey = u >= 1 ? 0.0 : pow(1 - u * u, 2).toDouble(); + robust.add(recency[i] * tukey); + } + // Refit only if the pass actually rejected/damped something and enough + // effective weight survives to keep the fit identifiable. + final kept = robust.where((w) => w > 0).length; + if (kept < 3) return fit; + final refit = _weightedLeastSquares(xs, ys, robust); + return refit.degenerate ? fit : refit; + } + + static _Fit _weightedLeastSquares( + List xs, + List ys, + List weights, + ) { + final n = xs.length; final wSum = weights.fold(0.0, (s, w) => s + w); double wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0; for (var i = 0; i < n; i++) { final w = weights[i]; - final x = dataPoints[i].x; - final y = dataPoints[i].y; - wSumX += w * x; - wSumY += w * y; - wSumXY += w * x * y; - wSumX2 += w * x * x; + wSumX += w * xs[i]; + wSumY += w * ys[i]; + wSumXY += w * xs[i] * ys[i]; + wSumX2 += w * xs[i] * xs[i]; } final denom = wSum * wSumX2 - wSumX * wSumX; - if (denom == 0) { - return GrowthModel(slope: 0, intercept: wSumY / wSum, r2: 0, lastTrained: DateTime.now()); + if (denom.abs() < 1e-12 || wSum <= 0) { + final mean = wSum > 0 ? wSumY / wSum : 0.0; + return _Fit( + slope: 0, + intercept: mean, + r2: 0, + rss: double.infinity, + stdError: 0, + degenerate: true, + ); } final slope = (wSum * wSumXY - wSumX * wSumY) / denom; final intercept = (wSumY - slope * wSumX) / wSum; final yBar = wSumY / wSum; - double ssTotal = 0, ssResidual = 0; + double ssTotal = 0, ssResidual = 0, wSqSum = 0; for (var i = 0; i < n; i++) { final w = weights[i]; - final predicted = slope * dataPoints[i].x + intercept; - ssTotal += w * pow(dataPoints[i].y - yBar, 2); - ssResidual += w * pow(dataPoints[i].y - predicted, 2); + final predicted = slope * xs[i] + intercept; + ssTotal += w * pow(ys[i] - yBar, 2); + ssResidual += w * pow(ys[i] - predicted, 2); + wSqSum += w * w; } - final r2 = ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0; - return GrowthModel( + // Weighted mean squared residual, dof-corrected via the Kish effective + // sample size (recency weights make n optimistic). + final nEff = wSqSum > 0 ? (wSum * wSum) / wSqSum : 0.0; + final dof = max(1.0, nEff - 2); + final stdError = sqrt(max(0.0, ssResidual / wSum) * (nEff / dof)); + + return _Fit( slope: slope, intercept: intercept, - r2: r2.clamp(0.0, 1.0), - lastTrained: DateTime.now(), + r2: ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0, + rss: ssResidual, + stdError: stdError, + degenerate: false, ); } + static double _median(List values) { + final sorted = List.from(values)..sort(); + final mid = sorted.length ~/ 2; + return sorted.length.isOdd + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + } + // ==================== DATA EXTRACTION ==================== /// x = days since first session for this exercise, y = total volume. @@ -215,13 +335,25 @@ class MLService implements IMLService { // ==================== RECOMMENDATIONS ==================== - /// Double-progression with optional recovery awareness. + // Weekly relative growth thresholds (% of current volume per week). + // Below _plateauWeeklyPct the curve is effectively flat; below + // _declineWeeklyPct volume is genuinely regressing and a deload pays off. + static const _plateauWeeklyPct = 0.5; + static const _declineWeeklyPct = -2.0; + static const _minR2ForTrendSignal = 0.2; + + /// Double-progression with trend- and recovery-aware modulation. /// /// Priority order: /// 1. Under-recovered primary muscle → maintenance (hold weight & reps). - /// 2. Plateau (model slope ≤ 0, R² > 0.25) → maintenance. - /// 3. reps ≥ maxReps → bump weight, reset to minReps. - /// 4. Otherwise → add 1 rep, hold weight. + /// 2. Decline (weekly growth < −2 %, trustworthy fit) → 10 % deload. + /// 3. Plateau (weekly growth < 0.5 %, trustworthy fit) → maintenance. + /// 4. reps ≥ maxReps → bump weight, reset to minReps. + /// 5. Otherwise → add 1 rep, hold weight. + /// + /// Trend checks use [GrowthModel.weeklyGrowthPercent] — growth relative to + /// the lifter's current volume — so the same thresholds work for a 60 kg + /// novice bench and a 10 t weekly squat volume. @override List recommendSets({ required List lastSession, @@ -233,9 +365,12 @@ class MLService implements IMLService { }) { if (lastSession.isEmpty) return []; - final isPlateau = growthModel != null && - growthModel.slope <= 0 && - growthModel.r2 > 0.25; + final trendIsTrustworthy = + growthModel != null && growthModel.r2 > _minR2ForTrendSignal; + final weeklyPct = trendIsTrustworthy ? growthModel.weeklyGrowthPercent : null; + final isDeclining = weeklyPct != null && weeklyPct < _declineWeeklyPct; + final isPlateau = + weeklyPct != null && !isDeclining && weeklyPct < _plateauWeeklyPct; final isUnderRecovered = primaryMuscleIds != null && recoveryScores != null && @@ -255,6 +390,7 @@ class MLService implements IMLService { minReps: minReps, maxReps: maxReps, isPlateau: isPlateau, + isDeclining: isDeclining, isUnderRecovered: isUnderRecovered, recoveryPercent: worstRecovery, )) @@ -266,6 +402,7 @@ class MLService implements IMLService { required int minReps, required int maxReps, required bool isPlateau, + required bool isDeclining, required bool isUnderRecovered, int? recoveryPercent, }) { @@ -279,6 +416,18 @@ class MLService implements IMLService { ); } + if (isDeclining) { + // Round the deload to the plate increment users can actually load. + final deloaded = max(0.0, ((set.weight * 0.9) / 2.5).round() * 2.5); + return SetRecommendation( + weight: deloaded, + reps: set.reps, + confidence: 'medium', + reasoning: + 'Volume trending down — deload ~10% for a session or two, then rebuild', + ); + } + if (isPlateau) { return SetRecommendation( weight: set.weight, @@ -322,7 +471,15 @@ class MLService implements IMLService { // ==================== TARGET PREDICTIONS ==================== - /// Slope is volume/day (x = days since first session). + // Predictions further out than this are noise, not information. + static const _maxPredictionDays = 365 * 2; + + /// Projects the fitted curve forward to the target (x = days). + /// + /// Linear fits extrapolate at the constant rate; logarithmic fits invert + /// the curve, so the flattening trajectory honestly pushes the date out + /// instead of promising linear gains forever. Predictions beyond two years + /// return null — too uncertain to show. @override DateTime? predictTargetCompletion({ required double currentValue, @@ -332,11 +489,33 @@ class MLService implements IMLService { }) { if (currentValue >= targetValue) return DateTime.now(); if (growthModel.slope <= 0) return null; - final days = ((targetValue - currentValue) / growthModel.slope).ceil(); - return DateTime.now().add(Duration(days: days)); + + final double daysFromNow; + switch (growthModel.curve) { + case GrowthCurve.linear: + daysFromNow = (targetValue - currentValue) / growthModel.slope; + case GrowthCurve.logarithmic: + // Map the live current value and the target through the curve's + // inverse x(y) = exp((y−a)/b) − 1 and take the day difference, so + // drift between the live value and the fitted curve cancels out. + final b = growthModel.coefficient; + if (b <= 0) return null; + final xTarget = exp((targetValue - growthModel.intercept) / b) - 1; + final xCurrent = exp((currentValue - growthModel.intercept) / b) - 1; + daysFromNow = xTarget - xCurrent; + } + + if (daysFromNow <= 0) return DateTime.now(); + if (!daysFromNow.isFinite || daysFromNow > _maxPredictionDays) return null; + return DateTime.now().add(Duration(days: daysFromNow.ceil())); } /// Confidence interval around the predicted completion date. + /// + /// Width comes from the model's residual standard error converted to days + /// at the current growth rate (± how long the typical session-to-session + /// scatter could shift the crossing point), falling back to an R²-scaled + /// margin for legacy models without a stored error. static ({DateTime optimistic, DateTime expected, DateTime pessimistic})? predictTargetWithConfidence({ required double currentValue, @@ -353,7 +532,14 @@ class MLService implements IMLService { if (expected == null) return null; final daysToTarget = expected.difference(DateTime.now()).inDays; - final uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); + final int uncertainty; + if (growthModel.stdError > 0 && growthModel.slope > 0) { + uncertainty = (growthModel.stdError / growthModel.slope) + .ceil() + .clamp(0, max(1, daysToTarget)); + } else { + uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); + } return ( optimistic: expected.subtract(Duration(days: uncertainty)), expected: expected, @@ -361,3 +547,22 @@ class MLService implements IMLService { ); } } + +/// Internal weighted-least-squares result for one candidate curve. +class _Fit { + final double slope; + final double intercept; + final double r2; + final double rss; // weighted residual sum of squares (selection criterion) + final double stdError; + final bool degenerate; + + const _Fit({ + required this.slope, + required this.intercept, + required this.r2, + required this.rss, + required this.stdError, + required this.degenerate, + }); +} diff --git a/workout-logger/test/ml_service_test.dart b/workout-logger/test/ml_service_test.dart index c33f4cd..ff68406 100644 --- a/workout-logger/test/ml_service_test.dart +++ b/workout-logger/test/ml_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' show log; + import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/ml_service.dart'; @@ -91,6 +93,97 @@ void main() { final expected = model.slope * 3 + model.intercept; expect(model.predict(3), closeTo(expected, 0.001)); }); + + test('linear data over a long span still selects the linear curve', () { + // 10 sessions spread over 63 days — log candidate is eligible but + // must not beat a genuinely linear trend. + final points = List.generate(10, (i) => dp(i * 7.0, 100 + 8.0 * i)); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.linear); + expect(model.r2, closeTo(1.0, 0.01)); + }); + + test('saturating data selects the logarithmic curve', () { + // y = 100 + 80·ln(1+x): fast early gains, then diminishing returns. + final points = List.generate(12, (i) { + final x = i * 5.0; + return dp(x, 100 + 80 * log(1 + x)); + }); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.logarithmic); + expect(model.r2, greaterThan(0.95)); + // predict() reproduces the generating curve. + expect(model.predict(30), closeTo(100 + 80 * log(31), 5.0)); + // Instantaneous slope at the newest point is the tangent, far below + // the early-history rate a linear fit would average in. + expect(model.slope, closeTo(80 / (1 + 55), 0.5)); + }); + + test('log curve is not considered for short histories', () { + // Strongly saturating but only 5 points over 8 days. + final points = List.generate(5, (i) { + final x = i * 2.0; + return dp(x, 100 + 80 * log(1 + x)); + }); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.linear); + }); + + test('a single deload outlier does not tilt the trend (robust pass)', () { + // Clean linear trend with one cut-short session at 40% volume. + final clean = List.generate(10, (i) => dp(i * 7.0, 200 + 5.0 * i * 7)); + final withOutlier = List.of(clean)..[5] = dp(35, (200 + 5.0 * 35) * 0.4); + + final robust = ml.trainGrowthModel(withOutlier); + final reference = ml.trainGrowthModel(clean); + // Slope recovered to within 10% of the outlier-free fit. + expect( + robust.slope, + closeTo(reference.slope, reference.slope.abs() * 0.10), + ); + }); + + test('model exposes lastX and a positive stdError on noisy data', () { + final points = [ + dp(0, 100), + dp(7, 130), + dp(14, 118), + dp(21, 150), + dp(28, 141), + dp(35, 168), + ]; + final model = ml.trainGrowthModel(points); + expect(model.lastX, 35); + expect(model.stdError, greaterThan(0)); + }); + }); + + group('GrowthModel - derived metrics', () { + test('weeklyGrowthPercent is growth relative to current level', () { + final model = GrowthModel( + slope: 2.0, // +2 volume/day + intercept: 600.0, + r2: 0.9, + lastTrained: DateTime.now(), + lastX: 50, + ); + // current = 600 + 2·50 = 700; weekly = 14/700 = 2% + expect(model.currentEstimate, closeTo(700, 0.001)); + expect(model.weeklyGrowthPercent, closeTo(2.0, 0.001)); + }); + + test('legacy four-field constructor stays linear and backward compatible', + () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + expect(model.curve, GrowthCurve.linear); + expect(model.coefficient, 5.0); + expect(model.predict(3), closeTo(115.0, 0.001)); + }); }); group('MLService - recommendSets', () { @@ -139,6 +232,43 @@ void main() { expect(recs.first.confidence, 'medium'); }); + test('declining trend → ~10% deload rounded to 2.5 kg', () { + final set = wset(weight: 100.0, reps: 8); + final decliningModel = GrowthModel( + slope: -3.0, // −21/week on ~600 volume ≈ −3.5%/week + intercept: 600.0, + r2: 0.8, + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: decliningModel, + maxReps: 12, + ); + expect(recs.first.weight, closeTo(90.0, 0.001)); + expect(recs.first.reps, 8); + expect(recs.first.confidence, 'medium'); + expect(recs.first.reasoning, contains('deload')); + }); + + test('untrustworthy fit (low r2) never triggers plateau or deload', () { + final set = wset(weight: 60.0, reps: 10); + final noisyModel = GrowthModel( + slope: -5.0, + intercept: 600.0, + r2: 0.1, // below the trust threshold + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: noisyModel, + maxReps: 12, + ); + // Falls through to normal double progression. + expect(recs.first.reps, 11); + expect(recs.first.weight, closeTo(60.0, 0.001)); + }); + test('under-recovered muscle → maintenance recommendation (low confidence)', () { final set = wset(weight: 80.0, reps: 8); @@ -240,6 +370,66 @@ void main() { ); expect(result, isNotNull); }); + + test('logarithmic curve pushes the date out vs naive linear extrapolation', + () { + // Curve y = 100 + 80·ln(1+x), currently at x=55 (y ≈ 422). + final model = GrowthModel( + slope: 80 / 56, // tangent at x=55 + intercept: 100.0, + r2: 0.95, + lastTrained: DateTime.now(), + curve: GrowthCurve.logarithmic, + coefficient: 80.0, + lastX: 55, + ); + final current = model.currentEstimate; + final target = current + 50; + + final curveAware = ml.predictTargetCompletion( + currentValue: current, + targetValue: target, + growthModel: model, + )!; + // Exact inversion: Δx = (1+x)·(e^(50/80) − 1) ≈ 48.5 days, while the + // tangent rate promises 50/(80/56) = 35 days. + final days = curveAware.difference(DateTime.now()).inDays; + expect(days, greaterThan(40)); + expect(days, lessThan(55)); + }); + + test('returns null when the curve cannot reach the target within 2 years', + () { + final model = GrowthModel( + slope: 0.01, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 500.0, // 40,000 days away at 0.01/day + growthModel: model, + ); + expect(result, isNull); + }); + + test('confidence interval uses stdError when available', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + stdError: 25.0, // → ±5 days at 5 volume/day + ); + final result = MLService.predictTargetWithConfidence( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + )!; + expect(result.expected.difference(result.optimistic).inDays, 5); + expect(result.pessimistic.difference(result.expected).inDays, 5); + }); }); group('MLService - computeMuscleRecoveryScores', () { From ca274e9db6e4e58a546da7d8239799a8a0cd0988 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:38:49 +0530 Subject: [PATCH 3/7] docs: add Sleep HR chart design spec Covers SleepHrSnapshot data model, 10-min bar chart with stage colors, moving-average trend line, and the per-stage HR distribution (range bar) chart replacing the two REM/Deep comparison cards. Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-06-11-sleep-hr-chart-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md diff --git a/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md b/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md new file mode 100644 index 0000000..353d9dc --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md @@ -0,0 +1,209 @@ +# Sleep HR Chart — Design Spec + +**Date:** 2026-06-11 +**Status:** Approved +**Feature area:** Readiness → Sleep heart-rate visualization + +--- + +## 1. Problem + +The Readiness feature currently reads resting HR and sleep duration from Health Connect. HRV is unavailable (Samsung Health writer has no HRV permission on this device). Minute-level heart-rate data during sleep is already accessible via `heartRateSeries`, and sleep stage timeline data is already extracted per `SleepPeriod` (`lightMinutes`, `deepMinutes`, `remMinutes`, `awakeMinutes`). Neither is surfaced to the user. + +Users want to understand how their heart behaved overnight — specifically whether deep sleep reached a true low, whether REM stayed elevated, and what a clean P95 "resting proxy" looks like — without needing to open Samsung Health. + +--- + +## 2. Goal + +Two new surfaces: +1. **Compact card** on the home screen (below the readiness ring card) showing a sparkline + three key numbers. +2. **Full detail bottom sheet** accessible by tapping the compact card, showing: + - A 10-minute bar chart (low/high per segment, color-coded by sleep stage, moving-average trend line) + - A "HR range by stage" horizontal distribution chart (min–max + P25–P75 + avg for each of Awake, REM, Light, Deep) + +--- + +## 3. Data Models + +### 3.1 `SleepHrSegment` (new) + +Represents one 10-minute window of the sleep period. + +```dart +class SleepHrSegment { + final DateTime windowStart; // truncated to 10-min boundary + final int minBpm; + final int maxBpm; + final double avgBpm; + final String stage; // 'deep' | 'rem' | 'light' | 'awake' +} +``` + +### 3.2 `SleepStageStats` (new) + +Aggregate stats for one stage, used by the distribution chart. + +```dart +class SleepStageStats { + final String stage; + final int minBpm; + final int p25Bpm; + final double avgBpm; + final int p75Bpm; + final int maxBpm; + final int sampleCount; +} +``` + +### 3.3 `SleepHrSnapshot` (new) + +Container stored in `ReadinessManager` and passed to both widgets. + +```dart +class SleepHrSnapshot { + final DateTime sleepStart; + final DateTime sleepEnd; + final int p95Bpm; // P95 of all overnight HR samples + final List segments; // ordered by windowStart + final List stageStats; // one entry per stage present +} +``` + +No persistence required — recomputed each `refresh()`. If the snapshot is null the compact card hides itself (`SizedBox.shrink()`). + +--- + +## 4. Data Pipeline + +### 4.1 New Health Connect service method + +```dart +// IHealthConnectService +Future> readHeartRateSamples(DateTime start, DateTime end); +// Already exists — no interface change needed. +``` + +`ReadinessManager.refresh()` calls `readHeartRateSamples(sleepStart - 30min, sleepEnd + 30min)` **only when** `HealthReadType.heartRate` is granted and at least one sleep period exists for last night. + +### 4.2 Stage assignment per sample + +Each `HealthSample` is tagged with the sleep stage active at its timestamp by walking the `SleepPeriod.samples` stage timeline (from `SleepSessionRecord.samples`, already loaded). Samples outside any stage window → tagged `'awake'`. + +### 4.3 Segment aggregation + +Samples are bucketed into 10-minute windows aligned to `sleepStart`. For each window: `minBpm`, `maxBpm`, `avgBpm` are computed. The stage for the window is the **mode** of sample stages in that window (most-frequent). Windows with zero samples are omitted. + +### 4.4 P95 and stage stats + +- **P95:** Sort all sample bpms → take the value at index `floor(0.95 * n)`. +- **Stage stats:** Group samples by stage → compute min, P25, avg, P75, max via sort-and-index. + +### 4.5 Where it lives in `ReadinessManager` + +```dart +SleepHrSnapshot? _sleepHrSnapshot; +SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; +``` + +Computed and stored at the end of `refresh()`, alongside the readiness score. Triggers `notifyListeners()` once (same call as the score update). + +--- + +## 5. UI Components + +### 5.1 `SleepHrCard` (compact, home screen) + +**File:** `lib/screens/widgets/sleep_hr_card.dart` + +Layout: +``` +┌─────────────────────────────────┐ +│ Sleep heart rate 1:24–8:17 │ ← header row +│ P95 67bpm REM 64bpm Deep 52bpm│ ← three mini-stats +│ [sparkline bar chart] │ ← canvas, 38dp tall +└─────────────────────────────────┘ +``` + +- Tapping the card opens `SleepHrSheet` via `showModalBottomSheet`. +- Hidden (`SizedBox.shrink()`) when `snapshot.sleepHrSnapshot == null`. +- Placed in `HomeScreen` body, directly below `ReadinessCard`. + +### 5.2 `SleepHrSheet` (full detail bottom sheet) + +**File:** `lib/screens/widgets/sleep_hr_sheet.dart` + +Sections top → bottom: +1. **Handle + title + subtitle** ("Sleep heart rate · 1:24 AM – 8:17 AM") +2. **Three key stats** (P95 HR, Deep avg, REM avg) in pill chips +3. **Bar chart** — `CustomPainter`, 140dp tall + - Y-axis: BPM labels (50, 60, 70, 80) with horizontal grid lines + - X-axis: time labels every 60 min + - Each bar: low→high range, fill color = stage color at 73% opacity + - Moving-average line (window=5 segments): `#00D9FF`, dashed +4. **Stage timeline bar** — thin colored strip below chart, same proportions +5. **Legend** (Deep / REM / Light / Awake / Avg line) +6. **"HR range by stage" section** + - Title label + - Four horizontal range rows: Awake → REM → Light → Deep (top → bottom) + - Each row: full-range bar (22% opacity) + IQR bar (72% opacity) + avg dot + avg bpm label + - Shared BPM x-axis with vertical grid lines (45, 50 … 85) + - Sub-legend: min–max / P25–P75 / Avg + +Scrollable (`SingleChildScrollView`) so it fits all screen sizes. + +--- + +## 6. Painting Strategy + +Both the bar chart and the distribution chart use `CustomPainter` (not canvas HTML). Stage colors are sourced from a local constant map in the widget file; no dependency on `AppColors.muscleGroupColors`. + +Stage color map: +```dart +const _stageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; +``` + +--- + +## 7. Error / Empty States + +| Condition | Behavior | +|-----------|----------| +| `heartRate` not granted | `sleepHrSnapshot` = null → compact card hidden | +| Sleep period missing | `sleepHrSnapshot` = null → compact card hidden | +| < 5 HR samples in a segment | Segment omitted from chart | +| Stage has < 3 samples | `SleepStageStats` for that stage omitted from distribution | +| Sheet opened with null snapshot | Should not happen (card hidden); guard with early return | + +--- + +## 8. HRV Lookback Cleanup + +`_todayHrv()` in `ReadinessManager` currently uses a 30-day diagnostic window. This should be reverted to 48 hours once the Sleep HR feature ships (confirms the device never writes HRV, so the wide window has no ongoing value). + +--- + +## 9. Files Changed / Created + +| Action | File | +|--------|------| +| New | `lib/models/sleep_hr_models.dart` — `SleepHrSegment`, `SleepStageStats`, `SleepHrSnapshot` | +| Modified | `lib/services/managers/readiness_manager.dart` — add `_buildSleepHrSnapshot()`, store result | +| New | `lib/screens/widgets/sleep_hr_card.dart` | +| New | `lib/screens/widgets/sleep_hr_sheet.dart` | +| Modified | `lib/screens/home_screen.dart` (or equivalent) — insert `SleepHrCard` below `ReadinessCard` | +| Modified | `lib/services/managers/readiness_manager.dart` — revert HRV window to 48h | + +--- + +## 10. Out of Scope + +- Trend over multiple nights (tonight vs last 7 nights) — future feature +- Tap-to-see-segment detail in the bar chart — future feature +- P95 participating in the readiness score formula — deferred; it replaces HRV only if baseline data accumulates +- Exporting or sharing the chart From 8f17f1178d53c168869fd1bf53bf3fddfa6acc3c Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:18:15 +0530 Subject: [PATCH 4/7] feat: Implement DebugLogBuffer for capturing debug prints - Added DebugLogBuffer class to capture and store debugPrint calls in a circular buffer. - Integrated DebugLogBuffer into the main application via the attach method. refactor: Update GeminiContextBuilder instructions and workflow - Revised instructions for modifying user data and clarified the workflow steps. - Enhanced clarity on data fetching and analysis processes. fix: Improve HealthConnectService error handling and logging - Added detailed debug prints for HealthConnectService methods. - Adjusted permissions handling for heart rate data to use heartRateSeries. - Enhanced error handling for read permissions and data fetching. feat: Enhance ReadinessManager with sleep HR snapshot functionality - Integrated SleepHrSnapshot to track heart rate during sleep. - Improved refresh logic to build and display sleep HR data. - Added debug tracing for readiness calculations and data fetching. refactor: Update ReadinessCalculator for accurate sleep duration calculations - Modified lastNightSleep method to return total sleep minutes instead of the longest period. - Maintained backward compatibility with a synthetic SleepPeriod return. --- workout-logger/lib/main.dart | 2 + workout-logger/lib/models/models.dart | 55 +- .../lib/models/sleep_hr_models.dart | 83 ++ workout-logger/lib/screens/home_screen.dart | 2 + .../lib/screens/profile_screen.dart | 12 +- .../lib/screens/widgets/profile_sections.dart | 133 ++- .../lib/screens/widgets/readiness_card.dart | 21 +- .../lib/screens/widgets/sleep_hr_card.dart | 268 ++++++ .../lib/screens/widgets/sleep_hr_sheet.dart | 763 ++++++++++++++++++ .../lib/services/ai/coach_tool_service.dart | 190 ++++- .../lib/services/ai/gemini_ai_service.dart | 330 ++++++-- .../lib/services/debug_log_buffer.dart | 35 + .../lib/services/gemini_context_builder.dart | 45 +- .../lib/services/health_connect_service.dart | 130 ++- .../services/managers/readiness_manager.dart | 262 +++++- .../services/utils/readiness_calculator.dart | 24 +- 16 files changed, 2201 insertions(+), 154 deletions(-) create mode 100644 workout-logger/lib/models/sleep_hr_models.dart create mode 100644 workout-logger/lib/screens/widgets/sleep_hr_card.dart create mode 100644 workout-logger/lib/screens/widgets/sleep_hr_sheet.dart create mode 100644 workout-logger/lib/services/debug_log_buffer.dart diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 3561aa1..6da7812 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'services/debug_log_buffer.dart'; import 'services/storage_service.dart'; import 'services/ml_service.dart'; import 'services/ai/gemini_ai_service.dart'; @@ -29,6 +30,7 @@ import 'screens/home_screen.dart'; import 'screens/onboarding_screen.dart'; void main() async { + DebugLogBuffer.attach(); WidgetsFlutterBinding.ensureInitialized(); // Set preferred orientations diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 4d655a4..493013a 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -1037,14 +1037,65 @@ class HealthSample { const HealthSample({required this.time, required this.value}); } +/// One continuous sleep-stage segment within a `SleepPeriod`. +/// +/// Stage is one of: `'deep'`, `'rem'`, `'light'`, `'awake'`. +class SleepStageInterval { + final DateTime start; + final DateTime end; + final String stage; + + const SleepStageInterval({ + required this.start, + required this.end, + required this.stage, + }); +} + /// A sleep session interval read from Health Connect. +/// +/// When stage data is available (from `SleepSessionRecord.samples`), +/// `lightMinutes`, `deepMinutes`, `remMinutes`, and `awakeMinutes` are +/// populated and `minutes` returns actual sleep time (light + deep + rem), +/// excluding awake/out-of-bed spans. Without stage data `minutes` falls back +/// to the raw session duration. +/// +/// `stageTimeline` carries the ordered list of stage segments when available, +/// used by the Sleep HR chart to colour-code each 10-minute bar. class SleepPeriod { final DateTime start; final DateTime end; - const SleepPeriod({required this.start, required this.end}); + /// Minutes in light (or unspecified) sleep. Null when no stage data. + final int? lightMinutes; + final int? deepMinutes; + final int? remMinutes; + + /// Awake/out-of-bed minutes within the session window. + final int? awakeMinutes; + + /// Ordered stage segments, populated from `SleepSessionRecord.samples`. + /// Empty when the session record carries no stage breakdown. + final List stageTimeline; + + const SleepPeriod({ + required this.start, + required this.end, + this.lightMinutes, + this.deepMinutes, + this.remMinutes, + this.awakeMinutes, + this.stageTimeline = const [], + }); + + bool get hasStages => + lightMinutes != null || deepMinutes != null || remMinutes != null; - int get minutes => end.difference(start).inMinutes; + /// Actual sleep minutes: light + deep + rem when stage data exists, + /// otherwise the raw session span (start → end). + int get minutes => hasStages + ? (lightMinutes ?? 0) + (deepMinutes ?? 0) + (remMinutes ?? 0) + : end.difference(start).inMinutes; } /// Rolling per-component averages used as the personal reference point diff --git a/workout-logger/lib/models/sleep_hr_models.dart b/workout-logger/lib/models/sleep_hr_models.dart new file mode 100644 index 0000000..19db22e --- /dev/null +++ b/workout-logger/lib/models/sleep_hr_models.dart @@ -0,0 +1,83 @@ +/// Data models for the Sleep HR chart feature. +/// +/// These are computed at runtime from Health Connect HR + sleep-stage data +/// and are never persisted. If the snapshot is null the compact card hides. +library; + +/// One 10-minute window of overnight HR data, colour-coded by sleep stage. +class SleepHrSegment { + final DateTime windowStart; + + /// BPM floor of all samples in this window. + final int minBpm; + + /// BPM ceiling of all samples in this window. + final int maxBpm; + + /// Mean BPM across all samples in this window. + final double avgBpm; + + /// Dominant sleep stage: 'deep' | 'rem' | 'light' | 'awake'. + final String stage; + + const SleepHrSegment({ + required this.windowStart, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.stage, + }); +} + +/// Aggregate HR statistics for one sleep stage. +class SleepStageStats { + /// 'deep' | 'rem' | 'light' | 'awake' + final String stage; + final int minBpm; + final int p25Bpm; + final double avgBpm; + final int p75Bpm; + final int maxBpm; + final int sampleCount; + + const SleepStageStats({ + required this.stage, + required this.minBpm, + required this.p25Bpm, + required this.avgBpm, + required this.p75Bpm, + required this.maxBpm, + required this.sampleCount, + }); +} + +/// Complete overnight HR picture — carried by ReadinessManager and consumed +/// by SleepHrCard (compact) and SleepHrSheet (full detail). +class SleepHrSnapshot { + final DateTime sleepStart; + final DateTime sleepEnd; + + /// 5th-percentile — overnight HR floor. + final int p5Bpm; + + /// 95th-percentile of all overnight HR samples — used as an RHR proxy. + final int p95Bpm; + + /// 10-minute segments ordered chronologically. + final List segments; + + /// One entry per stage present (deep / rem / light / awake). + final List stageStats; + + const SleepHrSnapshot({ + required this.sleepStart, + required this.sleepEnd, + required this.p5Bpm, + required this.p95Bpm, + required this.segments, + required this.stageStats, + }); + + SleepStageStats? statsFor(String stage) => + stageStats.where((s) => s.stage == stage).firstOrNull; +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index fc25b62..cda82fb 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -20,6 +20,7 @@ import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; import 'ai_coach_screen.dart'; import 'widgets/readiness_card.dart'; +import 'widgets/sleep_hr_card.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; @@ -207,6 +208,7 @@ class _DashboardTab extends StatelessWidget { _buildStreakHero(context: context, provider: provider, homeState: homeState), const SizedBox(height: 16), const ReadinessCard(), + const SleepHrCard(), _buildStatsGrid(context, provider), const SizedBox(height: 16), _buildHeatmapCard(context, provider), diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index d987348..112c9bf 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -138,10 +138,12 @@ class _ProfileScreenState extends State } Future _requestReadinessPermission() async { + debugPrint('[Readiness] toggle tapped — starting permission flow'); setState(() => _isRequestingReadinessPermission = true); try { final hc = context.read(); final available = await hc.isAvailable(); + debugPrint('[Readiness] isAvailable = $available'); if (!available) { if (mounted) { _showSnack( @@ -155,17 +157,21 @@ class _ProfileScreenState extends State // Any single granted read type is enough — readiness components // degrade independently when data is missing. var granted = await hc.grantedReadTypes(); + debugPrint('[Readiness] granted before request = $granted'); if (granted.isEmpty) { + debugPrint('[Readiness] requesting read permissions…'); try { await hc.requestReadPermissions(); - } catch (_) { - // Fall through to re-check below. + } catch (e) { + debugPrint('[Readiness] requestReadPermissions threw: $e'); } granted = await hc.grantedReadTypes(); + debugPrint('[Readiness] granted after request = $granted'); } if (!mounted) return; if (granted.isNotEmpty) { + debugPrint('[Readiness] permissions granted — enabling readiness'); final settings = context.read(); await settings.setReadinessEnabled(true); if (!mounted) return; @@ -173,12 +179,14 @@ class _ProfileScreenState extends State unawaited(context.read().refresh(force: true)); _showSnack('Readiness insights enabled!', AppColors.success); } else { + debugPrint('[Readiness] still no granted types — showing manual instructions'); _showSnack( 'Open Health Connect → App permissions → RepForge and allow Sleep and Heart rate.', AppColors.warning, ); } } catch (e) { + debugPrint('[Readiness] unexpected error: $e'); if (mounted) { _showSnack('Could not connect to Health Connect.', AppColors.error); } diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 2b5858c..78000c5 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; +import '../../services/debug_log_buffer.dart'; import '../../services/settings_provider.dart'; import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; @@ -474,10 +475,37 @@ class CloudSyncSection extends StatelessWidget { } // ── About section ───────────────────────────────────────────────────────────── -class AboutSection extends StatelessWidget { +class AboutSection extends StatefulWidget { const AboutSection({super.key, required this.appVersion}); final String appVersion; + @override + State createState() => _AboutSectionState(); +} + +class _AboutSectionState extends State { + int _versionTaps = 0; + + void _onVersionTap() { + _versionTaps++; + if (_versionTaps >= 5) { + _versionTaps = 0; + _showDebugLogs(context); + } + } + + void _showDebugLogs(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => const _DebugLogSheet(), + ); + } + @override Widget build(BuildContext context) { return _ProfileSection( @@ -487,10 +515,13 @@ class AboutSection extends StatelessWidget { subtitle: 'RepForge Workout Logger', child: Column( children: [ - _InfoTile( - label: 'Version', - value: appVersion, - icon: Icons.tag_rounded, + GestureDetector( + onTap: _onVersionTap, + child: _InfoTile( + label: 'Version', + value: widget.appVersion, + icon: Icons.tag_rounded, + ), ), const _SectionDivider(), _InfoTile( @@ -1033,6 +1064,98 @@ String _formatInt(int n) { return buf.toString(); } +// ── Debug log viewer (tap version 5× to open) ──────────────────────────────── +class _DebugLogSheet extends StatelessWidget { + const _DebugLogSheet(); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.md, AppSpacing.sm, AppSpacing.sm, 0), + child: Row( + children: [ + Text( + 'Debug Logs', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const Spacer(), + TextButton( + onPressed: () => DebugLogBuffer.instance.clear(), + child: Text( + 'Clear', + style: GoogleFonts.geist( + color: AppColors.accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: AppColors.textMuted, size: 18), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: AppColors.glassBorder, height: 1), + Expanded( + child: ListenableBuilder( + listenable: DebugLogBuffer.instance, + builder: (context, _) { + final lines = DebugLogBuffer.instance.lines; + if (lines.isEmpty) { + return Center( + child: Text( + 'No logs yet', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13), + ), + ); + } + return ListView.builder( + controller: scrollController, + reverse: true, + padding: const EdgeInsets.all(AppSpacing.sm), + itemCount: lines.length, + itemBuilder: (context, i) { + final line = lines[lines.length - 1 - i]; + final isHc = line.contains('[HC]'); + final isReadiness = line.contains('[Readiness]'); + final color = isHc + ? AppColors.secondary + : isReadiness + ? AppColors.primary + : AppColors.textSoft; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + line, + style: GoogleFonts.geistMono(fontSize: 10, color: color), + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +} + class _ComingSoonBadge extends StatelessWidget { const _ComingSoonBadge(); diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart index 25c3fdc..2c42625 100644 --- a/workout-logger/lib/screens/widgets/readiness_card.dart +++ b/workout-logger/lib/screens/widgets/readiness_card.dart @@ -25,15 +25,27 @@ class ReadinessCard extends StatelessWidget { Widget build(BuildContext context) { final manager = context.watch(); final snapshot = manager.snapshot; - if (manager.status != ReadinessStatus.ready || - snapshot == null || - snapshot.score == null || - snapshot.band == null) { + debugPrint('[ReadinessCard] build: status=${manager.status} score=${snapshot?.score} band=${snapshot?.band}'); + + final bool hasScore = manager.status == ReadinessStatus.ready && + snapshot != null && + snapshot.score != null && + snapshot.band != null; + + if (!hasScore) { return const SizedBox.shrink(); } final color = _bandColor(snapshot.band!); + return _buildMainCard(context, snapshot, color); + } + + Widget _buildMainCard( + BuildContext context, + ReadinessSnapshot snapshot, + Color color, + ) { return Padding( padding: const EdgeInsets.only(bottom: 16), child: GlassCard( @@ -253,6 +265,7 @@ class _ReadinessDetailsSheet extends StatelessWidget { } } + class _ComponentRow extends StatelessWidget { const _ComponentRow({ required this.label, diff --git a/workout-logger/lib/screens/widgets/sleep_hr_card.dart b/workout-logger/lib/screens/widgets/sleep_hr_card.dart new file mode 100644 index 0000000..ba172b9 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sleep_hr_card.dart @@ -0,0 +1,268 @@ +// SleepHrCard — compact overnight-HR summary on the dashboard. +// +// Self-hiding: renders SizedBox.shrink() when ReadinessManager has no +// SleepHrSnapshot, so the dashboard needs no conditional logic. + +import 'dart:math' show min; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import 'sleep_hr_sheet.dart'; + +// Stage colour map — shared with SleepHrSheet. +const Map kSleepStageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; + +class SleepHrCard extends StatelessWidget { + const SleepHrCard({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snap = manager.sleepHrSnapshot; + if (snap == null) return const SizedBox.shrink(); + + final remAvg = snap.statsFor('rem')?.avgBpm; + final deepAvg = snap.statsFor('deep')?.avgBpm; + + final startFmt = _fmtTime(_toIst(snap.sleepStart)); + final endFmt = _fmtTime(_toIst(snap.sleepEnd)); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + onTap: () => _openSheet(context, snap), + semanticsLabel: 'Sleep heart rate, P95 ${snap.p95Bpm} bpm', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sleep heart rate', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 2), + Text( + 'Last night · $startFmt – $endFmt', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 20, + ), + ], + ), + const SizedBox(height: 10), + // Mini-stats row + Row( + children: [ + _MiniStat( + label: 'P5', + value: '${snap.p5Bpm}', + unit: 'bpm', + color: AppColors.success, + ), + _MiniStat( + label: 'P95', + value: '${snap.p95Bpm}', + unit: 'bpm', + color: AppColors.primary, + ), + if (deepAvg != null) + _MiniStat( + label: 'Deep avg', + value: deepAvg.round().toString(), + unit: 'bpm', + color: kSleepStageColors['deep']!, + ), + if (remAvg != null) + _MiniStat( + label: 'REM avg', + value: remAvg.round().toString(), + unit: 'bpm', + color: kSleepStageColors['rem']!, + ), + ], + ), + const SizedBox(height: 8), + // Sparkline + SizedBox( + height: 44, + child: CustomPaint( + size: const Size(double.infinity, 44), + painter: _SparklinePainter(snap.segments), + ), + ), + ], + ), + ), + ); + } + + void _openSheet(BuildContext context, SleepHrSnapshot snap) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => SleepHrSheet(snapshot: snap), + ); + } + + static DateTime _toIst(DateTime dt) => + dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + + static String _fmtTime(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + return '$h:$m $period'; + } +} + +class _MiniStat extends StatelessWidget { + const _MiniStat({ + required this.label, + required this.value, + required this.unit, + required this.color, + }); + + final String label; + final String value; + final String unit; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 10, + ), + ), + const SizedBox(height: 1), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: GoogleFonts.geistMono( + color: color, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + TextSpan( + text: ' $unit', + style: GoogleFonts.geist( + color: AppColors.textFaint, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Draws the compact sparkline: coloured low/high bars + moving-average line. +class _SparklinePainter extends CustomPainter { + const _SparklinePainter(this.segments); + + final List segments; + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); + final bpmMin = allBpms.reduce(min).toDouble() - 4; + final bpmMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble() + 4; + + double yFor(double bpm) => + size.height - ((bpm - bpmMin) / (bpmMax - bpmMin)) * size.height; + + final n = segments.length; + final barW = size.width / n; + + // Draw bars + for (var i = 0; i < n; i++) { + final seg = segments[i]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final paint = Paint() + ..color = color.withValues(alpha: 0.75) + ..style = PaintingStyle.fill; + final x = i * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x + 0.5, yTop, barW - 1, (yBot - yTop).clamp(2, double.infinity)), + const Radius.circular(1.5), + ); + canvas.drawRRect(rect, paint); + } + + // Moving-average trend line (window = 5) + final linePaint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.85) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final path = Path(); + for (var i = 0; i < n; i++) { + final start = (i - 4).clamp(0, n - 1); + final slice = segments.sublist(start, i + 1); + final ma = slice.map((s) => s.avgBpm).reduce((a, b) => a + b) / slice.length; + final x = i * barW + barW / 2; + final y = yFor(ma); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + // Draw as solid for the compact sparkline — dashes not worth the complexity at 44dp. + canvas.drawPath(path, linePaint..style = PaintingStyle.stroke); + } + + @override + bool shouldRepaint(_SparklinePainter old) => old.segments != segments; +} diff --git a/workout-logger/lib/screens/widgets/sleep_hr_sheet.dart b/workout-logger/lib/screens/widgets/sleep_hr_sheet.dart new file mode 100644 index 0000000..19e0ef7 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sleep_hr_sheet.dart @@ -0,0 +1,763 @@ +// SleepHrSheet — full overnight-HR detail shown in a bottom sheet. +// +// Sections (top → bottom): +// 1. Handle + title + subtitle (times in IST) +// 2. Key stat chips (P5 / P95 / Deep avg / REM avg) +// 3. Interactive 10-minute bar chart — tap/drag to see segment tooltip +// 4. Stage timeline strip + legend +// 5. "HR range by stage" horizontal distribution chart + +import 'dart:math' show min, max; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'sleep_hr_card.dart' show kSleepStageColors; + +class SleepHrSheet extends StatelessWidget { + const SleepHrSheet({super.key, required this.snapshot}); + + final SleepHrSnapshot snapshot; + + static const _stageOrder = ['awake', 'rem', 'light', 'deep']; + static const _stageLabels = { + 'awake': 'Awake', + 'rem': 'REM', + 'light': 'Light', + 'deep': 'Deep', + }; + + static DateTime _toIst(DateTime dt) => + dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + + static String _fmtTime(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + return '$h:$m $period'; + } + + @override + Widget build(BuildContext context) { + final remAvg = snapshot.statsFor('rem')?.avgBpm; + final deepAvg = snapshot.statsFor('deep')?.avgBpm; + + return DraggableScrollableSheet( + initialChildSize: 0.88, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (_, controller) => Container( + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + Expanded( + child: ListView( + controller: controller, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + // Title + Text( + 'Sleep heart rate', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.3, + ), + ), + const SizedBox(height: 3), + Text( + 'Last night · ${_fmtTime(_toIst(snapshot.sleepStart))} – ' + '${_fmtTime(_toIst(snapshot.sleepEnd))} IST', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 16), + + // Stat pills row + Row( + children: [ + _StatPill(label: 'P5', value: '${snapshot.p5Bpm} bpm', color: AppColors.success), + const SizedBox(width: 6), + _StatPill(label: 'P95', value: '${snapshot.p95Bpm} bpm', color: AppColors.primary), + if (deepAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'Deep avg', + value: '${deepAvg.round()} bpm', + color: kSleepStageColors['deep']!, + ), + ], + if (remAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'REM avg', + value: '${remAvg.round()} bpm', + color: kSleepStageColors['rem']!, + ), + ], + ], + ), + const SizedBox(height: 20), + + // Interactive bar chart + Text( + 'Heart rate during sleep · 10-min bars', + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3, + ), + ), + const SizedBox(height: 8), + _InteractiveBarChart(segments: snapshot.segments), + const SizedBox(height: 6), + + // Stage timeline strip + _StageTimelineStrip(segments: snapshot.segments), + const SizedBox(height: 8), + + // Legend + _Legend(), + const SizedBox(height: 24), + + // HR range by stage + Text( + 'HR range by stage', + style: GoogleFonts.geist( + color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3, + ), + ), + const SizedBox(height: 10), + _StageDistributionChart( + stats: snapshot.stageStats, + stageOrder: _stageOrder, + stageLabels: _stageLabels, + ), + const SizedBox(height: 10), + _DistLegend(), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Stat pill ───────────────────────────────────────────────────────────────── + +class _StatPill extends StatelessWidget { + const _StatPill({required this.label, required this.value, required this.color}); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + Text( + value, + style: GoogleFonts.geistMono(color: color, fontSize: 14, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +// ── Interactive bar chart ───────────────────────────────────────────────────── + +class _InteractiveBarChart extends StatefulWidget { + const _InteractiveBarChart({required this.segments}); + final List segments; + + @override + State<_InteractiveBarChart> createState() => _InteractiveBarChartState(); +} + +class _InteractiveBarChartState extends State<_InteractiveBarChart> { + int? _hoveredIndex; + + static const _chartHeight = 160.0; + static const _padLeft = 28.0; + + int? _indexAt(Offset local, double width) { + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW) return null; + final idx = (x / chartW * widget.segments.length).floor(); + return idx.clamp(0, widget.segments.length - 1); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _chartHeight, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState( + () => _hoveredIndex = _indexAt(d.localPosition, width), + ), + onTapUp: (_) => setState(() => _hoveredIndex = null), + onPanUpdate: (d) => setState( + () => _hoveredIndex = _indexAt(d.localPosition, width), + ), + onPanEnd: (_) => setState(() => _hoveredIndex = null), + onPanCancel: () => setState(() => _hoveredIndex = null), + child: CustomPaint( + size: Size(width, _chartHeight), + painter: _BarChartPainter( + segments: widget.segments, + hoveredIndex: _hoveredIndex, + ), + ), + ); + }, + ), + ); + } +} + +// ── Bar chart CustomPainter ─────────────────────────────────────────────────── + +class _BarChartPainter extends CustomPainter { + const _BarChartPainter({required this.segments, this.hoveredIndex}); + + final List segments; + final int? hoveredIndex; + + static const _padLeft = 28.0; + static const _padTop = 6.0; + static const _padBottom = 22.0; + + static DateTime _toIst(DateTime dt) => + dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); + final rawMin = allBpms.reduce(min).toDouble(); + final rawMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble(); + final bpmMin = (rawMin / 10).floor() * 10.0 - 5; + final bpmMax = (rawMax / 10).ceil() * 10.0 + 5; + + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + final n = segments.length; + final barW = chartW / n; + + double yFor(double bpm) => + _padTop + chartH - ((bpm - bpmMin) / (bpmMax - bpmMin)) * chartH; + + // Grid lines + Y labels + final gridPaint = Paint()..color = AppColors.glassBorder..strokeWidth = 0.5; + final yLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + + final gridBpms = []; + for (var b = (bpmMin ~/ 10) * 10; b <= bpmMax; b += 10) { + gridBpms.add(b); + } + for (final bpm in gridBpms) { + final y = yFor(bpm.toDouble()); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '$bpm', style: yLabelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + // Bars + for (var i = 0; i < n; i++) { + final seg = segments[i]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final alpha = (hoveredIndex == null || hoveredIndex == i) ? 0.78 : 0.28; + final paint = Paint()..color = color.withValues(alpha: alpha)..style = PaintingStyle.fill; + final x = _padLeft + i * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), + const Radius.circular(1.5), + ), + paint, + ); + } + + // Moving-average trend line + final avgPaint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.9) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path(); + for (var i = 0; i < n; i++) { + final sl = segments.sublist(max(0, i - 4), i + 1); + final ma = sl.map((s) => s.avgBpm).reduce((a, b) => a + b) / sl.length; + final x = _padLeft + i * barW + barW / 2; + final y = yFor(ma); + i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); + } + canvas.drawPath(path, avgPaint); + + // X-axis time labels every ~6 bars + final xLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var i = 0; i < n; i += 6) { + final t = _toIst(segments[i].windowStart); + final h = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final m = t.minute.toString().padLeft(2, '0'); + final tp = TextPainter( + text: TextSpan(text: '$h:$m', style: xLabelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(_padLeft + i * barW + barW / 2 - tp.width / 2, size.height - _padBottom + 5), + ); + } + + // Tooltip for hovered bar + if (hoveredIndex != null) { + final idx = hoveredIndex!; + final seg = segments[idx]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final barX = _padLeft + idx * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + + // Highlight stroke on selected bar + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(barX + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), + const Radius.circular(1.5), + ), + Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 1.5, + ); + + // Tooltip box + final t = _toIst(seg.windowStart); + final tEnd = _toIst(seg.windowStart.add(const Duration(minutes: 10))); + final th = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final tm = t.minute.toString().padLeft(2, '0'); + final eh = tEnd.hour == 0 ? 12 : tEnd.hour > 12 ? tEnd.hour - 12 : tEnd.hour; + final em = tEnd.minute.toString().padLeft(2, '0'); + final stageName = const { + 'deep': 'Deep', 'rem': 'REM', 'light': 'Light', 'awake': 'Awake', + }[seg.stage] ?? seg.stage; + + final lines = ['$th:$tm–$eh:$em IST', '${seg.minBpm}–${seg.maxBpm} bpm', stageName]; + final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final painters = lines.map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()).toList(); + + const ttPadH = 8.0, ttPadV = 6.0, ttLineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + ttPadH * 2; + final ttH = painters.length * ttLineH + ttPadV * 2; + + // Position: above bar, clamped to chart bounds + var ttX = barX + barW / 2 - ttW / 2; + ttX = ttX.clamp(_padLeft, size.width - 4 - ttW); + var ttY = yTop - ttH - 6; + if (ttY < _padTop) ttY = yBot + 6; + + // Shadow + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = Colors.black.withValues(alpha: 0.4)..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + // Background + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + // Border + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = color.withValues(alpha: 0.7)..style = PaintingStyle.stroke..strokeWidth = 1, + ); + + // Text lines + for (var i = 0; i < painters.length; i++) { + final p = painters[i]; + // stage line gets stage colour + if (i == 2) { + final stagePainter = TextPainter( + text: TextSpan( + text: stageName, + style: GoogleFonts.geistMono(color: color, fontSize: 9.5, fontWeight: FontWeight.w700), + ), + textDirection: TextDirection.ltr, + )..layout(); + stagePainter.paint(canvas, Offset(ttX + ttPadH, ttY + ttPadV + i * ttLineH)); + } else { + p.paint(canvas, Offset(ttX + ttPadH, ttY + ttPadV + i * ttLineH)); + } + } + } + } + + @override + bool shouldRepaint(_BarChartPainter old) => + old.segments != segments || old.hoveredIndex != hoveredIndex; +} + +// ── Stage timeline strip ────────────────────────────────────────────────────── + +class _StageTimelineStrip extends StatelessWidget { + const _StageTimelineStrip({required this.segments}); + final List segments; + + @override + Widget build(BuildContext context) { + if (segments.isEmpty) return const SizedBox.shrink(); + return SizedBox( + height: 5, + child: Row( + children: segments.map((s) { + final color = kSleepStageColors[s.stage] ?? AppColors.primary; + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 0.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(1), + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +class _Legend extends StatelessWidget { + @override + Widget build(BuildContext context) { + final items = [ + ('Deep', kSleepStageColors['deep']!), + ('REM', kSleepStageColors['rem']!), + ('Light', kSleepStageColors['light']!), + ('Awake', kSleepStageColors['awake']!), + ]; + return Wrap( + spacing: 12, + runSpacing: 4, + children: [ + ...items.map((e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, height: 8, + decoration: BoxDecoration(color: e.$2, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 4), + Text(e.$1, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + )), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, height: 10, + child: CustomPaint(painter: _DashLinePainter()), + ), + const SizedBox(width: 4), + Text('Avg trend', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ), + ], + ); + } +} + +class _DashLinePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.85) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final y = size.height / 2; + for (var x = 0.0; x < size.width; x += 4) { + canvas.drawLine(Offset(x, y), Offset(min(x + 2.5, size.width), y), paint); + } + } + + @override + bool shouldRepaint(_DashLinePainter _) => false; +} + +// ── Stage distribution chart ────────────────────────────────────────────────── + +class _StageDistributionChart extends StatelessWidget { + const _StageDistributionChart({ + required this.stats, + required this.stageOrder, + required this.stageLabels, + }); + + final List stats; + final List stageOrder; + final Map stageLabels; + + @override + Widget build(BuildContext context) { + if (stats.isEmpty) { + return Text( + 'No stage HR data available.', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + ); + } + + final allMin = stats.map((s) => s.minBpm).reduce(min).toDouble() - 4; + final allMax = stats.map((s) => s.maxBpm).reduce(max).toDouble() + 4; + + final orderedStats = stageOrder + .map((k) => stats.where((s) => s.stage == k).firstOrNull) + .whereType() + .toList(); + + return Column( + children: [ + ...orderedStats.map((s) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _DistRow( + stats: s, + label: stageLabels[s.stage] ?? s.stage, + color: kSleepStageColors[s.stage] ?? AppColors.primary, + bpmMin: allMin, + bpmMax: allMax, + ), + )), + _DistAxis(bpmMin: allMin, bpmMax: allMax), + ], + ); + } +} + +class _DistRow extends StatelessWidget { + const _DistRow({ + required this.stats, + required this.label, + required this.color, + required this.bpmMin, + required this.bpmMax, + }); + + final SleepStageStats stats; + final String label; + final Color color; + final double bpmMin; + final double bpmMax; + + @override + Widget build(BuildContext context) { + double pct(double bpm) => ((bpm - bpmMin) / (bpmMax - bpmMin)).clamp(0.0, 1.0); + + return Row( + children: [ + SizedBox( + width: 40, + child: Text( + label, + textAlign: TextAlign.right, + style: GoogleFonts.geist(color: color, fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 28, + child: LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + return Stack( + children: [ + Positioned( + left: pct(stats.minBpm.toDouble()) * w, + width: (pct(stats.maxBpm.toDouble()) - pct(stats.minBpm.toDouble())) * w, + top: 7, height: 14, + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(7), + ), + ), + ), + Positioned( + left: pct(stats.p25Bpm.toDouble()) * w, + width: (pct(stats.p75Bpm.toDouble()) - pct(stats.p25Bpm.toDouble())) * w, + top: 7, height: 14, + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(7), + ), + ), + ), + Positioned( + left: pct(stats.avgBpm) * w - 4, + top: 10, + child: Container( + width: 8, height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: AppColors.card, width: 1.5), + ), + ), + ), + Positioned( + left: (pct(stats.avgBpm) * w - 16).clamp(0, w - 32), + top: 0, + child: Text( + '${stats.avgBpm.round()} bpm', + style: GoogleFonts.geistMono( + color: color, fontSize: 8, fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ], + ); + } +} + +class _DistAxis extends StatelessWidget { + const _DistAxis({required this.bpmMin, required this.bpmMax}); + + final double bpmMin; + final double bpmMax; + + @override + Widget build(BuildContext context) { + final ticks = []; + for (var b = (bpmMin / 5).ceil() * 5; b <= bpmMax; b += 5) { + ticks.add(b); + } + return Padding( + padding: const EdgeInsets.only(left: 48), + child: LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + double pct(double bpm) => ((bpm - bpmMin) / (bpmMax - bpmMin)).clamp(0.0, 1.0); + return SizedBox( + height: 16, + child: Stack( + children: ticks.map((t) => Positioned( + left: (pct(t.toDouble()) * w - 10).clamp(0, w - 20), + child: Text( + '$t', + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8), + ), + )).toList(), + ), + ); + }, + ), + ); + } +} + +// ── Distribution legend ─────────────────────────────────────────────────────── + +class _DistLegend extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _DistLi( + swatch: Container( + width: 16, height: 8, + decoration: BoxDecoration( + color: AppColors.textMuted.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(4), + ), + ), + label: 'Min–max', + ), + _DistLi( + swatch: Container( + width: 16, height: 8, + decoration: BoxDecoration( + color: AppColors.textMuted.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(4), + ), + ), + label: 'P25–P75', + ), + _DistLi( + swatch: Container( + width: 8, height: 8, + decoration: const BoxDecoration(color: AppColors.textSoft, shape: BoxShape.circle), + ), + label: 'Avg', + ), + ], + ); + } +} + +class _DistLi extends StatelessWidget { + const _DistLi({required this.swatch, required this.label}); + final Widget swatch; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + swatch, + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); + } +} diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index 9ff867c..1e929cf 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -73,9 +73,11 @@ class CoachToolService { FunctionDeclaration( 'get_exercise_performance', 'Get how a specific exercise has progressed: per-session volume ' - 'trend, growth slope, best estimated 1RM, last logged sets, and ' - 'personal record. Use for questions like "how is my bench press ' - 'progressing".', + 'trend, the full per-session weight×reps set history, growth ' + 'slope, best estimated 1RM, last logged sets, and personal ' + 'record. Use for questions like "how is my bench press ' + 'progressing" or "what weight and reps did I do for squats ' + 'last month".', Schema.object( properties: { 'exercise_name': Schema.string( @@ -87,6 +89,14 @@ class CoachToolService { 'Optional. Only consider sessions from the last N days.', nullable: true, ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent sessions to return ' + 'in set_history and volume_trend. Use a small value (e.g. ' + '1–5) when you only need recent sessions, to save tokens. ' + 'Defaults to 20; capped at 40.', + nullable: true, + ), }, requiredProperties: ['exercise_name'], ), @@ -112,6 +122,14 @@ class CoachToolService { 'Defaults to 30 if no dates are provided.', nullable: true, ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent sessions to include ' + 'in the per-session breakdown. The session_count and ' + 'total_volume totals always cover the full range. Use a ' + 'small value to save tokens. Defaults to 40; capped at 40.', + nullable: true, + ), }, ), ), @@ -130,6 +148,13 @@ class CoachToolService { 'Optional. Only consider sessions from the last N days.', nullable: true, ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent points to include in ' + 'volume_over_time. session_count and total_volume always ' + 'cover all matching sessions. Defaults to 40; capped at 40.', + nullable: true, + ), }, requiredProperties: ['routine_name'], ), @@ -223,6 +248,31 @@ class CoachToolService { requiredProperties: ['routine_name'], ), ), + FunctionDeclaration( + 'add_custom_exercise', + 'Create a new custom exercise in the catalogue when the one the user ' + 'wants does not already exist. Match the muscle to an existing ' + 'muscle group (call get_muscle_recovery or list routines first ' + 'if unsure of the available muscle names). After creating it you ' + 'can reference it by name in create_routine / update_routine.', + Schema.object( + properties: { + 'name': Schema.string( + description: 'Name of the new exercise, e.g. "Cable Crossover".', + ), + 'category': Schema.string( + description: + 'Either "compound" (multi-joint) or "isolation" (single-joint).', + ), + 'primary_muscle': Schema.string( + description: + 'Primary muscle group this exercise targets, e.g. "Chest" ' + 'or "Biceps". Must match an existing muscle group.', + ), + }, + requiredProperties: ['name', 'category', 'primary_muscle'], + ), + ), ]), ]; @@ -248,6 +298,8 @@ class CoachToolService { return _createRoutine(call.args); case 'update_routine': return await _updateRoutine(call.args); + case 'add_custom_exercise': + return await _addCustomExercise(call.args); default: return {'error': 'Unknown tool: ${call.name}'}; } @@ -287,16 +339,25 @@ class CoachToolService { final lastLog = _wp.getLastSessionForExercise(exercise.id); final pr = _pr.getRecord(exercise.id); + // Optional model-supplied cap; defaults preserve prior behaviour + // (40 trend points, 20 set-history sessions). + final hasLimit = args['limit'] != null; + final trendCap = hasLimit ? _limitArg(args, 40) : 40; + final setCap = hasLimit ? _limitArg(args, 20) : 20; + return { 'exercise': exercise.name, 'session_count': progression.length, if (days != null) 'window_days': days, 'volume_trend': [ - for (final p in progression.length > 40 - ? progression.sublist(progression.length - 40) + for (final p in progression.length > trendCap + ? progression.sublist(progression.length - trendCap) : progression) {'date': _d(p.date), 'volume': _round(p.volume)}, ], + // Per-session weight×reps breakdown (most recent first), so the model can + // answer "what weight/reps did I do" rather than only volume totals. + 'set_history': _setHistory(exercise.id, cutoff, setCap), 'growth': growth == null ? null : { @@ -361,7 +422,7 @@ class CoachToolService { 'session_count': sessions.length, 'total_volume': _round(totalVolume), 'sessions': [ - for (final s in sessions.take(40)) + for (final s in sessions.take(_limitArg(args, 40))) { 'date': _d(s.date), 'duration_min': s.duration, @@ -414,8 +475,8 @@ class CoachToolService { if (days != null) 'window_days': days, 'total_volume': _round(totalVolume), 'volume_over_time': [ - for (final s in sessions.length > 40 - ? sessions.sublist(sessions.length - 40) + for (final s in sessions.length > _limitArg(args, 40) + ? sessions.sublist(sessions.length - _limitArg(args, 40)) : sessions) {'date': _d(s.date), 'volume': _round(s.totalVolume)}, ], @@ -655,8 +716,100 @@ class CoachToolService { }; } + Future> _addCustomExercise( + Map args) async { + final name = (args['name'] as String?)?.trim() ?? ''; + if (name.isEmpty) return {'error': 'Exercise name cannot be empty.'}; + + // Reject duplicates so the model reuses the existing exercise instead. + final existing = _wp.allExercises.where( + (e) => e.name.toLowerCase() == name.toLowerCase(), + ); + if (existing.isNotEmpty) { + return { + 'error': 'An exercise named "${existing.first.name}" already exists. ' + 'Use it by name instead of creating a duplicate.', + }; + } + + final category = (args['category'] as String?)?.trim().toLowerCase() ?? ''; + if (category != 'compound' && category != 'isolation') { + return { + 'error': 'category must be "compound" or "isolation", got "$category".', + }; + } + + final muscleName = (args['primary_muscle'] as String?)?.trim() ?? ''; + final MuscleGroup muscle; + try { + final resolved = _resolveMuscleGroup(muscleName); + if (resolved == null) { + return { + 'error': 'No muscle group found matching "$muscleName".', + 'available_muscles': [for (final m in _wp.muscleGroups) m.name], + }; + } + muscle = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple muscle groups match "$muscleName". Did you mean:', + 'ambiguous_matches': e.candidates, + }; + } + + try { + await _wp.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscle.id, + ); + } catch (e) { + return {'error': 'Could not create exercise: $e'}; + } + + return { + 'created': true, + 'exercise_name': name, + 'category': category, + 'primary_muscle': muscle.name, + }; + } + // ── Helpers ──────────────────────────────────────────────────────────────── + /// Per-session weight×reps breakdown for [exerciseId], newest first. + /// Bounded to the most recent [limit] sessions (after the optional [cutoff]) + /// to keep the tool payload small. + List> _setHistory( + String exerciseId, DateTime? cutoff, int limit) { + final sessions = _wp.sessions + .where((s) => cutoff == null || !s.date.isBefore(cutoff)) + .where((s) => s.exercises.any((e) => e.exerciseId == exerciseId)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + return [ + for (final s in sessions.take(limit)) + { + 'date': _d(s.date), + 'sets': [ + for (final log in s.exercises.where((e) => e.exerciseId == exerciseId)) + for (final set in log.sets) + { + 'weight': _round(set.weight), + 'reps': set.reps, + if (set.isDropset) 'dropset': true, + if (set.isDropset && set.drops != null) + 'drops': [ + for (final d in set.drops!) + {'weight': _round(d.weight), 'reps': d.reps}, + ], + }, + ], + }, + ]; + } + Exercise? _resolveExercise(String query) { final q = query.toLowerCase().trim(); if (q.isEmpty) return null; @@ -684,6 +837,20 @@ class CoachToolService { throw AmbiguousMatchException([for (final r in partials) r.name]); } + MuscleGroup? _resolveMuscleGroup(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + for (final m in _wp.muscleGroups) { + if (m.name.toLowerCase() == q) return m; + } + final partials = [ + for (final m in _wp.muscleGroups) if (m.name.toLowerCase().contains(q)) m + ]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final m in partials) m.name]); + } + List _exampleExerciseNames() => _wp.allExercises.take(8).map((e) => e.name).toList(); @@ -695,4 +862,11 @@ class CoachToolService { double _round(double v) => (v * 10).round() / 10; double? _roundOrNull(double? v) => v == null ? null : _round(v); + + /// Read an optional `limit` arg, clamped to [1, 40]; [fallback] when absent. + int _limitArg(Map args, int fallback) { + final n = (args['limit'] as num?)?.toInt(); + if (n == null) return fallback; + return n.clamp(1, 40); + } } diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index 08f811c..de8729c 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -2,12 +2,19 @@ // // Backs the AI coach chat (streaming + tool calling), program generation, and // insights. Uses a user-supplied Google AI Studio API key (free-tier friendly). -// Implements [IAiService] so the backend can be swapped (e.g. firebase_ai) -// without touching consumers. +// Implements [IAiService] so the backend can be swapped without touching consumers. +// +// Uses direct HTTP calls (rather than the SDK's chat helpers) so we can pass +// thinkingConfig: {thinkingBudget: 0} and avoid the SDK crashing on the +// `thoughtSignature` parts that Gemini 3.x models return when thinking is active. +// The SDK is still used for its type definitions (Content, Tool, FunctionCall) +// and their toJson() serialisers which are part of the public API. import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, FunctionCall, Tool; +import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../../models/models.dart'; @@ -17,18 +24,46 @@ import '../interfaces/storage_service_interface.dart'; // Ordered list of available Gemini models shown in the picker. const kGeminiModels = [ ('gemini-2.5-flash', 'Gemini 2.5 Flash'), - ('gemini-3.0-flash', 'Gemini 3.0 Flash'), + ('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'), ('gemini-3.1-flash-lite', 'Gemini 3.1 Flash Lite'), ('gemini-3.5-flash', 'Gemini 3.5 Flash'), ]; -// Default to a fast, free-tier 3.x model. gemini-3.5-flash is selectable and -// preferable when heavy tool-calling reliability matters. -const kDefaultGeminiModel = 'gemini-3.1-flash-lite'; +// Default to the latest GA model. +const kDefaultGeminiModel = 'gemini-3.5-flash'; // Upper bound on tool-resolution rounds per user turn, to bound runaway loops. const int _kMaxToolRounds = 5; +// Retry policy for transient (5xx / 429) errors. Total attempts = 1 + retries. +const int _kMaxRetries = 2; + +const String _apiBase = + 'https://generativelanguage.googleapis.com/v1beta/models'; + +// 429 (rate limit) and 5xx (server/overload, e.g. 503 "high demand") are +// transient and worth retrying; 4xx (bad key, bad request) are not. +bool _isRetryableStatus(int code) => code == 429 || (code >= 500 && code < 600); + +// Exponential backoff: 500ms, 1s, 2s … +Duration _retryBackoff(int attempt) => + Duration(milliseconds: 500 * (1 << attempt)); + +// Gemini error bodies look like {"error":{"code":503,"message":"…","status":"…"}}. +// Surface just the human-readable message rather than the whole JSON blob. +String _errorMessage(int code, String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map && decoded['error'] is Map) { + final msg = (decoded['error'] as Map)['message']; + if (msg is String && msg.isNotEmpty) return msg; + } + } catch (_) { + // Body wasn't JSON — fall through to a generic message. + } + return 'request failed (HTTP $code).'; +} + class GeminiAiService extends ChangeNotifier implements IAiService { // Optional storage so cumulative token usage survives restarts. final IStorageService? _storage; @@ -96,7 +131,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { } /// Accumulate one request's token counts. Exposed for testing; normally - /// fed from a response's [UsageMetadata] via [_recordUsage]. + /// fed from the raw usageMetadata JSON via [_recordRawUsage]. @visibleForTesting Future recordUsage({ required int prompt, @@ -111,11 +146,12 @@ class GeminiAiService extends ChangeNotifier implements IAiService { notifyListeners(); } - void _recordUsage(UsageMetadata? m) { - if (m == null) return; - final p = m.promptTokenCount ?? 0; - final r = m.candidatesTokenCount ?? 0; - recordUsage(prompt: p, response: r, total: m.totalTokenCount ?? (p + r)); + void _recordRawUsage(Map? usage) { + if (usage == null) return; + final p = (usage['promptTokenCount'] as num?)?.toInt() ?? 0; + final r = (usage['candidatesTokenCount'] as num?)?.toInt() ?? 0; + final t = (usage['totalTokenCount'] as num?)?.toInt() ?? (p + r); + recordUsage(prompt: p, response: r, total: t); } Future _persistUsage() async { @@ -142,20 +178,131 @@ class GeminiAiService extends ChangeNotifier implements IAiService { notifyListeners(); } - GenerativeModel _makeModel({ - bool jsonMode = false, + // ── Raw HTTP helpers ──────────────────────────────────────────────────────── + + Map _makeBody({ + required List contents, String? system, List? tools, - }) { - return GenerativeModel( - model: _model, - apiKey: _apiKey, - systemInstruction: system != null ? Content.system(system) : null, - tools: tools, - generationConfig: jsonMode - ? GenerationConfig(responseMimeType: 'application/json') - : null, + bool jsonMode = false, + }) => + { + 'contents': contents, + if (system != null) + 'systemInstruction': { + 'parts': [ + {'text': system} + ] + }, + if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), + 'generationConfig': { + // Disable thinking tokens so SDK-incompatible thoughtSignature parts + // are never returned by Gemini 3.x models. + 'thinkingConfig': {'thinkingBudget': 0}, + if (jsonMode) 'responseMimeType': 'application/json', + }, + }; + + // Extracts non-thought text strings from a candidate object. + Iterable _textFromCandidate(Map candidate) sync* { + final content = candidate['content'] as Map?; + final parts = content?['parts'] as List? ?? []; + for (final part in parts) { + if (part is Map && + part.containsKey('text') && + part['thought'] != true) { + final t = part['text'] as String? ?? ''; + if (t.isNotEmpty) yield t; + } + } + } + + // Streams parsed SSE chunks from the streamGenerateContent endpoint. + Stream> _streamSse(Map body) async* { + final uri = Uri.parse( + '$_apiBase/$_model:streamGenerateContent?alt=sse&key=$_apiKey', ); + + // Establish the connection with retries. Retrying is only safe here — + // before any bytes are yielded — so a transient 503 never reaches the user, + // but a mid-stream failure is not retried (it would duplicate output). + http.Client client = http.Client(); + http.StreamedResponse streamed; + for (var attempt = 0;; attempt++) { + final request = http.Request('POST', uri) + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body); + final resp = await client.send(request); + if (resp.statusCode == 200) { + streamed = resp; + break; + } + final err = await resp.stream.bytesToString(); + if (_isRetryableStatus(resp.statusCode) && attempt < _kMaxRetries) { + client.close(); + await Future.delayed(_retryBackoff(attempt)); + client = http.Client(); + continue; + } + client.close(); + throw Exception(_errorMessage(resp.statusCode, err)); + } + + try { + final lineBuf = StringBuffer(); + await for (final raw in streamed.stream.transform(utf8.decoder)) { + lineBuf.write(raw); + final text = lineBuf.toString(); + final lines = text.split('\n'); + lineBuf + ..clear() + ..write(lines.last); // keep potentially incomplete last line + for (var i = 0; i < lines.length - 1; i++) { + final line = lines[i].trim(); + if (!line.startsWith('data: ')) continue; + final payload = line.substring(6).trim(); + if (payload.isEmpty || payload == '[DONE]') continue; + yield jsonDecode(payload) as Map; + } + } + // Flush any remaining buffered line. + final tail = lineBuf.toString().trim(); + if (tail.startsWith('data: ')) { + final payload = tail.substring(6).trim(); + if (payload.isNotEmpty && payload != '[DONE]') { + yield jsonDecode(payload) as Map; + } + } + } finally { + client.close(); + } + } + + // Single-shot (non-streaming) generateContent call, with retry on 5xx/429. + Future> _generate(Map body) async { + final uri = Uri.parse('$_apiBase/$_model:generateContent?key=$_apiKey'); + final payload = jsonEncode(body); + for (var attempt = 0;; attempt++) { + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: payload, + ); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + if (_isRetryableStatus(response.statusCode) && attempt < _kMaxRetries) { + await Future.delayed(_retryBackoff(attempt)); + continue; + } + throw Exception(_errorMessage(response.statusCode, response.body)); + } + } + + String _textFromResponse(Map data) { + final candidates = data['candidates'] as List? ?? []; + if (candidates.isEmpty) return ''; + return _textFromCandidate(candidates[0] as Map).join(); } // ── Coach chat (streaming + optional tool-call loop) ─────────────────────── @@ -175,42 +322,82 @@ class GeminiAiService extends ChangeNotifier implements IAiService { return; } try { - final chat = _makeModel(system: systemPrompt, tools: tools) - .startChat(history: history); - - Content next = Content.text(userMessage); + // Build the mutable contents list; grows with each tool-call round. + final contents = [ + ...history.map((c) => c.toJson()), + Content.text(userMessage).toJson(), + ]; for (var round = 0; round < _kMaxToolRounds; round++) { + final body = _makeBody( + contents: contents, + system: systemPrompt, + tools: tools, + ); + + // Raw parts from the model turn — preserved verbatim so that any + // thought_signature fields on functionCall parts are not dropped when + // we echo this turn back to the API in the next round. + final rawModelParts = >[]; final calls = []; - UsageMetadata? roundUsage; - await for (final chunk in chat.sendMessageStream(next)) { - final t = chunk.text; - if (t != null && t.isNotEmpty) yield t; - calls.addAll(chunk.functionCalls); - if (chunk.usageMetadata != null) roundUsage = chunk.usageMetadata; + Map? lastUsage; + + await for (final chunk in _streamSse(body)) { + final candidates = chunk['candidates'] as List? ?? []; + for (final raw in candidates) { + final c = raw as Map; + for (final t in _textFromCandidate(c)) { + yield t; + } + // Collect raw parts for the model-turn echo. + final content = c['content'] as Map?; + final parts = content?['parts'] as List? ?? []; + for (final part in parts) { + if (part is! Map) continue; + rawModelParts.add(part); + if (part.containsKey('functionCall')) { + final fc = part['functionCall'] as Map; + calls.add(FunctionCall( + fc['name'] as String, + (fc['args'] as Map? ?? {}) + .cast(), + )); + } + } + } + if (chunk['usageMetadata'] != null) { + lastUsage = chunk['usageMetadata'] as Map; + } } - // The final chunk of each round carries that round's cumulative usage. - _recordUsage(roundUsage); + _recordRawUsage(lastUsage); // No tools requested (or no handler) → the streamed text is the answer. if (calls.isEmpty || onToolCall == null) return; - // Resolve every requested call and feed the results back as one turn. - final responses = []; + // Echo the model turn back verbatim (preserves thought_signature). + contents.add({'role': 'model', 'parts': rawModelParts}); + + // Resolve every call and feed the results back as one function turn. + final responseParts = >[]; for (final call in calls) { try { final result = await onToolCall(call); - responses.add(FunctionResponse(call.name, result)); + responseParts.add({ + 'functionResponse': {'name': call.name, 'response': result} + }); } catch (e) { - responses.add(FunctionResponse(call.name, {'error': '$e'})); + responseParts.add({ + 'functionResponse': { + 'name': call.name, + 'response': {'error': '$e'} + } + }); } } - next = Content.functionResponses(responses); + contents.add({'role': 'function', 'parts': responseParts}); } // Exhausted the tool-round budget without a final text answer. yield '\n\n_(Stopped after $_kMaxToolRounds tool steps — try rephrasing.)_'; - } on GenerativeAIException catch (e) { - yield 'AI error: ${e.message}'; } catch (e) { yield 'Error: $e'; } @@ -283,22 +470,27 @@ Required JSON schema (follow exactly): 'Available exercises (ID: name [primary muscle]):\n$exerciseList\n\nUser request: $userPrompt'; try { - final response = await _makeModel(jsonMode: true, system: systemPrompt) - .generateContent([Content.text(prompt)]); - _recordUsage(response.usageMetadata); - final raw = response.text ?? ''; + final data = await _generate( + _makeBody( + contents: [Content.text(prompt).toJson()], + system: systemPrompt, + jsonMode: true, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final raw = _textFromResponse(data); if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); - final data = jsonDecode(raw) as Map; + final map = jsonDecode(raw) as Map; // Ensure a fresh UUID so it never collides with an existing program. - data['id'] = const Uuid().v4(); - data['isImported'] = true; - data['author'] = 'AI Coach'; - return TrainingProgram.fromJson(data); - } on GenerativeAIException catch (e) { - throw Exception('Gemini API error: ${e.message}'); + map['id'] = const Uuid().v4(); + map['isImported'] = true; + map['author'] = 'AI Coach'; + return TrainingProgram.fromJson(map); } on FormatException catch (e) { throw Exception('Could not parse program JSON: $e'); + } catch (e) { + throw Exception('Gemini API error: $e'); } } @@ -315,12 +507,15 @@ Required JSON schema (follow exactly): 'Cover: biggest win, one thing to watch, one tip for next week. ' 'No bullet points, no headers — natural flowing prose only.'; try { - final response = await _makeModel(system: systemPrompt) - .generateContent([Content.text(contextText)]); - _recordUsage(response.usageMetadata); - return response.text?.trim() ?? 'No insights generated.'; - } on GenerativeAIException catch (e) { - return 'AI error: ${e.message}'; + final data = await _generate( + _makeBody( + contents: [Content.text(contextText).toJson()], + system: systemPrompt, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final text = _textFromResponse(data).trim(); + return text.isNotEmpty ? text : 'No insights generated.'; } catch (e) { return 'Could not generate insights: $e'; } @@ -333,12 +528,15 @@ Required JSON schema (follow exactly): return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; } try { - final response = await _makeModel(system: system) - .generateContent([Content.text(context)]); - _recordUsage(response.usageMetadata); - return response.text?.trim() ?? 'No insight generated.'; - } on GenerativeAIException catch (e) { - return 'AI error: ${e.message}'; + final data = await _generate( + _makeBody( + contents: [Content.text(context).toJson()], + system: system, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final text = _textFromResponse(data).trim(); + return text.isNotEmpty ? text : 'No insight generated.'; } catch (e) { return 'Could not generate insight: $e'; } diff --git a/workout-logger/lib/services/debug_log_buffer.dart b/workout-logger/lib/services/debug_log_buffer.dart new file mode 100644 index 0000000..edff98b --- /dev/null +++ b/workout-logger/lib/services/debug_log_buffer.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; + +/// Captures every [debugPrint] call into a fixed-size circular buffer. +/// Wire up once in main() via [DebugLogBuffer.attach]. +class DebugLogBuffer extends ChangeNotifier { + DebugLogBuffer._(); + static final instance = DebugLogBuffer._(); + + static const _maxLines = 500; + final List _lines = []; + + List get lines => List.unmodifiable(_lines); + + static void attach() { + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + original(message, wrapWidth: wrapWidth); + instance._append(message ?? ''); + }; + } + + void _append(String line) { + final ts = DateTime.now(); + final stamp = + '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; + _lines.add('[$stamp] $line'); + if (_lines.length > _maxLines) _lines.removeAt(0); + notifyListeners(); + } + + void clear() { + _lines.clear(); + notifyListeners(); + } +} diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart index 98071e9..083ce5d 100644 --- a/workout-logger/lib/services/gemini_context_builder.dart +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -37,6 +37,14 @@ class GeminiContextBuilder { 'recovery — CALL THE PROVIDED TOOLS rather than guessing or inventing ' 'numbers. Pass ISO dates (YYYY-MM-DD) or a day count to the tools.', ) + ..writeln( + 'You can also MODIFY the user\'s data with tools: create or update ' + 'routines, and add a new custom exercise when one does not already ' + 'exist. You do not need to ask permission before calling a write tool ' + 'the user clearly requested, but confirm what you did in your reply. ' + 'If a routine needs an exercise that is not in the catalogue, create it ' + 'with add_custom_exercise first, then reference it by name.', + ) ..writeln( 'Weights are in $unitLabel. Format replies with Markdown (lists, bold, ' 'tables) where it aids clarity.', @@ -69,28 +77,37 @@ class GeminiContextBuilder { ..writeln() ..writeln('STRICT WORKFLOW — execute in this order every time:') ..writeln( - '1. QUESTIONS FIRST: Call ask_user_questions immediately. ' - 'Ask about (a) primary goal [Strength/Hypertrophy/Fat loss/Endurance], ' - '(b) sessions per week for this routine, and optionally (c) any exercises ' - 'they want to keep no matter what. Do NOT skip this step.', + '1. FETCH DATA FIRST: Before saying anything or asking anything, ' + 'call get_routine_performance for the routine, then call ' + 'get_exercise_performance for EVERY exercise in that routine (use ' + 'the exercise list from the routine response), and call ' + 'get_muscle_recovery. Never skip this step and never invent numbers.', + ) + ..writeln( + '2. ANALYSE SILENTLY: Identify issues — stalling or declining ' + 'exercises (negative slope or r²<0.5), missing muscle groups, ' + 'recovery conflicts, poor ordering. Do not output this analysis.', ) ..writeln( - '2. FETCH DATA: After answers arrive, call get_routine_performance ' - 'for the routine and get_exercise_performance for each exercise that ' - 'has data. Never invent numbers.', + '3. ASK ONLY IF AMBIGUOUS: Call ask_user_questions only if ' + 'the data alone cannot determine the best changes — e.g. the user ' + 'goal (strength vs hypertrophy) would flip which exercise to suggest, ' + 'or you need to know which exercises they want to keep. ' + 'Skip this step entirely if the data makes the answer obvious. ' + 'Never ask questions whose answers would not change your recommendations.', ) ..writeln( - '3. PROPOSE CHANGES: List proposed changes as short bullets: ' - 'reorder (give full new order), replace (which exercise → which ' - 'alternative and why), add (specific exercise to fill a gap). ' - 'Keep your analysis under 150 words.', + '4. PROPOSE CHANGES: List proposed changes as short bullets with ' + 'specific numbers from the data (e.g. "Overhead Press slope −0.3 kg/session"): ' + 'reorder (give full new order), replace (which → which and why), ' + 'add (specific exercise to fill a muscle gap). Under 150 words.', ) ..writeln( - '4. CONFIRM: Call ask_user_questions with multiSelect:true listing ' - 'your proposed changes as chips so the user can pick which to apply.', + '5. CONFIRM: Call ask_user_questions with multiSelect:true listing ' + 'each proposed change as a chip. The user picks which to apply.', ) ..writeln( - '5. APPLY: Call update_routine exactly once with only the confirmed ' + '6. APPLY: Call update_routine exactly once with only the confirmed ' 'changes. Then confirm in one sentence what was changed.', ) ..writeln() diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index cb9601a..b278b66 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -62,8 +62,10 @@ class HealthConnectService implements IHealthConnectService { Future isAvailable() async { try { final status = await HealthConnector.getHealthPlatformStatus(); + debugPrint('[HC] isAvailable: platform status = $status'); return status == HealthPlatformStatus.available; - } catch (_) { + } catch (e) { + debugPrint('[HC] isAvailable: exception = $e'); return false; } } @@ -98,7 +100,9 @@ class HealthConnectService implements IHealthConnectService { static final Map _readPermissions = { HealthReadType.sleep: HealthDataType.sleepSession.readPermission, - HealthReadType.heartRate: HealthDataType.heartRate.readPermission, + // heartRateSeries maps to Android HeartRateRecord (series with samples). + // heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect. + HealthReadType.heartRate: HealthDataType.heartRateSeries.readPermission, HealthReadType.restingHeartRate: HealthDataType.restingHeartRate.readPermission, HealthReadType.hrv: HealthDataType.heartRateVariabilityRMSSD.readPermission, @@ -106,31 +110,38 @@ class HealthConnectService implements IHealthConnectService { @override Future requestReadPermissions() async { - try { - _connector ??= await HealthConnector.create(); - final results = await _connector! - .requestPermissions(_readPermissions.values.toList()); - return results.any((r) => r.status == PermissionStatus.granted); - } catch (e) { - debugPrint('Health Connect requestReadPermissions failed: $e'); - return false; + debugPrint('[HC] requestReadPermissions: requesting ${_readPermissions.length} permissions individually'); + _connector ??= await HealthConnector.create(); + var anyGranted = false; + for (final entry in _readPermissions.entries) { + try { + final results = await _connector!.requestPermissions([entry.value]); + final granted = results.any((r) => r.status == PermissionStatus.granted); + debugPrint('[HC] requestReadPermissions: ${entry.key} → granted=$granted'); + if (granted) anyGranted = true; + } catch (e) { + debugPrint('[HC] requestReadPermissions: ${entry.key} unsupported, skipping ($e)'); + } } + debugPrint('[HC] requestReadPermissions: anyGranted = $anyGranted'); + return anyGranted; } @override Future> grantedReadTypes() async { - try { - _connector ??= await HealthConnector.create(); - final granted = {}; - for (final entry in _readPermissions.entries) { + _connector ??= await HealthConnector.create(); + final granted = {}; + for (final entry in _readPermissions.entries) { + try { final status = await _connector!.getPermissionStatus(entry.value); + debugPrint('[HC] grantedReadTypes: ${entry.key} → $status'); if (status == PermissionStatus.granted) granted.add(entry.key); + } catch (e) { + debugPrint('[HC] grantedReadTypes: ${entry.key} unsupported, skipping ($e)'); } - return granted; - } catch (e) { - debugPrint('Health Connect grantedReadTypes failed: $e'); - return const {}; } + debugPrint('[HC] grantedReadTypes: result = $granted'); + return granted; } @override @@ -146,11 +157,56 @@ class HealthConnectService implements IHealthConnectService { endTime: end, ), ); - return response.records - .map((r) => SleepPeriod(start: r.startTime, end: r.endTime)) - .toList(); + final result = response.records.map((r) { + // Tally stage durations from embedded SleepStageSamples and build + // an ordered stage timeline for HR segment colouring. + var light = 0, deep = 0, rem = 0, awake = 0; + final timeline = []; + var cursor = r.startTime; + for (final s in r.samples) { + final segEnd = cursor.add(s.duration); + final mins = s.duration.inMinutes; + switch (s.stageType) { + case SleepStage.light: + case SleepStage.sleeping: // generic "asleep" — count as light + light += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'light')); + case SleepStage.deep: + deep += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'deep')); + case SleepStage.rem: + rem += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'rem')); + case SleepStage.awake: + case SleepStage.outOfBed: + case SleepStage.inBed: + awake += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'awake')); + case SleepStage.unknown: + break; + } + cursor = segEnd; + } + final hasStages = r.samples.isNotEmpty; + final period = SleepPeriod( + start: r.startTime, + end: r.endTime, + lightMinutes: hasStages ? light : null, + deepMinutes: hasStages ? deep : null, + remMinutes: hasStages ? rem : null, + awakeMinutes: hasStages ? awake : null, + stageTimeline: timeline, + ); + debugPrint('[HC] sleep ${r.startTime.toLocal().hour}:${r.startTime.toLocal().minute.toString().padLeft(2, '0')}' + '→${r.endTime.toLocal().hour}:${r.endTime.toLocal().minute.toString().padLeft(2, '0')}' + ' actual=${period.minutes}min' + '${hasStages ? " (L=$light D=$deep R=$rem A=$awake)" : " (no stages)"}'); + return period; + }).toList(); + debugPrint('[HC] readSleepSessions [$start → $end]: ${result.length} records'); + return result; } catch (e) { - debugPrint('Health Connect readSleepSessions failed: $e'); + debugPrint('[HC] readSleepSessions failed: $e'); return const []; } } @@ -168,11 +224,13 @@ class HealthConnectService implements IHealthConnectService { endTime: end, ), ); - return response.records + final result = response.records .map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute)) .toList(); + debugPrint('[HC] readRestingHeartRate [$start → $end]: ${result.length} records'); + return result; } catch (e) { - debugPrint('Health Connect readRestingHeartRate failed: $e'); + debugPrint('[HC] readRestingHeartRate failed: $e'); return const []; } } @@ -187,11 +245,13 @@ class HealthConnectService implements IHealthConnectService { endTime: end, ), ); - return response.records + final result = response.records .map((r) => HealthSample(time: r.time, value: r.rmssd.inMilliseconds)) .toList(); + debugPrint('[HC] readHrvRmssd [$start → $end]: ${result.length} records'); + return result; } catch (e) { - debugPrint('Health Connect readHrvRmssd failed: $e'); + debugPrint('[HC] readHrvRmssd failed: $e'); return const []; } } @@ -203,19 +263,27 @@ class HealthConnectService implements IHealthConnectService { ) async { try { _connector ??= await HealthConnector.create(); + // heartRateSeries = Android HeartRateRecord (container with BPM samples). + // heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect. final response = await _connector!.readRecords( - HealthDataType.heartRate.readInTimeRange( + HealthDataType.heartRateSeries.readInTimeRange( startTime: start, endTime: end, - // Minute-level data over a narrow morning window; one page suffices. pageSize: 5000, ), ); - return response.records - .map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute)) + final samples = response.records + .expand( + (r) => r.samples.map( + (s) => HealthSample(time: s.time, value: s.rate.inPerMinute), + ), + ) .toList(); + debugPrint('[HC] readHeartRateSamples [$start → $end]: ' + '${response.records.length} series records, ${samples.length} samples'); + return samples; } catch (e) { - debugPrint('Health Connect readHeartRateSamples failed: $e'); + debugPrint('[HC] readHeartRateSamples failed: $e'); return const []; } } diff --git a/workout-logger/lib/services/managers/readiness_manager.dart b/workout-logger/lib/services/managers/readiness_manager.dart index 2a90ae6..6696205 100644 --- a/workout-logger/lib/services/managers/readiness_manager.dart +++ b/workout-logger/lib/services/managers/readiness_manager.dart @@ -13,6 +13,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; import '../interfaces/health_connect_service_interface.dart'; import '../interfaces/readiness_manager_interface.dart'; import '../interfaces/storage_service_interface.dart'; @@ -32,6 +33,14 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { ReadinessStatus _status = ReadinessStatus.idle; ReadinessSnapshot? _snapshot; + SleepHrSnapshot? _sleepHrSnapshot; + + SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; + + // Debug-only: human-readable trace of the last refresh() execution. + // Empty until refresh() runs for the first time. + String _debugTrace = ''; + String get debugTrace => _debugTrace; ReadinessManager( this._hc, @@ -48,11 +57,27 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { @override Future refresh({bool force = false}) async { - if (!_settings.readinessEnabled) return; + if (!_settings.readinessEnabled) { + _debugTrace = 'readinessEnabled=false — refresh skipped'; + return; + } try { final now = DateTime.now(); final todayKey = ReadinessCalculator.dateKey(now); + debugPrint('[Readiness] refresh: todayKey=$todayKey force=$force'); + + // Fetch permissions first — needed on both the cached and live paths. + final granted = await _hc.grantedReadTypes(); + debugPrint('[Readiness] refresh: granted=$granted'); + if (granted.isEmpty) { + debugPrint('[Readiness] refresh: no permissions → noData'); + _debugTrace = 'NO PERMISSIONS granted\n' + 'Open Health Connect → App permissions → RepForge\n' + 'and allow Sleep and Heart rate.'; + _setNoData(); + return; + } final cached = await _loadSnapshot(); if (cached != null && cached.dateKey == todayKey) { @@ -60,25 +85,96 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { _snapshot = cached; _status = ReadinessStatus.ready; notifyListeners(); - if (!force && now.difference(cached.computedAt) < _snapshotTtl) return; + debugPrint('[Readiness] refresh: serving cached snapshot score=${cached.score}'); + if (!force && now.difference(cached.computedAt) < _snapshotTtl) { + _debugTrace = 'Serving cached snapshot (within ${_snapshotTtl.inMinutes}min TTL)\n' + 'score=${cached.score} band=${cached.band}\n' + 'computedAt=${cached.computedAt.toLocal()}'; + // Still build the sleep HR snapshot if we don't have one yet. + if (_sleepHrSnapshot == null) { + _sleepHrSnapshot = await _buildSleepHrSnapshot(now, granted); + if (_sleepHrSnapshot != null) notifyListeners(); + } + return; + } } - final granted = await _hc.grantedReadTypes(); - if (granted.isEmpty) { - _setNoData(); - return; + final sleepMinutes = await _lastNightSleepMinutes(now, granted); + final restingHr = await _todayRestingHr(now, granted); + final hrv = await _todayHrv(now, granted); + + // Build overnight HR snapshot (best-effort; failure must not affect score). + try { + _sleepHrSnapshot = await _buildSleepHrSnapshot(now, granted); + } catch (e) { + debugPrint('[Readiness] _buildSleepHrSnapshot failed (non-fatal): $e'); + _sleepHrSnapshot = null; } + debugPrint('[Readiness] refresh: today → sleepMinutes=$sleepMinutes restingHr=$restingHr hrv=$hrv'); final baseline = await _baselineFor(todayKey, now, granted); + debugPrint('[Readiness] refresh: baseline → ' + 'avgSleep=${baseline.avgSleepMinutes?.toStringAsFixed(0)} (${baseline.sleepNights} nights) ' + 'avgRhr=${baseline.avgRestingHr?.toStringAsFixed(1)} (${baseline.rhrDays} days) ' + 'avgHrv=${baseline.avgHrvMs?.toStringAsFixed(1)} (${baseline.hrvDays} days)'); + final snapshot = _calculator.compute( today: now, baseline: baseline, - lastNightSleepMinutes: await _lastNightSleepMinutes(now, granted), - todayRestingHr: await _todayRestingHr(now, granted), - todayHrvMs: await _todayHrv(now, granted), + lastNightSleepMinutes: sleepMinutes, + todayRestingHr: restingHr, + todayHrvMs: hrv, ); + debugPrint('[Readiness] refresh: snapshot score=${snapshot.score} band=${snapshot.band} ' + 'sleepScore=${snapshot.sleepScore} rhrScore=${snapshot.rhrScore} hrvScore=${snapshot.hrvScore}'); + // Build human-readable trace for the in-app debug panel. + final need = ReadinessCalculator.minBaselineSamples; + final buf = StringBuffer(); + buf.writeln('Granted: ${granted.map((e) => e.name).join(', ')}'); + buf.writeln(''); + buf.writeln('TODAY:'); + buf.writeln(' sleep : ${sleepMinutes != null ? "${sleepMinutes}min" : "— (no data)"}' + '${!granted.contains(HealthReadType.sleep) ? " [no perm]" : ""}'); + buf.writeln(' RHR : ${restingHr != null ? "${restingHr.toStringAsFixed(1)} bpm" : "— (no data)"}' + '${!granted.contains(HealthReadType.restingHeartRate) ? " [no perm]" : ""}'); + buf.writeln(' HRV : ${hrv != null ? "${hrv.toStringAsFixed(1)} ms" : "— (no data)"}' + '${!granted.contains(HealthReadType.hrv) ? " [no perm]" : ""}'); + buf.writeln(''); + buf.writeln('BASELINE (14d, need ≥$need samples):'); + buf.writeln(' sleep : ${baseline.avgSleepMinutes?.toStringAsFixed(0) ?? "—"}min' + ' · ${baseline.sleepNights} nights' + ' ${baseline.sleepNights >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(' RHR : ${baseline.avgRestingHr?.toStringAsFixed(1) ?? "—"} bpm' + ' · ${baseline.rhrDays} days' + ' ${baseline.rhrDays >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(' HRV : ${baseline.avgHrvMs?.toStringAsFixed(1) ?? "—"} ms' + ' · ${baseline.hrvDays} days' + ' ${baseline.hrvDays >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(''); + buf.writeln('SLEEP HR:'); + if (_sleepHrSnapshot != null) { + final sh = _sleepHrSnapshot!; + buf.writeln(' segments=${sh.segments.length} p95=${sh.p95Bpm}bpm' + ' stages=${sh.stageStats.map((s) => s.stage).join(",")}'); + } else { + buf.writeln(' — no snapshot (need heartRate perm + sleep data)'); + } + buf.writeln(''); + buf.writeln('SCORES:'); + buf.writeln(' sleep=${snapshot.sleepScore ?? "—"} rhr=${snapshot.rhrScore ?? "—"} hrv=${snapshot.hrvScore ?? "—"}'); + buf.writeln(' overall=${snapshot.score ?? "null"} band=${snapshot.band?.name ?? "—"}'); if (snapshot.score == null) { + buf.writeln(''); + buf.writeln('⚠ Score null: a component needs both today\'s data'); + buf.writeln(' AND ≥$need baseline days to contribute.'); + } + _debugTrace = buf.toString().trimRight(); + + if (snapshot.score == null) { + debugPrint('[Readiness] refresh: score null → noData ' + '(need ${ReadinessCalculator.minBaselineSamples}+ baseline days; ' + 'have sleep=${baseline.sleepNights} rhr=${baseline.rhrDays} hrv=${baseline.hrvDays})'); _setNoData(); return; } @@ -88,11 +184,134 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { await _storage.saveSetting(_snapshotKey, jsonEncode(snapshot.toJson())); notifyListeners(); } catch (e) { - debugPrint('ReadinessManager: refresh failed: $e'); + debugPrint('[Readiness] refresh failed: $e'); + _debugTrace = 'refresh() threw: $e'; _setNoData(); } } + /// Builds an overnight HR snapshot for the Sleep HR chart. + /// Returns null when HR permission is missing or no samples exist. + Future _buildSleepHrSnapshot( + DateTime now, + Set granted, + ) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + if (!granted.contains(HealthReadType.sleep)) return null; + + final day = DateTime(now.year, now.month, now.day); + + // Try last night first; fall back to the night before if no data yet + // (covers mornings where the watch hasn't synced yet). + List periods = []; + DateTime windowStart = day.subtract(const Duration(hours: 6)); + DateTime windowEnd = day.add(const Duration(hours: 12)); + + periods = await _hc.readSleepSessions(windowStart, windowEnd); + if (periods.isEmpty) { + windowStart = windowStart.subtract(const Duration(days: 1)); + windowEnd = windowEnd.subtract(const Duration(days: 1)); + periods = await _hc.readSleepSessions(windowStart, windowEnd); + debugPrint('[Readiness] sleepHR: no data for last night — fell back to night before'); + } + if (periods.isEmpty) return null; + + // Use the earliest start and latest end across all records. + final sleepStart = periods.map((p) => p.start).reduce((a, b) => a.isBefore(b) ? a : b); + final sleepEnd = periods.map((p) => p.end).reduce((a, b) => a.isAfter(b) ? a : b); + + // Read HR samples covering the full sleep window (+ 15 min buffer). + final samples = await _hc.readHeartRateSamples( + sleepStart.subtract(const Duration(minutes: 15)), + sleepEnd.add(const Duration(minutes: 15)), + ); + if (samples.isEmpty) return null; + + // Flatten all stage intervals from all periods into one sorted list. + final allIntervals = periods + .expand((p) => p.stageTimeline) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + + // Assign each HR sample a stage by matching against intervals. + String stageAt(DateTime t) { + for (final iv in allIntervals) { + if (!t.isBefore(iv.start) && t.isBefore(iv.end)) return iv.stage; + } + return 'awake'; + } + + // Bucket samples into 10-minute windows aligned to sleepStart. + final segmentMap = >{}; + for (final s in samples) { + final offsetMin = s.time.difference(sleepStart).inMinutes; + if (offsetMin < 0) continue; + final bucket = (offsetMin ~/ 10) * 10; + segmentMap.putIfAbsent(bucket, () => []); + segmentMap[bucket]!.add((bpm: s.value.round(), stage: stageAt(s.time))); + } + + // Build ordered SleepHrSegment list (skip buckets with < 2 samples). + final segments = []; + final sortedBuckets = segmentMap.keys.toList()..sort(); + for (final bucket in sortedBuckets) { + final entries = segmentMap[bucket]!; + if (entries.length < 2) continue; + final bpms = entries.map((e) => e.bpm).toList()..sort(); + final stageCounts = {}; + for (final e in entries) { + stageCounts[e.stage] = (stageCounts[e.stage] ?? 0) + 1; + } + final dominantStage = stageCounts.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + segments.add(SleepHrSegment( + windowStart: sleepStart.add(Duration(minutes: bucket)), + minBpm: bpms.first, + maxBpm: bpms.last, + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + stage: dominantStage, + )); + } + if (segments.isEmpty) return null; + + // P95 across all samples. + final allBpms = samples.map((s) => s.value.round()).toList()..sort(); + final p5Bpm = allBpms[(allBpms.length * 0.05).floor().clamp(0, allBpms.length - 1)]; + final p95Bpm = allBpms[(allBpms.length * 0.95).floor().clamp(0, allBpms.length - 1)]; + + // Per-stage stats (min 3 samples required). + final byStage = >{}; + for (final s in samples) { + final stage = stageAt(s.time); + byStage.putIfAbsent(stage, () => []); + byStage[stage]!.add(s.value.round()); + } + final stageStats = []; + for (final entry in byStage.entries) { + final bpms = entry.value..sort(); + if (bpms.length < 3) continue; + stageStats.add(SleepStageStats( + stage: entry.key, + minBpm: bpms.first, + p25Bpm: bpms[(bpms.length * 0.25).floor()], + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + p75Bpm: bpms[(bpms.length * 0.75).floor()], + maxBpm: bpms.last, + sampleCount: bpms.length, + )); + } + + return SleepHrSnapshot( + sleepStart: sleepStart, + sleepEnd: sleepEnd, + p5Bpm: p5Bpm, + p95Bpm: p95Bpm, + segments: segments, + stageStats: stageStats, + ); + } + void _setNoData() { _snapshot = null; _status = ReadinessStatus.noData; @@ -179,14 +398,16 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { return baseline; } - /// One value per night: the longest sleep period attributed to the day it - /// ends on, so split records don't count as separate nights. + /// Total minutes per night, bucketed by the day the session ENDS on. + /// + /// Health Connect (Pixel Watch, etc.) writes multiple records per night — + /// one per sleep stage or one per awakening gap. Summing gives the real + /// nightly total; taking max severely under-counts fragmented recordings. List _nightlySleepMinutes(List periods) { final byNight = {}; for (final p in periods) { final key = ReadinessCalculator.dateKey(p.end); - final minutes = p.minutes; - if (minutes > (byNight[key] ?? 0)) byNight[key] = minutes; + byNight[key] = (byNight[key] ?? 0) + p.minutes; } return byNight.values.map((m) => m.toDouble()).toList(); } @@ -213,7 +434,15 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { day.subtract(const Duration(hours: 6)), day.add(const Duration(hours: 12)), ); - return _calculator.lastNightSleep(now, periods)?.minutes; + for (final p in periods) { + final stageInfo = p.hasStages + ? 'L=${p.lightMinutes} D=${p.deepMinutes} R=${p.remMinutes} A=${p.awakeMinutes}' + : 'no stages'; + debugPrint('[Readiness] period ${p.start.toLocal().hour}:${p.start.toLocal().minute.toString().padLeft(2, '0')}' + '→${p.end.toLocal().hour}:${p.end.toLocal().minute.toString().padLeft(2, '0')}' + ' actual=${p.minutes}min ($stageInfo)'); + } + return _calculator.lastNightSleepMinutes(now, periods); } /// Latest resting-HR record in the past 24h; falls back to the minimum @@ -249,9 +478,10 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { Future _todayHrv(DateTime now, Set granted) async { if (!granted.contains(HealthReadType.hrv)) return null; final samples = await _hc.readHrvRmssd( - now.subtract(const Duration(hours: 24)), + now.subtract(const Duration(hours: 48)), now, ); + debugPrint('[Readiness] HRV samples (48h): ${samples.length}'); if (samples.isEmpty) return null; samples.sort((a, b) => a.time.compareTo(b.time)); return samples.last.value; diff --git a/workout-logger/lib/services/utils/readiness_calculator.dart b/workout-logger/lib/services/utils/readiness_calculator.dart index 7087748..74a1259 100644 --- a/workout-logger/lib/services/utils/readiness_calculator.dart +++ b/workout-logger/lib/services/utils/readiness_calculator.dart @@ -27,19 +27,31 @@ class ReadinessCalculator { static const int highBandThreshold = 75; static const int moderateBandThreshold = 50; - /// Picks "last night's" sleep: the longest period overlapping the window - /// yesterday 18:00 → today 12:00 local. Returns null when nothing overlaps. - SleepPeriod? lastNightSleep(DateTime today, List periods) { + /// Returns total minutes of sleep in the window yesterday 18:00 → today 12:00. + /// + /// Health Connect (especially Pixel Watch) writes sleep as multiple records + /// per night — one per stage or one per awakening gap. Summing gives the true + /// sleep total; taking the longest single record under-counts badly. + int? lastNightSleepMinutes(DateTime today, List periods) { final day = DateTime(today.year, today.month, today.day); final windowStart = day.subtract(const Duration(hours: 6)); // 18:00 prev day final windowEnd = day.add(const Duration(hours: 12)); - SleepPeriod? longest; + var totalMinutes = 0; for (final p in periods) { if (!p.end.isAfter(windowStart) || !p.start.isBefore(windowEnd)) continue; - if (longest == null || p.minutes > longest.minutes) longest = p; + totalMinutes += p.minutes; } - return longest; + return totalMinutes > 0 ? totalMinutes : null; + } + + // Keep backward-compatible name used in tests; delegates to the new method. + SleepPeriod? lastNightSleep(DateTime today, List periods) { + final minutes = lastNightSleepMinutes(today, periods); + if (minutes == null) return null; + // Return a synthetic period whose .minutes equals the summed total. + final now = DateTime(today.year, today.month, today.day); + return SleepPeriod(start: now, end: now.add(Duration(minutes: minutes))); } ReadinessSnapshot compute({ From ea328a88f58d8f51bfe8ac0fa5b911ae534a17ce Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:37:23 +0530 Subject: [PATCH 5/7] feat: Implement HealthHistoryManager for sleep and heart rate data management - Added HealthHistoryManager to handle sleep and heart rate data aggregation and caching. - Introduced utility functions for date range calculations and stepping through time periods. - Implemented methods to fetch and cache daily heart rate snapshots and aggregated sleep data. - Created a new sleep_hr_builder utility for building sleep HR snapshots for any night. - Updated ReadinessManager to utilize the new HealthHistoryManager for daily HR snapshots. - Added unit tests for HealthHistoryManager covering various scenarios for sleep and heart rate data. --- workout-logger/lib/main.dart | 5 + .../lib/models/sleep_hr_models.dart | 131 ++++ .../lib/screens/heart_rate_detail_screen.dart | 299 +++++++++ workout-logger/lib/screens/home_screen.dart | 17 +- .../lib/screens/sleep_detail_screen.dart | 259 ++++++++ .../lib/screens/widgets/health_bar_chart.dart | 617 ++++++++++++++++++ .../screens/widgets/health_detail_shell.dart | 205 ++++++ .../lib/screens/widgets/heart_rate_card.dart | 193 ++++++ .../lib/screens/widgets/rf_widgets.dart | 16 + .../lib/screens/widgets/sleep_hr_card.dart | 20 +- ...eep_hr_sheet.dart => sleep_hr_charts.dart} | 373 +++++------ .../managers/health_history_manager.dart | 287 ++++++++ .../services/managers/readiness_manager.dart | 150 +---- .../lib/services/utils/sleep_hr_builder.dart | 210 ++++++ .../test/health_history_manager_test.dart | 200 ++++++ .../test/readiness_manager_test.dart | 9 +- 16 files changed, 2626 insertions(+), 365 deletions(-) create mode 100644 workout-logger/lib/screens/heart_rate_detail_screen.dart create mode 100644 workout-logger/lib/screens/sleep_detail_screen.dart create mode 100644 workout-logger/lib/screens/widgets/health_bar_chart.dart create mode 100644 workout-logger/lib/screens/widgets/health_detail_shell.dart create mode 100644 workout-logger/lib/screens/widgets/heart_rate_card.dart rename workout-logger/lib/screens/widgets/{sleep_hr_sheet.dart => sleep_hr_charts.dart} (65%) create mode 100644 workout-logger/lib/services/managers/health_history_manager.dart create mode 100644 workout-logger/lib/services/utils/sleep_hr_builder.dart create mode 100644 workout-logger/test/health_history_manager_test.dart diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 6da7812..199b39d 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -24,6 +24,7 @@ import 'services/managers/history_manager.dart'; import 'services/managers/health_sync_manager.dart'; import 'services/managers/pr_manager.dart'; import 'services/managers/readiness_manager.dart'; +import 'services/managers/health_history_manager.dart'; import 'services/managers/conversation_manager.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -71,6 +72,9 @@ class WorkoutLoggerApp extends StatelessWidget { // readiness setting so refresh() is a no-op until the user opts in. static final ReadinessManager _readinessManager = ReadinessManager(_healthConnectService, _storageService, _settingsProvider); + // Serves arbitrary-range sleep/HR data to the detail screens. + static final HealthHistoryManager _healthHistoryManager = + HealthHistoryManager(_healthConnectService, _storageService); static final GeminiAiService _geminiService = GeminiAiService(storage: _storageService); static final ConversationManager _conversationManager = @@ -102,6 +106,7 @@ class WorkoutLoggerApp extends StatelessWidget { ChangeNotifierProvider.value(value: _historyManager), ChangeNotifierProvider.value(value: _prManager), ChangeNotifierProvider.value(value: _readinessManager), + Provider.value(value: _healthHistoryManager), // GeminiAiService is the single AI backend instance. It's a ChangeNotifier // (settings UI watches isConfigured/model), so it's provided as such. // Consumers that should depend on the abstraction (the coach ViewModel, diff --git a/workout-logger/lib/models/sleep_hr_models.dart b/workout-logger/lib/models/sleep_hr_models.dart index 19db22e..f7a82d7 100644 --- a/workout-logger/lib/models/sleep_hr_models.dart +++ b/workout-logger/lib/models/sleep_hr_models.dart @@ -81,3 +81,134 @@ class SleepHrSnapshot { SleepStageStats? statsFor(String stage) => stageStats.where((s) => s.stage == stage).firstOrNull; } + +// ───────────────────────────────────────────────────────────────────────────── +// History & granularity models — added for the Sleep/HR detail screens. +// Like the snapshots above, these are computed at runtime from Health Connect +// and are not persisted (the heavy per-day HR results may be cached as JSON, +// but that is the manager's concern, not a contract here). +// ───────────────────────────────────────────────────────────────────────────── + +/// Granularity for the Sleep / Heart-rate detail screens. +enum HealthGranularity { day, week, month, year } + +extension HealthGranularityX on HealthGranularity { + /// Short toggle label. + String get label => switch (this) { + HealthGranularity.day => 'Day', + HealthGranularity.week => 'Week', + HealthGranularity.month => 'Month', + HealthGranularity.year => 'Year', + }; +} + +/// One ~30-minute window of all-day HR (min / max / avg). +class HrBucket { + final DateTime windowStart; + final int minBpm; + final int maxBpm; + final double avgBpm; + + const HrBucket({ + required this.windowStart, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + }); + + Map toJson() => { + 't': windowStart.toIso8601String(), + 'mn': minBpm, + 'mx': maxBpm, + 'av': avgBpm, + }; + + factory HrBucket.fromJson(Map j) => HrBucket( + windowStart: DateTime.parse(j['t'] as String), + minBpm: (j['mn'] as num).toInt(), + maxBpm: (j['mx'] as num).toInt(), + avgBpm: (j['av'] as num).toDouble(), + ); +} + +/// Complete all-day HR picture for one calendar day — backs the Heart-rate +/// card (compact) and the Day tab of HeartRateDetailScreen. +class HrDaySnapshot { + final DateTime day; + + /// Resting HR for the day (RHR record if present, else morning-min fallback). + final int? restingBpm; + final int minBpm; + final int maxBpm; + final double avgBpm; + + /// ~30-minute buckets ordered chronologically. + final List buckets; + + const HrDaySnapshot({ + required this.day, + required this.restingBpm, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.buckets, + }); + + Map toJson() => { + 'day': day.toIso8601String(), + 'rest': restingBpm, + 'mn': minBpm, + 'mx': maxBpm, + 'av': avgBpm, + 'b': buckets.map((b) => b.toJson()).toList(), + }; + + factory HrDaySnapshot.fromJson(Map j) => HrDaySnapshot( + day: DateTime.parse(j['day'] as String), + restingBpm: (j['rest'] as num?)?.toInt(), + minBpm: (j['mn'] as num).toInt(), + maxBpm: (j['mx'] as num).toInt(), + avgBpm: (j['av'] as num).toDouble(), + buckets: (j['b'] as List) + .map((e) => HrBucket.fromJson(e as Map)) + .toList(), + ); +} + +/// One aggregated sleep bar (a night, or a month in the year view). +class SleepDayBar { + final DateTime date; + final int totalMinutes; + final int deepMin; + final int remMin; + final int lightMin; + final int awakeMin; + + const SleepDayBar({ + required this.date, + required this.totalMinutes, + required this.deepMin, + required this.remMin, + required this.lightMin, + required this.awakeMin, + }); +} + +/// One aggregated HR range bar (a day, or a month in the year view). +class HrRangeBar { + final DateTime date; + final String label; + final int minBpm; + final int maxBpm; + final double avgBpm; + final int? restingBpm; + + const HrRangeBar({ + required this.date, + required this.label, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.restingBpm, + }); +} diff --git a/workout-logger/lib/screens/heart_rate_detail_screen.dart b/workout-logger/lib/screens/heart_rate_detail_screen.dart new file mode 100644 index 0000000..047209e --- /dev/null +++ b/workout-logger/lib/screens/heart_rate_detail_screen.dart @@ -0,0 +1,299 @@ +// heart_rate_detail_screen.dart — full-screen all-day heart-rate history. +// +// Day : ~30-min min–max HR bars + resting line for the selected day. +// Week / Month / Year : daily/monthly min–max range bars with resting markers. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/sleep_hr_models.dart'; +import '../services/managers/health_history_manager.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/health_bar_chart.dart'; +import 'widgets/health_detail_shell.dart'; +import 'widgets/rf_widgets.dart'; + +class HeartRateDetailScreen extends StatefulWidget { + const HeartRateDetailScreen({super.key, this.initialDate}); + + final DateTime? initialDate; + + @override + State createState() => _HeartRateDetailScreenState(); +} + +class _HeartRateDetailScreenState extends State { + late HealthHistoryManager _mgr; + HealthGranularity _g = HealthGranularity.day; + late DateTime _anchor; + late Set _workoutDays; + Future? _future; + + @override + void initState() { + super.initState(); + final now = widget.initialDate ?? DateTime.now(); + _anchor = DateTime(now.year, now.month, now.day); + final sessions = context.read().sessions; + _workoutDays = sessions.map((s) => HealthHistoryManager.dateKey(s.date)).toSet(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mgr = context.read(); + _future ??= _load(); + } + + Future _load() => _g == HealthGranularity.day + ? _mgr.hrDay(_anchor) + : _mgr.hrBars(_anchor, _g); + + bool get _canGoNext { + final today = DateTime.now(); + return HealthHistoryManager.stepBy(_anchor, _g, 1) + .isBefore(DateTime(today.year, today.month, today.day + 1)); + } + + void _step(int dir) => setState(() { + _anchor = HealthHistoryManager.stepBy(_anchor, _g, dir); + _future = _load(); + }); + + void _setG(HealthGranularity g) => setState(() { + _g = g; + _future = _load(); + }); + + String get _dateLabel { + switch (_g) { + case HealthGranularity.day: + return DateFormat('EEE · MMM d').format(_anchor); + case HealthGranularity.week: + final start = _anchor.subtract(const Duration(days: 6)); + return '${DateFormat('MMM d').format(start)} – ${DateFormat('MMM d').format(_anchor)}'; + case HealthGranularity.month: + return DateFormat('MMMM yyyy').format(_anchor); + case HealthGranularity.year: + return DateFormat('yyyy').format(_anchor); + } + } + + @override + Widget build(BuildContext context) { + return HealthDetailShell( + title: 'Heart rate', + icon: Icons.favorite_rounded, + iconColor: AppColors.accent, + dateLabel: _dateLabel, + granularity: _g, + onGranularityChanged: _setG, + onPrev: () => _step(-1), + onNext: () => _step(1), + canGoNext: _canGoNext, + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const SizedBox(height: 220, child: Center(child: RFLoadingDots())); + } + if (_g == HealthGranularity.day) { + final data = snap.data as HrDaySnapshot?; + if (data == null) return const _Empty('No heart-rate data for this day.'); + return _DayBody(snapshot: data); + } + final bars = (snap.data as List?) ?? const []; + return _AggBody(bars: bars, workoutDays: _workoutDays, granularity: _g); + }, + ), + ); + } +} + +class _DayBody extends StatelessWidget { + const _DayBody({required this.snapshot}); + final HrDaySnapshot snapshot; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _Pill(label: 'Resting', value: snapshot.restingBpm?.toString() ?? '—', color: AppColors.secondary), + const SizedBox(width: 6), + _Pill(label: 'Min', value: '${snapshot.minBpm}', color: AppColors.textMuted), + const SizedBox(width: 6), + _Pill(label: 'Max', value: '${snapshot.maxBpm}', color: AppColors.accent), + const SizedBox(width: 6), + _Pill(label: 'Avg', value: '${snapshot.avgBpm.round()}', color: AppColors.primary), + ], + ), + const SizedBox(height: 12), + GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'All-day heart rate · 30-min bars', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + Text('bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11)), + ], + ), + const SizedBox(height: 8), + HrDayChart(snapshot: snapshot), + const SizedBox(height: 10), + Wrap( + spacing: 12, + children: [ + _legend('Min–max', AppColors.secondary), + _legendDash('Resting', AppColors.secondary), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); + + Widget _legendDash(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 14, height: 2, color: c), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _AggBody extends StatelessWidget { + const _AggBody({required this.bars, required this.workoutDays, required this.granularity}); + + final List bars; + final Set workoutDays; + final HealthGranularity granularity; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.maxBpm > 0).toList(); + final resting = withData.where((b) => b.restingBpm != null).map((b) => b.restingBpm!).toList(); + final avgRest = resting.isEmpty ? null : (resting.reduce((a, b) => a + b) / resting.length).round(); + final mn = withData.isEmpty ? null : withData.map((b) => b.minBpm).reduce((a, b) => a < b ? a : b); + final mx = withData.isEmpty ? null : withData.map((b) => b.maxBpm).reduce((a, b) => a > b ? a : b); + final unit = granularity == HealthGranularity.year ? 'monthly' : 'daily'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _Pill(label: 'Avg resting', value: avgRest?.toString() ?? '—', color: AppColors.secondary), + const SizedBox(width: 6), + _Pill(label: 'Min', value: mn?.toString() ?? '—', color: AppColors.textMuted), + const SizedBox(width: 6), + _Pill(label: 'Max', value: mx?.toString() ?? '—', color: AppColors.accent), + ], + ), + const SizedBox(height: 12), + GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$unit range · resting ●', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 12), + HrRangeChart(bars: bars, workoutDays: workoutDays), + const SizedBox(height: 10), + Wrap( + spacing: 12, + children: [ + _legend('Min–max', AppColors.primary), + _legend('Resting', AppColors.secondary), + _legend('Workout day', AppColors.accent), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + Text( + value, + style: GoogleFonts.geistMono(color: color, fontSize: 16, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(this.message); + final String message; + @override + Widget build(BuildContext context) => GlassCard( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), + child: Center( + child: Text(message, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13)), + ), + ); +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index cda82fb..f46439f 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -21,6 +21,7 @@ import 'widgets/workout_conflict_dialog.dart'; import 'ai_coach_screen.dart'; import 'widgets/readiness_card.dart'; import 'widgets/sleep_hr_card.dart'; +import 'widgets/heart_rate_card.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/sparkline_painter.dart'; import 'widgets/activity_heatmap.dart'; @@ -209,6 +210,7 @@ class _DashboardTab extends StatelessWidget { const SizedBox(height: 16), const ReadinessCard(), const SleepHrCard(), + const HeartRateCard(), _buildStatsGrid(context, provider), const SizedBox(height: 16), _buildHeatmapCard(context, provider), @@ -1184,19 +1186,8 @@ class _RoutineSelectorSheet extends StatelessWidget { // ── Route helper ────────────────────────────────────────────────────────────── -PageRouteBuilder _slide(Widget page) { - return PageRouteBuilder( - pageBuilder: (_, __, ___) => page, - transitionsBuilder: (_, anim, __, child) => SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), - child: child, - ), - transitionDuration: const Duration(milliseconds: 300), - ); -} +// Thin alias to the shared slideRoute helper in rf_widgets.dart. +PageRouteBuilder _slide(Widget page) => slideRoute(page); // ── Weekly Insights Card ────────────────────────────────────────────────────── diff --git a/workout-logger/lib/screens/sleep_detail_screen.dart b/workout-logger/lib/screens/sleep_detail_screen.dart new file mode 100644 index 0000000..ccc6f1c --- /dev/null +++ b/workout-logger/lib/screens/sleep_detail_screen.dart @@ -0,0 +1,259 @@ +// sleep_detail_screen.dart — full-screen sleep history. +// +// Day : overnight HR breakdown (SleepHrDayView) for the selected night. +// Week / Month / Year : stacked sleep-duration bars (SleepBarsChart) with an +// 8h goal line and workout-day highlights. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/sleep_hr_models.dart'; +import '../services/managers/health_history_manager.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/health_bar_chart.dart'; +import 'widgets/health_detail_shell.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/sleep_hr_charts.dart'; + +class SleepDetailScreen extends StatefulWidget { + const SleepDetailScreen({super.key, this.initialDate}); + + final DateTime? initialDate; + + @override + State createState() => _SleepDetailScreenState(); +} + +class _SleepDetailScreenState extends State { + late HealthHistoryManager _mgr; + HealthGranularity _g = HealthGranularity.day; + late DateTime _anchor; + late Set _workoutDays; + Future? _future; + + @override + void initState() { + super.initState(); + final now = widget.initialDate ?? DateTime.now(); + _anchor = DateTime(now.year, now.month, now.day); + final sessions = context.read().sessions; + _workoutDays = sessions.map((s) => HealthHistoryManager.dateKey(s.date)).toSet(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mgr = context.read(); + _future ??= _load(); + } + + Future _load() => _g == HealthGranularity.day + ? _mgr.sleepNight(_anchor) + : _mgr.sleepBars(_anchor, _g); + + bool get _canGoNext { + final today = DateTime.now(); + return HealthHistoryManager.stepBy(_anchor, _g, 1) + .isBefore(DateTime(today.year, today.month, today.day + 1)); + } + + void _step(int dir) { + setState(() { + _anchor = HealthHistoryManager.stepBy(_anchor, _g, dir); + _future = _load(); + }); + } + + void _setG(HealthGranularity g) { + setState(() { + _g = g; + _future = _load(); + }); + } + + String get _dateLabel { + switch (_g) { + case HealthGranularity.day: + final prev = _anchor.subtract(const Duration(days: 1)); + return '${DateFormat('MMM d').format(prev)} → ${DateFormat('d').format(_anchor)}'; + case HealthGranularity.week: + final start = _anchor.subtract(const Duration(days: 6)); + return '${DateFormat('MMM d').format(start)} – ${DateFormat('MMM d').format(_anchor)}'; + case HealthGranularity.month: + return DateFormat('MMMM yyyy').format(_anchor); + case HealthGranularity.year: + return DateFormat('yyyy').format(_anchor); + } + } + + @override + Widget build(BuildContext context) { + return HealthDetailShell( + title: 'Sleep', + icon: Icons.nightlight_round, + iconColor: kSleepStageColors['rem']!, + dateLabel: _dateLabel, + granularity: _g, + onGranularityChanged: _setG, + onPrev: () => _step(-1), + onNext: () => _step(1), + canGoNext: _canGoNext, + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const _Loading(); + } + if (_g == HealthGranularity.day) { + final data = snap.data as SleepHrSnapshot?; + if (data == null) return const _Empty('No sleep data for this night.'); + return _DayBody(snapshot: data); + } + final bars = (snap.data as List?) ?? const []; + return _AggBody(bars: bars, workoutDays: _workoutDays, granularity: _g); + }, + ), + ); + } +} + +class _DayBody extends StatelessWidget { + const _DayBody({required this.snapshot}); + final SleepHrSnapshot snapshot; + + static DateTime _ist(DateTime dt) => dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + static String _fmt(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m ${dt.hour < 12 ? 'AM' : 'PM'}'; + } + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Asleep · ${_fmt(_ist(snapshot.sleepStart))} – ${_fmt(_ist(snapshot.sleepEnd))} IST', + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 16), + SleepHrDayView(snapshot: snapshot), + ], + ), + ); + } +} + +class _AggBody extends StatelessWidget { + const _AggBody({ + required this.bars, + required this.workoutDays, + required this.granularity, + }); + + final List bars; + final Set workoutDays; + final HealthGranularity granularity; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.totalMinutes > 0).toList(); + final avg = withData.isEmpty + ? 0 + : withData.fold(0, (s, b) => s + b.totalMinutes) ~/ withData.length; + final avgLabel = '${avg ~/ 60}h${(avg % 60).toString().padLeft(2, '0')}'; + final unit = granularity == HealthGranularity.year ? 'per month' : 'per night'; + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sleep duration · $unit', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + Text( + withData.isEmpty ? '—' : 'avg $avgLabel', + style: GoogleFonts.geistMono( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 12), + SleepBarsChart(bars: bars, workoutDays: workoutDays), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _legend('Deep', kSleepStageColors['deep']!), + _legend('REM', kSleepStageColors['rem']!), + _legend('Light', kSleepStageColors['light']!), + _legendDash('8h goal', kSleepStageColors['awake']!), + _legend('Workout day', AppColors.accent), + ], + ), + ], + ), + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); + + Widget _legendDash(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 14, height: 2, color: c), + const SizedBox(width: 4), + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => const SizedBox( + height: 220, + child: Center(child: RFLoadingDots()), + ); +} + +class _Empty extends StatelessWidget { + const _Empty(this.message); + final String message; + @override + Widget build(BuildContext context) => GlassCard( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), + child: Center( + child: Text( + message, + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 13), + ), + ), + ); +} diff --git a/workout-logger/lib/screens/widgets/health_bar_chart.dart b/workout-logger/lib/screens/widgets/health_bar_chart.dart new file mode 100644 index 0000000..1974cd3 --- /dev/null +++ b/workout-logger/lib/screens/widgets/health_bar_chart.dart @@ -0,0 +1,617 @@ +// health_bar_chart.dart — aggregated vertical bar chart for the Week / Month / +// Year tabs of the Sleep & Heart-rate detail screens. +// +// Two public widgets share one interactive painter: +// • SleepBarsChart — stacked sleep-stage duration bars + 8h goal line. +// • HrRangeChart — daily/monthly min–max range bars + resting-HR markers. +// Both highlight bars that fall on a logged-workout day. + +import 'dart:math' show max; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'sleep_hr_charts.dart' show kSleepStageColors; + +/// Default sleep goal used for the dashed reference line (8h). +const int kSleepGoalMinutes = 480; + +// ── Shared bar model ────────────────────────────────────────────────────────── + +class _Segment { + final Color color; + final double from; + final double to; + const _Segment(this.color, this.from, this.to); +} + +class _AggBar { + final String label; + final List<_Segment> segments; // drawn against the value axis + final double? marker; // e.g. resting-HR dot + final bool isWorkout; + final bool hasData; + final List tooltip; + + const _AggBar({ + required this.label, + required this.segments, + required this.tooltip, + this.marker, + this.isWorkout = false, + this.hasData = true, + }); +} + +String _hm(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return m == 0 ? '${h}h' : '${h}h${m.toString().padLeft(2, '0')}'; +} + +// ── Sleep stacked bars ──────────────────────────────────────────────────────── + +class SleepBarsChart extends StatelessWidget { + const SleepBarsChart({ + super.key, + required this.bars, + required this.workoutDays, + this.goalMinutes = kSleepGoalMinutes, + this.height = 180, + }); + + final List bars; + final Set workoutDays; + final int goalMinutes; + final double height; + + @override + Widget build(BuildContext context) { + final aggBars = bars.map((b) { + final light = b.lightMin.toDouble(); + final rem = b.remMin.toDouble(); + final deep = b.deepMin.toDouble(); + // Stack order from baseline up: deep, rem, light. + final segs = <_Segment>[ + _Segment(kSleepStageColors['deep']!, 0, deep), + _Segment(kSleepStageColors['rem']!, deep, deep + rem), + _Segment(kSleepStageColors['light']!, deep + rem, deep + rem + light), + ]; + return _AggBar( + label: _labelFor(b.date), + segments: segs, + hasData: b.totalMinutes > 0, + isWorkout: workoutDays.contains(_key(b.date)), + tooltip: [ + _labelFor(b.date), + '${_hm(b.totalMinutes)} total', + 'Deep ${_hm(b.deepMin)} · REM ${_hm(b.remMin)}', + 'Light ${_hm(b.lightMin)}', + ], + ); + }).toList(); + + final maxTotal = bars.fold(0, (m, b) => max(m, b.totalMinutes)); + final axisMax = (max(maxTotal, goalMinutes) / 60).ceil() * 60.0 + 30; + + return _AggBarChart( + bars: aggBars, + axisMin: 0, + axisMax: axisMax, + gridStep: 120, // every 2h + axisLabel: (v) => '${v ~/ 60}h', + goalLine: goalMinutes.toDouble(), + height: height, + ); + } + + String _labelFor(DateTime d) => + d.day == 1 && _isMonthBar(d) ? _months[d.month - 1] : '${d.day}'; + + // Year bars use the first-of-month date; show month initials there. + bool _isMonthBar(DateTime d) => bars.length == 12; + + static const _months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; +} + +// ── HR range bars ───────────────────────────────────────────────────────────── + +class HrRangeChart extends StatelessWidget { + const HrRangeChart({ + super.key, + required this.bars, + required this.workoutDays, + this.height = 180, + }); + + final List bars; + final Set workoutDays; + final double height; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.maxBpm > 0).toList(); + final dataMin = withData.isEmpty + ? 40 + : withData.map((b) => b.minBpm).reduce((a, b) => a < b ? a : b); + final dataMax = withData.isEmpty + ? 160 + : withData.map((b) => b.maxBpm).reduce((a, b) => a > b ? a : b); + final axisMin = (dataMin / 10).floor() * 10.0 - 5; + final axisMax = (dataMax / 10).ceil() * 10.0 + 5; + + final aggBars = bars.map((b) { + final hasData = b.maxBpm > 0; + return _AggBar( + label: b.label, + hasData: hasData, + isWorkout: workoutDays.contains(_key(b.date)), + marker: b.restingBpm?.toDouble(), + segments: hasData + ? [_Segment(AppColors.primary, b.minBpm.toDouble(), b.maxBpm.toDouble())] + : const [], + tooltip: hasData + ? [ + b.label, + '${b.minBpm}–${b.maxBpm} bpm', + if (b.restingBpm != null) 'resting ${b.restingBpm}', + ] + : [b.label, 'no data'], + ); + }).toList(); + + return _AggBarChart( + bars: aggBars, + axisMin: axisMin, + axisMax: axisMax, + gridStep: 30, + axisLabel: (v) => '${v.round()}', + rangeGradient: true, + height: height, + ); + } +} + +String _key(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +// ── All-day HR (Day tab) ────────────────────────────────────────────────────── + +/// ~30-minute min–max HR bars across one day, with a dashed resting line. +class HrDayChart extends StatefulWidget { + const HrDayChart({super.key, required this.snapshot, this.height = 180}); + + final HrDaySnapshot snapshot; + final double height; + + @override + State createState() => _HrDayChartState(); +} + +class _HrDayChartState extends State { + int? _hovered; + static const _padLeft = 26.0; + + int? _indexAt(Offset local, double width) { + final buckets = widget.snapshot.buckets; + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW || buckets.isEmpty) return null; + return (x / chartW * buckets.length).floor().clamp(0, buckets.length - 1); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hovered = null), + onPanUpdate: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hovered = null), + onPanCancel: () => setState(() => _hovered = null), + child: CustomPaint( + size: Size(width, widget.height), + painter: _HrDayPainter(widget.snapshot, _hovered), + ), + ); + }, + ), + ); + } +} + +class _HrDayPainter extends CustomPainter { + _HrDayPainter(this.snap, this.hovered); + final HrDaySnapshot snap; + final int? hovered; + + static const _padLeft = 26.0; + static const _padTop = 8.0; + static const _padBottom = 20.0; + + @override + void paint(Canvas canvas, Size size) { + final buckets = snap.buckets; + if (buckets.isEmpty) return; + + final axisMin = (snap.minBpm / 10).floor() * 10.0 - 5; + final axisMax = (snap.maxBpm / 10).ceil() * 10.0 + 5; + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + + double yFor(double v) => + _padTop + chartH - ((v - axisMin) / (axisMax - axisMin)) * chartH; + + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var v = (axisMin / 30).ceil() * 30.0; v <= axisMax; v += 30) { + final y = yFor(v); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}', style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + final slotW = chartW / buckets.length; + final barW = (slotW - 1).clamp(1.4, slotW); + + for (var i = 0; i < buckets.length; i++) { + final b = buckets[i]; + final x = _padLeft + i * slotW; + final dim = hovered != null && hovered != i; + final rect = Rect.fromLTWH(x + 0.5, yFor(b.maxBpm.toDouble()), barW, + max(yFor(b.minBpm.toDouble()) - yFor(b.maxBpm.toDouble()), 2)); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(rect) + ..color = Colors.white.withValues(alpha: dim ? 0.3 : 0.8); + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1.5)), paint); + } + + // Resting line. + if (snap.restingBpm != null) { + final ry = yFor(snap.restingBpm!.toDouble()); + final p = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.7) + ..strokeWidth = 1; + for (var x = _padLeft; x < size.width - 4; x += 8) { + canvas.drawLine(Offset(x, ry), Offset(x + 5, ry), p); + } + } + + // X-axis time labels (12a / 6a / 12p / 6p / 11p). + final labelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + const marks = ['12a', '6a', '12p', '6p', '11p']; + for (var i = 0; i < marks.length; i++) { + final x = _padLeft + (i / (marks.length - 1)) * chartW; + final tp = TextPainter( + text: TextSpan(text: marks[i], style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((x - tp.width / 2).clamp(0, size.width - tp.width), size.height - _padBottom + 5)); + } + + // Tooltip. + if (hovered != null) { + final b = buckets[hovered!]; + final t = b.windowStart.toUtc().add(const Duration(hours: 5, minutes: 30)); + final h12 = t.hour == 0 ? 12 : (t.hour > 12 ? t.hour - 12 : t.hour); + final mm = t.minute.toString().padLeft(2, '0'); + final ap = t.hour < 12 ? 'AM' : 'PM'; + final lines = ['$h12:$mm $ap', '${b.minBpm}–${b.maxBpm} bpm', 'avg ${b.avgBpm.round()}']; + final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final painters = lines + .map((l) => TextPainter(text: TextSpan(text: l, style: lineStyle), textDirection: TextDirection.ltr)..layout()) + .toList(); + const padH = 8.0, padV = 6.0, lineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + padH * 2; + final ttH = painters.length * lineH + padV * 2; + final cx = _padLeft + hovered! * slotW + slotW / 2; + final ttX = (cx - ttW / 2).clamp(_padLeft, size.width - 4 - ttW); + const ttY = _padTop + 2.0; + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = AppColors.secondary.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + for (var i = 0; i < painters.length; i++) { + painters[i].paint(canvas, Offset(ttX + padH, ttY + padV + i * lineH)); + } + } + } + + @override + bool shouldRepaint(_HrDayPainter old) => old.snap != snap || old.hovered != hovered; +} + +// ── Interactive chart shell + painter ───────────────────────────────────────── + +class _AggBarChart extends StatefulWidget { + const _AggBarChart({ + required this.bars, + required this.axisMin, + required this.axisMax, + required this.gridStep, + required this.axisLabel, + required this.height, + this.goalLine, + this.rangeGradient = false, + }); + + final List<_AggBar> bars; + final double axisMin; + final double axisMax; + final double gridStep; + final String Function(double) axisLabel; + final double? goalLine; + final bool rangeGradient; + final double height; + + @override + State<_AggBarChart> createState() => _AggBarChartState(); +} + +class _AggBarChartState extends State<_AggBarChart> { + int? _hovered; + + static const _padLeft = 26.0; + + int? _indexAt(Offset local, double width) { + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW || widget.bars.isEmpty) return null; + final idx = (x / chartW * widget.bars.length).floor(); + return idx.clamp(0, widget.bars.length - 1); + } + + @override + Widget build(BuildContext context) { + if (widget.bars.isEmpty) { + return SizedBox( + height: widget.height, + child: Center( + child: Text( + 'No data for this range.', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 12), + ), + ), + ); + } + return SizedBox( + height: widget.height, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hovered = null), + onPanUpdate: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hovered = null), + onPanCancel: () => setState(() => _hovered = null), + child: CustomPaint( + size: Size(width, widget.height), + painter: _AggPainter( + bars: widget.bars, + axisMin: widget.axisMin, + axisMax: widget.axisMax, + gridStep: widget.gridStep, + axisLabel: widget.axisLabel, + goalLine: widget.goalLine, + rangeGradient: widget.rangeGradient, + hovered: _hovered, + ), + ), + ); + }, + ), + ); + } +} + +class _AggPainter extends CustomPainter { + _AggPainter({ + required this.bars, + required this.axisMin, + required this.axisMax, + required this.gridStep, + required this.axisLabel, + required this.goalLine, + required this.rangeGradient, + required this.hovered, + }); + + final List<_AggBar> bars; + final double axisMin; + final double axisMax; + final double gridStep; + final String Function(double) axisLabel; + final double? goalLine; + final bool rangeGradient; + final int? hovered; + + static const _padLeft = 26.0; + static const _padTop = 8.0; + static const _padBottom = 20.0; + + @override + void paint(Canvas canvas, Size size) { + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + final n = bars.length; + final slotW = chartW / n; + final gap = (slotW * 0.32).clamp(2.0, 7.0); + final barW = slotW - gap; + + double yFor(double v) => + _padTop + chartH - ((v - axisMin) / (axisMax - axisMin)) * chartH; + + // Grid + Y labels. + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var v = (axisMin / gridStep).ceil() * gridStep; v <= axisMax; v += gridStep) { + final y = yFor(v); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: axisLabel(v), style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + // Goal line (sleep). + if (goalLine != null && goalLine! >= axisMin && goalLine! <= axisMax) { + final gy = yFor(goalLine!); + final p = Paint() + ..color = kSleepStageColors['awake']!.withValues(alpha: 0.8) + ..strokeWidth = 1; + for (var x = _padLeft; x < size.width - 4; x += 7) { + canvas.drawLine(Offset(x, gy), Offset(x + 4, gy), p); + } + } + + final baselineY = yFor(axisMin); + final labelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final labelEvery = n > 16 ? 5 : (n > 10 ? 2 : 1); + + for (var i = 0; i < n; i++) { + final bar = bars[i]; + final x = _padLeft + i * slotW + gap / 2; + final dim = hovered != null && hovered != i; + + if (bar.hasData) { + for (final seg in bar.segments) { + final yTop = yFor(seg.to); + final yBot = yFor(seg.from); + final paint = Paint()..style = PaintingStyle.fill; + if (rangeGradient) { + paint.shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(Rect.fromLTWH(x, yTop, barW, max(yBot - yTop, 2))); + paint.color = Colors.white.withValues(alpha: dim ? 0.3 : 0.85); + } else { + paint.color = seg.color.withValues(alpha: dim ? 0.3 : 0.88); + } + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, yTop, barW, max(yBot - yTop, 2)), + const Radius.circular(2), + ), + paint, + ); + } + + // Resting marker dot. + if (bar.marker != null) { + final my = yFor(bar.marker!); + canvas.drawCircle( + Offset(x + barW / 2, my), + 2.6, + Paint()..color = AppColors.secondary.withValues(alpha: dim ? 0.4 : 1), + ); + canvas.drawCircle( + Offset(x + barW / 2, my), + 2.6, + Paint() + ..color = AppColors.background + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + } + } + + // Workout-day highlight underline. + if (bar.isWorkout) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x - 1, baselineY + 2, barW + 2, 2.5), + const Radius.circular(1), + ), + Paint()..color = AppColors.accent.withValues(alpha: 0.9), + ); + } + + // X label (subset). + if (i % labelEvery == 0) { + final tp = TextPainter( + text: TextSpan(text: bar.label, style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(x + barW / 2 - tp.width / 2, size.height - _padBottom + 5), + ); + } + } + + // Tooltip. + if (hovered != null) { + _paintTooltip(canvas, size, hovered!, slotW, yFor); + } + } + + void _paintTooltip(Canvas canvas, Size size, int idx, double slotW, double Function(double) yFor) { + final bar = bars[idx]; + final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); + final painters = bar.tooltip + .map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()) + .toList(); + + const padH = 8.0, padV = 6.0, lineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + padH * 2; + final ttH = painters.length * lineH + padV * 2; + + final barCx = _padLeft + idx * slotW + slotW / 2; + var ttX = (barCx - ttW / 2).clamp(_padLeft, size.width - 4 - ttW); + var ttY = _padTop + 2.0; + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = AppColors.primary.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + for (var i = 0; i < painters.length; i++) { + painters[i].paint(canvas, Offset(ttX + padH, ttY + padV + i * lineH)); + } + } + + @override + bool shouldRepaint(_AggPainter old) => old.bars != bars || old.hovered != hovered; +} diff --git a/workout-logger/lib/screens/widgets/health_detail_shell.dart b/workout-logger/lib/screens/widgets/health_detail_shell.dart new file mode 100644 index 0000000..0529699 --- /dev/null +++ b/workout-logger/lib/screens/widgets/health_detail_shell.dart @@ -0,0 +1,205 @@ +// health_detail_shell.dart — shared scaffold for the Sleep & Heart-rate detail +// screens: ambient background, back button, title, prev/next date nav, and the +// Day/Week/Month/Year granularity toggle. The body is supplied by each screen. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class HealthDetailShell extends StatelessWidget { + const HealthDetailShell({ + super.key, + required this.title, + required this.icon, + required this.iconColor, + required this.dateLabel, + required this.granularity, + required this.onGranularityChanged, + required this.onPrev, + required this.onNext, + required this.canGoNext, + required this.child, + }); + + final String title; + final IconData icon; + final Color iconColor; + final String dateLabel; + final HealthGranularity granularity; + final ValueChanged onGranularityChanged; + final VoidCallback onPrev; + final VoidCallback onNext; + final bool canGoNext; + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const Positioned.fill(child: AmbientGlow()), + SafeArea( + child: Column( + children: [ + _header(context), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _GranularityToggle( + value: granularity, + onChanged: onGranularityChanged, + ), + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), + child: child, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, color: AppColors.textSoft), + tooltip: 'Back', + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _NavArrow(icon: Icons.chevron_left_rounded, onTap: onPrev), + const SizedBox(width: 12), + Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: iconColor), + const SizedBox(width: 5), + Text( + title, + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ], + ), + const SizedBox(height: 1), + Text( + dateLabel, + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + const SizedBox(width: 12), + _NavArrow( + icon: Icons.chevron_right_rounded, + onTap: canGoNext ? onNext : null, + ), + ], + ), + ), + const SizedBox(width: 40), // balance the back button + ], + ), + ); + } +} + +class _NavArrow extends StatelessWidget { + const _NavArrow({required this.icon, this.onTap}); + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return GestureDetector( + onTap: onTap, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + size: 18, + color: enabled ? AppColors.textMuted : AppColors.textFaint.withValues(alpha: 0.4), + ), + ), + ); + } +} + +class _GranularityToggle extends StatelessWidget { + const _GranularityToggle({required this.value, required this.onChanged}); + + final HealthGranularity value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: HealthGranularity.values.map((g) { + final active = g == value; + return Expanded( + child: GestureDetector( + onTap: () => onChanged(g), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: active ? AppColors.primary.withValues(alpha: 0.16) : Colors.transparent, + borderRadius: BorderRadius.circular(9), + border: active + ? Border.all(color: AppColors.primary.withValues(alpha: 0.5)) + : Border.all(color: Colors.transparent), + ), + alignment: Alignment.center, + child: Text( + g.label, + style: GoogleFonts.geist( + fontSize: 12, + fontWeight: FontWeight.w600, + color: active ? AppColors.textPrimary : AppColors.textMuted, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/heart_rate_card.dart b/workout-logger/lib/screens/widgets/heart_rate_card.dart new file mode 100644 index 0000000..4487caf --- /dev/null +++ b/workout-logger/lib/screens/widgets/heart_rate_card.dart @@ -0,0 +1,193 @@ +// HeartRateCard — compact all-day HR summary on the dashboard. +// +// Self-hiding: renders SizedBox.shrink() when ReadinessManager has no +// HrDaySnapshot, mirroring SleepHrCard. + +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import '../heart_rate_detail_screen.dart'; +import 'rf_widgets.dart'; + +class HeartRateCard extends StatelessWidget { + const HeartRateCard({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snap = manager.hrDaySnapshot; + if (snap == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + borderColor: AppColors.secondary.withValues(alpha: 0.20), + onTap: () => Navigator.of(context).push( + slideRoute(const HeartRateDetailScreen()), + ), + semanticsLabel: 'Heart rate, resting ${snap.restingBpm ?? '--'} bpm', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.favorite_rounded, size: 13, color: AppColors.accent), + const SizedBox(width: 5), + Text( + 'Heart rate', + style: GoogleFonts.geist( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Today · all-day', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textFaint, size: 20), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _MiniStat( + label: 'Resting', + value: snap.restingBpm?.toString() ?? '—', + unit: 'bpm', + color: AppColors.secondary, + ), + _MiniStat(label: 'Min', value: '${snap.minBpm}', unit: 'bpm', color: AppColors.textMuted), + _MiniStat(label: 'Max', value: '${snap.maxBpm}', unit: 'bpm', color: AppColors.accent), + _MiniStat(label: 'Avg', value: '${snap.avgBpm.round()}', unit: 'bpm', color: AppColors.primary), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 44, + child: CustomPaint( + size: const Size(double.infinity, 44), + painter: _HrSparkline(snap), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: ['12a', '6a', '12p', '6p', 'now'] + .map((l) => Text(l, style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8))) + .toList(), + ), + ], + ), + ), + ); + } +} + +class _MiniStat extends StatelessWidget { + const _MiniStat({required this.label, required this.value, required this.unit, required this.color}); + + final String label; + final String value; + final String unit; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), + const SizedBox(height: 1), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: GoogleFonts.geistMono( + color: color, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + TextSpan( + text: ' $unit', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Compact all-day HR sparkline: min–max range bars + resting baseline. +class _HrSparkline extends CustomPainter { + const _HrSparkline(this.snap); + + final HrDaySnapshot snap; + + @override + void paint(Canvas canvas, Size size) { + final buckets = snap.buckets; + if (buckets.isEmpty) return; + + final lo = snap.minBpm.toDouble() - 4; + final hi = snap.maxBpm.toDouble() + 4; + double yFor(double v) => size.height - ((v - lo) / (hi - lo)) * size.height; + + final n = buckets.length; + final barW = size.width / n; + + for (var i = 0; i < n; i++) { + final b = buckets[i]; + final x = i * barW; + final yTop = yFor(b.maxBpm.toDouble()); + final yBot = yFor(b.minBpm.toDouble()); + final rect = Rect.fromLTWH(x + 0.5, yTop, max(barW - 1, 1), max(yBot - yTop, 2)); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(rect) + ..color = Colors.white.withValues(alpha: 0.7); + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1)), paint); + } + + if (snap.restingBpm != null) { + final ry = yFor(snap.restingBpm!.toDouble()); + final p = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.6) + ..strokeWidth = 1; + for (var x = 0.0; x < size.width; x += 6) { + canvas.drawLine(Offset(x, ry), Offset(min(x + 3, size.width), ry), p); + } + } + } + + @override + bool shouldRepaint(_HrSparkline old) => old.snap != snap; +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index c470f99..c99ea32 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -8,6 +8,22 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; +// ── Route helper ────────────────────────────────────────────────────────────── +// Right-to-left slide push, shared by the home screen and detail entry points. +PageRouteBuilder slideRoute(Widget page) { + return PageRouteBuilder( + pageBuilder: (_, __, ___) => page, + transitionsBuilder: (_, anim, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 300), + ); +} + // ── GlassCard ─────────────────────────────────────────────────────────────── // Soft-futurist glass card — gradient top-to-bottom + subtle inner highlight. class GlassCard extends StatelessWidget { diff --git a/workout-logger/lib/screens/widgets/sleep_hr_card.dart b/workout-logger/lib/screens/widgets/sleep_hr_card.dart index ba172b9..3e1af45 100644 --- a/workout-logger/lib/screens/widgets/sleep_hr_card.dart +++ b/workout-logger/lib/screens/widgets/sleep_hr_card.dart @@ -12,16 +12,9 @@ import 'package:provider/provider.dart'; import '../../models/sleep_hr_models.dart'; import '../../services/managers/readiness_manager.dart'; import '../../theme/app_theme.dart'; +import '../sleep_detail_screen.dart'; import 'rf_widgets.dart'; -import 'sleep_hr_sheet.dart'; - -// Stage colour map — shared with SleepHrSheet. -const Map kSleepStageColors = { - 'deep': Color(0xFF4C8EFF), - 'rem': Color(0xFFA78BFA), - 'light': Color(0xFF34D399), - 'awake': Color(0xFFF59E0B), -}; +import 'sleep_hr_charts.dart' show kSleepStageColors; class SleepHrCard extends StatelessWidget { const SleepHrCard({super.key}); @@ -127,11 +120,10 @@ class SleepHrCard extends StatelessWidget { } void _openSheet(BuildContext context, SleepHrSnapshot snap) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => SleepHrSheet(snapshot: snap), + // Land on the night this snapshot represents (handles the watch-not-synced + // fallback where it's the night before last). + Navigator.of(context).push( + slideRoute(SleepDetailScreen(initialDate: snap.sleepEnd)), ); } diff --git a/workout-logger/lib/screens/widgets/sleep_hr_sheet.dart b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart similarity index 65% rename from workout-logger/lib/screens/widgets/sleep_hr_sheet.dart rename to workout-logger/lib/screens/widgets/sleep_hr_charts.dart index 19e0ef7..1c2c1e2 100644 --- a/workout-logger/lib/screens/widgets/sleep_hr_sheet.dart +++ b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart @@ -1,11 +1,9 @@ -// SleepHrSheet — full overnight-HR detail shown in a bottom sheet. +// sleep_hr_charts.dart — reusable overnight-HR chart widgets. // -// Sections (top → bottom): -// 1. Handle + title + subtitle (times in IST) -// 2. Key stat chips (P5 / P95 / Deep avg / REM avg) -// 3. Interactive 10-minute bar chart — tap/drag to see segment tooltip -// 4. Stage timeline strip + legend -// 5. "HR range by stage" horizontal distribution chart +// Extracted from the old SleepHrSheet so the Day tab of SleepDetailScreen and +// the dashboard card can share the same painters. `SleepHrDayView` composes the +// full day breakdown (stat pills + interactive bar chart + stage timeline + +// legend + HR-range-by-stage distribution). import 'dart:math' show min, max; @@ -14,144 +12,90 @@ import 'package:google_fonts/google_fonts.dart'; import '../../models/sleep_hr_models.dart'; import '../../theme/app_theme.dart'; -import 'sleep_hr_card.dart' show kSleepStageColors; -class SleepHrSheet extends StatelessWidget { - const SleepHrSheet({super.key, required this.snapshot}); +/// Stage colour map — shared across the sleep widgets. +const Map kSleepStageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; - final SleepHrSnapshot snapshot; +const _stageOrder = ['awake', 'rem', 'light', 'deep']; +const _stageLabels = { + 'awake': 'Awake', + 'rem': 'REM', + 'light': 'Light', + 'deep': 'Deep', +}; - static const _stageOrder = ['awake', 'rem', 'light', 'deep']; - static const _stageLabels = { - 'awake': 'Awake', - 'rem': 'REM', - 'light': 'Light', - 'deep': 'Deep', - }; - - static DateTime _toIst(DateTime dt) => - dt.toUtc().add(const Duration(hours: 5, minutes: 30)); - - static String _fmtTime(DateTime dt) { - final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; - final m = dt.minute.toString().padLeft(2, '0'); - final period = dt.hour < 12 ? 'AM' : 'PM'; - return '$h:$m $period'; - } +DateTime _toIst(DateTime dt) => dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + +/// Full Day-view breakdown for one overnight HR snapshot. +class SleepHrDayView extends StatelessWidget { + const SleepHrDayView({super.key, required this.snapshot}); + + final SleepHrSnapshot snapshot; @override Widget build(BuildContext context) { - final remAvg = snapshot.statsFor('rem')?.avgBpm; + final remAvg = snapshot.statsFor('rem')?.avgBpm; final deepAvg = snapshot.statsFor('deep')?.avgBpm; - return DraggableScrollableSheet( - initialChildSize: 0.88, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (_, controller) => Container( - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), - ), - child: Column( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stat pills row + Row( children: [ - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 4), - child: Container( - width: 36, height: 4, - decoration: BoxDecoration( - color: AppColors.glassBorderStrong, - borderRadius: BorderRadius.circular(AppRadius.full), - ), + _StatPill(label: 'P5', value: '${snapshot.p5Bpm} bpm', color: AppColors.success), + const SizedBox(width: 6), + _StatPill(label: 'P95', value: '${snapshot.p95Bpm} bpm', color: AppColors.primary), + if (deepAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'Deep avg', + value: '${deepAvg.round()} bpm', + color: kSleepStageColors['deep']!, ), - ), - Expanded( - child: ListView( - controller: controller, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), - children: [ - // Title - Text( - 'Sleep heart rate', - style: GoogleFonts.geist( - color: AppColors.textPrimary, - fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.3, - ), - ), - const SizedBox(height: 3), - Text( - 'Last night · ${_fmtTime(_toIst(snapshot.sleepStart))} – ' - '${_fmtTime(_toIst(snapshot.sleepEnd))} IST', - style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 12), - ), - const SizedBox(height: 16), - - // Stat pills row - Row( - children: [ - _StatPill(label: 'P5', value: '${snapshot.p5Bpm} bpm', color: AppColors.success), - const SizedBox(width: 6), - _StatPill(label: 'P95', value: '${snapshot.p95Bpm} bpm', color: AppColors.primary), - if (deepAvg != null) ...[ - const SizedBox(width: 6), - _StatPill( - label: 'Deep avg', - value: '${deepAvg.round()} bpm', - color: kSleepStageColors['deep']!, - ), - ], - if (remAvg != null) ...[ - const SizedBox(width: 6), - _StatPill( - label: 'REM avg', - value: '${remAvg.round()} bpm', - color: kSleepStageColors['rem']!, - ), - ], - ], - ), - const SizedBox(height: 20), - - // Interactive bar chart - Text( - 'Heart rate during sleep · 10-min bars', - style: GoogleFonts.geist( - color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3, - ), - ), - const SizedBox(height: 8), - _InteractiveBarChart(segments: snapshot.segments), - const SizedBox(height: 6), - - // Stage timeline strip - _StageTimelineStrip(segments: snapshot.segments), - const SizedBox(height: 8), - - // Legend - _Legend(), - const SizedBox(height: 24), - - // HR range by stage - Text( - 'HR range by stage', - style: GoogleFonts.geist( - color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3, - ), - ), - const SizedBox(height: 10), - _StageDistributionChart( - stats: snapshot.stageStats, - stageOrder: _stageOrder, - stageLabels: _stageLabels, - ), - const SizedBox(height: 10), - _DistLegend(), - ], + ], + if (remAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'REM avg', + value: '${remAvg.round()} bpm', + color: kSleepStageColors['rem']!, ), - ), + ], ], ), - ), + const SizedBox(height: 20), + + Text( + 'Heart rate during sleep · 10-min bars', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 8), + _InteractiveBarChart(segments: snapshot.segments), + const SizedBox(height: 6), + _StageTimelineStrip(segments: snapshot.segments), + const SizedBox(height: 8), + _Legend(), + const SizedBox(height: 24), + + Text( + 'HR range by stage', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 10), + _StageDistributionChart( + stats: snapshot.stageStats, + stageOrder: _stageOrder, + stageLabels: _stageLabels, + ), + const SizedBox(height: 10), + _DistLegend(), + ], ); } } @@ -226,13 +170,9 @@ class _InteractiveBarChartState extends State<_InteractiveBarChart> { builder: (_, constraints) { final width = constraints.maxWidth; return GestureDetector( - onTapDown: (d) => setState( - () => _hoveredIndex = _indexAt(d.localPosition, width), - ), + onTapDown: (d) => setState(() => _hoveredIndex = _indexAt(d.localPosition, width)), onTapUp: (_) => setState(() => _hoveredIndex = null), - onPanUpdate: (d) => setState( - () => _hoveredIndex = _indexAt(d.localPosition, width), - ), + onPanUpdate: (d) => setState(() => _hoveredIndex = _indexAt(d.localPosition, width)), onPanEnd: (_) => setState(() => _hoveredIndex = null), onPanCancel: () => setState(() => _hoveredIndex = null), child: CustomPaint( @@ -249,41 +189,37 @@ class _InteractiveBarChartState extends State<_InteractiveBarChart> { } } -// ── Bar chart CustomPainter ─────────────────────────────────────────────────── - class _BarChartPainter extends CustomPainter { const _BarChartPainter({required this.segments, this.hoveredIndex}); final List segments; final int? hoveredIndex; - static const _padLeft = 28.0; - static const _padTop = 6.0; + static const _padLeft = 28.0; + static const _padTop = 6.0; static const _padBottom = 22.0; - static DateTime _toIst(DateTime dt) => - dt.toUtc().add(const Duration(hours: 5, minutes: 30)); - @override void paint(Canvas canvas, Size size) { if (segments.isEmpty) return; final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); - final rawMin = allBpms.reduce(min).toDouble(); - final rawMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble(); - final bpmMin = (rawMin / 10).floor() * 10.0 - 5; - final bpmMax = (rawMax / 10).ceil() * 10.0 + 5; + final rawMin = allBpms.reduce(min).toDouble(); + final rawMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble(); + final bpmMin = (rawMin / 10).floor() * 10.0 - 5; + final bpmMax = (rawMax / 10).ceil() * 10.0 + 5; final chartW = size.width - _padLeft - 4; final chartH = size.height - _padTop - _padBottom; - final n = segments.length; - final barW = chartW / n; + final n = segments.length; + final barW = chartW / n; double yFor(double bpm) => _padTop + chartH - ((bpm - bpmMin) / (bpmMax - bpmMin)) * chartH; - // Grid lines + Y labels - final gridPaint = Paint()..color = AppColors.glassBorder..strokeWidth = 0.5; + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; final yLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); final gridBpms = []; @@ -300,13 +236,14 @@ class _BarChartPainter extends CustomPainter { tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); } - // Bars for (var i = 0; i < n; i++) { - final seg = segments[i]; + final seg = segments[i]; final color = kSleepStageColors[seg.stage] ?? AppColors.primary; final alpha = (hoveredIndex == null || hoveredIndex == i) ? 0.78 : 0.28; - final paint = Paint()..color = color.withValues(alpha: alpha)..style = PaintingStyle.fill; - final x = _padLeft + i * barW; + final paint = Paint() + ..color = color.withValues(alpha: alpha) + ..style = PaintingStyle.fill; + final x = _padLeft + i * barW; final yTop = yFor(seg.maxBpm.toDouble()); final yBot = yFor(seg.minBpm.toDouble()); canvas.drawRRect( @@ -318,7 +255,6 @@ class _BarChartPainter extends CustomPainter { ); } - // Moving-average trend line final avgPaint = Paint() ..color = AppColors.secondary.withValues(alpha: 0.9) ..strokeWidth = 1.5 @@ -329,19 +265,18 @@ class _BarChartPainter extends CustomPainter { for (var i = 0; i < n; i++) { final sl = segments.sublist(max(0, i - 4), i + 1); final ma = sl.map((s) => s.avgBpm).reduce((a, b) => a + b) / sl.length; - final x = _padLeft + i * barW + barW / 2; - final y = yFor(ma); + final x = _padLeft + i * barW + barW / 2; + final y = yFor(ma); i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); } canvas.drawPath(path, avgPaint); - // X-axis time labels every ~6 bars final xLabelStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); for (var i = 0; i < n; i += 6) { - final t = _toIst(segments[i].windowStart); - final h = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; - final m = t.minute.toString().padLeft(2, '0'); - final tp = TextPainter( + final t = _toIst(segments[i].windowStart); + final h = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final m = t.minute.toString().padLeft(2, '0'); + final tp = TextPainter( text: TextSpan(text: '$h:$m', style: xLabelStyle), textDirection: TextDirection.ltr, )..layout(); @@ -351,72 +286,76 @@ class _BarChartPainter extends CustomPainter { ); } - // Tooltip for hovered bar if (hoveredIndex != null) { final idx = hoveredIndex!; final seg = segments[idx]; final color = kSleepStageColors[seg.stage] ?? AppColors.primary; - final barX = _padLeft + idx * barW; - final yTop = yFor(seg.maxBpm.toDouble()); - final yBot = yFor(seg.minBpm.toDouble()); + final barX = _padLeft + idx * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); - // Highlight stroke on selected bar canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(barX + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), const Radius.circular(1.5), ), - Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 1.5, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, ); - // Tooltip box - final t = _toIst(seg.windowStart); - final tEnd = _toIst(seg.windowStart.add(const Duration(minutes: 10))); - final th = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; - final tm = t.minute.toString().padLeft(2, '0'); - final eh = tEnd.hour == 0 ? 12 : tEnd.hour > 12 ? tEnd.hour - 12 : tEnd.hour; - final em = tEnd.minute.toString().padLeft(2, '0'); + final t = _toIst(seg.windowStart); + final tEnd = _toIst(seg.windowStart.add(const Duration(minutes: 10))); + final th = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final tm = t.minute.toString().padLeft(2, '0'); + final eh = tEnd.hour == 0 ? 12 : tEnd.hour > 12 ? tEnd.hour - 12 : tEnd.hour; + final em = tEnd.minute.toString().padLeft(2, '0'); final stageName = const { - 'deep': 'Deep', 'rem': 'REM', 'light': 'Light', 'awake': 'Awake', + 'deep': 'Deep', + 'rem': 'REM', + 'light': 'Light', + 'awake': 'Awake', }[seg.stage] ?? seg.stage; final lines = ['$th:$tm–$eh:$em IST', '${seg.minBpm}–${seg.maxBpm} bpm', stageName]; final lineStyle = GoogleFonts.geistMono(color: Colors.white, fontSize: 9.5); - final painters = lines.map((l) => TextPainter( - text: TextSpan(text: l, style: lineStyle), - textDirection: TextDirection.ltr, - )..layout()).toList(); + final painters = lines + .map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()) + .toList(); const ttPadH = 8.0, ttPadV = 6.0, ttLineH = 14.0; final ttW = painters.map((p) => p.width).reduce(max) + ttPadH * 2; final ttH = painters.length * ttLineH + ttPadV * 2; - // Position: above bar, clamped to chart bounds var ttX = barX + barW / 2 - ttW / 2; ttX = ttX.clamp(_padLeft, size.width - 4 - ttW); var ttY = yTop - ttH - 6; if (ttY < _padTop) ttY = yBot + 6; - // Shadow canvas.drawRRect( RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), - Paint()..color = Colors.black.withValues(alpha: 0.4)..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), ); - // Background canvas.drawRRect( RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), Paint()..color = const Color(0xFF1E1E2E), ); - // Border canvas.drawRRect( RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), - Paint()..color = color.withValues(alpha: 0.7)..style = PaintingStyle.stroke..strokeWidth = 1, + Paint() + ..color = color.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, ); - // Text lines for (var i = 0; i < painters.length; i++) { final p = painters[i]; - // stage line gets stage colour if (i == 2) { final stagePainter = TextPainter( text: TextSpan( @@ -473,8 +412,8 @@ class _Legend extends StatelessWidget { @override Widget build(BuildContext context) { final items = [ - ('Deep', kSleepStageColors['deep']!), - ('REM', kSleepStageColors['rem']!), + ('Deep', kSleepStageColors['deep']!), + ('REM', kSleepStageColors['rem']!), ('Light', kSleepStageColors['light']!), ('Awake', kSleepStageColors['awake']!), ]; @@ -486,7 +425,8 @@ class _Legend extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - width: 8, height: 8, + width: 8, + height: 8, decoration: BoxDecoration(color: e.$2, borderRadius: BorderRadius.circular(2)), ), const SizedBox(width: 4), @@ -496,10 +436,7 @@ class _Legend extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 14, height: 10, - child: CustomPaint(painter: _DashLinePainter()), - ), + SizedBox(width: 14, height: 10, child: CustomPaint(painter: _DashLinePainter())), const SizedBox(width: 4), Text('Avg trend', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 10)), ], @@ -615,7 +552,8 @@ class _DistRow extends StatelessWidget { Positioned( left: pct(stats.minBpm.toDouble()) * w, width: (pct(stats.maxBpm.toDouble()) - pct(stats.minBpm.toDouble())) * w, - top: 7, height: 14, + top: 7, + height: 14, child: Container( decoration: BoxDecoration( color: color.withValues(alpha: 0.22), @@ -626,7 +564,8 @@ class _DistRow extends StatelessWidget { Positioned( left: pct(stats.p25Bpm.toDouble()) * w, width: (pct(stats.p75Bpm.toDouble()) - pct(stats.p25Bpm.toDouble())) * w, - top: 7, height: 14, + top: 7, + height: 14, child: Container( decoration: BoxDecoration( color: color.withValues(alpha: 0.72), @@ -638,7 +577,8 @@ class _DistRow extends StatelessWidget { left: pct(stats.avgBpm) * w - 4, top: 10, child: Container( - width: 8, height: 8, + width: 8, + height: 8, decoration: BoxDecoration( color: color, shape: BoxShape.circle, @@ -652,7 +592,9 @@ class _DistRow extends StatelessWidget { child: Text( '${stats.avgBpm.round()} bpm', style: GoogleFonts.geistMono( - color: color, fontSize: 8, fontWeight: FontWeight.w700, + color: color, + fontSize: 8, + fontWeight: FontWeight.w700, ), ), ), @@ -688,13 +630,15 @@ class _DistAxis extends StatelessWidget { return SizedBox( height: 16, child: Stack( - children: ticks.map((t) => Positioned( - left: (pct(t.toDouble()) * w - 10).clamp(0, w - 20), - child: Text( - '$t', - style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8), - ), - )).toList(), + children: ticks + .map((t) => Positioned( + left: (pct(t.toDouble()) * w - 10).clamp(0, w - 20), + child: Text( + '$t', + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8), + ), + )) + .toList(), ), ); }, @@ -703,8 +647,6 @@ class _DistAxis extends StatelessWidget { } } -// ── Distribution legend ─────────────────────────────────────────────────────── - class _DistLegend extends StatelessWidget { @override Widget build(BuildContext context) { @@ -714,7 +656,8 @@ class _DistLegend extends StatelessWidget { children: [ _DistLi( swatch: Container( - width: 16, height: 8, + width: 16, + height: 8, decoration: BoxDecoration( color: AppColors.textMuted.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(4), @@ -724,7 +667,8 @@ class _DistLegend extends StatelessWidget { ), _DistLi( swatch: Container( - width: 16, height: 8, + width: 16, + height: 8, decoration: BoxDecoration( color: AppColors.textMuted.withValues(alpha: 0.72), borderRadius: BorderRadius.circular(4), @@ -734,7 +678,8 @@ class _DistLegend extends StatelessWidget { ), _DistLi( swatch: Container( - width: 8, height: 8, + width: 8, + height: 8, decoration: const BoxDecoration(color: AppColors.textSoft, shape: BoxShape.circle), ), label: 'Avg', diff --git a/workout-logger/lib/services/managers/health_history_manager.dart b/workout-logger/lib/services/managers/health_history_manager.dart new file mode 100644 index 0000000..ea5b77b --- /dev/null +++ b/workout-logger/lib/services/managers/health_history_manager.dart @@ -0,0 +1,287 @@ +// Health History Manager +// +// Serves arbitrary-range sleep & heart-rate data for the detail screens. +// Stateless w.r.t. UI (not a ChangeNotifier) — screens drive it via +// FutureBuilder. The Health Connect service already reads any date range; +// this manager owns the windowing, bucketing and light caching on top. +// +// Performance: Day/Week use full HR samples (heavy, cached per immutable past +// day). Month/Year use restingHeartRate records (one/day, light) so a year +// never fans out into 365 sample queries. + +import 'dart:convert'; + +import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../utils/sleep_hr_builder.dart'; + +class HealthHistoryManager { + final IHealthConnectService _hc; + final IStorageService _storage; + + HealthHistoryManager(this._hc, this._storage); + + // ── Date helpers ──────────────────────────────────────────────────────────── + + static String dateKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + static DateTime _midnight(DateTime d) => DateTime(d.year, d.month, d.day); + + /// The [start, end) window covered by [g] anchored at [anchor]. + /// Day → that day. Week → 7 days ending on anchor. Month/Year → calendar unit. + static ({DateTime start, DateTime end}) rangeFor( + DateTime anchor, + HealthGranularity g, + ) { + final day = _midnight(anchor); + switch (g) { + case HealthGranularity.day: + return (start: day, end: day.add(const Duration(days: 1))); + case HealthGranularity.week: + final start = day.subtract(const Duration(days: 6)); + return (start: start, end: day.add(const Duration(days: 1))); + case HealthGranularity.month: + final start = DateTime(day.year, day.month, 1); + final end = DateTime(day.year, day.month + 1, 1); + return (start: start, end: end); + case HealthGranularity.year: + return (start: DateTime(day.year, 1, 1), end: DateTime(day.year + 1, 1, 1)); + } + } + + /// Steps the anchor by one unit of [g] in [dir] (+1 forward, -1 back). + static DateTime stepBy(DateTime anchor, HealthGranularity g, int dir) { + final day = _midnight(anchor); + switch (g) { + case HealthGranularity.day: + return day.add(Duration(days: dir)); + case HealthGranularity.week: + return day.add(Duration(days: 7 * dir)); + case HealthGranularity.month: + return DateTime(day.year, day.month + dir, day.day); + case HealthGranularity.year: + return DateTime(day.year + dir, day.month, day.day); + } + } + + Future> _granted() => _hc.grantedReadTypes(); + + // ── Day detail ────────────────────────────────────────────────────────────── + + /// Overnight HR snapshot for the night ending the morning of [morning]. + Future sleepNight(DateTime morning) async { + final granted = await _granted(); + return buildSleepHrSnapshot(_hc, morning, granted); + } + + /// All-day HR snapshot for [day]. Immutable past days are cached permanently; + /// today is always rebuilt (data is still accumulating). + Future hrDay(DateTime day) async { + final d = _midnight(day); + final isPast = d.isBefore(_midnight(DateTime.now())); + final cacheKey = 'hr.day.${dateKey(d)}'; + + if (isPast) { + final cached = await _readCachedHrDay(cacheKey); + if (cached != null) return cached; + } + + final granted = await _granted(); + final snap = await buildHrDaySnapshot(_hc, d, granted); + if (snap != null && isPast) { + try { + await _storage.saveSetting(cacheKey, jsonEncode(snap.toJson())); + } catch (_) {/* cache best-effort */} + } + return snap; + } + + Future _readCachedHrDay(String key) async { + try { + final raw = await _storage.getSetting(key); + if (raw == null) return null; + return HrDaySnapshot.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return null; + } + } + + // ── Sleep aggregation ───────────────────────────────────────────────────────── + + /// Aggregated sleep-duration bars for [g] anchored at [anchor]. + /// Day/Week/Month → one bar per night; Year → 12 monthly averages. + /// Bars are emitted for every calendar slot in range (zero-filled) so the + /// chart axis stays stable. + Future> sleepBars( + DateTime anchor, + HealthGranularity g, + ) async { + final granted = await _granted(); + if (!granted.contains(HealthReadType.sleep)) return const []; + + final r = rangeFor(anchor, g); + // Pad the end so sleep ending the morning after the last day is captured. + final periods = + await _hc.readSleepSessions(r.start, r.end.add(const Duration(hours: 12))); + + // Group nightly totals by the day the session ENDS on (handles fragmented + // Pixel-Watch records — sum, don't max). + final byNight = {}; + for (final p in periods) { + final key = dateKey(p.end); + final t = byNight.putIfAbsent(key, () => _StageTally()); + t.add(p); + } + + if (g == HealthGranularity.year) { + // Average each month's nightly totals. + final byMonth = >{}; + byNight.forEach((key, tally) { + final d = DateTime.parse(key); + byMonth.putIfAbsent(d.month, () => []).add(tally); + }); + return List.generate(12, (i) { + final month = i + 1; + final tallies = byMonth[month] ?? const []; + final date = DateTime(_midnight(anchor).year, month, 1); + if (tallies.isEmpty) { + return SleepDayBar( + date: date, totalMinutes: 0, deepMin: 0, remMin: 0, lightMin: 0, awakeMin: 0); + } + final n = tallies.length; + return SleepDayBar( + date: date, + totalMinutes: tallies.fold(0, (s, t) => s + t.total) ~/ n, + deepMin: tallies.fold(0, (s, t) => s + t.deep) ~/ n, + remMin: tallies.fold(0, (s, t) => s + t.rem) ~/ n, + lightMin: tallies.fold(0, (s, t) => s + t.light) ~/ n, + awakeMin: tallies.fold(0, (s, t) => s + t.awake) ~/ n, + ); + }); + } + + // Per-night bars for each day in the range. + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final t = byNight[dateKey(d)]; + bars.add(SleepDayBar( + date: d, + totalMinutes: t?.total ?? 0, + deepMin: t?.deep ?? 0, + remMin: t?.rem ?? 0, + lightMin: t?.light ?? 0, + awakeMin: t?.awake ?? 0, + )); + } + return bars; + } + + // ── HR aggregation ──────────────────────────────────────────────────────────── + + /// Aggregated HR range bars for [g] anchored at [anchor]. + /// Week → per-day min/max from full samples (cached). Month/Year → daily / + /// monthly min–max of resting-HR records (light query path). + Future> hrBars( + DateTime anchor, + HealthGranularity g, + ) async { + final granted = await _granted(); + if (!granted.contains(HealthReadType.heartRate) && + !granted.contains(HealthReadType.restingHeartRate)) { + return const []; + } + final r = rangeFor(anchor, g); + + if (g == HealthGranularity.week) { + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final snap = await hrDay(d); + bars.add(HrRangeBar( + date: d, + label: _weekdayLabel(d), + minBpm: snap?.minBpm ?? 0, + maxBpm: snap?.maxBpm ?? 0, + avgBpm: snap?.avgBpm ?? 0, + restingBpm: snap?.restingBpm, + )); + } + return bars; + } + + // Month / Year → resting-HR records only. + final rhr = granted.contains(HealthReadType.restingHeartRate) + ? await _hc.readRestingHeartRate(r.start, r.end) + : []; + + final byDay = >{}; + for (final s in rhr) { + byDay.putIfAbsent(dateKey(s.time), () => []).add(s.value); + } + + if (g == HealthGranularity.month) { + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final vals = byDay[dateKey(d)] ?? const []; + bars.add(_rangeBar(d, '${d.day}', vals)); + } + return bars; + } + + // Year → 12 monthly bars. + final byMonth = >{}; + byDay.forEach((key, vals) { + final m = DateTime.parse(key).month; + byMonth.putIfAbsent(m, () => []).addAll(vals); + }); + return List.generate(12, (i) { + final month = i + 1; + final date = DateTime(_midnight(anchor).year, month, 1); + return _rangeBar(date, _monthLabel(month), byMonth[month] ?? const []); + }); + } + + HrRangeBar _rangeBar(DateTime date, String label, List vals) { + if (vals.isEmpty) { + return HrRangeBar( + date: date, label: label, minBpm: 0, maxBpm: 0, avgBpm: 0, restingBpm: null); + } + final mn = vals.reduce((a, b) => a < b ? a : b); + final mx = vals.reduce((a, b) => a > b ? a : b); + final avg = vals.reduce((a, b) => a + b) / vals.length; + return HrRangeBar( + date: date, + label: label, + minBpm: mn.round(), + maxBpm: mx.round(), + avgBpm: avg, + restingBpm: avg.round(), + ); + } + + static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + static const _months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + String _weekdayLabel(DateTime d) => _weekdays[d.weekday - 1]; + String _monthLabel(int month) => _months[month - 1]; +} + +/// Accumulates stage minutes for one night across fragmented records. +class _StageTally { + int total = 0; + int deep = 0; + int rem = 0; + int light = 0; + int awake = 0; + + void add(SleepPeriod p) { + total += p.minutes; + deep += p.deepMinutes ?? 0; + rem += p.remMinutes ?? 0; + light += p.lightMinutes ?? 0; + awake += p.awakeMinutes ?? 0; + } +} diff --git a/workout-logger/lib/services/managers/readiness_manager.dart b/workout-logger/lib/services/managers/readiness_manager.dart index 6696205..7b8f9f1 100644 --- a/workout-logger/lib/services/managers/readiness_manager.dart +++ b/workout-logger/lib/services/managers/readiness_manager.dart @@ -19,6 +19,7 @@ import '../interfaces/readiness_manager_interface.dart'; import '../interfaces/storage_service_interface.dart'; import '../settings_provider.dart'; import '../utils/readiness_calculator.dart'; +import '../utils/sleep_hr_builder.dart'; class ReadinessManager extends ChangeNotifier implements IReadinessManager { final IHealthConnectService _hc; @@ -34,9 +35,14 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { ReadinessStatus _status = ReadinessStatus.idle; ReadinessSnapshot? _snapshot; SleepHrSnapshot? _sleepHrSnapshot; + HrDaySnapshot? _hrDaySnapshot; SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; + /// Today's all-day HR snapshot — backs the dashboard Heart-rate card. + /// Built best-effort during [refresh]; null when no HR data/permission. + HrDaySnapshot? get hrDaySnapshot => _hrDaySnapshot; + // Debug-only: human-readable trace of the last refresh() execution. // Empty until refresh() runs for the first time. String _debugTrace = ''; @@ -90,10 +96,11 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { _debugTrace = 'Serving cached snapshot (within ${_snapshotTtl.inMinutes}min TTL)\n' 'score=${cached.score} band=${cached.band}\n' 'computedAt=${cached.computedAt.toLocal()}'; - // Still build the sleep HR snapshot if we don't have one yet. - if (_sleepHrSnapshot == null) { - _sleepHrSnapshot = await _buildSleepHrSnapshot(now, granted); - if (_sleepHrSnapshot != null) notifyListeners(); + // Still build the HR snapshots if we don't have them yet. + if (_sleepHrSnapshot == null || _hrDaySnapshot == null) { + _sleepHrSnapshot ??= await _buildSleepHrSnapshot(now, granted); + _hrDaySnapshot ??= await _buildHrDaySnapshot(now, granted); + notifyListeners(); } return; } @@ -103,13 +110,19 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { final restingHr = await _todayRestingHr(now, granted); final hrv = await _todayHrv(now, granted); - // Build overnight HR snapshot (best-effort; failure must not affect score). + // Build HR snapshots (best-effort; failure must not affect score). try { _sleepHrSnapshot = await _buildSleepHrSnapshot(now, granted); } catch (e) { debugPrint('[Readiness] _buildSleepHrSnapshot failed (non-fatal): $e'); _sleepHrSnapshot = null; } + try { + _hrDaySnapshot = await _buildHrDaySnapshot(now, granted); + } catch (e) { + debugPrint('[Readiness] _buildHrDaySnapshot failed (non-fatal): $e'); + _hrDaySnapshot = null; + } debugPrint('[Readiness] refresh: today → sleepMinutes=$sleepMinutes restingHr=$restingHr hrv=$hrv'); final baseline = await _baselineFor(todayKey, now, granted); @@ -190,127 +203,20 @@ class ReadinessManager extends ChangeNotifier implements IReadinessManager { } } - /// Builds an overnight HR snapshot for the Sleep HR chart. - /// Returns null when HR permission is missing or no samples exist. + /// Builds last night's overnight HR snapshot for the Sleep HR chart, + /// falling back to the night before when the watch hasn't synced yet. Future _buildSleepHrSnapshot( DateTime now, Set granted, - ) async { - if (!granted.contains(HealthReadType.heartRate)) return null; - if (!granted.contains(HealthReadType.sleep)) return null; - - final day = DateTime(now.year, now.month, now.day); + ) => + buildSleepHrSnapshot(_hc, now, granted, fallbackToPriorNight: true); - // Try last night first; fall back to the night before if no data yet - // (covers mornings where the watch hasn't synced yet). - List periods = []; - DateTime windowStart = day.subtract(const Duration(hours: 6)); - DateTime windowEnd = day.add(const Duration(hours: 12)); - - periods = await _hc.readSleepSessions(windowStart, windowEnd); - if (periods.isEmpty) { - windowStart = windowStart.subtract(const Duration(days: 1)); - windowEnd = windowEnd.subtract(const Duration(days: 1)); - periods = await _hc.readSleepSessions(windowStart, windowEnd); - debugPrint('[Readiness] sleepHR: no data for last night — fell back to night before'); - } - if (periods.isEmpty) return null; - - // Use the earliest start and latest end across all records. - final sleepStart = periods.map((p) => p.start).reduce((a, b) => a.isBefore(b) ? a : b); - final sleepEnd = periods.map((p) => p.end).reduce((a, b) => a.isAfter(b) ? a : b); - - // Read HR samples covering the full sleep window (+ 15 min buffer). - final samples = await _hc.readHeartRateSamples( - sleepStart.subtract(const Duration(minutes: 15)), - sleepEnd.add(const Duration(minutes: 15)), - ); - if (samples.isEmpty) return null; - - // Flatten all stage intervals from all periods into one sorted list. - final allIntervals = periods - .expand((p) => p.stageTimeline) - .toList() - ..sort((a, b) => a.start.compareTo(b.start)); - - // Assign each HR sample a stage by matching against intervals. - String stageAt(DateTime t) { - for (final iv in allIntervals) { - if (!t.isBefore(iv.start) && t.isBefore(iv.end)) return iv.stage; - } - return 'awake'; - } - - // Bucket samples into 10-minute windows aligned to sleepStart. - final segmentMap = >{}; - for (final s in samples) { - final offsetMin = s.time.difference(sleepStart).inMinutes; - if (offsetMin < 0) continue; - final bucket = (offsetMin ~/ 10) * 10; - segmentMap.putIfAbsent(bucket, () => []); - segmentMap[bucket]!.add((bpm: s.value.round(), stage: stageAt(s.time))); - } - - // Build ordered SleepHrSegment list (skip buckets with < 2 samples). - final segments = []; - final sortedBuckets = segmentMap.keys.toList()..sort(); - for (final bucket in sortedBuckets) { - final entries = segmentMap[bucket]!; - if (entries.length < 2) continue; - final bpms = entries.map((e) => e.bpm).toList()..sort(); - final stageCounts = {}; - for (final e in entries) { - stageCounts[e.stage] = (stageCounts[e.stage] ?? 0) + 1; - } - final dominantStage = stageCounts.entries - .reduce((a, b) => a.value >= b.value ? a : b) - .key; - segments.add(SleepHrSegment( - windowStart: sleepStart.add(Duration(minutes: bucket)), - minBpm: bpms.first, - maxBpm: bpms.last, - avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, - stage: dominantStage, - )); - } - if (segments.isEmpty) return null; - - // P95 across all samples. - final allBpms = samples.map((s) => s.value.round()).toList()..sort(); - final p5Bpm = allBpms[(allBpms.length * 0.05).floor().clamp(0, allBpms.length - 1)]; - final p95Bpm = allBpms[(allBpms.length * 0.95).floor().clamp(0, allBpms.length - 1)]; - - // Per-stage stats (min 3 samples required). - final byStage = >{}; - for (final s in samples) { - final stage = stageAt(s.time); - byStage.putIfAbsent(stage, () => []); - byStage[stage]!.add(s.value.round()); - } - final stageStats = []; - for (final entry in byStage.entries) { - final bpms = entry.value..sort(); - if (bpms.length < 3) continue; - stageStats.add(SleepStageStats( - stage: entry.key, - minBpm: bpms.first, - p25Bpm: bpms[(bpms.length * 0.25).floor()], - avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, - p75Bpm: bpms[(bpms.length * 0.75).floor()], - maxBpm: bpms.last, - sampleCount: bpms.length, - )); - } - - return SleepHrSnapshot( - sleepStart: sleepStart, - sleepEnd: sleepEnd, - p5Bpm: p5Bpm, - p95Bpm: p95Bpm, - segments: segments, - stageStats: stageStats, - ); - } + /// Builds today's all-day HR snapshot for the Heart-rate card. + Future _buildHrDaySnapshot( + DateTime now, + Set granted, + ) => + buildHrDaySnapshot(_hc, now, granted); void _setNoData() { _snapshot = null; diff --git a/workout-logger/lib/services/utils/sleep_hr_builder.dart b/workout-logger/lib/services/utils/sleep_hr_builder.dart new file mode 100644 index 0000000..02f6ffa --- /dev/null +++ b/workout-logger/lib/services/utils/sleep_hr_builder.dart @@ -0,0 +1,210 @@ +// Sleep-HR snapshot builder. +// +// Extracted from ReadinessManager so the overnight-HR snapshot can be built +// for ANY night, not just last night. ReadinessManager builds it for "today" +// (with a prior-night fallback for un-synced mornings); HealthHistoryManager +// builds it for arbitrary historical dates as the user navigates. +// +// Pure function over IHealthConnectService — no state, no caching here. + +import 'package:flutter/foundation.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; + +/// Builds an overnight HR snapshot for the night that ENDS on the morning of +/// [morning] (i.e. the local calendar day [morning]). +/// +/// Returns null when HR/sleep permission is missing or no samples exist. +/// When [fallbackToPriorNight] is true and the target night has no sleep data, +/// it retries the night before (covers mornings where the watch hasn't synced). +Future buildSleepHrSnapshot( + IHealthConnectService hc, + DateTime morning, + Set granted, { + bool fallbackToPriorNight = false, +}) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + if (!granted.contains(HealthReadType.sleep)) return null; + + final day = DateTime(morning.year, morning.month, morning.day); + + var windowStart = day.subtract(const Duration(hours: 6)); + var windowEnd = day.add(const Duration(hours: 12)); + + var periods = await hc.readSleepSessions(windowStart, windowEnd); + if (periods.isEmpty && fallbackToPriorNight) { + windowStart = windowStart.subtract(const Duration(days: 1)); + windowEnd = windowEnd.subtract(const Duration(days: 1)); + periods = await hc.readSleepSessions(windowStart, windowEnd); + debugPrint('[SleepHr] no data for target night — fell back to night before'); + } + if (periods.isEmpty) return null; + + // Use the earliest start and latest end across all records. + final sleepStart = periods.map((p) => p.start).reduce((a, b) => a.isBefore(b) ? a : b); + final sleepEnd = periods.map((p) => p.end).reduce((a, b) => a.isAfter(b) ? a : b); + + // Read HR samples covering the full sleep window (+ 15 min buffer). + final samples = await hc.readHeartRateSamples( + sleepStart.subtract(const Duration(minutes: 15)), + sleepEnd.add(const Duration(minutes: 15)), + ); + if (samples.isEmpty) return null; + + // Flatten all stage intervals from all periods into one sorted list. + final allIntervals = periods + .expand((p) => p.stageTimeline) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + + String stageAt(DateTime t) { + for (final iv in allIntervals) { + if (!t.isBefore(iv.start) && t.isBefore(iv.end)) return iv.stage; + } + return 'awake'; + } + + // Bucket samples into 10-minute windows aligned to sleepStart. + final segmentMap = >{}; + for (final s in samples) { + final offsetMin = s.time.difference(sleepStart).inMinutes; + if (offsetMin < 0) continue; + final bucket = (offsetMin ~/ 10) * 10; + segmentMap.putIfAbsent(bucket, () => []); + segmentMap[bucket]!.add((bpm: s.value.round(), stage: stageAt(s.time))); + } + + final segments = []; + final sortedBuckets = segmentMap.keys.toList()..sort(); + for (final bucket in sortedBuckets) { + final entries = segmentMap[bucket]!; + if (entries.length < 2) continue; + final bpms = entries.map((e) => e.bpm).toList()..sort(); + final stageCounts = {}; + for (final e in entries) { + stageCounts[e.stage] = (stageCounts[e.stage] ?? 0) + 1; + } + final dominantStage = stageCounts.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + segments.add(SleepHrSegment( + windowStart: sleepStart.add(Duration(minutes: bucket)), + minBpm: bpms.first, + maxBpm: bpms.last, + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + stage: dominantStage, + )); + } + if (segments.isEmpty) return null; + + // P5 / P95 across all samples. + final allBpms = samples.map((s) => s.value.round()).toList()..sort(); + final p5Bpm = allBpms[(allBpms.length * 0.05).floor().clamp(0, allBpms.length - 1)]; + final p95Bpm = allBpms[(allBpms.length * 0.95).floor().clamp(0, allBpms.length - 1)]; + + // Per-stage stats (min 3 samples required). + final byStage = >{}; + for (final s in samples) { + final stage = stageAt(s.time); + byStage.putIfAbsent(stage, () => []); + byStage[stage]!.add(s.value.round()); + } + final stageStats = []; + for (final entry in byStage.entries) { + final bpms = entry.value..sort(); + if (bpms.length < 3) continue; + stageStats.add(SleepStageStats( + stage: entry.key, + minBpm: bpms.first, + p25Bpm: bpms[(bpms.length * 0.25).floor()], + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + p75Bpm: bpms[(bpms.length * 0.75).floor()], + maxBpm: bpms.last, + sampleCount: bpms.length, + )); + } + + return SleepHrSnapshot( + sleepStart: sleepStart, + sleepEnd: sleepEnd, + p5Bpm: p5Bpm, + p95Bpm: p95Bpm, + segments: segments, + stageStats: stageStats, + ); +} + +/// Builds an all-day HR snapshot for the local calendar day [day]: ~30-minute +/// min/max/avg buckets plus a resting-HR figure. +/// +/// Returns null when HR permission is missing or no samples exist for the day. +/// Resting HR = latest restingHeartRate record that day, else the minimum +/// raw sample between 02:00–10:00 (same fallback ReadinessManager uses). +Future buildHrDaySnapshot( + IHealthConnectService hc, + DateTime day, + Set granted, { + Duration bucket = const Duration(minutes: 30), +}) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + + final start = DateTime(day.year, day.month, day.day); + final end = start.add(const Duration(days: 1)); + + final samples = await hc.readHeartRateSamples(start, end); + if (samples.isEmpty) return null; + + final bucketMin = bucket.inMinutes; + final byBucket = >{}; + for (final s in samples) { + final offset = s.time.difference(start).inMinutes; + if (offset < 0 || offset >= 1440) continue; + final key = (offset ~/ bucketMin) * bucketMin; + byBucket.putIfAbsent(key, () => []).add(s.value.round()); + } + + final buckets = []; + for (final key in byBucket.keys.toList()..sort()) { + final bpms = byBucket[key]!; + buckets.add(HrBucket( + windowStart: start.add(Duration(minutes: key)), + minBpm: bpms.reduce((a, b) => a < b ? a : b), + maxBpm: bpms.reduce((a, b) => a > b ? a : b), + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + )); + } + if (buckets.isEmpty) return null; + + final allBpms = samples.map((s) => s.value.round()).toList(); + final minBpm = allBpms.reduce((a, b) => a < b ? a : b); + final maxBpm = allBpms.reduce((a, b) => a > b ? a : b); + final avgBpm = allBpms.reduce((a, b) => a + b) / allBpms.length; + + // Resting HR. + int? restingBpm; + if (granted.contains(HealthReadType.restingHeartRate)) { + final rhr = await hc.readRestingHeartRate(start, end); + if (rhr.isNotEmpty) { + rhr.sort((a, b) => a.time.compareTo(b.time)); + restingBpm = rhr.last.value.round(); + } + } + restingBpm ??= () { + final morning = samples.where((s) { + final h = s.time.difference(start).inMinutes; + return h >= 120 && h <= 600; // 02:00–10:00 + }); + if (morning.isEmpty) return null; + return morning.map((s) => s.value).reduce((a, b) => a < b ? a : b).round(); + }(); + + return HrDaySnapshot( + day: start, + restingBpm: restingBpm, + minBpm: minBpm, + maxBpm: maxBpm, + avgBpm: avgBpm, + buckets: buckets, + ); +} diff --git a/workout-logger/test/health_history_manager_test.dart b/workout-logger/test/health_history_manager_test.dart new file mode 100644 index 0000000..293fb0d --- /dev/null +++ b/workout-logger/test/health_history_manager_test.dart @@ -0,0 +1,200 @@ +// Unit tests for HealthHistoryManager (windowing + aggregation). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +class _StubHc implements IHealthConnectService { + Set granted; + List sleep; + List resting; + List heartRate; + + _StubHc({ + this.granted = const {}, + this.sleep = const [], + this.resting = const [], + this.heartRate = const [], + }); + + @override + Future> grantedReadTypes() async => granted; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => + sleep.where((p) => p.end.isAfter(start) && p.start.isBefore(end)).toList(); + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => + resting.where((s) => !s.time.isBefore(start) && s.time.isBefore(end)).toList(); + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + heartRate.where((s) => !s.time.isBefore(start) && s.time.isBefore(end)).toList(); + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => const []; + + // Unused by these tests. + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +SleepPeriod _night(DateTime end, {int deep = 0, int rem = 0, int light = 0, int awake = 0}) { + final total = deep + rem + light; + return SleepPeriod( + start: end.subtract(Duration(minutes: total + awake)), + end: end, + deepMinutes: deep, + remMinutes: rem, + lightMinutes: light, + awakeMinutes: awake, + ); +} + +void main() { + group('rangeFor / stepBy', () { + final anchor = DateTime(2026, 6, 14); // a Sunday + + test('day window is the single day', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.day); + expect(r.start, DateTime(2026, 6, 14)); + expect(r.end, DateTime(2026, 6, 15)); + }); + + test('week is the 7 days ending on the anchor', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.week); + expect(r.start, DateTime(2026, 6, 8)); + expect(r.end, DateTime(2026, 6, 15)); + }); + + test('month is the calendar month', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.month); + expect(r.start, DateTime(2026, 6, 1)); + expect(r.end, DateTime(2026, 7, 1)); + }); + + test('year is the calendar year', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.year); + expect(r.start, DateTime(2026, 1, 1)); + expect(r.end, DateTime(2027, 1, 1)); + }); + + test('stepBy moves by the active unit', () { + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.day, 1), + DateTime(2026, 6, 15)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.week, -1), + DateTime(2026, 6, 7)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.month, 1), + DateTime(2026, 7, 14)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.year, -1), + DateTime(2025, 6, 14)); + }); + }); + + group('sleepBars', () { + test('sums fragmented same-night records into one bar and zero-fills', () async { + // Two fragments ending the morning of Jun 14. + final hc = _StubHc( + granted: {HealthReadType.sleep}, + sleep: [ + _night(DateTime(2026, 6, 14, 3, 0), deep: 40, rem: 30, light: 60), + _night(DateTime(2026, 6, 14, 6, 30), deep: 20, rem: 50, light: 90), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.sleepBars(DateTime(2026, 6, 14), HealthGranularity.week); + expect(bars.length, 7); + + final night = bars.firstWhere((b) => b.date == DateTime(2026, 6, 14)); + expect(night.deepMin, 60); // 40 + 20 + expect(night.remMin, 80); // 30 + 50 + expect(night.lightMin, 150); // 60 + 90 + expect(night.totalMinutes, 290); + + // Other nights are zero-filled, keeping a stable 7-slot axis. + final empty = bars.firstWhere((b) => b.date == DateTime(2026, 6, 10)); + expect(empty.totalMinutes, 0); + }); + + test('year view returns 12 monthly average bars', () async { + final hc = _StubHc( + granted: {HealthReadType.sleep}, + sleep: [ + // Two nights in March averaging to 400 total min. + _night(DateTime(2026, 3, 10, 6), deep: 60, rem: 60, light: 180), // 300 + _night(DateTime(2026, 3, 20, 6), deep: 100, rem: 100, light: 300), // 500 + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.sleepBars(DateTime(2026, 6, 14), HealthGranularity.year); + expect(bars.length, 12); + final march = bars[2]; + expect(march.date, DateTime(2026, 3, 1)); + expect(march.totalMinutes, 400); // (300 + 500) / 2 + expect(bars[0].totalMinutes, 0); // January empty + }); + }); + + group('hrBars (week, full-sample path)', () { + test('builds per-day min/max from HR samples and zero-fills', () async { + final hc = _StubHc( + granted: {HealthReadType.heartRate}, + heartRate: [ + HealthSample(time: DateTime(2026, 6, 13, 9), value: 70), + HealthSample(time: DateTime(2026, 6, 13, 14), value: 120), + HealthSample(time: DateTime(2026, 6, 13, 22), value: 60), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.hrBars(DateTime(2026, 6, 14), HealthGranularity.week); + expect(bars.length, 7); + + final d13 = bars.firstWhere((b) => b.date == DateTime(2026, 6, 13)); + expect(d13.minBpm, 60); + expect(d13.maxBpm, 120); + + final empty = bars.firstWhere((b) => b.date == DateTime(2026, 6, 9)); + expect(empty.maxBpm, 0); + }); + }); + + group('hrBars (month, resting-HR path)', () { + test('yields one range bar per day from resting records', () async { + final hc = _StubHc( + granted: {HealthReadType.restingHeartRate}, + resting: [ + HealthSample(time: DateTime(2026, 6, 5, 8), value: 56), + HealthSample(time: DateTime(2026, 6, 5, 9), value: 60), + HealthSample(time: DateTime(2026, 6, 12, 8), value: 52), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.hrBars(DateTime(2026, 6, 14), HealthGranularity.month); + expect(bars.length, 30); // June + + final d5 = bars[4]; + expect(d5.minBpm, 56); + expect(d5.maxBpm, 60); + expect(d5.restingBpm, 58); // mean of 56 & 60 + + final d1 = bars[0]; + expect(d1.maxBpm, 0); // no data → empty bar + }); + }); +} diff --git a/workout-logger/test/readiness_manager_test.dart b/workout-logger/test/readiness_manager_test.dart index 1af1c7c..d469aa1 100644 --- a/workout-logger/test/readiness_manager_test.dart +++ b/workout-logger/test/readiness_manager_test.dart @@ -247,7 +247,10 @@ void main() { expect(manager.snapshot!.restingHr, 60.5); expect(manager.snapshot!.rhrScore, 50); - expect(hc.hrReadCount, 0); + // The one HR-sample read is the all-day Heart-rate-card snapshot; the + // scoring path still uses the RHR record and skips its minute-level + // fallback (verified by restingHr above coming from the RHR record). + expect(hc.hrReadCount, 1); }); test('falls back to minimum morning heart rate when no RHR record today', @@ -271,7 +274,9 @@ void main() { await manager.refresh(); - expect(hc.hrReadCount, 1); + // Two HR-sample reads now: the all-day HR snapshot (for the Heart-rate + // card) plus the morning-RHR fallback used for scoring. + expect(hc.hrReadCount, 2); expect(manager.snapshot?.restingHr, 55); }); From 0a611d40e017890a61857e77e8d89bcd54e42ed0 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:52:13 +0530 Subject: [PATCH 6/7] feat: Add workout heart rate analysis and recovery metrics --- .../lib/models/workout_hr_models.dart | 105 +++++ .../widgets/session_details_sheet.dart | 4 + .../screens/widgets/workout_hr_section.dart | 436 ++++++++++++++++++ .../managers/health_history_manager.dart | 9 + .../services/utils/workout_hr_builder.dart | 169 +++++++ .../test/workout_hr_builder_test.dart | 110 +++++ 6 files changed, 833 insertions(+) create mode 100644 workout-logger/lib/models/workout_hr_models.dart create mode 100644 workout-logger/lib/screens/widgets/workout_hr_section.dart create mode 100644 workout-logger/lib/services/utils/workout_hr_builder.dart create mode 100644 workout-logger/test/workout_hr_builder_test.dart diff --git a/workout-logger/lib/models/workout_hr_models.dart b/workout-logger/lib/models/workout_hr_models.dart new file mode 100644 index 0000000..f756b28 --- /dev/null +++ b/workout-logger/lib/models/workout_hr_models.dart @@ -0,0 +1,105 @@ +// Data models for the per-workout heart-rate breakdown shown in the History +// session-details sheet. Computed at runtime from Health Connect HR samples + +// the session's set timestamps; never persisted. +library; + +/// One point on the workout HR curve (~30-second bucket average). +class HrCurvePoint { + final DateTime time; + final double bpm; + const HrCurvePoint({required this.time, required this.bpm}); +} + +/// HR recovery across one rest gap between two sets. +class RestRecovery { + /// 1-based index of the set this rest follows (global across the session). + final int afterSet; + final DateTime restStart; + final int durationSec; + + /// HR at the end of the preceding set (local peak). + final int peakBpm; + + /// Lowest HR reached during the rest. + final int troughBpm; + + /// peakBpm − troughBpm (positive means HR came down). + final int recoveryBpm; + + /// True when the drop met the recovery threshold. + final bool recovered; + + const RestRecovery({ + required this.afterSet, + required this.restStart, + required this.durationSec, + required this.peakBpm, + required this.troughBpm, + required this.recoveryBpm, + required this.recovered, + }); +} + +/// Time span of one exercise within the session — drawn as a labelled flag / +/// section on the HR curve so you can see which part of the workout is which. +class ExerciseHrSpan { + final String exerciseId; + final DateTime start; + final DateTime end; + final int setCount; + + const ExerciseHrSpan({ + required this.exerciseId, + required this.start, + required this.end, + required this.setCount, + }); +} + +/// Complete HR picture for one recorded workout. +class WorkoutHrAnalysis { + final DateTime start; + final DateTime end; + final int avgBpm; + final int peakBpm; + final int minBpm; + + /// Ordered curve points across the session. + final List curve; + + /// Per-rest recovery. Empty when set timestamps aren't trustworthy + /// ([hasRestAnalysis] is false) — the curve still renders. + final List rests; + + /// Exercise sections across the session, ordered in time. Empty when set + /// timestamps aren't trustworthy. + final List exercises; + + /// Whether rest/section analysis was computed (set timestamps spanned the + /// session). + final bool hasRestAnalysis; + + const WorkoutHrAnalysis({ + required this.start, + required this.end, + required this.avgBpm, + required this.peakBpm, + required this.minBpm, + required this.curve, + required this.rests, + required this.exercises, + required this.hasRestAnalysis, + }); + + int get restsRecovered => rests.where((r) => r.recovered).length; + int get restCount => rests.length; + + /// Mean recovery (bpm) across the rests that recovered; 0 when none did. + int get avgRecoveryBpm { + final ok = rests.where((r) => r.recovered).toList(); + if (ok.isEmpty) return 0; + return (ok.fold(0, (s, r) => s + r.recoveryBpm) / ok.length).round(); + } + + int get restsTooShort => rests.where((r) => !r.recovered).length; +} diff --git a/workout-logger/lib/screens/widgets/session_details_sheet.dart b/workout-logger/lib/screens/widgets/session_details_sheet.dart index 9e20c3e..2e9fb71 100644 --- a/workout-logger/lib/screens/widgets/session_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/session_details_sheet.dart @@ -9,6 +9,7 @@ import '../../services/workout_provider.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import 'workout_hr_section.dart'; const Color _hcColor = Color(0xFF4ECDC4); @@ -163,6 +164,9 @@ class SessionDetailsSheet extends StatelessWidget { (log) => _ExerciseDetailCard(log: log, provider: provider), ), + // HR + rest-recovery breakdown (self-hides when no HR data). + WorkoutHrSection(session: session, provider: provider), + if (session.notes != null && session.notes!.isNotEmpty) ...[ const SizedBox(height: AppSpacing.md), const RFSectionHeader('Notes'), diff --git a/workout-logger/lib/screens/widgets/workout_hr_section.dart b/workout-logger/lib/screens/widgets/workout_hr_section.dart new file mode 100644 index 0000000..c4b6df7 --- /dev/null +++ b/workout-logger/lib/screens/widgets/workout_hr_section.dart @@ -0,0 +1,436 @@ +// workout_hr_section.dart — per-workout HR breakdown for the History session +// sheet. Self-hides when Health Connect has no HR data for the workout window. +// +// Shows: avg/peak/min pills, an HR curve with exercise-section flags + rest +// shading (green = HR recovered, amber = didn't), a recovery summary, and an +// expandable per-rest table. + +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../models/workout_hr_models.dart'; +import '../../services/managers/health_history_manager.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class WorkoutHrSection extends StatefulWidget { + const WorkoutHrSection({super.key, required this.session, required this.provider}); + + final WorkoutSession session; + final WorkoutProvider provider; + + @override + State createState() => _WorkoutHrSectionState(); +} + +class _WorkoutHrSectionState extends State { + Future? _future; + bool _expanded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= context.read().workoutHr(widget.session); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done || snap.data == null) { + // Self-hide while loading and when there's no HR data. + return const SizedBox.shrink(); + } + final a = snap.data!; + final sections = a.exercises + .map((e) => _Section( + label: _shortName(widget.provider.getExercise(e.exerciseId)?.name ?? '—'), + start: e.start, + end: e.end, + )) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Heart rate'), + const SizedBox(height: AppSpacing.sm), + GlassCard( + padding: const EdgeInsets.all(14), + borderColor: AppColors.accent.withValues(alpha: 0.18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Pill(label: 'Avg', value: '${a.avgBpm}', color: AppColors.primary), + const SizedBox(width: 6), + _Pill(label: 'Peak', value: '${a.peakBpm}', color: AppColors.accent), + const SizedBox(width: 6), + _Pill(label: 'Min', value: '${a.minBpm}', color: AppColors.secondary), + ], + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'HR across the session · ⚑ = exercise', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11), + ), + Text('bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11)), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 150, + child: CustomPaint( + size: const Size(double.infinity, 150), + painter: _CurvePainter(analysis: a, sections: sections), + ), + ), + if (a.hasRestAnalysis && a.restCount > 0) ...[ + const SizedBox(height: 12), + _RecoverySummary(analysis: a), + const SizedBox(height: 10), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: AppColors.glassBorder), + ), + alignment: Alignment.center, + child: Text( + _expanded ? 'Hide per-rest breakdown ▴' : 'Show per-rest breakdown ▾', + style: GoogleFonts.geist( + color: AppColors.textMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (_expanded) ...[ + const SizedBox(height: 8), + ...a.rests.map((r) => _RestRow(rest: r)), + ], + ] else if (!a.hasRestAnalysis) ...[ + const SizedBox(height: 10), + Text( + 'Per-rest recovery needs per-set timing, which this workout ' + 'didn\'t record.', + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 11, height: 1.4), + ), + ], + ], + ), + ), + ], + ); + }, + ); + } + + static String _shortName(String name) { + if (name.length <= 14) return name; + final words = name.split(' '); + if (words.length >= 2) return '${words.first} ${words[1][0]}.'; + return '${name.substring(0, 12)}…'; + } +} + +class _Section { + final String label; + final DateTime start; + final DateTime end; + const _Section({required this.label, required this.start, required this.end}); +} + +// ── Recovery summary ────────────────────────────────────────────────────────── + +class _RecoverySummary extends StatelessWidget { + const _RecoverySummary({required this.analysis}); + final WorkoutHrAnalysis analysis; + + @override + Widget build(BuildContext context) { + final tooShort = analysis.restsTooShort; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Text( + '${analysis.restsRecovered}/${analysis.restCount}', + style: GoogleFonts.geistMono( + color: AppColors.success, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: GoogleFonts.geist(color: AppColors.textMuted, fontSize: 11, height: 1.4), + children: [ + const TextSpan( + text: 'rests brought your HR down\n', + style: TextStyle(color: AppColors.textSoft, fontWeight: FontWeight.w600), + ), + TextSpan(text: 'avg '), + TextSpan( + text: '−${analysis.avgRecoveryBpm} bpm', + style: const TextStyle(color: AppColors.success, fontWeight: FontWeight.w700), + ), + TextSpan(text: ' per rest'), + if (tooShort > 0) TextSpan(text: ' · $tooShort too short to drop'), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RestRow extends StatelessWidget { + const _RestRow({required this.rest}); + final RestRecovery rest; + + @override + Widget build(BuildContext context) { + final ok = rest.recovered; + final color = ok ? AppColors.success : AppColors.warning; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(ok ? Icons.check_rounded : Icons.priority_high_rounded, size: 13, color: color), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'After set ${rest.afterSet} · rest ${rest.durationSec}s', + style: GoogleFonts.geist( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 1), + Text( + 'peak ${rest.peakBpm} → low ${rest.troughBpm} bpm${ok ? '' : ' · too short'}', + style: GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 10), + ), + ], + ), + ), + Text( + '−${rest.recoveryBpm} bpm', + style: GoogleFonts.geistMono(color: color, fontSize: 14, fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + +// ── Pill ────────────────────────────────────────────────────────────────────── + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + RichText( + text: TextSpan(children: [ + TextSpan( + text: value, + style: GoogleFonts.geistMono(color: color, fontSize: 16, fontWeight: FontWeight.w700), + ), + TextSpan(text: ' bpm', style: GoogleFonts.geist(color: AppColors.textFaint, fontSize: 9)), + ]), + ), + ], + ), + ), + ); + } +} + +// ── Curve painter ───────────────────────────────────────────────────────────── + +class _CurvePainter extends CustomPainter { + _CurvePainter({required this.analysis, required this.sections}); + + final WorkoutHrAnalysis analysis; + final List<_Section> sections; + + static const _padLeft = 24.0; + static const _padTop = 14.0; + static const _padBottom = 16.0; + + @override + void paint(Canvas canvas, Size size) { + final curve = analysis.curve; + if (curve.isEmpty) return; + + final startMs = analysis.start.millisecondsSinceEpoch; + final spanMs = max(analysis.end.millisecondsSinceEpoch - startMs, 1); + final vmin = (analysis.minBpm - 6).toDouble(); + final vmax = (analysis.peakBpm + 6).toDouble(); + + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + + double x(DateTime t) => + _padLeft + ((t.millisecondsSinceEpoch - startMs) / spanMs).clamp(0.0, 1.0) * chartW; + double y(double v) => _padTop + chartH - ((v - vmin) / (vmax - vmin)) * chartH; + + // Grid + Y labels. + final grid = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + for (var v = (vmin / 20).ceil() * 20; v <= vmax; v += 20) { + final yy = y(v.toDouble()); + canvas.drawLine(Offset(_padLeft, yy), Offset(size.width - 4, yy), grid); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}', style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, yy - tp.height / 2)); + } + + // Rest shading (green = recovered, amber = not). + for (final r in analysis.rests) { + final rx = x(r.restStart); + final rEnd = x(r.restStart.add(Duration(seconds: r.durationSec))); + final c = (r.recovered ? AppColors.success : AppColors.warning).withValues(alpha: 0.14); + canvas.drawRect(Rect.fromLTRB(rx, _padTop, max(rEnd, rx + 1), _padTop + chartH), Paint()..color = c); + } + + // Area + line. + final path = Path(); + final area = Path(); + for (var i = 0; i < curve.length; i++) { + final px = x(curve[i].time); + final py = y(curve[i].bpm); + if (i == 0) { + path.moveTo(px, py); + area.moveTo(px, y(vmin)); + area.lineTo(px, py); + } else { + path.lineTo(px, py); + area.lineTo(px, py); + } + } + area.lineTo(x(curve.last.time), y(vmin)); + area.close(); + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent.withValues(alpha: 0.3), AppColors.accent.withValues(alpha: 0.0)], + ).createShader(Rect.fromLTWH(_padLeft, _padTop, chartW, chartH)), + ); + canvas.drawPath( + path, + Paint() + ..color = AppColors.accent + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6 + ..strokeJoin = StrokeJoin.round, + ); + + // Exercise-section flags. + final flagPaint = Paint() + ..color = AppColors.textMuted.withValues(alpha: 0.5) + ..strokeWidth = 1; + for (final s in sections) { + final fx = x(s.start); + canvas.drawLine(Offset(fx, _padTop), Offset(fx, _padTop + chartH), flagPaint); + // Flag label chip at top. + final tp = TextPainter( + text: TextSpan( + text: s.label, + style: GoogleFonts.geist(color: AppColors.textSoft, fontSize: 8, fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: 64); + final lx = min(fx + 3, size.width - 4 - tp.width - 6); + final chip = Rect.fromLTWH(lx, _padTop - 1, tp.width + 6, 11); + canvas.drawRRect( + RRect.fromRectAndRadius(chip, const Radius.circular(3)), + Paint()..color = AppColors.card.withValues(alpha: 0.92), + ); + tp.paint(canvas, Offset(lx + 3, _padTop - 0.5)); + } + + // X labels (minutes). + final xStyle = GoogleFonts.geistMono(color: AppColors.textFaint, fontSize: 8); + final totalMin = (spanMs / 60000).round(); + final stepMin = totalMin <= 0 ? 1 : (totalMin / 4).ceil(); + for (var m = 0; m <= totalMin; m += stepMin) { + final tx = _padLeft + (m * 60000 / spanMs).clamp(0.0, 1.0) * chartW; + final tp = TextPainter( + text: TextSpan(text: '${m}m', style: xStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((tx - tp.width / 2).clamp(0, size.width - tp.width), size.height - _padBottom + 4)); + } + } + + @override + bool shouldRepaint(_CurvePainter old) => old.analysis != analysis; +} diff --git a/workout-logger/lib/services/managers/health_history_manager.dart b/workout-logger/lib/services/managers/health_history_manager.dart index ea5b77b..deb661e 100644 --- a/workout-logger/lib/services/managers/health_history_manager.dart +++ b/workout-logger/lib/services/managers/health_history_manager.dart @@ -13,9 +13,11 @@ import 'dart:convert'; import '../../models/models.dart'; import '../../models/sleep_hr_models.dart'; +import '../../models/workout_hr_models.dart'; import '../interfaces/health_connect_service_interface.dart'; import '../interfaces/storage_service_interface.dart'; import '../utils/sleep_hr_builder.dart'; +import '../utils/workout_hr_builder.dart'; class HealthHistoryManager { final IHealthConnectService _hc; @@ -71,6 +73,13 @@ class HealthHistoryManager { Future> _granted() => _hc.grantedReadTypes(); + /// HR breakdown for one recorded workout: curve, peak/avg/min, exercise + /// sections, and per-rest HR recovery. Null when no HR data covers the window. + Future workoutHr(WorkoutSession session) async { + final granted = await _granted(); + return buildWorkoutHrAnalysis(_hc, session, granted); + } + // ── Day detail ────────────────────────────────────────────────────────────── /// Overnight HR snapshot for the night ending the morning of [morning]. diff --git a/workout-logger/lib/services/utils/workout_hr_builder.dart b/workout-logger/lib/services/utils/workout_hr_builder.dart new file mode 100644 index 0000000..23dba10 --- /dev/null +++ b/workout-logger/lib/services/utils/workout_hr_builder.dart @@ -0,0 +1,169 @@ +// Workout HR analysis builder. +// +// Pure function over IHealthConnectService: pulls HR samples for a recorded +// workout window, builds a downsampled curve, and measures HR recovery across +// each rest gap (reconstructed from set timestamps + timeTaken). + +import '../../models/models.dart'; +import '../../models/workout_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; + +/// Minimum HR drop (bpm) for a rest to count as "recovered". +const int kRestRecoveryThreshold = 5; + +/// Minimum HR samples in-window before an analysis is worthwhile. +const int _minSamples = 5; + +/// Builds the per-workout HR analysis, or null when HR permission is missing +/// or too few samples cover the workout window. +Future buildWorkoutHrAnalysis( + IHealthConnectService hc, + WorkoutSession session, + Set granted, +) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + + final start = session.date; + final end = start.add(Duration(minutes: session.duration)); + + // Read with a small buffer so set-end peaks near the edges are covered. + final raw = await hc.readHeartRateSamples( + start.subtract(const Duration(minutes: 1)), + end.add(const Duration(minutes: 1)), + ); + final samples = raw.where((s) => !s.time.isBefore(start) && !s.time.isAfter(end)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + if (samples.length < _minSamples) return null; + + final bpms = samples.map((s) => s.value).toList(); + final avg = (bpms.reduce((a, b) => a + b) / bpms.length).round(); + final peak = bpms.reduce((a, b) => a > b ? a : b).round(); + final lo = bpms.reduce((a, b) => a < b ? a : b).round(); + + // Curve: 30-second bucket averages. + final curve = _buildCurve(samples, start); + + // Rest + exercise-section analysis from set timestamps (shared validity gate). + final valid = _timestampsValid(session, start, end); + final rests = valid ? _buildRests(session, samples) : const []; + final exercises = valid ? _buildExerciseSpans(session) : const []; + + return WorkoutHrAnalysis( + start: start, + end: end, + avgBpm: avg, + peakBpm: peak, + minBpm: lo, + curve: curve, + rests: rests, + exercises: exercises, + hasRestAnalysis: valid, + ); +} + +/// Set timestamps must actually span the session, otherwise they're +/// placeholders (old/imported sessions) and gaps/sections are meaningless. +bool _timestampsValid(WorkoutSession session, DateTime start, DateTime end) { + final sets = session.exercises.expand((e) => e.sets).toList(); + if (sets.length < 2) return false; + final ts = sets.map((s) => s.timestamp).toList()..sort(); + if (ts.last.difference(ts.first).inMinutes < 5) return false; + if (ts.first.isBefore(start.subtract(const Duration(minutes: 5))) || + ts.last.isAfter(end.add(const Duration(minutes: 5)))) { + return false; + } + return true; +} + +/// One section per exercise, spanning its first set's start to its last set. +List _buildExerciseSpans(WorkoutSession session) { + final spans = []; + for (final log in session.exercises) { + if (log.sets.isEmpty) continue; + final times = log.sets.map((s) => s.timestamp).toList()..sort(); + final firstSet = log.sets.reduce((a, b) => a.timestamp.isBefore(b.timestamp) ? a : b); + final start = firstSet.timestamp.subtract(Duration(seconds: firstSet.timeTaken ?? 0)); + spans.add(ExerciseHrSpan( + exerciseId: log.exerciseId, + start: start, + end: times.last, + setCount: log.sets.length, + )); + } + spans.sort((a, b) => a.start.compareTo(b.start)); + return spans; +} + +List _buildCurve(List samples, DateTime start) { + const bucketSec = 30; + final byBucket = >{}; + for (final s in samples) { + final off = s.time.difference(start).inSeconds; + if (off < 0) continue; + byBucket.putIfAbsent((off ~/ bucketSec) * bucketSec, () => []).add(s.value); + } + final points = []; + for (final key in byBucket.keys.toList()..sort()) { + final vals = byBucket[key]!; + points.add(HrCurvePoint( + time: start.add(Duration(seconds: key)), + bpm: vals.reduce((a, b) => a + b) / vals.length, + )); + } + return points; +} + +List _buildRests( + WorkoutSession session, + List samples, +) { + // Flatten all sets across exercises, ordered by timestamp. + final sets = session.exercises.expand((e) => e.sets).toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + if (sets.length < 2) return const []; + + double? maxIn(DateTime a, DateTime b) { + final vs = samples + .where((s) => !s.time.isBefore(a) && !s.time.isAfter(b)) + .map((s) => s.value); + return vs.isEmpty ? null : vs.reduce((x, y) => x > y ? x : y); + } + + double? minIn(DateTime a, DateTime b) { + final vs = samples + .where((s) => !s.time.isBefore(a) && !s.time.isAfter(b)) + .map((s) => s.value); + return vs.isEmpty ? null : vs.reduce((x, y) => x < y ? x : y); + } + + final rests = []; + for (var i = 0; i < sets.length - 1; i++) { + final a = sets[i]; + final b = sets[i + 1]; + final restStart = a.timestamp; + // Next set begins after subtracting how long it took to perform. + final nextStart = b.timestamp.subtract(Duration(seconds: b.timeTaken ?? 0)); + final restEnd = nextStart.isAfter(restStart) ? nextStart : b.timestamp; + final durSec = restEnd.difference(restStart).inSeconds; + if (durSec < 5) continue; + + // Peak HR around the set's end; trough during the rest. + final peak = maxIn(restStart.subtract(const Duration(seconds: 20)), + restStart.add(const Duration(seconds: 20))) ?? + minIn(restStart, restEnd); + final trough = minIn(restStart, restEnd); + if (peak == null || trough == null) continue; + + final recovery = (peak - trough).round(); + rests.add(RestRecovery( + afterSet: i + 1, + restStart: restStart, + durationSec: durSec, + peakBpm: peak.round(), + troughBpm: trough.round(), + recoveryBpm: recovery, + recovered: recovery >= kRestRecoveryThreshold, + )); + } + return rests; +} diff --git a/workout-logger/test/workout_hr_builder_test.dart b/workout-logger/test/workout_hr_builder_test.dart new file mode 100644 index 0000000..b0ce186 --- /dev/null +++ b/workout-logger/test/workout_hr_builder_test.dart @@ -0,0 +1,110 @@ +// Unit tests for the workout HR analysis builder (rest recovery + guards). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/utils/workout_hr_builder.dart'; + +class _Hc implements IHealthConnectService { + final List hr; + _Hc(this.hr); + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + hr.where((s) => !s.time.isBefore(start) && !s.time.isAfter(end)).toList(); + + @override + Future> grantedReadTypes() async => {HealthReadType.heartRate}; + @override + Future> readSleepSessions(DateTime s, DateTime e) async => const []; + @override + Future> readRestingHeartRate(DateTime s, DateTime e) async => const []; + @override + Future> readHrvRmssd(DateTime s, DateTime e) async => const []; + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +DateTime _t(int h, int m, [int s = 0]) => DateTime(2026, 6, 9, h, m, s); + +WorkoutSet _set(DateTime ts, {int timeTaken = 30}) => + WorkoutSet(weight: 100, reps: 8, timestamp: ts, timeTaken: timeTaken); + +void main() { + final session = WorkoutSession( + id: 'w1', + date: _t(18, 0), + duration: 20, // ends 18:20 + exercises: [ + ExerciseLog(exerciseId: 'bench', sets: [_set(_t(18, 2)), _set(_t(18, 5))]), + ExerciseLog(exerciseId: 'row', sets: [_set(_t(18, 10)), _set(_t(18, 14))]), + ], + ); + + // Crafted HR: drops during the first two rests, stays high in the third. + final samples = [ + HealthSample(time: _t(18, 2), value: 160), // set A1 end (peak) + HealthSample(time: _t(18, 3), value: 140), // rest 1 trough + HealthSample(time: _t(18, 4), value: 142), + HealthSample(time: _t(18, 5), value: 158), // set A2 end + HealthSample(time: _t(18, 7), value: 130), // rest 2 trough + HealthSample(time: _t(18, 10), value: 162), // set B1 end + HealthSample(time: _t(18, 12), value: 159), // rest 3 stays high + HealthSample(time: _t(18, 14), value: 150), // set B2 end + ]; + + test('computes per-rest recovery and flags short rests', () async { + final a = await buildWorkoutHrAnalysis(_Hc(samples), session, {HealthReadType.heartRate}); + expect(a, isNotNull); + expect(a!.peakBpm, 162); + expect(a.minBpm, 130); + expect(a.hasRestAnalysis, true); + + expect(a.restCount, 3); + expect(a.restsRecovered, 2); + expect(a.rests[0].recoveryBpm, 20); // 160 → 140 + expect(a.rests[0].recovered, true); + expect(a.rests[2].recoveryBpm, 3); // 162 → 159 + expect(a.rests[2].recovered, false); + expect(a.avgRecoveryBpm, 24); // (20 + 28) / 2 + + expect(a.exercises.length, 2); + expect(a.exercises.first.setCount, 2); + }); + + test('guards against placeholder timestamps (no per-set timing)', () async { + final flat = WorkoutSession( + id: 'w2', + date: _t(18, 0), + duration: 20, + exercises: [ + ExerciseLog(exerciseId: 'bench', sets: [_set(_t(18, 0)), _set(_t(18, 0))]), + ], + ); + final a = await buildWorkoutHrAnalysis(_Hc(samples), flat, {HealthReadType.heartRate}); + expect(a, isNotNull); + expect(a!.hasRestAnalysis, false); + expect(a.rests, isEmpty); + expect(a.exercises, isEmpty); + expect(a.curve, isNotEmpty); // curve still renders + }); + + test('returns null without HR permission', () async { + final a = await buildWorkoutHrAnalysis(_Hc(samples), session, {HealthReadType.sleep}); + expect(a, isNull); + }); + + test('returns null when too few samples cover the window', () async { + final sparse = _Hc([HealthSample(time: _t(18, 5), value: 150)]); + final a = await buildWorkoutHrAnalysis(sparse, session, {HealthReadType.heartRate}); + expect(a, isNull); + }); +} From 57ee3629fe2e644041cff5e511dc7e4c8f682948 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:02:09 +0530 Subject: [PATCH 7/7] feat: Enhance release process with keystore decoding and APK signing configuration --- .github/workflows/release.yml | 24 ++++-- docs/FUTURE_IMPROVEMENTS.md | 92 +++++++++++++++++++++ workout-logger/android/app/build.gradle.kts | 27 ++++-- 3 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 docs/FUTURE_IMPROVEMENTS.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4dda78..393c5ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,14 +115,26 @@ jobs: echo "EOF" } >> $GITHUB_OUTPUT + - name: Decode release keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/repforge-release.jks + - name: Build APK working-directory: ./workout-logger - run: flutter build apk --release + env: + KEYSTORE_PATH: /tmp/repforge-release.jks + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: flutter build apk --release --split-per-abi - - name: Rename APK + - name: Rename APKs run: | - mv workout-logger/build/app/outputs/flutter-apk/app-release.apk \ - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + V="${{ steps.version.outputs.value }}" + DIR="workout-logger/build/app/outputs/flutter-apk" + mv "$DIR/app-arm64-v8a-release.apk" "$DIR/repforge-v${V}-arm64-v8a.apk" 2>/dev/null || true + mv "$DIR/app-armeabi-v7a-release.apk" "$DIR/repforge-v${V}-armeabi-v7a.apk" 2>/dev/null || true + mv "$DIR/app-x86_64-release.apk" "$DIR/repforge-v${V}-x86_64.apk" 2>/dev/null || true - name: Sanitize ref name for artifact id: sanitize_ref @@ -133,7 +145,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: repforge-v${{ steps.version.outputs.value }}-${{ steps.sanitize_ref.outputs.ref_name }} - path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk retention-days: 7 - name: Create GitHub Release @@ -156,7 +168,7 @@ jobs: - **Build Date**: ${{ github.event.head_commit.timestamp }} - **Commit**: ${{ github.sha }} files: | - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk draft: false prerelease: false env: diff --git a/docs/FUTURE_IMPROVEMENTS.md b/docs/FUTURE_IMPROVEMENTS.md new file mode 100644 index 0000000..8db491b --- /dev/null +++ b/docs/FUTURE_IMPROVEMENTS.md @@ -0,0 +1,92 @@ +# Future Improvements — Open Source Store Launch + +This file tracks the remaining work for Options B and C of the open-source store launch plan. +Option A (production signing + IzzyOnDroid/Obtainium) is complete. + +--- + +## Option B — F-Droid Readiness + +### 1. Bundle Geist fonts locally (google_fonts) + +Currently `google_fonts` may fetch font files from Google's CDN at first launch. F-Droid requires +all network access to be under user control — a silent font download at startup fails that bar. + +**Fix:** Download the Geist Sans and Geist Mono `.ttf` files, add them to `assets/fonts/`, declare +them in `pubspec.yaml` under `flutter.fonts`, and replace `GoogleFonts.geist(...)` calls with +`TextStyle(fontFamily: 'Geist')`. Then remove the `google_fonts` package. + +### 2. F-Droid metadata file + +Create `fdroid/metadata/com.devasy.repforge.yml` following the F-Droid metadata spec: + +```yaml +Categories: + - Sports & Health +License: Apache-2.0 +SourceCode: https://github.com//repforge +IssueTracker: https://github.com//repforge/issues + +AutoName: RepForge +Summary: Workout logger with AI-powered coaching +Description: |- + RepForge is an open-source workout logging app with set/rep/weight tracking, + progress analytics, AI coaching (optional, requires user-supplied Gemini API key), + and Health Connect integration. + +AntiFeatures: + NonFreeNet: + - description: > + Optional AI Coach and Routine Optimizer features send data to Google's Gemini API. + These features are disabled unless the user provides their own API key in Settings. + +Builds: + - versionName: 2.x.x + versionCode: xx + commit: vX.X.X + subdir: workout-logger + gradle: + - release +``` + +### 3. Fastlane store metadata + +Create `fastlane/metadata/android/en-US/` with: +- `title.txt` — "RepForge" +- `short_description.txt` — one-line summary (≤80 chars) +- `full_description.txt` — full store description +- `changelogs/.txt` — per-release changelog + +IzzyOnDroid also reads fastlane metadata for its store listing. + +--- + +## Option C — Strict F-Droid Compliance + +### 4. Health Connect graceful degradation + +Health Connect is an OS API (not Google Play Services) so F-Droid accepts it. However, for +maximum compatibility on AOSP/custom ROMs without Health Connect: + +- Add an `isHealthConnectAvailable()` check at startup +- Show a "Health Connect not available" state in the Readiness screen instead of crashing +- Make daily readiness score optional in the Home screen when Health Connect is absent + +### 5. Replace google_fonts package entirely + +After completing item B.1, the `google_fonts` package can be removed from `pubspec.yaml` entirely. +This eliminates any risk of runtime Google CDN fetches and removes a transitive dependency. + +--- + +## IzzyOnDroid Submission Checklist + +- [ ] Merge this branch to main and confirm a production-signed release appears on GitHub Releases +- [ ] Submit via: https://gitlab.com/IzzyOnDroid/repo/-/issues (open a new issue, "App submission" template) +- [ ] Provide: repo URL, anti-features (NonFreeNet), brief description +- [ ] Wait for review (typically 1–7 days) + +## Obtainium + +No submission needed. Users add the GitHub repo URL directly in Obtainium and install the latest +release APK automatically. Share the repo URL in your README. diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index d478d13..727fd5e 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -20,11 +20,23 @@ android { jvmTarget = JavaVersion.VERSION_11.toString() } + signingConfigs { + create("release") { + val keystorePath = System.getenv("KEYSTORE_PATH") + val storePass = System.getenv("KEY_STORE_PASSWORD") + val alias = System.getenv("KEY_ALIAS") + val keyPass = System.getenv("KEY_PASSWORD") + if (keystorePath != null && storePass != null && alias != null && keyPass != null) { + storeFile = file(keystorePath) + storePassword = storePass + keyAlias = alias + keyPassword = keyPass + } + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.devasy.repforge" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. // MIGRATION NOTE: minSdk is intentionally set to 26 (Android 8.0 Oreo). // Health Connect requires API 26+. Devices running API <26 are no longer // supported. If downgrading, remove the health_connector dependency and @@ -48,9 +60,12 @@ android { manifestPlaceholders["appLabel"] = "RepForge (Debug)" } release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Uses the production EC P-256 keystore when KEYSTORE_PATH env var is set + // (CI injects it via GitHub Secrets). Falls back to debug key for local + // flutter run --release without env vars configured. + val releaseConfig = signingConfigs.getByName("release") + signingConfig = if (releaseConfig.storeFile != null) releaseConfig + else signingConfigs.getByName("debug") } } }