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:
parent
662c30b117
commit
f5d8a3ca1f
10 changed files with 192 additions and 6 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:';
|
||||
|
|
|
|||
|
|
@ -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:';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
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, and the
|
||||
|
|
@ -344,6 +347,75 @@ void main() {
|
|||
expect(find.text('Signed out'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('while the account is deleted, the person stays in their app, '
|
||||
'Delete account showing it is under way', (tester) async {
|
||||
await start(tester);
|
||||
gateway.deletionWaits = Completer();
|
||||
|
||||
await tester.tap(find.text('Delete account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete account').last);
|
||||
// The spinner never settles while it runs.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.text('App of anonymous-1'),
|
||||
findsOneWidget,
|
||||
reason: 'never the launch screen in between',
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'Delete account'),
|
||||
matching: find.byType(ButtonLoadingIndicator),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
gateway.deletionWaits!.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a deletion the instance never got keeps the person in their '
|
||||
'app, and says so once', (tester) async {
|
||||
await start(tester);
|
||||
gateway.deletionError = Exception('No connection');
|
||||
|
||||
await tester.tap(find.text('Delete account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete account').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
"Couldn't delete your account: the instance wasn't reached. "
|
||||
'Try again.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('App of anonymous-1'), findsOneWidget);
|
||||
expect(gateway.status, isA<SignedIn>());
|
||||
expect(find.byType(ButtonLoadingIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a sign-out the device's storage failed keeps the person in "
|
||||
'their app, and says so', (tester) async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
|
||||
)..signOutError = Exception('The keyring is locked');
|
||||
await start(tester);
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text("Couldn't sign out on this device. Try again."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('App of github-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('speaks German', (tester) async {
|
||||
await start(tester);
|
||||
final german = lookupHalkyonAppLocalizations(const Locale('de'));
|
||||
|
|
@ -358,5 +430,14 @@ void main() {
|
|||
expect(german.accountSignOutTitle, 'Abmelden?');
|
||||
expect(german.accountDelete, 'Konto löschen');
|
||||
expect(german.accountDeleteTitle, 'Dein Konto löschen?');
|
||||
expect(
|
||||
german.accountSignOutFailed,
|
||||
'Abmelden auf diesem Gerät hat nicht geklappt. Versuch es noch einmal.',
|
||||
);
|
||||
expect(
|
||||
german.accountDeleteFailed,
|
||||
'Dein Konto wurde nicht gelöscht: Die Instanz war nicht erreichbar. '
|
||||
'Versuch es noch einmal.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,4 +225,36 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a sign-out under way or failed leaves the person's app as it "
|
||||
'is, built once', (tester) async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'ada', isIdentified: true),
|
||||
)..signOutError = Exception('The keyring is locked');
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
var builds = 0;
|
||||
await tester.pumpWidget(
|
||||
AppFrameScope(
|
||||
frame: testFrame(),
|
||||
child: AppScope(
|
||||
dependencies: dependencies,
|
||||
child: SessionGate(
|
||||
look: (context) => (themeMode: ThemeMode.light, locale: null),
|
||||
app: (person) {
|
||||
builds++;
|
||||
return _PersonApp(person);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(builds, 1);
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('App of ada'), findsOneWidget);
|
||||
expect(builds, 1, reason: "busy and its problem are the account tiles'");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue