feat(train): the Loop's training sessions as workouts (ADR 0030, D39)
Each read of the band lists the sessions it holds, reads those from the read's first day on (`LoopDays.kt`, one line per exercise: its start with the band's offset, length, Polar's sport ID, figures and heart rate at its interval), and deletes those up to the day kept, as the days. The app keeps each as a workout of origin Polar Loop with its heart rate (`takeLoopSessions`), Polar's running and cycling as Train's and every other sport as the new `Activity.other`, which a workout's edit sheet now changes; a session overlapping a workout Train recorded gives it its heart rate where it has none instead. The sessions taken are remembered in `loop.json`, so one deleted in Train isn't brought back. Health Connect writes the Loop's workouts as Train's own. The SDK gives sessions as its protobuf messages: protobuf-javalite 3.20.0, the SDK's own, is named for compiling only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
06cc39025b
commit
2c5467a282
18 changed files with 794 additions and 32 deletions
|
|
@ -23,6 +23,7 @@
|
|||
"activityHiking": "Wandern",
|
||||
"activityCycling": "Radfahren",
|
||||
"activityIndoorRowing": "Rudern drinnen",
|
||||
"activityOther": "Anderes Training",
|
||||
"recordAutoPause": "Von selbst pausieren",
|
||||
"recordAutoPauseHint": "Wenn du stehst, und weiter, sobald du dich wieder bewegst",
|
||||
"recordStart": "Start",
|
||||
|
|
@ -60,6 +61,7 @@
|
|||
"workoutEdit": "Bearbeiten",
|
||||
"workoutEditTitle": "Training bearbeiten",
|
||||
"workoutNameLabel": "Name",
|
||||
"workoutActivityLabel": "Aktivität",
|
||||
"workoutNotesLabel": "Notizen",
|
||||
"workoutSave": "Speichern",
|
||||
"workoutDelete": "Löschen",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"activityHiking": "Hiking",
|
||||
"activityCycling": "Cycling",
|
||||
"activityIndoorRowing": "Rowing indoors",
|
||||
"activityOther": "Other workout",
|
||||
"recordAutoPause": "Pause by itself",
|
||||
"@recordAutoPause": {"description": "The switch for auto-pause, as the recording starts."},
|
||||
"recordAutoPauseHint": "When you stand still, and going on as you move again",
|
||||
|
|
@ -75,6 +76,7 @@
|
|||
"workoutEdit": "Edit",
|
||||
"workoutEditTitle": "Edit workout",
|
||||
"workoutNameLabel": "Name",
|
||||
"workoutActivityLabel": "Activity",
|
||||
"workoutNotesLabel": "Notes",
|
||||
"workoutSave": "Save",
|
||||
"workoutDelete": "Delete",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,12 @@ abstract class AppLocalizations {
|
|||
/// **'Rowing indoors'**
|
||||
String get activityIndoorRowing;
|
||||
|
||||
/// No description provided for @activityOther.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Other workout'**
|
||||
String get activityOther;
|
||||
|
||||
/// The switch for auto-pause, as the recording starts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -418,6 +424,12 @@ abstract class AppLocalizations {
|
|||
/// **'Name'**
|
||||
String get workoutNameLabel;
|
||||
|
||||
/// No description provided for @workoutActivityLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Activity'**
|
||||
String get workoutActivityLabel;
|
||||
|
||||
/// No description provided for @workoutNotesLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get activityIndoorRowing => 'Rudern drinnen';
|
||||
|
||||
@override
|
||||
String get activityOther => 'Anderes Training';
|
||||
|
||||
@override
|
||||
String get recordAutoPause => 'Von selbst pausieren';
|
||||
|
||||
|
|
@ -219,6 +222,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get workoutNameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get workoutActivityLabel => 'Aktivität';
|
||||
|
||||
@override
|
||||
String get workoutNotesLabel => 'Notizen';
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get activityIndoorRowing => 'Rowing indoors';
|
||||
|
||||
@override
|
||||
String get activityOther => 'Other workout';
|
||||
|
||||
@override
|
||||
String get recordAutoPause => 'Pause by itself';
|
||||
|
||||
|
|
@ -217,6 +220,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get workoutNameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get workoutActivityLabel => 'Activity';
|
||||
|
||||
@override
|
||||
String get workoutNotesLabel => 'Notes';
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ Future<TrainPerson> _openPersonData(
|
|||
LoopStore(File(p.join(folder.path, 'loop.json'))),
|
||||
profile,
|
||||
days: days,
|
||||
workouts: workouts,
|
||||
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
|
||||
)..start());
|
||||
return TrainPerson(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/model/loop_session.dart';
|
||||
|
||||
/// What reads of the Loop brought (ADR 0030, D37): the lines `LoopDays.kt`
|
||||
/// makes, from the method channel or the background job's file, sorted by
|
||||
|
|
@ -17,15 +18,20 @@ final class LoopLines() {
|
|||
final recharges = <DateTime, Recharge>{};
|
||||
final skins = <DateTime, List<(Duration, double)>>{};
|
||||
|
||||
/// The training sessions the band recorded (D39), by their ID.
|
||||
final sessions = <String, LoopSession>{};
|
||||
|
||||
/// Whether nothing but reads came.
|
||||
bool get isEmpty =>
|
||||
summaries.isEmpty &&
|
||||
hearts.isEmpty &&
|
||||
nights.isEmpty &&
|
||||
recharges.isEmpty &&
|
||||
skins.isEmpty;
|
||||
skins.isEmpty &&
|
||||
sessions.isEmpty;
|
||||
|
||||
/// Every date something came for.
|
||||
/// Every date a day's row is written for: a night and a session have rows
|
||||
/// of their own.
|
||||
Set<DateTime> get dates => {
|
||||
...summaries.keys,
|
||||
...hearts.keys,
|
||||
|
|
@ -91,6 +97,10 @@ final class LoopLines() {
|
|||
baselineBreathInterval: _int(json['baselineBreathInterval']),
|
||||
baselineBreathIntervalSd: _int(json['baselineBreathIntervalSd']),
|
||||
);
|
||||
case 'session':
|
||||
if (_sessionOf(date, json) case final session?) {
|
||||
sessions[session.id] = session;
|
||||
}
|
||||
case 'skin':
|
||||
skins[date] = [
|
||||
for (final sample in _list(json['samples']))
|
||||
|
|
@ -187,6 +197,33 @@ NightContent _nightOf(DateTime date, Map<String, Object?> json) => NightContent(
|
|||
skinDeviation: _double(json['skinDeviation']),
|
||||
);
|
||||
|
||||
LoopSession? _sessionOf(DateTime date, Map<String, Object?> json) {
|
||||
final id = json['id'];
|
||||
final start = _time(json['start']);
|
||||
final seconds = json['seconds'];
|
||||
if (id is! String || start == null || seconds is! num || seconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
final interval = json['interval'];
|
||||
return LoopSession(
|
||||
id: id,
|
||||
date: date,
|
||||
start: start,
|
||||
duration: Duration(milliseconds: (seconds * 1000).round()),
|
||||
sport: _int(json['sport']),
|
||||
calories: _int(json['calories']),
|
||||
metres: _double(json['metres']),
|
||||
ascent: _double(json['ascent']),
|
||||
interval: interval is num && interval > 0
|
||||
? Duration(milliseconds: (interval * 1000).round())
|
||||
: null,
|
||||
heart: [
|
||||
for (final bpm in _list(json['heart']))
|
||||
if (bpm is num && bpm > 0) bpm.round() else null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _time(Object? value) =>
|
||||
value is String ? DateTime.tryParse(value)?.toUtc() : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/day/model/loop_session.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_import.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// Train's activity for Polar's sport [id]: only those the SDK itself names
|
||||
/// (running 1, cycling 2); every other, its default *Other outdoor* (16)
|
||||
/// among them, is [Activity.other] until a band shows its IDs.
|
||||
Activity activityOfSport(int? id) => switch (id) {
|
||||
1 => Activity.running,
|
||||
2 => Activity.cycling,
|
||||
_ => Activity.other,
|
||||
};
|
||||
|
||||
/// [session] as a workout of Train's (ADR 0030, D39): its sport, its time,
|
||||
/// its distance as the band summed it, and its heart rate as its series.
|
||||
(WorkoutContent, List<SeriesChunk>) loopWorkout(LoopSession session) {
|
||||
final content = WorkoutContent(
|
||||
activity: activityOfSport(session.sport),
|
||||
startedAt: session.start,
|
||||
endedAt: session.end,
|
||||
distanceMetres: switch (session.metres) {
|
||||
final metres? when metres > 0 => metres.round(),
|
||||
_ => null,
|
||||
},
|
||||
movingTime: session.duration,
|
||||
origin: WorkoutOrigin(
|
||||
app: loopApp,
|
||||
appName: 'Polar Loop',
|
||||
sessionId: session.id,
|
||||
),
|
||||
);
|
||||
return (content, heartRateSeries(content, loopHeartRates(session)));
|
||||
}
|
||||
|
||||
/// [session]'s heart rate, each sample at its time.
|
||||
List<HeartRate> loopHeartRates(LoopSession session) {
|
||||
final interval = session.interval;
|
||||
if (interval == null) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final (i, bpm) in session.heart.indexed)
|
||||
if (bpm != null)
|
||||
HeartRate(time: session.start.add(interval * i), bpm: bpm),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [session] is a double of [workout], whatever the sports (D39):
|
||||
/// overlapping for at least half the shorter of the two.
|
||||
bool isLoopDouble(LoopSession session, WorkoutContent workout) {
|
||||
final from = session.start.isAfter(workout.startedAt)
|
||||
? session.start
|
||||
: workout.startedAt;
|
||||
final to = session.end.isBefore(workout.endedAt)
|
||||
? session.end
|
||||
: workout.endedAt;
|
||||
final overlap = to.difference(from);
|
||||
final shorter = [
|
||||
session.duration,
|
||||
workout.endedAt.difference(workout.startedAt),
|
||||
].reduce((a, b) => a < b ? a : b);
|
||||
return overlap > Duration.zero && overlap * 2 >= shorter;
|
||||
}
|
||||
|
||||
/// Keeps [sessions] as workouts (D39), but those [taken] before or kept as
|
||||
/// a workout already: a double of a workout Train recorded gives it its
|
||||
/// heart rate where it has none, and is no workout of its own. Returns the
|
||||
/// IDs to remember as taken from now on, those found kept already among
|
||||
/// them; throws `KeysMissing` where this device can't seal.
|
||||
Future<Set<String>> takeLoopSessions(
|
||||
WorkoutRepository workouts,
|
||||
Iterable<LoopSession> sessions, {
|
||||
required Set<String> taken,
|
||||
}) async {
|
||||
final now = <String>{};
|
||||
final fresh = [
|
||||
for (final session in sessions)
|
||||
if (!taken.contains(session.id)) session,
|
||||
];
|
||||
if (fresh.isEmpty) {
|
||||
return now;
|
||||
}
|
||||
final all = await workouts.all();
|
||||
final kept = {
|
||||
for (final workout in all)
|
||||
if (workout.content?.origin case WorkoutOrigin(
|
||||
app: loopApp,
|
||||
:final sessionId,
|
||||
))
|
||||
sessionId,
|
||||
};
|
||||
for (final session in fresh) {
|
||||
now.add(session.id);
|
||||
if (kept.contains(session.id)) {
|
||||
continue;
|
||||
}
|
||||
final double = all
|
||||
.where(
|
||||
(workout) => switch (workout.content) {
|
||||
final content? =>
|
||||
content.origin == null && isLoopDouble(session, content),
|
||||
null => false,
|
||||
},
|
||||
)
|
||||
.firstOrNull;
|
||||
if (double != null) {
|
||||
final series = await workouts.series(double.id);
|
||||
if (!series.any((chunk) => chunk.kind == SeriesKind.heartRate)) {
|
||||
await workouts.addSeries(
|
||||
double.id,
|
||||
heartRateSeries(double.content!, loopHeartRates(session)),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final (content, series) = loopWorkout(session);
|
||||
await workouts.save(content, series);
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A training session the Loop recorded by itself (ADR 0030, D39), one of
|
||||
/// its exercises, as `LoopDays.kt` read it; what the band gave nothing for
|
||||
/// is null.
|
||||
final class const LoopSession({
|
||||
/// The band's reference of the session and the exercise's index.
|
||||
required final String id,
|
||||
|
||||
/// The band's date it started on, as UTC midnight.
|
||||
required final DateTime date,
|
||||
required final DateTime start,
|
||||
required final Duration duration,
|
||||
|
||||
/// Polar's sport ID.
|
||||
final int? sport,
|
||||
final int? calories,
|
||||
final double? metres,
|
||||
final double? ascent,
|
||||
|
||||
/// How far apart its samples are; null where it has none.
|
||||
final Duration? interval,
|
||||
|
||||
/// Its heart rate at [interval], null where the band read none.
|
||||
final List<int?> heart = const [],
|
||||
}) with Equatable {
|
||||
DateTime get end => start.add(duration);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
date,
|
||||
start,
|
||||
duration,
|
||||
sport,
|
||||
calories,
|
||||
metres,
|
||||
ascent,
|
||||
interval,
|
||||
heart,
|
||||
];
|
||||
}
|
||||
|
|
@ -191,13 +191,16 @@ class HealthConnect(
|
|||
}
|
||||
}
|
||||
|
||||
/// Writes each workout of Train's not written as it is now, and deletes
|
||||
/// there those deleted here; one that doesn't open on this device is left
|
||||
/// as it is, one brought in from there is never written back.
|
||||
/// Writes each workout of Train's not written as it is now, the Loop's
|
||||
/// among them (D39), and deletes there those deleted here; one that
|
||||
/// doesn't open on this device is left as it is, one brought in from there
|
||||
/// is never written back.
|
||||
Future<void> _write(List<StoredWorkout> workouts) async {
|
||||
for (final workout in workouts) {
|
||||
final content = workout.content;
|
||||
if (content == null || content.origin != null || !_settings.writes) {
|
||||
if (content == null ||
|
||||
(content.origin?.fromHealthConnect ?? false) ||
|
||||
!_settings.writes) {
|
||||
continue;
|
||||
}
|
||||
final fingerprint = _fingerprintOf(encodeWorkout(content));
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ final class const KnownLoop({
|
|||
|
||||
/// When its days were last read with the app open.
|
||||
final DateTime? lastRead,
|
||||
|
||||
/// The band's training sessions taken in (D39), by ID, each with its
|
||||
/// date, so that one isn't brought again, deleted in Train or not.
|
||||
final Map<String, DateTime> takenSessions = const {},
|
||||
}) with Equatable {
|
||||
KnownLoop copyWith({
|
||||
String? name,
|
||||
|
|
@ -39,6 +43,7 @@ final class const KnownLoop({
|
|||
String? firmware,
|
||||
DateTime? keptUntil,
|
||||
DateTime? lastRead,
|
||||
Map<String, DateTime>? takenSessions,
|
||||
}) => KnownLoop(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
|
|
@ -49,6 +54,7 @@ final class const KnownLoop({
|
|||
firmware: firmware ?? this.firmware,
|
||||
keptUntil: keptUntil ?? this.keptUntil,
|
||||
lastRead: lastRead ?? this.lastRead,
|
||||
takenSessions: takenSessions ?? this.takenSessions,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -62,6 +68,7 @@ final class const KnownLoop({
|
|||
firmware,
|
||||
keptUntil,
|
||||
lastRead,
|
||||
takenSessions,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +100,11 @@ class LoopStore(final File _file) {
|
|||
final String read => DateTime.tryParse(read),
|
||||
_ => null,
|
||||
},
|
||||
takenSessions: {
|
||||
if (json['takenSessions'] case final Map<String, Object?> taken)
|
||||
for (final MapEntry(:key, :value) in taken.entries)
|
||||
key: ?dateOfKey(value),
|
||||
},
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -116,6 +128,11 @@ class LoopStore(final File _file) {
|
|||
null => null,
|
||||
},
|
||||
'lastRead': ?loop.lastRead?.toUtc().toIso8601String(),
|
||||
if (loop.takenSessions.isNotEmpty)
|
||||
'takenSessions': {
|
||||
for (final MapEntry(:key, :value) in loop.takenSessions.entries)
|
||||
key: dayKey(value),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import 'package:equatable/equatable.dart';
|
|||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_workouts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// How long a band chosen has to connect and be ready for its setup.
|
||||
|
|
@ -23,6 +25,10 @@ const _readEvery = Duration(minutes: 30);
|
|||
/// How far back the first read of a band goes.
|
||||
const _firstReadDays = 28;
|
||||
|
||||
/// How long a session taken in is remembered after its day was kept, when
|
||||
/// the band has deleted it long since (D39).
|
||||
const _takenKept = Duration(days: 7);
|
||||
|
||||
/// Where the person's Loop stands.
|
||||
final class const LoopStatus({
|
||||
/// The band remembered; null without one.
|
||||
|
|
@ -53,6 +59,9 @@ class PolarLoop(
|
|||
final ProfileRepository _profile, {
|
||||
required final DayRepository days,
|
||||
|
||||
/// Where the band's training sessions go (D39).
|
||||
required final WorkoutRepository workouts,
|
||||
|
||||
/// `loop_days.txt` in the person's folder, where the background job
|
||||
/// writes what it reads.
|
||||
required final File backgroundLines,
|
||||
|
|
@ -152,6 +161,7 @@ class PolarLoop(
|
|||
firmware: same ? before?.firmware : null,
|
||||
keptUntil: same ? before?.keptUntil : null,
|
||||
lastRead: same ? before?.lastRead : null,
|
||||
takenSessions: same ? before!.takenSessions : const {},
|
||||
),
|
||||
connected: true,
|
||||
),
|
||||
|
|
@ -312,7 +322,8 @@ class PolarLoop(
|
|||
keptUntil: known.keptUntil,
|
||||
)).forEach(lines.add);
|
||||
await days.take(lines);
|
||||
await _kept(lines, readAt: now().toUtc());
|
||||
final taken = await _takeSessions(lines);
|
||||
await _kept(lines, taken: taken, readAt: now().toUtc());
|
||||
}
|
||||
|
||||
/// Keeps what the background job wrote: the file moved aside first, so
|
||||
|
|
@ -340,29 +351,52 @@ class PolarLoop(
|
|||
lines.addText(file.readAsStringSync());
|
||||
}
|
||||
await days.take(lines);
|
||||
final taken = await _takeSessions(lines);
|
||||
for (final file in taking) {
|
||||
file.deleteSync();
|
||||
}
|
||||
await _kept(lines);
|
||||
await _kept(lines, taken: taken);
|
||||
}
|
||||
|
||||
/// Keeps the band's sessions [lines] brought as workouts (D39); the IDs
|
||||
/// to remember as taken.
|
||||
Future<Set<String>> _takeSessions(LoopLines lines) => takeLoopSessions(
|
||||
workouts,
|
||||
lines.sessions.values,
|
||||
taken: {...?_status.loop?.takenSessions.keys},
|
||||
);
|
||||
|
||||
/// Remembers that the days [lines] saw whole are kept, so that the band
|
||||
/// may delete them (D38), and tells the background job.
|
||||
Future<void> _kept(LoopLines lines, {DateTime? readAt}) async {
|
||||
/// may delete them (D38), and the sessions [taken] (D39); tells the
|
||||
/// background job.
|
||||
Future<void> _kept(
|
||||
LoopLines lines, {
|
||||
Set<String> taken = const {},
|
||||
DateTime? readAt,
|
||||
}) async {
|
||||
final known = _status.loop;
|
||||
if (known == null) {
|
||||
return;
|
||||
}
|
||||
final before = known.keptUntil;
|
||||
final complete = lines.completeUntil;
|
||||
final keptUntil =
|
||||
before != null && complete != null && before.isAfter(complete)
|
||||
? before
|
||||
: complete ?? before;
|
||||
final forgetBefore = keptUntil?.subtract(_takenKept);
|
||||
final takenSessions = {
|
||||
for (final MapEntry(:key, :value) in known.takenSessions.entries)
|
||||
if (forgetBefore == null || !value.isBefore(forgetBefore)) key: value,
|
||||
for (final id in taken)
|
||||
if (lines.sessions[id] case final session?) id: session.date,
|
||||
};
|
||||
_update(
|
||||
_with(
|
||||
known.copyWith(
|
||||
keptUntil:
|
||||
before != null && complete != null && before.isAfter(complete)
|
||||
? before
|
||||
: complete,
|
||||
keptUntil: keptUntil,
|
||||
lastRead: readAt,
|
||||
takenSessions: takenSessions,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ class RecordingBloc(
|
|||
Activity.walking ||
|
||||
Activity.hiking => AutoPauseBy.steps,
|
||||
Activity.indoorRowing => AutoPauseBy.strokes,
|
||||
// Not recorded on the phone (D39): only the Loop brings it.
|
||||
Activity.other => null,
|
||||
};
|
||||
final granted = await _platform.ask({
|
||||
// The rower's connection, which also gives Android its service's type.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ String activityName(AppLocalizations l10n, Activity activity) =>
|
|||
Activity.hiking => l10n.activityHiking,
|
||||
Activity.cycling => l10n.activityCycling,
|
||||
Activity.indoorRowing => l10n.activityIndoorRowing,
|
||||
Activity.other => l10n.activityOther,
|
||||
};
|
||||
|
||||
/// [time] as a clock shows it: `32:14`, `1:02:03` from an hour on.
|
||||
|
|
|
|||
|
|
@ -28,13 +28,17 @@ class WorkoutCubit(
|
|||
await _read();
|
||||
}
|
||||
|
||||
/// Names it [name] with [notes] (blank: none).
|
||||
Future<void> editText({String? name, String? notes}) async {
|
||||
/// Names it [name] with [notes] (blank: none), as [activity] where given
|
||||
/// (one the Loop brought as another, D39).
|
||||
Future<void> edit({String? name, String? notes, Activity? activity}) async {
|
||||
final content = state.content;
|
||||
if (content == null) {
|
||||
return;
|
||||
}
|
||||
await _workouts.update(id, content.withText(name: name, notes: notes));
|
||||
await _workouts.update(
|
||||
id,
|
||||
content.withText(name: name, notes: notes, activity: activity),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _read() async {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,27 @@ enum Activity() {
|
|||
hiking,
|
||||
cycling,
|
||||
indoorRowing,
|
||||
|
||||
/// What Train has no activity of its own for: most of what the Loop
|
||||
/// detects by itself (ADR 0030, D39), which the person can name.
|
||||
other,
|
||||
}
|
||||
|
||||
/// The [WorkoutOrigin.app] of a workout the Polar Loop recorded (D39).
|
||||
const loopApp = 'polar-loop';
|
||||
|
||||
/// Where a workout came from, where Train didn't record it (D31): the app
|
||||
/// that wrote it to Health Connect, its package and its name where Android
|
||||
/// let Train see it, and Health Connect's ID of the session.
|
||||
/// let Train see it, and Health Connect's ID of the session; or the Polar
|
||||
/// Loop ([loopApp]) and the band's reference of the session (D39).
|
||||
final class const WorkoutOrigin({
|
||||
required final String app,
|
||||
final String? appName,
|
||||
required final String sessionId,
|
||||
}) with Equatable {
|
||||
/// Whether it came from Health Connect, rather than from the Loop.
|
||||
bool get fromHealthConnect => app != loopApp;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [app, appName, sessionId];
|
||||
}
|
||||
|
|
@ -47,11 +58,11 @@ final class const WorkoutContent({
|
|||
final WorkoutOrigin? origin,
|
||||
}) with Equatable {
|
||||
/// As it is, named [name] with [notes] (each null or blank: none).
|
||||
WorkoutContent withText({String? name, String? notes}) {
|
||||
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
|
||||
String? text(String? value) =>
|
||||
value == null || value.trim().isEmpty ? null : value.trim();
|
||||
return WorkoutContent(
|
||||
activity: activity,
|
||||
activity: activity ?? this.activity,
|
||||
startedAt: startedAt,
|
||||
endedAt: endedAt,
|
||||
name: text(name),
|
||||
|
|
|
|||
|
|
@ -87,12 +87,16 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
|
|||
|
||||
Future<void> _edit(BuildContext context, WorkoutContent content) async {
|
||||
final cubit = context.read<WorkoutCubit>();
|
||||
final text = await showAdaptiveSheet<({String name, String notes})>(
|
||||
context,
|
||||
builder: (context) => _EditSheet(content: content),
|
||||
);
|
||||
if (text != null) {
|
||||
await cubit.editText(name: text.name, notes: text.notes);
|
||||
final edited =
|
||||
await showAdaptiveSheet<
|
||||
({String name, String notes, Activity activity})
|
||||
>(context, builder: (context) => _EditSheet(content: content));
|
||||
if (edited != null) {
|
||||
await cubit.edit(
|
||||
name: edited.name,
|
||||
notes: edited.notes,
|
||||
activity: edited.activity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +179,7 @@ class const _Workout({
|
|||
// Another app's route asks the person's consent for each session
|
||||
// (D31), so it comes only when asked for; a row has none.
|
||||
if (!hasTrack &&
|
||||
origin.fromHealthConnect &&
|
||||
content.activity != Activity.indoorRowing &&
|
||||
PersonScope.of<TrainPerson>(context).health != null)
|
||||
Align(
|
||||
|
|
@ -605,7 +610,7 @@ class const _Figure({required final String value, required final String label})
|
|||
}
|
||||
}
|
||||
|
||||
/// Names the workout and writes its notes.
|
||||
/// Names the workout, writes its notes and says what it was.
|
||||
class const _EditSheet({required final WorkoutContent content})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -615,6 +620,7 @@ class const _EditSheet({required final WorkoutContent content})
|
|||
class _EditSheetState() extends State<_EditSheet> {
|
||||
late final _name = TextEditingController(text: widget.content.name);
|
||||
late final _notes = TextEditingController(text: widget.content.notes);
|
||||
late var _activity = widget.content.activity;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -635,11 +641,31 @@ class _EditSheetState() extends State<_EditSheet> {
|
|||
decoration: InputDecoration(
|
||||
labelText: l10n.workoutNameLabel,
|
||||
// The name it has while it has none of its own.
|
||||
hintText: workoutTitle(l10n, widget.content.withText()),
|
||||
hintText: workoutTitle(
|
||||
l10n,
|
||||
widget.content.withText(activity: _activity),
|
||||
),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<Activity>(
|
||||
initialValue: _activity,
|
||||
decoration: InputDecoration(labelText: l10n.workoutActivityLabel),
|
||||
items: [
|
||||
for (final activity in Activity.values)
|
||||
DropdownMenuItem(
|
||||
value: activity,
|
||||
child: Text(activityName(l10n, activity)),
|
||||
),
|
||||
],
|
||||
onChanged: (activity) {
|
||||
if (activity != null) {
|
||||
setState(() => _activity = activity);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _notes,
|
||||
decoration: InputDecoration(labelText: l10n.workoutNotesLabel),
|
||||
|
|
@ -652,7 +678,7 @@ class _EditSheetState() extends State<_EditSheet> {
|
|||
footer: FilledButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop((name: _name.text, notes: _notes.text)),
|
||||
).pop((name: _name.text, notes: _notes.text, activity: _activity)),
|
||||
child: Text(l10n.workoutSave),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ IconData activityIcon(Activity activity) => switch (activity) {
|
|||
Activity.hiking => LucideIcons.mountain,
|
||||
Activity.cycling => LucideIcons.bike,
|
||||
Activity.indoorRowing => LucideIcons.waves,
|
||||
Activity.other => LucideIcons.heartPulse,
|
||||
};
|
||||
|
||||
/// [metres] with their unit: `5.12 km`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue