Merge branch 'train-days' into integration

# Conflicts:
#	apps/train/halkyon_train_flutter/lib/src/app/train_paths.dart
#	docs/adr/0030-halkyon-train.md
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 23:14:02 +01:00
commit 81adeb91cc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 25705 additions and 410 deletions

View file

@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/night_chart.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets("a phase starting at the night's end or after it paints "
'nothing out of its row rather than throwing mid-paint, which left '
'the rows and the charts below drawn out of place (3 Nov 2026)', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: 320,
child: NightChart(
phases: [
(Duration.zero, SleepState.light),
(Duration(hours: 2), SleepState.rem),
(Duration(hours: 3), SleepState.deep),
// Polar's last phase, at the night's very end, and one past
// it.
(Duration(hours: 4), SleepState.wake),
(Duration(hours: 5), SleepState.rem),
],
length: Duration(hours: 4),
),
),
),
),
);
expect(tester.takeException(), isNull);
});
}

View file

@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
void main() {
test("Polar's marks for no value (-1, -100, 0) are no value, the "
"person's own figures kept", () {
const fromBand = Recharge(
ansStatus: -100,
recovery: -1,
recoverySubLevel: -1,
rri: 1000,
rmssd: 69,
breathInterval: 4380,
baselineRri: 0,
baselineRmssd: -1,
);
expect(
fromBand.known,
const Recharge(rri: 1000, rmssd: 69, breathInterval: 4380),
);
});
test('figures in their ranges stay as they are', () {
const fromBand = Recharge(
ansStatus: -2.5,
recovery: 4,
recoverySubLevel: 2,
rmssd: 48,
baselineRmssd: 45,
);
expect(fromBand.known, fromBand);
});
}

View file

@ -0,0 +1,177 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import '../../support/fake_health_platform.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_weight_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// Other apps' weights brought in from Health Connect (ADR 0030, D64): off
/// until the person turns it on, then each weight once, as a weight of
/// Train's own store; one the person entered in Train already is a double
/// and stays out, as does one deleted in Train.
void main() {
final morning = DateTime.utc(2026, 11, 3, 6, 30);
const scale = 'com.example.scale';
late Directory folder;
late TrainKeys keys;
late WorkoutRepository workouts;
late WeightRepository weights;
late FakeHealthPlatform platform;
final opened = <HealthConnect>[];
HealthWeight measured(String id, double kilograms, {int days = 0}) =>
HealthWeight(
id: id,
at: morning.add(Duration(days: days)),
kilograms: kilograms,
app: scale,
);
HealthConnect open() {
final health = HealthConnect(
platform,
HealthStore(File('${folder.path}/health_connect.json')),
workouts,
weights: weights,
)..start();
opened.add(health);
return health;
}
Future<void> settled(HealthConnect health) async {
await pumpEventQueue();
await health.idle;
}
Future<List<WeightContent>> kept() async => [
for (final weight in await weights.all()) weight.content!,
];
setUp(() {
folder = Directory.systemTemp.createTempSync('health');
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
weights = WeightRepository(MemoryWeightGateway(), keys);
platform = FakeHealthPlatform();
});
tearDown(() async {
for (final health in opened) {
await health.dispose();
}
opened.clear();
await workouts.dispose();
await weights.dispose();
keys.dispose();
folder.deleteSync(recursive: true);
});
test('nothing is read until the person turns it on, which asks Health '
'Connect for the weights alone', () async {
platform.otherWeights.add(measured('a', 74.3));
final health = open();
await settled(health);
expect(await weights.all(), isEmpty);
expect(platform.asked, isEmpty);
expect(await health.setReadingWeights(on: true), isTrue);
await settled(health);
expect(platform.asked, [
{HealthAccess.weight},
]);
expect(await kept(), [
WeightContent(
at: morning,
kilograms: 74.3,
source: WeightSource.healthConnect,
healthId: 'a',
app: scale,
),
]);
});
test('each weight comes in once, read again or not', () async {
platform.otherWeights.add(measured('a', 74.3));
final health = open();
await health.setReadingWeights(on: true);
await settled(health);
platform.otherWeights.add(measured('b', 74.1, days: 1));
await health.read();
await settled(health);
await health.read();
await settled(health);
expect([for (final weight in await kept()) weight.healthId], ['a', 'b']);
});
test('one the person entered in Train already, within ten minutes and a '
'tenth of a kilogram, stays out', () async {
await weights.save(
WeightContent(
at: morning.add(const Duration(minutes: 4)),
kilograms: 74.35,
),
);
platform.otherWeights
..add(measured('double', 74.3))
..add(measured('other', 74.9, days: 1));
final health = open();
await health.setReadingWeights(on: true);
await settled(health);
expect(
[for (final weight in await kept()) weight.healthId],
[null, 'other'],
);
});
test('one deleted in Train stays out', () async {
platform.otherWeights.add(measured('a', 74.3));
final health = open();
await health.setReadingWeights(on: true);
await settled(health);
await weights.delete((await weights.all()).single.id);
await health.read();
await settled(health);
expect(await weights.all(), isEmpty);
});
test('refused, it stays off and says so; turned off, nothing more '
'comes', () async {
platform = FakeHealthPlatform(grants: {});
platform.otherWeights.add(measured('a', 74.3));
final health = open();
expect(await health.setReadingWeights(on: true), isFalse);
expect(health.settings.readsWeights, isFalse);
expect(health.settings.weightsRefused, isTrue);
expect(await weights.all(), isEmpty);
});
test("the switch and what came in are kept with the phone's settings", () {
final store = HealthStore(File('${folder.path}/health_connect.json'));
const settings = HealthSettings(
readsWeights: true,
weightsRefused: true,
takenWeights: {'a', 'b'},
);
store.save(settings);
expect(store.read(), settings);
});
}

View file

@ -8,6 +8,7 @@ import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dar
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_jellyfin.dart';
@ -34,7 +35,10 @@ void main() {
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
HalkyonUiLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
@ -112,7 +116,10 @@ void main() {
addTearDown(kept.dispose);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
HalkyonUiLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
@ -217,7 +224,10 @@ void main() {
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
HalkyonUiLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(

View file

@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_payload.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_weight_gateway.dart';
/// The person's weights, sealed on the way to the instance and opened on
/// the way back (ADR 0030, D7, D64), with libsodium.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemoryWeightGateway rows;
final opened = <TrainKeys>[];
final monday = WeightContent(
at: DateTime.utc(2026, 11, 2, 7),
kilograms: 74.3,
);
final tuesday = WeightContent(
at: DateTime.utc(2026, 11, 3, 7),
kilograms: 74.05,
source: WeightSource.healthConnect,
healthId: 'hc-1',
app: 'com.example.scale',
);
Future<WeightRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return WeightRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryWeightGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('weights entered on one device read on another of the account, the '
'earliest first, and are deleted there', () async {
final phone = await device(alice);
final second = await phone.save(tuesday);
final first = await phone.save(monday);
final laptop = await device(alice);
expect(await laptop.all(), [
StoredWeight(first, monday),
StoredWeight(second, tuesday),
]);
await laptop.delete(first);
expect(await phone.all(), [StoredWeight(second, tuesday)]);
});
test('nothing the instance gets is readable: no weight, no JSON', () async {
await (await device(alice)).save(tuesday);
final sealed = rows.rows.values.single;
expect(sealed.keyId, isNotNull);
final text = utf8.decode(
Uint8List.sublistView(sealed.payload),
allowMalformed: true,
);
expect(text, isNot(contains('74050')));
expect(text, isNot(contains('scale')));
});
test("another person's weight, a readable one, or one moved to another "
'row shows locked, never as theirs', () async {
final id = await (await device(alice)).save(monday);
rows.rows
..['moved'] = rows.rows[id]!
..['readable'] = (
keyId: null,
nonce: null,
payload: encodeWeight(monday),
);
expect(
(await (await device(bob)).all()).where((w) => w.content != null),
isEmpty,
);
expect(
{for (final w in await (await device(alice)).all()) w.id: w.content},
{id: monday, 'moved': null, 'readable': null},
);
});
test('a device without the keys reads nothing and saves nothing', () async {
await (await device(alice)).save(monday);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.save(monday), throwsA(isA<KeysMissing>()));
});
group('the payload', () {
test('keeps the weight to the gram, its time in UTC, and where it came '
'from', () {
expect(decodeWeight(encodeWeight(tuesday)), tuesday);
expect(decodeWeight(encodeWeight(monday)), monday);
final local = WeightContent(
at: DateTime(2026, 11, 3, 8),
kilograms: 80.0004,
);
expect(
decodeWeight(encodeWeight(local)),
WeightContent(at: local.at.toUtc(), kilograms: 80),
);
});
test('one of a newer schema, or none at all, reads as nothing', () {
ByteData of(String json) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(json)));
expect(
decodeWeight(
of('{"schemaVersion":2,"at":"2026-11-03T07:00:00Z","grams":1}'),
),
isNull,
);
expect(decodeWeight(of('{"schemaVersion":1}')), isNull);
expect(
decodeWeight(
of('{"schemaVersion":1,"at":"2026-11-03T07:00:00Z","grams":0}'),
),
isNull,
);
});
});
}

View file

@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_profile_gateway.dart';
import '../../support/memory_weight_gateway.dart';
void main() {
late TrainKeys keys;
late WeightRepository weights;
late ProfileRepository profile;
late WeightsCubit cubit;
setUp(() async {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
weights = WeightRepository(MemoryWeightGateway(), keys);
profile = ProfileRepository(MemoryProfileGateway(), keys, person: 'alice');
await profile.save(const ProfileContent(heightCm: 180, weightKg: 76));
cubit = WeightsCubit(weights, profile);
await cubit.open();
});
tearDown(() async {
await cubit.close();
await weights.dispose();
await profile.dispose();
keys.dispose();
});
test('a weight entered shows, the earliest first, and the latest sets '
"Your body's weight, the other figures kept", () async {
final monday = DateTime.utc(2026, 11, 2, 7);
await cubit.add(75.2, monday.add(const Duration(days: 1)));
await pumpEventQueue();
expect(
await profile.read(),
const ProfileContent(heightCm: 180, weightKg: 75.2),
);
// An earlier one, entered late, changes nothing of the profile.
await cubit.add(75.8, monday);
await pumpEventQueue();
expect(
[for (final weight in cubit.state.weights) weight.content!.kilograms],
[75.8, 75.2],
);
expect(cubit.state.latest?.kilograms, 75.2);
expect((await profile.read())?.weightKg, 75.2);
expect(cubit.state.weights.first.content!.source, WeightSource.own);
});
test('a weight deleted goes', () async {
await cubit.add(75.2, DateTime.utc(2026, 11, 3, 7));
await pumpEventQueue();
await cubit.delete(cubit.state.weights.single.id);
await pumpEventQueue();
expect(cubit.state.weights, isEmpty);
expect(cubit.state.loaded, isTrue);
});
}

View file

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// A heart rate from 60 at second 0 to 120 at second 100, in steps of 10
/// a second.
Future<void> pumpChart(WidgetTester tester) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: WorkoutChart(
title: 'Heart rate',
points: [
for (var second = 0; second <= 100; second += 10)
(x: second.toDouble(), value: 60 + second * 0.6),
],
label: (value) => '${value.round()} bpm',
xLabel: (x) => 'at ${x.round()} s',
),
),
),
),
);
testWidgets('held, the value and its moment under the finger show beside '
'the title, following the finger, and go when it lifts', (
tester,
) async {
await pumpChart(tester);
final plot = find.byKey(WorkoutChart.plotKey);
final box = tester.getRect(plot);
expect(find.text('at 0 s'), findsNothing);
// Held at the plot's start: the first point.
final gesture = await tester.startGesture(
box.centerLeft + const Offset(1, 0),
);
await tester.pump(const Duration(milliseconds: 600));
expect(find.text('60 bpm · at 0 s'), findsOneWidget);
// Dragged to the middle: second 50, 90 bpm.
await gesture.moveTo(box.center);
await tester.pump();
expect(find.text('90 bpm · at 50 s'), findsOneWidget);
expect(find.text('60 bpm · at 0 s'), findsNothing);
await gesture.up();
await tester.pump();
expect(find.textContaining(' · at '), findsNothing);
});
testWidgets('a tap shows nothing: only a long press inspects', (
tester,
) async {
await pumpChart(tester);
await tester.tap(find.byKey(WorkoutChart.plotKey));
await tester.pump();
expect(find.textContaining(' · at '), findsNothing);
});
}