added support for multiple accounts with an account switcher in settings
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../session/account_codec.dart';
|
||||
import '../session/session_lifecycle.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import '../state/app/modules/account/bloc/account_bloc.dart';
|
||||
import '../state/app/modules/account/bloc/account_state.dart';
|
||||
import '../utils/haptics.dart';
|
||||
import 'app_progress_indicator.dart';
|
||||
import 'async_action_button.dart';
|
||||
import 'centered_leading.dart';
|
||||
import 'confirm_dialog.dart';
|
||||
import 'details_bottom_sheet.dart';
|
||||
import 'user_avatar.dart';
|
||||
|
||||
/// Lists the signed-in accounts: tap one to switch, add another, or sign out
|
||||
/// of an inactive one.
|
||||
Future<void> showAccountSwitcherSheet(BuildContext context) {
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
return showDetailsBottomSheet(
|
||||
context,
|
||||
header: Builder(
|
||||
builder: (headerContext) => ListTile(
|
||||
title: const Text('Konten'),
|
||||
trailing: TextButton.icon(
|
||||
icon: const Icon(Icons.person_add_alt_outlined, size: 18),
|
||||
label: const Text('Hinzufügen'),
|
||||
onPressed: () {
|
||||
Navigator.pop(headerContext);
|
||||
unawaited(startAddAccount(accountBloc));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
children: (sheetContext) => [
|
||||
ValueListenableBuilder<AccountIndex>(
|
||||
valueListenable: SessionManager().accounts,
|
||||
builder: (context, index, _) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final account in index.accounts)
|
||||
_AccountTile(
|
||||
account: account,
|
||||
active: account.id == index.activeId,
|
||||
accountBloc: accountBloc,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the login for another account; the active one is parked meanwhile.
|
||||
Future<void> startAddAccount(AccountBloc accountBloc) async {
|
||||
await SessionLifecycle.beginAddAccount();
|
||||
accountBloc.setStatus(AccountStatus.addingAccount);
|
||||
}
|
||||
|
||||
/// Stacked next to the account name, which needs the width more.
|
||||
/// Only the horizontal padding is trimmed; height stays at a full tap target.
|
||||
final ButtonStyle compactAccountButtonStyle = TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
);
|
||||
|
||||
/// Below the sign-out button: adds an account, or opens the switcher once
|
||||
/// there is more than one.
|
||||
class AccountSwitchButton extends StatelessWidget {
|
||||
const AccountSwitchButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ValueListenableBuilder<AccountIndex>(
|
||||
valueListenable: SessionManager().accounts,
|
||||
builder: (context, index, _) => index.accounts.length > 1
|
||||
? TextButton.icon(
|
||||
style: compactAccountButtonStyle,
|
||||
icon: const Icon(Icons.switch_account_outlined, size: 18),
|
||||
label: const Text('Wechseln'),
|
||||
onPressed: () => showAccountSwitcherSheet(context),
|
||||
)
|
||||
: TextButton.icon(
|
||||
style: compactAccountButtonStyle,
|
||||
icon: const Icon(Icons.person_add_alt_outlined, size: 18),
|
||||
label: const Text('Hinzufügen'),
|
||||
onPressed: () => startAddAccount(context.read<AccountBloc>()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AccountTile extends StatefulWidget {
|
||||
final AccountEntry account;
|
||||
final bool active;
|
||||
final AccountBloc accountBloc;
|
||||
|
||||
const _AccountTile({
|
||||
required this.account,
|
||||
required this.active,
|
||||
required this.accountBloc,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AccountTile> createState() => _AccountTileState();
|
||||
}
|
||||
|
||||
class _AccountTileState extends State<_AccountTile> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _switch() async {
|
||||
Haptics.selection();
|
||||
setState(() => _busy = true);
|
||||
final ok = await runWithErrorDialog(
|
||||
context,
|
||||
() => SessionLifecycle.switchTo(widget.account.id),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (!ok) return;
|
||||
Navigator.pop(context);
|
||||
widget.accountBloc.activated(SessionManager().activeAccount?.id);
|
||||
}
|
||||
|
||||
void _confirmRemove() => ConfirmDialog(
|
||||
title: 'Abmelden?',
|
||||
content:
|
||||
'${widget.account.displayName ?? widget.account.label} wird von diesem Gerät abgemeldet und seine '
|
||||
'lokal gespeicherten Daten werden gelöscht.',
|
||||
confirmButton: 'Abmelden',
|
||||
onConfirmAsync: () => SessionLifecycle.removeInactive(widget.account.id),
|
||||
).asDialog(context);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final account = widget.account;
|
||||
return ListTile(
|
||||
leading: CenteredLeading(
|
||||
_busy
|
||||
? const SizedBox.square(
|
||||
dimension: 24,
|
||||
child: AppProgressIndicator.small(),
|
||||
)
|
||||
: _AccountAvatar(account: account),
|
||||
),
|
||||
title: Text(
|
||||
account.displayName ?? account.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
account.isDemo ? '${account.label} (Demo)' : account.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Sized like the IconButton so both line up.
|
||||
trailing: widget.active
|
||||
? const SizedBox.square(
|
||||
dimension: kMinInteractiveDimension,
|
||||
child: Icon(Icons.check),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.logout_outlined),
|
||||
tooltip: 'Abmelden',
|
||||
onPressed: _busy ? null : _confirmRemove,
|
||||
),
|
||||
onTap: widget.active || _busy ? null : _switch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountAvatar extends StatelessWidget {
|
||||
final AccountEntry account;
|
||||
|
||||
const _AccountAvatar({required this.account});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Demo accounts have no real Nextcloud user behind them.
|
||||
if (!account.isDemo) return UserAvatar(id: account.label, size: 16);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: colors.secondaryContainer,
|
||||
foregroundColor: colors.onSecondaryContainer,
|
||||
child: Text(account.label.characters.first.toUpperCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,30 +15,38 @@ import '../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../storage/account_storage.dart';
|
||||
import '../../storage/hydrated_storage_bootstrap.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../placeholder_view.dart';
|
||||
import 'json_viewer.dart';
|
||||
|
||||
/// Lists every key persisted through [HydratedBloc.storage].
|
||||
/// Lists every key persisted by the active account's blocs and the app-wide
|
||||
/// settings.
|
||||
class BlocStorageView extends StatefulWidget {
|
||||
const BlocStorageView({super.key});
|
||||
|
||||
/// All persisted keys; `null` when the storage can't be enumerated.
|
||||
static List<Storage> get _storages => [
|
||||
HydratedBloc.storage,
|
||||
AccountStorage.global,
|
||||
];
|
||||
|
||||
/// All persisted keys; `null` when a storage can't be enumerated.
|
||||
static List<String>? keys() {
|
||||
final storage = HydratedBloc.storage;
|
||||
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
|
||||
try {
|
||||
// hydrated_bloc exposes no key listing; the Hive instance is the only
|
||||
// way in.
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
final box = HydratedStorage.hive.box<dynamic>('hydrated_box');
|
||||
return box.keys.map((k) => '$k').toList()..sort();
|
||||
} on Object {
|
||||
return null;
|
||||
final keys = <String>{};
|
||||
for (final storage in _storages) {
|
||||
final own = hydratedStorageKeys(storage);
|
||||
if (own == null) return null;
|
||||
keys.addAll(own);
|
||||
}
|
||||
return keys.toList()..sort();
|
||||
}
|
||||
|
||||
static Storage _storageOf(String key) => _storages.firstWhere(
|
||||
(storage) => hydratedStorageKeys(storage)?.contains(key) ?? false,
|
||||
orElse: () => HydratedBloc.storage,
|
||||
);
|
||||
|
||||
@override
|
||||
State<BlocStorageView> createState() => _BlocStorageViewState();
|
||||
}
|
||||
@@ -75,7 +83,7 @@ class _BlocStorageViewState extends State<BlocStorageView> {
|
||||
final keys = BlocStorageView.keys();
|
||||
setState(
|
||||
() => _entries = keys
|
||||
?.map((k) => _Entry(k, HydratedBloc.storage.read(k)))
|
||||
?.map((k) => _Entry(k, BlocStorageView._storageOf(k).read(k)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -114,7 +122,7 @@ class _BlocStorageViewState extends State<BlocStorageView> {
|
||||
if (reset != null) {
|
||||
await reset();
|
||||
} else {
|
||||
await HydratedBloc.storage.delete(key);
|
||||
await BlocStorageView._storageOf(key).delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user