diff --git a/catalyst_voices/lib/app/view/app_content.dart b/catalyst_voices/lib/app/view/app_content.dart index fc8160e07f..be743c669a 100644 --- a/catalyst_voices/lib/app/view/app_content.dart +++ b/catalyst_voices/lib/app/view/app_content.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices/app/view/app_precache_image_assets.dart'; +import 'package:catalyst_voices/app/view/app_session_listener.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; @@ -51,7 +52,9 @@ class AppContentState extends State { ), builder: (context, child) { return GlobalPrecacheImages( - child: child ?? const SizedBox.shrink(), + child: GlobalSessionListener( + child: child ?? const SizedBox.shrink(), + ), ); }, ); diff --git a/catalyst_voices/lib/app/view/app_session_listener.dart b/catalyst_voices/lib/app/view/app_session_listener.dart new file mode 100644 index 0000000000..42bf1fae47 --- /dev/null +++ b/catalyst_voices/lib/app/view/app_session_listener.dart @@ -0,0 +1,67 @@ +import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart'; +import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.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'; + +/// Listens globally to a session and can show different +/// snackBars when a session changes. +class GlobalSessionListener extends StatelessWidget { + final Widget child; + + const GlobalSessionListener({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: _listenToSessionChangesWhen, + listener: _onSessionChanged, + child: child, + ); + } + + bool _listenToSessionChangesWhen(SessionState prev, SessionState next) { + // We deliberately check if previous was guest because we don't + // want to show the snackbar after the registration is completed. + final keychainUnlocked = + prev is GuestSessionState && next is ActiveUserSessionState; + + final keychainLocked = + prev is ActiveUserSessionState && next is GuestSessionState; + + return keychainUnlocked || keychainLocked; + } + + void _onSessionChanged(BuildContext context, SessionState state) { + if (state is ActiveUserSessionState) { + _onUnlockedKeychain(context); + } else if (state is GuestSessionState) { + _onLockedKeychain(context); + } + } + + void _onUnlockedKeychain(BuildContext context) { + VoicesSnackBar( + type: VoicesSnackBarType.success, + behavior: SnackBarBehavior.floating, + icon: VoicesAssets.icons.lockOpen.buildIcon(), + title: context.l10n.unlockSnackbarTitle, + message: context.l10n.unlockSnackbarMessage, + ).show(context); + } + + void _onLockedKeychain(BuildContext context) { + VoicesSnackBar( + type: VoicesSnackBarType.error, + behavior: SnackBarBehavior.floating, + icon: VoicesAssets.icons.lockClosed.buildIcon(), + title: context.l10n.lockSnackbarTitle, + message: context.l10n.lockSnackbarMessage, + ).show(context); + } +} diff --git a/catalyst_voices/lib/common/error_handler.dart b/catalyst_voices/lib/common/error_handler.dart index 13ce296d18..2a85b2ded8 100644 --- a/catalyst_voices/lib/common/error_handler.dart +++ b/catalyst_voices/lib/common/error_handler.dart @@ -1,16 +1,46 @@ //ignore_for_file: one_member_abstracts +import 'dart:async'; + import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +/// An interface of an abstract error handler. abstract interface class ErrorHandler { void handleError(Object error); } -mixin ErrorHandlerStateMixin on State - implements ErrorHandler { +/// A convenient mixin that subscribes to the [ErrorEmitter] +/// obtained from the [errorEmitter] and calls the [handleError]. +/// +/// After the widget is disposed the error stream is disposed too. +mixin ErrorHandlerStateMixin + on State implements ErrorHandler { + StreamSubscription? _errorSub; + + @override + void initState() { + super.initState(); + _errorSub = errorEmitter.errorStream.listen(handleError); + } + + @override + void dispose() { + unawaited(_errorSub?.cancel()); + _errorSub = null; + super.dispose(); + } + + /// A method that can be overridden to provide a custom error emitter. + /// + /// If this method is not overriden then the emitter of type [E] + /// must be provided in a widget tree so that context.read can find it. + E get errorEmitter => context.read(); + @override void handleError(Object error) { if (error is LocalizedException) { @@ -19,7 +49,9 @@ mixin ErrorHandlerStateMixin on State } void handleLocalizedException(LocalizedException exception) { - // TODO(damian-molinski): VoicesSnackBar does not support custom text yet. - const VoicesSnackBar(type: VoicesSnackBarType.error).show(context); + VoicesSnackBar( + type: VoicesSnackBarType.error, + message: exception.message(context), + ).show(context); } } diff --git a/catalyst_voices/lib/pages/account/unlock_keychain_dialog.dart b/catalyst_voices/lib/pages/account/unlock_keychain_dialog.dart new file mode 100644 index 0000000000..b606edd9da --- /dev/null +++ b/catalyst_voices/lib/pages/account/unlock_keychain_dialog.dart @@ -0,0 +1,171 @@ +import 'package:catalyst_voices/common/error_handler.dart'; +import 'package:catalyst_voices/pages/registration/pictures/unlock_keychain_picture.dart'; +import 'package:catalyst_voices/pages/registration/widgets/information_panel.dart'; +import 'package:catalyst_voices/pages/registration/widgets/registration_stage_message.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// A dialog which allows to unlock the session (keychain). +class UnlockKeychainDialog extends StatefulWidget { + const UnlockKeychainDialog({super.key}); + + static Future show(BuildContext context) { + return VoicesDialog.show( + context: context, + routeSettings: const RouteSettings(name: '/unlock'), + builder: (context) => const UnlockKeychainDialog(), + barrierDismissible: false, + ); + } + + @override + State createState() => _UnlockKeychainDialogState(); +} + +class _UnlockKeychainDialogState extends State + with ErrorHandlerStateMixin { + final TextEditingController _passwordController = TextEditingController(); + LocalizedException? _error; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + @override + void handleError(Object error) { + setState(() { + _error = error is LocalizedException + ? error + : const LocalizedUnlockPasswordException(); + }); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: _handleSessionChange, + child: VoicesTwoPaneDialog( + left: InformationPanel( + title: context.l10n.unlockDialogHeader, + picture: const UnlockKeychainPicture(), + ), + right: _UnlockPasswordPanel( + controller: _passwordController, + error: _error, + onUnlock: _onUnlock, + ), + ), + ); + } + + void _handleSessionChange(BuildContext context, SessionState state) { + if (state is ActiveUserSessionState) { + Navigator.of(context).pop(); + } + } + + void _onUnlock() { + setState(() { + _error = null; + }); + + final password = _passwordController.text; + final unlockFactor = PasswordLockFactor(password); + context + .read() + .add(UnlockSessionEvent(unlockFactor: unlockFactor)); + } +} + +class _UnlockPasswordPanel extends StatelessWidget { + final TextEditingController controller; + final LocalizedException? error; + final VoidCallback onUnlock; + + const _UnlockPasswordPanel({ + required this.controller, + required this.error, + required this.onUnlock, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + RegistrationStageMessage( + title: Text(context.l10n.unlockDialogTitle), + subtitle: Text(context.l10n.unlockDialogContent), + ), + const SizedBox(height: 24), + _UnlockPassword( + controller: controller, + error: error, + ), + const Spacer(), + _Navigation( + onUnlock: onUnlock, + ), + ], + ); + } +} + +class _UnlockPassword extends StatelessWidget { + final TextEditingController controller; + final LocalizedException? error; + + const _UnlockPassword({ + required this.controller, + required this.error, + }); + + @override + Widget build(BuildContext context) { + return VoicesPasswordTextField( + controller: controller, + decoration: VoicesTextFieldDecoration( + labelText: context.l10n.unlockDialogHint, + errorText: error?.message(context), + hintText: context.l10n.passwordLabelText, + ), + ); + } +} + +class _Navigation extends StatelessWidget { + final VoidCallback onUnlock; + + const _Navigation({ + required this.onUnlock, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: VoicesOutlinedButton( + onTap: () => Navigator.of(context).pop(), + child: Text(context.l10n.continueAsGuest), + ), + ), + const SizedBox(width: 10), + Expanded( + child: VoicesFilledButton( + onTap: onUnlock, + child: Text(context.l10n.confirmPassword), + ), + ), + ], + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/pictures/unlock_keychain_picture.dart b/catalyst_voices/lib/pages/registration/pictures/unlock_keychain_picture.dart new file mode 100644 index 0000000000..ec5b9dc8e7 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/pictures/unlock_keychain_picture.dart @@ -0,0 +1,26 @@ +import 'package:catalyst_voices/pages/registration/pictures/task_picture.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class UnlockKeychainPicture extends StatelessWidget { + const UnlockKeychainPicture({super.key}); + + @override + Widget build(BuildContext context) { + return TaskPicture( + child: TaskPictureIconBox( + child: AspectRatio( + aspectRatio: 101 / 42, + child: Container( + height: double.infinity, + margin: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv0, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/registration_dialog.dart b/catalyst_voices/lib/pages/registration/registration_dialog.dart index 172ebc3fd4..a479796b5d 100644 --- a/catalyst_voices/lib/pages/registration/registration_dialog.dart +++ b/catalyst_voices/lib/pages/registration/registration_dialog.dart @@ -28,26 +28,18 @@ class RegistrationDialog extends StatefulWidget { } class _RegistrationDialogState extends State - with ErrorHandlerStateMixin { - late final RegistrationCubit _cubit; - StreamSubscription? _errorSub; - - @override - void initState() { - super.initState(); - _cubit = Dependencies.instance.get(); - _errorSub = _cubit.errorStream.listen(handleError); - } + with ErrorHandlerStateMixin { + late final RegistrationCubit _cubit = Dependencies.instance.get(); @override void dispose() { - unawaited(_errorSub?.cancel()); - _errorSub = null; - unawaited(_cubit.close()); super.dispose(); } + @override + RegistrationCubit get errorEmitter => _cubit; + @override Widget build(BuildContext context) { return BlocProvider.value( diff --git a/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart b/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart index d354771106..23c2276461 100644 --- a/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart +++ b/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:catalyst_voices/pages/account/unlock_keychain_dialog.dart'; import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart'; import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; @@ -52,9 +55,7 @@ class _LockButton extends StatelessWidget { Widget build(BuildContext context) { return VoicesIconButton.filled( style: const ButtonStyle(shape: WidgetStatePropertyAll(CircleBorder())), - onTap: () { - context.read().add(const GuestSessionEvent()); - }, + onTap: () => context.read().add(const LockSessionEvent()), child: VoicesAssets.icons.lockClosed.buildIcon(), ); } @@ -67,9 +68,7 @@ class _UnlockButton extends StatelessWidget { Widget build(BuildContext context) { return VoicesFilledButton( trailing: VoicesAssets.icons.lockOpen.buildIcon(), - onTap: () { - context.read().add(const ActiveUserSessionEvent()); - }, + onTap: () => unawaited(UnlockKeychainDialog.show(context)), child: Text(context.l10n.unlock), ); } diff --git a/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart b/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart index 79fad5e330..537b095f55 100644 --- a/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart +++ b/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart @@ -21,15 +21,23 @@ class SessionStateHeader extends StatelessWidget { GuestSessionState() => const _GuestButton(), ActiveUserSessionState(:final user) => AccountPopup( avatarLetter: user.acronym, - onLockAccountTap: () => debugPrint('Lock account'), - onProfileKeychainTap: () => unawaited( - const AccountRoute().push(context), - ), + onLockAccountTap: () => _onLockAccount(context), + onProfileKeychainTap: () => _onSeeProfile(context), ), }; }, ); } + + void _onLockAccount(BuildContext context) { + context.read().add(const LockSessionEvent()); + } + + void _onSeeProfile(BuildContext context) { + unawaited( + const AccountRoute().push(context), + ); + } } class _GuestButton extends StatelessWidget { diff --git a/catalyst_voices/lib/widgets/footers/standard_links_page_footer.dart b/catalyst_voices/lib/widgets/footers/standard_links_page_footer.dart index e5f982340d..c8370ce61a 100644 --- a/catalyst_voices/lib/widgets/footers/standard_links_page_footer.dart +++ b/catalyst_voices/lib/widgets/footers/standard_links_page_footer.dart @@ -9,7 +9,7 @@ class StandardLinksPageFooter extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO(damian): implement proper routing actions once we have them + // TODO(damian-molinski): implement proper routing actions once we have them return LinksPageFooter( upperChildren: [ LinkText( diff --git a/catalyst_voices/lib/widgets/snackbar/voices_snackbar.dart b/catalyst_voices/lib/widgets/snackbar/voices_snackbar.dart index 02c584363a..f74e85d660 100644 --- a/catalyst_voices/lib/widgets/snackbar/voices_snackbar.dart +++ b/catalyst_voices/lib/widgets/snackbar/voices_snackbar.dart @@ -1,7 +1,11 @@ +import 'dart:math'; + +import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; +import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_action.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter/material.dart'; /// [VoicesSnackBar] is a custom [SnackBar] widget that displays messages with @@ -14,12 +18,19 @@ class VoicesSnackBar extends StatelessWidget { /// which determines its appearance and behavior. final VoicesSnackBarType type; - /// Function to be executed when the primary action button is pressed. - final VoidCallback? onPrimaryPressed; + /// A custom icon. Overrides the default one specified by [type]. + final Widget? icon; + + /// A custom title. Overrides the default one specified by [type]. + final String? title; + + /// A custom message. Overrides the default one specified by [type]. + final String? message; - /// Callback function to be executed when the secondary action button is - /// pressed. - final VoidCallback? onSecondaryPressed; + /// The list of actions attached to the bottom of the snackBar. + /// + /// See [VoicesSnackBarPrimaryAction] and [VoicesSnackBarSecondaryAction]. + final List actions; /// Callback function to be executed when the close button is pressed. final VoidCallback? onClosePressed; @@ -27,17 +38,24 @@ class VoicesSnackBar extends StatelessWidget { /// The behavior of the [VoicesSnackBar], which can be fixed or floating. final SnackBarBehavior? behavior; - /// The padding around the content of the [VoicesSnackBar]. + /// The padding around the the [VoicesSnackBar]. final EdgeInsetsGeometry? padding; /// The width of the [VoicesSnackBar]. + /// + /// If null and [behavior] is [SnackBarBehavior.floating] then snackbar + /// will calculate it's size using the following formula: + /// - max(screenWidth * 0.4, 300) + /// but no more than the screenWidth. final double? width; const VoicesSnackBar({ super.key, required this.type, - this.onPrimaryPressed, - this.onSecondaryPressed, + this.icon, + this.title, + this.message, + this.actions = const [], this.onClosePressed, this.width, this.behavior = SnackBarBehavior.fixed, @@ -48,97 +66,61 @@ class VoicesSnackBar extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = theme.textTheme; - final l10n = context.l10n; return DecoratedBox( decoration: BoxDecoration( color: type.backgroundColor(context), borderRadius: BorderRadius.circular(8), ), - child: Stack( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Positioned( - top: 12, - right: 12, - child: IconButton( - icon: VoicesAssets.icons.x.buildIcon( - size: 24, - color: theme.colors.iconsForeground, + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), - onPressed: onClosePressed, - ), - ), - Column( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - type.icon().buildIcon( - size: 20, - color: type.iconColor(context), - ), - const SizedBox(width: 16), - Text( - type.title(context), - style: TextStyle( - color: type.titleColor(context), - fontSize: textTheme.titleMedium?.fontSize, - fontWeight: textTheme.titleMedium?.fontWeight, - fontFamily: textTheme.titleMedium?.fontFamily, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 48, - ), - child: Row( - children: [ - Text( - type.message(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _IconAndTitle( + type: type, + icon: icon ?? type.icon().buildIcon(), + title: title ?? type.title(context), + ), + Padding( + padding: const EdgeInsets.only(left: 28), + child: Text( + message ?? type.message(context), style: textTheme.bodyMedium, ), - ], - ), - ), - const SizedBox(height: 18), - Padding( - padding: const EdgeInsets.only( - left: 36, - ), - child: Row( - children: [ - TextButton( - onPressed: onPrimaryPressed, - child: Text( - type == VoicesSnackBarType.success - ? l10n.snackbarOkButtonText - : l10n.snackbarRefreshButtonText, - style: TextStyle( - color: theme.colors.textPrimary, - ), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: onSecondaryPressed, - child: Text( - l10n.snackbarMoreButtonText, - style: TextStyle( - color: theme.colors.textPrimary, - decoration: TextDecoration.underline, - ), + ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Row( + children: actions + .separatedBy(const SizedBox(width: 8)) + .toList(), ), ), ], - ), + ], ), - const SizedBox(height: 18), - ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8, right: 4), + child: IconButton( + icon: VoicesAssets.icons.x.buildIcon( + size: 24, + color: theme.colors.iconsForeground, + ), + onPressed: onClosePressed ?? () => hideCurrent(context), + ), ), ], ), @@ -150,7 +132,9 @@ class VoicesSnackBar extends StatelessWidget { SnackBar( content: this, behavior: behavior, - width: behavior == SnackBarBehavior.floating ? width : null, + width: _calculateSnackBarWidth( + screenWidth: MediaQuery.sizeOf(context).width, + ), padding: padding, elevation: 0, backgroundColor: Colors.transparent, @@ -158,6 +142,53 @@ class VoicesSnackBar extends StatelessWidget { ); } + double? _calculateSnackBarWidth({required double screenWidth}) { + switch (behavior) { + case null: + case SnackBarBehavior.fixed: + // custom width not supported + return null; + case SnackBarBehavior.floating: + return max(screenWidth * 0.4, 300).clamp(0.0, screenWidth).toDouble(); + } + } + static void hideCurrent(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar(); } + +class _IconAndTitle extends StatelessWidget { + final VoicesSnackBarType type; + final Widget icon; + final String title; + + const _IconAndTitle({ + required this.type, + required this.icon, + required this.title, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return AffixDecorator( + prefix: IconTheme( + data: IconThemeData( + size: 20, + color: type.iconColor(context), + ), + child: icon, + ), + child: Text( + title, + style: TextStyle( + color: type.titleColor(context), + fontSize: textTheme.titleMedium?.fontSize, + fontWeight: textTheme.titleMedium?.fontWeight, + fontFamily: textTheme.titleMedium?.fontFamily, + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/snackbar/voices_snackbar_action.dart b/catalyst_voices/lib/widgets/snackbar/voices_snackbar_action.dart new file mode 100644 index 0000000000..5d0742d58a --- /dev/null +++ b/catalyst_voices/lib/widgets/snackbar/voices_snackbar_action.dart @@ -0,0 +1,51 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesSnackBarPrimaryAction extends StatelessWidget { + final VoidCallback onPressed; + final Widget child; + + const VoicesSnackBarPrimaryAction({ + super.key, + required this.onPressed, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: DefaultTextStyle( + style: TextStyle( + color: Theme.of(context).colors.textPrimary, + ), + child: child, + ), + ); + } +} + +class VoicesSnackBarSecondaryAction extends StatelessWidget { + final VoidCallback onPressed; + final Widget child; + + const VoicesSnackBarSecondaryAction({ + super.key, + required this.onPressed, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: DefaultTextStyle( + style: TextStyle( + color: Theme.of(context).colors.textPrimary, + decoration: TextDecoration.underline, + ), + child: child, + ), + ); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/bloc_error_emitter_mixin.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/bloc_error_emitter_mixin.dart index e433b92881..8a03613824 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/bloc_error_emitter_mixin.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/bloc_error_emitter_mixin.dart @@ -2,9 +2,16 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; -mixin BlocErrorEmitterMixin on BlocBase { +/// An interface of an abstract error emitter. +abstract interface class ErrorEmitter { + /// The asynchronous error stream that can be listened by error handlers. + Stream get errorStream; +} + +mixin BlocErrorEmitterMixin on BlocBase implements ErrorEmitter { late final _errorController = StreamController.broadcast(); + @override Stream get errorStream => _errorController.stream; void emitError(Object error) { diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart index a5434019ad..e38d3ad0a0 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_bloc.dart @@ -1,17 +1,21 @@ import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_voices_blocs/src/bloc_error_emitter_mixin.dart'; import 'package:catalyst_voices_blocs/src/session/session_event.dart'; import 'package:catalyst_voices_blocs/src/session/session_state.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -// TODO(dtscalac): unlock session /// Manages the user session. -final class SessionBloc extends Bloc { +final class SessionBloc extends Bloc + with BlocErrorEmitterMixin { final Keychain _keychain; SessionBloc(this._keychain) : super(const VisitorSessionState()) { on(_onRestoreSessionEvent); + on(_onUnlockSessionEvent); + on(_onLockSessionEvent); on(_onNextStateEvent); on(_onVisitorEvent); on(_onGuestEvent); @@ -26,12 +30,36 @@ final class SessionBloc extends Bloc { if (!await _keychain.hasSeedPhrase) { emit(const VisitorSessionState()); } else if (await _keychain.isUnlocked) { - emit(ActiveUserSessionState(user: _dummyUser)); + // TODO(damian-molinski): we shouldn't keep the keychain unlocked + // after leaving the app. In the future once keychain stays locked + // when leaving the app the logic here is not needed. + await _keychain.lock(); + emit(const GuestSessionState()); } else { emit(const GuestSessionState()); } } + Future _onUnlockSessionEvent( + UnlockSessionEvent event, + Emitter emit, + ) async { + final unlocked = await _keychain.unlock(event.unlockFactor); + if (unlocked) { + emit(ActiveUserSessionState(user: _dummyUser)); + } else { + emitError(const LocalizedUnlockPasswordException()); + } + } + + Future _onLockSessionEvent( + LockSessionEvent event, + Emitter emit, + ) async { + await _keychain.lock(); + emit(const GuestSessionState()); + } + void _onNextStateEvent( NextStateSessionEvent event, Emitter emit, @@ -49,7 +77,9 @@ final class SessionBloc extends Bloc { VisitorSessionEvent event, Emitter emit, ) async { - await _keychain.clearAndLock(); + if (await _keychain.hasSeedPhrase) { + await _keychain.clearAndLock(); + } emit(const VisitorSessionState()); } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_event.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_event.dart index aaa6012abf..d87c8d8935 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_event.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/session/session_event.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; import 'package:equatable/equatable.dart'; /// Describes events that change the user session. @@ -13,6 +14,29 @@ final class RestoreSessionEvent extends SessionEvent { List get props => []; } +/// An event to unlock the session with given [unlockFactor]. +final class UnlockSessionEvent extends SessionEvent { + final LockFactor unlockFactor; + + const UnlockSessionEvent({required this.unlockFactor}); + + @override + List get props => [unlockFactor]; + + // Deliberately override the toString() so that + // we don't expose the unlockFactor in the logs. + @override + String toString() => 'UnlockSessionEvent'; +} + +/// An event to lock the session. +final class LockSessionEvent extends SessionEvent { + const LockSessionEvent(); + + @override + List get props => []; +} + /// Dummy implementation of session management, /// just toggles the next session state or reset to the initial one. final class NextStateSessionEvent extends SessionEvent { 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 b472432839..47b88a2ba9 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 @@ -1738,6 +1738,66 @@ abstract class VoicesLocalizations { /// **'With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. 

But it can be a bit tedious to enter every single time you want to use the app. 

In this next step, you\'ll set your Unlock Password for your current device. It\'s like a shortcut for proving ownership of your Keychain. 

Whenever you recover your account for the first time on a new device, you\'ll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.'** String get recoveryUnlockPasswordInstructionsSubtitle; + /// The header label in unlock dialog. + /// + /// In en, this message translates to: + /// **'Unlock Catalyst'** + String get unlockDialogHeader; + + /// The title label in unlock dialog. + /// + /// In en, this message translates to: + /// **'Welcome back!'** + String get unlockDialogTitle; + + /// The content (body) in unlock dialog. + /// + /// In en, this message translates to: + /// **'Please enter your device specific unlock password\nto unlock Catalyst Voices.'** + String get unlockDialogContent; + + /// The hint for the unlock password field. + /// + /// In en, this message translates to: + /// **'Enter your Unlock password'** + String get unlockDialogHint; + + /// An error message shown below the password field when the password is incorrect. + /// + /// In en, this message translates to: + /// **'Password is incorrect, try again.'** + String get unlockDialogIncorrectPassword; + + /// The message shown when asking the user to login/unlock and the user wants to cancel the process. + /// + /// In en, this message translates to: + /// **'Continue as guest'** + String get continueAsGuest; + + /// The title shown in confirmation snackbar after unlocking the keychain. + /// + /// In en, this message translates to: + /// **'Catalyst unlocked!'** + String get unlockSnackbarTitle; + + /// The message shown below the title in confirmation snackbar after unlocking the keychain. + /// + /// In en, this message translates to: + /// **'You can now fully use the application.'** + String get unlockSnackbarMessage; + + /// The title shown in confirmation snackbar after locking the keychain. + /// + /// In en, this message translates to: + /// **'Catalyst locked'** + String get lockSnackbarTitle; + + /// The message shown below the title in confirmation snackbar after locking the keychain. + /// + /// In en, this message translates to: + /// **'Catalyst is now in guest/locked mode.'** + String get lockSnackbarMessage; + /// No description provided for @recoverySuccessTitle. /// /// In en, this message translates to: 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 bc75761b32..9900fc75d1 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 @@ -914,6 +914,36 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get recoveryUnlockPasswordInstructionsSubtitle => 'With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. 

But it can be a bit tedious to enter every single time you want to use the app. 

In this next step, you\'ll set your Unlock Password for your current device. It\'s like a shortcut for proving ownership of your Keychain. 

Whenever you recover your account for the first time on a new device, you\'ll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.'; + @override + String get unlockDialogHeader => 'Unlock Catalyst'; + + @override + String get unlockDialogTitle => 'Welcome back!'; + + @override + String get unlockDialogContent => 'Please enter your device specific unlock password\nto unlock Catalyst Voices.'; + + @override + String get unlockDialogHint => 'Enter your Unlock password'; + + @override + String get unlockDialogIncorrectPassword => 'Password is incorrect, try again.'; + + @override + String get continueAsGuest => 'Continue as guest'; + + @override + String get unlockSnackbarTitle => 'Catalyst unlocked!'; + + @override + String get unlockSnackbarMessage => 'You can now fully use the application.'; + + @override + String get lockSnackbarTitle => 'Catalyst locked'; + + @override + String get lockSnackbarMessage => 'Catalyst is now in guest/locked mode.'; + @override String get recoverySuccessTitle => 'Congratulations your Catalyst 
Keychain is restored!'; 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 edbee7bc55..bbf30a8d75 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 @@ -914,6 +914,36 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get recoveryUnlockPasswordInstructionsSubtitle => 'With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. 

But it can be a bit tedious to enter every single time you want to use the app. 

In this next step, you\'ll set your Unlock Password for your current device. It\'s like a shortcut for proving ownership of your Keychain. 

Whenever you recover your account for the first time on a new device, you\'ll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.'; + @override + String get unlockDialogHeader => 'Unlock Catalyst'; + + @override + String get unlockDialogTitle => 'Welcome back!'; + + @override + String get unlockDialogContent => 'Please enter your device specific unlock password\nto unlock Catalyst Voices.'; + + @override + String get unlockDialogHint => 'Enter your Unlock password'; + + @override + String get unlockDialogIncorrectPassword => 'Password is incorrect, try again.'; + + @override + String get continueAsGuest => 'Continue as guest'; + + @override + String get unlockSnackbarTitle => 'Catalyst unlocked!'; + + @override + String get unlockSnackbarMessage => 'You can now fully use the application.'; + + @override + String get lockSnackbarTitle => 'Catalyst locked'; + + @override + String get lockSnackbarMessage => 'Catalyst is now in guest/locked mode.'; + @override String get recoverySuccessTitle => 'Congratulations your Catalyst 
Keychain is restored!'; 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 e1c28f58de..cb29aa5008 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 @@ -912,6 +912,46 @@ "recoveryAccountDetailsAction": "Set unlock password for this device", "recoveryUnlockPasswordInstructionsTitle": "Set your Catalyst unlock password f\u2028or this device", "recoveryUnlockPasswordInstructionsSubtitle": "With over 300 trillion possible combinations, your 12 word seed phrase is great for keeping your account safe. \u2028\u2028But it can be a bit tedious to enter every single time you want to use the app. \u2028\u2028In this next step, you'll set your Unlock Password for your current device. It's like a shortcut for proving ownership of your Keychain. \u2028\u2028Whenever you recover your account for the first time on a new device, you'll need to use your Catalyst Keychain to get started. Every time after that, you can use your Unlock Password to quickly regain access.", + "unlockDialogHeader": "Unlock Catalyst", + "@unlockDialogHeader": { + "description": "The header label in unlock dialog." + }, + "unlockDialogTitle": "Welcome back!", + "@unlockDialogTitle": { + "description": "The title label in unlock dialog." + }, + "unlockDialogContent": "Please enter your device specific unlock password\nto unlock Catalyst Voices.", + "@unlockDialogContent": { + "description": "The content (body) in unlock dialog." + }, + "unlockDialogHint": "Enter your Unlock password", + "@unlockDialogHint": { + "description": "The hint for the unlock password field." + }, + "unlockDialogIncorrectPassword": "Password is incorrect, try again.", + "@unlockDialogIncorrectPassword": { + "description": "An error message shown below the password field when the password is incorrect." + }, + "continueAsGuest": "Continue as guest", + "@continueAsGuest": { + "description": "The message shown when asking the user to login/unlock and the user wants to cancel the process." + }, + "unlockSnackbarTitle": "Catalyst unlocked!", + "@unlockSnackbarTitle": { + "description": "The title shown in confirmation snackbar after unlocking the keychain." + }, + "unlockSnackbarMessage": "You can now fully use the application.", + "@unlockSnackbarMessage": { + "description": "The message shown below the title in confirmation snackbar after unlocking the keychain." + }, + "lockSnackbarTitle": "Catalyst locked", + "@lockSnackbarTitle": { + "description": "The title shown in confirmation snackbar after locking the keychain." + }, + "lockSnackbarMessage": "Catalyst is now in guest/locked mode.", + "@lockSnackbarMessage": { + "description": "The message shown below the title in confirmation snackbar after locking the keychain." + }, "recoverySuccessTitle": "Congratulations your Catalyst \u2028Keychain is restored!", "recoverySuccessSubtitle": "You have successfully restored your Catalyst Keychain, and unlocked Catalyst Voices on this device.", "recoverySuccessGoToDashboard": "Jump into the Discovery space / Dashboard", diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/key_derivation.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/key_derivation.dart index cf95a0d0cc..53ec2f37d9 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/key_derivation.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/key_derivation.dart @@ -3,7 +3,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:ed25519_hd_key/ed25519_hd_key.dart'; /// Derives key pairs from a seed phrase. -class KeyDerivation { +final class KeyDerivation { /// Derives an [Ed25519KeyPair] from a [seedPhrase] and [path]. /// /// Example [path]: m/0'/2147483647' diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart index 2d10047112..33ea17b5b1 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/keychain/keychain.dart @@ -13,7 +13,7 @@ const _seedPhraseKey = 'keychain_seed_phrase'; // TODO(dtscalac): in the future when key derivation algorithm spec // will become stable consider to store derived keys instead of deriving // them each time they are needed. -class Keychain { +final class Keychain { final _logger = Logger('Keychain'); final KeyDerivation _keyDerivation; @@ -60,9 +60,9 @@ class Keychain { /// /// In most cases the [unlock] is going to be /// an instance of a [PasswordLockFactor]. - Future unlock(LockFactor unlock) async { + Future unlock(LockFactor unlock) async { _logger.info('unlock'); - await _vault.unlock(unlock); + return _vault.unlock(unlock); } /// Locks the keychain. diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart index b517132f70..63850d0bcd 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart @@ -8,7 +8,7 @@ typedef RegistrationMetadata = X509MetadataEnvelope; /// A builder that builds a Catalyst user registration transaction /// using RBAC specification. -class RegistrationTransactionBuilder { +final class RegistrationTransactionBuilder { /// The RBAC registration purpose for the Catalyst Project. static const _catalystUserRoleRegistrationPurpose = 'ca7a1457-ef9f-4c7f-9c74-7f8c4a4cfa6c'; 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 index 6206c2555d..6ac95a1ea3 100644 --- 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 @@ -1,3 +1,4 @@ export 'email.dart'; +export 'exception/localized_unlock_password_exception.dart'; export 'password.dart'; export 'unlock_password.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/exception/localized_unlock_password_exception.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/exception/localized_unlock_password_exception.dart new file mode 100644 index 0000000000..bf86cdecdc --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/authentication/exception/localized_unlock_password_exception.dart @@ -0,0 +1,12 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/src/exception/localized_exception.dart'; +import 'package:flutter/widgets.dart'; + +/// An incorrect password was used to unlock the keychain. +final class LocalizedUnlockPasswordException extends LocalizedException { + const LocalizedUnlockPasswordException(); + + @override + String message(BuildContext context) => + context.l10n.unlockDialogIncorrectPassword; +} diff --git a/catalyst_voices/uikit_example/lib/examples/voices_snackbar_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_snackbar_example.dart index bcc457d83b..79cf16e14e 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_snackbar_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_snackbar_example.dart @@ -1,5 +1,7 @@ import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart'; +import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_action.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; class VoicesSnackbarExample extends StatelessWidget { @@ -9,7 +11,19 @@ class VoicesSnackbarExample extends StatelessWidget { @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.sizeOf(context).width; + final actionsList = [ + [ + VoicesSnackBarPrimaryAction( + onPressed: () {}, + child: Text(context.l10n.snackbarRefreshButtonText), + ), + VoicesSnackBarSecondaryAction( + onPressed: () {}, + child: Text(context.l10n.learnMore), + ), + ], + [], + ]; return Scaffold( appBar: AppBar(title: const Text('Voices Snackbar')), @@ -20,27 +34,27 @@ class VoicesSnackbarExample extends StatelessWidget { runSpacing: 16, children: [ for (final type in VoicesSnackBarType.values) - OutlinedButton( - onPressed: () { - VoicesSnackBar( - type: type, - padding: EdgeInsets.only( - bottom: screenWidth / 2, - left: screenWidth / 3, - right: screenWidth / 3, + for (final actions in actionsList) + for (final behavior in SnackBarBehavior.values) + OutlinedButton( + onPressed: () { + VoicesSnackBar( + type: type, + behavior: behavior, + onClosePressed: () => + VoicesSnackBar.hideCurrent(context), + actions: actions, + ).show(context); + }, + child: Text( + '${behavior.toString().split('.').last}' + ' ${type.toString().split('.').last}' + '${actions.isEmpty ? '' : ' with actions'}', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), - onPrimaryPressed: () {}, - onSecondaryPressed: () {}, - onClosePressed: () => VoicesSnackBar.hideCurrent(context), - ).show(context); - }, - child: Text( - type.toString().split('.').last, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, ), - ), - ), ], ), ),