From f28c90eb879467ce3ddc41cc4148b7320321e486 Mon Sep 17 00:00:00 2001 From: Simon Lightfoot Date: Fri, 21 Feb 2025 15:12:43 +0000 Subject: [PATCH] fix: pr changes --- .../clerk_auth/lib/src/clerk_auth/auth.dart | 13 +- .../lib/src/clerk_auth/auth_error.dart | 30 +++- .../lib/src/models/client/client.dart | 2 +- .../lib/src/models/client/field.dart | 4 + .../lib/src/models/client/session_token.dart | 2 +- .../lib/src/models/client/sign_in.dart | 6 +- .../lib/src/models/client/strategy.dart | 2 +- packages/clerk_auth/lib/src/models/enums.dart | 2 +- packages/clerk_flutter/README.md | 31 ++-- packages/clerk_flutter/example/lib/main.dart | 9 +- packages/clerk_flutter/l10n/en.arb | 4 +- .../lib/src/clerk_auth_state.dart | 34 +++-- .../generated/clerk_sdk_localizations.dart | 2 +- .../generated/clerk_sdk_localizations_en.dart | 2 +- .../lib/src/utils/extensions.dart | 137 ----------------- .../src/utils/localization_extensions.dart | 144 ++++++++++++++++++ .../authentication/clerk_sign_in_panel.dart | 7 +- .../authentication/clerk_sign_up_panel.dart | 33 ++-- .../lib/src/widgets/control/clerk_auth.dart | 83 +++++++--- .../widgets/control/clerk_error_listener.dart | 15 +- .../lib/src/widgets/ui/strategy_button.dart | 14 +- .../src/widgets/user/clerk_user_profile.dart | 21 ++- 22 files changed, 347 insertions(+), 250 deletions(-) delete mode 100644 packages/clerk_flutter/lib/src/utils/extensions.dart create mode 100644 packages/clerk_flutter/lib/src/utils/localization_extensions.dart diff --git a/packages/clerk_auth/lib/src/clerk_auth/auth.dart b/packages/clerk_auth/lib/src/clerk_auth/auth.dart index dd09685..275fcc0 100644 --- a/packages/clerk_auth/lib/src/clerk_auth/auth.dart +++ b/packages/clerk_auth/lib/src/clerk_auth/auth.dart @@ -153,6 +153,7 @@ class Auth { ApiResponse _housekeeping(ApiResponse resp) { if (resp.isError) { throw AuthError( + code: AuthErrorCode.serverErrorResponse, message: '{arg}: ${resp.errorMessage}', argument: resp.status.toString(), ); @@ -188,9 +189,9 @@ class Auth { : null; final token = await _api.sessionToken(org, templateName); if (token is! SessionToken) { - throw AuthError( + throw const AuthError( message: 'No session token retrieved', - localizationCode: AuthErrorLocalizationCode.noSessionTokenRetrieved, + code: AuthErrorCode.noSessionTokenRetrieved, ); } return token; @@ -345,9 +346,9 @@ class Auth { String? signature, }) async { if (password != passwordConfirmation) { - throw AuthError( + throw const AuthError( message: "Password and password confirmation must match", - localizationCode: AuthErrorLocalizationCode.passwordMatchError, + code: AuthErrorCode.passwordMatchError, ); } @@ -543,9 +544,9 @@ class Auth { final expiry = client.signIn?.firstFactorVerification?.expireAt; if (expiry?.isAfter(DateTime.timestamp()) != true) { - throw AuthError( + throw const AuthError( message: 'Awaited user action not completed in required timeframe', - localizationCode: AuthErrorLocalizationCode.actionNotTimely, + code: AuthErrorCode.actionNotTimely, ); } 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 75e4648..a706ac4 100644 --- a/packages/clerk_auth/lib/src/clerk_auth/auth_error.dart +++ b/packages/clerk_auth/lib/src/clerk_auth/auth_error.dart @@ -1,8 +1,15 @@ /// Container for errors encountered during Clerk auth(entication|orization) /// -class AuthError extends Error { +class AuthError implements Exception { /// Construct an [AuthError] - AuthError({required this.message, this.argument, this.localizationCode}); + const AuthError({ + required this.code, + required this.message, + this.argument, + }); + + /// Error code + final AuthErrorCode? code; /// The associated [message] final String message; @@ -10,9 +17,6 @@ class AuthError extends Error { /// Any arguments final String? argument; - /// Localization code - final AuthErrorLocalizationCode? localizationCode; - @override String toString() { if (argument case String argument) { @@ -22,8 +26,20 @@ class AuthError extends Error { } } -/// Code to enable consuming apps to localize if they choose -enum AuthErrorLocalizationCode { +/// Code to enable consuming apps to identify the error +enum AuthErrorCode { + /// Server error response + serverErrorResponse, + + /// Error during sign-up flow + signUpFlowError, + + /// Invalid Password + invalidPassword, + + /// Type Invalid + typeInvalid, + /// No stage for status noStageForStatus, diff --git a/packages/clerk_auth/lib/src/models/client/client.dart b/packages/clerk_auth/lib/src/models/client/client.dart index 3d3da08..44e4f18 100644 --- a/packages/clerk_auth/lib/src/models/client/client.dart +++ b/packages/clerk_auth/lib/src/models/client/client.dart @@ -83,7 +83,7 @@ class Client { throw AuthError( message: 'No session found for {arg}', argument: user.name, - localizationCode: AuthErrorLocalizationCode.noSessionFoundForUser, + code: AuthErrorCode.noSessionFoundForUser, ); } diff --git a/packages/clerk_auth/lib/src/models/client/field.dart b/packages/clerk_auth/lib/src/models/client/field.dart index f09f94b..258e0a8 100644 --- a/packages/clerk_auth/lib/src/models/client/field.dart +++ b/packages/clerk_auth/lib/src/models/client/field.dart @@ -23,9 +23,13 @@ class Field { /// email address static const emailAddress = Field._(name: 'email_address'); + /// username + static const username = Field._(name: 'username'); + static final _values = { phoneNumber.name: phoneNumber, emailAddress.name: emailAddress, + username.name: username, }; /// The [values] of the Fields diff --git a/packages/clerk_auth/lib/src/models/client/session_token.dart b/packages/clerk_auth/lib/src/models/client/session_token.dart index 3ba12c6..b40a706 100644 --- a/packages/clerk_auth/lib/src/models/client/session_token.dart +++ b/packages/clerk_auth/lib/src/models/client/session_token.dart @@ -28,7 +28,7 @@ class SessionToken { _ => throw AuthError( message: "JWT poorly formatted: {arg}", argument: jwt, - localizationCode: AuthErrorLocalizationCode.jwtPoorlyFormatted, + code: AuthErrorCode.jwtPoorlyFormatted, ), }; diff --git a/packages/clerk_auth/lib/src/models/client/sign_in.dart b/packages/clerk_auth/lib/src/models/client/sign_in.dart index ef6636c..248d602 100644 --- a/packages/clerk_auth/lib/src/models/client/sign_in.dart +++ b/packages/clerk_auth/lib/src/models/client/sign_in.dart @@ -99,14 +99,14 @@ class SignIn { throw AuthError( message: 'Strategy {arg} unsupported for first factor', argument: strategy.toString(), - localizationCode: AuthErrorLocalizationCode.noSuchFirstFactorStrategy, + code: AuthErrorCode.noSuchFirstFactorStrategy, ); case Stage.second: throw AuthError( message: 'Strategy {arg} unsupported for second factor', argument: strategy.toString(), - localizationCode: - AuthErrorLocalizationCode.noSuchSecondFactorStrategy, + code: + AuthErrorCode.noSuchSecondFactorStrategy, ); } } diff --git a/packages/clerk_auth/lib/src/models/client/strategy.dart b/packages/clerk_auth/lib/src/models/client/strategy.dart index 9027393..a7b69ac 100644 --- a/packages/clerk_auth/lib/src/models/client/strategy.dart +++ b/packages/clerk_auth/lib/src/models/client/strategy.dart @@ -201,7 +201,7 @@ class Strategy { String name => throw AuthError( message: 'No strategy associated with {arg}', argument: '${T.runtimeType} \'$name\'', - localizationCode: AuthErrorLocalizationCode.noAssociatedStrategy, + code: AuthErrorCode.noAssociatedStrategy, ), }; } diff --git a/packages/clerk_auth/lib/src/models/enums.dart b/packages/clerk_auth/lib/src/models/enums.dart index f7d95b4..d7ebbdd 100644 --- a/packages/clerk_auth/lib/src/models/enums.dart +++ b/packages/clerk_auth/lib/src/models/enums.dart @@ -170,7 +170,7 @@ enum Stage { _ => throw AuthError( message: 'No Stage for {arg}', argument: status.toString(), - localizationCode: AuthErrorLocalizationCode.noStageForStatus, + code: AuthErrorCode.noStageForStatus, ), }; } diff --git a/packages/clerk_flutter/README.md b/packages/clerk_flutter/README.md index d49660e..b7a7ec9 100644 --- a/packages/clerk_flutter/README.md +++ b/packages/clerk_flutter/README.md @@ -31,7 +31,7 @@ To use this package you will need to go to your [Clerk Dashboard](https://dashbo create an application and copy the public and publishable API keys into your project. ```dart -class ExampleApp extends StatefulWidget { +class ExampleApp extends StatelessWidget { /// Constructs an instance of Example App const ExampleApp({ super.key, @@ -41,27 +41,22 @@ class ExampleApp extends StatefulWidget { /// Publishable Key final String publishableKey; - @override - State createState() => _ExampleAppState(); -} - -class _ExampleAppState extends State { @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, - home: ClerkAuth( - publishableKey: widget.publishableKey, - child: Scaffold( - backgroundColor: ClerkColors.whiteSmoke, - body: Padding( - padding: horizontalPadding32, - child: Center( - child: ClerkAuthBuilder( - signedInBuilder: (context, auth) => const ClerkUserButton(), - signedOutBuilder: (context, auth) => - const ClerkAuthenticationWidget(), - ), + builder: ClerkAuth.materialAppBuilder(publishableKey: publishableKey), + home: Scaffold( + backgroundColor: const Color(0xFFf5f5f5), + body: SafeArea( + child: Center( + child: ClerkAuthBuilder( + signedInBuilder: (context, auth) { + return const ClerkUserButton(); + }, + signedOutBuilder: (context, auth) { + return const ClerkAuthentication(); + }, ), ), ), diff --git a/packages/clerk_flutter/example/lib/main.dart b/packages/clerk_flutter/example/lib/main.dart index 4bece89..70bc31f 100644 --- a/packages/clerk_flutter/example/lib/main.dart +++ b/packages/clerk_flutter/example/lib/main.dart @@ -43,13 +43,7 @@ class ExampleApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, - builder: (BuildContext context, Widget? child) { - return ClerkAuth( - publishableKey: publishableKey, - pollMode: SessionTokenPollMode.hungry, - child: ClerkErrorListener(child: child!), - ); - }, + builder: ClerkAuth.materialAppBuilder(publishableKey: publishableKey), home: Scaffold( backgroundColor: const Color(0xFFf5f5f5), body: SafeArea( @@ -59,7 +53,6 @@ class ExampleApp extends StatelessWidget { if (auth.env.organization.isEnabled == false) { return const ClerkUserButton(); } - return const _UserAndOrgTabs(); }, signedOutBuilder: (context, auth) { diff --git a/packages/clerk_flutter/l10n/en.arb b/packages/clerk_flutter/l10n/en.arb index c45fa3d..8c0e76a 100644 --- a/packages/clerk_flutter/l10n/en.arb +++ b/packages/clerk_flutter/l10n/en.arb @@ -97,7 +97,7 @@ } } }, - "loading": "Loading...", + "loading": "Loading…", "logo": "Logo", "missingRequirements": "MISSING REQUIREMENTS", "name": "Name", @@ -213,4 +213,4 @@ "welcomeBackPleaseSignInToContinue": "Welcome back! Please sign in to continue", "welcomePleaseFillInTheDetailsToGetStarted": "Welcome! Please fill in the details to get started", "youNeedToAdd": "You need to add:" -} \ No newline at end of file +} diff --git a/packages/clerk_flutter/lib/src/clerk_auth_state.dart b/packages/clerk_flutter/lib/src/clerk_auth_state.dart index 1b8eefb..c0aac78 100644 --- a/packages/clerk_flutter/lib/src/clerk_auth_state.dart +++ b/packages/clerk_flutter/lib/src/clerk_auth_state.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:clerk_auth/clerk_auth.dart' as clerk; import 'package:clerk_flutter/clerk_flutter.dart'; -import 'package:clerk_flutter/src/utils/extensions.dart'; +import 'package:clerk_flutter/src/utils/localization_extensions.dart'; import 'package:clerk_flutter/src/widgets/ui/common.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -90,10 +90,12 @@ class ClerkAuthState extends clerk.Auth with ChangeNotifier { useSafeArea: false, useRootNavigator: true, routeSettings: const RouteSettings(name: _kSsoRouteName), - builder: (context) => _SsoWebViewOverlay( - url: url, - onError: (error) => _onError(error, onError), - ), + builder: (BuildContext context) { + return _SsoWebViewOverlay( + url: url, + onError: (error) => _onError(error, onError), + ); + }, ); if (responseUrl == clerk.ClerkConstants.oauthRedirect) { await refreshClient(); @@ -265,9 +267,7 @@ class ClerkAuthState extends clerk.Auth with ChangeNotifier { } /// Add an [clerk.AuthError] for [message] to the [errorStream] - void addError(String message) { - _errors.add(clerk.AuthError(message: message)); - } + void addError(clerk.AuthError error) => _errors.add(error); } class _SsoWebViewOverlay extends StatefulWidget { @@ -286,11 +286,12 @@ class _SsoWebViewOverlay extends StatefulWidget { class _SsoWebViewOverlayState extends State<_SsoWebViewOverlay> { late final WebViewController controller; - var _title = Future.value('Loading...'); + Future? _title; @override void initState() { super.initState(); + controller = WebViewController() ..setUserAgent( 'Clerk Flutter SDK v${clerk.ClerkConstants.flutterSdkVersion}', @@ -301,7 +302,10 @@ class _SsoWebViewOverlayState extends State<_SsoWebViewOverlay> { NavigationDelegate( onPageFinished: (_) => _updateTitle(), onWebResourceError: (e) => widget.onError( - clerk.AuthError(message: e.toString()), + clerk.AuthError( + code: clerk.AuthErrorCode.serverErrorResponse, + message: e.toString(), + ), ), onNavigationRequest: (NavigationRequest request) async { try { @@ -324,6 +328,14 @@ class _SsoWebViewOverlayState extends State<_SsoWebViewOverlay> { controller.loadRequest(Uri.parse(widget.url)); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _title ??= Future.value( + ClerkAuth.localizationsOf(context).loading, + ); + } + void _updateTitle() { setState(() { _title = controller.getTitle(); @@ -336,7 +348,7 @@ class _SsoWebViewOverlayState extends State<_SsoWebViewOverlay> { appBar: AppBar( automaticallyImplyLeading: false, title: FutureBuilder( - future: _title, + future: _title!, builder: (context, snapshot) { return Text(snapshot.data ?? ''); }, diff --git a/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations.dart b/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations.dart index 178597c..eb7cf38 100644 --- a/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations.dart +++ b/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations.dart @@ -283,7 +283,7 @@ abstract class ClerkSdkLocalizations { /// No description provided for @loading. /// /// In en, this message translates to: - /// **'Loading...'** + /// **'Loading…'** String get loading; /// No description provided for @logo. diff --git a/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations_en.dart b/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations_en.dart index 93c08fd..f8e4fcc 100644 --- a/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations_en.dart +++ b/packages/clerk_flutter/lib/src/generated/clerk_sdk_localizations_en.dart @@ -119,7 +119,7 @@ class ClerkSdkLocalizationsEn extends ClerkSdkLocalizations { } @override - String get loading => 'Loading...'; + String get loading => 'Loading…'; @override String get logo => 'Logo'; diff --git a/packages/clerk_flutter/lib/src/utils/extensions.dart b/packages/clerk_flutter/lib/src/utils/extensions.dart deleted file mode 100644 index 3cdfd67..0000000 --- a/packages/clerk_flutter/lib/src/utils/extensions.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:clerk_auth/clerk_auth.dart' as clerk; -import 'package:clerk_flutter/src/generated/clerk_sdk_localizations.dart'; - -/// An extension class to enable localization of [AuthError] messaging -/// -extension AuthErrorExtension on clerk.AuthError { - /// Allow localization of an [AuthError] - String localizedMessage(ClerkSdkLocalizations localizations) { - switch (localizationCode) { - case clerk.AuthErrorLocalizationCode.noStageForStatus: - return localizations.noStageForStatus(argument.toString()); - case clerk.AuthErrorLocalizationCode.noSessionTokenRetrieved: - return localizations.noSessionTokenRetrieved; - case clerk.AuthErrorLocalizationCode.noAssociatedStrategy: - return localizations.noAssociatedStrategy(argument.toString()); - case clerk.AuthErrorLocalizationCode.passwordMatchError: - return localizations.passwordAndPasswordConfirmationMustMatch; - case clerk.AuthErrorLocalizationCode.jwtPoorlyFormatted: - return localizations.jwtPoorlyFormatted(argument.toString()); - case clerk.AuthErrorLocalizationCode.actionNotTimely: - return localizations.actionNotTimely; - case clerk.AuthErrorLocalizationCode.noSessionFoundForUser: - return localizations.noSessionFoundForUser(argument.toString()); - case clerk.AuthErrorLocalizationCode.noSuchFirstFactorStrategy: - return localizations.noSuchFirstFactorStrategy(argument.toString()); - case clerk.AuthErrorLocalizationCode.noSuchSecondFactorStrategy: - return localizations.noSuchSecondFactorStrategy(argument.toString()); - default: - return toString(); - } - } -} - -/// An enum to enable contextual dynamic lookup of string keys -/// in the localized appiolect. This is required so that we're not relying on -/// translated terms being inserted into other translations, which may not work -/// cross language. -/// -enum ClerkSdkLocalizationType { - /// verify - verify, - - /// verification - verification, - - /// sign in - signIn, -} - -/// An extension class for [ClerkSdkLocalizations] -/// -extension ClerkSdkLocalizationsExt on ClerkSdkLocalizations { - /// Return a translated version of a [key] - String lookup(String key, {ClerkSdkLocalizationType? type}) { - return switch (type) { - ClerkSdkLocalizationType.verify => switch (key) { - 'email_address' => verifyYourEmailAddress, - 'phone_number' => verifyYourPhoneNumber, - _ => key, - }, - ClerkSdkLocalizationType.verification => switch (key) { - 'email_address' => verificationEmailAddress, - 'phone_number' => verificationPhoneNumber, - _ => key, - }, - ClerkSdkLocalizationType.signIn => switch (key) { - 'email_link' => signInByClickingALinkSentToYouByEmail, - 'email_code' => signInByEnteringACodeSentToYouByEmail, - 'phone_code' => signInByEnteringACodeSentToYouByTextMessage, - _ => key, - }, - _ => switch (key) { - 'email_address' => emailAddress, - 'phone_number' => phoneNumber, - 'username' => username, - 'abandoned' => abandoned, - 'active' => active, - 'missing_requirements' => missingRequirements, - 'needs_identifier' => needsIdentifier, - 'needs_first_factor' => needsFirstFactor, - 'needs_second_factor' => needsSecondFactor, - 'transferable' => transferable, - 'unverified' => unverified, - 'verified' => verified, - 'complete' => complete, - 'expired' => expired, - 'failed' => failed, - _ => key, - }, - }; - } -} - -/// An extension class for [String] -/// -extension StringExt on String { - /// A method that takes a list of pre-translated [items] e.g. - /// \['first', 'second', 'third'\] and returns a textual representation - /// of its contents as alternatives e.g. "first, second or third" - /// - /// [connector] can be overridden, and a [prefix] can be prepended. Both - /// should already be translated as required. - /// - /// This method should be overridden for languages where this format does not - /// provide the correct representation for alternates - /// - static String alternatives( - List items, { - required String connector, - String? prefix, - }) { - if (items.isEmpty) { - return ''; - } - - final buf = StringBuffer(); - - if (prefix case String prefix) { - buf.write(prefix); - buf.writeCharCode(0x20); - } - - buf.write(items.first); - - for (int i = 1; i < items.length - 1; ++i) { - buf.write(', '); - buf.write(items[i]); - } - - if (items.length > 1) { - buf.write(' $connector '); - buf.write(items.last); - } - - return buf.toString(); - } -} diff --git a/packages/clerk_flutter/lib/src/utils/localization_extensions.dart b/packages/clerk_flutter/lib/src/utils/localization_extensions.dart new file mode 100644 index 0000000..640f83e --- /dev/null +++ b/packages/clerk_flutter/lib/src/utils/localization_extensions.dart @@ -0,0 +1,144 @@ +import 'package:clerk_auth/clerk_auth.dart' as clerk; +import 'package:clerk_flutter/src/generated/clerk_sdk_localizations.dart'; + +/// Function that performs localization +typedef LocalizedMessage = String Function(ClerkSdkLocalizations localizations); + +/// An extension class to enable localization of [clerk.AuthError] +/// +extension ClerkAuthErrorExtension on clerk.AuthError { + /// Allow localization of an [clerk.AuthError] + String localizedMessage(ClerkSdkLocalizations localizations) { + switch (code) { + case clerk.AuthErrorCode.noStageForStatus: + return localizations.noStageForStatus(argument.toString()); + case clerk.AuthErrorCode.noSessionTokenRetrieved: + return localizations.noSessionTokenRetrieved; + case clerk.AuthErrorCode.noAssociatedStrategy: + return localizations.noAssociatedStrategy(argument.toString()); + case clerk.AuthErrorCode.passwordMatchError: + return localizations.passwordAndPasswordConfirmationMustMatch; + case clerk.AuthErrorCode.jwtPoorlyFormatted: + return localizations.jwtPoorlyFormatted(argument.toString()); + case clerk.AuthErrorCode.actionNotTimely: + return localizations.actionNotTimely; + case clerk.AuthErrorCode.noSessionFoundForUser: + return localizations.noSessionFoundForUser(argument.toString()); + case clerk.AuthErrorCode.noSuchFirstFactorStrategy: + return localizations.noSuchFirstFactorStrategy(argument.toString()); + case clerk.AuthErrorCode.noSuchSecondFactorStrategy: + return localizations.noSuchSecondFactorStrategy(argument.toString()); + default: + return toString(); + } + } +} + +/// An extension class to enable localization of [clerk.Status] +/// +extension ClerkStatusLocalization on clerk.Status { + /// Allow localization of an [clerk.Status] + String localizedMessage(ClerkSdkLocalizations localizations) { + return switch (this) { + clerk.Status.abandoned => localizations.abandoned, + clerk.Status.active => localizations.active, + clerk.Status.missingRequirements => localizations.missingRequirements, + clerk.Status.needsIdentifier => localizations.needsIdentifier, + clerk.Status.needsFirstFactor => localizations.needsFirstFactor, + clerk.Status.needsSecondFactor => localizations.needsSecondFactor, + clerk.Status.transferable => localizations.transferable, + clerk.Status.unverified => localizations.unverified, + clerk.Status.verified => localizations.verified, + clerk.Status.complete => localizations.complete, + clerk.Status.expired => localizations.expired, + clerk.Status.failed => localizations.failed, + }; + } +} + +/// An extension class to enable localization of [clerk.Strategy] +/// +extension ClerkStrategyLocalization on clerk.Strategy { + /// Allow localization of an [clerk.Strategy] + String localizedMessage(ClerkSdkLocalizations localizations) { + return switch (this) { + clerk.Strategy.emailAddress => localizations.emailAddress, + clerk.Strategy.phoneNumber => localizations.phoneNumber, + clerk.Strategy.username => localizations.username, + _ => toString(), + }; + } +} + +/// An extension class to enable localization of [clerk.Field] +/// +extension ClerkFieldLocalization on clerk.Field { + /// Allow localization of an [clerk.Field] + String localizedMessage(ClerkSdkLocalizations localizations) { + return switch (this) { + clerk.Field.emailAddress => localizations.emailAddress, + clerk.Field.phoneNumber => localizations.phoneNumber, + clerk.Field.username => localizations.username, + _ => name, + }; + } +} + +/// An extension class to enable localization of [clerk.UserAttribute] +/// +extension ClerkUserAttributeLocalization on clerk.UserAttribute { + /// Allow localization of an [clerk.UserAttribute] + String localizedMessage(ClerkSdkLocalizations localizations) { + return switch (this) { + clerk.UserAttribute.emailAddress => localizations.emailAddress, + clerk.UserAttribute.phoneNumber => localizations.phoneNumber, + clerk.UserAttribute.username => localizations.username, + _ => name, + }; + } +} + +/// An extension class for [String] +/// +extension StringExt on String { + /// A method that takes a list of pre-translated [items] e.g. + /// \['first', 'second', 'third'\] and returns a textual representation + /// of its contents as alternatives e.g. "first, second or third" + /// + /// [connector] can be overridden, and a [prefix] can be prepended. Both + /// should already be translated as required. + /// + /// This method should be overridden for languages where this format does not + /// provide the correct representation for alternates + /// + static String alternatives( + List items, { + required String connector, + String? prefix, + }) { + if (items.isEmpty) { + return ''; + } + + final buf = StringBuffer(); + + if (prefix case String prefix) { + buf.write(prefix); + buf.writeCharCode(0x20); + } + + buf.write(items.first); + + for (int i = 1; i < items.length - 1; i++) { + buf.write(', '); + buf.write(items[i]); + } + + if (items.length > 1) { + buf.write(' $connector '); + buf.write(items.last); + } + + return buf.toString(); + } +} diff --git a/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_in_panel.dart b/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_in_panel.dart index c9acc99..fa7c8e2 100644 --- a/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_in_panel.dart +++ b/packages/clerk_flutter/lib/src/widgets/authentication/clerk_sign_in_panel.dart @@ -1,7 +1,7 @@ import 'package:clerk_auth/clerk_auth.dart' as clerk; import 'package:clerk_flutter/clerk_flutter.dart'; import 'package:clerk_flutter/src/utils/clerk_telemetry.dart'; -import 'package:clerk_flutter/src/utils/extensions.dart'; +import 'package:clerk_flutter/src/utils/localization_extensions.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_code_input.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_material_button.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_text_form_field.dart'; @@ -81,7 +81,8 @@ class _ClerkSignInPanelState extends State final localizations = ClerkAuth.localizationsOf(context); final env = authState.env; final identifiers = env.identificationStrategies - .map((s) => localizations.lookup(s.toString())); + .map((s) => s.localizedMessage(localizations)) + .toList(growable: false); final factor = authState.client.signIn?.supportedFirstFactors .firstWhereOrNull((f) => f.strategy == _strategy); final safeIdentifier = factor?.safeIdentifier; @@ -100,7 +101,7 @@ class _ClerkSignInPanelState extends State child: ClerkTextFormField( key: const Key('identifier'), label: StringExt.alternatives( - identifiers.toList(growable: false), + identifiers, connector: localizations.or, ).capitalized, onChanged: (text) { 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 2997059..0efc057 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 @@ -1,7 +1,7 @@ import 'package:clerk_auth/clerk_auth.dart' as clerk; import 'package:clerk_flutter/clerk_flutter.dart'; import 'package:clerk_flutter/src/utils/clerk_telemetry.dart'; -import 'package:clerk_flutter/src/utils/extensions.dart'; +import 'package:clerk_flutter/src/utils/localization_extensions.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_code_input.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_material_button.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_phone_number_form_field.dart'; @@ -57,10 +57,15 @@ class _ClerkSignUpPanelState extends State when missingFields.isNotEmpty) { final localizations = ClerkAuth.localizationsOf(context); authState.addError( - StringExt.alternatives( - missingFields.map((f) => localizations.lookup(f.name)).toList(), - prefix: localizations.youNeedToAdd, - connector: localizations.and, + clerk.AuthError( + code: clerk.AuthErrorCode.signUpFlowError, + message: StringExt.alternatives( + missingFields + .map((f) => f.localizedMessage(localizations)) + .toList(), + prefix: localizations.youNeedToAdd, + connector: localizations.and, + ), ), ); } @@ -84,7 +89,10 @@ class _ClerkSignUpPanelState extends State _valueOrNull(clerk.UserAttribute.passwordConfirmation); if (auth.checkPassword(password, passwordConfirmation, localizations) case String errorMessage) { - auth.addError(errorMessage); + auth.addError(clerk.AuthError( + code: clerk.AuthErrorCode.invalidPassword, + message: errorMessage, + )); } else { await auth.attemptSignUp( strategy: strategy ?? clerk.Strategy.password, @@ -251,10 +259,13 @@ class _CodeInputBoxState extends State<_CodeInputBox> { ClerkCodeInput( key: Key(widget.attribute.name), focusNode: _focus, - title: localizations.lookup( - widget.attribute.relatedField!.name, - type: ClerkSdkLocalizationType.verify, - ), + title: switch (widget.attribute) { + clerk.UserAttribute.emailAddress => + localizations.verifyYourEmailAddress, + clerk.UserAttribute.phoneNumber => + localizations.verifyYourPhoneNumber, + _ => widget.attribute.toString(), + }, subtitle: localizations.enterCodeSentTo(widget.value), onSubmit: widget.onSubmit, ), @@ -298,5 +309,5 @@ class _Attribute { bool get isOptional => isRequired == false; String title(ClerkSdkLocalizations localizations) => - localizations.lookup(attr.toString()).capitalized; + attr.localizedMessage(localizations).capitalized; } diff --git a/packages/clerk_flutter/lib/src/widgets/control/clerk_auth.dart b/packages/clerk_flutter/lib/src/widgets/control/clerk_auth.dart index 996560e..92551a1 100644 --- a/packages/clerk_flutter/lib/src/widgets/control/clerk_auth.dart +++ b/packages/clerk_flutter/lib/src/widgets/control/clerk_auth.dart @@ -19,26 +19,44 @@ class ClerkAuth extends StatefulWidget { this.loading, this.httpService, required this.child, - }) : assert( - (publishableKey is String) != (authState is ClerkAuthState), - 'Either [publishableKey] or an [authState] instance must ' - 'be provided, but not both', - ); + }); + + /// Constructor to use when using [MaterialApp] for your project. + static TransitionBuilder materialAppBuilder({ + required String? publishableKey, + clerk.SessionTokenPollMode? pollMode, + LocalizationsDelegate? localizations, + clerk.Persistor? persistor, + Widget? loading, + clerk.HttpService? httpService, + }) { + return (BuildContext context, Widget? child) { + return ClerkAuth( + publishableKey: publishableKey, + pollMode: pollMode ?? clerk.SessionTokenPollMode.lazy, + localizations: localizations ?? ClerkSdkLocalizations.delegate, + persistor: persistor, + loading: loading, + httpService: httpService, + child: ClerkErrorListener(child: child!), + ); + }; + } /// Clerk publishable key from dashboard final String? publishableKey; + /// Poll mode: should we regularly poll for session token? + final clerk.SessionTokenPollMode pollMode; + /// auth instance from elsewhere final ClerkAuthState? authState; - /// Persistence service for caching tokens - final clerk.Persistor? persistor; - /// Injectable translations for strings final LocalizationsDelegate localizations; - /// Poll mode: should we regularly poll for session token? - final clerk.SessionTokenPollMode pollMode; + /// Persistence service for caching tokens + final clerk.Persistor? persistor; /// Loading widget final Widget? loading; @@ -132,19 +150,40 @@ class _ClerkAuthState extends State with ClerkTelemetryStateMixin { @override Widget build(BuildContext context) { if (effectiveAuthState case ClerkAuthState authState) { - return Localizations.override( - context: context, - delegates: [widget.localizations], - child: ListenableBuilder( - listenable: authState, - builder: (BuildContext context, Widget? child) { - return _ClerkAuthData( - authState: authState, - child: widget.child, - ); - }, - ), + final child = ListenableBuilder( + listenable: authState, + builder: (BuildContext context, Widget? child) { + return _ClerkAuthData( + authState: authState, + child: widget.child, + ); + }, ); + + // Return localized child + + final localizations = + context.findAncestorWidgetOfExactType(); + // If we dont have parent Localizations, inject default English + if (localizations == null) { + return Localizations( + locale: View.of(context).platformDispatcher.locale, + delegates: [widget.localizations], + child: child, + ); + } + // If [MaterialApp] does not contain [ClerkSdkLocalizations] + else if (Localizations.of(context, ClerkSdkLocalizations) == null) { + return Localizations.override( + context: context, + delegates: [widget.localizations], + child: child, + ); + } + // [MaterialApp] contains overridden [ClerkSdkLocalizations] + else { + return child; + } } return widget.loading ?? emptyWidget; } diff --git a/packages/clerk_flutter/lib/src/widgets/control/clerk_error_listener.dart b/packages/clerk_flutter/lib/src/widgets/control/clerk_error_listener.dart index dd31ba6..4f9bdad 100644 --- a/packages/clerk_flutter/lib/src/widgets/control/clerk_error_listener.dart +++ b/packages/clerk_flutter/lib/src/widgets/control/clerk_error_listener.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:clerk_auth/clerk_auth.dart'; import 'package:clerk_flutter/clerk_flutter.dart'; -import 'package:clerk_flutter/src/utils/extensions.dart'; +import 'package:clerk_flutter/src/utils/localization_extensions.dart'; import 'package:clerk_flutter/src/widgets/ui/style/colors.dart'; import 'package:clerk_flutter/src/widgets/ui/style/text_style.dart'; import 'package:flutter/material.dart'; @@ -39,7 +39,16 @@ class ClerkErrorListener extends StatefulWidget { AuthError error, ) async { final localizations = ClerkAuth.localizationsOf(context); - ScaffoldMessenger.of(context).showSnackBar( + final message = error.localizedMessage(localizations); + + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + debugPrint('Warning: no ScaffoldMessenger found ' + 'to display error: $message'); + return; + } + + messenger.showSnackBar( SnackBar( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -48,7 +57,7 @@ class ClerkErrorListener extends StatefulWidget { ), ), content: Text( - error.localizedMessage(localizations), + 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 7e388ca..7f082a4 100644 --- a/packages/clerk_flutter/lib/src/widgets/ui/strategy_button.dart +++ b/packages/clerk_flutter/lib/src/widgets/ui/strategy_button.dart @@ -1,6 +1,5 @@ import 'package:clerk_auth/clerk_auth.dart' as clerk; import 'package:clerk_flutter/clerk_flutter.dart'; -import 'package:clerk_flutter/src/utils/extensions.dart'; import 'package:clerk_flutter/src/widgets/ui/common.dart'; import 'package:clerk_flutter/src/widgets/ui/style/colors.dart'; import 'package:clerk_flutter/src/widgets/ui/style/text_style.dart'; @@ -62,10 +61,15 @@ class StrategyButton extends StatelessWidget { horizontalMargin8, Expanded( child: Text( - localizations.lookup( - strategy.toString(), - type: ClerkSdkLocalizationType.signIn, - ), + switch (strategy) { + clerk.Strategy.emailLink => + localizations.signInByClickingALinkSentToYouByEmail, + clerk.Strategy.emailCode => + localizations.signInByEnteringACodeSentToYouByEmail, + clerk.Strategy.phoneCode => + localizations.signInByEnteringACodeSentToYouByTextMessage, + _ => strategy.toString(), + }, maxLines: 1, style: ClerkTextStyle.buttonTitle, ), 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 8076685..fd4eb69 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 @@ -4,7 +4,7 @@ import 'package:clerk_auth/clerk_auth.dart' as clerk; import 'package:clerk_flutter/clerk_flutter.dart'; import 'package:clerk_flutter/src/assets.dart'; import 'package:clerk_flutter/src/utils/clerk_telemetry.dart'; -import 'package:clerk_flutter/src/utils/extensions.dart'; +import 'package:clerk_flutter/src/utils/localization_extensions.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_avatar.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_code_input.dart'; import 'package:clerk_flutter/src/widgets/ui/clerk_icon.dart'; @@ -45,6 +45,7 @@ class _ClerkUserProfileState extends State default: final localizations = ClerkAuth.localizationsOf(context); throw clerk.AuthError( + code: clerk.AuthErrorCode.typeInvalid, message: localizations.typeTypeInvalid(type.name), ); } @@ -65,10 +66,13 @@ class _ClerkUserProfileState extends State context, showOk: false, child: ClerkCodeInput( - title: localizations.lookup( - uid.type.name, - type: ClerkSdkLocalizationType.verification, - ), + title: switch (uid.type) { + clerk.IdentifierType.emailAddress => + localizations.verificationEmailAddress, + clerk.IdentifierType.phoneNumber => + localizations.verificationPhoneNumber, + _ => uid.type.toString(), + }, subtitle: localizations.enterCodeSentTo(identifier), onSubmit: (code) async { await auth.verifyIdentifyingData(uid, code); @@ -107,6 +111,7 @@ class _ClerkUserProfileState extends State onSubmit: (_) => Navigator.of(context).pop(true), ), _ => throw clerk.AuthError( + code: clerk.AuthErrorCode.typeInvalid, message: localizations.typeTypeInvalid(type.name), ), }, @@ -121,6 +126,7 @@ class _ClerkUserProfileState extends State } } else { throw clerk.AuthError( + code: clerk.AuthErrorCode.typeInvalid, message: type == clerk.IdentifierType.phoneNumber ? localizations.invalidPhoneNumber(identifier) : localizations.invalidEmailAddress(identifier), @@ -267,9 +273,8 @@ class _ExternalAccountList extends StatelessWidget { ), if (account.isVerified == false) // _RowLabel( - label: localizations.lookup( - account.verification.status.toString(), - ), + label: account.verification.status + .localizedMessage(localizations), ), ], ),