Browse Source

Merge pull request #53 from jld3103/feature/improve-multi-account-ui

neon: Improve multi-account UI
pull/54/head
jld3103 2 years ago committed by GitHub
parent
commit
2e17fade97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  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",
"globalOptionsSystemTrayHideToTrayWhenMinimized": "Hide to system tray when minimized",
"globalOptionsAccountsRememberLastUsedAccount": "Remember last used account",
"globalOptionsAccountsRemoveConfirm": "Are you sure you want to remove the account {name} from {url}?",
"@globalOptionsAccountsRemoveConfirm": {
"globaloptionsaccountsInitialAccount": "Initial account",
"globalOptionsAccountsAdd": "Add account",
"accountOptionsRemoveConfirm": "Are you sure you want to remove the account {id}?",
"@accountOptionsRemoveConfirm": {
"placeholders": {
"name": {
"type": "String"
},
"url": {
"id": {
"type": "String"
}
}
},
"globalOptionsAccountsAdd": "Add account",
"accountOptionsInitialApp": "App to show initially",
"accountOptionsAutomatic": "Automatic",
"licenses": "Licenses",

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

@ -485,11 +485,11 @@ abstract class AppLocalizations {
/// **'Remember last used account'**
String get globalOptionsAccountsRememberLastUsedAccount;
/// No description provided for @globalOptionsAccountsRemoveConfirm.
/// No description provided for @globaloptionsaccountsInitialAccount.
///
/// In en, this message translates to:
/// **'Are you sure you want to remove the account {name} from {url}?'**
String globalOptionsAccountsRemoveConfirm(String name, String url);
/// **'Initial account'**
String get globaloptionsaccountsInitialAccount;
/// No description provided for @globalOptionsAccountsAdd.
///
@ -497,6 +497,12 @@ abstract class AppLocalizations {
/// **'Add account'**
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.
///
/// 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';
@override
String globalOptionsAccountsRemoveConfirm(String name, String url) {
return 'Are you sure you want to remove the account $name from $url?';
}
String get globaloptionsaccountsInitialAccount => 'Initial account';
@override
String get globalOptionsAccountsAdd => 'Add account';
@override
String accountOptionsRemoveConfirm(String id) {
return 'Are you sure you want to remove the account $id?';
}
@override
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:neon/src/apps/files/app.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:nextcloud/nextcloud.dart';
import 'package:open_file/open_file.dart';
@ -79,7 +80,7 @@ class FilesBloc extends $FilesBloc {
final file = File(
p.join(
await _platform.getUserAccessibleAppDataPath(),
'${client.username!}@${Uri.parse(client.baseURL).host}',
client.humanReadableID,
'files',
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);
_activeAccountSubject.add(accounts.singleWhere((final account) => account.id == lastUsedAccountID));
} 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();
if (matches.isNotEmpty) {
_activeAccountSubject.add(matches[0]);

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

@ -70,7 +70,7 @@ class Account {
Map<String, String> _idCache = {};
extension NextcloudClientID on NextcloudClient {
extension NextcloudClientHelpers on NextcloudClient {
String get id {
final key = '$username@$baseURL';
if (_idCache[key] != null) {
@ -78,6 +78,12 @@ extension NextcloudClientID on NextcloudClient {
}
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 {

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();
return false;
},
child: Scaffold(
key: _scaffoldKey,
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Row(
children: [
Expanded(
child: Row(
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,
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[
Flexible(
child: Text(
appsData.singleWhere((final a) => a.id == activeAppIDSnapshot.data!).name(context),
),
),
],
if (appsError != null) ...[
const SizedBox(
width: 8,
),
Icon(
Icons.error_outline,
size: 30,
color: Theme.of(context).colorScheme.onPrimary,
),
],
if (appsLoading) ...[
const SizedBox(
width: 8,
),
SizedBox(
height: 30,
width: 30,
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.onPrimary,
strokeWidth: 2,
),
Row(
children: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[
Flexible(
child: Text(
appsData
.singleWhere((final a) => a.id == activeAppIDSnapshot.data!)
.name(context),
),
),
],
if (appsError != null) ...[
const SizedBox(
width: 8,
),
Icon(
Icons.error_outline,
size: 30,
color: Theme.of(context).colorScheme.onPrimary,
),
],
if (appsLoading) ...[
const SizedBox(
width: 8,
),
SizedBox(
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,
),
],
),
),
Row(
children: [
actions: [
if (appsData != null && activeAppIDSnapshot.hasData) ...[
IconButton(
icon: const Icon(Icons.settings),
@ -388,209 +398,210 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
);
},
),
Builder(
builder: (final context) {
if (accountsSnapshot.hasData) {
final matches = accountsSnapshot.data!
.where(
(final account) => account.id == widget.account.id,
)
.toList();
if (matches.length == 1) {
return AccountAvatar(
account: matches[0],
requestManager: _requestManager,
);
}
}
return Container();
IconButton(
icon: AccountAvatar(
account: account,
requestManager: _requestManager,
),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc,
account: account,
),
),
);
},
),
],
],
),
],
),
),
drawer: Drawer(
child: Column(
children: [
Expanded(
child: Scrollbar(
child: ListView(
// Needed for the drawer header to also render in the statusbar
padding: EdgeInsets.zero,
children: [
Builder(
builder: (final context) {
if (accountsSnapshot.hasData) {
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,
drawer: Drawer(
child: Column(
children: [
Expanded(
child: Scrollbar(
child: ListView(
// Needed for the drawer header to also render in the statusbar
padding: EdgeInsets.zero,
children: [
Builder(
builder: (final context) {
if (accountsSnapshot.hasData) {
return DrawerHeader(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
),
],
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,
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,
),
)
.toList(),
onChanged: (final id) {
for (final account in accountsSnapshot.data!) {
if (account.id == id) {
accountsBloc.setActiveAccount(account);
break;
}
}
},
),
),
],
] else ...[
ExceptionWidget(
capabilitiesError,
onRetry: () {
_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();
},
),
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();
);
}
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();
},
),
],
],
],
],
],
],
],
),
),
),
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(),
),
),
),
);
},
),
],
),
),
body: Column(
children: [
ServerStatus(
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,
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(),
),
);
},
),
),
],
),
] else ...[
if (activeAppIDSnapshot.hasData) ...[
Expanded(
child: appsData
.singleWhere((final a) => a.id == activeAppIDSnapshot.data!)
.buildPage(context, _appsBloc),
),
body: Column(
children: [
ServerStatus(
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) ...[
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;
late final _options = bloc.getOptions(account)!;
late final _name = '${account.username}@${Uri.parse(account.serverURL).host}';
late final _name = account.client.humanReadableID;
@override
Widget build(final BuildContext context) => Scaffold(
@ -19,6 +19,18 @@ class AccountSpecificSettingsPage extends StatelessWidget {
appBar: AppBar(
title: Text(_name),
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(
onPressed: () async {
if (await showConfirmationDialog(

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

@ -72,271 +72,226 @@ class _SettingsPageState extends State<SettingsPage> {
final context,
final pushNotificationsEnabledEnabledSnapshot,
) =>
OptionBuilder<bool>(
option: globalOptions.rememberLastUsedAccount,
builder: (final context, final rememberLastUsedAccount) => OptionBuilder<String?>(
option: globalOptions.lastAccount,
builder: (final context, final lastAccount) => SettingsList(
categories: [
SettingsCategory(
title: Text(AppLocalizations.of(context).settingsApps),
tiles: <SettingsTile>[
for (final appImplementation in appImplementations) ...[
if (appImplementation.options.options.isNotEmpty) ...[
CustomSettingsTile(
leading: appImplementation.buildIcon(context),
title: Text(appImplementation.name(context)),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => NextcloudAppSpecificSettingsPage(
appImplementation: appImplementation,
),
),
);
},
),
],
],
SettingsList(
categories: [
SettingsCategory(
title: Text(AppLocalizations.of(context).settingsApps),
tiles: <SettingsTile>[
for (final appImplementation in appImplementations) ...[
if (appImplementation.options.options.isNotEmpty) ...[
CustomSettingsTile(
leading: appImplementation.buildIcon(context),
title: Text(appImplementation.name(context)),
onTap: () async {
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(
title: Text(AppLocalizations.of(context).optionsCategoryTheme),
tiles: [
DropdownButtonSettingsTile(
option: globalOptions.themeMode,
CheckBoxSettingsTile(
option: globalOptions.themeOLEDAsDark,
),
],
),
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,
),
),
],
),
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,
),
),
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,
),
],
CheckBoxSettingsTile(
option: globalOptions.pushNotificationsEnabled,
),
],
if (platform.canUseWindowManager) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryStartup),
tiles: [
CheckBoxSettingsTile(
option: globalOptions.startupMinimized,
),
CheckBoxSettingsTile(
option: globalOptions.startupMinimizeInsteadOfExit,
),
],
DropdownButtonSettingsTile(
option: globalOptions.pushNotificationsDistributor,
),
],
if (platform.canUseWindowManager && platform.canUseSystemTray) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategorySystemTray),
tiles: [
CheckBoxSettingsTile(
option: globalOptions.systemTrayEnabled,
),
CheckBoxSettingsTile(
option: globalOptions.systemTrayHideToTrayWhenMinimized,
),
],
),
],
if (platform.canUseWindowManager) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryStartup),
tiles: [
CheckBoxSettingsTile(
option: globalOptions.startupMinimized,
),
CheckBoxSettingsTile(
option: globalOptions.startupMinimizeInsteadOfExit,
),
],
if (accountsSnapshot.hasData) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryAccounts),
tiles: [
if (accountsSnapshot.data!.length > 1) ...[
CheckBoxSettingsTile(
option: globalOptions.rememberLastUsedAccount,
),
],
for (final account in accountsSnapshot.data!) ...[
AccountSettingsTile(
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),
),
)
],
),
],
if (platform.canUseWindowManager && platform.canUseSystemTray) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategorySystemTray),
tiles: [
CheckBoxSettingsTile(
option: globalOptions.systemTrayEnabled,
),
CheckBoxSettingsTile(
option: globalOptions.systemTrayHideToTrayWhenMinimized,
),
],
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,
),
],
if (accountsSnapshot.hasData) ...[
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryAccounts),
tiles: [
if (accountsSnapshot.data!.length > 1) ...[
CheckBoxSettingsTile(
option: globalOptions.rememberLastUsedAccount,
),
DropdownButtonSettingsTile(
option: globalOptions.initialAccount,
),
],
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(
MdiIcons.export,
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(
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),
),
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),
onTap: () async {
try {
final result = await FilePicker.platform.pickFiles(
withData: true,
);
if (result == null) {
return;
}
applicationLegalese: await rootBundle.loadString('assets/LEGALESE.txt'),
applicationVersion: Provider.of<PackageInfo>(context, listen: false).version,
);
},
),
CustomSettingsTile(
leading: Icon(
MdiIcons.export,
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 (mounted) {
ExceptionWidget.showSnackbar(
context,
AppLocalizations.of(context).settingsImportWrongFileExtension,
);
}
return;
}
if (result == null) {
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);
if (!result.files.single.path!.endsWith('.json.base64')) {
if (mounted) {
ExceptionWidget.showSnackbar(
context,
AppLocalizations.of(context).settingsImportWrongFileExtension,
);
}
},
),
],
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,
this.color,
this.trailing,
this.onTap,
super.key,
});
final Account account;
final Color? color;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(final BuildContext context) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: AccountTile(
account: account,
color: color,
trailing: trailing,
),
Widget build(final BuildContext context) => AccountTile(
account: account,
color: color,
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 {
_initialAccountEnabledSubject.add(!remember);
if (remember) {
await lastAccount.set(null);
await initialAccount.set(null);
} 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 PackageInfo _packageInfo;
final _accountsIDsSubject = BehaviorSubject<Map<String?, LabelBuilder>>();
final _themeOLEDAsDarkEnabledSubject = BehaviorSubject<bool>();
final _pushNotificationsEnabledEnabledSubject = BehaviorSubject<bool>();
final _pushNotificationsDistributorsSubject = BehaviorSubject<Map<String?, LabelBuilder>>();
final _accountsIDsSubject = BehaviorSubject<Map<String?, LabelBuilder>>();
final _initialAccountEnabledSubject = BehaviorSubject<bool>();
late final _distributorsMap = <String, String Function(BuildContext)>{
_packageInfo.packageName: (final context) =>
@ -61,7 +67,7 @@ class GlobalOptions {
systemTrayEnabled,
systemTrayHideToTrayWhenMinimized,
rememberLastUsedAccount,
lastAccount,
initialAccount,
];
Future reset() async {
@ -84,7 +90,7 @@ class GlobalOptions {
}
_accountsIDsSubject.add({
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),
);
late final lastAccount = SelectOption<String?>(
late final initialAccount = SelectOption<String?>(
storage: _storage,
key: 'last-account',
label: (final _) => '',
key: 'initial-account',
label: (final context) => AppLocalizations.of(context).globaloptionsaccountsInitialAccount,
defaultValue: BehaviorSubject.seeded(null),
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 {
const AccountTile({
required this.account,
this.trailing,
this.color,
this.trailing,
this.onTap,
this.dense = false,
super.key,
});
final Account account;
final Widget? trailing;
final Color? color;
final Widget? trailing;
final VoidCallback? onTap;
final bool dense;
@override
State<AccountTile> createState() => _AccountTileState();
@ -27,101 +31,71 @@ class _AccountTileState extends State<AccountTile> {
}
@override
Widget build(final BuildContext context) => Row(
children: [
AccountAvatar(
account: widget.account,
requestManager: Provider.of<RequestManager>(context),
),
const SizedBox(
width: 10,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
bloc: _userDetailsBloc,
state: (final bloc) => bloc.userDetails,
builder: (
final context,
final userDetailsData,
final userDetailsError,
final userDetailsLoading,
final _,
) =>
Row(
children: [
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,
),
],
],
),
),
Widget build(final BuildContext context) => ListTile(
onTap: widget.onTap,
dense: widget.dense,
contentPadding: widget.dense ? EdgeInsets.zero : null,
visualDensity: widget.dense
? const VisualDensity(
horizontal: -4,
vertical: -4,
)
: null,
leading: AccountAvatar(
account: widget.account,
requestManager: Provider.of<RequestManager>(context),
),
title: StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
bloc: _userDetailsBloc,
state: (final bloc) => bloc.userDetails,
builder: (
final context,
final userDetailsData,
final userDetailsError,
final userDetailsLoading,
final _,
) =>
Row(
children: [
if (userDetailsData != null) ...[
Text(
widget.account.serverURL,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
userDetailsData.getDisplayName()!,
style: Theme.of(context).textTheme.bodyLarge!.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,
),
],
],
),
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;
T get value {
if (hasValue) {
return stream.value;
}
return defaultValue.value;
}
bool get hasValue {
if (!enabled.value) {
throw OptionDisableException();
}
return stream.value ?? defaultValue.value;
return stream.hasValue;
}
Future reset() async {

Loading…
Cancel
Save