feat(learn,app): explanations wait behind a question mark beside their title

What explains a setting or a section, rather than what is the person's
own, moves behind halkyon_ui's HelpButton, beside its title through the
new ExplainedTitle (the developer's review of 3 Nov 2026): in Learn's
settings (citations, the Pomodoro timer, grammar, the web search,
Jellyfin, the device's dictionaries, counting one's learning), the study
settings, export's progress, the Jellyfin tags and the study button's
back first; in halkyon_app, setting up a password, adding SimpleLogin,
own keys not allowed, the profile page's link back and the operator's
grant sheet.

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:24:39 +01:00
commit 830aea10fe
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 279 additions and 115 deletions

View file

@ -455,7 +455,11 @@ class _GrantSheetState() extends State<_GrantSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (grantee == null) ...[
Text(l10n.assistantPeopleTitle, style: heading),
ExplainedTitle(
l10n.assistantPeopleTitle,
help: l10n.assistantGrantNote,
style: heading,
),
const SizedBox(height: 8),
if (state.accounts.isNotEmpty) ...[
Wrap(
@ -505,7 +509,14 @@ class _GrantSheetState() extends State<_GrantSheet> {
),
],
const SizedBox(height: 16),
Text(l10n.assistantGrantBudget, style: heading),
ExplainedTitle(
l10n.assistantGrantBudget,
help: [
l10n.assistantGrantLimitHelper,
if (priced) l10n.assistantGrantMoneyNote,
].join(' '),
style: heading,
),
const SizedBox(height: 8),
if (priced)
_LimitRow(
@ -520,13 +531,6 @@ class _GrantSheetState() extends State<_GrantSheet> {
suffix: null,
enabled: !state.busy,
),
Text(
[
l10n.assistantGrantLimitHelper,
if (priced) l10n.assistantGrantMoneyNote,
].join(' '),
style: muted,
),
if (grantee == null && state.replaced.isNotEmpty) ...[
const SizedBox(height: 16),
_Replaced(state.replaced, currency: setUp.currency),
@ -538,10 +542,6 @@ class _GrantSheetState() extends State<_GrantSheet> {
style: TextStyle(color: theme.colorScheme.error),
),
],
if (grantee == null) ...[
const SizedBox(height: 16),
Text(l10n.assistantGrantNote, style: muted),
],
],
),
footer: FilledButton(

View file

@ -72,7 +72,7 @@ class const _AssistantsView({
return const SizedBox.shrink();
}
final offer = state.offer;
final muted = theme.textTheme.bodyMedium?.copyWith(
final subtitle = theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding(
@ -141,21 +141,16 @@ class const _AssistantsView({
if (ownKept && (offer.ownKeys || state.own.isNotEmpty)) ...[
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.assistantOwnTitle,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
// Why own keys go unasked, where they are not allowed,
// behind its question mark (decided 3 Nov 2026).
child: offer.ownKeys
? Text(l10n.assistantOwnTitle, style: subtitle)
: ExplainedTitle(
l10n.assistantOwnTitle,
help: l10n.assistantOwnNotAllowed(instance),
style: subtitle,
),
),
if (!offer.ownKeys)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.assistantOwnNotAllowed(instance),
style: muted,
),
),
for (final own in state.own)
ListTile(
contentPadding: EdgeInsets.zero,

View file

@ -79,8 +79,10 @@ class const _AccountSecurity() extends StatelessWidget {
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.keyRound),
title: Text(l10n.passwordSectionSetUp),
subtitle: Text(l10n.passwordSectionSetUpHint),
title: ExplainedTitle(
l10n.passwordSectionSetUp,
help: l10n.passwordSectionSetUpHint,
),
onTap: () => _setUp(context, security),
),
] else if (!security.finished)

View file

@ -731,9 +731,6 @@ class const _ProfilePage({required final String url}) extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
@ -751,6 +748,9 @@ class const _ProfilePage({required final String url}) extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// What linking back does, behind its question mark (decided
// 3 Nov 2026).
HelpButton(l10n.publicProfileVerifyHint),
IconButton(
tooltip: l10n.publicProfilePageCopy,
icon: const Icon(LucideIcons.copy, size: 18),
@ -766,7 +766,6 @@ class const _ProfilePage({required final String url}) extends StatelessWidget {
),
],
),
Text(l10n.publicProfileVerifyHint, style: muted),
],
),
);

View file

@ -7,6 +7,7 @@ import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
import 'package:halkyon_app/src/simplelogin/widget/keys_reset.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_sheet.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_standing_scope.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -64,8 +65,10 @@ class const _SimpleLoginSection({required final double gapAbove})
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.link),
title: Text(l10n.simpleLoginAddTitle),
subtitle: Text(l10n.simpleLoginAddHint),
title: ExplainedTitle(
l10n.simpleLoginAddTitle,
help: l10n.simpleLoginAddHint,
),
onTap: () => addSimpleLogin(context),
),
],

View file

@ -144,6 +144,37 @@ void main() {
await show(tester);
await tap(tester, find.text('Grant assistants'));
expect(tester.widget<FilledButton>(saveButton()).onPressed, isNull);
// What a grant does and how its budget counts, behind the question
// marks beside People and Budget (decided 3 Nov 2026).
final note = find.textContaining('They agree once');
final counts = find.textContaining('Empty for none.');
expect(note, findsNothing);
expect(counts, findsNothing);
await tap(
tester,
find.descendant(
of: find.widgetWithText(ExplainedTitle, 'People'),
matching: find.byType(HelpButton),
),
);
expect(note, findsOneWidget);
await tap(tester, find.text('OK'));
await tester.ensureVisible(
find.widgetWithText(ExplainedTitle, 'Budget, for all of them together'),
);
await tester.pumpAndSettle();
await tap(
tester,
find.descendant(
of: find.widgetWithText(
ExplainedTitle,
'Budget, for all of them together',
),
matching: find.byType(HelpButton),
),
);
expect(counts, findsOneWidget);
await tap(tester, find.text('OK'));
await addPerson(tester, 'anna');
await addPerson(tester, 'ben');

View file

@ -406,14 +406,16 @@ void main() {
),
);
await show(tester);
expect(
find.text(
const why =
"Whoever runs test.example.org doesn't allow own keys here, so the "
"app doesn't ask these.",
),
findsOneWidget,
);
"app doesn't ask these.";
expect(find.text(why), findsNothing, reason: 'behind its question mark');
await tester.tap(find.byType(HelpButton));
await tester.pumpAndSettle();
expect(find.text(why), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
expect(find.text('Add an assistant with your own key'), findsNothing);
});

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's app as a test makes one: whose it is.
@ -592,6 +593,20 @@ void main() {
expect(find.text('Set up a password'), findsOneWidget);
expect(find.text('Change password'), findsNothing);
// What it is, behind its question mark (decided 3 Nov 2026).
const what =
'Sign in with your email address, a password and an '
'authenticator app.';
expect(find.text(what), findsNothing);
await tester.tap(
find.descendant(
of: find.widgetWithText(ExplainedTitle, 'Set up a password'),
matching: find.byType(HelpButton),
),
);
await tester.pumpAndSettle();
expect(find.text(what), findsOneWidget);
});
testWidgets('offers none to one without an address: a code mailed to '

View file

@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
const password = 'correct horse battery';
@ -46,10 +47,11 @@ void main() {
expect(find.text('SimpleLogin'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Add SimpleLogin'), findsOneWidget);
expect(
find.text('Sign in with SimpleLogin, or with your Proton account.'),
findsOneWidget,
);
const hint = 'Sign in with SimpleLogin, or with your Proton account.';
expect(find.text(hint), findsNothing, reason: 'behind its question mark');
await tester.tap(find.byType(HelpButton));
await tester.pumpAndSettle();
expect(find.text(hint), findsOneWidget);
});
testWidgets('nothing where the instance offers no SimpleLogin', (

View file

@ -22,6 +22,7 @@ export 'src/widget/confirm_dialog.dart';
export 'src/widget/count_badge.dart';
export 'src/widget/count_stepper.dart';
export 'src/widget/empty_list_message.dart';
export 'src/widget/explained_title.dart';
export 'src/widget/field_label.dart';
export 'src/widget/floating_navigation_bar.dart';
export 'src/widget/folding_tabs.dart';

View file

@ -0,0 +1,22 @@
import 'package:halkyon_ui/src/widget/help_button.dart';
import 'package:material_ui/material_ui.dart';
/// A section's or a setting's [title] with its [HelpButton] right beside
/// it, opening [help] under the title (decided 3 Nov 2026: what explains
/// is one tap away). In [style] where given, the surrounding text's
/// otherwise, as a [ListTile]'s title; a long title wraps, the question
/// mark kept beside its end.
class const ExplainedTitle(
final String title, {
required final String help,
final TextStyle? style,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
Flexible(child: Text(title, style: style)),
HelpButton(help, title: title),
],
);
}

View file

@ -0,0 +1,52 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
testWidgets('a title with its question mark right beside it, the '
'explanation opening under the title', (tester) async {
await tester.pumpWidget(
testApp(
const Scaffold(
body: SizedBox(
width: 300,
child: ListTile(
title: ExplainedTitle(
'Check spelling',
help: 'Underlines possible mistakes.',
),
),
),
),
),
);
expect(find.text('Underlines possible mistakes.'), findsNothing);
final title = tester.getRect(find.text('Check spelling'));
final help = tester.getRect(find.byType(HelpButton));
expect(help.left - title.right, lessThan(8), reason: 'beside it');
await tester.tap(find.byType(HelpButton));
await tester.pumpAndSettle();
expect(find.text('Check spelling'), findsNWidgets(2));
expect(find.text('Underlines possible mistakes.'), findsOneWidget);
});
testWidgets('a long title wraps, its question mark kept', (
tester,
) async {
await tester.pumpWidget(
testApp(
Scaffold(
body: SizedBox(
width: 200,
child: ExplainedTitle('A title ' * 20, help: 'Why.'),
),
),
),
);
expect(tester.takeException(), isNull);
expect(find.byType(HelpButton), findsOneWidget);
});
}