diff --git a/app/devtools_options.yaml b/app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/app/lib/presentation/navigation/routers.dart b/app/lib/presentation/navigation/routers.dart index 74fef07..648fde8 100644 --- a/app/lib/presentation/navigation/routers.dart +++ b/app/lib/presentation/navigation/routers.dart @@ -21,7 +21,7 @@ enum Routes { String get path => '/$name'; String get subPath => name; - void nav(BuildContext context, {Object? extra}) { + void go(BuildContext context, {Object? extra}) { context.router.goNamed( name, extra: extra, @@ -63,7 +63,7 @@ class Routers { return; } debugPrint('Navigating to app route'); - Routes.app.nav(context); + Routes.app.go(context); break; case AuthStateUnauthenticated _: debugPrint( @@ -74,7 +74,7 @@ class Routers { return; } debugPrint('Navigating to auth route'); - Routes.auth.nav(context); + Routes.auth.go(context); break; case _: } diff --git a/app/lib/presentation/resources/dim.dart b/app/lib/presentation/resources/dim.dart index d8fcc70..027ea22 100644 --- a/app/lib/presentation/resources/dim.dart +++ b/app/lib/presentation/resources/dim.dart @@ -7,7 +7,7 @@ class Dimen { static const loadingSpinnerSize = 32.0; static const loadingSpinnerSizeS = 16.0; - static const double loginFormMaxWidth = 400.0; + static const double authFormMaxWidth = 400.0; static const spacingXxs = 2.0; static const spacingXs = 4.0; diff --git a/app/lib/presentation/resources/locale/generated/intl/messages_en.dart b/app/lib/presentation/resources/locale/generated/intl/messages_en.dart index 025ec66..9fe9aca 100644 --- a/app/lib/presentation/resources/locale/generated/intl/messages_en.dart +++ b/app/lib/presentation/resources/locale/generated/intl/messages_en.dart @@ -31,6 +31,7 @@ class MessageLookup extends MessageLookupByLibrary { "This website uses cookies", ), "ctaLogin": MessageLookupByLibrary.simpleMessage("Login"), + "ctaSignUp": MessageLookupByLibrary.simpleMessage("Sign Up"), "errorEmailInvalid": MessageLookupByLibrary.simpleMessage( "Please enter a valid email address.", ), @@ -43,9 +44,18 @@ class MessageLookup extends MessageLookupByLibrary { "errorPasswordWeak": MessageLookupByLibrary.simpleMessage( "Password is too weak.", ), + "errorPasswordsDoNotMatch": MessageLookupByLibrary.simpleMessage( + "Passwords do not match.", + ), + "hintTermsAndConditions": MessageLookupByLibrary.simpleMessage( + "This should open the terms and conditions URL.", + ), "labelAgreeToTerms": MessageLookupByLibrary.simpleMessage( "I agree to the Terms and Conditions", ), + "labelConfirmPassword": MessageLookupByLibrary.simpleMessage( + "Confirm Password", + ), "labelEmail": MessageLookupByLibrary.simpleMessage("Email"), "labelPassword": MessageLookupByLibrary.simpleMessage("Password"), "loginErrorInvalidCredentials": MessageLookupByLibrary.simpleMessage( @@ -67,5 +77,9 @@ class MessageLookup extends MessageLookupByLibrary { "titleLoginSubtitle": MessageLookupByLibrary.simpleMessage( "Use your email and password to login to your account.", ), + "titleSignUp": MessageLookupByLibrary.simpleMessage("Sign Up"), + "titleSignUpSubtitle": MessageLookupByLibrary.simpleMessage( + "Create an account using your email and password.", + ), }; } diff --git a/app/lib/presentation/resources/locale/generated/l10n.dart b/app/lib/presentation/resources/locale/generated/l10n.dart index 1a37223..7daf6f1 100644 --- a/app/lib/presentation/resources/locale/generated/l10n.dart +++ b/app/lib/presentation/resources/locale/generated/l10n.dart @@ -125,6 +125,11 @@ class S { return Intl.message('Login', name: 'ctaLogin', desc: '', args: []); } + /// `Sign Up` + String get ctaSignUp { + return Intl.message('Sign Up', name: 'ctaSignUp', desc: '', args: []); + } + /// `Email` String get labelEmail { return Intl.message('Email', name: 'labelEmail', desc: '', args: []); @@ -135,6 +140,26 @@ class S { return Intl.message('Password', name: 'labelPassword', desc: '', args: []); } + /// `Confirm Password` + String get labelConfirmPassword { + return Intl.message( + 'Confirm Password', + name: 'labelConfirmPassword', + desc: '', + args: [], + ); + } + + /// `Passwords do not match.` + String get errorPasswordsDoNotMatch { + return Intl.message( + 'Passwords do not match.', + name: 'errorPasswordsDoNotMatch', + desc: '', + args: [], + ); + } + /// `Min 8 characters long: 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character.` String get passwordInstructions { return Intl.message( @@ -180,6 +205,11 @@ class S { return Intl.message('Login', name: 'titleLogin', desc: '', args: []); } + /// `Sign Up` + String get titleSignUp { + return Intl.message('Sign Up', name: 'titleSignUp', desc: '', args: []); + } + /// `Use your email and password to login to your account.` String get titleLoginSubtitle { return Intl.message( @@ -190,6 +220,16 @@ class S { ); } + /// `Create an account using your email and password.` + String get titleSignUpSubtitle { + return Intl.message( + 'Create an account using your email and password.', + name: 'titleSignUpSubtitle', + desc: '', + args: [], + ); + } + /// `Please enter a valid email address.` String get errorEmailInvalid { return Intl.message( @@ -219,6 +259,16 @@ class S { args: [], ); } + + /// `This should open the terms and conditions URL.` + String get hintTermsAndConditions { + return Intl.message( + 'This should open the terms and conditions URL.', + name: 'hintTermsAndConditions', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/app/lib/presentation/resources/locale/intl_en.arb b/app/lib/presentation/resources/locale/intl_en.arb index 9ec3f6f..3ab38c5 100644 --- a/app/lib/presentation/resources/locale/intl_en.arb +++ b/app/lib/presentation/resources/locale/intl_en.arb @@ -8,15 +8,21 @@ "pleaseTryAgainLaterWeArenworkingToFixTheIssue": "Please try again later, we are\nworking to fix the issue.", "sorryWeDidntFindAnyProduct": "Sorry we didn't find any product", "ctaLogin": "Login", + "ctaSignUp": "Sign Up", "labelEmail": "Email", "labelPassword": "Password", + "labelConfirmPassword": "Confirm Password", + "errorPasswordsDoNotMatch": "Passwords do not match.", "passwordInstructions": "Min 8 characters long: 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special character.", "labelAgreeToTerms": "I agree to the Terms and Conditions", "errorEmailRequired": "Email is required.", "errorPasswordRequired": "Password is required.", "titleLogin": "Login", + "titleSignUp": "Sign Up", "titleLoginSubtitle": "Use your email and password to login to your account.", + "titleSignUpSubtitle": "Create an account using your email and password.", "errorEmailInvalid": "Please enter a valid email address.", "errorPasswordWeak": "Password is too weak.", - "loginErrorInvalidCredentials": "Invalid email or password." + "loginErrorInvalidCredentials": "Invalid email or password.", + "hintTermsAndConditions": "This should open the terms and conditions URL." } diff --git a/app/lib/presentation/ui/pages/auth/login/login_form.dart b/app/lib/presentation/ui/pages/auth/login/login_form.dart index 3446a06..2aa42db 100644 --- a/app/lib/presentation/ui/pages/auth/login/login_form.dart +++ b/app/lib/presentation/ui/pages/auth/login/login_form.dart @@ -1,3 +1,4 @@ +import 'package:app/presentation/navigation/routers.dart'; import 'package:app/presentation/resources/locale/generated/l10n.dart'; import 'package:app/presentation/resources/resources.dart'; import 'package:app/presentation/ui/components/primary_button.dart'; @@ -52,6 +53,7 @@ class _LoginFormState extends State { labelText: S.of(context).labelEmail, ), keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username, AutofillHints.email], controller: emailController, validator: (value) { if (!FormValidator.isEmail(value)) { @@ -101,9 +103,8 @@ class _LoginFormState extends State { IconButton( onPressed: () { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "This should open the terms and conditions URL."), + SnackBar( + content: Text(S.of(context).hintTermsAndConditions), ), ); }, @@ -147,6 +148,26 @@ class _LoginFormState extends State { ); }, ), + const Gap(Dimen.spacingL), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded(child: Divider()), + Padding( + padding: EdgeInsets.symmetric(horizontal: Dimen.spacingS), + child: Text("OR"), + ), + Expanded(child: Divider()), + ], + ), + const Gap(Dimen.spacingM), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Routes.signup.go(context), + child: Text(S.of(context).ctaSignUp.toUpperCase()), + ), + ), ], ), ); diff --git a/app/lib/presentation/ui/pages/auth/login/login_page.dart b/app/lib/presentation/ui/pages/auth/login/login_page.dart index 0aa30da..97bc8c4 100644 --- a/app/lib/presentation/ui/pages/auth/login/login_page.dart +++ b/app/lib/presentation/ui/pages/auth/login/login_page.dart @@ -1,5 +1,6 @@ import 'package:app/presentation/resources/resources.dart'; import 'package:app/presentation/ui/pages/auth/login/login_form.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class LoginPage extends StatelessWidget { @@ -11,7 +12,7 @@ class LoginPage extends StatelessWidget { body: Center( child: Container( constraints: const BoxConstraints( - maxWidth: Dimen.loginFormMaxWidth, + maxWidth: kIsWeb ? Dimen.authFormMaxWidth : double.infinity, ), child: const Card( child: Padding( diff --git a/app/lib/presentation/ui/pages/auth/sign_up/sign_up_form.dart b/app/lib/presentation/ui/pages/auth/sign_up/sign_up_form.dart new file mode 100644 index 0000000..09c1d30 --- /dev/null +++ b/app/lib/presentation/ui/pages/auth/sign_up/sign_up_form.dart @@ -0,0 +1,170 @@ +import 'package:app/presentation/resources/locale/generated/l10n.dart'; +import 'package:app/presentation/resources/resources.dart'; +import 'package:app/presentation/ui/components/primary_button.dart'; +import 'package:common/core/resource.dart'; +import 'package:common/validators/form_validator.dart'; +import 'package:domain/bloc/auth/auth_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gap/gap.dart'; + +class SignUpForm extends StatefulWidget { + const SignUpForm({super.key}); + + @override + State createState() => _SignUpFormState(); +} + +class _SignUpFormState extends State { + TextEditingController emailController = TextEditingController(); + TextEditingController passwordController = TextEditingController(); + TextEditingController confirmPasswordController = TextEditingController(); + bool agreeToTerms = false; + + final _formKey = GlobalKey(); + + @override + void dispose() { + emailController.dispose(); + passwordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context).titleSignUp, + style: Theme.of(context).textTheme.titleMedium, + ), + const Gap(Dimen.spacingL), + Text( + S.of(context).titleSignUpSubtitle, + style: Theme.of(context).textTheme.titleSmall, + ), + const Gap(Dimen.spacingL), + TextFormField( + decoration: InputDecoration( + labelText: S.of(context).labelEmail, + ), + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.username, AutofillHints.email], + controller: emailController, + validator: (value) { + if (!FormValidator.isEmail(value)) { + return S.of(context).errorEmailInvalid; + } + + return null; + }, + ), + const Gap(Dimen.spacingM), + TextFormField( + decoration: InputDecoration( + labelText: S.of(context).labelPassword, + ), + obscureText: true, + controller: passwordController, + validator: (value) { + if (!FormValidator.isStrongPassword(value)) { + return S.of(context).errorPasswordWeak; + } + return null; + }, + ), + const Gap(Dimen.spacingM), + Text( + S.of(context).passwordInstructions, + style: Theme.of(context).textTheme.bodySmall, + ), + const Gap(Dimen.spacingM), + TextFormField( + decoration: InputDecoration( + labelText: S.of(context).labelConfirmPassword, + ), + obscureText: true, + controller: confirmPasswordController, + validator: (value) { + if (value != passwordController.text) { + return S.of(context).errorPasswordsDoNotMatch; + } + return null; + }, + ), + const Gap(Dimen.spacingM), + TextButton( + onPressed: () => setState(() { + agreeToTerms = !agreeToTerms; + }), + child: Row( + children: [ + Checkbox( + value: agreeToTerms, + onChanged: (value) => setState(() { + agreeToTerms = value ?? false; + }), + ), + Text( + S.of(context).labelAgreeToTerms, + style: Theme.of(context).textTheme.titleSmall, + ), + const Gap(Dimen.spacingS), + IconButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).hintTermsAndConditions), + ), + ); + }, + icon: const Icon(Icons.info), + ) + ], + ), + ), + const Gap(Dimen.spacingM), + BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state is RError) ...[ + Text( + S.of(context).loginErrorInvalidCredentials, + style: + Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + const Gap(Dimen.spacingM), + ], + PrimaryButton( + label: S.of(context).ctaSignUp, + onPressed: () { + if ((_formKey.currentState?.validate() ?? false) && + agreeToTerms) { + context.read().signUp( + email: emailController.text, + password: passwordController.text, + ); + } + }, + isEnabled: agreeToTerms, + isLoading: state is RLoading, + trailingIcon: const Icon(Icons.login), + ) + ], + ); + }, + ), + ], + ), + ); + } +} diff --git a/app/lib/presentation/ui/pages/auth/sign_up/sign_up_page.dart b/app/lib/presentation/ui/pages/auth/sign_up/sign_up_page.dart index 232ee46..b737758 100644 --- a/app/lib/presentation/ui/pages/auth/sign_up/sign_up_page.dart +++ b/app/lib/presentation/ui/pages/auth/sign_up/sign_up_page.dart @@ -1,3 +1,6 @@ +import 'package:app/presentation/resources/resources.dart'; +import 'package:app/presentation/ui/pages/auth/sign_up/sign_up_form.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class SignUpPage extends StatelessWidget { @@ -5,6 +8,20 @@ class SignUpPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Placeholder(); + return Scaffold( + body: Center( + child: Container( + constraints: const BoxConstraints( + maxWidth: kIsWeb ? Dimen.authFormMaxWidth : double.infinity, + ), + child: const Card( + child: Padding( + padding: EdgeInsets.all(Dimen.spacingM), + child: SignUpForm(), + ), + ), + ), + ), + ); } } diff --git a/app/lib/presentation/ui/pages/auth/sign_up/sign_up_view.dart b/app/lib/presentation/ui/pages/auth/sign_up/sign_up_view.dart deleted file mode 100644 index ddd94b2..0000000 --- a/app/lib/presentation/ui/pages/auth/sign_up/sign_up_view.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class SignUpView extends StatelessWidget { - const SignUpView({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} diff --git a/modules/data/lib/repositories/auth_repository_impl.dart b/modules/data/lib/repositories/auth_repository_impl.dart index d0e117b..9c3d51a 100644 --- a/modules/data/lib/repositories/auth_repository_impl.dart +++ b/modules/data/lib/repositories/auth_repository_impl.dart @@ -29,4 +29,14 @@ class AuthRepositoryImpl implements AuthRepository { Future logout() async { _preferences.clear(); } + + @override + Future> signUp( + String username, + String password, + ) async { + await Future.delayed(const Duration(seconds: 1)); + _preferences.setToken('new-token'); + return TSuccess(null); + } } diff --git a/modules/domain/lib/bloc/auth/auth_cubit.dart b/modules/domain/lib/bloc/auth/auth_cubit.dart index 03d0d5e..b4cdda7 100644 --- a/modules/domain/lib/bloc/auth/auth_cubit.dart +++ b/modules/domain/lib/bloc/auth/auth_cubit.dart @@ -23,6 +23,21 @@ class AuthCubit extends BaseCubit { ..mapError((failure) => isError(failure)); } + Future signUp({ + required String email, + required String password, + }) async { + isLoading(); + final signUpResult = await _authService.signUpWithCredentials( + email, + password, + ); + + signUpResult + ..mapSuccess((_) => isLogin()) + ..mapError((failure) => isError(failure)); + } + Future onValidate() async { if (_authService.isLoggedIn()) { isLogin(); diff --git a/modules/domain/lib/repositories/auth_repository.dart b/modules/domain/lib/repositories/auth_repository.dart index e0f9117..93fb9fa 100644 --- a/modules/domain/lib/repositories/auth_repository.dart +++ b/modules/domain/lib/repositories/auth_repository.dart @@ -8,4 +8,6 @@ abstract class AuthRepository { Future> login(String username, String password); Future logout(); + + Future> signUp(String username, String password); } diff --git a/modules/domain/lib/services/auth_service.dart b/modules/domain/lib/services/auth_service.dart index 772f150..2b8dddd 100644 --- a/modules/domain/lib/services/auth_service.dart +++ b/modules/domain/lib/services/auth_service.dart @@ -12,6 +12,12 @@ class AuthService { ) => _authRepository.login(username, password); + Future> signUpWithCredentials( + String username, + String password, + ) => + _authRepository.signUp(username, password); + bool isLoggedIn() => _authRepository.isLoggedIn(); Future onLogout() async {