Add account switcher

Fixes #146
This commit is contained in:
Henry Hiles 2026-11-02 17:00:54 -05:00
commit 641341ed90
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 667 additions and 144 deletions

View file

@ -0,0 +1,41 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/notification_request.dart";
/// Runs [child] as one account, in a [ProviderScope] of its own that's
/// replaced when switching accounts, so no state leaks between them.
class const AccountScope({
required final String initialAccountId,
required final Widget child,
final List<ProviderObserver>? observers,
final Duration? Function(int retryCount, Object error)? retry,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final active = useState<AccountSwitch>((
accountId: initialAccountId,
notificationRowId: null,
));
return ProviderScope(
key: ValueKey(active.value.accountId),
retry: retry,
observers: observers,
overrides: [
ActiveAccountController.provider.overrideWith(
() => ActiveAccountController(
active.value.accountId,
(next) => active.value = next,
),
),
NotificationRequestController.provider.overrideWithBuild(
(_, _) => active.value.notificationRowId,
),
],
child: child,
);
}
}

View file

@ -0,0 +1,125 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/accounts.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/helpers/extensions/log_out.dart";
import "package:nexus/main.dart";
import "package:nexus/models/account.dart";
import "package:nexus/widgets/adaptive_menu_button.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
/// Switches between, adds and logs out of accounts. With [onlyWithOthers], it's
/// hidden when there's only one account.
class const AccountSwitcher({final bool onlyWithOthers = false, super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final danger = Theme.of(context).colorScheme.error;
final accounts = ref.watch(AccountsController.provider).value;
final activeId = ref.watch(ActiveAccountController.provider);
if (accounts == null || (onlyWithOthers && accounts.length < 2)) {
return const SizedBox.shrink();
}
String nameOf(Account account) =>
account.displayName ?? account.userId ?? "New account";
Widget avatarOf(Account account, double dimension) => AvatarOrHash(
account.avatar,
nameOf(account),
dimension: dimension,
fallback: account.userId == null
? Icon(Icons.account_circle, size: dimension)
: null,
);
final active = accounts.firstWhereOrNull(
(account) => account.id == activeId,
);
void switchTo(String accountId) => ref
.read(ActiveAccountController.provider.notifier)
.switchTo(accountId)
.onError(showError);
return AdaptiveMenuButton(
tooltip: "Switch account",
icon: active == null
? const Icon(Icons.account_circle)
: avatarOf(active, 28),
itemBuilder: (_) => [
for (final account in accounts)
PopupMenuItem(
onTap: () => switchTo(account.id),
child: ListTile(
selected: account.id == activeId,
leading: avatarOf(account, 32),
title: Text(nameOf(account)),
subtitle: switch (account) {
Account(displayName: _?, :final userId?) => Text(userId),
_ => null,
},
trailing: account.userId == null
? null
// Inside the menu, so it can close it
: Builder(
builder: (menuContext) => IconButton(
tooltip: "Log out",
icon: Icon(Icons.logout, color: danger),
onPressed: () async {
Navigator.of(menuContext).pop();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text("Log out of ${nameOf(account)}?"),
content: Text(
account.id == activeId
? "You'll be switched to another account, or returned to the login page if this is your only one."
: "You'll stop receiving notifications for this account.",
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(false),
child: const Text("Cancel"),
),
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(true),
style: TextButton.styleFrom(
foregroundColor: danger,
),
child: const Text("Log out"),
),
],
),
);
if (confirmed != true) return;
await ref.logOut(account.id).onError(showError);
},
),
),
),
),
const PopupMenuDivider(),
PopupMenuItem(
onTap: () {
final id = DateTime.now().millisecondsSinceEpoch.toString();
ref
.read(AccountsController.provider.notifier)
.save(.new(id: id))
.then((_) => switchTo(id))
.onError(showError);
},
child: const ListTile(
leading: Icon(Icons.person_add),
title: Text("Add account"),
),
),
],
);
}
}

View file

@ -13,6 +13,7 @@ import "package:nexus/models/gomuks_exception.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/account_switcher.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart";
@ -122,6 +123,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
return Scaffold(
appBar: Appbar(
actions: .new([
const AccountSwitcher(onlyWithOthers: true),
IconButton(
onPressed: () =>
showDialog(context: context, builder: (_) => SettingsPage()),

View file

@ -7,6 +7,7 @@ import "package:nexus/models/client_state.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/account_switcher.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/helpers/required_validator_helper.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart";
@ -50,6 +51,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
return Scaffold(
appBar: Appbar(
actions: .new([
const AccountSwitcher(onlyWithOthers: true),
IconButton(
onPressed: () =>
showDialog(context: context, builder: (_) => SettingsPage()),

View file

@ -8,6 +8,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
import "package:nexus/helpers/extensions/room_destinations.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/widgets/account_switcher.dart";
import "package:nexus/widgets/adaptive_menu_button.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/appbar.dart";
@ -192,6 +193,7 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
],
icon: const Icon(Icons.add),
),
const AccountSwitcher(),
const IconButton(
tooltip: "Explore other rooms (Not yet implemented)",
onPressed: null,