fix(app): a sign-out or deletion that failed says so, its tile showing it under way

The account's tiles show a spinner on Sign out or Delete account while it
runs, start neither again meanwhile, and say once, in a snack bar, why one
failed ("Couldn't delete your account: the instance wasn't reached. Try
again."; "Couldn't sign out on this device. Try again."), in English and
German. The session gate no longer builds the person's app anew for that:
only the tiles follow it.

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-21 19:49:18 +02:00
commit f5d8a3ca1f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 192 additions and 6 deletions

View file

@ -39,6 +39,8 @@
"accountSignOutTitle": "Abmelden?",
"accountDelete": "Konto löschen",
"accountDeleteTitle": "Dein Konto löschen?",
"accountSignOutFailed": "Abmelden auf diesem Gerät hat nicht geklappt. Versuch es noch einmal.",
"accountDeleteFailed": "Dein Konto wurde nicht gelöscht: Die Instanz war nicht erreichbar. Versuch es noch einmal.",
"aboutSourceCode": "Der Quellcode dessen, was diese Instanz ausführt:",
"aboutSourceUnreachable": "Diese Instanz war nicht erreichbar, um nach ihrem Quellcode zu fragen.",
"sessionUnreachable": "Der Server war nicht erreichbar.",

View file

@ -149,6 +149,8 @@
"accountSignOutTitle": "Sign out?",
"accountDelete": "Delete account",
"accountDeleteTitle": "Delete your account?",
"accountSignOutFailed": "Couldn't sign out on this device. Try again.",
"accountDeleteFailed": "Couldn't delete your account: the instance wasn't reached. Try again.",
"aboutSourceCode": "The source of what this instance runs:",
"aboutSourceUnreachable": "Couldn't reach this instance to ask where its source is.",
"sessionUnreachable": "Couldn't reach the server.",

View file

@ -337,6 +337,18 @@ abstract class HalkyonAppLocalizations {
/// **'Delete your account?'**
String get accountDeleteTitle;
/// No description provided for @accountSignOutFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t sign out on this device. Try again.'**
String get accountSignOutFailed;
/// No description provided for @accountDeleteFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t delete your account: the instance wasn\'t reached. Try again.'**
String get accountDeleteFailed;
/// No description provided for @aboutSourceCode.
///
/// In en, this message translates to:

View file

@ -155,6 +155,14 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get accountDeleteTitle => 'Dein Konto löschen?';
@override
String get accountSignOutFailed =>
'Abmelden auf diesem Gerät hat nicht geklappt. Versuch es noch einmal.';
@override
String get accountDeleteFailed =>
'Dein Konto wurde nicht gelöscht: Die Instanz war nicht erreichbar. Versuch es noch einmal.';
@override
String get aboutSourceCode =>
'Der Quellcode dessen, was diese Instanz ausführt:';

View file

@ -155,6 +155,14 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get accountDeleteTitle => 'Delete your account?';
@override
String get accountSignOutFailed =>
'Couldn\'t sign out on this device. Try again.';
@override
String get accountDeleteFailed =>
'Couldn\'t delete your account: the instance wasn\'t reached. Try again.';
@override
String get aboutSourceCode => 'The source of what this instance runs:';

View file

@ -37,15 +37,19 @@ class const AccountTiles({
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final identified = switch (context.watch<SessionBloc>().state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
final session = context.watch<SessionBloc>().state;
final (identified, busy) = switch (session) {
SessionStateActive(:final person, :final busy) => (
person.isIdentified,
busy,
),
_ => (false, null),
};
final instances = InstanceScope.maybeOf(context);
final instance =
instances?.choice.current?.name ??
AppScope.of<AppDependencies<Disposable>>(context).serverName;
return Column(
final column = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -71,10 +75,14 @@ class const AccountTiles({
subtitle: Text(instances.choice.current?.name ?? ''),
onTap: () => showAccountSwitcher(context),
),
// While one is under way, its tile says so and neither starts again.
ListTile(
leading: const Icon(LucideIcons.logOut),
title: Text(l10n.accountSignOut),
onTap: () => _signOut(context),
trailing: busy == SessionAction.signOut
? const ButtonLoadingIndicator()
: null,
onTap: busy == null ? () => _signOut(context) : null,
),
ListTile(
leading: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
@ -82,10 +90,30 @@ class const AccountTiles({
l10n.accountDelete,
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () => _delete(context),
trailing: busy == SessionAction.delete
? const ButtonLoadingIndicator()
: null,
onTap: busy == null ? () => _delete(context) : null,
),
],
);
// A failure keeps the person here: said once, as it happens.
return BlocListener<SessionBloc, SessionState>(
listenWhen: (previous, current) =>
current is SessionStateActive &&
current.problem != null &&
previous != current,
listener: (context, state) {
final text = switch ((state as SessionStateActive).problem!) {
SessionProblem.signOutFailed => l10n.accountSignOutFailed,
SessionProblem.deleteFailed => l10n.accountDeleteFailed,
};
ScaffoldMessenger.maybeOf(
context,
)?.showSnackBar(SnackBar(content: Text(text)));
},
child: column,
);
}
/// Signs out; an anonymous account can't be signed into again, so signing

View file

@ -39,6 +39,12 @@ class const SessionGate({
AppScope.of(context).signedOut,
)..add(const SessionEventStarted()),
child: BlocBuilder<SessionBloc, SessionState>(
// A sign-out or deletion under way, or failed, leaves the person's
// app as it is: the account's tiles show it.
buildWhen: (previous, current) =>
!(previous is SessionStateActive &&
current is SessionStateActive &&
previous.person == current.person),
builder: (context, state) => switch (state) {
SessionStateLoading() => PlainApp(
look: look(context),

View file

@ -36,6 +36,10 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
/// delete nothing.
Exception? deletionError;
/// While set, deletions of the account wait for it, as for an instance
/// slow to answer.
Completer<void>? deletionWaits;
/// Whether the instance offers GitHub sign-in.
bool offersGitHub = true;
@ -99,6 +103,9 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
@override
Future<void> deleteAccount() async {
accountDeletions++;
if (deletionWaits case final wait?) {
await wait.future;
}
if (deletionError case final error?) {
throw error;
}