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:
parent
3f7b409e80
commit
e4585b478d
1 changed files with 1882 additions and 30 deletions
|
|
@ -27,6 +27,7 @@ import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/movement/data/moving_more.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
|
|
@ -69,6 +70,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
import '../support/fake_firmware_relay.dart';
|
||||
import '../support/fake_health_platform.dart';
|
||||
import '../support/fake_loop_platform.dart';
|
||||
import '../support/fake_movement_platform.dart';
|
||||
import '../support/fake_place_search_gateway.dart';
|
||||
import '../support/fake_recorder_platform.dart';
|
||||
import '../support/fake_route_files.dart';
|
||||
|
|
@ -122,6 +124,7 @@ void main() {
|
|||
RoutingGateway? routing,
|
||||
PlaceSearchGateway? placeSearch,
|
||||
WeatherGateway? weather,
|
||||
MovingMore? movement,
|
||||
OfflineMaps? offlineMaps,
|
||||
RouteFiles? routeFiles,
|
||||
}) async {
|
||||
|
|
@ -265,6 +268,7 @@ void main() {
|
|||
placeSearch:
|
||||
placeSearch ?? FakePlaceSearchGateway(isOffered: false),
|
||||
weather: weather ?? FakeWeatherGateway(),
|
||||
movement: movement,
|
||||
recording: recording,
|
||||
health: health,
|
||||
loop: loop,
|
||||
|
|
@ -897,6 +901,138 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('moving more (ADR 0037)', () {
|
||||
late Directory folder;
|
||||
late FakeRecorderPlatform recorder;
|
||||
late FakeMovementPlatform platform;
|
||||
late MovingMore movement;
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('movement');
|
||||
recorder = FakeRecorderPlatform();
|
||||
platform = FakeMovementPlatform();
|
||||
// In memory: the file's own tests are MovingMore's.
|
||||
movement = MovingMore(platform, recorder);
|
||||
});
|
||||
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
Future<void> startWith(WidgetTester tester) => start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
recording: (
|
||||
platform: recorder,
|
||||
file: File('${folder.path}/recording.txt'),
|
||||
devices: {
|
||||
for (final kind in DeviceKind.values)
|
||||
kind: DeviceStore(File('${folder.path}/${kind.name}.json')),
|
||||
},
|
||||
announces: AnnounceStore(File('${folder.path}/announce.json')),
|
||||
),
|
||||
movement: movement,
|
||||
);
|
||||
|
||||
Future<void> openMovement(WidgetTester tester) async {
|
||||
await startWith(tester);
|
||||
await openSettings(tester);
|
||||
await tester.tap(find.text('Moving more'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('reminders turned on ask for the permissions, and the job '
|
||||
'gets the settings and the words, each change again', (tester) async {
|
||||
await openMovement(tester);
|
||||
expect(platform.configured, [null], reason: 'off as the app opened');
|
||||
|
||||
await tester.tap(find.text('Remind me to move'));
|
||||
await settle(tester);
|
||||
expect(recorder.asked.last, {
|
||||
RecorderPermission.activity,
|
||||
RecorderPermission.notifications,
|
||||
});
|
||||
final on = platform.configured.last!;
|
||||
expect((on['settings']! as Map)['remind'], isTrue);
|
||||
expect((on['texts']! as Map)['remindTitle'], 'Time to move');
|
||||
expect(
|
||||
(on['texts']! as Map)['remindText'],
|
||||
'30 minutes with hardly a step. Up for a short walk?',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('30 min still'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('45 min still').last);
|
||||
await settle(tester);
|
||||
expect(movement.read().idleMinutes, 45);
|
||||
expect(
|
||||
(platform.configured.last!['texts']! as Map)['remindText'],
|
||||
'45 minutes with hardly a step. Up for a short walk?',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Sat'));
|
||||
await settle(tester);
|
||||
expect(movement.read().days, {1, 2, 3, 4, 5, 6});
|
||||
});
|
||||
|
||||
testWidgets('refused the permissions, it stays off and says why', (
|
||||
tester,
|
||||
) async {
|
||||
recorder.allows = {};
|
||||
await openMovement(tester);
|
||||
await tester.tap(find.text('Suggest workouts'));
|
||||
await settle(tester);
|
||||
expect(movement.read().suggest, isFalse);
|
||||
expect(
|
||||
find.text(
|
||||
'Train needs the physical activity and notification permissions '
|
||||
'for this.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("on a phone that doesn't count steps, neither can be turned "
|
||||
'on', (tester) async {
|
||||
platform.counts = false;
|
||||
await openMovement(tester);
|
||||
expect(
|
||||
find.text(
|
||||
"This phone doesn't count steps, so it can neither remind nor "
|
||||
'suggest.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Remind me to move'),
|
||||
)
|
||||
.onChanged,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a suggested walk tapped opens the Record sheet for walking', (
|
||||
tester,
|
||||
) async {
|
||||
await startWith(tester);
|
||||
platform.tap(Activity.walking);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SheetScaffold), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.text('Walking'),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('your body (ADR 0030, D33)', () {
|
||||
late TrainKeys keys;
|
||||
late ProfileRepository profile;
|
||||
|
|
|
|||
Loading…
Reference in a new issue