Repository navigation
feat(workouts): score an imported workout with band heart rate (closes #325) #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1f373b4
1e6418d
fd502b7
d606279
dd7923f
37ded15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,7 +38,9 @@ import '../../models/activity_suggestion.dart'; | |
| import 'detected_activities.dart'; | ||
| import '../../data/db.dart'; | ||
| import '../../data/journal_fields.dart' show formatMinuteOfDay; | ||
| import '../../data/local_repository.dart'; | ||
| import '../../health/health_export.dart'; | ||
| import '../../health/health_workout_import.dart' show rememberDeletedUuid; | ||
| import '../../l10n/app_localizations.dart'; | ||
| import '../../l10n/date_text.dart'; | ||
| import '../../state/app_state.dart'; | ||
|
|
@@ -126,6 +128,7 @@ class LogWorkout extends StatefulWidget { | |
| this.title, | ||
| this.spans, | ||
| this.now, | ||
| this.importedUuid, | ||
| }); | ||
|
|
||
| final String? sessionId; | ||
|
|
@@ -145,6 +148,10 @@ class LogWorkout extends StatefulWidget { | |
| /// Injected in tests so "that hasn't happened yet" is deterministic. | ||
| final DateTime? now; | ||
|
|
||
| /// Set when this scores an imported workout (#325): the saved session | ||
| /// replaces that import, so the workout is not listed and counted twice. | ||
| final String? importedUuid; | ||
|
|
||
| @override | ||
| State<LogWorkout> createState() => _LogWorkoutState(); | ||
| } | ||
|
|
@@ -198,8 +205,12 @@ class _LogWorkoutState extends State<LogWorkout> { | |
| existing: _spans, | ||
| // A retime must not collide with itself; a new entry's id is derived | ||
| // from its start second, so re-logging the same window updates that | ||
| // row rather than colliding with it. | ||
| editingId: widget.sessionId ?? manualSessionId(_startSec), | ||
| // row rather than colliding with it. Not when scoring an import: a | ||
| // session already at that start would be overwritten by the scored | ||
| // copy (and deleted with it if that saves unscored), so every saved | ||
| // session counts as a collision. | ||
| editingId: widget.sessionId ?? | ||
| (widget.importedUuid == null ? manualSessionId(_startSec) : null), | ||
| ); | ||
|
|
||
| Future<void> _pickDate() async { | ||
|
|
@@ -282,15 +293,39 @@ class _LogWorkoutState extends State<LogWorkout> { | |
| if (mounted) nav.pop(true); | ||
| return; | ||
| } | ||
| if (widget.importedUuid != null) { | ||
| // The spans this form opened with may still have been loading: check | ||
| // the window against what is saved now, before writing over it. | ||
| final spans = await repo.savedSessionSpans(); | ||
| if (!mounted) return; | ||
| setState(() => _spans = spans); | ||
| if (_invalid != null) { | ||
| setState(() => _saving = false); | ||
| return; | ||
| } | ||
| } | ||
| final r = widget.sessionId == null | ||
| ? await repo.logManualWorkout( | ||
| startTs: _startSec, endTs: _endSec, type: _activity.typeKey) | ||
| : await repo.setWorkoutWindow(widget.sessionId!, | ||
| startTs: _startSec, endTs: _endSec); | ||
| // Both branches: a new session and a RETIMED one both change what the | ||
| // health store should hold for that window (#130). | ||
| await HealthExporter.exportWorkoutId( | ||
| (r['workout_id'] ?? widget.sessionId) as String?); | ||
| if (widget.importedUuid case final uuid?) { | ||
| if (!await replaceImportWithScored(repo, uuid, r)) { | ||
|
abdulsaheel marked this conversation as resolved.
|
||
| if (!mounted) return; | ||
| setState(() { | ||
| _saving = false; | ||
| _wrote = l?.logWorkoutImportedHrGone ?? | ||
| 'Heart rate for this workout is no longer stored, so the band ' | ||
| 'cannot score it.'; | ||
| }); | ||
| return; | ||
| } | ||
| } else { | ||
| // Both branches: a new session and a RETIMED one both change what the | ||
| // health store should hold for that window (#130). | ||
| await HealthExporter.exportWorkoutId( | ||
| (r['workout_id'] ?? widget.sessionId) as String?); | ||
| } | ||
| // Say what was actually banked. A window with no 1 Hz substrate left | ||
| // behind it — anything past the `rawRetentionDays` retention, or a | ||
| // stretch the band was off — is saved UNSCORED, and a screen that pops | ||
|
|
@@ -526,6 +561,65 @@ AppState? appOf(BuildContext c) { | |
| } | ||
| } | ||
|
|
||
| /// Settle a scoring save against the import it was opened from (#325). | ||
| /// | ||
| /// Only a SCORED session supersedes the import. The heart-rate check runs | ||
| /// before the form opens, so a retimed window (or samples pruned meanwhile) | ||
| /// can still save unscored; that copy carries less than the import, so it is | ||
| /// removed and the import kept. False when that happened. | ||
| Future<bool> replaceImportWithScored( | ||
| LocalRepository repo, String uuid, Map<String, dynamic> saved) async { | ||
| final id = saved['workout_id'] as String; | ||
| if (saved['unscored'] == true) { | ||
| await repo.deleteWorkout(id); | ||
| return false; | ||
| } | ||
| // Superseded first: a tombstone left by a failed supersede would have the | ||
| // next import pass delete an import that is still the only copy. The other | ||
| // order's failure only re-imports it beside the scored session. | ||
| await LocalDb.supersedeImportedWorkout(uuid, id); | ||
| // The original already sits in the health store; exporting ours too would | ||
| // put the same workout there twice. | ||
| await rememberDeletedUuid(uuid); | ||
| return true; | ||
| } | ||
|
|
||
| /// Tapping an imported workout (#325): score its window from the band's own | ||
| /// 1 Hz heart rate through the ordinary manual-log form, or say plainly that | ||
| /// the heart rate behind it is gone. [hasHr] is injected in tests. | ||
| Future<void> scoreImportedWorkout( | ||
| BuildContext c, { | ||
| required String uuid, | ||
| required DateTime start, | ||
| required DateTime end, | ||
| required Activity activity, | ||
| Future<bool> Function(int startSec, int endSec)? hasHr, | ||
| }) async { | ||
| final l = AppLocalizations.of(c); | ||
| final s = start.millisecondsSinceEpoch ~/ 1000; | ||
| final e = end.millisecondsSinceEpoch ~/ 1000; | ||
| final stored = await (hasHr ?? | ||
| (s, e) async => (await LocalDb.hrSamplesInRange(s, e)).isNotEmpty)(s, e); | ||
|
Comment on lines
+601
to
+602
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win Use an existence query for the heart-rate check.
🤖 Prompt for AI Agents |
||
| if (!c.mounted) return; | ||
| if (!stored) { | ||
| ScaffoldMessenger.of(c).showSnackBar(SnackBar( | ||
| content: Text(l?.logWorkoutImportedHrGone ?? | ||
| 'Heart rate for this workout is no longer stored, so the band ' | ||
| 'cannot score it.'), | ||
| )); | ||
| return; | ||
| } | ||
| await Navigator.of(c).push(MaterialPageRoute<void>( | ||
| builder: (_) => LogWorkout( | ||
| start: start, | ||
| end: end, | ||
| activity: activity, | ||
| importedUuid: uuid, | ||
| title: l?.logWorkoutScoreWithBand ?? 'Score with band heart rate', | ||
| ), | ||
| )); | ||
| } | ||
|
|
||
| /// Pending workouts for the History tab, independent of push preferences. | ||
| Future<List<Suggestion>> activeSuggestions() async { | ||
| try { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -607,6 +607,16 @@ class _WorkoutScreenState extends State<WorkoutScreen> with RevisionReload { | |
| onDelete: w.id.isEmpty | ||
| ? null | ||
| : () => _confirmDeleteWorkout(c, w), | ||
| onScore: w.importedFrom != null && w.id.isNotEmpty | ||
| ? () async { | ||
| await scoreImportedWorkout(c, | ||
| uuid: w.id, | ||
| start: w.start, | ||
| end: w.start.add(w.duration), | ||
| activity: w.activity); | ||
|
abdulsaheel marked this conversation as resolved.
|
||
| if (mounted) reload(); | ||
| } | ||
| : null, | ||
| // A retime is a re-score over the new window, so it is offered | ||
| // only where there is something of ours to re-score: an imported | ||
| // row's times belong to the app that recorded it, and this band | ||
|
|
@@ -965,8 +975,11 @@ class _HistoryRow extends StatelessWidget { | |
| /// Remove this session locally. Null hides the control (no id to delete). | ||
| final VoidCallback? onDelete; | ||
|
|
||
| /// An imported row's tap: score its window from the band (#325). | ||
| final VoidCallback? onScore; | ||
|
|
||
| const _HistoryRow(this.w, | ||
| {this.weightKg, this.onRetime, this.onDelete}); | ||
| {this.weightKg, this.onRetime, this.onDelete, this.onScore}); | ||
|
|
||
| Future<void> _open(BuildContext c) async { | ||
| final nav = Navigator.of(c); | ||
|
|
@@ -982,13 +995,11 @@ class _HistoryRow extends StatelessWidget { | |
| final a = w.activity; | ||
| final stats = _stats(c); | ||
| return Surface( | ||
| // An imported row does not open. The summary screen behind this tap is | ||
| // built to show a session THIS band measured — its rating control, its | ||
| // heart-rate trace, its zone split — and it has nowhere to say whose | ||
| // workout it is. A screen that presents an Apple Watch run exactly like | ||
| // one of ours is the fabrication this whole table exists to avoid, so | ||
| // the row stays a row until that screen can name its source. | ||
| onTap: w.importedFrom == null ? () => _open(c) : null, | ||
| // An imported row does not open the summary: that screen shows a | ||
| // session THIS band measured and has nowhere to say whose workout it is. | ||
| // Its tap offers to score the window from the band's own heart rate | ||
| // instead, which makes it one of ours and replaces the import (#325). | ||
| onTap: w.importedFrom == null ? () => _open(c) : onScore, | ||
| child: Column(children: [ | ||
| Row(children: [ | ||
| Container( | ||
|
|
@@ -1998,9 +2009,11 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { | |
| if (r is! Map) continue; | ||
| final ts = (r['start_ts'] as num?)?.toInt(); | ||
| if (ts == null) continue; | ||
| // An unknown type keeps its own name: a scored import is saved | ||
| // under the store's sport (`surfing`), which the catalogue may lack. | ||
| final a = activityByName(r['type'] as String?) ?? | ||
| const Activity('Workout', LucideIcons.activity, C.purple, | ||
| Track.duration, 5.0); | ||
| Activity(importedWorkoutTitle(r['type']), LucideIcons.activity, | ||
| C.purple, Track.duration, 5.0); | ||
|
Comment on lines
+2015
to
+2016
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not assign a 5.0 MET estimate to an unknown sport. After an imported sport is scored, this fallback can represent its saved session and enter the recent-activity list. The 5.0 MET value then gives that unknown sport a calorie estimate without a known energy cost. Use 🤖 Prompt for AI Agents |
||
| past.add(_PastWorkout( | ||
| (r['id'] as String?) ?? '', | ||
| a, | ||
|
|
@@ -2049,9 +2062,12 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { | |
| // type. The NAME always comes from the store — `activityByName` | ||
| // resolves the ~40 types this app can start, and the fallback would | ||
| // print "Workout" over a surf. | ||
| // Named after the store's type, not "Workout": scoring this row | ||
| // saves `activity.typeKey`, and a generic fallback would replace | ||
| // the import's sport with `workout`. | ||
| activityByName(title) ?? | ||
| const Activity('Workout', LucideIcons.activity, C.purple, | ||
| Track.duration, 5.0), | ||
| Activity(title, LucideIcons.activity, C.purple, Track.duration, | ||
| 5.0), | ||
| at, | ||
| Motion.tick * (endTs - ts), | ||
| // No strain, ever. It is not omitted pending a better idea — there | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.