feat(train): reminders to move and workouts suggested from the steps (ADR 0037)

Settings' Moving more, on Android: a reminder to get up after an idle
time, and a suggested walk or run, each off until turned on (then every
30 minutes, weekdays 9:00 to 18:00, not 12:00 to 13:00, each value the
person's), turning on asking for the activity and notification
permissions.

A WorkManager job every 15 minutes reads the phone's step counter and
keeps two hours of readings. It reminds where fewer than 50 steps came
over the idle time, all of it within the hours and outside the break,
on the person's days, never while Train records or the phone is in a
car (Android's car mode, or a car's Bluetooth audio or hands-free). It
suggests a walk or a run where the last span averaged a walking or
running cadence, once an hour; tapped, the Record sheet for it opens.
The decisions are pure Kotlin (Movement.kt), tested on the JVM; the
notifications' words are the app's, written into the job's
configuration as the person's app opens.

Polar's SDK sets neither the band's inactivity alert nor tells of one
without an all-day connection, hence the phone's steps (ADR 0037).

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:32:02 +01:00
commit e4585b478d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1882 additions and 30 deletions

View file

@ -720,5 +720,26 @@
"weatherDirection": "{direction, select, n{Nord} ne{Nordost} e{Ost} se{Südost} s{Süd} sw{Südwest} w{West} nw{Nordwest} other{Wind}}",
"weatherPrecipitation": "{amount} mm Niederschlag",
"weatherDry": "Trocken",
"weatherBy": "Wetterdaten von Open-Meteo.com"
"weatherBy": "Wetterdaten von Open-Meteo.com",
"movementTitle": "Mehr bewegen",
"movementHint": "Erinnerungen aufzustehen und Trainings, wenn du dich bewegst",
"movementRemind": "Ans Bewegen erinnern",
"movementRemindHint": "Wenn das Handy eine Weile kaum einen Schritt gezählt hat; nie beim Aufzeichnen oder im Auto.",
"movementIdle": "Nach",
"movementIdleMinutes": "{minutes} Min. ohne Bewegung",
"movementHours": "Uhrzeit",
"movementBreak": "Pause",
"movementBreakHint": "Darin keine Erinnerungen.",
"movementDays": "Tage",
"movementSuggest": "Trainings vorschlagen",
"movementSuggestHint": "Wenn deine Schritte nach Gehen oder Laufen aussehen und nichts aufzeichnet: tippen, um den Rest aufzuzeichnen. Die eigene Erkennung deines Loop ist auf seiner Seite.",
"movementUnavailable": "Dieses Handy zählt keine Schritte, kann also weder erinnern noch vorschlagen.",
"movementRefused": "Train braucht dafür die Berechtigungen für körperliche Aktivität und Benachrichtigungen.",
"movementLate": "Die Schritte werden alle 15 Minuten gezählt, eine Erinnerung kann also etwas später kommen.",
"movementChannel": "Mehr bewegen",
"movementRemindTitle": "Zeit für Bewegung",
"movementRemindText": "{minutes} Minuten kaum ein Schritt. Lust auf einen kurzen Spaziergang?",
"movementWalkingTitle": "Unterwegs zu Fuß?",
"movementRunningTitle": "Beim Laufen?",
"movementSuggestText": "Tippen, um den Rest aufzuzeichnen."
}

View file

@ -912,5 +912,28 @@
"weatherPrecipitation": "{amount} mm fell",
"@weatherPrecipitation": {"placeholders": {"amount": {"type": "String"}}},
"weatherDry": "Nothing fell",
"weatherBy": "Weather data by Open-Meteo.com"
"weatherBy": "Weather data by Open-Meteo.com",
"movementTitle": "Moving more",
"movementHint": "Reminders to get up, and workouts suggested as you move",
"movementRemind": "Remind me to move",
"movementRemindHint": "When the phone counted hardly a step for a while; never while recording or in a car.",
"movementIdle": "After",
"movementIdleMinutes": "{minutes} min still",
"@movementIdleMinutes": {"placeholders": {"minutes": {"type": "int"}}},
"movementHours": "Hours",
"movementBreak": "Break",
"movementBreakHint": "No reminders over it.",
"movementDays": "Days",
"movementSuggest": "Suggest workouts",
"movementSuggestHint": "When your steps look like a walk or a run and nothing records: tap to record the rest of it. Your Loop's own detection is on its screen.",
"movementUnavailable": "This phone doesn't count steps, so it can neither remind nor suggest.",
"movementRefused": "Train needs the physical activity and notification permissions for this.",
"movementLate": "The steps are counted every 15 minutes, so a reminder can come a little late.",
"movementChannel": "Moving more",
"movementRemindTitle": "Time to move",
"movementRemindText": "{minutes} minutes with hardly a step. Up for a short walk?",
"@movementRemindText": {"placeholders": {"minutes": {"type": "int"}}},
"movementWalkingTitle": "Out for a walk?",
"movementRunningTitle": "Out for a run?",
"movementSuggestText": "Tap to record the rest of it."
}

View file

@ -4335,6 +4335,132 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Weather data by Open-Meteo.com'**
String get weatherBy;
/// No description provided for @movementTitle.
///
/// In en, this message translates to:
/// **'Moving more'**
String get movementTitle;
/// No description provided for @movementHint.
///
/// In en, this message translates to:
/// **'Reminders to get up, and workouts suggested as you move'**
String get movementHint;
/// No description provided for @movementRemind.
///
/// In en, this message translates to:
/// **'Remind me to move'**
String get movementRemind;
/// No description provided for @movementRemindHint.
///
/// In en, this message translates to:
/// **'When the phone counted hardly a step for a while; never while recording or in a car.'**
String get movementRemindHint;
/// No description provided for @movementIdle.
///
/// In en, this message translates to:
/// **'After'**
String get movementIdle;
/// No description provided for @movementIdleMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min still'**
String movementIdleMinutes(int minutes);
/// No description provided for @movementHours.
///
/// In en, this message translates to:
/// **'Hours'**
String get movementHours;
/// No description provided for @movementBreak.
///
/// In en, this message translates to:
/// **'Break'**
String get movementBreak;
/// No description provided for @movementBreakHint.
///
/// In en, this message translates to:
/// **'No reminders over it.'**
String get movementBreakHint;
/// No description provided for @movementDays.
///
/// In en, this message translates to:
/// **'Days'**
String get movementDays;
/// No description provided for @movementSuggest.
///
/// In en, this message translates to:
/// **'Suggest workouts'**
String get movementSuggest;
/// No description provided for @movementSuggestHint.
///
/// In en, this message translates to:
/// **'When your steps look like a walk or a run and nothing records: tap to record the rest of it. Your Loop\'s own detection is on its screen.'**
String get movementSuggestHint;
/// No description provided for @movementUnavailable.
///
/// In en, this message translates to:
/// **'This phone doesn\'t count steps, so it can neither remind nor suggest.'**
String get movementUnavailable;
/// No description provided for @movementRefused.
///
/// In en, this message translates to:
/// **'Train needs the physical activity and notification permissions for this.'**
String get movementRefused;
/// No description provided for @movementLate.
///
/// In en, this message translates to:
/// **'The steps are counted every 15 minutes, so a reminder can come a little late.'**
String get movementLate;
/// No description provided for @movementChannel.
///
/// In en, this message translates to:
/// **'Moving more'**
String get movementChannel;
/// No description provided for @movementRemindTitle.
///
/// In en, this message translates to:
/// **'Time to move'**
String get movementRemindTitle;
/// No description provided for @movementRemindText.
///
/// In en, this message translates to:
/// **'{minutes} minutes with hardly a step. Up for a short walk?'**
String movementRemindText(int minutes);
/// No description provided for @movementWalkingTitle.
///
/// In en, this message translates to:
/// **'Out for a walk?'**
String get movementWalkingTitle;
/// No description provided for @movementRunningTitle.
///
/// In en, this message translates to:
/// **'Out for a run?'**
String get movementRunningTitle;
/// No description provided for @movementSuggestText.
///
/// In en, this message translates to:
/// **'Tap to record the rest of it.'**
String get movementSuggestText;
}
class _AppLocalizationsDelegate

View file

@ -2823,4 +2823,77 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get weatherBy => 'Wetterdaten von Open-Meteo.com';
@override
String get movementTitle => 'Mehr bewegen';
@override
String get movementHint =>
'Erinnerungen aufzustehen und Trainings, wenn du dich bewegst';
@override
String get movementRemind => 'Ans Bewegen erinnern';
@override
String get movementRemindHint =>
'Wenn das Handy eine Weile kaum einen Schritt gezählt hat; nie beim Aufzeichnen oder im Auto.';
@override
String get movementIdle => 'Nach';
@override
String movementIdleMinutes(int minutes) {
return '$minutes Min. ohne Bewegung';
}
@override
String get movementHours => 'Uhrzeit';
@override
String get movementBreak => 'Pause';
@override
String get movementBreakHint => 'Darin keine Erinnerungen.';
@override
String get movementDays => 'Tage';
@override
String get movementSuggest => 'Trainings vorschlagen';
@override
String get movementSuggestHint =>
'Wenn deine Schritte nach Gehen oder Laufen aussehen und nichts aufzeichnet: tippen, um den Rest aufzuzeichnen. Die eigene Erkennung deines Loop ist auf seiner Seite.';
@override
String get movementUnavailable =>
'Dieses Handy zählt keine Schritte, kann also weder erinnern noch vorschlagen.';
@override
String get movementRefused =>
'Train braucht dafür die Berechtigungen für körperliche Aktivität und Benachrichtigungen.';
@override
String get movementLate =>
'Die Schritte werden alle 15 Minuten gezählt, eine Erinnerung kann also etwas später kommen.';
@override
String get movementChannel => 'Mehr bewegen';
@override
String get movementRemindTitle => 'Zeit für Bewegung';
@override
String movementRemindText(int minutes) {
return '$minutes Minuten kaum ein Schritt. Lust auf einen kurzen Spaziergang?';
}
@override
String get movementWalkingTitle => 'Unterwegs zu Fuß?';
@override
String get movementRunningTitle => 'Beim Laufen?';
@override
String get movementSuggestText => 'Tippen, um den Rest aufzuzeichnen.';
}

View file

@ -2810,6 +2810,79 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get weatherBy => 'Weather data by Open-Meteo.com';
@override
String get movementTitle => 'Moving more';
@override
String get movementHint =>
'Reminders to get up, and workouts suggested as you move';
@override
String get movementRemind => 'Remind me to move';
@override
String get movementRemindHint =>
'When the phone counted hardly a step for a while; never while recording or in a car.';
@override
String get movementIdle => 'After';
@override
String movementIdleMinutes(int minutes) {
return '$minutes min still';
}
@override
String get movementHours => 'Hours';
@override
String get movementBreak => 'Break';
@override
String get movementBreakHint => 'No reminders over it.';
@override
String get movementDays => 'Days';
@override
String get movementSuggest => 'Suggest workouts';
@override
String get movementSuggestHint =>
'When your steps look like a walk or a run and nothing records: tap to record the rest of it. Your Loop\'s own detection is on its screen.';
@override
String get movementUnavailable =>
'This phone doesn\'t count steps, so it can neither remind nor suggest.';
@override
String get movementRefused =>
'Train needs the physical activity and notification permissions for this.';
@override
String get movementLate =>
'The steps are counted every 15 minutes, so a reminder can come a little late.';
@override
String get movementChannel => 'Moving more';
@override
String get movementRemindTitle => 'Time to move';
@override
String movementRemindText(int minutes) {
return '$minutes minutes with hardly a step. Up for a short walk?';
}
@override
String get movementWalkingTitle => 'Out for a walk?';
@override
String get movementRunningTitle => 'Out for a run?';
@override
String get movementSuggestText => 'Tap to record the rest of it.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).