fix(learn): Settings no longer jumps while scrolled up slowly

Settings built its cards as they scrolled into view and let go of those
far above. Scrolled back up, a card built anew asked again for what it
shows and grew once answered: Japanese on this device stays hidden until
the instance says what it offers devices, and Jellyfin's form fills in
the server the instance suggests and looks it up. Whatever was on the
screen was pushed down by as much, and near the top the list put right
where it had thought the first cards were.

Settings is short, so it is built whole now (`SingleChildScrollView`):
nothing is let go of on the way, text typed into Jellyfin's form stays
when scrolled away, and the scrollbar knows the list's length.

The new test scrolls a phone's Settings up from the bottom, 20 pixels at
a time, and holds every card to its place in the list; before, a card
moved 64 pixels. The citation test now scrolls to the dropdown it taps,
not to the guide's link below it, which only left the dropdown on the
screen while the cards beneath weren't built yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 13:19:53 +01:00
commit c38dc69018
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 106 additions and 47 deletions

View file

@ -40,56 +40,64 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
constraints: const BoxConstraints( constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth, maxWidth: readableContentMaxWidth,
), ),
child: ListView( // Built whole, not as it scrolls into view (decided
// 2026-10-25): a card built anew on the way back up asks again
// for what it shows (the dictionaries the instance offers, the
// Jellyfin server it suggests) and grows once answered, which
// pushed everything on the screen down. Settings is short.
child: SingleChildScrollView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
children: [ child: Column(
const SyncStatusCard(), crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 12), children: [
const DeviceSettingsCard(), const SyncStatusCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const CitationSettingsCard(), const DeviceSettingsCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const _StudyCard(), const CitationSettingsCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const AssistantSettingsCard(), const _StudyCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const JellyfinSettingsCard(), const AssistantSettingsCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const DeviceDictionarySettingsCard(), const JellyfinSettingsCard(),
const _PrivacyCard(), const SizedBox(height: 12),
const SizedBox(height: 12), const DeviceDictionarySettingsCard(),
SectionCard( const _PrivacyCard(),
padding: const EdgeInsets.symmetric(vertical: 8), const SizedBox(height: 12),
child: Column( SectionCard(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.symmetric(vertical: 8),
children: [ child: Column(
Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), children: [
child: Text( Padding(
l10n.settingsAccount, padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
style: theme.textTheme.titleMedium, child: Text(
l10n.settingsAccount,
style: theme.textTheme.titleMedium,
),
), ),
), // Which account, on which instance (decided
// Which account, on which instance (decided // 2026-10-16), switching, signing out and deleting it:
// 2026-10-16), switching, signing out and deleting it: // halkyon_app's, in Learn's words where only Learn can
// halkyon_app's, in Learn's words where only Learn can // say it.
// say it. AccountTiles(deleteMessage: l10n.settingsDeleteMessage),
AccountTiles(deleteMessage: l10n.settingsDeleteMessage), ],
], ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), SectionCard(
SectionCard( padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 8), child: ListTile(
child: ListTile( leading: const Icon(LucideIcons.info),
leading: const Icon(LucideIcons.info), title: Text(l10n.settingsAbout),
title: Text(l10n.settingsAbout), onTap: () => showAbout(context),
onTap: () => showAbout(context), ),
), ),
), const SizedBox(height: 24),
const SizedBox(height: 24), const _Version(),
const _Version(), ],
], ),
), ),
), ),
), ),

View file

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart'; import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
@ -7,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart'; import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.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_device_dictionaries.dart'; import '../../../support/fake_device_dictionaries.dart';
@ -54,6 +56,55 @@ void main() {
expect(find.text('Version 1.2.3 (build 45)'), findsOneWidget); expect(find.text('Version 1.2.3 (build 45)'), findsOneWidget);
}); });
testWidgets('scrolled up slowly from the bottom, the cards move only as '
'far as the screen scrolls, though some grow once the instance '
'answers', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
// Shown only once the instance said what it offers devices.
deviceDictionaries: deviceDictionariesIn(
FakeDeviceDictionaryGateway(offered: offeredJapanese()),
),
),
location: RoutePaths.settings,
);
await tester.pumpAndSettle();
final position = tester
.state<ScrollableState>(find.byType(Scrollable).first)
.position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
// Where each card built stands in the list: its place on the screen
// plus how far the list is scrolled, which stays as long as nothing
// above it changes its height.
Map<Element, double> places() => {
for (final card in find.byType(SectionCard).evaluate())
card:
tester.getTopLeft(find.byElementPredicate((e) => e == card)).dy +
position.pixels,
};
while (position.pixels > 0) {
final before = places();
position.jumpTo(math.max(0, position.pixels - 20));
await tester.pumpAndSettle();
final after = places();
for (final MapEntry(key: card, value: place) in before.entries) {
if (after[card] case final now?) {
expect(
now,
moreOrLessEquals(place),
reason: 'a card moved at ${position.pixels}',
);
}
}
}
});
group('Japanese on this device', () { group('Japanese on this device', () {
/// Opens Settings, tall enough to show every card at once. /// Opens Settings, tall enough to show every card at once.
Future<void> openSettings( Future<void> openSettings(
@ -161,7 +212,7 @@ void main() {
location: RoutePaths.settings, location: RoutePaths.settings,
); );
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
find.text('Official guide'), find.text('APA (7th edition)'),
200, 200,
scrollable: find.byType(Scrollable).first, scrollable: find.byType(Scrollable).first,
); );