feat(train): where sync stands in Settings, with Sync now, and the cog's dot

As Learn's and Todo's: halkyon_app's Sync card first in Settings, and the
dot on the cog while sync fails; the person's SyncService is now part of
TrainPerson, which was the only reason Train had neither (the developer's
review of 3 Nov 2026).

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-03 21:31:45 +01:00
commit adbb2c4f2f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 29 additions and 6 deletions

View file

@ -106,6 +106,12 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
),
// Where sync stands: Settings' card and the dot on the cog, as
// Learn's and Todo's.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of<TrainPerson>(context).sync),
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);

View file

@ -75,6 +75,10 @@ class TrainPerson(
/// Whose they are.
final SignedIn person, {
/// What sends their changes and brings their other devices' (D14):
/// Settings' Sync card and the cog's dot follow it.
required final SyncService sync,
/// Their workouts, sealed and synced.
required final WorkoutRepository workouts,
@ -496,6 +500,7 @@ Future<TrainPerson> _openPersonData(
: AppLifecycleListener(onResume: () => unawaited(keptMusic.sync()));
return TrainPerson(
person,
sync: sync,
workouts: workouts,
profile: profile,
days: days,

View file

@ -20,13 +20,16 @@ class const SettingsButton({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => IconButton(
tooltip: AppLocalizations.of(context).settingsTitle,
icon: const WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
// The dot of a failing sync, which the Sync card explains, as Learn's.
icon: const SyncTroubleBadge(
child: WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
),
onPressed: () => context.push(TrainPaths.settings),
);
}
/// Settings, over the tabs (ADR 0030, D60), grouped in cards as Learn's:
/// the operator's card for whoever operates the instance; the training's
/// where sync stands, with Sync now; the operator's card for whoever operates the instance; the training's
/// screens (the body's figures, Health Connect, the Polar Loop, music,
/// those the device has); the account, near the foot (halkyon_app's
/// sections: whose it is on which instance, switching, signing out and
@ -63,6 +66,10 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Where sync stands, first, as Learn's and Todo's: the
// cog's dot leads here.
const SyncStatusCard(),
const SizedBox(height: 12),
// Accounts waiting for approval, for whoever operates
// the instance alone (Halkyon Learn's ADR 0021, D34),
// first, as the cog's count leads here.

View file

@ -191,6 +191,10 @@ void main() {
opened.add(person);
return TrainPerson(
person,
sync: SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
),
workouts:
workouts ??
WorkoutRepository(
@ -488,14 +492,15 @@ void main() {
}
});
testWidgets('Settings groups the training first, then the account, About '
'Halkyon Train last and the version small at the very foot', (
tester,
) async {
testWidgets('Settings groups where sync stands first, with Sync now, then '
'the training, then the account, About Halkyon Train last and the '
'version small at the very foot', (tester) async {
await start(tester, usedBefore: true);
await openSettings(tester);
expect(find.text('Sync now'), findsOneWidget);
final order = [
'Sync',
'Your body',
'Account',
'Sign out',