feat(train): the settings' explanations behind a question mark (ADR 0030, D63)

What a switch or a section explains, rather than what is the person's
own, is one tap away: `HelpTitle`, a tile's title with halkyon_ui's
`HelpButton` beside it, in place of the explaining subtitles of Moving
more's reminders and suggestions, the music's playlists and smaller
copies, and the Loop's detection, firmware check and firmware through
the instance. Health Connect's short hints stay (they name what each
switch does, and its "taken back" state takes their place), as do its
rationale, the Loop's warnings before an update or a reset and the
settings' one-line hints.

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 22:11:57 +01:00
commit b7db078c8e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 123 additions and 19 deletions

View file

@ -0,0 +1,19 @@
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A tile's [title] with its explanation, [help], behind halkyon_ui's
/// [HelpButton] beside it rather than under it (ADR 0030, D63): what
/// explains is one tap away, what is the person's own stays in sight.
class const HelpTitle(
final String title, {
required final String help,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
Flexible(child: Text(title)),
HelpButton(help, title: title),
],
);
}

View file

@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart'; 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/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/core/widget/help_title.dart';
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_cubit.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/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_platform.dart';
@ -207,8 +208,7 @@ class const LoopScreen({super.key}) extends StatelessWidget {
return [ return [
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.activity), secondary: const Icon(LucideIcons.activity),
title: Text(l10n.loopDetection), title: HelpTitle(l10n.loopDetection, help: l10n.loopDetectionHint),
subtitle: Text(l10n.loopDetectionHint),
value: on, value: on,
onChanged: usable onChanged: usable
? (on) => unawaited( ? (on) => unawaited(
@ -303,9 +303,11 @@ class const LoopScreen({super.key}) extends StatelessWidget {
), ),
ListTile( ListTile(
leading: const Icon(LucideIcons.search), leading: const Icon(LucideIcons.search),
title: Text(l10n.loopFirmwareCheck), title: HelpTitle(
subtitle: Text( l10n.loopFirmwareCheck,
through ? l10n.loopFirmwareViaInstance : l10n.loopFirmwareViaPolar, help: through
? l10n.loopFirmwareViaInstance
: l10n.loopFirmwareViaPolar,
), ),
onTap: state.busy || stage != null || !state.status.connected onTap: state.busy || stage != null || !state.status.connected
? null ? null
@ -314,8 +316,10 @@ class const LoopScreen({super.key}) extends StatelessWidget {
if (state.status.relayOffered || through) if (state.status.relayOffered || through)
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.server), secondary: const Icon(LucideIcons.server),
title: Text(l10n.loopFirmwareThroughInstance), title: HelpTitle(
subtitle: Text(l10n.loopFirmwareThroughInstanceHint), l10n.loopFirmwareThroughInstance,
help: l10n.loopFirmwareThroughInstanceHint,
),
value: through, value: through,
onChanged: stage != null onChanged: stage != null
? null ? null

View file

@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart'; 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/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/core/widget/help_title.dart';
import 'package:halkyon_train_flutter/src/feature/movement/bloc/movement_cubit.dart'; import 'package:halkyon_train_flutter/src/feature/movement/bloc/movement_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/movement/model/movement_settings.dart'; import 'package:halkyon_train_flutter/src/feature/movement/model/movement_settings.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -98,8 +99,10 @@ class const MovementScreen({super.key}) extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SwitchListTile( SwitchListTile(
title: Text(l10n.movementRemind), title: HelpTitle(
subtitle: Text(l10n.movementRemindHint), l10n.movementRemind,
help: l10n.movementRemindHint,
),
value: settings.remind, value: settings.remind,
onChanged: usable onChanged: usable
? (on) => change(settings.copyWith(remind: on)) ? (on) => change(settings.copyWith(remind: on))
@ -194,8 +197,10 @@ class const MovementScreen({super.key}) extends StatelessWidget {
SectionCard( SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: SwitchListTile( child: SwitchListTile(
title: Text(l10n.movementSuggest), title: HelpTitle(
subtitle: Text(l10n.movementSuggestHint), l10n.movementSuggest,
help: l10n.movementSuggestHint,
),
value: settings.suggest, value: settings.suggest,
onChanged: usable onChanged: usable
? (on) => change(settings.copyWith(suggest: on)) ? (on) => change(settings.copyWith(suggest: on))

View file

@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart'; 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/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/core/widget/help_title.dart';
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart'; import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
@ -262,11 +263,13 @@ class const _Playlists({
), ),
const Divider(), const Divider(),
ListTile( ListTile(
title: Text( title: DefaultTextStyle.merge(
l10n.musicPlaylists,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
child: HelpTitle(
l10n.musicPlaylists,
help: l10n.musicPlaylistsHint,
),
), ),
subtitle: Text(l10n.musicPlaylistsHint),
), ),
if (failure != null) if (failure != null)
ListTile( ListTile(
@ -287,8 +290,7 @@ class const _Playlists({
_Kept(kept: kept), _Kept(kept: kept),
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.fileAudio), secondary: const Icon(LucideIcons.fileAudio),
title: Text(l10n.musicSmaller), title: HelpTitle(l10n.musicSmaller, help: l10n.musicSmallerHint),
subtitle: Text(l10n.musicSmallerHint),
value: settings.smaller, value: settings.smaller,
onChanged: (on) => cubit().keepSmaller(on: on), onChanged: (on) => cubit().keepSmaller(on: on),
), ),

View file

@ -963,6 +963,25 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
testWidgets("the switches' explanations wait behind their question marks "
'(D63)', (tester) async {
await openMovement(tester);
const hint =
'When the phone counted hardly a step for a while; never while '
'recording or in a car.';
expect(find.text(hint), findsNothing);
await tester.tap(
find.descendant(
of: find.widgetWithText(SwitchListTile, 'Remind me to move'),
matching: find.byType(HelpButton),
),
);
await tester.pumpAndSettle();
expect(find.text(hint), findsOneWidget);
expect(platform.configured, [null], reason: 'nothing switched');
});
testWidgets('reminders turned on ask for the permissions, and the job ' testWidgets('reminders turned on ask for the permissions, and the job '
'gets the settings and the words, each change again', (tester) async { 'gets the settings and the words, each change again', (tester) async {
await openMovement(tester); await openMovement(tester);

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/core/widget/help_title.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets("a setting's title keeps its explanation behind the question "
'mark, and a tap on the title still reaches the tile', (tester) async {
var on = false;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => SwitchListTile(
title: const HelpTitle(
'Smaller copies',
help: 'Lossless files kept as Opus.',
),
value: on,
onChanged: (value) => setState(() => on = value),
),
),
),
),
);
expect(find.text('Lossless files kept as Opus.'), findsNothing);
await tester.tap(find.text('Smaller copies'));
await tester.pump();
expect(on, isTrue);
await tester.tap(find.byType(HelpButton));
await tester.pumpAndSettle();
expect(find.text('Lossless files kept as Opus.'), findsOneWidget);
expect(
find.descendant(
of: find.byType(AlertDialog),
matching: find.text('Smaller copies'),
),
findsOneWidget,
);
expect(on, isTrue, reason: 'the question mark leaves the switch alone');
});
}

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