feat(train): the Loop's own detection of training, switched on its screen (ADR 0037)

The band's screen shows whether the Loop detects training by itself and
switches it, with the shortest session it keeps (10, 15, 20 or 30
minutes), so a session nobody recorded still comes in afterwards as a
workout (ADR 0030, D39). Read on each connection and after the band's
setup through the SDK's getUserDeviceSettings, set with
setAutomaticTrainingDetectionSettings; its sensitivity stays as the band
has it, as the SDK documents no range for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 14:49:48 +01:00
commit e673aa33aa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 375 additions and 5 deletions

File diff suppressed because one or more lines are too long

View file

@ -127,6 +127,30 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
}
"setClock" -> result.success(api.setLocalTime(id, LocalDateTime.now()).let { null })
"reset" -> result.success(api.doFactoryReset(id).let { null })
// The band's own detection of training (ADR 0037, D2).
"trainingDetection" -> {
val settings = api.getUserDeviceSettings(id)
result.success(
mapOf(
"on" to settings.automaticTrainingDetectionMode,
"minimumSeconds" to settings.minimumTrainingDurationSeconds,
"sensitivity" to settings.automaticTrainingDetectionSensitivity,
),
)
}
"setTrainingDetection" -> {
// Its sensitivity as the band has it: the SDK documents no range.
val sensitivity =
api.getUserDeviceSettings(id).automaticTrainingDetectionSensitivity
?: throw IllegalStateException("no sensitivity")
api.setAutomaticTrainingDetectionSettings(
id,
call.argument<Boolean>("on")!!,
sensitivity,
call.argument<Int>("minimumSeconds")!!,
)
result.success(null)
}
"readDays" ->
result.success(
LoopDays.read(

View file

@ -741,5 +741,9 @@
"movementRemindText": "{minutes} Minuten kaum ein Schritt. Lust auf einen kurzen Spaziergang?",
"movementWalkingTitle": "Unterwegs zu Fuß?",
"movementRunningTitle": "Beim Laufen?",
"movementSuggestText": "Tippen, um den Rest aufzuzeichnen."
"movementSuggestText": "Tippen, um den Rest aufzuzeichnen.",
"loopDetection": "Training selbst erkennen",
"loopDetectionHint": "Das Band bemerkt ein Training, das du nicht aufgezeichnet hast, und es kommt als Training herein.",
"loopDetectionMinimum": "Behält Trainings ab",
"loopDetectionMinutes": "{minutes} Min."
}

View file

@ -935,5 +935,10 @@
"@movementRemindText": {"placeholders": {"minutes": {"type": "int"}}},
"movementWalkingTitle": "Out for a walk?",
"movementRunningTitle": "Out for a run?",
"movementSuggestText": "Tap to record the rest of it."
"movementSuggestText": "Tap to record the rest of it.",
"loopDetection": "Detect training by itself",
"loopDetectionHint": "The band notices a session you didn't record, and it comes in as a workout.",
"loopDetectionMinimum": "Keeps sessions from",
"loopDetectionMinutes": "{minutes} min",
"@loopDetectionMinutes": {"placeholders": {"minutes": {"type": "int"}}}
}

View file

@ -4461,6 +4461,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Tap to record the rest of it.'**
String get movementSuggestText;
/// No description provided for @loopDetection.
///
/// In en, this message translates to:
/// **'Detect training by itself'**
String get loopDetection;
/// No description provided for @loopDetectionHint.
///
/// In en, this message translates to:
/// **'The band notices a session you didn\'t record, and it comes in as a workout.'**
String get loopDetectionHint;
/// No description provided for @loopDetectionMinimum.
///
/// In en, this message translates to:
/// **'Keeps sessions from'**
String get loopDetectionMinimum;
/// No description provided for @loopDetectionMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min'**
String loopDetectionMinutes(int minutes);
}
class _AppLocalizationsDelegate

View file

@ -2896,4 +2896,19 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get movementSuggestText => 'Tippen, um den Rest aufzuzeichnen.';
@override
String get loopDetection => 'Training selbst erkennen';
@override
String get loopDetectionHint =>
'Das Band bemerkt ein Training, das du nicht aufgezeichnet hast, und es kommt als Training herein.';
@override
String get loopDetectionMinimum => 'Behält Trainings ab';
@override
String loopDetectionMinutes(int minutes) {
return '$minutes Min.';
}
}

View file

@ -2883,6 +2883,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get movementSuggestText => 'Tap to record the rest of it.';
@override
String get loopDetection => 'Detect training by itself';
@override
String get loopDetectionHint =>
'The band notices a session you didn\'t record, and it comes in as a workout.';
@override
String get loopDetectionMinimum => 'Keeps sessions from';
@override
String loopDetectionMinutes(int minutes) {
return '$minutes min';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -15,6 +15,9 @@ enum LoopOutcome() {
firmwareCurrent,
firmwareFound,
updated,
/// Its own detection of training set (ADR 0037, D2).
detectionSet,
failed,
}
@ -49,6 +52,14 @@ class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
void setFirmwareVia(FirmwareVia via) => _loop.setFirmwareVia(via);
Future<void> setTrainingDetection({
required bool on,
required int minimumSeconds,
}) => _do(
() => _loop.setTrainingDetection(on: on, minimumSeconds: minimumSeconds),
LoopOutcome.detectionSet,
);
Future<void> _do(Future<void> Function() action, LoopOutcome done) =>
_ask(() async {
await action();

View file

@ -54,6 +54,34 @@ class AndroidLoopPlatform(final RecorderPlatform _recorder)
@override
Future<void> reset(String id) => _call('reset', {'id': id});
@override
Future<LoopTrainingDetection> trainingDetection(String id) async {
try {
final settings = await _channel.invokeMapMethod<String, Object?>(
'trainingDetection',
{'id': id},
);
return LoopTrainingDetection(
on: settings?['on'] as bool?,
minimumSeconds: settings?['minimumSeconds'] as int?,
sensitivity: settings?['sensitivity'] as int?,
);
} on PlatformException catch (error) {
throw LoopFailed(error.message);
}
}
@override
Future<void> setTrainingDetection(
String id, {
required bool on,
required int minimumSeconds,
}) => _call('setTrainingDetection', {
'id': id,
'on': on,
'minimumSeconds': minimumSeconds,
});
@override
Future<List<Object?>> readDays(
String id, {

View file

@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -38,6 +39,22 @@ final class LoopFirmwareStage(super.id, final String stage) extends LoopEvent;
/// band, its firmware's version and its hardware code.
typedef LoopFirmwareInfo = ({String uuid, String version, String hardwareCode});
/// The band's own detection of training (ADR 0037, D2): whether it is on,
/// the shortest session it keeps, and its sensitivity, which Train leaves as
/// the band has it (the SDK documents no range); each null where the band
/// didn't say.
final class const LoopTrainingDetection({
final bool? on,
final int? minimumSeconds,
final int? sensitivity,
}) with Equatable {
/// The shortest sessions offered, in minutes.
static const minimumChoices = [10, 15, 20, 30];
@override
List<Object?> get props => [on, minimumSeconds, sensitivity];
}
/// The Loop through Polar's SDK on this phone (D34); each call throws a
/// [LoopFailed] where the band refused or wasn't there.
abstract interface class LoopPlatform() {
@ -86,6 +103,17 @@ abstract interface class LoopPlatform() {
/// [LoopFirmwareStage]s. Throws [LoopFailed] where it failed.
Future<void> updateFirmware(String id, {String? file});
/// The band's detection of training as it is set (ADR 0037, D2).
Future<LoopTrainingDetection> trainingDetection(String id);
/// Switches the band's detection of training [on] or off, keeping
/// sessions of [minimumSeconds] or longer, its sensitivity as it was.
Future<void> setTrainingDetection(
String id, {
required bool on,
required int minimumSeconds,
});
/// Has the band read every few hours while the app is closed, into
/// `loop_days.txt` in the person's folder, with what the app kept; or no longer,
/// where [config] is null.

View file

@ -51,6 +51,10 @@ final class const LoopStatus({
/// How far an update of its firmware is (a [LoopFirmwareStage]'s), or
/// `reading` before it; null while none runs.
final String? firmwareStage,
/// The band's own detection of training as it said last (ADR 0037, D2);
/// null until a connection read it.
final LoopTrainingDetection? detection,
}) with Equatable {
LoopStatus copyWith({
KnownLoop? loop,
@ -58,12 +62,14 @@ final class const LoopStatus({
bool? profileChanged,
bool? relayOffered,
String? Function()? firmwareStage,
LoopTrainingDetection? Function()? detection,
}) => LoopStatus(
loop: loop ?? this.loop,
connected: connected ?? this.connected,
profileChanged: profileChanged ?? this.profileChanged,
relayOffered: relayOffered ?? this.relayOffered,
firmwareStage: firmwareStage == null ? this.firmwareStage : firmwareStage(),
detection: detection == null ? this.detection : detection(),
);
@override
@ -73,6 +79,7 @@ final class const LoopStatus({
profileChanged,
relayOffered,
firmwareStage,
detection,
];
}
@ -225,8 +232,10 @@ class PolarLoop(
);
await _comparing.run();
await _schedule();
// A band just set up is asked for its firmware as a connection is.
// A band just set up is asked for its firmware and its detection of
// training as a connection is.
unawaited(_checking.run());
unawaited(_readDetection(id));
}
/// Sends the profile as it is now to the band (D35); throws [LoopFailed]
@ -246,6 +255,41 @@ class PolarLoop(
await _comparing.run();
}
/// Switches the band's own detection of training [on] or off, keeping
/// sessions of [minimumSeconds] or longer (ADR 0037, D2); throws
/// [LoopFailed] where it isn't ready or refused.
Future<void> setTrainingDetection({
required bool on,
required int minimumSeconds,
}) async {
final known = _status.loop;
if (known == null) {
return;
}
if (!_ready.contains(known.id) || _updating) {
throw const LoopFailed('not connected');
}
await platform.setTrainingDetection(
known.id,
on: on,
minimumSeconds: minimumSeconds,
);
await _readDetection(known.id);
}
/// Reads the band's detection of training into the status; left as it was
/// where the band doesn't say.
Future<void> _readDetection(String id) async {
try {
final detection = await platform.trainingDetection(id);
if (_status.loop?.id == id) {
_update(_status.copyWith(detection: () => detection));
}
} on LoopFailed catch (error, st) {
_failed('asked for its training detection', error, st);
}
}
/// Where the band's firmware comes from from now on (D10).
void setFirmwareVia(FirmwareVia via) {
if (_status.loop case final known?) {
@ -434,6 +478,7 @@ class PolarLoop(
unawaited(_clocking.run());
unawaited(_reading.run());
unawaited(_checking.run());
unawaited(_readDetection(id));
}
case LoopDisconnected():
_update(

View file

@ -6,6 +6,7 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_setup_cubit.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/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
@ -50,7 +51,10 @@ class const LoopScreen({super.key}) extends StatelessWidget {
LoopOutcome.firmwareCurrent => l10n.loopFirmwareCurrent,
LoopOutcome.updated => l10n.loopFirmwareUpdated,
LoopOutcome.failed => l10n.loopFailed,
LoopOutcome.letGo || LoopOutcome.firmwareFound || null => null,
LoopOutcome.letGo ||
LoopOutcome.firmwareFound ||
LoopOutcome.detectionSet ||
null => null,
};
if (text != null) {
// In place of the setup's, which would hold this one back.
@ -127,6 +131,10 @@ class const LoopScreen({super.key}) extends StatelessWidget {
title: Text(l10n.loopFirmware(version)),
),
..._firmware(context, l10n, state, known),
if (state.status.detection
case LoopTrainingDetection(:final bool on) &&
final detection)
..._detection(context, l10n, state, on, detection),
if (state.status.profileChanged)
Card(
margin: const EdgeInsets.all(16),
@ -181,6 +189,62 @@ class const LoopScreen({super.key}) extends StatelessWidget {
);
}
/// The band's own detection of training (ADR 0037, D2): on or off, and
/// the shortest session it keeps; its sensitivity as the band has it.
List<Widget> _detection(
BuildContext context,
AppLocalizations l10n,
LoopState state,
bool on,
LoopTrainingDetection detection,
) {
final usable = !state.busy && state.status.connected;
final minimum = detection.minimumSeconds ?? 600;
final choices = {
for (final minutes in LoopTrainingDetection.minimumChoices) minutes * 60,
minimum,
}.toList()..sort();
return [
SwitchListTile(
secondary: const Icon(LucideIcons.activity),
title: Text(l10n.loopDetection),
subtitle: Text(l10n.loopDetectionHint),
value: on,
onChanged: usable
? (on) => unawaited(
context.read<LoopCubit>().setTrainingDetection(
on: on,
minimumSeconds: minimum,
),
)
: null,
),
if (on)
ListTile(
leading: const SizedBox(width: 24),
title: Text(l10n.loopDetectionMinimum),
trailing: DropdownButton<int>(
value: minimum,
items: [
for (final seconds in choices)
DropdownMenuItem(
value: seconds,
child: Text(l10n.loopDetectionMinutes(seconds ~/ 60)),
),
],
onChanged: usable
? (seconds) => unawaited(
context.read<LoopCubit>().setTrainingDetection(
on: true,
minimumSeconds: seconds ?? minimum,
),
)
: null,
),
),
];
}
/// The firmware's part (D40): an update running, or one offered with
/// what Polar learns; asking for one; where it comes from, where the
/// instance relays it or was chosen.

View file

@ -1309,6 +1309,36 @@ void main() {
expect(find.text('Connected'), findsOneWidget);
});
testWidgets("the band's own detection of training is switched on and "
'its shortest session chosen on its screen (ADR 0037, D2)', (
tester,
) async {
await tester.runAsync(() => profile.save(body));
await openLoop(tester);
await chooseBand(tester);
final detect = find.widgetWithText(
SwitchListTile,
'Detect training by itself',
);
await tester.scrollUntilVisible(
detect,
200,
scrollable: find.byType(Scrollable).last,
);
expect(tester.widget<SwitchListTile>(detect).value, isFalse);
await tester.tap(detect);
await settle(tester);
expect(platform.detectionSet, [(on: true, minimumSeconds: 600)]);
expect(tester.widget<SwitchListTile>(detect).value, isTrue);
await tester.tap(find.text('10 min'));
await frames(tester);
await tester.tap(find.text('20 min').last);
await settle(tester);
expect(platform.detectionSet.last, (on: true, minimumSeconds: 1200));
});
testWidgets('what the profile lacks is asked alone and needed; then '
'kept, and the band set up', (tester) async {
await tester.runAsync(

View file

@ -259,6 +259,49 @@ void main() {
expect(loop.status.loop, isNotNull);
});
test("each connection reads the band's own detection of training "
'(ADR 0037, D2), which is set when the person says, its sensitivity '
'as it was', () async {
final loop = open();
await loop.setUp(band, loopFiguresOf(body, today)!);
await settled(loop);
expect(
loop.status.detection,
const LoopTrainingDetection(
on: false,
minimumSeconds: 600,
sensitivity: 1,
),
);
await loop.setTrainingDetection(on: true, minimumSeconds: 900);
expect(platform.detectionSet, [(on: true, minimumSeconds: 900)]);
expect(
loop.status.detection,
const LoopTrainingDetection(
on: true,
minimumSeconds: 900,
sensitivity: 1,
),
);
});
test(
'a band out of reach has its detection of training left alone',
() async {
final loop = open();
await loop.setUp(band, loopFiguresOf(body, today)!);
platform.goAway('A1');
await settled(loop);
await expectLater(
loop.setTrainingDetection(on: true, minimumSeconds: 600),
throwsA(isA<LoopFailed>()),
);
expect(platform.detectionSet, isEmpty);
},
);
test('closed with the person, the band is disconnected', () async {
final loop = open();
await loop.setUp(band, loopFiguresOf(body, today)!);

View file

@ -44,6 +44,15 @@ class FakeLoopPlatform({
/// Whether a band comes back after its firmware was written.
bool comesBack = true;
/// The band's detection of training (ADR 0037), and each setting of it
/// (its asking kept out of [calls], whose order other tests hold).
LoopTrainingDetection detection = const LoopTrainingDetection(
on: false,
minimumSeconds: 600,
sensitivity: 1,
);
final detectionSet = <({bool on, int minimumSeconds})>[];
/// The figures each band was set up with.
final setUpWith = <String, LoopFigures>{};
@ -110,6 +119,31 @@ class FakeLoopPlatform({
setUpWith[id] = figures;
}
@override
Future<LoopTrainingDetection> trainingDetection(String id) async {
if (!inReach.contains(id)) {
throw const LoopFailed('away');
}
return detection;
}
@override
Future<void> setTrainingDetection(
String id, {
required bool on,
required int minimumSeconds,
}) async {
if (!inReach.contains(id)) {
throw const LoopFailed('away');
}
detectionSet.add((on: on, minimumSeconds: minimumSeconds));
detection = LoopTrainingDetection(
on: on,
minimumSeconds: minimumSeconds,
sensitivity: detection.sensitivity,
);
}
@override
Future<void> setClock(String id) async {
calls.add('setClock $id');