Skip to content

Commit 0344686

Browse files
chrisbobbegnprice
authored andcommitted
login: Support logging out of an account
Fixes: #463
1 parent fcccb94 commit 0344686

File tree

8 files changed

+399
-6
lines changed

8 files changed

+399
-6
lines changed

assets/l10n/app_en.arb

+16
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@
1919
"@chooseAccountPageTitle": {
2020
"description": "Title for ChooseAccountPage"
2121
},
22+
"chooseAccountPageLogOutButton": "Log out",
23+
"@chooseAccountPageLogOutButton": {
24+
"description": "Label for the 'Log out' button for an account on the choose-account page"
25+
},
26+
"logOutConfirmationDialogTitle": "Log out?",
27+
"@logOutConfirmationDialogTitle": {
28+
"description": "Title for a confirmation dialog for logging out."
29+
},
30+
"logOutConfirmationDialogMessage": "To use this account in the future, you will have to re-enter the URL for your organization and your account information.",
31+
"@logOutConfirmationDialogMessage": {
32+
"description": "Message for a confirmation dialog for logging out."
33+
},
34+
"logOutConfirmationDialogConfirmButton": "Log out",
35+
"@logOutConfirmationDialogConfirmButton": {
36+
"description": "Label for the 'Log out' button on a confirmation dialog for logging out."
37+
},
2238
"chooseAccountButtonAddAnAccount": "Add an account",
2339
"@chooseAccountButtonAddAnAccount": {
2440
"description": "Label for ChooseAccountPage button to add an account"

lib/widgets/actions.dart

+37
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,53 @@
66
/// in order to present success or error feedback to the user through the UI.
77
library;
88

9+
import 'dart:async';
10+
911
import 'package:flutter/material.dart';
1012
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
1113

1214
import '../api/model/model.dart';
1315
import '../api/model/narrow.dart';
1416
import '../api/route/messages.dart';
1517
import '../model/narrow.dart';
18+
import '../model/store.dart';
19+
import '../notifications/receive.dart';
1620
import 'dialog.dart';
1721
import 'store.dart';
1822

23+
Future<void> logOutAccount(BuildContext context, int accountId) async {
24+
final globalStore = GlobalStoreWidget.of(context);
25+
26+
final account = globalStore.getAccount(accountId);
27+
if (account == null) return; // TODO(log)
28+
29+
// Unawaited, to not block removing the account on this request.
30+
unawaited(unregisterToken(globalStore, accountId));
31+
32+
await globalStore.removeAccount(accountId);
33+
}
34+
35+
Future<void> unregisterToken(GlobalStore globalStore, int accountId) async {
36+
final account = globalStore.getAccount(accountId);
37+
if (account == null) return; // TODO(log)
38+
39+
// TODO(#322) use actual acked push token; until #322, this is just null.
40+
final token = account.ackedPushToken
41+
// Try the current token as a fallback; maybe the server has registered
42+
// it and we just haven't recorded that fact in the client.
43+
?? NotificationService.instance.token.value;
44+
if (token == null) return;
45+
46+
final connection = globalStore.apiConnectionFromAccount(account);
47+
try {
48+
await NotificationService.unregisterToken(connection, token: token);
49+
} catch (e) {
50+
// TODO retry? handle failures?
51+
} finally {
52+
connection.close();
53+
}
54+
}
55+
1956
Future<void> markNarrowAsRead(BuildContext context, Narrow narrow) async {
2057
final store = PerAccountStoreWidget.of(context);
2158
final connection = store.connection;

lib/widgets/app.dart

+36
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
88
import '../log.dart';
99
import '../model/localizations.dart';
1010
import '../model/narrow.dart';
11+
import '../model/store.dart';
1112
import '../notifications/display.dart';
1213
import 'about_zulip.dart';
14+
import 'actions.dart';
1315
import 'app_bar.dart';
1416
import 'dialog.dart';
1517
import 'inbox.dart';
@@ -232,11 +234,45 @@ class ChooseAccountPage extends StatelessWidget {
232234
required Widget title,
233235
Widget? subtitle,
234236
}) {
237+
final designVariables = DesignVariables.of(context);
238+
final zulipLocalizations = ZulipLocalizations.of(context);
239+
final materialLocalizations = MaterialLocalizations.of(context);
235240
return Card(
236241
clipBehavior: Clip.hardEdge,
237242
child: ListTile(
238243
title: title,
239244
subtitle: subtitle,
245+
trailing: MenuAnchor(
246+
menuChildren: [
247+
MenuItemButton(
248+
onPressed: () {
249+
showSuggestedActionDialog(context: context,
250+
title: zulipLocalizations.logOutConfirmationDialogTitle,
251+
message: zulipLocalizations.logOutConfirmationDialogMessage,
252+
// TODO(#1032) "destructive" style for action button
253+
actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton,
254+
onActionButtonPress: () {
255+
// TODO error handling if db write fails?
256+
logOutAccount(context, accountId);
257+
});
258+
},
259+
child: Text(zulipLocalizations.chooseAccountPageLogOutButton)),
260+
],
261+
builder: (BuildContext context, MenuController controller, Widget? child) {
262+
return IconButton(
263+
tooltip: materialLocalizations.showMenuTooltip, // "Show menu"
264+
onPressed: () {
265+
if (controller.isOpen) {
266+
controller.close();
267+
} else {
268+
controller.open();
269+
}
270+
},
271+
icon: Icon(Icons.adaptive.more, color: designVariables.icon));
272+
}),
273+
// The default trailing padding with M3 is 24px. Decrease by 12 because
274+
// IconButton (the "…" button) comes with 12px padding on all sides.
275+
contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 12),
240276
onTap: () => Navigator.push(context,
241277
HomePage.buildRoute(accountId: accountId))));
242278
}

lib/widgets/page.dart

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ mixin AccountPageRouteMixin<T extends Object?> on PageRoute<T> {
4040
return PerAccountStoreWidget(
4141
accountId: accountId,
4242
placeholder: const LoadingPlaceholderPage(),
43+
routeToRemoveOnLogout: this,
4344
child: super.buildPage(context, animation, secondaryAnimation));
4445
}
4546
}

lib/widgets/store.dart

+22-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/scheduler.dart';
23

34
import '../model/binding.dart';
45
import '../model/store.dart';
6+
import 'page.dart';
57

68
/// Provides access to the app's data.
79
///
@@ -112,11 +114,19 @@ class PerAccountStoreWidget extends StatefulWidget {
112114
super.key,
113115
required this.accountId,
114116
this.placeholder = const LoadingPlaceholder(),
117+
this.routeToRemoveOnLogout,
115118
required this.child,
116119
});
117120

118121
final int accountId;
119122
final Widget placeholder;
123+
124+
/// A per-account [Route] that should be removed on logout.
125+
///
126+
/// Use this when the widget is a page on a route that should go away
127+
/// when the account is logged out, instead of lingering with [placeholder].
128+
final AccountPageRouteMixin? routeToRemoveOnLogout;
129+
120130
final Widget child;
121131

122132
/// The user's data for the relevant Zulip account for this widget.
@@ -195,6 +205,16 @@ class _PerAccountStoreWidgetState extends State<PerAccountStoreWidget> {
195205
void didChangeDependencies() {
196206
super.didChangeDependencies();
197207
final globalStore = GlobalStoreWidget.of(context);
208+
final accountExists = globalStore.getAccount(widget.accountId) != null;
209+
if (!accountExists) {
210+
// logged out
211+
_setStore(null);
212+
if (widget.routeToRemoveOnLogout != null) {
213+
SchedulerBinding.instance.addPostFrameCallback(
214+
(_) => Navigator.of(context).removeRoute(widget.routeToRemoveOnLogout!));
215+
}
216+
return;
217+
}
198218
// If we already have data, get it immediately. This avoids showing one
199219
// frame of loading indicator each time we have a new PerAccountStoreWidget.
200220
final store = globalStore.perAccountSync(widget.accountId);
@@ -212,13 +232,13 @@ class _PerAccountStoreWidgetState extends State<PerAccountStoreWidget> {
212232
// The account was logged out while its store was loading.
213233
// This widget will be showing [placeholder] perpetually,
214234
// but that's OK as long as other code will be removing it from the UI
215-
// (for example by removing a per-account route from the nav).
235+
// (usually by using [routeToRemoveOnLogout]).
216236
}
217237
}();
218238
}
219239
}
220240

221-
void _setStore(PerAccountStore store) {
241+
void _setStore(PerAccountStore? store) {
222242
if (store != this.store) {
223243
setState(() {
224244
this.store = store;

test/model/test_store.dart

+17-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ class TestGlobalStore extends GlobalStore {
4747
/// but that breach is sometimes convenient for tests.
4848
bool useCachedApiConnections = false;
4949

50+
void clearCachedApiConnections() {
51+
_apiConnections.clear();
52+
}
53+
5054
/// Get or construct a [FakeApiConnection] with the given arguments.
5155
///
5256
/// To access the same [FakeApiConnection] that the code under test will get,
@@ -120,9 +124,21 @@ class TestGlobalStore extends GlobalStore {
120124
// Nothing to do.
121125
}
122126

127+
static const Duration removeAccountDuration = Duration(milliseconds: 1);
128+
129+
/// Consume the log of calls made to [doRemoveAccount].
130+
List<int> takeDoRemoveAccountCalls() {
131+
final result = _doRemoveAccountCalls;
132+
_doRemoveAccountCalls = null;
133+
return result ?? [];
134+
}
135+
List<int>? _doRemoveAccountCalls;
136+
123137
@override
124138
Future<void> doRemoveAccount(int accountId) async {
125-
// Nothing to do.
139+
(_doRemoveAccountCalls ??= []).add(accountId);
140+
await Future<void>.delayed(removeAccountDuration);
141+
// Nothing else to do.
126142
}
127143

128144
@override

0 commit comments

Comments
 (0)