Browse Source

neon: Improve multi-account UI

pull/53/head
jld3103 2 years ago
parent
commit
60e5a285d3
No known key found for this signature in database
GPG Key ID: 9062417B9E8EB7B3
  1. 12
      packages/neon/lib/l10n/en.arb
  2. 12
      packages/neon/lib/l10n/localizations.dart
  3. 9
      packages/neon/lib/l10n/localizations_en.dart
  4. 3
      packages/neon/lib/src/apps/files/blocs/files.dart
  5. 2
      packages/neon/lib/src/blocs/accounts.dart
  6. 8
      packages/neon/lib/src/models/account.dart
  7. 67
      packages/neon/lib/src/pages/home/home.dart
  8. 14
      packages/neon/lib/src/pages/settings/account_specific_settings.dart
  9. 57
      packages/neon/lib/src/pages/settings/settings.dart
  10. 10
      packages/neon/lib/src/pages/settings/widgets/account_settings_tile.dart
  11. 23
      packages/neon/lib/src/utils/global_options.dart
  12. 66
      packages/neon/lib/src/widgets/account_tile.dart
  13. 10
      packages/settings/lib/src/options/option.dart

12
packages/neon/lib/l10n/en.arb

@ -94,18 +94,16 @@
"globalOptionsSystemTrayEnabled": "Enable system tray", "globalOptionsSystemTrayEnabled": "Enable system tray",
"globalOptionsSystemTrayHideToTrayWhenMinimized": "Hide to system tray when minimized", "globalOptionsSystemTrayHideToTrayWhenMinimized": "Hide to system tray when minimized",
"globalOptionsAccountsRememberLastUsedAccount": "Remember last used account", "globalOptionsAccountsRememberLastUsedAccount": "Remember last used account",
"globalOptionsAccountsRemoveConfirm": "Are you sure you want to remove the account {name} from {url}?", "globaloptionsaccountsInitialAccount": "Initial account",
"@globalOptionsAccountsRemoveConfirm": { "globalOptionsAccountsAdd": "Add account",
"accountOptionsRemoveConfirm": "Are you sure you want to remove the account {id}?",
"@accountOptionsRemoveConfirm": {
"placeholders": { "placeholders": {
"name": { "id": {
"type": "String"
},
"url": {
"type": "String" "type": "String"
} }
} }
}, },
"globalOptionsAccountsAdd": "Add account",
"accountOptionsInitialApp": "App to show initially", "accountOptionsInitialApp": "App to show initially",
"accountOptionsAutomatic": "Automatic", "accountOptionsAutomatic": "Automatic",
"licenses": "Licenses", "licenses": "Licenses",

12
packages/neon/lib/l10n/localizations.dart

@ -485,11 +485,11 @@ abstract class AppLocalizations {
/// **'Remember last used account'** /// **'Remember last used account'**
String get globalOptionsAccountsRememberLastUsedAccount; String get globalOptionsAccountsRememberLastUsedAccount;
/// No description provided for @globalOptionsAccountsRemoveConfirm. /// No description provided for @globaloptionsaccountsInitialAccount.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Are you sure you want to remove the account {name} from {url}?'** /// **'Initial account'**
String globalOptionsAccountsRemoveConfirm(String name, String url); String get globaloptionsaccountsInitialAccount;
/// No description provided for @globalOptionsAccountsAdd. /// No description provided for @globalOptionsAccountsAdd.
/// ///
@ -497,6 +497,12 @@ abstract class AppLocalizations {
/// **'Add account'** /// **'Add account'**
String get globalOptionsAccountsAdd; String get globalOptionsAccountsAdd;
/// No description provided for @accountOptionsRemoveConfirm.
///
/// In en, this message translates to:
/// **'Are you sure you want to remove the account {id}?'**
String accountOptionsRemoveConfirm(String id);
/// No description provided for @accountOptionsInitialApp. /// No description provided for @accountOptionsInitialApp.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

9
packages/neon/lib/l10n/localizations_en.dart

@ -216,13 +216,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get globalOptionsAccountsRememberLastUsedAccount => 'Remember last used account'; String get globalOptionsAccountsRememberLastUsedAccount => 'Remember last used account';
@override @override
String globalOptionsAccountsRemoveConfirm(String name, String url) { String get globaloptionsaccountsInitialAccount => 'Initial account';
return 'Are you sure you want to remove the account $name from $url?';
}
@override @override
String get globalOptionsAccountsAdd => 'Add account'; String get globalOptionsAccountsAdd => 'Add account';
@override
String accountOptionsRemoveConfirm(String id) {
return 'Are you sure you want to remove the account $id?';
}
@override @override
String get accountOptionsInitialApp => 'App to show initially'; String get accountOptionsInitialApp => 'App to show initially';

3
packages/neon/lib/src/apps/files/blocs/files.dart

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:neon/src/apps/files/app.dart'; import 'package:neon/src/apps/files/app.dart';
import 'package:neon/src/apps/files/blocs/browser.dart'; import 'package:neon/src/apps/files/blocs/browser.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/neon.dart'; import 'package:neon/src/neon.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
@ -79,7 +80,7 @@ class FilesBloc extends $FilesBloc {
final file = File( final file = File(
p.join( p.join(
await _platform.getUserAccessibleAppDataPath(), await _platform.getUserAccessibleAppDataPath(),
'${client.username!}@${Uri.parse(client.baseURL).host}', client.humanReadableID,
'files', 'files',
path.join(Platform.pathSeparator), path.join(Platform.pathSeparator),
), ),

2
packages/neon/lib/src/blocs/accounts.dart

@ -106,7 +106,7 @@ class AccountsBloc extends $AccountsBloc {
final lastUsedAccountID = _storage.getString(_keyLastUsedAccount); final lastUsedAccountID = _storage.getString(_keyLastUsedAccount);
_activeAccountSubject.add(accounts.singleWhere((final account) => account.id == lastUsedAccountID)); _activeAccountSubject.add(accounts.singleWhere((final account) => account.id == lastUsedAccountID));
} else { } else {
_globalOptions.lastAccount.stream.first.then((final lastAccount) { _globalOptions.initialAccount.stream.first.then((final lastAccount) {
final matches = accounts.where((final account) => account.id == lastAccount).toList(); final matches = accounts.where((final account) => account.id == lastAccount).toList();
if (matches.isNotEmpty) { if (matches.isNotEmpty) {
_activeAccountSubject.add(matches[0]); _activeAccountSubject.add(matches[0]);

8
packages/neon/lib/src/models/account.dart

@ -70,7 +70,7 @@ class Account {
Map<String, String> _idCache = {}; Map<String, String> _idCache = {};
extension NextcloudClientID on NextcloudClient { extension NextcloudClientHelpers on NextcloudClient {
String get id { String get id {
final key = '$username@$baseURL'; final key = '$username@$baseURL';
if (_idCache[key] != null) { if (_idCache[key] != null) {
@ -78,6 +78,12 @@ extension NextcloudClientID on NextcloudClient {
} }
return _idCache[key] = sha1.convert(utf8.encode(key)).toString(); return _idCache[key] = sha1.convert(utf8.encode(key)).toString();
} }
String get humanReadableID {
final uri = Uri.parse(baseURL);
// Maybe also show path if it is not '/' ?
return '${username!}@${uri.port != 443 ? '${uri.host}:${uri.port}' : uri.host}';
}
} }
class AccountSpecificOptions { class AccountSpecificOptions {

67
packages/neon/lib/src/pages/home/home.dart

@ -327,19 +327,26 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
_scaffoldKey.currentState!.openDrawer(); _scaffoldKey.currentState!.openDrawer();
return false; return false;
}, },
child: Scaffold( child: Builder(
builder: (final context) {
if (accountsSnapshot.hasData) {
final accounts = accountsSnapshot.data!;
final account = accounts.singleWhere((final account) => account.id == widget.account.id);
return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
title: Row( title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Row(
child: Row(
children: [ children: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[ if (appsData != null && activeAppIDSnapshot.hasData) ...[
Flexible( Flexible(
child: Text( child: Text(
appsData.singleWhere((final a) => a.id == activeAppIDSnapshot.data!).name(context), appsData
.singleWhere((final a) => a.id == activeAppIDSnapshot.data!)
.name(context),
), ),
), ),
], ],
@ -366,14 +373,17 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
), ),
), ),
], ],
const SizedBox(
width: 8,
),
], ],
), ),
if (accounts.length > 1) ...[
Text(
account.client.humanReadableID,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
), ),
Row( actions: [
children: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[ if (appsData != null && activeAppIDSnapshot.hasData) ...[
IconButton( IconButton(
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
@ -388,30 +398,25 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
); );
}, },
), ),
Builder( IconButton(
builder: (final context) { icon: AccountAvatar(
if (accountsSnapshot.hasData) { account: account,
final matches = accountsSnapshot.data!
.where(
(final account) => account.id == widget.account.id,
)
.toList();
if (matches.length == 1) {
return AccountAvatar(
account: matches[0],
requestManager: _requestManager, requestManager: _requestManager,
),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc,
account: account,
),
),
); );
}
}
return Container();
}, },
), ),
], ],
], ],
), ),
],
),
),
drawer: Drawer( drawer: Drawer(
child: Column( child: Column(
children: [ children: [
@ -472,6 +477,7 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
value: account.id, value: account.id,
child: AccountTile( child: AccountTile(
account: account, account: account,
dense: true,
), ),
), ),
) )
@ -515,7 +521,8 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(appImplementation.name(context)), Text(appImplementation.name(context)),
if (unreadCounterSnapshot.hasData && unreadCounterSnapshot.data! > 0) ...[ if (unreadCounterSnapshot.hasData &&
unreadCounterSnapshot.data! > 0) ...[
Text( Text(
unreadCounterSnapshot.data!.toString(), unreadCounterSnapshot.data!.toString(),
style: TextStyle( style: TextStyle(
@ -591,6 +598,10 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
], ],
], ],
), ),
);
}
return Container();
},
), ),
), ),
), ),

14
packages/neon/lib/src/pages/settings/account_specific_settings.dart

@ -11,7 +11,7 @@ class AccountSpecificSettingsPage extends StatelessWidget {
final Account account; final Account account;
late final _options = bloc.getOptions(account)!; late final _options = bloc.getOptions(account)!;
late final _name = '${account.username}@${Uri.parse(account.serverURL).host}'; late final _name = account.client.humanReadableID;
@override @override
Widget build(final BuildContext context) => Scaffold( Widget build(final BuildContext context) => Scaffold(
@ -19,6 +19,18 @@ class AccountSpecificSettingsPage extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: Text(_name), title: Text(_name),
actions: [ actions: [
IconButton(
onPressed: () async {
if (await showConfirmationDialog(
context,
AppLocalizations.of(context).accountOptionsRemoveConfirm(account.client.humanReadableID),
)) {
bloc.removeAccount(account);
Navigator.of(context).pop();
}
},
icon: const Icon(MdiIcons.delete),
),
IconButton( IconButton(
onPressed: () async { onPressed: () async {
if (await showConfirmationDialog( if (await showConfirmationDialog(

57
packages/neon/lib/src/pages/settings/settings.dart

@ -72,11 +72,7 @@ class _SettingsPageState extends State<SettingsPage> {
final context, final context,
final pushNotificationsEnabledEnabledSnapshot, final pushNotificationsEnabledEnabledSnapshot,
) => ) =>
OptionBuilder<bool>( SettingsList(
option: globalOptions.rememberLastUsedAccount,
builder: (final context, final rememberLastUsedAccount) => OptionBuilder<String?>(
option: globalOptions.lastAccount,
builder: (final context, final lastAccount) => SettingsList(
categories: [ categories: [
SettingsCategory( SettingsCategory(
title: Text(AppLocalizations.of(context).settingsApps), title: Text(AppLocalizations.of(context).settingsApps),
@ -176,31 +172,15 @@ class _SettingsPageState extends State<SettingsPage> {
CheckBoxSettingsTile( CheckBoxSettingsTile(
option: globalOptions.rememberLastUsedAccount, option: globalOptions.rememberLastUsedAccount,
), ),
DropdownButtonSettingsTile(
option: globalOptions.initialAccount,
),
], ],
for (final account in accountsSnapshot.data!) ...[ for (final account in accountsSnapshot.data!) ...[
AccountSettingsTile( AccountSettingsTile(
account: account, account: account,
color: activeAccountSnapshot.data == account && accountsSnapshot.data!.length > 1 onTap: () {
? Theme.of(context).colorScheme.primary Navigator.of(context).push(
: Theme.of(context).colorScheme.onBackground,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<SettingsAccountAction>(
itemBuilder: (final context) => [
PopupMenuItem(
value: SettingsAccountAction.settings,
child: Text(AppLocalizations.of(context).settings),
),
PopupMenuItem(
value: SettingsAccountAction.delete,
child: Text(AppLocalizations.of(context).delete),
),
],
onSelected: (final action) async {
switch (action) {
case SettingsAccountAction.settings:
await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (final context) => AccountSpecificSettingsPage( builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc, bloc: accountsBloc,
@ -208,31 +188,8 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
), ),
); );
break;
case SettingsAccountAction.delete:
if (await showConfirmationDialog(
context,
AppLocalizations.of(context).globalOptionsAccountsRemoveConfirm(
account.username,
account.serverURL,
),
)) {
accountsBloc.removeAccount(account);
}
break;
}
}, },
), ),
if (accountsSnapshot.data!.length > 1 && rememberLastUsedAccount != null) ...[
Radio<String>(
groupValue: lastAccount,
value: account.id,
onChanged: !rememberLastUsedAccount ? globalOptions.lastAccount.set : null,
),
],
],
),
),
], ],
CustomSettingsTile( CustomSettingsTile(
title: ElevatedButton.icon( title: ElevatedButton.icon(
@ -337,8 +294,6 @@ class _SettingsPageState extends State<SettingsPage> {
], ],
), ),
), ),
),
),
); );
}, },
), ),

10
packages/neon/lib/src/pages/settings/widgets/account_settings_tile.dart

@ -5,22 +5,20 @@ class AccountSettingsTile extends SettingsTile {
required this.account, required this.account,
this.color, this.color,
this.trailing, this.trailing,
this.onTap,
super.key, super.key,
}); });
final Account account; final Account account;
final Color? color; final Color? color;
final Widget? trailing; final Widget? trailing;
final VoidCallback? onTap;
@override @override
Widget build(final BuildContext context) => Container( Widget build(final BuildContext context) => AccountTile(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: AccountTile(
account: account, account: account,
color: color, color: color,
trailing: trailing, trailing: trailing,
), onTap: onTap,
); );
} }

23
packages/neon/lib/src/utils/global_options.dart

@ -22,20 +22,26 @@ class GlobalOptions {
}); });
rememberLastUsedAccount.stream.listen((final remember) async { rememberLastUsedAccount.stream.listen((final remember) async {
_initialAccountEnabledSubject.add(!remember);
if (remember) { if (remember) {
await lastAccount.set(null); await initialAccount.set(null);
} else { } else {
await lastAccount.set((await lastAccount.values.first).keys.toList()[0]); // Only override the initial account if there already has been a value,
// which means it's not the initial emit from rememberLastUsedAccount
if (initialAccount.hasValue) {
await initialAccount.set((await initialAccount.values.first).keys.toList()[0]);
}
} }
}); });
} }
final Storage _storage; final Storage _storage;
final PackageInfo _packageInfo; final PackageInfo _packageInfo;
final _accountsIDsSubject = BehaviorSubject<Map<String?, LabelBuilder>>();
final _themeOLEDAsDarkEnabledSubject = BehaviorSubject<bool>(); final _themeOLEDAsDarkEnabledSubject = BehaviorSubject<bool>();
final _pushNotificationsEnabledEnabledSubject = BehaviorSubject<bool>(); final _pushNotificationsEnabledEnabledSubject = BehaviorSubject<bool>();
final _pushNotificationsDistributorsSubject = BehaviorSubject<Map<String?, LabelBuilder>>(); final _pushNotificationsDistributorsSubject = BehaviorSubject<Map<String?, LabelBuilder>>();
final _accountsIDsSubject = BehaviorSubject<Map<String?, LabelBuilder>>();
final _initialAccountEnabledSubject = BehaviorSubject<bool>();
late final _distributorsMap = <String, String Function(BuildContext)>{ late final _distributorsMap = <String, String Function(BuildContext)>{
_packageInfo.packageName: (final context) => _packageInfo.packageName: (final context) =>
@ -61,7 +67,7 @@ class GlobalOptions {
systemTrayEnabled, systemTrayEnabled,
systemTrayHideToTrayWhenMinimized, systemTrayHideToTrayWhenMinimized,
rememberLastUsedAccount, rememberLastUsedAccount,
lastAccount, initialAccount,
]; ];
Future reset() async { Future reset() async {
@ -84,7 +90,7 @@ class GlobalOptions {
} }
_accountsIDsSubject.add({ _accountsIDsSubject.add({
for (final account in accounts) ...{ for (final account in accounts) ...{
account.id: (final _) => '', account.id: (final context) => account.client.humanReadableID,
}, },
}); });
} }
@ -183,11 +189,12 @@ class GlobalOptions {
defaultValue: BehaviorSubject.seeded(true), defaultValue: BehaviorSubject.seeded(true),
); );
late final lastAccount = SelectOption<String?>( late final initialAccount = SelectOption<String?>(
storage: _storage, storage: _storage,
key: 'last-account', key: 'initial-account',
label: (final _) => '', label: (final context) => AppLocalizations.of(context).globaloptionsaccountsInitialAccount,
defaultValue: BehaviorSubject.seeded(null), defaultValue: BehaviorSubject.seeded(null),
values: _accountsIDsSubject, values: _accountsIDsSubject,
enabled: _initialAccountEnabledSubject,
); );
} }

66
packages/neon/lib/src/widgets/account_tile.dart

@ -3,14 +3,18 @@ part of '../neon.dart';
class AccountTile extends StatefulWidget { class AccountTile extends StatefulWidget {
const AccountTile({ const AccountTile({
required this.account, required this.account,
this.trailing,
this.color, this.color,
this.trailing,
this.onTap,
this.dense = false,
super.key, super.key,
}); });
final Account account; final Account account;
final Widget? trailing;
final Color? color; final Color? color;
final Widget? trailing;
final VoidCallback? onTap;
final bool dense;
@override @override
State<AccountTile> createState() => _AccountTileState(); State<AccountTile> createState() => _AccountTileState();
@ -27,21 +31,21 @@ class _AccountTileState extends State<AccountTile> {
} }
@override @override
Widget build(final BuildContext context) => Row( Widget build(final BuildContext context) => ListTile(
children: [ onTap: widget.onTap,
AccountAvatar( dense: widget.dense,
contentPadding: widget.dense ? EdgeInsets.zero : null,
visualDensity: widget.dense
? const VisualDensity(
horizontal: -4,
vertical: -4,
)
: null,
leading: AccountAvatar(
account: widget.account, account: widget.account,
requestManager: Provider.of<RequestManager>(context), requestManager: Provider.of<RequestManager>(context),
), ),
const SizedBox( title: StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
width: 10,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
bloc: _userDetailsBloc, bloc: _userDetailsBloc,
state: (final bloc) => bloc.userDetails, state: (final bloc) => bloc.userDetails,
builder: ( builder: (
@ -60,29 +64,6 @@ class _AccountTileState extends State<AccountTile> {
color: widget.color, color: widget.color,
), ),
), ),
const SizedBox(
width: 5,
),
Text(
'(',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: widget.color,
),
),
],
Text(
widget.account.username,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: widget.color,
),
),
if (userDetailsData != null) ...[
Text(
')',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: widget.color,
),
),
], ],
if (userDetailsLoading) ...[ if (userDetailsLoading) ...[
const SizedBox( const SizedBox(
@ -110,18 +91,11 @@ class _AccountTileState extends State<AccountTile> {
], ],
), ),
), ),
Text( subtitle: Text(
widget.account.serverURL, widget.account.client.humanReadableID,
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: widget.color, color: widget.color,
), ),
), ),
],
),
),
if (widget.trailing != null) ...[
widget.trailing!,
],
],
); );
} }

10
packages/settings/lib/src/options/option.dart

@ -27,11 +27,19 @@ abstract class Option<T> {
late BehaviorSubject<T> stream; late BehaviorSubject<T> stream;
T get value { T get value {
if (hasValue) {
return stream.value;
}
return defaultValue.value;
}
bool get hasValue {
if (!enabled.value) { if (!enabled.value) {
throw OptionDisableException(); throw OptionDisableException();
} }
return stream.value ?? defaultValue.value; return stream.hasValue;
} }
Future reset() async { Future reset() async {

Loading…
Cancel
Save