diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index 7d8af298bfc..b429b1f7188 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -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", diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index c896bca47f0..fe24bd1a0d8 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -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: diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index aaf27a653b9..c8d2aaab991 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -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'; diff --git a/packages/neon_framework/lib/src/blocs/user_details.dart b/packages/neon_framework/lib/src/blocs/user_details.dart index 2b0ee8c61ec..b72ec27cffe 100644 --- a/packages/neon_framework/lib/src/blocs/user_details.dart +++ b/packages/neon_framework/lib/src/blocs/user_details.dart @@ -21,6 +21,9 @@ abstract class UserDetailsBloc implements InteractiveBloc { /// Contains the user details. BehaviorSubject> 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 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 {}, + ); + } } diff --git a/packages/neon_framework/lib/src/pages/account_settings.dart b/packages/neon_framework/lib/src/pages/account_settings.dart index bc16425e3d1..7fa458fcb91 100644 --- a/packages/neon_framework/lib/src/pages/account_settings.dart +++ b/packages/neon_framework/lib/src/pages/account_settings.dart @@ -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:url_launcher/url_launcher.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(context); - final options = bloc.getOptionsFor(account); - final userDetailsBloc = bloc.getUserDetailsBlocFor(account); - final name = account.humanReadableID; + State createState() => _AccountSettingsPageState(); +} + +class _AccountSettingsPageState extends State { + late final AccountsBloc bloc; + late final AccountOptions options; + late final UserDetailsBloc userDetailsBloc; + late final name = widget.account.humanReadableID; + late final StreamSubscription errorSubscription; + @override + void initState() { + super.initState(); + + bloc = NeonProvider.of(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( context: context, builder: (context) => NeonAccountDeletionDialog( - account: account, - capabilitiesBloc: bloc.getCapabilitiesBlocFor(account), + account: widget.account, + capabilitiesBloc: bloc.getCapabilitiesBlocFor(widget.account), ), ); @@ -57,15 +87,15 @@ class AccountSettingsPage extends StatelessWidget { break; case AccountDeletion.remote: await launchUrl( - account.serverURL.replace( - path: '${account.serverURL.path}/index.php/settings/user/drop_account', + widget.account.serverURL.replace( + path: '${widget.account.serverURL.path}/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; @@ -104,15 +134,43 @@ 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 = [_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( @@ -138,49 +196,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(), + ), + ), ), ], ); diff --git a/packages/neon_framework/lib/src/widgets/settings_profile_field.dart b/packages/neon_framework/lib/src/widgets/settings_profile_field.dart new file mode 100644 index 00000000000..c649884e325 --- /dev/null +++ b/packages/neon_framework/lib/src/widgets/settings_profile_field.dart @@ -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 createState() => _NeonSettingsProfileFieldState(); +} + +class _NeonSettingsProfileFieldState extends State { + late final textEditingController = TextEditingController( + text: widget.value, + ); + final streamController = StreamController(); + late final stream = streamController.stream.asBroadcastStream(); + late final StreamSubscription 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 + }; + } +} diff --git a/packages/neon_framework/lib/src/widgets/settings_profile_section.dart b/packages/neon_framework/lib/src/widgets/settings_profile_section.dart new file mode 100644 index 00000000000..14995526784 --- /dev/null +++ b/packages/neon_framework/lib/src/widgets/settings_profile_section.dart @@ -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 _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 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, + ); + } +} diff --git a/packages/neon_framework/test/settings_profile_field_test.dart b/packages/neon_framework/test/settings_profile_field_test.dart new file mode 100644 index 00000000000..ee61d4b7ad5 --- /dev/null +++ b/packages/neon_framework/test/settings_profile_field_test.dart @@ -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')); + }); + }); +} diff --git a/packages/neon_framework/test/settings_profile_section_test.dart b/packages/neon_framework/test/settings_profile_section_test.dart new file mode 100644 index 00000000000..b55d82d169e --- /dev/null +++ b/packages/neon_framework/test/settings_profile_section_test.dart @@ -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': { + 'lastLogin': 0, + }, + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + }, + }); + }); + + testWidgets('Update scope', (tester) async { + final callback = MockStringStringCallbackFunction(); + + await tester.pumpWidgetWithAccessibility( + TestApp( + providers: [Provider.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.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); + }); +} diff --git a/packages/neon_framework/test/user_details_bloc_test.dart b/packages/neon_framework/test/user_details_bloc_test.dart index 2649140616d..2b33616f4f1 100644 --- a/packages/neon_framework/test/user_details_bloc_test.dart +++ b/packages/neon_framework/test/user_details_bloc_test.dart @@ -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': {}, + }, + }, + ), + 200, + headers: {'content-type': 'application/json'}, + ), + }, RegExp(r'/ocs/v2\.php/cloud/user'): { 'get': (match, bodyBytes) => Response( json.encode( @@ -44,7 +58,7 @@ Account mockUserDetailsAccount() => mockServer({ 'role': '', 'subadmin': [], 'twitter': '', - 'website': '', + 'website': 'https://example.com', }, }, }, @@ -88,4 +102,20 @@ void main() { await Future.delayed(const Duration(milliseconds: 1)); await bloc.refresh(); }); + + test('updateProperty', () async { + expect( + bloc.userDetails.transformResult((e) => e.website), + emitsInOrder([ + Result.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.delayed(const Duration(milliseconds: 1)); + + bloc.updateProperty('website', 'https://example.org'); + }); }