diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..24b7c2d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 + +updates: + - package-ecosystem: "pub" + directory: "packages/firebase_user_repository" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "packages/flutter_user" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "packages/rest_user_repository" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "packages/user_repository_interface" + schedule: + interval: "weekly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9cbe6..ce21aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 7.0.0 +- Fixed a bug with registration errors still triggering the success callback. +- Made all translations required in the constructor of ForgotPasswordTranslations and LoginTranslations. +- Removed the default values for colors in the LoginOptions, RegistrationOptions, and ForgotPasswordOptions. +- Added rest_user_repository package which is a REST implementation of the UserRepositoryInterface. +- Changed afterLoginScreen to a nullable Widget so a screen isn't automatically pushed after login. +- Moved the RegistrationOptions and ForgotPasswordOptions to the FlutterUserOptions. +- Changed the image option for the Login page to the top of the screen. + ## 6.4.0 - Added proper use of exceptions for all auth methods. diff --git a/packages/firebase_user_repository/pubspec.yaml b/packages/firebase_user_repository/pubspec.yaml index fd2ec27..141c544 100644 --- a/packages/firebase_user_repository/pubspec.yaml +++ b/packages/firebase_user_repository/pubspec.yaml @@ -1,6 +1,6 @@ name: firebase_user_repository description: "firebase_user_repository for flutter_user package" -version: 6.4.0 +version: 7.0.0 repository: https://github.com/Iconica-Development/flutter_user publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub @@ -14,7 +14,7 @@ dependencies: sdk: flutter user_repository_interface: hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ - version: ^6.4.0 + version: ^7.0.0 cloud_firestore: ^5.4.2 firebase_auth: ^5.3.0 diff --git a/packages/flutter_user/example/lib/main.dart b/packages/flutter_user/example/lib/main.dart index 6191e5c..4ef62bf 100644 --- a/packages/flutter_user/example/lib/main.dart +++ b/packages/flutter_user/example/lib/main.dart @@ -13,15 +13,29 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) => MaterialApp( title: "flutter_user Example", theme: theme, - home: FlutterUserNavigatorUserstory( - afterLoginScreen: const Home(), - options: FlutterUserOptions( - loginOptions: const LoginOptions( - biometricsOptions: LoginBiometricsOptions( - loginWithBiometrics: true, - ), + home: const UserstoryScreen(), + ); +} + +class UserstoryScreen extends StatelessWidget { + const UserstoryScreen({super.key}); + + @override + Widget build(BuildContext context) => FlutterUserNavigatorUserstory( + afterLoginScreen: const Home(), + options: FlutterUserOptions( + loginOptions: const LoginOptions( + biometricsOptions: LoginBiometricsOptions( + loginWithBiometrics: true, + ), + translations: LoginTranslations.empty( + loginTitle: "Login", + loginButton: "Log in", + loginSubtitle: "Welcome back!", ), ), + forgotPasswordOptions: const ForgotPasswordOptions(), + registrationOptions: RegistrationOptions(), ), ); } @@ -39,9 +53,7 @@ class Home extends StatelessWidget { onPressed: () async => Navigator.pushReplacement( context, MaterialPageRoute( - builder: (context) => const FlutterUserNavigatorUserstory( - afterLoginScreen: Home(), - ), + builder: (context) => const UserstoryScreen(), ), ), child: const Text("Logout"), diff --git a/packages/flutter_user/lib/flutter_user.dart b/packages/flutter_user/lib/flutter_user.dart index 5fae4f6..4e38732 100644 --- a/packages/flutter_user/lib/flutter_user.dart +++ b/packages/flutter_user/lib/flutter_user.dart @@ -16,13 +16,13 @@ export "src/models/image_picker_theme.dart"; export "src/models/login/login_options.dart"; export "src/models/login/login_spacer_options.dart"; export "src/models/login/login_translations.dart"; -export "src/models/registration/auth_action.dart"; -export "src/models/registration/auth_bool_field.dart"; -export "src/models/registration/auth_drop_down.dart"; -export "src/models/registration/auth_field.dart"; -export "src/models/registration/auth_pass_field.dart"; -export "src/models/registration/auth_step.dart"; -export "src/models/registration/auth_text_field.dart"; +export "src/models/registration/auth/auth_action.dart"; +export "src/models/registration/auth/auth_bool_field.dart"; +export "src/models/registration/auth/auth_drop_down.dart"; +export "src/models/registration/auth/auth_field.dart"; +export "src/models/registration/auth/auth_pass_field.dart"; +export "src/models/registration/auth/auth_step.dart"; +export "src/models/registration/auth/auth_text_field.dart"; export "src/models/registration/registration_options.dart"; export "src/models/registration/registration_spacer_options.dart"; export "src/models/registration/registration_translations.dart"; diff --git a/packages/flutter_user/lib/src/flutter_user_navigator_userstory.dart b/packages/flutter_user/lib/src/flutter_user_navigator_userstory.dart index d9bb945..2c73a94 100644 --- a/packages/flutter_user/lib/src/flutter_user_navigator_userstory.dart +++ b/packages/flutter_user/lib/src/flutter_user_navigator_userstory.dart @@ -6,25 +6,29 @@ import "package:flutter_user/src/models/auth_error_details.dart"; class FlutterUserNavigatorUserstory extends StatefulWidget { const FlutterUserNavigatorUserstory({ - required this.afterLoginScreen, + this.afterLoginScreen, this.afterRegistration, this.userService, this.options, - this.forgotPasswordTranslations, - this.registrationOptions, - this.forgotPasswordOptions = const ForgotPasswordOptions(), super.key, }); + /// The options for the user story. + /// This includes the login options, registration options and forgot password + /// options. final FlutterUserOptions? options; + + /// The user service to use for authentication and registration. final UserService? userService; - final Widget afterLoginScreen; + + /// Provide a widget to push after login is successful. + /// If not provided, nothing will happen and you will need to handle the + /// navigation yourself through the [afterLogin] callback in the + /// [FlutterUserOptions]. + final Widget? afterLoginScreen; /// A callback function executed after successful registration. final VoidCallback? afterRegistration; - final ForgotPasswordTranslations? forgotPasswordTranslations; - final RegistrationOptions? registrationOptions; - final ForgotPasswordOptions forgotPasswordOptions; @override State createState() => @@ -33,17 +37,13 @@ class FlutterUserNavigatorUserstory extends StatefulWidget { class _FlutterUserNavigatorUserstoryState extends State { - UserService? userService; - FlutterUserOptions? options; - ForgotPasswordTranslations? forgotPasswordTranslations; - RegistrationOptions? registrationOptions; + late UserService userService; + late FlutterUserOptions options; + @override void initState() { userService = widget.userService ?? UserService(); options = widget.options ?? FlutterUserOptions(); - forgotPasswordTranslations = - widget.forgotPasswordTranslations ?? const ForgotPasswordTranslations(); - registrationOptions = widget.registrationOptions ?? RegistrationOptions(); super.initState(); } @@ -62,21 +62,6 @@ class _FlutterUserNavigatorUserstoryState userService = widget.userService ?? UserService(); }); } - - if (widget.forgotPasswordTranslations != - oldWidget.forgotPasswordTranslations) { - setState(() { - forgotPasswordTranslations = widget.forgotPasswordTranslations ?? - const ForgotPasswordTranslations(); - }); - } - - if (widget.registrationOptions != oldWidget.registrationOptions) { - setState(() { - registrationOptions = - widget.registrationOptions ?? RegistrationOptions(); - }); - } } @override @@ -84,19 +69,22 @@ class _FlutterUserNavigatorUserstoryState Widget _loginScreen() { var theme = Theme.of(context); + var textTheme = theme.textTheme; + var loginOptions = options.loginOptions; + var loginTranslations = loginOptions.translations; var title = Text( - options!.loginTranslations.loginTitle, - style: theme.textTheme.headlineLarge, + loginTranslations.loginTitle, + style: textTheme.headlineLarge, ); - var subtitle = Text(options?.loginTranslations.loginSubtitle ?? ""); + var subtitle = Text(loginTranslations.loginSubtitle ?? ""); FutureOr onLogin(String email, String password) async { - await options?.beforeLogin?.call(email, password); + await options.beforeLogin?.call(email, password); if (!mounted) return; unawaited(showLoadingIndicator(context)); try { - await userService?.loginWithEmailAndPassword( + await userService.loginWithEmailAndPassword( email: email, password: password, ); @@ -104,44 +92,45 @@ class _FlutterUserNavigatorUserstoryState if (!mounted) return; Navigator.of(context, rootNavigator: true).pop(); if (!context.mounted) return; - var authErrorDetails = options!.authExceptionFormatter.format(e); + var authErrorDetails = options.authExceptionFormatter.format(e); await errorScaffoldMessenger(context, authErrorDetails); return; } - await options?.afterLogin?.call(); + await options.afterLogin?.call(); - var onboardingUser = await options?.onBoardedUser?.call(); + var onboardingUser = await options.onBoardedUser?.call(); if (!mounted) return; Navigator.of(context, rootNavigator: true).pop(); - if (options!.useOnboarding && onboardingUser?.onboarded == false) { + + if (options.useOnboarding && onboardingUser?.onboarded == false) { await push( Onboarding( onboardingFinished: (results) async { - await options?.onOnboardingComplete?.call(results); + await options.onOnboardingComplete?.call(results); if (!mounted || !context.mounted) return; Navigator.of(context).pop(); - await pushReplacement(widget.afterLoginScreen); + await pushReplacementIfNotNull(widget.afterLoginScreen); }, ), ); } else { if (!context.mounted) return; - await pushReplacement(widget.afterLoginScreen); + await pushReplacementIfNotNull(widget.afterLoginScreen); } } return EmailPasswordLoginForm( title: title, subtitle: subtitle, - options: options!.loginOptions, + options: options.loginOptions, onLogin: onLogin, onForgotPassword: (email, ctx) async { - await options?.onForgotPassword?.call(email, ctx) ?? + await options.onForgotPassword?.call(email, ctx) ?? await push(_forgotPasswordScreen()); }, onRegister: (email, password, context) async { - await options?.onRegister?.call(email, password, context) ?? + await options.onRegister?.call(email, password, context) ?? await push(_registrationScreen()); }, ); @@ -149,32 +138,36 @@ class _FlutterUserNavigatorUserstoryState Widget _forgotPasswordScreen() { var theme = Theme.of(context); + var textTheme = theme.textTheme; + var forgotOptions = options.forgotPasswordOptions; + var forgotTranslations = forgotOptions.translations; + var title = Text( - options!.forgotPasswordTranslations.forgotPasswordTitle, - style: theme.textTheme.headlineLarge, + forgotTranslations.forgotPasswordTitle, + style: textTheme.headlineLarge, ); var description = Padding( padding: const EdgeInsets.only(top: 8, bottom: 32), child: Text( - options!.forgotPasswordTranslations.forgotPasswordDescription, + forgotTranslations.forgotPasswordDescription, textAlign: TextAlign.center, ), ); FutureOr onRequestForgotPassword(String email) async { - if (options?.onRequestForgotPassword != null) { - await options!.onRequestForgotPassword!(email); + if (options.onRequestForgotPassword != null) { + await options.onRequestForgotPassword!(email); return; } unawaited(showLoadingIndicator(context)); try { - var response = await userService!.requestChangePassword(email: email); + var response = await userService.requestChangePassword(email: email); if (!mounted) return; Navigator.of(context).pop(); if (response.requestSuccesfull) { - await pushReplacement(_forgotPasswordSuccessScreen()); + await pushReplacementIfNotNull(_forgotPasswordSuccessScreen()); } else { await push(_forgotPasswordUnsuccessfullScreen()); } @@ -189,38 +182,38 @@ class _FlutterUserNavigatorUserstoryState return ForgotPasswordForm( title: title, description: description, - loginOptions: options!.loginOptions, - forgotPasswordOptions: widget.forgotPasswordOptions, + loginOptions: options.loginOptions, + forgotPasswordOptions: options.forgotPasswordOptions, onRequestForgotPassword: onRequestForgotPassword, ); } Widget _forgotPasswordSuccessScreen() => ForgotPasswordSuccess( - translations: options!.forgotPasswordTranslations, + translations: options.forgotPasswordOptions.translations, onRequestForgotPassword: () async { - await options?.onForgotPasswordSuccess?.call() ?? + await options.onForgotPasswordSuccess?.call() ?? // ignore: use_build_context_synchronously Navigator.of(context).pop(); }, ); Widget _forgotPasswordUnsuccessfullScreen() => ForgotPasswordUnsuccessfull( - translations: forgotPasswordTranslations!, + translations: options.forgotPasswordOptions.translations, onPressed: () async { - await options?.onForgotPasswordUnsuccessful?.call() ?? + await options.onForgotPasswordUnsuccessful?.call() ?? // ignore: use_build_context_synchronously Navigator.of(context).pop(); }, ); Widget _registrationScreen() => RegistrationScreen( - registrationOptions: registrationOptions!, - userService: userService!, + registrationOptions: options.registrationOptions, + userService: userService, onError: (error) async { - var errorDetails = options!.authExceptionFormatter.format(error); + var errorDetails = options.authExceptionFormatter.format(error); - if (options?.onRegistrationError != null) { - return options!.onRegistrationError!(error, errorDetails); + if (options.onRegistrationError != null) { + return options.onRegistrationError!(error, errorDetails); } await push( _registrationUnsuccessfullScreen( @@ -235,15 +228,15 @@ class _FlutterUserNavigatorUserstoryState return null; }, afterRegistration: () async { - options?.afterRegistration?.call() ?? - await pushReplacement(_registrationSuccessScreen()); + options.afterRegistration?.call() ?? + await pushReplacementIfNotNull(_registrationSuccessScreen()); }, ); Widget _registrationSuccessScreen() => RegistrationSuccess( - registrationOptions: registrationOptions!, + registrationOptions: options.registrationOptions, onPressed: () async { - await options?.afterRegistrationSuccess?.call() ?? + await options.afterRegistrationSuccess?.call() ?? // ignore: use_build_context_synchronously Navigator.of(context).pop(); }, @@ -251,9 +244,9 @@ class _FlutterUserNavigatorUserstoryState Widget _registrationUnsuccessfullScreen(AuthErrorDetails errorDetails) => RegistrationUnsuccessfull( - registrationOptions: registrationOptions!, + registrationOptions: options.registrationOptions, onPressed: () async { - await options!.afterRegistrationUnsuccessful?.call() ?? + await options.afterRegistrationUnsuccessful?.call() ?? // ignore: use_build_context_synchronously Navigator.of(context).pop(); }, @@ -269,8 +262,10 @@ class _FlutterUserNavigatorUserstoryState ); } - Future pushReplacement(Widget screen) async { - if (!context.mounted) return; + /// Pushes a new screen and replaces the current one if the provided screen is + /// not null. + Future pushReplacementIfNotNull(Widget? screen) async { + if (!context.mounted || screen == null) return; await Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => screen, diff --git a/packages/flutter_user/lib/src/models/flutter_user_options.dart b/packages/flutter_user/lib/src/models/flutter_user_options.dart index fb3b05e..4823a7c 100644 --- a/packages/flutter_user/lib/src/models/flutter_user_options.dart +++ b/packages/flutter_user/lib/src/models/flutter_user_options.dart @@ -5,8 +5,7 @@ import "package:flutter_user/src/models/auth_error_details.dart"; class FlutterUserOptions { FlutterUserOptions({ this.loginOptions = const LoginOptions(), - this.loginTranslations = const LoginTranslations(), - this.forgotPasswordTranslations = const ForgotPasswordTranslations(), + this.forgotPasswordOptions = const ForgotPasswordOptions(), this.authExceptionFormatter = const AuthExceptionFormatter(), this.beforeLogin, this.afterLogin, @@ -26,9 +25,8 @@ class FlutterUserOptions { }) : registrationOptions = registrationOptions ?? RegistrationOptions(); final LoginOptions loginOptions; - final LoginTranslations loginTranslations; - final RegistrationOptions? registrationOptions; - final ForgotPasswordTranslations forgotPasswordTranslations; + final RegistrationOptions registrationOptions; + final ForgotPasswordOptions forgotPasswordOptions; final AuthExceptionFormatter authExceptionFormatter; final Future Function(String email, String password)? beforeLogin; final Future Function()? afterLogin; diff --git a/packages/flutter_user/lib/src/models/forgot_password/forgot_password_options.dart b/packages/flutter_user/lib/src/models/forgot_password/forgot_password_options.dart index 829b1af..0931e92 100644 --- a/packages/flutter_user/lib/src/models/forgot_password/forgot_password_options.dart +++ b/packages/flutter_user/lib/src/models/forgot_password/forgot_password_options.dart @@ -9,13 +9,13 @@ import "package:flutter_user/src/widgets/primary_button.dart"; class ForgotPasswordOptions { const ForgotPasswordOptions({ this.forgotPasswordCustomAppBar, - this.forgotPasswordBackgroundColor = const Color(0xffFAF9F6), + this.forgotPasswordBackgroundColor, this.forgotPasswordScreenPadding = const Padding( padding: EdgeInsets.symmetric(horizontal: 60), ), this.forgotPasswordSpacerOptions = const ForgotPasswordSpacerOptions(), this.maxFormWidth = 300, - this.translations = const ForgotPasswordTranslations(), + this.translations = const ForgotPasswordTranslations.empty(), this.accessibilityIdentifiers = const LoginAccessibilityIdentifiers.empty(), this.requestForgotPasswordButtonBuilder = _createRequestForgotPasswordButton, diff --git a/packages/flutter_user/lib/src/models/forgot_password/forgot_password_translations.dart b/packages/flutter_user/lib/src/models/forgot_password/forgot_password_translations.dart index 1ba20de..5ed44c4 100644 --- a/packages/flutter_user/lib/src/models/forgot_password/forgot_password_translations.dart +++ b/packages/flutter_user/lib/src/models/forgot_password/forgot_password_translations.dart @@ -1,5 +1,23 @@ +/// class ForgotPasswordTranslations { + /// Creates a [ForgotPasswordTranslations] with the provided values. const ForgotPasswordTranslations({ + required this.forgotPasswordTitle, + required this.forgotPasswordDescription, + required this.requestForgotPasswordButton, + required this.forgotPasswordSuccessTitle, + required this.forgotPasswordSuccessButtonTitle, + required this.registrationSuccessTitle, + required this.registrationSuccessButtonTitle, + required this.forgotPasswordUnsuccessfullTitle, + required this.forgotPasswordUnsuccessfullDescription, + required this.forgotPasswordUnsuccessButtonTitle, + required this.registrationUnsuccessfullTitle, + }); + + /// Creates a [ForgotPasswordTranslations] with default values. + /// /// This constructor is used when no specific translations are provided. + const ForgotPasswordTranslations.empty({ this.forgotPasswordTitle = "forgot password", this.forgotPasswordDescription = "No worries. Enter your email address below" diff --git a/packages/flutter_user/lib/src/models/login/login_options.dart b/packages/flutter_user/lib/src/models/login/login_options.dart index d1010d8..11bd3f0 100644 --- a/packages/flutter_user/lib/src/models/login/login_options.dart +++ b/packages/flutter_user/lib/src/models/login/login_options.dart @@ -12,7 +12,7 @@ class LoginOptions extends Equatable { const LoginOptions({ this.image, this.spacers = const LoginSpacerOptions(), - this.translations = const LoginTranslations(), + this.translations = const LoginTranslations.empty(), this.validationService, this.biometricsOptions = const LoginBiometricsOptions(), this.accessibilityIdentifiers = const LoginAccessibilityIdentifiers.empty(), @@ -26,7 +26,7 @@ class LoginOptions extends Equatable { this.showObscurePassword = true, this.suffixIconSize, this.suffixIconPadding, - this.loginBackgroundColor = const Color(0xffFAF9F6), + this.loginBackgroundColor, this.forgotPasswordButtonBuilder = _createForgotPasswordButton, this.loginButtonBuilder = _createLoginButton, this.registrationButtonBuilder = _createRegisterButton, @@ -36,11 +36,6 @@ class LoginOptions extends Equatable { contentPadding: EdgeInsets.symmetric(horizontal: 8), labelText: "Email address", border: OutlineInputBorder(), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Color(0xff71C6D1), - ), - ), labelStyle: TextStyle( color: Colors.black, fontWeight: FontWeight.w400, @@ -51,11 +46,6 @@ class LoginOptions extends Equatable { contentPadding: EdgeInsets.symmetric(horizontal: 8), labelText: "Password", border: OutlineInputBorder(), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Color(0xff71C6D1), - ), - ), labelStyle: TextStyle( color: Colors.black, fontWeight: FontWeight.w400, @@ -83,7 +73,7 @@ class LoginOptions extends Equatable { final LoginButtonBuilder forgotPasswordButtonBuilder; final LoginButtonBuilder loginButtonBuilder; final LoginButtonBuilder registrationButtonBuilder; - final Color loginBackgroundColor; + final Color? loginBackgroundColor; final InputContainerBuilder emailInputContainerBuilder; final InputContainerBuilder passwordInputContainerBuilder; diff --git a/packages/flutter_user/lib/src/models/login/login_spacer_options.dart b/packages/flutter_user/lib/src/models/login/login_spacer_options.dart index 34c70a5..7d38ffd 100644 --- a/packages/flutter_user/lib/src/models/login/login_spacer_options.dart +++ b/packages/flutter_user/lib/src/models/login/login_spacer_options.dart @@ -9,23 +9,23 @@ class LoginSpacerOptions extends Equatable { this.spacerAfterForm, this.spacerAfterButton, this.titleSpacer = 1, - this.spacerBeforeTitle = 8, + this.spacerBeforeImage = 8, this.spacerAfterTitle = 2, this.formFlexValue = 2, }); - /// Flex value for the spacer before the title. - final int? spacerBeforeTitle; + /// Flex value for the spacer before the image. + final int? spacerBeforeImage; + + /// Flex value for the spacer between the image and title + final int? spacerAfterImage; /// Flex value for the spacer between the title and subtitle. final int? spacerAfterTitle; - /// Flex value for the spacer between the subtitle and image. + /// Flex value for the spacer between the subtitle and form final int? spacerAfterSubtitle; - /// Flex value for the spacer between the image and form. - final int? spacerAfterImage; - /// Flex value for the spacer between the form and button. final int? spacerAfterForm; @@ -40,7 +40,7 @@ class LoginSpacerOptions extends Equatable { @override List get props => [ - spacerBeforeTitle, + spacerBeforeImage, spacerAfterTitle, spacerAfterSubtitle, spacerAfterImage, diff --git a/packages/flutter_user/lib/src/models/login/login_translations.dart b/packages/flutter_user/lib/src/models/login/login_translations.dart index 689cbff..e61716b 100644 --- a/packages/flutter_user/lib/src/models/login/login_translations.dart +++ b/packages/flutter_user/lib/src/models/login/login_translations.dart @@ -2,6 +2,18 @@ import "package:equatable/equatable.dart"; class LoginTranslations extends Equatable { const LoginTranslations({ + required this.loginTitle, + required this.loginSubtitle, + required this.emailEmpty, + required this.passwordEmpty, + required this.emailInvalid, + required this.loginButton, + required this.forgotPasswordButton, + required this.registrationButton, + required this.biometricsLoginMessage, + }); + + const LoginTranslations.empty({ this.loginTitle = "log in", this.loginSubtitle, this.emailEmpty = "Please enter your email address", diff --git a/packages/flutter_user/lib/src/models/registration/auth_action.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_action.dart similarity index 95% rename from packages/flutter_user/lib/src/models/registration/auth_action.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_action.dart index b00a46c..7ceb029 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_action.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_action.dart @@ -7,7 +7,7 @@ import "package:flutter/material.dart"; /// An action that can be performed during authentication. class AuthAction { /// Constructs an [AuthAction] object. - AuthAction({ + const AuthAction({ required this.title, required this.onPress, }); diff --git a/packages/flutter_user/lib/src/models/registration/auth_bool_field.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_bool_field.dart similarity index 96% rename from packages/flutter_user/lib/src/models/registration/auth_bool_field.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_bool_field.dart index bf90373..e28e791 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_bool_field.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_bool_field.dart @@ -4,7 +4,7 @@ import "package:flutter/material.dart"; import "package:flutter_input_library/flutter_input_library.dart"; -import "package:flutter_user/src/models/registration/auth_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_field.dart"; /// A field for capturing boolean values in a Flutter form. /// diff --git a/packages/flutter_user/lib/src/models/registration/auth_drop_down.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_drop_down.dart similarity index 96% rename from packages/flutter_user/lib/src/models/registration/auth_drop_down.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_drop_down.dart index a15fbc9..2b47618 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_drop_down.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_drop_down.dart @@ -1,5 +1,5 @@ import "package:flutter/material.dart"; -import "package:flutter_user/src/models/registration/auth_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_field.dart"; /// A field for capturing dropdown selections in a Flutter form. /// diff --git a/packages/flutter_user/lib/src/models/registration/auth_field.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_field.dart similarity index 96% rename from packages/flutter_user/lib/src/models/registration/auth_field.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_field.dart index 9b0bc4f..3b87fd8 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_field.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_field.dart @@ -42,7 +42,7 @@ abstract class AuthField { final Widget? title; /// A list of validation functions for the field. - List validators; + final List validators; /// Builds the widget representing the field. /// diff --git a/packages/flutter_user/lib/src/models/registration/auth_pass_field.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_pass_field.dart similarity index 97% rename from packages/flutter_user/lib/src/models/registration/auth_pass_field.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_pass_field.dart index 7a55926..1a349b0 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_pass_field.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_pass_field.dart @@ -4,7 +4,7 @@ import "package:flutter/material.dart"; import "package:flutter_input_library/flutter_input_library.dart"; -import "package:flutter_user/src/models/registration/auth_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_field.dart"; /// A field for capturing password inputs in a Flutter form. /// diff --git a/packages/flutter_user/lib/src/models/registration/auth_step.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_step.dart similarity index 66% rename from packages/flutter_user/lib/src/models/registration/auth_step.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_step.dart index 6ef85f7..7914047 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_step.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_step.dart @@ -2,15 +2,15 @@ // // SPDX-License-Identifier: BSD-3-Clause -import "package:flutter_user/src/models/registration/auth_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_field.dart"; /// A step in the authentication process. class AuthStep { /// Constructs an [AuthStep] object. - AuthStep({ + const AuthStep({ required this.fields, }); /// The fields in the step. - List fields; + final List fields; } diff --git a/packages/flutter_user/lib/src/models/registration/auth_text_field.dart b/packages/flutter_user/lib/src/models/registration/auth/auth_text_field.dart similarity index 97% rename from packages/flutter_user/lib/src/models/registration/auth_text_field.dart rename to packages/flutter_user/lib/src/models/registration/auth/auth_text_field.dart index c4d00de..f8e9e75 100644 --- a/packages/flutter_user/lib/src/models/registration/auth_text_field.dart +++ b/packages/flutter_user/lib/src/models/registration/auth/auth_text_field.dart @@ -3,7 +3,7 @@ // SPDX-License-Identifier: BSD-3-Clause import "package:flutter/material.dart"; -import "package:flutter_user/src/models/registration/auth_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_field.dart"; /// A field for capturing text inputs in a Flutter form. /// diff --git a/packages/flutter_user/lib/src/models/registration/registration_options.dart b/packages/flutter_user/lib/src/models/registration/registration_options.dart index ca162bd..e38d87b 100644 --- a/packages/flutter_user/lib/src/models/registration/registration_options.dart +++ b/packages/flutter_user/lib/src/models/registration/registration_options.dart @@ -1,8 +1,8 @@ import "package:flutter/material.dart"; import "package:flutter_user/src/models/login/login_options.dart"; -import "package:flutter_user/src/models/registration/auth_pass_field.dart"; -import "package:flutter_user/src/models/registration/auth_step.dart"; -import "package:flutter_user/src/models/registration/auth_text_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_pass_field.dart"; +import "package:flutter_user/src/models/registration/auth/auth_step.dart"; +import "package:flutter_user/src/models/registration/auth/auth_text_field.dart"; import "package:flutter_user/src/models/registration/registration_spacer_options.dart"; import "package:flutter_user/src/models/registration/registration_translations.dart"; @@ -10,9 +10,9 @@ class RegistrationOptions { RegistrationOptions({ this.translations = const RegistrationTranslations.empty(), this.accessibilityIdentifiers = const LoginAccessibilityIdentifiers.empty(), - this.registrationBackgroundColor = const Color(0xffFAF9F6), + this.registrationBackgroundColor, this.maxFormWidth = 300, - this.customAppbarBuilder = _createCustomAppBar, + this.customAppbarBuilder = _defaultAppBar, this.steps = const [], this.title, this.spacerOptions = const RegistrationSpacerOptions(), @@ -33,7 +33,7 @@ class RegistrationOptions { /// This is used for testing purposes. final LoginAccessibilityIdentifiers accessibilityIdentifiers; - final Color registrationBackgroundColor; + final Color? registrationBackgroundColor; final double maxFormWidth; final AppBar Function(String title) customAppbarBuilder; List steps; @@ -46,10 +46,8 @@ class RegistrationOptions { final Widget? loginButton; } -AppBar _createCustomAppBar(String title) => AppBar( - iconTheme: const IconThemeData(color: Colors.black, size: 16), +AppBar _defaultAppBar(String title) => AppBar( title: Text(title), - backgroundColor: Colors.transparent, ); List getDefaultSteps({ @@ -86,8 +84,6 @@ List getDefaultSteps({ contentPadding: const EdgeInsets.symmetric(horizontal: 8), label: labelBuilder?.call(translations.defaultEmailLabel), hintText: translations.defaultEmailHint, - border: const OutlineInputBorder(), - focusedBorder: const OutlineInputBorder(), ), textStyle: textStyle, padding: const EdgeInsets.symmetric(vertical: 20), diff --git a/packages/flutter_user/lib/src/models/registration/registration_translations.dart b/packages/flutter_user/lib/src/models/registration/registration_translations.dart index d36a24e..4f0e24e 100644 --- a/packages/flutter_user/lib/src/models/registration/registration_translations.dart +++ b/packages/flutter_user/lib/src/models/registration/registration_translations.dart @@ -29,20 +29,9 @@ class RegistrationTranslations { required this.registrationUnsuccessButtonTitle, }); - // this.registrationSuccessTitle = "your registration was successful", - // this.registrationSuccessButtonTitle = "Finish", - // this.registrationUnsuccessfullTitle = "something went wrong", - // this.registrationEmailUnsuccessfullDescription = - // "This email address is already" - // " associated with an account. Please try again.", - // this.registrationPasswordUnsuccessfullDescription = - // "The password you entered" - // " is invalid. Please try again.", - // this.registrationUnsuccessButtonTitle = "Try again", - /// Constructs a [RegistrationTranslations] object with empty strings. const RegistrationTranslations.empty() - : title = "", + : title = "Registration", registerBtn = "Register", previousStepBtn = "Previous", nextStepBtn = "Next", diff --git a/packages/flutter_user/lib/src/screens/email_password_login_form.dart b/packages/flutter_user/lib/src/screens/email_password_login_form.dart index 0bdea1c..f7cd03c 100644 --- a/packages/flutter_user/lib/src/screens/email_password_login_form.dart +++ b/packages/flutter_user/lib/src/screens/email_password_login_form.dart @@ -5,6 +5,7 @@ import "package:flutter_accessibility/flutter_accessibility.dart"; import "package:flutter_user/src/models/login/login_options.dart"; import "package:flutter_user/src/services/local_auth.dart"; import "package:flutter_user/src/widgets/biometrics_button.dart"; +import "package:flutter_user/src/widgets/optional_spacer.dart"; class EmailPasswordLoginForm extends StatefulWidget { /// Constructs an [EmailPasswordLoginForm] widget. @@ -223,7 +224,6 @@ class _EmailPasswordLoginFormState extends State { return Scaffold( backgroundColor: options.loginBackgroundColor, body: CustomScrollView( - physics: const ScrollPhysics(), slivers: [ SliverFillRemaining( hasScrollBody: false, @@ -262,10 +262,16 @@ class _EmailPasswordLoginFormState extends State { passwordTextFormField, ), ), - forgotPasswordButton, - if (options.spacers.spacerAfterForm != null) ...[ - Spacer(flex: options.spacers.spacerAfterForm!), - ], + Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 8.0, + ), + child: forgotPasswordButton, + ), + ...buildOptionalSpacer( + options.spacers.spacerAfterForm, + ), if (options .biometricsOptions.loginWithBiometrics) ...[ Row( @@ -288,11 +294,14 @@ class _EmailPasswordLoginFormState extends State { ), ], if (widget.onRegister != null) ...[ + const SizedBox( + height: 8.0, + ), registerButton, ], - if (options.spacers.spacerAfterButton != null) ...[ - Spacer(flex: options.spacers.spacerAfterButton!), - ], + ...buildOptionalSpacer( + options.spacers.spacerAfterButton, + ), ], ), ), @@ -324,9 +333,18 @@ class _LoginTitle extends StatelessWidget { var theme = Theme.of(context); return Column( children: [ - if (options.spacers.spacerBeforeTitle != null) ...[ - Spacer(flex: options.spacers.spacerBeforeTitle!), + ...buildOptionalSpacer( + options.spacers.spacerBeforeImage, + ), + if (options.image != null) ...[ + Padding( + padding: const EdgeInsets.all(16), + child: options.image, + ), ], + ...buildOptionalSpacer( + options.spacers.spacerAfterImage, + ), if (title != null) ...[ Align( alignment: Alignment.topCenter, @@ -336,9 +354,9 @@ class _LoginTitle extends StatelessWidget { ), ), ], - if (options.spacers.spacerAfterTitle != null) ...[ - Spacer(flex: options.spacers.spacerAfterTitle!), - ], + ...buildOptionalSpacer( + options.spacers.spacerAfterTitle, + ), if (subtitle != null) ...[ Align( alignment: Alignment.topCenter, @@ -348,18 +366,9 @@ class _LoginTitle extends StatelessWidget { ), ), ], - if (options.spacers.spacerAfterSubtitle != null) ...[ - Spacer(flex: options.spacers.spacerAfterSubtitle!), - ], - if (options.image != null) ...[ - Padding( - padding: const EdgeInsets.all(16), - child: options.image, - ), - ], - if (options.spacers.spacerAfterImage != null) ...[ - Spacer(flex: options.spacers.spacerAfterImage!), - ], + ...buildOptionalSpacer( + options.spacers.spacerAfterSubtitle, + ), ], ); } diff --git a/packages/flutter_user/lib/src/screens/forgot_password_form.dart b/packages/flutter_user/lib/src/screens/forgot_password_form.dart index 029f6ae..55987f0 100644 --- a/packages/flutter_user/lib/src/screens/forgot_password_form.dart +++ b/packages/flutter_user/lib/src/screens/forgot_password_form.dart @@ -5,6 +5,7 @@ import "package:flutter_accessibility/flutter_accessibility.dart"; import "package:flutter_user/src/models/forgot_password/forgot_password_options.dart"; import "package:flutter_user/src/models/login/login_options.dart"; import "package:flutter_user/src/screens/email_password_login_form.dart"; +import "package:flutter_user/src/widgets/optional_spacer.dart"; class ForgotPasswordForm extends StatefulWidget { /// Constructs a [ForgotPasswordForm] widget. @@ -77,10 +78,7 @@ class _ForgotPasswordFormState extends State { return Scaffold( backgroundColor: forgotPasswordOptions.forgotPasswordBackgroundColor, - appBar: forgotPasswordOptions.forgotPasswordCustomAppBar ?? - AppBar( - backgroundColor: const Color(0xffFAF9F6), - ), + appBar: forgotPasswordOptions.forgotPasswordCustomAppBar ?? AppBar(), body: Padding( padding: forgotPasswordOptions.forgotPasswordScreenPadding.padding, child: CustomScrollView( @@ -91,14 +89,10 @@ class _ForgotPasswordFormState extends State { fillOverscroll: true, child: Column( children: [ - if (forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerBeforeTitle != - null) ...[ - Spacer( - flex: forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerBeforeTitle!, - ), - ], + ...buildOptionalSpacer( + forgotPasswordOptions + .forgotPasswordSpacerOptions.spacerBeforeTitle, + ), Align( alignment: Alignment.topCenter, child: wrapWithDefaultStyle( @@ -106,14 +100,10 @@ class _ForgotPasswordFormState extends State { theme.textTheme.displaySmall, ), ), - if (forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerAfterTitle != - null) ...[ - Spacer( - flex: forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerAfterTitle!, - ), - ], + ...buildOptionalSpacer( + forgotPasswordOptions + .forgotPasswordSpacerOptions.spacerAfterTitle, + ), Align( alignment: Alignment.topCenter, child: wrapWithDefaultStyle( @@ -123,14 +113,10 @@ class _ForgotPasswordFormState extends State { ), ), ), - if (forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerAfterDescription != - null) ...[ - Spacer( - flex: forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerAfterDescription!, - ), - ], + ...buildOptionalSpacer( + forgotPasswordOptions + .forgotPasswordSpacerOptions.spacerAfterDescription, + ), Expanded( flex: forgotPasswordOptions .forgotPasswordSpacerOptions.formFlexValue, @@ -168,14 +154,10 @@ class _ForgotPasswordFormState extends State { ), ), ), - if (forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerBeforeButton != - null) ...[ - Spacer( - flex: forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerBeforeButton!, - ), - ], + ...buildOptionalSpacer( + forgotPasswordOptions + .forgotPasswordSpacerOptions.spacerBeforeButton, + ), AnimatedBuilder( animation: _formValid, builder: (context, snapshot) => Align( @@ -201,14 +183,10 @@ class _ForgotPasswordFormState extends State { ), ), ), - if (forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerAfterButton != - null) ...[ - Spacer( - flex: forgotPasswordOptions - .forgotPasswordSpacerOptions.spacerAfterButton!, - ), - ], + ...buildOptionalSpacer( + forgotPasswordOptions + .forgotPasswordSpacerOptions.spacerAfterButton, + ), ], ), ), diff --git a/packages/flutter_user/lib/src/screens/forgot_password_success.dart b/packages/flutter_user/lib/src/screens/forgot_password_success.dart index 3395613..3f720da 100644 --- a/packages/flutter_user/lib/src/screens/forgot_password_success.dart +++ b/packages/flutter_user/lib/src/screens/forgot_password_success.dart @@ -7,7 +7,7 @@ class ForgotPasswordSuccess extends StatelessWidget { /// Forgot Password Success constructor const ForgotPasswordSuccess({ required this.onRequestForgotPassword, - this.translations = const ForgotPasswordTranslations(), + this.translations = const ForgotPasswordTranslations.empty(), super.key, }); diff --git a/packages/flutter_user/lib/src/screens/registration_screen.dart b/packages/flutter_user/lib/src/screens/registration_screen.dart index 6fe85cf..7f1a7e6 100644 --- a/packages/flutter_user/lib/src/screens/registration_screen.dart +++ b/packages/flutter_user/lib/src/screens/registration_screen.dart @@ -55,7 +55,7 @@ class _RegistrationScreenState extends State { ); } - Future onNext() async { + Future onClickNext() async { FocusScope.of(context).unfocus(); if (!_formKey.currentState!.validate()) { @@ -65,57 +65,52 @@ class _RegistrationScreenState extends State { _formKey.currentState!.save(); _validate(_pageController.page!.toInt()); - var success = await onFinish(); - if (success) { - return; - } - - await _pageController.nextPage( - duration: _animationDuration, - curve: _animationCurve, - ); + await goToNextPage(); } - Future onFinish() async { + Future goToNextPage() async { if (_pageController.page!.toInt() == widget.registrationOptions.steps.length - 1) { - var values = {}; - - for (var step in widget.registrationOptions.steps) { - for (var field in step.fields) { - values[field.name] = field.value; - } - } + await onFinish(); + } else { + await _pageController.nextPage( + duration: _animationDuration, + curve: _animationCurve, + ); + } + } - try { - await widget.userService.register(values: values); - } on AuthException catch (e) { - var pageToReturn = await widget.onError.call(e); + Future onFinish() async { + var values = {}; - if (pageToReturn != null) { - if (pageToReturn == _pageController.page!.toInt()) { - return true; - } - await _pageController.animateToPage( - pageToReturn, - duration: _animationDuration, - curve: _animationCurve, - ); - return true; - } + for (var step in widget.registrationOptions.steps) { + for (var field in step.fields) { + values[field.name] = field.value; } + } + try { + await widget.userService.register(values: values); await widget.afterRegistration.call(); + } on AuthException catch (e) { + var pageToReturn = await widget.onError.call(e); - return true; + if (pageToReturn != null && + pageToReturn != _pageController.page!.toInt()) { + await _pageController.animateToPage( + pageToReturn, + duration: _animationDuration, + curve: _animationCurve, + ); + } } - return false; } @override Widget build(BuildContext context) { var theme = Theme.of(context); var registrationOptions = widget.registrationOptions; + return Scaffold( backgroundColor: registrationOptions.registrationBackgroundColor, appBar: registrationOptions.customAppbarBuilder.call( @@ -129,9 +124,8 @@ class _RegistrationScreenState extends State { controller: _pageController, physics: const NeverScrollableScrollPhysics(), children: [ - for (var currentStep = 0; - currentStep < registrationOptions.steps.length; - currentStep++) ...[ + for (var (index, step) + in registrationOptions.steps.indexed) ...[ Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -141,16 +135,14 @@ class _RegistrationScreenState extends State { mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Expanded( + Spacer( flex: registrationOptions .spacerOptions.beforeTitleFlex, - child: Container(), ), registrationOptions.title!, - Expanded( + Spacer( flex: registrationOptions .spacerOptions.afterTitleFlex, - child: Container(), ), ], ), @@ -161,8 +153,7 @@ class _RegistrationScreenState extends State { alignment: Alignment.topCenter, child: Column( children: [ - for (AuthField field in registrationOptions - .steps[currentStep].fields) ...[ + for (AuthField field in step.fields) ...[ if (field.title != null) ...[ wrapWithDefaultStyle( style: theme.textTheme.headlineLarge!, @@ -174,7 +165,7 @@ class _RegistrationScreenState extends State { maxWidth: registrationOptions.maxFormWidth, ), child: field.build(context, () { - _validate(currentStep); + _validate(index); }), ), ], @@ -201,10 +192,10 @@ class _RegistrationScreenState extends State { onPrevious, registrationOptions .translations.previousStepBtn, - currentStep, + index, ) ?? Visibility( - visible: currentStep != 0, + visible: index != 0, child: stepButton( buttonText: registrationOptions .translations.previousStepBtn, @@ -213,9 +204,10 @@ class _RegistrationScreenState extends State { }, ), ), + const SizedBox(width: 16), registrationOptions.nextButtonBuilder?.call( - onPrevious, - currentStep == + onClickNext, + index == registrationOptions .steps.length - 1 @@ -223,10 +215,10 @@ class _RegistrationScreenState extends State { .translations.registerBtn : registrationOptions .translations.nextStepBtn, - currentStep, + index, ) ?? stepButton( - buttonText: currentStep == + buttonText: index == registrationOptions .steps.length - 1 @@ -235,18 +227,17 @@ class _RegistrationScreenState extends State { : registrationOptions .translations.nextStepBtn, onTap: () async { - await onNext(); + await onClickNext(); }, ), ], ), ), ), - const SizedBox( - height: 8, - ), + const SizedBox(height: 8), if (registrationOptions.loginButton != null) ...[ registrationOptions.loginButton!, + const SizedBox(height: 8), ], ], ), diff --git a/packages/flutter_user/lib/src/screens/registration_unsuccessfull.dart b/packages/flutter_user/lib/src/screens/registration_unsuccessfull.dart index 871c507..7960401 100644 --- a/packages/flutter_user/lib/src/screens/registration_unsuccessfull.dart +++ b/packages/flutter_user/lib/src/screens/registration_unsuccessfull.dart @@ -56,10 +56,13 @@ class RegistrationUnsuccessfull extends StatelessWidget { ), child: SafeArea( bottom: true, - child: PrimaryButton( - buttonTitle: registrationOptions - .translations.registrationUnsuccessButtonTitle, - onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: PrimaryButton( + buttonTitle: registrationOptions + .translations.registrationUnsuccessButtonTitle, + onPressed: onPressed, + ), ), ), ), diff --git a/packages/flutter_user/lib/src/widgets/optional_spacer.dart b/packages/flutter_user/lib/src/widgets/optional_spacer.dart new file mode 100644 index 0000000..a8ffb89 --- /dev/null +++ b/packages/flutter_user/lib/src/widgets/optional_spacer.dart @@ -0,0 +1,17 @@ +import "package:flutter/material.dart"; + +/// Helper function to conditionally add a Spacer. +/// +/// If [flex] is not null, a [Spacer] with the given flex value is returned. +/// Otherwise, an empty list is returned, effectively adding nothing to the +/// widget tree. +List buildOptionalSpacer(int? flex) { + if (flex != null) { + return [ + Spacer( + flex: flex, + ), + ]; + } + return []; +} diff --git a/packages/flutter_user/pubspec.yaml b/packages/flutter_user/pubspec.yaml index 17e1247..5088c38 100644 --- a/packages/flutter_user/pubspec.yaml +++ b/packages/flutter_user/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_user description: "Flutter Userstory for onboarding, login, and registration." -version: 6.4.0 +version: 7.0.0 repository: https://github.com/Iconica-Development/flutter_user publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub @@ -22,7 +22,7 @@ dependencies: version: ^4.1.0 user_repository_interface: hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ - version: ^6.4.0 + version: ^7.0.0 flutter_accessibility: hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub version: ^0.0.3 diff --git a/packages/rest_user_repository/.gitignore b/packages/rest_user_repository/.gitignore new file mode 100644 index 0000000..ac5aa98 --- /dev/null +++ b/packages/rest_user_repository/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/packages/rest_user_repository/CHANGELOG.md b/packages/rest_user_repository/CHANGELOG.md new file mode 120000 index 0000000..699cc9e --- /dev/null +++ b/packages/rest_user_repository/CHANGELOG.md @@ -0,0 +1 @@ +../../CHANGELOG.md \ No newline at end of file diff --git a/packages/rest_user_repository/CONTRIBUTING.md b/packages/rest_user_repository/CONTRIBUTING.md new file mode 120000 index 0000000..f939e75 --- /dev/null +++ b/packages/rest_user_repository/CONTRIBUTING.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file diff --git a/packages/rest_user_repository/LICENSE b/packages/rest_user_repository/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/packages/rest_user_repository/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/rest_user_repository/README.md b/packages/rest_user_repository/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/packages/rest_user_repository/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packages/rest_user_repository/analysis_options.yaml b/packages/rest_user_repository/analysis_options.yaml new file mode 100644 index 0000000..0736605 --- /dev/null +++ b/packages/rest_user_repository/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/components_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/rest_user_repository/lib/rest_user_repository.dart b/packages/rest_user_repository/lib/rest_user_repository.dart new file mode 100644 index 0000000..85d3949 --- /dev/null +++ b/packages/rest_user_repository/lib/rest_user_repository.dart @@ -0,0 +1,213 @@ +import "dart:async"; +import "dart:convert"; + +import "package:dart_api_service/dart_api_service.dart"; +import "package:user_repository_interface/user_repository_interface.dart"; + +class _TokenAuthService extends AuthenticationService { + String? _token; + + set token(String? newToken) => _token = newToken; + void clearToken() => _token = null; + bool get hasToken => _token != null; + + @override + FutureOr getCredentials() { + if (!hasToken) { + throw const RequiresRecentLoginError( + message: "User is not authenticated.", + ); + } + return TokenAuthCredentials(token: _token!); + } +} + +/// An implementation of [UserRepositoryInterface] that uses a REST API. +class RestUserRepository extends HttpApiService + implements UserRepositoryInterface { + /// Creates an instance of the REST user repository. + /// + /// Requires the [baseUrl] for the API endpoints. + RestUserRepository({ + required super.baseUrl, // Pass baseUrl to HttpApiService + super.client, + this.apiPrefix = "", + this.loginEndpoint = "/auth/token", + this.registerEndpoint = "/user", + this.passwordResetEndpoint = "/user/password-reset/request", + this.loggedInUserEndpoint = "/users/me", + this.onTokenReceived, + }) : _authService = _TokenAuthService(), + super( + authenticationService: _TokenAuthService(), + defaultHeaders: const { + "Content-Type": "application/json", + "Accept": "application/json", + }, + apiResponseConverter: const MapJsonResponseConverter(), + ); + + final _TokenAuthService _authService; + + /// The prefix for the API endpoints, allowing for versioning + /// or path adjustments. + final String apiPrefix; + + /// Endpoint for user login. + final String loginEndpoint; + + /// Endpoint for user registration. + final String registerEndpoint; + + /// Endpoint for password reset requests. + final String passwordResetEndpoint; + + /// Endpoint for fetching the logged-in user's profile. + final String loggedInUserEndpoint; + + /// Callback to handle the received token. + final void Function(String? token)? onTokenReceived; + + /// The base endpoint for all API calls within this repository, + /// incorporating the [apiPrefix]. + Endpoint get _baseEndpoint => endpoint(apiPrefix); + + /// Logs in a user with the provided [email] and [password]. + /// + /// On a successful login, it returns an [AuthResponse] and stores the + /// authentication token. Throws an [AuthException] on failure. + @override + Future loginWithEmailAndPassword({ + required String email, + required String password, + }) async { + try { + var converter = + ModelJsonResponseConverter>( + deserialize: (json) => AuthResponse(userObject: json["user"]), + serialize: (body) => body, + ); + var endpoint = + _baseEndpoint.child(loginEndpoint).withConverter(converter); + + var response = await endpoint.post( + requestModel: {"email": email, "password": password}, + ); + + var userMap = response.result?.userObject as Map?; + var token = userMap?["token"] as String?; + _authService.token = token; + onTokenReceived?.call(token); + + return response.result!; + } on ApiException catch (e) { + throw _handleAuthError(e); + } + } + + /// Registers a new user with the given [values]. + /// + /// On a successful registration, it returns an [AuthResponse] and stores + /// the authentication token. Throws an [AuthException] on failure. + @override + Future register({ + required Map values, + }) async { + try { + var converter = + ModelJsonResponseConverter>( + deserialize: (json) => AuthResponse(userObject: json["user"]), + serialize: (body) => body, + ); + var endpoint = + _baseEndpoint.child(registerEndpoint).withConverter(converter); + + var response = await endpoint.post(requestModel: values); + + var userMap = response.result?.userObject as Map?; + var token = userMap?["token"] as String?; + _authService.token = token; + onTokenReceived?.call(token); + + return response.result!; + } on ApiException catch (e) { + throw _handleAuthError(e); + } + } + + /// Requests a password change for the user associated with the [email]. + /// + /// Returns a [RequestPasswordResponse] indicating if the request was + /// successful. + @override + Future requestChangePassword({ + required String email, + }) async { + try { + var converter = ModelJsonResponseConverter>( + deserialize: (json) => RequestPasswordResponse( + requestSuccesfull: json["success"] ?? false, + ), + serialize: (body) => body, + ); + var endpoint = + _baseEndpoint.child(passwordResetEndpoint).withConverter(converter); + + var response = await endpoint.post(requestModel: {"email": email}); + return response.statusCode == 200 + ? const RequestPasswordResponse(requestSuccesfull: true) + : response.result!; + } on ApiException catch (e) { + throw _handleAuthError(e); + } + } + + /// Fetches the profile of the currently logged-in user. + /// + /// Throws a [RequiresRecentLoginError] if the user is not authenticated. + @override + Future getLoggedInUser() async { + try { + var endpoint = _baseEndpoint.child(loggedInUserEndpoint).authenticate(); + var response = await endpoint.get(); + return jsonDecode(response.inner.body); + } on ApiException catch (e) { + throw _handleAuthError(e); + } + } + + /// Checks if a user is currently logged in. + @override + Future isLoggedIn() async => _authService.hasToken; + + /// Logs the current user out by clearing the stored authentication token. + @override + Future logout() async { + _authService.clearToken(); + onTokenReceived?.call(null); + return true; + } + + AuthException _handleAuthError(ApiException e) { + var message = _getErrorMessage(e.inner.body); + return switch (e.statusCode) { + 400 => InvalidCredentialError(message: message ?? "Invalid request."), + 401 => WrongPasswordError(message: message ?? "Incorrect credentials."), + 403 => UserDisabledError(message: message ?? "User account is disabled."), + 404 => UserNotFoundError(message: message ?? "User not found."), + 409 => + EmailAlreadyInUseError(message: message ?? "Email is already in use."), + 429 => TooManyRequestsError(message: message ?? "Too many requests."), + _ => GenericAuthError(message: message ?? "An unknown error occurred."), + }; + } + + String? _getErrorMessage(String body) { + try { + return (jsonDecode(body) as Map)["message"] as String?; + } on Exception { + return null; + } + } +} diff --git a/packages/rest_user_repository/pubspec.yaml b/packages/rest_user_repository/pubspec.yaml new file mode 100644 index 0000000..6c16374 --- /dev/null +++ b/packages/rest_user_repository/pubspec.yaml @@ -0,0 +1,22 @@ +name: rest_user_repository +description: "RESTful implementation of the user_repository_interface for flutter_user package" +version: 7.0.0 +repository: https://github.com/Iconica-Development/flutter_user + +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub + +environment: + sdk: ^3.5.1 + +dependencies: + dart_api_service: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ + version: ^1.1.2 + user_repository_interface: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ + version: ^7.0.0 + +dev_dependencies: + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ + version: ^7.0.0 diff --git a/packages/user_repository_interface/pubspec.yaml b/packages/user_repository_interface/pubspec.yaml index 14b9f77..4fc94b3 100644 --- a/packages/user_repository_interface/pubspec.yaml +++ b/packages/user_repository_interface/pubspec.yaml @@ -1,17 +1,12 @@ name: user_repository_interface description: "user_repository_interface for flutter_user package" -version: 6.4.0 +version: 7.0.0 repository: https://github.com/Iconica-Development/flutter_user publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub environment: sdk: ^3.5.1 - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter dev_dependencies: flutter_iconica_analysis: