From 708a8b4ba1f5c35fdfb9b71f04b6ff11207fdbad Mon Sep 17 00:00:00 2001 From: Nic Ford Date: Thu, 19 Dec 2024 10:41:38 +0000 Subject: [PATCH] fix: add asynchronous translations [CLERK_SDK #74] --- .../lib/src/clerk_auth/auth_error.dart | 14 +---- .../lib/src/clerk_auth/http_client.dart | 2 +- packages/clerk_auth/lib/src/models/enums.dart | 2 +- .../clerk_auth/lib/src/models/strategy.dart | 2 +- packages/clerk_flutter/README.md | 63 +++++++++++++++++++ .../lib/src/utils/clerk_translator.dart | 60 +++++++++++++----- .../authentication/clerk_sign_out_panel.dart | 2 +- .../authentication/clerk_sign_up_panel.dart | 4 +- .../widgets/control/clerk_auth_provider.dart | 5 +- .../src/widgets/ui/clerk_error_message.dart | 12 ++-- .../lib/src/widgets/ui/strategy_button.dart | 4 +- .../src/widgets/user/clerk_user_profile.dart | 10 +-- 12 files changed, 134 insertions(+), 46 deletions(-) diff --git a/packages/clerk_auth/lib/src/clerk_auth/auth_error.dart b/packages/clerk_auth/lib/src/clerk_auth/auth_error.dart index ef8eefc..55a8cb9 100644 --- a/packages/clerk_auth/lib/src/clerk_auth/auth_error.dart +++ b/packages/clerk_auth/lib/src/clerk_auth/auth_error.dart @@ -2,7 +2,7 @@ /// class AuthError extends Error { /// Construct an [AuthError] - AuthError({this.code, required this.message, this.substitution}); + AuthError({this.code, required this.message, this.messageSubstitutions}); /// An error [code], likely to be an http status code final int? code; @@ -10,14 +10,6 @@ class AuthError extends Error { /// The associated [message] final String message; - /// A possible [substitution] within the message - final String? substitution; - - @override - String toString() { - if (substitution case String substitution) { - return message.replaceAll('###', substitution); - } - return message; - } + /// A possible set of substitutions within the message + final List? messageSubstitutions; } diff --git a/packages/clerk_auth/lib/src/clerk_auth/http_client.dart b/packages/clerk_auth/lib/src/clerk_auth/http_client.dart index bedca74..3056b1d 100644 --- a/packages/clerk_auth/lib/src/clerk_auth/http_client.dart +++ b/packages/clerk_auth/lib/src/clerk_auth/http_client.dart @@ -109,7 +109,7 @@ class DefaultHttpClient implements HttpClient { String ext when _imageExts.contains(ext) => MediaType('image', ext), String ext => throw AuthError( message: 'Unknown media type for upload: ###', - substitution: ext, + messageSubstitutions: [ext], ), }; } diff --git a/packages/clerk_auth/lib/src/models/enums.dart b/packages/clerk_auth/lib/src/models/enums.dart index 04fb046..077b6a8 100644 --- a/packages/clerk_auth/lib/src/models/enums.dart +++ b/packages/clerk_auth/lib/src/models/enums.dart @@ -143,7 +143,7 @@ enum Stage { Status.needsSecondFactor => second, _ => throw AuthError( message: 'No Stage for ###', - substitution: status.toString(), + messageSubstitutions: [status.toString()], ), }; } diff --git a/packages/clerk_auth/lib/src/models/strategy.dart b/packages/clerk_auth/lib/src/models/strategy.dart index 8ad8194..6c6409d 100644 --- a/packages/clerk_auth/lib/src/models/strategy.dart +++ b/packages/clerk_auth/lib/src/models/strategy.dart @@ -198,7 +198,7 @@ class Strategy { Field.emailAddress => Strategy.emailCode, _ => throw AuthError( message: 'No way to verify ###', - substitution: field.name, + messageSubstitutions: [field.name], ), }; } diff --git a/packages/clerk_flutter/README.md b/packages/clerk_flutter/README.md index b11fcdb..59c5f8a 100644 --- a/packages/clerk_flutter/README.md +++ b/packages/clerk_flutter/README.md @@ -71,6 +71,69 @@ class _ExampleAppState extends State { } ``` +## Translation + +The default language in use for alert and UI messaging within the Clerk Flutter SDK is English. For other languages, +you will need to override the `ClerkTranslator` passed in to the `ClerkAuth` or `ClerkAuthProvider` widgets via the +optional `translator` parameter. Translations will need to be provided for the following (hopefully self-explanatory) +words and phrases: +```text +Add account +Add email address +Add phone number +Already have an account? +Authenticator app +Backup code +Cancel +Click on the link that‘s been sent to ### and then check back here +Continue +Don’t have an account? +Email address +Email address '###' is invalid +Email address verification +Enter the code sent to ### +Enter the code sent to you +First name +Last name +Manage account +OK +Optional +or +Passkey +Password +Password and password confirmation must match +Password confirmation +Phone number +Phone number '###' is invalid +Phone number verification +Phone numbers +PRIMARY +Profile +Profile details +Sign in +Sign in by entering a code sent to you by email +Sign in by entering a code sent to you by text message +Sign in to ### +Sign out +Sign out of all accounts +Sign up +Sign up to ### +Type '###' invalid +UNVERIFIED +Username +Verify your email address +Verify your phone number +Web3 wallet +Welcome back! Please sign in to continue +Welcome! Please fill in the details to get started +``` + + +### Asynchronous translation +Some translations cannot be known ahead of time e.g. arbitrary error messages from the Clerk server. An +asynchronous translation method `translateAsync` is provided to allow for translation generation on the fly in this +instance. + ## License This SDK is licensed under the MIT license found in the [LICENSE](./LICENSE) file. diff --git a/packages/clerk_flutter/lib/src/utils/clerk_translator.dart b/packages/clerk_flutter/lib/src/utils/clerk_translator.dart index ef4e566..117bdbe 100644 --- a/packages/clerk_flutter/lib/src/utils/clerk_translator.dart +++ b/packages/clerk_flutter/lib/src/utils/clerk_translator.dart @@ -5,26 +5,29 @@ abstract class ClerkTranslator { const ClerkTranslator(); /// the character sequence to replace with [substitution] during - /// a call to [translate] - final substitutionKey = '###'; + /// a call to [translate]. By default this is `###` for the first item + /// (index 0) and `##` for subsequent indices (index 1 or more) + /// + String substitutionKey([int idx = 0]) => idx < 1 ? '###' : '#${idx + 1}#'; /// Translate a [phrase]] into a different language. Identity function by default /// for English /// /// A [substitution] or multiple [substitutions] for keys inside the [phrase]: /// - /// if a single [substitution] is present, replace the first instance of - /// [substitutionKey] (default `###`) with it + /// If a single [substitution] is present, replace the first instance of + /// `substitutionKey()` with it /// - /// if multiple [substitutions] are present, replace the first found character sequences `##` - /// with each substitution where [n] is the 1-based index into the array. + /// If multiple [substitutions] are present, use each substitution to + /// replace the first found appropriate character sequence: + /// `substitutionKey(index)` /// - /// This allows word order to vary across different languages without having to manipulate the - /// [substitutions] array, viz: + /// This allows word order to vary across different languages without having + /// to manipulate the [substitutions] array, viz: /// - /// `translate('#1# bites #2#', substitutions: ['man', 'dog'])` + /// `translate('### bites #2#', substitutions: ['man', 'dog'])` /// vs - /// `translate('#2# is bitten by #1#', substitutions: ['man', 'dog'])` + /// `translate('#2# is bitten by ###', substitutions: ['man', 'dog'])` /// String translate( String phrase, { @@ -32,6 +35,15 @@ abstract class ClerkTranslator { List substitutions = const [], }); + /// An asynchronous version of [translate], for situations in which the + /// translation might not immediately be to hand. Parameters as above. + /// + Future translateAsync( + String phrase, { + String? substitution, + List? substitutions, + }); + /// A method that takes a list of [items] e.g. \['first', 'second', 'third'\] /// and returns a textual representation of its contents as alternatives /// e.g. "first, second or third" @@ -55,19 +67,37 @@ class DefaultClerkTranslator extends ClerkTranslator { const DefaultClerkTranslator(); @override - String translate(String phrase, - {String? substitution, List substitutions = const []}) { + String translate( + String phrase, { + String? substitution, + List? substitutions, + }) { if (substitution case String sub) { - return phrase.replaceFirst(substitutionKey, sub); + return phrase.replaceFirst(substitutionKey(), sub); } - for (int i = 0; i < substitutions.length; ++i) { - phrase = phrase.replaceFirst('#${i + 1}#', substitutions[i]); + if (substitutions case List substitutions) { + for (int i = 0; i < substitutions.length; ++i) { + phrase = phrase.replaceFirst(substitutionKey(i), substitutions[i]); + } } return phrase; } + @override + Future translateAsync( + String phrase, { + String? substitution, + List? substitutions, + }) async { + return translate( + phrase, + substitution: substitution, + substitutions: substitutions, + ); + } + @override String alternatives(List items, {String connector = 'or', String? prefix}) { diff --git a/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_out_panel.dart b/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_out_panel.dart index 0d2ca11..ebbb0e5 100644 --- a/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_out_panel.dart +++ b/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_out_panel.dart @@ -15,7 +15,7 @@ class ClerkSignOutPanel extends StatelessWidget { padding: horizontalPadding16, child: ClerkMaterialButton( onPressed: () => auth.signOut(), - label: Text(auth.translator.translate('Sign Out')), + label: Text(auth.translator.translate('Sign out')), ), ); } diff --git a/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_up_panel.dart b/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_up_panel.dart index e58e291..7350cb8 100644 --- a/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_up_panel.dart +++ b/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_up_panel.dart @@ -74,7 +74,7 @@ class _ClerkSignUpPanelState extends State { key: const Key('phone_code'), title: translator.translate('Verify your phone number'), subtitle: translator.translate( - 'Enter code sent to ###', + 'Enter the code sent to ###', substitution: _values[clerk.UserAttribute.phoneNumber], ), onSubmit: (code) async { @@ -96,7 +96,7 @@ class _ClerkSignUpPanelState extends State { key: const Key('email_code'), title: translator.translate('Verify your email address'), subtitle: translator.translate( - 'Enter code sent to ###', + 'Enter the code sent to ###', substitution: _values[clerk.UserAttribute.emailAddress], ), onSubmit: (code) async { diff --git a/packages/clerk_flutter/lib/src/widgets/control/clerk_auth_provider.dart b/packages/clerk_flutter/lib/src/widgets/control/clerk_auth_provider.dart index 08d4e1d..440ab56 100644 --- a/packages/clerk_flutter/lib/src/widgets/control/clerk_auth_provider.dart +++ b/packages/clerk_flutter/lib/src/widgets/control/clerk_auth_provider.dart @@ -149,8 +149,9 @@ class ClerkAuthProvider extends clerk.Auth with ChangeNotifier { /// but may still not be acceptable to the back end String? checkPassword(String? password, String? confirmation) { if (password != confirmation) { - return translator - .translate('Password and password confirmation must match'); + return translator.translate( + 'Password and password confirmation must match', + ); } if (password case String password when password.isNotEmpty) { diff --git a/packages/clerk_flutter/lib/src/widgets/ui/clerk_error_message.dart b/packages/clerk_flutter/lib/src/widgets/ui/clerk_error_message.dart index be5b986..ba20315 100644 --- a/packages/clerk_flutter/lib/src/widgets/ui/clerk_error_message.dart +++ b/packages/clerk_flutter/lib/src/widgets/ui/clerk_error_message.dart @@ -13,9 +13,12 @@ class ClerkErrorMessage extends StatelessWidget { const ClerkErrorMessage({super.key}); Future _showError(BuildContext context, clerk.AuthError error) async { - await null; + final translator = ClerkAuth.translatorOf(context); + final message = await translator.translateAsync( + error.message, + substitutions: error.messageSubstitutions, + ); if (context.mounted) { - final translator = ClerkAuth.translatorOf(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( shape: const RoundedRectangleBorder( @@ -25,10 +28,7 @@ class ClerkErrorMessage extends StatelessWidget { ), ), content: Text( - translator.translate( - error.message, - substitution: error.substitution, - ), + message, style: ClerkTextStyle.subtitle.copyWith(color: ClerkColors.white), ), ), diff --git a/packages/clerk_flutter/lib/src/widgets/ui/strategy_button.dart b/packages/clerk_flutter/lib/src/widgets/ui/strategy_button.dart index 44a5391..da49492 100644 --- a/packages/clerk_flutter/lib/src/widgets/ui/strategy_button.dart +++ b/packages/clerk_flutter/lib/src/widgets/ui/strategy_button.dart @@ -54,9 +54,9 @@ class StrategyButton extends StatelessWidget { child: MaterialButton( onPressed: onClick, elevation: 2.0, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: borderRadius4, - side: const BorderSide(color: ClerkColors.dawnPink), + side: BorderSide(color: ClerkColors.dawnPink), ), child: Padding( padding: verticalPadding4, diff --git a/packages/clerk_flutter/lib/src/widgets/user/clerk_user_profile.dart b/packages/clerk_flutter/lib/src/widgets/user/clerk_user_profile.dart index 1bab74b..1448ad2 100644 --- a/packages/clerk_flutter/lib/src/widgets/user/clerk_user_profile.dart +++ b/packages/clerk_flutter/lib/src/widgets/user/clerk_user_profile.dart @@ -27,7 +27,7 @@ class ClerkUserProfile extends StatelessWidget { default: throw clerk.AuthError( message: "Type '###' invalid", - substitution: type.name, + messageSubstitutions: [type.name], ); } } @@ -92,7 +92,7 @@ class ClerkUserProfile extends StatelessWidget { ), _ => throw clerk.AuthError( message: "Type '###' invalid", - substitution: type.name, + messageSubstitutions: [type.name], ), }, ); @@ -102,11 +102,13 @@ class ClerkUserProfile extends StatelessWidget { if (submitted) { if (_validate(identifier, type)) { await auth.addIdentifyingData(identifier, type); - if (context.mounted) _verifyIdentifyingData(context, auth, identifier); + if (context.mounted) { + await _verifyIdentifyingData(context, auth, identifier); + } } else { throw clerk.AuthError( message: "$title '###' is invalid", - substitution: identifier, + messageSubstitutions: [identifier], ); } }