Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(neon_framework): Enable viewing and editing profile properties #2345

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
feat(neon_framework): Enable viewing and editing profile properties
Signed-off-by: provokateurin <kate@provokateurin.de>
provokateurin committed Aug 17, 2024
commit 4c877ac45c58a9fe6715f53b16a95b2ba4d69902
27 changes: 27 additions & 0 deletions packages/neon_framework/lib/l10n/en.arb
Original file line number Diff line number Diff line change
@@ -201,6 +201,33 @@
}
}
},
"accountOptionsCategoryProfile": "Profile",
"accountOptionsProfileDisplayNameLabel": "Full name",
"accountOptionsProfileDisplayNameHint": "Your full name",
"accountOptionsProfileEmailLabel": "Email",
"accountOptionsProfileEmailHint": "Primary email for password reset and notifications",
"accountOptionsProfilePhoneLabel": "Phone number",
"accountOptionsProfilePhoneHint": "Your phone number",
"accountOptionsProfileAddressLabel": "Location",
"accountOptionsProfileAddressHint": "Your city",
"accountOptionsProfileWebsiteLabel": "Website",
"accountOptionsProfileWebsiteHint": "Your website",
"accountOptionsProfileTwitterLabel": "X (formerly Twitter)",
"accountOptionsProfileTwitterHint": "Your X (formerly Twitter) handle",
"accountOptionsProfileFediverseLabel": "Fediverse (e.g. Mastodon)",
"accountOptionsProfileFediverseHint": "Your handle",
"accountOptionsProfileOrganisationLabel": "Organisation",
"accountOptionsProfileOrganisationHint": "Your organisation",
"accountOptionsProfileRoleLabel": "Role",
"accountOptionsProfileRoleHint": "Your role",
"accountOptionsProfileHeadlineLabel": "Headline",
"accountOptionsProfileHeadlineHint": "Your headline",
"accountOptionsProfileBiographyLabel": "About",
"accountOptionsProfileBiographyHint": "Your biography",
"accountOptionsProfileScopePrivate": "Only visible to people matched via phone number integration through Talk on mobile",
"accountOptionsProfileScopeLocal": "Only visible to people on this instance and guests",
"accountOptionsProfileScopeFederated": "Only synchronize to trusted servers",
"accountOptionsProfileScopePublished": "Synchronize to trusted servers and the global and public address book",
"accountOptionsInitialApp": "App to show initially",
"accountOptionsAutomatic": "Automatic",
"licenses": "Licenses",
162 changes: 162 additions & 0 deletions packages/neon_framework/lib/l10n/localizations.dart
Original file line number Diff line number Diff line change
@@ -725,6 +725,168 @@ abstract class NeonLocalizations {
/// **'{used} used of {total} ({relative}%)'**
String accountOptionsQuotaUsedOf(String used, String total, String relative);

/// No description provided for @accountOptionsCategoryProfile.
///
/// In en, this message translates to:
/// **'Profile'**
String get accountOptionsCategoryProfile;

/// No description provided for @accountOptionsProfileDisplayNameLabel.
///
/// In en, this message translates to:
/// **'Full name'**
String get accountOptionsProfileDisplayNameLabel;

/// No description provided for @accountOptionsProfileDisplayNameHint.
///
/// In en, this message translates to:
/// **'Your full name'**
String get accountOptionsProfileDisplayNameHint;

/// No description provided for @accountOptionsProfileEmailLabel.
///
/// In en, this message translates to:
/// **'Email'**
String get accountOptionsProfileEmailLabel;

/// No description provided for @accountOptionsProfileEmailHint.
///
/// In en, this message translates to:
/// **'Primary email for password reset and notifications'**
String get accountOptionsProfileEmailHint;

/// No description provided for @accountOptionsProfilePhoneLabel.
///
/// In en, this message translates to:
/// **'Phone number'**
String get accountOptionsProfilePhoneLabel;

/// No description provided for @accountOptionsProfilePhoneHint.
///
/// In en, this message translates to:
/// **'Your phone number'**
String get accountOptionsProfilePhoneHint;

/// No description provided for @accountOptionsProfileAddressLabel.
///
/// In en, this message translates to:
/// **'Location'**
String get accountOptionsProfileAddressLabel;

/// No description provided for @accountOptionsProfileAddressHint.
///
/// In en, this message translates to:
/// **'Your city'**
String get accountOptionsProfileAddressHint;

/// No description provided for @accountOptionsProfileWebsiteLabel.
///
/// In en, this message translates to:
/// **'Website'**
String get accountOptionsProfileWebsiteLabel;

/// No description provided for @accountOptionsProfileWebsiteHint.
///
/// In en, this message translates to:
/// **'Your website'**
String get accountOptionsProfileWebsiteHint;

/// No description provided for @accountOptionsProfileTwitterLabel.
///
/// In en, this message translates to:
/// **'X (formerly Twitter)'**
String get accountOptionsProfileTwitterLabel;

/// No description provided for @accountOptionsProfileTwitterHint.
///
/// In en, this message translates to:
/// **'Your X (formerly Twitter) handle'**
String get accountOptionsProfileTwitterHint;

/// No description provided for @accountOptionsProfileFediverseLabel.
///
/// In en, this message translates to:
/// **'Fediverse (e.g. Mastodon)'**
String get accountOptionsProfileFediverseLabel;

/// No description provided for @accountOptionsProfileFediverseHint.
///
/// In en, this message translates to:
/// **'Your handle'**
String get accountOptionsProfileFediverseHint;

/// No description provided for @accountOptionsProfileOrganisationLabel.
///
/// In en, this message translates to:
/// **'Organisation'**
String get accountOptionsProfileOrganisationLabel;

/// No description provided for @accountOptionsProfileOrganisationHint.
///
/// In en, this message translates to:
/// **'Your organisation'**
String get accountOptionsProfileOrganisationHint;

/// No description provided for @accountOptionsProfileRoleLabel.
///
/// In en, this message translates to:
/// **'Role'**
String get accountOptionsProfileRoleLabel;

/// No description provided for @accountOptionsProfileRoleHint.
///
/// In en, this message translates to:
/// **'Your role'**
String get accountOptionsProfileRoleHint;

/// No description provided for @accountOptionsProfileHeadlineLabel.
///
/// In en, this message translates to:
/// **'Headline'**
String get accountOptionsProfileHeadlineLabel;

/// No description provided for @accountOptionsProfileHeadlineHint.
///
/// In en, this message translates to:
/// **'Your headline'**
String get accountOptionsProfileHeadlineHint;

/// No description provided for @accountOptionsProfileBiographyLabel.
///
/// In en, this message translates to:
/// **'About'**
String get accountOptionsProfileBiographyLabel;

/// No description provided for @accountOptionsProfileBiographyHint.
///
/// In en, this message translates to:
/// **'Your biography'**
String get accountOptionsProfileBiographyHint;

/// No description provided for @accountOptionsProfileScopePrivate.
///
/// In en, this message translates to:
/// **'Only visible to people matched via phone number integration through Talk on mobile'**
String get accountOptionsProfileScopePrivate;

/// No description provided for @accountOptionsProfileScopeLocal.
///
/// In en, this message translates to:
/// **'Only visible to people on this instance and guests'**
String get accountOptionsProfileScopeLocal;

/// No description provided for @accountOptionsProfileScopeFederated.
///
/// In en, this message translates to:
/// **'Only synchronize to trusted servers'**
String get accountOptionsProfileScopeFederated;

/// No description provided for @accountOptionsProfileScopePublished.
///
/// In en, this message translates to:
/// **'Synchronize to trusted servers and the global and public address book'**
String get accountOptionsProfileScopePublished;

/// No description provided for @accountOptionsInitialApp.
///
/// In en, this message translates to:
83 changes: 83 additions & 0 deletions packages/neon_framework/lib/l10n/localizations_en.dart
Original file line number Diff line number Diff line change
@@ -370,6 +370,89 @@ class NeonLocalizationsEn extends NeonLocalizations {
return '$used used of $total ($relative%)';
}

@override
String get accountOptionsCategoryProfile => 'Profile';

@override
String get accountOptionsProfileDisplayNameLabel => 'Full name';

@override
String get accountOptionsProfileDisplayNameHint => 'Your full name';

@override
String get accountOptionsProfileEmailLabel => 'Email';

@override
String get accountOptionsProfileEmailHint => 'Primary email for password reset and notifications';

@override
String get accountOptionsProfilePhoneLabel => 'Phone number';

@override
String get accountOptionsProfilePhoneHint => 'Your phone number';

@override
String get accountOptionsProfileAddressLabel => 'Location';

@override
String get accountOptionsProfileAddressHint => 'Your city';

@override
String get accountOptionsProfileWebsiteLabel => 'Website';

@override
String get accountOptionsProfileWebsiteHint => 'Your website';

@override
String get accountOptionsProfileTwitterLabel => 'X (formerly Twitter)';

@override
String get accountOptionsProfileTwitterHint => 'Your X (formerly Twitter) handle';

@override
String get accountOptionsProfileFediverseLabel => 'Fediverse (e.g. Mastodon)';

@override
String get accountOptionsProfileFediverseHint => 'Your handle';

@override
String get accountOptionsProfileOrganisationLabel => 'Organisation';

@override
String get accountOptionsProfileOrganisationHint => 'Your organisation';

@override
String get accountOptionsProfileRoleLabel => 'Role';

@override
String get accountOptionsProfileRoleHint => 'Your role';

@override
String get accountOptionsProfileHeadlineLabel => 'Headline';

@override
String get accountOptionsProfileHeadlineHint => 'Your headline';

@override
String get accountOptionsProfileBiographyLabel => 'About';

@override
String get accountOptionsProfileBiographyHint => 'Your biography';

@override
String get accountOptionsProfileScopePrivate =>
'Only visible to people matched via phone number integration through Talk on mobile';

@override
String get accountOptionsProfileScopeLocal => 'Only visible to people on this instance and guests';

@override
String get accountOptionsProfileScopeFederated => 'Only synchronize to trusted servers';

@override
String get accountOptionsProfileScopePublished =>
'Synchronize to trusted servers and the global and public address book';

@override
String get accountOptionsInitialApp => 'App to show initially';

33 changes: 33 additions & 0 deletions packages/neon_framework/lib/src/blocs/user_details.dart
Original file line number Diff line number Diff line change
@@ -21,6 +21,9 @@ abstract class UserDetailsBloc implements InteractiveBloc {

/// Contains the user details.
BehaviorSubject<Result<provisioning_api.UserDetails>> get userDetails;

/// Updates a property of the [userDetails].
void updateProperty(String key, String value);
}

class _UserDetailsBloc extends InteractiveBloc implements UserDetailsBloc {
@@ -54,4 +57,34 @@ class _UserDetailsBloc extends InteractiveBloc implements UserDetailsBloc {
unwrap: (response) => response.body.ocs.data,
);
}

@override
Future<void> updateProperty(String key, String value) async {
await wrapAction(
() async {
userDetails.add(userDetails.valueOrNull?.asLoading() ?? Result.loading());

await account.client.provisioningApi.users.editUser(
userId: account.username,
$body: provisioning_api.UsersEditUserRequestApplicationJson(
(b) => b
..key = key
..value = value,
),
);

var data = userDetails.valueOrNull?.data;
if (data == null) {
return;
}

final raw = data.toJson();
raw[key] = value;
data = provisioning_api.UserDetails.fromJson(raw);

userDetails.add(Result.success(data));
},
refresh: () async {},
);
}
}
152 changes: 93 additions & 59 deletions packages/neon_framework/lib/src/pages/account_settings.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
@@ -14,15 +16,17 @@ import 'package:neon_framework/src/settings/widgets/settings_list.dart';
import 'package:neon_framework/src/theme/dialog.dart';
import 'package:neon_framework/src/utils/account_options.dart';
import 'package:neon_framework/src/widgets/dialog.dart';
import 'package:neon_framework/src/widgets/error.dart';
import 'package:neon_framework/src/widgets/settings_profile_section.dart';
import 'package:neon_framework/utils.dart';
import 'package:neon_framework/widgets.dart';
import 'package:nextcloud/provisioning_api.dart' as provisioning_api;
import 'package:provider/provider.dart';

/// Account settings page.
///
/// Displays settings for an [Account]. Settings are specified as `Option`s.
@internal
class AccountSettingsPage extends StatelessWidget {
class AccountSettingsPage extends StatefulWidget {
/// Creates a new account settings page for the given [account].
const AccountSettingsPage({
required this.account,
@@ -33,22 +37,48 @@ class AccountSettingsPage extends StatelessWidget {
final Account account;

@override
Widget build(BuildContext context) {
final bloc = NeonProvider.of<AccountsBloc>(context);
final options = bloc.getOptionsFor(account);
final userDetailsBloc = bloc.getUserDetailsBlocFor(account);
final name = account.humanReadableID;
State<AccountSettingsPage> createState() => _AccountSettingsPageState();
}

class _AccountSettingsPageState extends State<AccountSettingsPage> {
late final AccountsBloc bloc;
late final AccountOptions options;
late final UserDetailsBloc userDetailsBloc;
late final name = widget.account.humanReadableID;
late final StreamSubscription<Object> errorSubscription;

@override
void initState() {
super.initState();

bloc = NeonProvider.of<AccountsBloc>(context);
options = bloc.getOptionsFor(widget.account);
userDetailsBloc = bloc.getUserDetailsBlocFor(widget.account);

errorSubscription = userDetailsBloc.errors.listen((error) {
NeonError.showSnackbar(context, error);
});
}

@override
void dispose() {
unawaited(errorSubscription.cancel());

super.dispose();
}

@override
Widget build(BuildContext context) {
final appBar = AppBar(
title: Text(name),
title: Text(widget.account.humanReadableID),
actions: [
IconButton(
onPressed: () async {
final decision = await showAdaptiveDialog<AccountDeletion>(
context: context,
builder: (context) => NeonAccountDeletionDialog(
account: account,
capabilitiesBloc: bloc.getCapabilitiesBlocFor(account),
account: widget.account,
capabilitiesBloc: bloc.getCapabilitiesBlocFor(widget.account),
),
);

@@ -57,13 +87,13 @@ class AccountSettingsPage extends StatelessWidget {
break;
case AccountDeletion.remote:
if (context.mounted) {
await launchUrl(NeonProvider.of<Account>(context), '/index.php/settings/user/drop_account');
await launchUrl(widget.account, '/index.php/settings/user/drop_account');
}
case AccountDeletion.local:
final isActive = bloc.activeAccount.valueOrNull == account;
final isActive = bloc.activeAccount.valueOrNull == widget.account;

options.reset();
bloc.removeAccount(account);
bloc.removeAccount(widget.account);

if (!context.mounted) {
return;
@@ -102,22 +132,50 @@ class AccountSettingsPage extends StatelessWidget {
],
);

final body = SettingsList(
categories: [
_buildGeneralSection(context, options),
_buildStorageSection(context, userDetailsBloc),
],
final body = ResultBuilder.behaviorSubject(
subject: userDetailsBloc.userDetails,
builder: (context, userDetails) {
final categories = <Widget>[_buildGeneralSection(context, options)];

if (userDetails.hasError) {
categories.add(
NeonError(
userDetails.error,
type: NeonErrorType.listTile,
onRetry: userDetailsBloc.refresh,
),
);
}
if (userDetails.hasData) {
categories
..add(
_buildStorageSection(
context,
userDetails.requireData,
),
)
..add(
NeonSettingsProfileSection(
userDetails: userDetails.requireData,
onUpdateProperty: userDetailsBloc.updateProperty,
),
);
}

return SettingsList(
categories: categories,
);
},
);

return Scaffold(
resizeToAvoidBottomInset: false,
appBar: appBar,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: NeonDialogTheme.of(context).constraints,
child: Provider<Account>.value(
value: account,
value: widget.account,
child: body,
),
),
@@ -139,49 +197,25 @@ class AccountSettingsPage extends StatelessWidget {

Widget _buildStorageSection(
BuildContext context,
UserDetailsBloc userDetailsBloc,
provisioning_api.UserDetails userDetails,
) {
return SettingsCategory(
title: Text(NeonLocalizations.of(context).accountOptionsCategoryStorageInfo),
tiles: [
ResultBuilder.behaviorSubject(
subject: userDetailsBloc.userDetails,
builder: (context, userDetails) {
if (userDetails.hasError) {
return NeonError(
userDetails.error,
type: NeonErrorType.listTile,
onRetry: userDetailsBloc.refresh,
);
}

double? value;
Widget? subtitle;
if (userDetails.hasData) {
final quotaRelative = userDetails.data?.quota.relative ?? 0;
final quotaTotal = userDetails.data?.quota.total ?? 0;
final quotaUsed = userDetails.data?.quota.used ?? 0;

value = quotaRelative / 100;
subtitle = Text(
NeonLocalizations.of(context).accountOptionsQuotaUsedOf(
filesize(quotaUsed, 1),
filesize(quotaTotal, 1),
quotaRelative.toString(),
),
);
}

return CustomSettingsTile(
title: LinearProgressIndicator(
value: value,
minHeight: isCupertino(context) ? 15 : null,
borderRadius: BorderRadius.circular(isCupertino(context) ? 5 : 3),
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
subtitle: subtitle,
);
},
CustomSettingsTile(
title: LinearProgressIndicator(
value: userDetails.quota.relative / 100,
minHeight: isCupertino(context) ? 15 : null,
borderRadius: BorderRadius.circular(isCupertino(context) ? 5 : 3),
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
subtitle: Text(
NeonLocalizations.of(context).accountOptionsQuotaUsedOf(
filesize(userDetails.quota.used, 1),
filesize(userDetails.quota.total, 1),
userDetails.quota.relative.toString(),
),
),
),
],
);
177 changes: 177 additions & 0 deletions packages/neon_framework/lib/src/widgets/settings_profile_field.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:neon_framework/l10n/localizations.dart';
import 'package:neon_framework/src/settings/widgets/custom_settings_tile.dart';
import 'package:neon_framework/theme.dart';
import 'package:rxdart/rxdart.dart';

/// Input field for profile properties of the user.
class NeonSettingsProfileField extends StatefulWidget {
/// Creates a new [NeonSettingsProfileField].
const NeonSettingsProfileField({
required this.value,
required this.scope,
required this.labelText,
required this.hintText,
required this.onUpdateValue,
required this.onUpdateScope,
this.keyboardType,
super.key,
});

/// Value of the profile property.
final String value;

/// Scope of the profile property.
final String scope;

/// Label for the profile property.
final String labelText;

/// Hint text for the profile property in the value is empty.
final String hintText;

/// Called when the value is updated.
final void Function(String value) onUpdateValue;

/// Called when the scope is updated.
final void Function(String scope) onUpdateScope;

/// Keyboard type used for the input field.
final TextInputType? keyboardType;

@override
State<NeonSettingsProfileField> createState() => _NeonSettingsProfileFieldState();
}

class _NeonSettingsProfileFieldState extends State<NeonSettingsProfileField> {
late final textEditingController = TextEditingController(
text: widget.value,
);
final streamController = StreamController<String>();
late final stream = streamController.stream.asBroadcastStream();
late final StreamSubscription<String> subscription;
late String submittedValue = widget.value;

@override
void initState() {
super.initState();

subscription = stream.debounceTime(const Duration(seconds: 1)).listen((value) {
if (value != submittedValue) {
setState(() {
submittedValue = value;
widget.onUpdateValue(value);
});
}
});
}

@override
void dispose() {
textEditingController.dispose();
unawaited(subscription.cancel());
unawaited(streamController.close());

super.dispose();
}

@override
Widget build(BuildContext context) {
final scopeButton = MenuAnchor(
menuChildren: [
for (final scope in ['v2-private', 'v2-local', 'v2-federated', 'v2-published'])
MenuItemButton(
leadingIcon: buildScopeIcon(scope),
trailingIcon: scope == widget.scope ? const Icon(Icons.check) : null,
onPressed: () {
if (scope != widget.scope) {
widget.onUpdateScope(scope);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Text(getScopeDescription(scope)),
),
),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
padding: const EdgeInsets.all(4),
iconSize: 20,
icon: buildScopeIcon(widget.scope),
tooltip: getScopeDescription(widget.scope),
),
);

return CustomSettingsTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.labelText),
const SizedBox(width: 5),
scopeButton,
],
),
subtitle: TextField(
controller: textEditingController,
decoration: InputDecoration(
hintText: widget.hintText,
suffixIcon: StreamBuilder(
stream: stream,
builder: (context, valueSnapshot) => valueSnapshot.data == submittedValue
? const Icon(
Icons.check,
color: NcColors.success,
)
: const SizedBox(),
),
),
keyboardType: widget.keyboardType,
onChanged: streamController.add,
onSubmitted: (value) {
if (value != submittedValue) {
setState(() {
submittedValue = value;
widget.onUpdateValue(value);
});
}
},
),
);
}

Icon buildScopeIcon(String scope) {
return Icon(
switch (scope) {
'v2-private' => Icons.phone_android,
'v2-local' || 'private' => Icons.lock,
'v2-federated' || 'contacts' => Icons.groups,
'v2-published' || 'public' => Icons.web,
_ => throw UnimplementedError('Unknown scope $scope'), // coverage:ignore-line
},
);
}

String getScopeDescription(String scope) {
final localizations = NeonLocalizations.of(context);

return switch (scope) {
'v2-private' => localizations.accountOptionsProfileScopePrivate,
'v2-local' || 'private' => localizations.accountOptionsProfileScopeLocal,
'v2-federated' || 'contacts' => localizations.accountOptionsProfileScopeFederated,
'v2-published' || 'public' => localizations.accountOptionsProfileScopePublished,
_ => throw UnimplementedError('Unknown scope $scope'), // coverage:ignore-line
};
}
}
161 changes: 161 additions & 0 deletions packages/neon_framework/lib/src/widgets/settings_profile_section.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:neon_framework/l10n/localizations.dart';
import 'package:neon_framework/src/settings/widgets/settings_category.dart';
import 'package:neon_framework/src/utils/password_confirmation.dart';
import 'package:neon_framework/src/widgets/settings_profile_field.dart';
import 'package:nextcloud/provisioning_api.dart' as provisioning_api;

/// A settings section allowing the user to view and edit their profile properties.
class NeonSettingsProfileSection extends StatelessWidget {
/// Creates a new [NeonSettingsProfileSection].
const NeonSettingsProfileSection({
required this.userDetails,
required this.onUpdateProperty,
super.key,
});

/// All detailed properties of the user.
final provisioning_api.UserDetails userDetails;

/// Called when a property is updated.
final void Function(String key, String value) onUpdateProperty;

@override
Widget build(BuildContext context) {
return SettingsCategory(
title: Text(NeonLocalizations.of(context).accountOptionsCategoryProfile),
tiles: _buildFields(context, userDetails).toList(),
);
}

Iterable<Widget> _buildFields(BuildContext context, provisioning_api.UserDetails userDetails) sync* {
final data = userDetails.toJson();
final localizations = NeonLocalizations.of(context);

yield _buildField(
context,
data,
key: 'displayname',
label: localizations.accountOptionsProfileDisplayNameLabel,
hint: localizations.accountOptionsProfileDisplayNameHint,
);

yield _buildField(
context,
data,
key: 'email',
label: localizations.accountOptionsProfileEmailLabel,
hint: localizations.accountOptionsProfileEmailHint,
keyboardType: TextInputType.emailAddress,
);

yield _buildField(
context,
data,
key: 'phone',
label: localizations.accountOptionsProfilePhoneLabel,
hint: localizations.accountOptionsProfilePhoneHint,
keyboardType: TextInputType.phone,
);

yield _buildField(
context,
data,
key: 'address',
label: localizations.accountOptionsProfileAddressLabel,
hint: localizations.accountOptionsProfileAddressHint,
keyboardType: TextInputType.streetAddress,
);

// Language and locale fields are omitted intentionally as these only make sense for the Web UI
// and do not affect the app settings.

yield _buildField(
context,
data,
key: 'website',
label: localizations.accountOptionsProfileWebsiteLabel,
hint: localizations.accountOptionsProfileWebsiteHint,
keyboardType: TextInputType.url,
);

yield _buildField(
context,
data,
key: 'twitter',
label: localizations.accountOptionsProfileTwitterLabel,
hint: localizations.accountOptionsProfileTwitterHint,
);

yield _buildField(
context,
data,
key: 'fediverse',
label: localizations.accountOptionsProfileFediverseLabel,
hint: localizations.accountOptionsProfileFediverseHint,
);

yield _buildField(
context,
data,
key: 'organisation',
label: localizations.accountOptionsProfileOrganisationLabel,
hint: localizations.accountOptionsProfileOrganisationHint,
);

yield _buildField(
context,
data,
key: 'role',
label: localizations.accountOptionsProfileRoleLabel,
hint: localizations.accountOptionsProfileRoleHint,
);

yield _buildField(
context,
data,
key: 'headline',
label: localizations.accountOptionsProfileHeadlineLabel,
hint: localizations.accountOptionsProfileHeadlineHint,
);

yield _buildField(
context,
data,
key: 'biography',
label: localizations.accountOptionsProfileBiographyLabel,
hint: localizations.accountOptionsProfileBiographyHint,
);
}

Widget _buildField(
BuildContext context,
Map<String, dynamic> userDetails, {
required String key,
required String label,
required String hint,
TextInputType? keyboardType,
}) {
final value = userDetails[key] as String;
final scopeKey = '${key}Scope';
final scopeValue = userDetails[scopeKey] as String;

return NeonSettingsProfileField(
value: value,
scope: scopeValue,
labelText: label,
hintText: hint,
onUpdateValue: (value) async {
if (await confirmPassword(context)) {
onUpdateProperty(key, value);
}
},
onUpdateScope: (scope) async {
if (await confirmPassword(context)) {
onUpdateProperty(scopeKey, scope);
}
},
keyboardType: keyboardType,
);
}
}
126 changes: 126 additions & 0 deletions packages/neon_framework/test/settings_profile_field_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:neon_framework/src/widgets/settings_profile_field.dart';
import 'package:neon_framework/testing.dart';

class MockStringCallbackFunction extends Mock {
void call(String value);
}

void main() {
testWidgets('Open an close scope menu', (tester) async {
final callback = MockStringCallbackFunction();

await tester.pumpWidgetWithAccessibility(
TestApp(
child: NeonSettingsProfileField(
value: '',
scope: 'v2-private',
labelText: '',
hintText: '',
onUpdateValue: (_) {},
onUpdateScope: callback.call,
),
),
);

await tester.tap(find.byIcon(Icons.phone_android));
await tester.pumpAndSettle();

expect(find.byIcon(Icons.check), findsOne);
expect(find.byIcon(Icons.phone_android), findsExactly(2));
expect(find.byIcon(Icons.lock), findsOne);
expect(find.byIcon(Icons.groups), findsOne);
expect(find.byIcon(Icons.web), findsOne);

await tester.tap(find.byIcon(Icons.phone_android).first);
await tester.pumpAndSettle();

verifyNever(() => callback(any()));
});

testWidgets('Change scope', (tester) async {
final callback = MockStringCallbackFunction();

await tester.pumpWidgetWithAccessibility(
TestApp(
child: NeonSettingsProfileField(
value: '',
scope: 'v2-private',
labelText: '',
hintText: '',
onUpdateValue: (_) {},
onUpdateScope: callback.call,
),
),
);

await tester.tap(find.byIcon(Icons.phone_android));
await tester.pumpAndSettle();

await tester.runAsync(() async {
await tester.tap(find.byIcon(Icons.web));
await tester.pumpAndSettle();
});

verify(() => callback('v2-published')).called(1);
});

group('Change value', () {
testWidgets('Debounce', (tester) async {
final callback = MockStringCallbackFunction();

await tester.pumpWidgetWithAccessibility(
TestApp(
child: NeonSettingsProfileField(
value: '123',
scope: 'v2-private',
labelText: '',
hintText: '',
onUpdateValue: callback.call,
onUpdateScope: (_) {},
),
),
);
expect(find.byIcon(Icons.check), findsNothing);

await tester.enterText(find.byType(TextField), '456');
await tester.pumpAndSettle();

await TestWidgetsFlutterBinding.instance.delayed(const Duration(seconds: 1));
verify(() => callback('456')).called(1);

await tester.pumpAndSettle();
expect(find.byIcon(Icons.check), findsOne);
});

testWidgets('Submit', (tester) async {
final callback = MockStringCallbackFunction();

await tester.pumpWidgetWithAccessibility(
TestApp(
child: NeonSettingsProfileField(
value: '123',
scope: 'v2-private',
labelText: '',
hintText: '',
onUpdateValue: callback.call,
onUpdateScope: (_) {},
),
),
);
expect(find.byIcon(Icons.check), findsNothing);

await tester.enterText(find.byType(TextField), '456');
await tester.testTextInput.receiveAction(TextInputAction.done);
verify(() => callback('456')).called(1);

await tester.pumpAndSettle();
expect(find.byIcon(Icons.check), findsOne);

await TestWidgetsFlutterBinding.instance.delayed(const Duration(seconds: 1));
verifyNever(() => callback('456'));
});
});
}
134 changes: 134 additions & 0 deletions packages/neon_framework/test/settings_profile_section_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/src/widgets/dialog.dart';
import 'package:neon_framework/src/widgets/settings_profile_field.dart';
import 'package:neon_framework/src/widgets/settings_profile_section.dart';
import 'package:neon_framework/testing.dart';
import 'package:nextcloud/provisioning_api.dart' as provisioning_api;
import 'package:provider/provider.dart';

class MockStringStringCallbackFunction extends Mock {
void call(String key, String value);
}

void main() {
late final provisioning_api.UserDetails userDetails;
late Account account;

setUpAll(() {
userDetails = MockUserDetails();
when(() => userDetails.toJson()).thenReturn({
'displayname': '123',
'displaynameScope': 'v2-private',
'email': '',
'emailScope': 'v2-private',
'phone': '',
'phoneScope': 'v2-private',
'address': '',
'addressScope': 'v2-private',
'website': '',
'websiteScope': 'v2-private',
'twitter': '',
'twitterScope': 'v2-private',
'fediverse': '',
'fediverseScope': 'v2-private',
'organisation': '',
'organisationScope': 'v2-private',
'role': '',
'roleScope': 'v2-private',
'headline': '',
'headlineScope': 'v2-private',
'biography': '',
'biographyScope': 'v2-private',
});

FakeNeonStorage.setup();
});

setUp(() {
account = mockServer({
RegExp(r'/ocs/v2\.php/core/apppassword/confirm'): {
'put': (match, bodyBytes) => http.Response(
json.encode({
'ocs': {
'meta': {'status': '', 'statuscode': 0},
'data': <dynamic, dynamic>{
'lastLogin': 0,
},
},
}),
200,
headers: {'content-type': 'application/json'},
),
},
});
});

testWidgets('Update scope', (tester) async {
final callback = MockStringStringCallbackFunction();

await tester.pumpWidgetWithAccessibility(
TestApp(
providers: [Provider<Account>.value(value: account)],
child: SingleChildScrollView(
child: NeonSettingsProfileSection(
userDetails: userDetails,
onUpdateProperty: callback.call,
),
),
),
);
expect(find.byType(NeonSettingsProfileField), findsExactly(11));

await tester.tap(find.byIcon(Icons.phone_android).first);
await tester.pumpAndSettle();

await tester.runAsync(() async {
await tester.tap(find.byIcon(Icons.web));
await tester.pumpAndSettle();

expect(find.byType(NeonPasswordConfirmationDialog), findsOne);

await tester.enterText(find.byType(TextFormField), 'password');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
});

verify(() => callback('displaynameScope', 'v2-published')).called(1);
});

testWidgets('Update value', (tester) async {
final callback = MockStringStringCallbackFunction();

await tester.pumpWidgetWithAccessibility(
TestApp(
providers: [Provider<Account>.value(value: account)],
child: SingleChildScrollView(
child: NeonSettingsProfileSection(
userDetails: userDetails,
onUpdateProperty: callback.call,
),
),
),
);
expect(find.byType(NeonSettingsProfileField), findsExactly(11));

await tester.enterText(find.byType(TextField).first, '456');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.byType(NeonPasswordConfirmationDialog), findsOne);

await tester.runAsync(() async {
await tester.enterText(find.byType(TextFormField), 'password');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
});

verify(() => callback('displayname', '456')).called(1);
});
}
32 changes: 31 additions & 1 deletion packages/neon_framework/test/user_details_bloc_test.dart
Original file line number Diff line number Diff line change
@@ -7,6 +7,20 @@ import 'package:neon_framework/models.dart';
import 'package:neon_framework/testing.dart';

Account mockUserDetailsAccount() => mockServer({
RegExp(r'/ocs/v2\.php/cloud/users/test'): {
'put': (match, bodyBytes) => Response(
json.encode(
{
'ocs': {
'meta': {'status': '', 'statuscode': 0},
'data': <dynamic, dynamic>{},
},
},
),
200,
headers: {'content-type': 'application/json'},
),
},
RegExp(r'/ocs/v2\.php/cloud/user'): {
'get': (match, request) => Response(
json.encode(
@@ -44,7 +58,7 @@ Account mockUserDetailsAccount() => mockServer({
'role': '',
'subadmin': <dynamic>[],
'twitter': '',
'website': '',
'website': 'https://example.com',
},
},
},
@@ -88,4 +102,20 @@ void main() {
await Future<void>.delayed(const Duration(milliseconds: 1));
await bloc.refresh();
});

test('updateProperty', () async {
expect(
bloc.userDetails.transformResult((e) => e.website),
emitsInOrder([
Result<String>.loading(),
Result.success('https://example.com'),
Result.success('https://example.com').asLoading(),
Result.success('https://example.org'),
]),
);
// The delay is necessary to avoid a race condition with loading twice at the same time
await Future<void>.delayed(const Duration(milliseconds: 1));

bloc.updateProperty('website', 'https://example.org');
});
}