From 27568e1310c09a545f7e418c258f7115add5becb Mon Sep 17 00:00:00 2001 From: provokateurin Date: Sun, 28 Jul 2024 16:21:25 +0200 Subject: [PATCH 1/3] feat(neon_framework): Enable user password confirmation Signed-off-by: provokateurin --- .cspell/dart_flutter.txt | 1 + packages/neon_framework/lib/l10n/en.arb | 5 +- .../lib/l10n/localizations.dart | 18 +++ .../lib/l10n/localizations_en.dart | 9 ++ .../lib/src/utils/password_confirmation.dart | 36 ++++++ .../lib/src/widgets/dialog.dart | 102 ++++++++++++++++ packages/neon_framework/lib/widgets.dart | 7 +- packages/neon_framework/test/dialog_test.dart | 84 ++++++++++++++ .../test/password_confirmation_test.dart | 109 ++++++++++++++++++ 9 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 packages/neon_framework/lib/src/utils/password_confirmation.dart create mode 100644 packages/neon_framework/test/password_confirmation_test.dart diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index caaec313796..544e49b6364 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -19,3 +19,4 @@ unfocus writeln xmark arrowshape +autocorrect diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index 4cdc22f24f7..7d8af298bfc 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -78,6 +78,8 @@ } } }, + "errorUserPasswordConfirmationRequired": "You need to confirm your user password", + "errorWrongUserPassword": "Wrong user password", "errorDialog": "An error has occurred", "actionYes": "Yes", "actionNo": "No", @@ -278,5 +280,6 @@ "userStatusClearAtThisWeek": "This week", "userStatusActionClear": "Clear status", "userStatusStatusMessage": "Status message", - "userStatusOnlineStatus": "Online status" + "userStatusOnlineStatus": "Online status", + "passwordConfirmationUserPassword": "Your user password" } diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index e6d35f53533..c896bca47f0 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -275,6 +275,18 @@ abstract class NeonLocalizations { /// **'Route not found: {route}'** String errorRouteNotFound(String route); + /// No description provided for @errorUserPasswordConfirmationRequired. + /// + /// In en, this message translates to: + /// **'You need to confirm your user password'** + String get errorUserPasswordConfirmationRequired; + + /// No description provided for @errorWrongUserPassword. + /// + /// In en, this message translates to: + /// **'Wrong user password'** + String get errorWrongUserPassword; + /// No description provided for @errorDialog. /// /// In en, this message translates to: @@ -868,6 +880,12 @@ abstract class NeonLocalizations { /// In en, this message translates to: /// **'Online status'** String get userStatusOnlineStatus; + + /// No description provided for @passwordConfirmationUserPassword. + /// + /// In en, this message translates to: + /// **'Your user password'** + String get passwordConfirmationUserPassword; } class _NeonLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index 095105424c5..aaf27a653b9 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -130,6 +130,12 @@ class NeonLocalizationsEn extends NeonLocalizations { return 'Route not found: $route'; } + @override + String get errorUserPasswordConfirmationRequired => 'You need to confirm your user password'; + + @override + String get errorWrongUserPassword => 'Wrong user password'; + @override String get errorDialog => 'An error has occurred'; @@ -505,4 +511,7 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get userStatusOnlineStatus => 'Online status'; + + @override + String get passwordConfirmationUserPassword => 'Your user password'; } diff --git a/packages/neon_framework/lib/src/utils/password_confirmation.dart b/packages/neon_framework/lib/src/utils/password_confirmation.dart new file mode 100644 index 00000000000..c67a9aff31f --- /dev/null +++ b/packages/neon_framework/lib/src/utils/password_confirmation.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/utils.dart'; + +/// For testing using `MockAccount` is just fine because each of them will have a different hashCode and will not interfere with existing state. +Map _lastPasswordConfirmations = {}; + +/// Confirms the user password if necessary. +/// +/// Returns `true` if not necessary or successful. +/// Returns `false` if the user aborted or it was unsuccessful. +Future confirmPassword( + BuildContext context, { + @visibleForTesting DateTime? now, +}) async { + final account = NeonProvider.of(context); + final lastConfirmation = _lastPasswordConfirmations[account]; + if (lastConfirmation != null && (now ?? DateTime.now()).difference(lastConfirmation) < const Duration(minutes: 30)) { + return true; + } + + final result = await showAdaptiveDialog( + context: context, + builder: (context) => NeonPasswordConfirmationDialog( + account: account, + ), + ); + + if (result == null || !result) { + return false; + } + + _lastPasswordConfirmations[account] = now ?? DateTime.now(); + return true; +} diff --git a/packages/neon_framework/lib/src/widgets/dialog.dart b/packages/neon_framework/lib/src/widgets/dialog.dart index fc0a364b301..3d812087d1a 100644 --- a/packages/neon_framework/lib/src/widgets/dialog.dart +++ b/packages/neon_framework/lib/src/widgets/dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:built_collection/built_collection.dart'; +import 'package:dynamite_runtime/http_client.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -1133,3 +1134,104 @@ class _NeonUserStatusDialogState extends State { ); } } + +@internal +class NeonPasswordConfirmationDialog extends StatefulWidget { + const NeonPasswordConfirmationDialog({ + required this.account, + super.key, + }); + + final Account account; + + @override + State createState() => _NeonPasswordConfirmationDialogState(); +} + +class _NeonPasswordConfirmationDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + final focusNode = FocusNode(); + bool wrongPassword = false; + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + Future submit() async { + setState(() { + wrongPassword = false; + }); + + if (formKey.currentState!.validate()) { + try { + await widget.account.client.core.appPassword.confirmUserPassword( + $body: core.AppPasswordConfirmUserPasswordRequestApplicationJson( + (b) => b.password = controller.text, + ), + ); + + if (mounted) { + Navigator.of(context).pop(true); + } + } on Exception catch (error) { + // Do not log here as error messages could contain the user password + + if (error case DynamiteStatusCodeException(statusCode: 403)) { + setState(() { + wrongPassword = true; + }); + + controller.clear(); + focusNode.requestFocus(); + + return; + } + + if (mounted) { + NeonError.showSnackbar(context, error); + } + } + } + } + + @override + Widget build(BuildContext context) { + return NeonDialog( + title: Text(NeonLocalizations.of(context).errorUserPasswordConfirmationRequired), + icon: const Icon(Icons.password), + content: Form( + key: formKey, + child: TextFormField( + obscureText: true, + enableSuggestions: false, + autocorrect: false, + autofocus: true, + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.continueAction, + decoration: InputDecoration( + hintText: NeonLocalizations.of(context).passwordConfirmationUserPassword, + errorText: wrongPassword ? NeonLocalizations.of(context).errorWrongUserPassword : null, + suffixIconConstraints: BoxConstraints.tight(const Size(32, 24)), + ), + validator: (input) => validateNotEmpty(context, input), + onFieldSubmitted: (_) async { + await submit(); + }, + ), + ), + actions: [ + NeonDialogAction( + onPressed: submit, + isDefaultAction: true, + child: Text(NeonLocalizations.of(context).actionContinue), + ), + ], + ); + } +} diff --git a/packages/neon_framework/lib/widgets.dart b/packages/neon_framework/lib/widgets.dart index 7813ed5e50d..ebf37c33c21 100644 --- a/packages/neon_framework/lib/widgets.dart +++ b/packages/neon_framework/lib/widgets.dart @@ -1,7 +1,12 @@ export 'package:neon_framework/src/widgets/autocomplete.dart'; export 'package:neon_framework/src/widgets/custom_background.dart'; export 'package:neon_framework/src/widgets/dialog.dart' - hide AccountDeletion, NeonAccountDeletionDialog, NeonAccountSelectionDialog, NeonUnifiedPushDialog; + hide + AccountDeletion, + NeonAccountDeletionDialog, + NeonAccountSelectionDialog, + NeonPasswordConfirmationDialog, + NeonUnifiedPushDialog; export 'package:neon_framework/src/widgets/error.dart'; export 'package:neon_framework/src/widgets/image.dart' hide NeonImage; export 'package:neon_framework/src/widgets/linear_progress_indicator.dart'; diff --git a/packages/neon_framework/test/dialog_test.dart b/packages/neon_framework/test/dialog_test.dart index eaf704ce723..cbfabe8bdf3 100644 --- a/packages/neon_framework/test/dialog_test.dart +++ b/packages/neon_framework/test/dialog_test.dart @@ -1,9 +1,13 @@ +import 'dart:convert'; + import 'package:built_collection/built_collection.dart'; 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/blocs.dart'; import 'package:neon_framework/l10n/localizations_en.dart'; +import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/widgets/dialog.dart'; import 'package:neon_framework/testing.dart'; import 'package:neon_framework/utils.dart'; @@ -15,6 +19,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/timezone.dart' as tz; void main() { + setUpAll(() { + registerFallbackValue(MaterialPageRoute(builder: (_) => const SizedBox()) as Route); + + FakeNeonStorage.setup(); + }); + group('dialog', () { group('NeonConfirmationDialog', () { testWidgets('NeonConfirmationDialog widget', (tester) async { @@ -526,4 +536,78 @@ void main() { }); }); }); + + group('NeonPasswordConfirmationDialog', () { + late final Account account; + + setUpAll(() { + account = mockServer({ + RegExp(r'/ocs/v2\.php/core/apppassword/confirm'): { + 'put': (match, request) { + final data = json.decode(request.body) as Map; + final password = data['password'] as String; + + return http.Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + 'lastLogin': 0, + }, + }, + }), + password == 'correct' ? 200 : 403, + headers: {'content-type': 'application/json'}, + ); + }, + }, + }); + }); + + testWidgets('Empty password', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + child: NeonPasswordConfirmationDialog( + account: account, + ), + ), + ); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.text(NeonLocalizationsEn().errorEmptyField), findsOne); + }); + + testWidgets('Wrong password', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + child: NeonPasswordConfirmationDialog( + account: account, + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'wrong'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.text(NeonLocalizationsEn().errorWrongUserPassword), findsOne); + }); + + testWidgets('Correct password', (tester) async { + final navigatorObserver = MockNavigatorObserver(); + await tester.pumpWidgetWithAccessibility( + TestApp( + navigatorObserver: navigatorObserver, + child: NeonPasswordConfirmationDialog( + account: account, + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'correct'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + verify(() => navigatorObserver.didPop(any(), any())).called(1); + }); + }); } diff --git a/packages/neon_framework/test/password_confirmation_test.dart b/packages/neon_framework/test/password_confirmation_test.dart new file mode 100644 index 00000000000..bceae965508 --- /dev/null +++ b/packages/neon_framework/test/password_confirmation_test.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/utils/password_confirmation.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/testing.dart'; +import 'package:provider/provider.dart'; + +void main() { + final now = DateTime.timestamp(); + late Account account; + + 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'}, + ), + }, + }); + + FakeNeonStorage.setup(); + }); + + testWidgets('Success', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + providers: [ + Provider.value(value: account), + ], + child: const SizedBox(), + ), + ); + final context = tester.element(find.byType(SizedBox)); + + var future = confirmPassword( + context, + now: now.subtract(const Duration(minutes: 29)), + ); + 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(); + }); + + expect(await future, true); + + expect( + await confirmPassword( + context, + now: now, + ), + true, + ); + + future = confirmPassword( + context, + now: now.add(const Duration(minutes: 1)), + ); + 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(); + }); + + expect(await future, true); + }); + + testWidgets('Cancel', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + providers: [ + Provider.value(value: account), + ], + child: const SizedBox(), + ), + ); + final context = tester.element(find.byType(SizedBox)); + + final future = confirmPassword( + context, + now: now.subtract(const Duration(minutes: 29)), + ); + await tester.pumpAndSettle(); + expect(find.byType(NeonPasswordConfirmationDialog), findsOne); + + Navigator.of(context).pop(); + + expect(await future, false); + }); +} From c080ca3607678de65b61100b9c1e5815e2066e7a Mon Sep 17 00:00:00 2001 From: provokateurin Date: Fri, 10 May 2024 18:33:16 +0200 Subject: [PATCH 2/3] refactor(neon_framework): Separate account settings sections Signed-off-by: provokateurin --- .../lib/src/pages/account_settings.dart | 117 ++++++++++-------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/packages/neon_framework/lib/src/pages/account_settings.dart b/packages/neon_framework/lib/src/pages/account_settings.dart index e4397acb0c0..be4d4e807c9 100644 --- a/packages/neon_framework/lib/src/pages/account_settings.dart +++ b/packages/neon_framework/lib/src/pages/account_settings.dart @@ -5,12 +5,14 @@ import 'package:meta/meta.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/bloc/result.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; +import 'package:neon_framework/src/blocs/user_details.dart'; import 'package:neon_framework/src/router.dart'; import 'package:neon_framework/src/settings/widgets/custom_settings_tile.dart'; import 'package:neon_framework/src/settings/widgets/option_settings_tile.dart'; import 'package:neon_framework/src/settings/widgets/settings_category.dart'; 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/utils.dart'; @@ -102,58 +104,8 @@ class AccountSettingsPage extends StatelessWidget { final body = SettingsList( categories: [ - SettingsCategory( - title: Text(NeonLocalizations.of(context).accountOptionsCategoryStorageInfo), - tiles: [ - ResultBuilder.behaviorSubject( - subject: userDetailsBloc.userDetails, - builder: (context, userDetails) { - if (userDetails.hasError) { - return NeonError( - userDetails.error ?? 'Something went wrong', - 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, - ); - }, - ), - ], - ), - SettingsCategory( - title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), - tiles: [ - SelectSettingsTile( - option: options.initialApp, - ), - ], - ), + _buildGeneralSection(context, options), + _buildStorageSection(context, userDetailsBloc), ], ); @@ -173,4 +125,65 @@ class AccountSettingsPage extends StatelessWidget { ), ); } + + Widget _buildGeneralSection(BuildContext context, AccountOptions options) { + return SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + SelectSettingsTile( + option: options.initialApp, + ), + ], + ); + } + + Widget _buildStorageSection( + BuildContext context, + UserDetailsBloc userDetailsBloc, + ) { + 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, + ); + }, + ), + ], + ); + } } From 4c877ac45c58a9fe6715f53b16a95b2ba4d69902 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 29 Jul 2024 09:31:59 +0200 Subject: [PATCH 3/3] feat(neon_framework): Enable viewing and editing profile properties Signed-off-by: provokateurin --- packages/neon_framework/lib/l10n/en.arb | 27 +++ .../lib/l10n/localizations.dart | 162 ++++++++++++++++ .../lib/l10n/localizations_en.dart | 83 ++++++++ .../lib/src/blocs/user_details.dart | 33 ++++ .../lib/src/pages/account_settings.dart | 152 +++++++++------ .../src/widgets/settings_profile_field.dart | 177 ++++++++++++++++++ .../src/widgets/settings_profile_section.dart | 161 ++++++++++++++++ .../test/settings_profile_field_test.dart | 126 +++++++++++++ .../test/settings_profile_section_test.dart | 134 +++++++++++++ .../test/user_details_bloc_test.dart | 32 +++- 10 files changed, 1027 insertions(+), 60 deletions(-) create mode 100644 packages/neon_framework/lib/src/widgets/settings_profile_field.dart create mode 100644 packages/neon_framework/lib/src/widgets/settings_profile_section.dart create mode 100644 packages/neon_framework/test/settings_profile_field_test.dart create mode 100644 packages/neon_framework/test/settings_profile_section_test.dart 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 2d61daf2b3d..1c5ce03e09f 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 be4d4e807c9..7ac368590e9 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: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(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,13 +87,13 @@ class AccountSettingsPage extends StatelessWidget { break; case AccountDeletion.remote: if (context.mounted) { - await launchUrl(NeonProvider.of(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 = [_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.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(), + ), + ), ), ], ); 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 da2b2c98f57..b4cfa7de8bf 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, request) => 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'); + }); }