diff --git a/README.md b/README.md index b589ccc..6772c15 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ SupaEmailAuth( // do something, for example: navigate("wait_for_email"); }, metadataFields: [ + // Creates an additional TextField for string metadata, for example: + // {'username': 'exampleUsername'} MetaDataField( prefixIcon: const Icon(Icons.person), label: 'Username', @@ -38,8 +40,38 @@ SupaEmailAuth( return null; }, ), - ], -), + + // Creates a CheckboxListTile for boolean metadata, for example: + // {'marketing_consent': true} + BooleanMetaDataField( + label: 'I wish to receive marketing emails', + key: 'marketing_consent', + checkboxPosition: ListTileControlAffinity.leading, + ), + // Supports interactive text. Fields can be marked as required, blocking form + // submission unless the checkbox is checked. + BooleanMetaDataField( + key: 'terms_agreement', + isRequired: true, + checkboxPosition: ListTileControlAffinity.leading, + richLabelSpans: [ + const TextSpan( + text: 'I have read and agree to the '), + TextSpan( + text: 'Terms and Conditions', + style: const TextStyle( + color: Colors.blue, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // do something, for example: navigate("terms_and_conditions"); + }, + ), + // Or use some other custom widget. + WidgetSpan() + ], + ), + ]), ``` ## Magic Link Auth diff --git a/example/lib/sign_in.dart b/example/lib/sign_in.dart index e369583..6492132 100644 --- a/example/lib/sign_in.dart +++ b/example/lib/sign_in.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; @@ -26,12 +27,16 @@ class SignUp extends StatelessWidget { borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, ), - labelStyle: const TextStyle(color: Color.fromARGB(179, 255, 255, 255)), // text labeling text entry + labelStyle: const TextStyle( + color: + Color.fromARGB(179, 255, 255, 255)), // text labeling text entry ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromARGB(255, 22, 135, 188), // main button - foregroundColor: const Color.fromARGB(255, 255, 255, 255), // main button text + backgroundColor: + const Color.fromARGB(255, 22, 135, 188), // main button + foregroundColor: + const Color.fromARGB(255, 255, 255, 255), // main button text shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), @@ -60,6 +65,29 @@ class SignUp extends StatelessWidget { return null; }, ), + BooleanMetaDataField( + label: 'Keep me up to date with the latest news and updates.', + key: 'marketing_consent', + checkboxPosition: ListTileControlAffinity.leading, + ), + BooleanMetaDataField( + key: 'terms_agreement', + isRequired: true, + checkboxPosition: ListTileControlAffinity.leading, + richLabelSpans: [ + const TextSpan(text: 'I have read and agree to the '), + TextSpan( + text: 'Terms and Conditions', + style: const TextStyle( + color: Colors.blue, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + // Handle tap on Terms and Conditions + }, + ), + ], + ), ], ), @@ -76,17 +104,55 @@ class SignUp extends StatelessWidget { child: Theme( data: darkModeThemeData, child: SupaEmailAuth( - redirectTo: kIsWeb ? null : 'io.supabase.flutter://', - onSignInComplete: navigateHome, - onSignUpComplete: navigateHome, - prefixIconEmail: null, - prefixIconPassword: null, - localization: const SupaEmailAuthLocalization( - enterEmail: "email", - enterPassword: "password", - dontHaveAccount: "sign up", - forgotPassword: "forgot password"), - ), + redirectTo: kIsWeb ? null : 'io.supabase.flutter://', + onSignInComplete: navigateHome, + onSignUpComplete: navigateHome, + prefixIconEmail: null, + prefixIconPassword: null, + localization: const SupaEmailAuthLocalization( + enterEmail: "email", + enterPassword: "password", + dontHaveAccount: "sign up", + forgotPassword: "forgot password"), + metadataFields: [ + MetaDataField( + prefixIcon: const Icon(Icons.person), + label: 'Username', + key: 'username', + validator: (val) { + if (val == null || val.isEmpty) { + return 'Please enter something'; + } + return null; + }, + ), + BooleanMetaDataField( + label: + 'Keep me up to date with the latest news and updates.', + key: 'marketing_consent', + checkboxPosition: ListTileControlAffinity.leading, + ), + BooleanMetaDataField( + key: 'terms_agreement', + isRequired: true, + checkboxPosition: ListTileControlAffinity.leading, + richLabelSpans: [ + const TextSpan( + text: 'I have read and agree to the '), + TextSpan( + text: 'Terms and Conditions.', + style: const TextStyle( + color: Colors.blue, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + //ignore: avoid_print + print('Terms and Conditions'); + }, + ), + ], + ), + ]), ), )), diff --git a/lib/src/components/supa_email_auth.dart b/lib/src/components/supa_email_auth.dart index 82592c0..11f782f 100644 --- a/lib/src/components/supa_email_auth.dart +++ b/lib/src/components/supa_email_auth.dart @@ -4,9 +4,11 @@ import 'package:supabase_auth_ui/src/localizations/supa_email_auth_localization. import 'package:supabase_auth_ui/src/utils/constants.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +/// {@template metadata_field} /// Information about the metadata to pass to the signup form /// -/// You can use this object to create additional fields that will be passed to the metadata of the user upon signup. +/// You can use this object to create additional text fields that will be +/// passed to the metadata of the user upon signup. /// For example, in order to create additional `username` field, you can use the following: /// ```dart /// MetaDataField(label: 'Username', key: 'username') @@ -17,6 +19,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; /// ```dart /// { 'username': 'Whatever your user entered' } /// ``` +/// {@endtemplate} class MetaDataField { /// Label of the `TextFormField` for this metadata final String label; @@ -30,6 +33,7 @@ class MetaDataField { /// Icon to show as the prefix icon in TextFormField final Icon? prefixIcon; + /// {@macro metadata_field} MetaDataField({ required this.label, required this.key, @@ -38,6 +42,104 @@ class MetaDataField { }); } +/// {@template boolean_metadata_field} +/// Represents a boolean metadata field for the signup form. +/// +/// This class is used to create checkbox fields that will be passed +/// to the metadata of the user upon signup. It supports both simple +/// text labels and rich text labels with interactive elements. +/// +/// For example, in order to add a simple consent checkbox, +/// you can use the following: +/// ```dart +/// BooleanMetaDataField( +/// label: 'I agree to marketing emails', +/// key: 'email_consent', +/// ) +/// ``` +/// +/// Which will update the user's metadata accordingly: +/// +/// ```dart +/// { 'email_consent': true } +/// ``` +/// +/// You can also use rich text labels with interactive elements. +/// For example: +/// ```dart +/// BooleanMetaDataField( +/// key: 'terms_and_conditions_consent', +/// required: true, +/// richLabelSpans: [ +/// TextSpan(text: 'I have read and agree to the '), +/// TextSpan( +/// text: 'Terms and Conditions', +/// style: TextStyle(color: Colors.blue), +/// recognizer: TapGestureRecognizer() +/// ..onTap = () { +/// // Handle tap on 'Terms and Conditions' +/// }, +/// ), +/// ], +/// ) +/// ``` +/// +/// This will create a checkbox with a label that includes a link to the terms +/// and conditions. When the user taps on the link, you can handle the tap +/// event in your application. Because `required` is set to `true`, the user +/// must check the checkbox in order to sign up. +/// {@endtemplate} +class BooleanMetaDataField extends MetaDataField { + /// Rich text spans for the label. If provided, this will be used instead of [label]. + final List? richLabelSpans; + + /// Position of the checkbox in the [ListTile] created by this. + /// + /// Default is to ListTileControlAffinity.platform, which matches the default + /// value of the underlying ListTile widget. + final ListTileControlAffinity checkboxPosition; + + /// Whether the field is required. + /// + /// If true, the user must check the checkbox in order for the form to submit. + final bool isRequired; + + /// Semantic label for the checkbox. + final String? checkboxSemanticLabel; + + /// {@macro boolean_metadata_field} + BooleanMetaDataField({ + String? label, + this.richLabelSpans, + this.checkboxSemanticLabel, + this.isRequired = false, + this.checkboxPosition = ListTileControlAffinity.platform, + required super.key, + }) : assert(label != null || richLabelSpans != null, + 'Either label or richLabelSpans must be provided'), + super(label: label ?? ''); + + Widget getLabelWidget(BuildContext context) { + // This matches the default style of [TextField], to match the other fields + // in the form. TextField's default style uses `bodyLarge` for Material 3, + // or otherwise `titleMedium`. + final defaultStyle = Theme.of(context).useMaterial3 + ? Theme.of(context).textTheme.bodyLarge + : Theme.of(context).textTheme.titleMedium; + return richLabelSpans != null + ? RichText( + text: TextSpan( + style: defaultStyle, + children: richLabelSpans, + ), + ) + : Text(label, style: defaultStyle); + } +} + +// Used to allow storing both bool and TextEditingController in the same map. +typedef MetadataController = Object; + /// {@template supa_email_auth} /// UI component to create email and password signup/ signin form /// @@ -125,7 +227,7 @@ class _SupaEmailAuthState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); - late final Map _metadataControllers; + late final Map _metadataControllers; bool _isLoading = false; @@ -142,8 +244,11 @@ class _SupaEmailAuthState extends State { void initState() { super.initState(); _metadataControllers = Map.fromEntries((widget.metadataFields ?? []).map( - (metadataField) => - MapEntry(metadataField.key, TextEditingController()))); + (metadataField) => MapEntry( + metadataField.key, + metadataField is BooleanMetaDataField ? false : TextEditingController(), + ), + )); } @override @@ -151,7 +256,9 @@ class _SupaEmailAuthState extends State { _emailController.dispose(); _passwordController.dispose(); for (final controller in _metadataControllers.values) { - controller.dispose(); + if (controller is TextEditingController) { + controller.dispose(); + } } super.dispose(); } @@ -225,26 +332,106 @@ class _SupaEmailAuthState extends State { if (widget.metadataFields != null && !_isSigningIn) ...widget.metadataFields! .map((metadataField) => [ - TextFormField( - controller: _metadataControllers[metadataField.key], - textInputAction: - widget.metadataFields!.last == metadataField - ? TextInputAction.done - : TextInputAction.next, - decoration: InputDecoration( - label: Text(metadataField.label), - prefixIcon: metadataField.prefixIcon, + // Render a Checkbox that displays an error message + // beneath it if the field is required and the user + // hasn't checked it when submitting the form. + if (metadataField is BooleanMetaDataField) + FormField( + validator: metadataField.isRequired + ? (bool? value) { + if (value != true) { + return localization.requiredFieldError; + } + return null; + } + : null, + builder: (FormFieldState field) { + final theme = Theme.of(context); + final isDark = + theme.brightness == Brightness.dark; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CheckboxListTile( + title: + metadataField.getLabelWidget(context), + value: _metadataControllers[ + metadataField.key] as bool, + onChanged: (bool? value) { + setState(() { + _metadataControllers[metadataField + .key] = value ?? false; + }); + field.didChange(value); + }, + checkboxSemanticLabel: + metadataField.checkboxSemanticLabel, + controlAffinity: + metadataField.checkboxPosition, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 4.0), + activeColor: theme.colorScheme.primary, + checkColor: theme.colorScheme.onPrimary, + tileColor: isDark + ? theme.inputDecorationTheme.fillColor + : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: field.hasError + ? theme.colorScheme.error + : theme + .inputDecorationTheme + .border + ?.borderSide + .color ?? + theme.dividerColor, + ), + ), + ), + if (field.hasError) + Padding( + padding: const EdgeInsets.only( + left: 16, top: 4), + child: Text( + field.errorText!, + style: theme.textTheme.labelSmall + ?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ); + }, + ) + else + // Otherwise render a normal TextFormField matching + // the style of the other fields in the form. + TextFormField( + controller: + _metadataControllers[metadataField.key] + as TextEditingController, + textInputAction: + widget.metadataFields!.last == metadataField + ? TextInputAction.done + : TextInputAction.next, + decoration: InputDecoration( + label: Text(metadataField.label), + prefixIcon: metadataField.prefixIcon, + ), + validator: metadataField.validator, + onFieldSubmitted: (_) { + if (metadataField != + widget.metadataFields!.last) { + FocusScope.of(context).nextFocus(); + } else { + _signInSignUp(); + } + }, ), - validator: metadataField.validator, - onFieldSubmitted: (_) { - if (metadataField != - widget.metadataFields!.last) { - FocusScope.of(context).nextFocus(); - } else { - _signInSignUp(); - } - }, - ), spacer(16), ]) .expand((element) => element), @@ -422,9 +609,10 @@ class _SupaEmailAuthState extends State { /// Resolve the user_metadata coming from the metadataFields Map _resolveMetadataFieldsData() { - return widget.metadataFields != null - ? _metadataControllers.map( - (key, controller) => MapEntry(key, controller.text)) - : {}; + return Map.fromEntries(_metadataControllers.entries.map((entry) => MapEntry( + entry.key, + entry.value is TextEditingController + ? (entry.value as TextEditingController).text + : entry.value))); } } diff --git a/lib/src/localizations/supa_email_auth_localization.dart b/lib/src/localizations/supa_email_auth_localization.dart index a37e442..c17e88a 100644 --- a/lib/src/localizations/supa_email_auth_localization.dart +++ b/lib/src/localizations/supa_email_auth_localization.dart @@ -12,6 +12,7 @@ class SupaEmailAuthLocalization { final String passwordResetSent; final String backToSignIn; final String unexpectedError; + final String requiredFieldError; const SupaEmailAuthLocalization({ this.enterEmail = 'Enter your email', @@ -28,5 +29,6 @@ class SupaEmailAuthLocalization { this.passwordResetSent = 'Password reset email has been sent', this.backToSignIn = 'Back to sign in', this.unexpectedError = 'An unexpected error occurred', + this.requiredFieldError = 'This field is required', }); }