diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 5ba54627045..73c6b017b12 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -1,5 +1,5 @@ -aarch aapt +aarch addrr adminer androidx @@ -32,6 +32,8 @@ drep dreps encryptor fontawesome +formz +Formz gapless gcloud genhtml diff --git a/catalyst_voices/integration_test/scenarios/login_scenario.dart b/catalyst_voices/integration_test/scenarios/login_scenario.dart index 814d47a0b0d..12c88e0dd42 100644 --- a/catalyst_voices/integration_test/scenarios/login_scenario.dart +++ b/catalyst_voices/integration_test/scenarios/login_scenario.dart @@ -13,17 +13,17 @@ void main() { (tester) async { loginRobot = await _configure(tester); - await loginRobot.enterUsername('Not Valid'); + await loginRobot.enterEmail('Not Valid'); await loginRobot.tapLoginButton(); await loginRobot.checkInvalidCredentialsMessageIsShown(); }); - testWidgets('authenticates a user with an username and password', + testWidgets('authenticates a user with an email and password', (tester) async { loginRobot = await _configure(tester); - await loginRobot.enterUsername('robot'); - await loginRobot.enterPassword('1234'); + await loginRobot.enterEmail('mail@example.com'); + await loginRobot.enterPassword('MyPass123'); await loginRobot.tapLoginButton(); }); }); diff --git a/catalyst_voices/integration_test/scenarios/robots/login_robot.dart b/catalyst_voices/integration_test/scenarios/robots/login_robot.dart index db6677860a3..cb5ee85be20 100644 --- a/catalyst_voices/integration_test/scenarios/robots/login_robot.dart +++ b/catalyst_voices/integration_test/scenarios/robots/login_robot.dart @@ -1,4 +1,5 @@ -import 'package:catalyst_voices/dummy/constants.dart'; +import 'package:catalyst_voices/pages/login/login.dart'; +import 'package:catalyst_voices/pages/widgets/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; final class LoginRobot { @@ -9,36 +10,34 @@ final class LoginRobot { }); Future checkInvalidCredentialsMessageIsShown() async { - final loginErrorSnackbar = find.byKey(WidgetKeys.loginErrorSnackbar); + final loginErrorSnackbar = find.byKey(LoginForm.loginErrorSnackbarKey); expect(loginErrorSnackbar, findsOneWidget); await widgetTester.pump(); } - Future enterPassword(String password) async { - final passwordTextController = - find.byKey(WidgetKeys.passwordTextController); - expect(passwordTextController, findsOneWidget); - await widgetTester.enterText(passwordTextController, password); + Future enterEmail(String email) async { + final emailTextField = find.byKey(EmailInput.emailInputKey); + expect(emailTextField, findsOneWidget); + await widgetTester.enterText(emailTextField, email); await widgetTester.pump(); } - Future enterUsername(String username) async { - final usernameTextController = - find.byKey(WidgetKeys.usernameTextController); - expect(usernameTextController, findsOneWidget); - await widgetTester.enterText(usernameTextController, username); + Future enterPassword(String password) async { + final passwordTextField = find.byKey(PasswordInput.passwordInputKey); + expect(passwordTextField, findsOneWidget); + await widgetTester.enterText(passwordTextField, password); await widgetTester.pump(); } Future tapLoginButton() async { - final loginButton = find.byKey(WidgetKeys.loginButton); + final loginButton = find.byKey(LoginInButton.loginButtonKey); expect(loginButton, findsOneWidget); await widgetTester.tap(loginButton); await widgetTester.pump(); } void verifyLoginScreenIsShown() { - final loginScreen = find.byKey(WidgetKeys.loginScreen); + final loginScreen = find.byKey(LoginForm.loginFormKey); expect(loginScreen, findsOneWidget); } } diff --git a/catalyst_voices/lib/app/view/app.dart b/catalyst_voices/lib/app/view/app.dart index 1c9a879f136..88ed5d37259 100644 --- a/catalyst_voices/lib/app/view/app.dart +++ b/catalyst_voices/lib/app/view/app.dart @@ -1,22 +1,2 @@ -import 'package:catalyst_voices/dummy/dummy.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_localized_locales/flutter_localized_locales.dart'; - -final class App extends StatelessWidget { - const App({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - restorationScopeId: 'rootVoices', - localizationsDelegates: const [ - ...VoicesLocalizations.localizationsDelegates, - LocaleNamesLocalizationsDelegate(), - ], - supportedLocales: VoicesLocalizations.supportedLocales, - localeListResolutionCallback: basicLocaleListResolution, - home: isUserLoggedIn ? const HomeScreen() : const LoginPage(), - ); - } -} +export 'app_content.dart'; +export 'app_page.dart'; diff --git a/catalyst_voices/lib/app/view/app_content.dart b/catalyst_voices/lib/app/view/app_content.dart new file mode 100644 index 00000000000..c8b23616ee7 --- /dev/null +++ b/catalyst_voices/lib/app/view/app_content.dart @@ -0,0 +1,37 @@ +import 'package:catalyst_voices/routes/routes.dart' show AppRouter; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; + +final class AppContent extends StatelessWidget { + const AppContent({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) {}, + child: MaterialApp.router( + restorationScopeId: 'rootVoices', + localizationsDelegates: const [ + ...VoicesLocalizations.localizationsDelegates, + LocaleNamesLocalizationsDelegate(), + ], + supportedLocales: VoicesLocalizations.supportedLocales, + localeListResolutionCallback: basicLocaleListResolution, + routerConfig: AppRouter.init( + authenticationBloc: context.read(), + ), + title: 'Catalyst Voices', + theme: ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/app/view/app_page.dart b/catalyst_voices/lib/app/view/app_page.dart new file mode 100644 index 00000000000..71628b90fb1 --- /dev/null +++ b/catalyst_voices/lib/app/view/app_page.dart @@ -0,0 +1,59 @@ +import 'package:catalyst_voices/app/view/app_content.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final class App extends StatefulWidget { + const App({super.key}); + + @override + State createState() => _AppState(); +} + +final class _AppState extends State { + late final AuthenticationRepository _authenticationRepository; + late final CredentialsStorageRepository _credentialsStorageRepository; + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value( + value: _authenticationRepository, + ), + ], + child: BlocProvider( + create: (_) => AuthenticationBloc( + authenticationRepository: _authenticationRepository, + ), + child: const AppContent(), + ), + ); + } + + @override + Future dispose() async { + await _authenticationRepository.dispose(); + + super.dispose(); + } + + @override + void initState() { + super.initState(); + + _configureRepositories(); + } + + void _configureRepositories() { + _credentialsStorageRepository = CredentialsStorageRepository( + secureStorageService: SecureStorageService(), + ); + + _authenticationRepository = AuthenticationRepository( + credentialsStorageRepository: _credentialsStorageRepository, + ); + } +} diff --git a/catalyst_voices/lib/configs/bootstrap.dart b/catalyst_voices/lib/configs/bootstrap.dart index dc8ed07db07..fc4621f63a4 100644 --- a/catalyst_voices/lib/configs/bootstrap.dart +++ b/catalyst_voices/lib/configs/bootstrap.dart @@ -4,10 +4,15 @@ import 'dart:developer'; import 'package:catalyst_voices/configs/app_bloc_observer.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_strategy/url_strategy.dart'; Future bootstrap(FutureOr Function() builder) async { WidgetsFlutterBinding.ensureInitialized(); + GoRouter.optionURLReflectsImperativeAPIs = true; + setPathUrlStrategy(); + FlutterError.onError = (details) { log( details.exceptionAsString(), diff --git a/catalyst_voices/lib/dummy/constants.dart b/catalyst_voices/lib/dummy/constants.dart deleted file mode 100644 index a9cf3a8ed7f..00000000000 --- a/catalyst_voices/lib/dummy/constants.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/foundation.dart'; - -bool isUserLoggedIn = false; - -abstract class WidgetKeys { - static const loginScreen = Key('loginScreen'); - static const usernameTextController = Key('usernameTextController'); - static const passwordTextController = Key('passwordTextController'); - static const loginButton = Key('loginButton'); - static const loginErrorSnackbar = Key('loginErrorSnackbar'); - static const homeScreen = Key('homeScreen'); -} diff --git a/catalyst_voices/lib/dummy/dummy.dart b/catalyst_voices/lib/dummy/dummy.dart deleted file mode 100644 index 19e88d2e485..00000000000 --- a/catalyst_voices/lib/dummy/dummy.dart +++ /dev/null @@ -1,8 +0,0 @@ -// These files used as dummy implementations -// for integration tests. As soon as we have -// a real implementation, we can remove them. -// TODO(minikin): remove dummy files, https://github.com/input-output-hk/catalyst-voices/issues/103. - -export 'constants.dart'; -export 'home_screen.dart'; -export 'login_page.dart'; diff --git a/catalyst_voices/lib/dummy/login_page.dart b/catalyst_voices/lib/dummy/login_page.dart deleted file mode 100644 index e80241e7e64..00000000000 --- a/catalyst_voices/lib/dummy/login_page.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:catalyst_voices/dummy/dummy.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:flutter/material.dart'; - -final class LoginPage extends StatefulWidget { - const LoginPage({super.key}); - - @override - State createState() => _LoginPageState(); -} - -abstract class _Constants { - static const username = 'robot'; - static const password = '1234'; -} - -final class _LoginPageState extends State { - late TextEditingController usernameTextController; - late TextEditingController passwordTextController; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return Scaffold( - key: WidgetKeys.loginScreen, - body: Center( - child: SizedBox( - width: 400, - height: 400, - child: Card( - margin: const EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: TextFormField( - key: WidgetKeys.usernameTextController, - controller: usernameTextController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.loginScreenUsernameLabelText, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: TextFormField( - key: WidgetKeys.passwordTextController, - controller: passwordTextController, - obscureText: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.loginScreenPasswordLabelText, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: ElevatedButton( - key: WidgetKeys.loginButton, - onPressed: () async => _loginButtonPressed(context), - child: Text(l10n.loginScreenLoginButtonText), - ), - ), - ], - ), - ), - ), - ), - ); - } - - @override - void dispose() { - usernameTextController.dispose(); - passwordTextController.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - usernameTextController = TextEditingController(); - passwordTextController = TextEditingController(); - } - - Future _loginButtonPressed(BuildContext context) async { - if (_validateCredentials()) { - await _navigateToHomeScreen(context); - } else { - _showError(context); - } - } - - Future _navigateToHomeScreen(BuildContext context) async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const HomeScreen(), - ), - ); - } - - void _showError(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - key: WidgetKeys.loginErrorSnackbar, - content: Text(context.l10n.loginScreenErrorMessage), - ), - ); - } - - bool _validateCredentials() { - final username = usernameTextController.text; - final password = passwordTextController.text; - - return isUserLoggedIn = - username == _Constants.username && password == _Constants.password; - } -} diff --git a/catalyst_voices/lib/dummy/home_screen.dart b/catalyst_voices/lib/pages/home/home_page.dart similarity index 84% rename from catalyst_voices/lib/dummy/home_screen.dart rename to catalyst_voices/lib/pages/home/home_page.dart index 84e1415ffa5..ca1ca537294 100644 --- a/catalyst_voices/lib/dummy/home_screen.dart +++ b/catalyst_voices/lib/pages/home/home_page.dart @@ -1,15 +1,16 @@ -import 'package:catalyst_voices/dummy/dummy.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; -final class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); +final class HomePage extends StatelessWidget { + static const homePageKey = Key('HomePage'); + + const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( - key: WidgetKeys.homeScreen, + key: homePageKey, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/catalyst_voices/lib/pages/login/login.dart b/catalyst_voices/lib/pages/login/login.dart new file mode 100644 index 00000000000..d7ad2cd0537 --- /dev/null +++ b/catalyst_voices/lib/pages/login/login.dart @@ -0,0 +1,3 @@ +export 'login_button.dart'; +export 'login_form.dart'; +export 'login_page.dart'; diff --git a/catalyst_voices/lib/pages/login/login_button.dart b/catalyst_voices/lib/pages/login/login_button.dart new file mode 100644 index 00000000000..d1852a0ce99 --- /dev/null +++ b/catalyst_voices/lib/pages/login/login_button.dart @@ -0,0 +1,34 @@ +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; + +final class LoginInButton extends StatelessWidget { + static const loginButtonKey = Key('LoginInButton'); + + const LoginInButton({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocBuilder( + buildWhen: (previous, current) => previous.status != current.status, + builder: (context, state) { + return state.status.isInProgress + ? const Center(child: CircularProgressIndicator()) + : FloatingActionButton.extended( + heroTag: UniqueKey(), + label: Text( + l10n.loginButtonText, + ), + onPressed: () { + if (state.isValid) { + context.read().add(const LoginSubmitted()); + } + }, + ); + }, + ); + } +} diff --git a/catalyst_voices/lib/pages/login/login_form.dart b/catalyst_voices/lib/pages/login/login_form.dart new file mode 100644 index 00000000000..f400117fcba --- /dev/null +++ b/catalyst_voices/lib/pages/login/login_form.dart @@ -0,0 +1,71 @@ +import 'package:catalyst_voices/pages/login/login.dart'; +import 'package:catalyst_voices/pages/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; + +final class LoginForm extends StatelessWidget { + static const loginFormKey = Key('LoginForm'); + static const loginErrorSnackbarKey = Key('LoginErrorSnackbar'); + + const LoginForm({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + body: BlocListener( + listener: (context, state) { + if (state.status.isFailure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + key: loginErrorSnackbarKey, + content: Text(l10n.loginScreenErrorMessage), + ), + ); + } + }, + child: Center( + child: SizedBox( + height: 460, + width: 480, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text( + l10n.loginTitleText, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 30), + const EmailInput(), + const SizedBox(height: 20), + const PasswordInput(), + const SizedBox(height: 20), + const SizedBox( + width: double.infinity, + child: LoginInButton(), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/pages/login/login_page.dart b/catalyst_voices/lib/pages/login/login_page.dart new file mode 100644 index 00000000000..19ed2e06b38 --- /dev/null +++ b/catalyst_voices/lib/pages/login/login_page.dart @@ -0,0 +1,27 @@ +import 'package:catalyst_voices/pages/login/login.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final class LoginPage extends StatelessWidget { + static const loginPage = Key('LoginInPage'); + + const LoginPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + key: loginPage, + create: (context) { + return LoginBloc( + authenticationRepository: + RepositoryProvider.of( + context, + ), + ); + }, + child: const LoginForm(), + ); + } +} diff --git a/catalyst_voices/lib/pages/widgets/email_input.dart b/catalyst_voices/lib/pages/widgets/email_input.dart new file mode 100644 index 00000000000..7f47dc773f4 --- /dev/null +++ b/catalyst_voices/lib/pages/widgets/email_input.dart @@ -0,0 +1,38 @@ +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final class EmailInput extends StatelessWidget { + static const emailInputKey = Key('EmailInput'); + + const EmailInput({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocBuilder( + buildWhen: (previous, current) => previous.email != current.email, + builder: (context, state) { + return TextField( + key: emailInputKey, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + onChanged: (email) => context.read().add( + LoginEmailChanged(email), + ), + decoration: InputDecoration( + filled: true, + labelText: l10n.emailLabelText, + hintText: l10n.emailHintText, + errorText: l10n.emailErrorText, + border: const OutlineInputBorder(), + ), + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ); + }, + ); + } +} diff --git a/catalyst_voices/lib/pages/widgets/password_input.dart b/catalyst_voices/lib/pages/widgets/password_input.dart new file mode 100644 index 00000000000..0b9a526de9c --- /dev/null +++ b/catalyst_voices/lib/pages/widgets/password_input.dart @@ -0,0 +1,46 @@ +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final class PasswordInput extends StatelessWidget { + static const passwordInputKey = Key('PasswordInput'); + + const PasswordInput({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocBuilder( + buildWhen: (previous, current) => previous.password != current.password, + builder: (context, state) { + return TextField( + key: passwordInputKey, + keyboardType: TextInputType.multiline, + obscureText: true, + textInputAction: TextInputAction.done, + onChanged: (password) => _onPasswordChanged(context, password), + decoration: InputDecoration( + filled: true, + errorMaxLines: 2, + labelText: l10n.passwordLabelText, + hintText: l10n.passwordHintText, + errorText: l10n.passwordErrorText, + border: const OutlineInputBorder(), + ), + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ); + }, + ); + } + + void _onPasswordChanged(BuildContext context, String password) { + return context.read().add( + LoginPasswordChanged(password), + ); + } +} diff --git a/catalyst_voices/lib/pages/widgets/widgets.dart b/catalyst_voices/lib/pages/widgets/widgets.dart new file mode 100644 index 00000000000..2151fa5c6b9 --- /dev/null +++ b/catalyst_voices/lib/pages/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'email_input.dart'; +export 'password_input.dart'; diff --git a/catalyst_voices/lib/routes/app_router.dart b/catalyst_voices/lib/routes/app_router.dart new file mode 100644 index 00000000000..c780dae1b6e --- /dev/null +++ b/catalyst_voices/lib/routes/app_router.dart @@ -0,0 +1,55 @@ +import 'package:catalyst_voices/routes/home_page_route.dart' as home_route; +import 'package:catalyst_voices/routes/login_page_route.dart' as login_route; +import 'package:catalyst_voices/routes/routes.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +final class AppRouter { + static final _rootNavigatorKey = GlobalKey( + debugLabel: 'rootNavigatorKey', + ); + + static GoRouter init({ + required AuthenticationBloc authenticationBloc, + }) { + return GoRouter( + debugLogDiagnostics: true, + navigatorKey: _rootNavigatorKey, + initialLocation: _isWeb(), + refreshListenable: AppRouterRefreshStream(authenticationBloc.stream), + redirect: (context, state) => _guard(authenticationBloc, state), + routes: [ + ...login_route.$appRoutes, + ...home_route.$appRoutes, + ], + ); + } + + static String? _guard( + AuthenticationBloc authenticationBloc, + GoRouterState state, + ) { + final isAuthenticated = authenticationBloc.isAuthenticated; + final signingIn = state.matchedLocation == login_route.loginPath; + + if (!isAuthenticated) { + return login_route.loginPath; + } + + if (signingIn) { + return home_route.homePath; + } + + return null; + } + + static String? _isWeb() { + if (kIsWeb) { + return Uri.base.toString().replaceFirst(Uri.base.origin, ''); + } else { + return null; + } + } +} diff --git a/catalyst_voices/lib/routes/app_router_refresh_stream.dart b/catalyst_voices/lib/routes/app_router_refresh_stream.dart new file mode 100644 index 00000000000..e748de521a4 --- /dev/null +++ b/catalyst_voices/lib/routes/app_router_refresh_stream.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +final class AppRouterRefreshStream extends ChangeNotifier { + late final StreamSubscription _subscription; + + AppRouterRefreshStream(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen((_) => notifyListeners()); + } + + @override + Future dispose() async { + await _subscription.cancel(); + super.dispose(); + } +} diff --git a/catalyst_voices/lib/routes/app_scaffold.dart b/catalyst_voices/lib/routes/app_scaffold.dart new file mode 100644 index 00000000000..4b65ad64f85 --- /dev/null +++ b/catalyst_voices/lib/routes/app_scaffold.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; +import 'package:go_router/go_router.dart'; + +final class AppScaffold extends StatelessWidget { + final StatefulNavigationShell navigationShell; + + const AppScaffold({ + required this.navigationShell, + Key? key, + }) : super( + key: key ?? const ValueKey('AppScaffoldWithNavBar'), + ); + + @override + Widget build(BuildContext context) { + return AdaptiveScaffold( + useDrawer: false, + selectedIndex: navigationShell.currentIndex, + body: (context) => navigationShell, + onSelectedIndexChange: (idx) => _onTap(idx, context), + destinations: const [], + ); + } + + void _onTap( + int index, + BuildContext context, + ) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + } +} diff --git a/catalyst_voices/lib/routes/home_page_route.dart b/catalyst_voices/lib/routes/home_page_route.dart new file mode 100644 index 00000000000..2efb81d2023 --- /dev/null +++ b/catalyst_voices/lib/routes/home_page_route.dart @@ -0,0 +1,15 @@ +import 'package:catalyst_voices/pages/home/home_page.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +part 'home_page_route.g.dart'; + +const homePath = '/home'; + +@TypedGoRoute(path: homePath) +final class HomeRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const HomePage(); + } +} diff --git a/catalyst_voices/lib/routes/home_page_route.g.dart b/catalyst_voices/lib/routes/home_page_route.g.dart new file mode 100644 index 00000000000..60410b89093 --- /dev/null +++ b/catalyst_voices/lib/routes/home_page_route.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_page_route.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $homeRoute, + ]; + +RouteBase get $homeRoute => GoRouteData.$route( + path: '/home', + factory: $HomeRouteExtension._fromState, + ); + +extension $HomeRouteExtension on HomeRoute { + static HomeRoute _fromState(GoRouterState state) => HomeRoute(); + + String get location => GoRouteData.$location( + '/home', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/catalyst_voices/lib/routes/login_page_route.dart b/catalyst_voices/lib/routes/login_page_route.dart new file mode 100644 index 00000000000..e1773759a60 --- /dev/null +++ b/catalyst_voices/lib/routes/login_page_route.dart @@ -0,0 +1,15 @@ +import 'package:catalyst_voices/pages/login/login.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +part 'login_page_route.g.dart'; + +const loginPath = '/login'; + +@TypedGoRoute(path: loginPath) +final class LoginRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const LoginPage(); + } +} diff --git a/catalyst_voices/lib/routes/login_page_route.g.dart b/catalyst_voices/lib/routes/login_page_route.g.dart new file mode 100644 index 00000000000..a81d22d9fa9 --- /dev/null +++ b/catalyst_voices/lib/routes/login_page_route.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'login_page_route.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $loginRoute, + ]; + +RouteBase get $loginRoute => GoRouteData.$route( + path: '/login', + factory: $LoginRouteExtension._fromState, + ); + +extension $LoginRouteExtension on LoginRoute { + static LoginRoute _fromState(GoRouterState state) => LoginRoute(); + + String get location => GoRouteData.$location( + '/login', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/catalyst_voices/lib/routes/routes.dart b/catalyst_voices/lib/routes/routes.dart new file mode 100644 index 00000000000..52454815c79 --- /dev/null +++ b/catalyst_voices/lib/routes/routes.dart @@ -0,0 +1,3 @@ +export 'app_router.dart'; +export 'app_router_refresh_stream.dart'; +export 'app_scaffold.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication.dart new file mode 100644 index 00000000000..0db6272d6e7 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication.dart @@ -0,0 +1 @@ +export 'authentication_bloc.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_bloc.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_bloc.dart new file mode 100644 index 00000000000..499fdf48940 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_bloc.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:equatable/equatable.dart'; + +part 'authentication_event.dart'; +part 'authentication_state.dart'; + +final class AuthenticationBloc + extends Bloc { + final AuthenticationRepository _authenticationRepository; + + late StreamSubscription + _authenticationStatusSubscription; + + AuthenticationBloc({ + required AuthenticationRepository authenticationRepository, + }) : _authenticationRepository = authenticationRepository, + super(const AuthenticationState.unknown()) { + on<_AuthenticationStatusChanged>(_onAuthenticationStatusChanged); + on(_onAuthenticationLogoutRequested); + _authenticationStatusSubscription = _authenticationRepository.status.listen( + (status) => add(_AuthenticationStatusChanged(status)), + ); + } + + bool get isAuthenticated => + state.status == AuthenticationStatus.authenticated; + + bool get isInitial => state.status == AuthenticationStatus.unknown; + AuthenticationStatus get status => state.status; + + @override + Future close() { + _authenticationStatusSubscription.cancel(); + return super.close(); + } + + void _onAuthenticationLogoutRequested( + AuthenticationLogoutRequested event, + Emitter emit, + ) { + _authenticationRepository.logOut(); + } + + Future _onAuthenticationStatusChanged( + _AuthenticationStatusChanged event, + Emitter emit, + ) async { + switch (event.status) { + case AuthenticationStatus.unauthenticated: + return emit(const AuthenticationState.unauthenticated()); + case AuthenticationStatus.authenticated: + final sessionData = await _authenticationRepository.getSessionData(); + + return emit( + sessionData != null + ? AuthenticationState.authenticated(sessionData) + : const AuthenticationState.unauthenticated(), + ); + case AuthenticationStatus.unknown: + return emit(const AuthenticationState.unknown()); + } + } +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_event.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_event.dart new file mode 100644 index 00000000000..2c0e160b760 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_event.dart @@ -0,0 +1,13 @@ +part of 'authentication_bloc.dart'; + +abstract final class AuthenticationEvent { + const AuthenticationEvent(); +} + +final class AuthenticationLogoutRequested extends AuthenticationEvent {} + +final class _AuthenticationStatusChanged extends AuthenticationEvent { + final AuthenticationStatus status; + + const _AuthenticationStatusChanged(this.status); +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_state.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_state.dart new file mode 100644 index 00000000000..857aa20c0c0 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/authentication/authentication_state.dart @@ -0,0 +1,28 @@ +part of 'authentication_bloc.dart'; + +final class AuthenticationState extends Equatable { + final AuthenticationStatus status; + final SessionData? sessionData; + + const AuthenticationState.authenticated( + SessionData sessionData, + ) : this._( + status: AuthenticationStatus.authenticated, + sessionData: sessionData, + ); + + const AuthenticationState.unauthenticated() + : this._( + status: AuthenticationStatus.unauthenticated, + ); + + const AuthenticationState.unknown() : this._(); + + const AuthenticationState._({ + this.status = AuthenticationStatus.unknown, + this.sessionData, + }); + + @override + List get props => [status, sessionData ?? '']; +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart index caaba687a83..d1042742844 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart @@ -1,3 +1,2 @@ -class CatalystVoicesBlocs { - const CatalystVoicesBlocs(); -} +export 'authentication/authentication.dart'; +export 'login/login.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login.dart new file mode 100644 index 00000000000..ef50ddcb013 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login.dart @@ -0,0 +1 @@ +export 'login_bloc.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_bloc.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_bloc.dart new file mode 100644 index 00000000000..72cb9e1fc5d --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_bloc.dart @@ -0,0 +1,87 @@ +import 'package:bloc/bloc.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:equatable/equatable.dart'; +import 'package:formz/formz.dart'; + +part 'login_event.dart'; +part 'login_state.dart'; + +final class LoginBloc extends Bloc { + final AuthenticationRepository _authenticationRepository; + + LoginBloc({ + required AuthenticationRepository authenticationRepository, + }) : _authenticationRepository = authenticationRepository, + super(LoginState()) { + on(_onEmailChanged); + on(_onPasswordChanged); + on(_onSubmitted); + } + + void _onEmailChanged( + LoginEmailChanged event, + Emitter emit, + ) { + final email = Email.dirty(event.email); + final isValid = Formz.validate([email, state.password]); + emit( + state.copyWith( + email: email, + status: isValid ? FormzSubmissionStatus.success : null, + ), + ); + } + + void _onPasswordChanged( + LoginPasswordChanged event, + Emitter emit, + ) { + final password = Password.dirty(event.password); + final isValid = Formz.validate([password, state.email]); + emit( + state.copyWith( + password: password, + status: isValid ? FormzSubmissionStatus.success : null, + ), + ); + } + + Future _onSubmitted( + LoginSubmitted event, + Emitter emit, + ) async { + if (state.isValid) { + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + try { + if (_validateTempCredentials( + email: state.email.value, + password: state.password.value, + )) { + await _authenticationRepository.signIn( + email: state.email.value, + password: state.password.value, + ); + + emit(state.copyWith(status: FormzSubmissionStatus.success)); + } else { + emit(state.copyWith(status: FormzSubmissionStatus.failure)); + } + } catch (_) { + emit(state.copyWith(status: FormzSubmissionStatus.failure)); + } + } + } + + bool _validateTempCredentials({ + required String email, + required String password, + }) { + return email == _TempConstants.email && password == _TempConstants.password; + } +} + +abstract class _TempConstants { + static const email = 'mail@example.com'; + static const password = 'MyPass123'; +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_event.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_event.dart new file mode 100644 index 00000000000..55d0c6d50e8 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_event.dart @@ -0,0 +1,30 @@ +part of 'login_bloc.dart'; + +final class LoginEmailChanged extends LoginEvent { + final String email; + + const LoginEmailChanged(this.email); + + @override + List get props => [email]; +} + +abstract final class LoginEvent extends Equatable { + const LoginEvent(); + + @override + List get props => []; +} + +final class LoginPasswordChanged extends LoginEvent { + final String password; + + const LoginPasswordChanged(this.password); + + @override + List get props => [password]; +} + +final class LoginSubmitted extends LoginEvent { + const LoginSubmitted(); +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_state.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_state.dart new file mode 100644 index 00000000000..3f4bdf19f91 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/login/login_state.dart @@ -0,0 +1,35 @@ +part of 'login_bloc.dart'; + +final class LoginState extends Equatable with FormzMixin { + final FormzSubmissionStatus status; + final Email email; + final Password password; + + LoginState({ + this.status = FormzSubmissionStatus.initial, + this.email = const Email.pure(), + this.password = const Password.pure(), + }); + + @override + List> get inputs => [email, password]; + + @override + List get props => [ + status, + email, + password, + ]; + + LoginState copyWith({ + FormzSubmissionStatus? status, + Email? email, + Password? password, + }) { + return LoginState( + status: status ?? this.status, + email: email ?? this.email, + password: password ?? this.password, + ); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml index 3ba43eab54f..70de129a70f 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml @@ -8,10 +8,21 @@ environment: flutter: 3.16.5 dependencies: - bloc: ^8.1.2 - bloc_concurrency: ^0.2.2 - flutter: - sdk: flutter + bloc: ^8.1.2 + bloc_concurrency: ^0.2.2 + catalyst_voices_models: + path: ../catalyst_voices_models + catalyst_voices_repositories: + path: ../catalyst_voices_repositories + catalyst_voices_view_models: + path: ../catalyst_voices_view_models + collection: ^1.17.1 + equatable: ^2.0.5 + flutter: + sdk: flutter + formz: ^0.6.1 + meta: ^1.10.0 + result_type: ^0.2.0 dev_dependencies: bloc_test: ^9.1.4 diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart index c58d7449479..1fcd2e59b3a 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart @@ -92,29 +92,59 @@ abstract class VoicesLocalizations { Locale('es') ]; - /// Text shown in the login screen for the username field + /// Text shown in email field /// /// In en, this message translates to: - /// **'Username'** - String get loginScreenUsernameLabelText; + /// **'Email'** + String get emailLabelText; - /// Text shown in the login screen for the password field + /// Text shown in email field when empty + /// + /// In en, this message translates to: + /// **'mail@example.com'** + String get emailHintText; + + /// Text shown in email field when input is invalid + /// + /// In en, this message translates to: + /// **'mail@example.com'** + String get emailErrorText; + + /// Text shown in password field /// /// In en, this message translates to: /// **'Password'** - String get loginScreenPasswordLabelText; + String get passwordLabelText; - /// Text shown in the login screen when the user enters wrong credentials + /// Text shown in password field when empty /// /// In en, this message translates to: - /// **'Wrong credentials'** - String get loginScreenErrorMessage; + /// **'My1SecretPassword'** + String get passwordHintText; + + /// Text shown in password field when input is invalid + /// + /// In en, this message translates to: + /// **'Password must be at least 8 characters long'** + String get passwordErrorText; + + /// Text shown in the login screen title + /// + /// In en, this message translates to: + /// **'Login'** + String get loginTitleText; /// Text shown in the login screen for the login button /// /// In en, this message translates to: /// **'Login'** - String get loginScreenLoginButtonText; + String get loginButtonText; + + /// Text shown in the login screen when the user enters wrong credentials + /// + /// In en, this message translates to: + /// **'Wrong credentials'** + String get loginScreenErrorMessage; /// Text shown in the home screen /// diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart index effa58a9a59..2b8cc9c8402 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart @@ -5,16 +5,31 @@ class VoicesLocalizationsEn extends VoicesLocalizations { VoicesLocalizationsEn([String locale = 'en']) : super(locale); @override - String get loginScreenUsernameLabelText => 'Username'; + String get emailLabelText => 'Email'; @override - String get loginScreenPasswordLabelText => 'Password'; + String get emailHintText => 'mail@example.com'; @override - String get loginScreenErrorMessage => 'Wrong credentials'; + String get emailErrorText => 'mail@example.com'; + + @override + String get passwordLabelText => 'Password'; + + @override + String get passwordHintText => 'My1SecretPassword'; + + @override + String get passwordErrorText => 'Password must be at least 8 characters long'; + + @override + String get loginTitleText => 'Login'; @override - String get loginScreenLoginButtonText => 'Login'; + String get loginButtonText => 'Login'; + + @override + String get loginScreenErrorMessage => 'Wrong credentials'; @override String get homeScreenText => 'Catalyst Voices'; diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart index 28b089fd89b..dd9de40e9ee 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart @@ -5,16 +5,31 @@ class VoicesLocalizationsEs extends VoicesLocalizations { VoicesLocalizationsEs([String locale = 'es']) : super(locale); @override - String get loginScreenUsernameLabelText => 'Nombre de usuario'; + String get emailLabelText => 'Email'; @override - String get loginScreenPasswordLabelText => 'Contraseña'; + String get emailHintText => 'mail@example.com'; @override - String get loginScreenErrorMessage => 'Credenciales incorrectas'; + String get emailErrorText => 'mail@example.com'; + + @override + String get passwordLabelText => 'Password'; + + @override + String get passwordHintText => 'My1SecretPassword'; + + @override + String get passwordErrorText => 'Password must be at least 8 characters long'; + + @override + String get loginTitleText => 'Login'; @override - String get loginScreenLoginButtonText => 'Acceso'; + String get loginButtonText => 'Login'; + + @override + String get loginScreenErrorMessage => 'Credenciales incorrectas'; @override String get homeScreenText => 'Catalyst Voices'; diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb index 437ad23ca69..5136e4327b0 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -1,21 +1,41 @@ { "@@locale": "en", - "loginScreenUsernameLabelText": "Username", - "@loginScreenUsernameLabelText": { - "description": "Text shown in the login screen for the username field" + "emailLabelText": "Email", + "@emailLabelText": { + "description": "Text shown in email field" }, - "loginScreenPasswordLabelText": "Password", - "@loginScreenPasswordLabelText": { - "description": "Text shown in the login screen for the password field" + "emailHintText": "mail@example.com", + "@emailHintText": { + "description": "Text shown in email field when empty" + }, + "emailErrorText": "mail@example.com", + "@emailErrorText": { + "description": "Text shown in email field when input is invalid" + }, + "passwordLabelText": "Password", + "@passwordLabelText": { + "description": "Text shown in password field" + }, + "passwordHintText": "My1SecretPassword", + "@passwordHintText": { + "description": "Text shown in password field when empty" + }, + "passwordErrorText": "Password must be at least 8 characters long", + "@passwordErrorText": { + "description": "Text shown in password field when input is invalid" + }, + "loginTitleText": "Login", + "@loginTitleText": { + "description": "Text shown in the login screen title" + }, + "loginButtonText": "Login", + "@loginButtonText": { + "description": "Text shown in the login screen for the login button" }, "loginScreenErrorMessage": "Wrong credentials", "@loginScreenErrorMessage": { "description": "Text shown in the login screen when the user enters wrong credentials" }, - "loginScreenLoginButtonText": "Login", - "@loginScreenLoginButtonText": { - "description": "Text shown in the login screen for the login button" - }, "homeScreenText": "Catalyst Voices", "@homeScreenText": { "description": "Text shown in the home screen" diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/catalyst_voices_models.dart b/catalyst_voices/packages/catalyst_voices_models/lib/catalyst_voices_models.dart index 34ee251355e..14d334ec9c2 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/catalyst_voices_models.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/catalyst_voices_models.dart @@ -1,3 +1,5 @@ library catalyst_voices_models; +export 'src/authentication_status.dart'; export 'src/catalyst_voices_models.dart'; +export 'src/session_data.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/authentication_status.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/authentication_status.dart new file mode 100644 index 00000000000..eb34f04f373 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/authentication_status.dart @@ -0,0 +1,5 @@ +enum AuthenticationStatus { + unknown, + authenticated, + unauthenticated; +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 07fc667aa63..e321b665c65 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -1,3 +1 @@ -class CatalystVoicesModels { - const CatalystVoicesModels(); -} +export 'errors/errors.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart new file mode 100644 index 00000000000..f4604d99cbb --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/errors.dart @@ -0,0 +1,2 @@ +export 'network_error.dart'; +export 'secure_storage_error.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/network_error.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/network_error.dart new file mode 100644 index 00000000000..9ef37ffbae5 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/network_error.dart @@ -0,0 +1,23 @@ +enum NetworkErrors implements Comparable { + badRequest(400), + unauthorized(401), + forbidden(403), + notFound(404), + gone(410), + tooManyRequests(429), + internalServerError(500), + badGateway(502), + serviceUnavailable(503); + + final int code; + + const NetworkErrors(this.code); + + @override + int compareTo(NetworkErrors other) { + return code.compareTo(other.code); + } + + @override + String toString() => 'NetworkErrors(code: $code)'; +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/secure_storage_error.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/secure_storage_error.dart new file mode 100644 index 00000000000..e71ba740885 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/errors/secure_storage_error.dart @@ -0,0 +1,16 @@ +enum SecureStorageError implements Comparable { + canNotReadData('Cannot Read Data From Secure Storage'), + canNotSaveData('Cannot Save Data From Secure Storage'); + + final String description; + + const SecureStorageError(this.description); + + @override + int compareTo(SecureStorageError other) { + return description.compareTo(other.description); + } + + @override + String toString() => 'SecureStorageError(description: $description)'; +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/session_data.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/session_data.dart new file mode 100644 index 00000000000..c2ef7fb8e47 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/session_data.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +@immutable +final class SessionData extends Equatable { + final String email; + final String password; + + const SessionData({ + required this.email, + required this.password, + }); + + factory SessionData.fromJson(String source) => SessionData.fromMap( + json.decode( + source, + ) as Map, + ); + + factory SessionData.fromMap(Map map) { + return SessionData( + email: map['email'] as String? ?? '', + password: map['password'] as String? ?? '', + ); + } + + @override + List get props => [email, password]; + + @override + bool get stringify => true; + + String toJson() => json.encode(toMap()); + + Map toMap() { + return { + 'email': email, + 'password': password, + }; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml index 9b551ed16a9..3e96a64a793 100644 --- a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml @@ -5,11 +5,10 @@ publish_to: none environment: sdk: ">=3.2.3 <4.0.0" - flutter: 3.16.5 dependencies: - flutter: - sdk: flutter + equatable: ^2.0.5 + meta: ^1.10.0 dev_dependencies: catalyst_analysis: diff --git a/catalyst_voices/packages/catalyst_voices_models/test/src/catalyst_voices_models_test.dart b/catalyst_voices/packages/catalyst_voices_models/test/src/catalyst_voices_models_test.dart index 38b9c5a9675..ab73b3a234a 100644 --- a/catalyst_voices/packages/catalyst_voices_models/test/src/catalyst_voices_models_test.dart +++ b/catalyst_voices/packages/catalyst_voices_models/test/src/catalyst_voices_models_test.dart @@ -1,11 +1 @@ -// ignore_for_file: prefer_const_constructors -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:test/test.dart'; - -void main() { - group('CatalystVoicesModels', () { - test('can be instantiated', () { - expect(CatalystVoicesModels(), isNotNull); - }); - }); -} +void main() {} diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/authentication_repository.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/authentication_repository.dart new file mode 100644 index 00000000000..496f0fc6508 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/authentication_repository.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; + +final class AuthenticationRepository { + final CredentialsStorageRepository credentialsStorageRepository; + final _controller = StreamController(); + + AuthenticationRepository({required this.credentialsStorageRepository}); + + Stream get status async* { + try { + final sessionData = await credentialsStorageRepository.getSessionData(); + + if (sessionData.isSuccess) { + yield AuthenticationStatus.authenticated; + } else { + yield AuthenticationStatus.unauthenticated; + } + } catch (error) { + yield AuthenticationStatus.unknown; + } + + yield* _controller.stream; + } + + Future dispose() async => _controller.close(); + + Future getSessionData() async { + try { + final sessionData = await credentialsStorageRepository.getSessionData(); + + if (sessionData.isSuccess) { + return sessionData.success; + } else { + return null; + } + } catch (error) { + return null; + } + } + + void logOut() { + credentialsStorageRepository.clearSessionData; + _controller.add(AuthenticationStatus.unauthenticated); + } + + Future signIn({ + required String email, + required String password, + }) async { + await credentialsStorageRepository.storeSessionData( + SessionData( + email: email, + password: password, + ), + ); + + // TODO(minikin): remove this delay after implementing real auth flow. + await Future.delayed( + const Duration(milliseconds: 300), + () => _controller.add(AuthenticationStatus.authenticated), + ); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart index 74f7a3a1189..2bf0c332b14 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart @@ -1,3 +1,2 @@ -class CatalystVoicesRepositories { - const CatalystVoicesRepositories(); -} +export 'authentication_repository.dart'; +export 'credentials_storage_repository.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart new file mode 100644 index 00000000000..6367bd42a5e --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:result_type/result_type.dart'; + +/// This is a temporary implementation of CredentialsStorageRepository +/// It's only used to set-up state management for now. +/// It will be replaced by a proper implementation as soon as authentication +/// flow will be defined. +final class CredentialsStorageRepository { + final SecureStorageService secureStorageService; + + const CredentialsStorageRepository({required this.secureStorageService}); + + void get clearSessionData => secureStorageService.deleteAll; + + Future> getSessionData() async { + try { + final email = await secureStorageService.get( + SecureStorageKeysConst.dummyEmail, + ); + + final password = await secureStorageService.get( + SecureStorageKeysConst.dummyPassword, + ); + + if (email == null || password == null) { + return Success(null); + } + + return Success( + SessionData( + email: email, + password: password, + ), + ); + } on SecureStorageError catch (_) { + return Failure(SecureStorageError.canNotReadData); + } + } + + Future> storeSessionData( + SessionData sessionData, + ) async { + try { + await secureStorageService.set( + SecureStorageKeysConst.dummyEmail, + sessionData.email, + ); + + await secureStorageService.set( + SecureStorageKeysConst.dummyPassword, + sessionData.password, + ); + return Success(null); + } on SecureStorageError catch (_) { + return Failure(SecureStorageError.canNotSaveData); + } + } +} diff --git a/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml index e137a05f28f..2f8d1d9a199 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml @@ -8,8 +8,14 @@ environment: flutter: 3.16.5 dependencies: + catalyst_voices_models: + path: ../catalyst_voices_models + catalyst_voices_services: + path: ../catalyst_voices_services flutter: sdk: flutter + result_type: ^0.2.0 + rxdart: ^0.27.7 dev_dependencies: catalyst_analysis: diff --git a/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_voices_repositories_test.dart b/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_voices_repositories_test.dart index b179dc826e5..e591dc15668 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_voices_repositories_test.dart +++ b/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_voices_repositories_test.dart @@ -1,11 +1,5 @@ -// ignore_for_file: prefer_const_constructors -import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:test/test.dart'; void main() { - group('CatalystVoicesRepositories', () { - test('can be instantiated', () { - expect(CatalystVoicesRepositories(), isNotNull); - }); - }); + group('CatalystVoicesRepositories', () {}); } diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart index 591bcd564a3..7a5df87dd44 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart @@ -1,5 +1,6 @@ final class SecureStorageKeysConst { - static const dummyKey = 'dummyKey'; + static const dummyEmail = 'email'; + static const dummyPassword = 'password'; const SecureStorageKeysConst._(); } diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/authentication.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/authentication.dart new file mode 100644 index 00000000000..31cc892b7de --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/authentication.dart @@ -0,0 +1,2 @@ +export 'email.dart'; +export 'password.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/email.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/email.dart new file mode 100644 index 00000000000..1c27c4d99b5 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/email.dart @@ -0,0 +1,27 @@ +import 'package:formz/formz.dart'; + +final class Email extends FormzInput { + static final _emailRegExp = RegExp( + r'^[a-zA-Z\d.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z\d-]+(?:\.[a-zA-Z\d-]+)*$', + ); + + const Email.dirty([super.value = '']) : super.dirty(); + + const Email.pure([super.value = '']) : super.pure(); + + @override + EmailValidationError? validator(String value) { + return _emailRegExp.hasMatch(value) ? null : EmailValidationError.invalid; + } +} + +enum EmailValidationError { + invalid; + + String description(String text) { + switch (this) { + case EmailValidationError.invalid: + return text; + } + } +} diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/password.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/password.dart new file mode 100644 index 00000000000..3b5dc62137f --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/password.dart @@ -0,0 +1,27 @@ +import 'package:formz/formz.dart'; + +final class Password extends FormzInput { + static final _passwordRegex = RegExp(r'^(?=.*[a-zA-Z])(?=.*\d).{8,}$'); + + const Password.dirty([super.value = '']) : super.dirty(); + + const Password.pure([super.value = '']) : super.pure(); + + @override + PasswordValidationError? validator(String value) { + return _passwordRegex.hasMatch(value) + ? null + : PasswordValidationError.invalid; + } +} + +enum PasswordValidationError { + invalid; + + String description(String text) { + switch (this) { + case PasswordValidationError.invalid: + return text; + } + } +} diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index d53d43e6c4e..b623c7d2547 100644 --- a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1,3 +1 @@ -class CatalystVoicesViewModels { - const CatalystVoicesViewModels(); -} +export 'authentication/authentication.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml index e5cf705ee24..f888c39fb0c 100644 --- a/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml @@ -8,8 +8,10 @@ environment: flutter: 3.16.5 dependencies: + equatable: ^2.0.5 flutter: sdk: flutter + formz: ^0.6.1 dev_dependencies: catalyst_analysis: diff --git a/catalyst_voices/packages/catalyst_voices_view_models/test/src/catalyst_voices_view_models_test.dart b/catalyst_voices/packages/catalyst_voices_view_models/test/src/catalyst_voices_view_models_test.dart index 49a6d3807a7..b0d5ab372b4 100644 --- a/catalyst_voices/packages/catalyst_voices_view_models/test/src/catalyst_voices_view_models_test.dart +++ b/catalyst_voices/packages/catalyst_voices_view_models/test/src/catalyst_voices_view_models_test.dart @@ -1,11 +1,5 @@ -// ignore_for_file: prefer_const_constructors -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:test/test.dart'; void main() { - group('CatalystVoicesViewModels', () { - test('can be instantiated', () { - expect(CatalystVoicesViewModels(), isNotNull); - }); - }); + group('CatalystVoicesViewModels', () {}); } diff --git a/catalyst_voices/pubspec.yaml b/catalyst_voices/pubspec.yaml index 6c3c10fc312..641ec6c2e29 100644 --- a/catalyst_voices/pubspec.yaml +++ b/catalyst_voices/pubspec.yaml @@ -8,6 +8,7 @@ environment: flutter: 3.16.5 dependencies: + animations: ^2.0.11 catalyst_voices_assets: path: ./packages/catalyst_voices_assets catalyst_voices_blocs: @@ -26,14 +27,24 @@ dependencies: path: ./packages/catalyst_voices_view_models flutter: sdk: flutter + flutter_adaptive_scaffold: ^0.1.7+2 flutter_bloc: ^8.1.3 flutter_localized_locales: ^2.0.5 + flutter_web_plugins: + sdk: flutter + formz: ^0.6.1 + go_router: ^13.0.0 + url_launcher: ^6.2.2 + url_strategy: ^0.2.0 dev_dependencies: + build_runner: ^2.4.7 + build_verify: ^3.1.0 catalyst_analysis: path: ../catalyst_voices_packages/catalyst_analysis flutter_test: sdk: flutter + go_router_builder: ^2.4.1 integration_test: sdk: flutter mocktail: ^1.0.1 diff --git a/melos.yaml b/melos.yaml index 5d2c4719300..f2c4f1a6bf7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -10,6 +10,22 @@ command: version: linkToCommits: true workspaceChangelog: true + bootstrap: + environment: + sdk: '>=3.2.1 <4.0.0' + flutter: 3.16.5 + dependencies: + bloc_concurrency: ^0.2.2 + bloc: ^8.1.2 + collection: ^1.17.1 + equatable: ^2.0.5 + formz: ^0.6.1 + meta: ^1.10.0 + result_type: ^0.2.0 + dev_dependencies: + test: ^1.24.9 + build_runner: ^2.3.3 + mocktail: ^1.0.1 scripts: lint: