fix(app): coming back with a field focused brings its keyboard back

Leaving the app while its keyboard was moving and coming back left the
screen pushed up as if the keyboard were there, the field focused and
no keyboard showing: Flutter gives the window back the keyboard's
height then (flutter#191156, open). Coming back with a field focused now
brings its keyboard back, as other apps on Android do, so the height is
the keyboard's again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 22:38:57 +02:00
commit ec48eaa6cf
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 143 additions and 39 deletions

View file

@ -168,6 +168,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash. - **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`): Serverpod deletes them only for email sign-in, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`): Serverpod deletes them only for email sign-in, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. - **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in.

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart'; import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/core/widget/keyboard_back_on_resume.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
@ -67,46 +68,52 @@ class _PersonAppState() extends State<PersonApp> {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router, routerConfig: _router,
// The person's data opens around the router's navigator, so every screen // The person's data opens around the router's navigator, so every screen
// and dialog is inside it; snack bars fit the window around it all. // and dialog is inside it; snack bars fit the window around it all, and
builder: (context, child) => FittedSnackBars( // a field focused when the app is left has its keyboard back on return.
// Packages that still import package:flutter/material.dart builder: (context, child) => KeyboardBackOnResume(
// (Serverpod's sign-in widgets, the rich-text editor, go_router's child: FittedSnackBars(
// pages) see this app's theme and localizations only through the // Packages that still import package:flutter/material.dart
// bridge, since material_ui is a separate copy of that library. The // (Serverpod's sign-in widgets, the rich-text editor, go_router's
// bridge is deprecated on purpose, as a reminder: remove it once they // pages) see this app's theme and localizations only through the
// have moved. // bridge, since material_ui is a separate copy of that library. The
// ignore: deprecated_member_use // bridge is deprecated on purpose, as a reminder: remove it once they
child: MaterialUiCompatibilityBridge( // have moved.
child: PersonScope( // ignore: deprecated_member_use
person: widget.person, child: MaterialUiCompatibilityBridge(
child: MultiBlocProvider( child: PersonScope(
providers: [ person: widget.person,
// Where sync stands: the trouble dot, Settings and Home show it. child: MultiBlocProvider(
BlocProvider( providers: [
create: (context) => // Where sync stands: the trouble dot, Settings and Home show it.
SyncStatusBloc(PersonScope.of(context).sync) BlocProvider(
..add(const SyncStatusEventStarted()), create: (context) =>
SyncStatusBloc(PersonScope.of(context).sync)
..add(const SyncStatusEventStarted()),
),
// What is new in the inbox, for the Profile tab's badge.
BlocProvider(
create: (context) =>
InboxCountBloc(PersonScope.of(context).feedback)
..add(const InboxCountEventRefreshed()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(
PersonScope.of(context).settings,
PersonScope.of(context).pomodoroRuns,
stoppedOutside: DependenciesScope.of(
context,
).pomodoro.takeStoppedOutside,
)..add(const PomodoroEventStarted()),
),
],
child: PomodoroPresenter(
// The editors know the features' blocks too.
child: EditorBlocksScope(
blocks: _editorBlocks,
child: child!,
),
), ),
// What is new in the inbox, for the Profile tab's badge.
BlocProvider(
create: (context) =>
InboxCountBloc(PersonScope.of(context).feedback)
..add(const InboxCountEventRefreshed()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(
PersonScope.of(context).settings,
PersonScope.of(context).pomodoroRuns,
stoppedOutside: DependenciesScope.of(
context,
).pomodoro.takeStoppedOutside,
)..add(const PomodoroEventStarted()),
),
],
child: PomodoroPresenter(
// The editors know the features' blocks too.
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
), ),
), ),
), ),

View file

@ -0,0 +1,46 @@
import 'package:material_ui/material_ui.dart';
/// Coming back to the app with a text field focused brings its keyboard
/// back, as other apps on Android do.
///
/// Flutter (checked with v3.47.5) gives the window back its keyboard's
/// height after the app was left while the keyboard was moving, though no
/// keyboard shows: the screen stays pushed up, as if it were there
/// ([flutter#191156](https://github.com/flutter/flutter/issues/191156),
/// open). With the keyboard back, the height is the keyboard's again.
/// Remove this once that is fixed.
class const KeyboardBackOnResume({required final Widget child, super.key})
extends StatefulWidget {
@override
State<KeyboardBackOnResume> createState() => _KeyboardBackOnResumeState();
}
class _KeyboardBackOnResumeState() extends State<KeyboardBackOnResume> {
late final AppLifecycleListener _lifecycle;
@override
void initState() {
super.initState();
_lifecycle = AppLifecycleListener(onResume: _resumed);
}
@override
void dispose() {
_lifecycle.dispose();
super.dispose();
}
void _resumed() {
// Once the window is back, when the keyboard may show.
WidgetsBinding.instance.addPostFrameCallback((_) {
final field = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>();
if (field != null && field.mounted && field.widget.focusNode.hasFocus) {
field.requestKeyboard();
}
});
}
@override
Widget build(BuildContext context) => widget.child;
}

View file

@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/widget/keyboard_back_on_resume.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> leaveAndComeBack(WidgetTester tester) async {
[
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
AppLifecycleState.hidden,
AppLifecycleState.inactive,
AppLifecycleState.resumed,
].forEach(tester.binding.handleAppLifecycleStateChanged);
await tester.pump();
}
bool keyboardAskedFor(WidgetTester tester) =>
tester.testTextInput.log.any((call) => call.method == 'TextInput.show');
testWidgets('a field focused when the app was left has its keyboard back '
'on return', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: KeyboardBackOnResume(
child: Scaffold(body: TextField(autofocus: true)),
),
),
);
await tester.pump();
tester.testTextInput.log.clear();
await leaveAndComeBack(tester);
expect(keyboardAskedFor(tester), isTrue);
});
testWidgets('without a field focused, no keyboard', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: KeyboardBackOnResume(child: Scaffold(body: TextField())),
),
);
tester.testTextInput.log.clear();
await leaveAndComeBack(tester);
expect(keyboardAskedFor(tester), isFalse);
});
}