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. 467
      packages/neon/lib/src/pages/home/home.dart
  8. 14
      packages/neon/lib/src/pages/settings/account_specific_settings.dart
  9. 435
      packages/neon/lib/src/pages/settings/settings.dart
  10. 16
      packages/neon/lib/src/pages/settings/widgets/account_settings_tile.dart
  11. 23
      packages/neon/lib/src/utils/global_options.dart
  12. 158
      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 {

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

@ -327,53 +327,63 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
_scaffoldKey.currentState!.openDrawer(); _scaffoldKey.currentState!.openDrawer();
return false; return false;
}, },
child: Scaffold( child: Builder(
key: _scaffoldKey, builder: (final context) {
resizeToAvoidBottomInset: false, if (accountsSnapshot.hasData) {
appBar: AppBar( final accounts = accountsSnapshot.data!;
title: Row( final account = accounts.singleWhere((final account) => account.id == widget.account.id);
children: [ return Scaffold(
Expanded( key: _scaffoldKey,
child: Row( resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[ Row(
Flexible( children: [
child: Text( if (appsData != null && activeAppIDSnapshot.hasData) ...[
appsData.singleWhere((final a) => a.id == activeAppIDSnapshot.data!).name(context), Flexible(
), child: Text(
), appsData
], .singleWhere((final a) => a.id == activeAppIDSnapshot.data!)
if (appsError != null) ...[ .name(context),
const SizedBox( ),
width: 8, ),
), ],
Icon( if (appsError != null) ...[
Icons.error_outline, const SizedBox(
size: 30, width: 8,
color: Theme.of(context).colorScheme.onPrimary, ),
), Icon(
], Icons.error_outline,
if (appsLoading) ...[ size: 30,
const SizedBox( color: Theme.of(context).colorScheme.onPrimary,
width: 8, ),
), ],
SizedBox( if (appsLoading) ...[
height: 30, const SizedBox(
width: 30, width: 8,
child: CircularProgressIndicator( ),
color: Theme.of(context).colorScheme.onPrimary, SizedBox(
strokeWidth: 2, height: 30,
), width: 30,
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.onPrimary,
strokeWidth: 2,
),
),
],
],
),
if (accounts.length > 1) ...[
Text(
account.client.humanReadableID,
style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
const SizedBox(
width: 8,
),
], ],
), ),
), actions: [
Row(
children: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[ if (appsData != null && activeAppIDSnapshot.hasData) ...[
IconButton( IconButton(
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
@ -388,209 +398,210 @@ 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! requestManager: _requestManager,
.where( ),
(final account) => account.id == widget.account.id, onPressed: () async {
) await Navigator.of(context).push(
.toList(); MaterialPageRoute(
if (matches.length == 1) { builder: (final context) => AccountSpecificSettingsPage(
return AccountAvatar( bloc: accountsBloc,
account: matches[0], account: account,
requestManager: _requestManager, ),
); ),
} );
}
return Container();
}, },
), ),
], ],
], ],
), ),
], drawer: Drawer(
), child: Column(
), children: [
drawer: Drawer( Expanded(
child: Column( child: Scrollbar(
children: [ child: ListView(
Expanded( // Needed for the drawer header to also render in the statusbar
child: Scrollbar( padding: EdgeInsets.zero,
child: ListView( children: [
// Needed for the drawer header to also render in the statusbar Builder(
padding: EdgeInsets.zero, builder: (final context) {
children: [ if (accountsSnapshot.hasData) {
Builder( return DrawerHeader(
builder: (final context) { decoration: BoxDecoration(
if (accountsSnapshot.hasData) { color: Theme.of(context).colorScheme.primary,
return DrawerHeader(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (capabilitiesData != null) ...[
Text(
capabilitiesData.capabilities!.theming!.name!,
style: DefaultTextStyle.of(context).style.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
if (capabilitiesData.capabilities!.theming!.logo != null) ...[
Flexible(
child: CachedURLImage(
url: capabilitiesData.capabilities!.theming!.logo!,
requestManager: _requestManager,
client: widget.account.client,
),
),
],
] else ...[
ExceptionWidget(
capabilitiesError,
onRetry: () {
_capabilitiesBloc.refresh();
},
),
CustomLinearProgressIndicator(
visible: capabilitiesLoading,
), ),
], child: Column(
if (accountsSnapshot.data!.length != 1) ...[ crossAxisAlignment: CrossAxisAlignment.start,
DropdownButtonHideUnderline( mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: DropdownButton<String>( children: [
isExpanded: true, if (capabilitiesData != null) ...[
dropdownColor: Theme.of(context).colorScheme.primary, Text(
iconEnabledColor: Theme.of(context).colorScheme.onPrimary, capabilitiesData.capabilities!.theming!.name!,
value: widget.account.id, style: DefaultTextStyle.of(context).style.copyWith(
items: accountsSnapshot.data! color: Theme.of(context).colorScheme.onPrimary,
.map<DropdownMenuItem<String>>(
(final account) => DropdownMenuItem<String>(
value: account.id,
child: AccountTile(
account: account,
), ),
),
if (capabilitiesData.capabilities!.theming!.logo != null) ...[
Flexible(
child: CachedURLImage(
url: capabilitiesData.capabilities!.theming!.logo!,
requestManager: _requestManager,
client: widget.account.client,
), ),
) ),
.toList(), ],
onChanged: (final id) { ] else ...[
for (final account in accountsSnapshot.data!) { ExceptionWidget(
if (account.id == id) { capabilitiesError,
accountsBloc.setActiveAccount(account); onRetry: () {
break; _capabilitiesBloc.refresh();
} },
} ),
}, CustomLinearProgressIndicator(
), visible: capabilitiesLoading,
),
],
if (accountsSnapshot.data!.length != 1) ...[
DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
dropdownColor: Theme.of(context).colorScheme.primary,
iconEnabledColor: Theme.of(context).colorScheme.onPrimary,
value: widget.account.id,
items: accountsSnapshot.data!
.map<DropdownMenuItem<String>>(
(final account) => DropdownMenuItem<String>(
value: account.id,
child: AccountTile(
account: account,
dense: true,
),
),
)
.toList(),
onChanged: (final id) {
for (final account in accountsSnapshot.data!) {
if (account.id == id) {
accountsBloc.setActiveAccount(account);
break;
}
}
},
),
),
],
],
), ),
], );
], }
), return Container();
);
}
return Container();
},
),
ExceptionWidget(
appsError,
onRetry: () {
_appsBloc.refresh();
},
),
CustomLinearProgressIndicator(
visible: appsLoading,
),
if (appsData != null) ...[
for (final appImplementation in appsData) ...[
if (appsData.map((final a) => a.id).contains(appImplementation.id)) ...[
ListTile(
key: Key('app-${appImplementation.id}'),
title: StreamBuilder<int>(
stream: appImplementation.getUnreadCounter(_appsBloc) ??
BehaviorSubject<int>.seeded(0),
builder: (final context, final unreadCounterSnapshot) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(appImplementation.name(context)),
if (unreadCounterSnapshot.hasData && unreadCounterSnapshot.data! > 0) ...[
Text(
unreadCounterSnapshot.data!.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
],
),
),
leading: appImplementation.buildIcon(context),
minLeadingWidth: 0,
onTap: () {
_appsBloc.setActiveApp(appImplementation.id);
Navigator.of(context).pop();
}, },
), ),
ExceptionWidget(
appsError,
onRetry: () {
_appsBloc.refresh();
},
),
CustomLinearProgressIndicator(
visible: appsLoading,
),
if (appsData != null) ...[
for (final appImplementation in appsData) ...[
if (appsData.map((final a) => a.id).contains(appImplementation.id)) ...[
ListTile(
key: Key('app-${appImplementation.id}'),
title: StreamBuilder<int>(
stream: appImplementation.getUnreadCounter(_appsBloc) ??
BehaviorSubject<int>.seeded(0),
builder: (final context, final unreadCounterSnapshot) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(appImplementation.name(context)),
if (unreadCounterSnapshot.hasData &&
unreadCounterSnapshot.data! > 0) ...[
Text(
unreadCounterSnapshot.data!.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
],
),
),
leading: appImplementation.buildIcon(context),
minLeadingWidth: 0,
onTap: () {
_appsBloc.setActiveApp(appImplementation.id);
Navigator.of(context).pop();
},
),
],
],
],
], ],
], ),
], ),
],
),
),
),
ListTile(
key: const Key('settings'),
title: Text(AppLocalizations.of(context).settings),
leading: const Icon(Icons.settings),
minLeadingWidth: 0,
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => const SettingsPage(),
), ),
); ListTile(
}, key: const Key('settings'),
), title: Text(AppLocalizations.of(context).settings),
], leading: const Icon(Icons.settings),
), minLeadingWidth: 0,
), onTap: () async {
body: Column( await Navigator.of(context).push(
children: [ MaterialPageRoute(
ServerStatus( builder: (final context) => const SettingsPage(),
account: widget.account, ),
), );
ExceptionWidget( },
appsError,
onRetry: () {
_appsBloc.refresh();
},
),
if (appsData != null) ...[
if (appsData.isEmpty) ...[
Expanded(
child: Center(
child: Text(
AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound,
textAlign: TextAlign.center,
), ),
), ],
), ),
] else ...[ ),
if (activeAppIDSnapshot.hasData) ...[ body: Column(
Expanded( children: [
child: appsData ServerStatus(
.singleWhere((final a) => a.id == activeAppIDSnapshot.data!) account: widget.account,
.buildPage(context, _appsBloc), ),
ExceptionWidget(
appsError,
onRetry: () {
_appsBloc.refresh();
},
), ),
if (appsData != null) ...[
if (appsData.isEmpty) ...[
Expanded(
child: Center(
child: Text(
AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound,
textAlign: TextAlign.center,
),
),
),
] else ...[
if (activeAppIDSnapshot.hasData) ...[
Expanded(
child: appsData
.singleWhere((final a) => a.id == activeAppIDSnapshot.data!)
.buildPage(context, _appsBloc),
),
],
],
],
], ],
], ),
], );
], }
), 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(

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

@ -72,271 +72,226 @@ class _SettingsPageState extends State<SettingsPage> {
final context, final context,
final pushNotificationsEnabledEnabledSnapshot, final pushNotificationsEnabledEnabledSnapshot,
) => ) =>
OptionBuilder<bool>( SettingsList(
option: globalOptions.rememberLastUsedAccount, categories: [
builder: (final context, final rememberLastUsedAccount) => OptionBuilder<String?>( SettingsCategory(
option: globalOptions.lastAccount, title: Text(AppLocalizations.of(context).settingsApps),
builder: (final context, final lastAccount) => SettingsList( tiles: <SettingsTile>[
categories: [ for (final appImplementation in appImplementations) ...[
SettingsCategory( if (appImplementation.options.options.isNotEmpty) ...[
title: Text(AppLocalizations.of(context).settingsApps), CustomSettingsTile(
tiles: <SettingsTile>[ leading: appImplementation.buildIcon(context),
for (final appImplementation in appImplementations) ...[ title: Text(appImplementation.name(context)),
if (appImplementation.options.options.isNotEmpty) ...[ onTap: () async {
CustomSettingsTile( await Navigator.of(context).push(
leading: appImplementation.buildIcon(context), MaterialPageRoute(
title: Text(appImplementation.name(context)), builder: (final context) => NextcloudAppSpecificSettingsPage(
onTap: () async { appImplementation: appImplementation,
await Navigator.of(context).push( ),
MaterialPageRoute( ),
builder: (final context) => NextcloudAppSpecificSettingsPage( );
appImplementation: appImplementation, },
), ),
),
);
},
),
],
],
], ],
],
],
),
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryTheme),
tiles: [
DropdownButtonSettingsTile(
option: globalOptions.themeMode,
), ),
SettingsCategory( CheckBoxSettingsTile(
title: Text(AppLocalizations.of(context).optionsCategoryTheme), option: globalOptions.themeOLEDAsDark,
tiles: [ ),
DropdownButtonSettingsTile( ],
option: globalOptions.themeMode, ),
if (platform.canUsePushNotifications) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryPushNotifications),
tiles: [
TextSettingsTile(
text: AppLocalizations.of(context).globalOptionsPushNotificationsNotice,
style: const TextStyle(
fontWeight: FontWeight.w300,
fontStyle: FontStyle.italic,
), ),
CheckBoxSettingsTile( ),
option: globalOptions.themeOLEDAsDark, if (pushNotificationsEnabledEnabledSnapshot.data != null &&
!pushNotificationsEnabledEnabledSnapshot.data!) ...[
TextSettingsTile(
text: AppLocalizations.of(context).globalOptionsPushNotificationsEnabledDisabledNotice,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
color: Colors.red,
),
), ),
], ],
), CheckBoxSettingsTile(
if (platform.canUsePushNotifications) ...[ option: globalOptions.pushNotificationsEnabled,
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryPushNotifications),
tiles: [
TextSettingsTile(
text: AppLocalizations.of(context).globalOptionsPushNotificationsNotice,
style: const TextStyle(
fontWeight: FontWeight.w300,
fontStyle: FontStyle.italic,
),
),
if (pushNotificationsEnabledEnabledSnapshot.data != null &&
!pushNotificationsEnabledEnabledSnapshot.data!) ...[
TextSettingsTile(
text: AppLocalizations.of(context).globalOptionsPushNotificationsEnabledDisabledNotice,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
color: Colors.red,
),
),
],
CheckBoxSettingsTile(
option: globalOptions.pushNotificationsEnabled,
),
DropdownButtonSettingsTile(
option: globalOptions.pushNotificationsDistributor,
),
],
), ),
], DropdownButtonSettingsTile(
if (platform.canUseWindowManager) ...[ option: globalOptions.pushNotificationsDistributor,
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryStartup),
tiles: [
CheckBoxSettingsTile(
option: globalOptions.startupMinimized,
),
CheckBoxSettingsTile(
option: globalOptions.startupMinimizeInsteadOfExit,
),
],
), ),
], ],
if (platform.canUseWindowManager && platform.canUseSystemTray) ...[ ),
SettingsCategory( ],
title: Text(AppLocalizations.of(context).optionsCategorySystemTray), if (platform.canUseWindowManager) ...[
tiles: [ SettingsCategory(
CheckBoxSettingsTile( title: Text(AppLocalizations.of(context).optionsCategoryStartup),
option: globalOptions.systemTrayEnabled, tiles: [
), CheckBoxSettingsTile(
CheckBoxSettingsTile( option: globalOptions.startupMinimized,
option: globalOptions.systemTrayHideToTrayWhenMinimized, ),
), CheckBoxSettingsTile(
], option: globalOptions.startupMinimizeInsteadOfExit,
), ),
], ],
if (accountsSnapshot.hasData) ...[ ),
SettingsCategory( ],
title: Text(AppLocalizations.of(context).optionsCategoryAccounts), if (platform.canUseWindowManager && platform.canUseSystemTray) ...[
tiles: [ SettingsCategory(
if (accountsSnapshot.data!.length > 1) ...[ title: Text(AppLocalizations.of(context).optionsCategorySystemTray),
CheckBoxSettingsTile( tiles: [
option: globalOptions.rememberLastUsedAccount, CheckBoxSettingsTile(
), option: globalOptions.systemTrayEnabled,
], ),
for (final account in accountsSnapshot.data!) ...[ CheckBoxSettingsTile(
AccountSettingsTile( option: globalOptions.systemTrayHideToTrayWhenMinimized,
account: account,
color: activeAccountSnapshot.data == account && accountsSnapshot.data!.length > 1
? Theme.of(context).colorScheme.primary
: 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(
builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc,
account: account,
),
),
);
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(
title: ElevatedButton.icon(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => const LoginPage(),
),
);
},
icon: const Icon(MdiIcons.accountPlus),
label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd),
),
)
],
), ),
], ],
SettingsCategory( ),
title: Text(AppLocalizations.of(context).optionsCategoryOther), ],
tiles: <SettingsTile>[ if (accountsSnapshot.hasData) ...[
CustomSettingsTile( SettingsCategory(
leading: Icon( title: Text(AppLocalizations.of(context).optionsCategoryAccounts),
MdiIcons.scriptText, tiles: [
color: Theme.of(context).colorScheme.primary, if (accountsSnapshot.data!.length > 1) ...[
), CheckBoxSettingsTile(
title: Text(AppLocalizations.of(context).licenses), option: globalOptions.rememberLastUsedAccount,
onTap: () async { ),
showLicensePage( DropdownButtonSettingsTile(
context: context, option: globalOptions.initialAccount,
applicationName: AppLocalizations.of(context).appName, ),
applicationIcon: const NeonLogo( ],
withoutText: true, for (final account in accountsSnapshot.data!) ...[
AccountSettingsTile(
account: account,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc,
account: account,
),
), ),
applicationLegalese: await rootBundle.loadString('assets/LEGALESE.txt'),
applicationVersion: Provider.of<PackageInfo>(context, listen: false).version,
); );
}, },
), ),
CustomSettingsTile( ],
leading: Icon( CustomSettingsTile(
MdiIcons.export, title: ElevatedButton.icon(
color: Theme.of(context).colorScheme.primary, onPressed: () async {
), await Navigator.of(context).push(
title: Text(AppLocalizations.of(context).settingsExport), MaterialPageRoute(
onTap: () async { builder: (final context) => const LoginPage(),
try { ),
final fileName = );
'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json.base64';
final data = base64.encode(
utf8.encode(
json.encode(
settingsExportHelper.toJsonExport(),
),
),
);
await saveFileWithPickDialog(fileName, Uint8List.fromList(utf8.encode(data)));
} catch (e, s) {
debugPrint(e.toString());
debugPrintStack(stackTrace: s);
ExceptionWidget.showSnackbar(context, e);
}
}, },
icon: const Icon(MdiIcons.accountPlus),
label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd),
), ),
CustomSettingsTile( )
leading: Icon( ],
MdiIcons.import, ),
color: Theme.of(context).colorScheme.primary, ],
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryOther),
tiles: <SettingsTile>[
CustomSettingsTile(
leading: Icon(
MdiIcons.scriptText,
color: Theme.of(context).colorScheme.primary,
),
title: Text(AppLocalizations.of(context).licenses),
onTap: () async {
showLicensePage(
context: context,
applicationName: AppLocalizations.of(context).appName,
applicationIcon: const NeonLogo(
withoutText: true,
), ),
title: Text(AppLocalizations.of(context).settingsImport), applicationLegalese: await rootBundle.loadString('assets/LEGALESE.txt'),
onTap: () async { applicationVersion: Provider.of<PackageInfo>(context, listen: false).version,
try { );
final result = await FilePicker.platform.pickFiles( },
withData: true, ),
); CustomSettingsTile(
leading: Icon(
if (result == null) { MdiIcons.export,
return; color: Theme.of(context).colorScheme.primary,
} ),
title: Text(AppLocalizations.of(context).settingsExport),
onTap: () async {
try {
final fileName =
'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json.base64';
final data = base64.encode(
utf8.encode(
json.encode(
settingsExportHelper.toJsonExport(),
),
),
);
await saveFileWithPickDialog(fileName, Uint8List.fromList(utf8.encode(data)));
} catch (e, s) {
debugPrint(e.toString());
debugPrintStack(stackTrace: s);
ExceptionWidget.showSnackbar(context, e);
}
},
),
CustomSettingsTile(
leading: Icon(
MdiIcons.import,
color: Theme.of(context).colorScheme.primary,
),
title: Text(AppLocalizations.of(context).settingsImport),
onTap: () async {
try {
final result = await FilePicker.platform.pickFiles(
withData: true,
);
if (!result.files.single.path!.endsWith('.json.base64')) { if (result == null) {
if (mounted) { return;
ExceptionWidget.showSnackbar( }
context,
AppLocalizations.of(context).settingsImportWrongFileExtension,
);
}
return;
}
final data = if (!result.files.single.path!.endsWith('.json.base64')) {
json.decode(utf8.decode(base64.decode(utf8.decode(result.files.single.bytes!)))); if (mounted) {
await settingsExportHelper.applyFromJson(data as Map<String, dynamic>); ExceptionWidget.showSnackbar(
} catch (e, s) { context,
debugPrint(e.toString()); AppLocalizations.of(context).settingsImportWrongFileExtension,
debugPrintStack(stackTrace: s); );
ExceptionWidget.showSnackbar(context, e);
} }
}, return;
), }
],
final data =
json.decode(utf8.decode(base64.decode(utf8.decode(result.files.single.bytes!))));
await settingsExportHelper.applyFromJson(data as Map<String, dynamic>);
} catch (e, s) {
debugPrint(e.toString());
debugPrintStack(stackTrace: s);
ExceptionWidget.showSnackbar(context, e);
}
},
), ),
], ],
), ),
), ],
), ),
), ),
); );

16
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( account: account,
horizontal: 16, color: color,
), trailing: trailing,
child: AccountTile( onTap: onTap,
account: account,
color: color,
trailing: trailing,
),
); );
} }

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,
); );
} }

158
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,101 +31,71 @@ 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,
account: widget.account, contentPadding: widget.dense ? EdgeInsets.zero : null,
requestManager: Provider.of<RequestManager>(context), visualDensity: widget.dense
), ? const VisualDensity(
const SizedBox( horizontal: -4,
width: 10, vertical: -4,
), )
Expanded( : null,
child: Column( leading: AccountAvatar(
mainAxisAlignment: MainAxisAlignment.center, account: widget.account,
crossAxisAlignment: CrossAxisAlignment.start, requestManager: Provider.of<RequestManager>(context),
children: [ ),
StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>( title: StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
bloc: _userDetailsBloc, bloc: _userDetailsBloc,
state: (final bloc) => bloc.userDetails, state: (final bloc) => bloc.userDetails,
builder: ( builder: (
final context, final context,
final userDetailsData, final userDetailsData,
final userDetailsError, final userDetailsError,
final userDetailsLoading, final userDetailsLoading,
final _, final _,
) => ) =>
Row( Row(
children: [ children: [
if (userDetailsData != null) ...[ if (userDetailsData != null) ...[
Text(
userDetailsData.getDisplayName()!,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
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) ...[
const SizedBox(
width: 5,
),
SizedBox(
height: 10,
width: 10,
child: CircularProgressIndicator(
strokeWidth: 1,
color: widget.color,
),
),
],
if (userDetailsError != null) ...[
const SizedBox(
width: 5,
),
Icon(
Icons.error_outline,
size: 20,
color: widget.color,
),
],
],
),
),
Text( Text(
widget.account.serverURL, userDetailsData.getDisplayName()!,
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: widget.color, color: widget.color,
), ),
), ),
], ],
), if (userDetailsLoading) ...[
const SizedBox(
width: 5,
),
SizedBox(
height: 10,
width: 10,
child: CircularProgressIndicator(
strokeWidth: 1,
color: widget.color,
),
),
],
if (userDetailsError != null) ...[
const SizedBox(
width: 5,
),
Icon(
Icons.error_outline,
size: 20,
color: widget.color,
),
],
],
), ),
if (widget.trailing != null) ...[ ),
widget.trailing!, subtitle: Text(
], widget.account.client.humanReadableID,
], style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: widget.color,
),
),
); );
} }

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