diff --git a/catalyst_voices/apps/voices/lib/app/view/app.dart b/catalyst_voices/apps/voices/lib/app/view/app.dart index e80ddc5797a3..4aed7c8cd33e 100644 --- a/catalyst_voices/apps/voices/lib/app/view/app.dart +++ b/catalyst_voices/apps/voices/lib/app/view/app.dart @@ -74,9 +74,6 @@ class _AppState extends State { BlocProvider( create: (_) => Dependencies.instance.get(), ), - BlocProvider( - create: (_) => Dependencies.instance.get(), - ), BlocProvider( // Making it not lazy to not show two loading screens in a row (one for app splash screen and one for campaign phase aware) lazy: false, diff --git a/catalyst_voices/apps/voices/lib/app/view/app_content.dart b/catalyst_voices/apps/voices/lib/app/view/app_content.dart index c06b0df4c84c..aea1cb83a6b7 100644 --- a/catalyst_voices/apps/voices/lib/app/view/app_content.dart +++ b/catalyst_voices/apps/voices/lib/app/view/app_content.dart @@ -7,6 +7,7 @@ import 'package:catalyst_voices/app/view/app_splash_screen_manager.dart'; import 'package:catalyst_voices/app/view/video_cache/app_video_manager_scope.dart'; import 'package:catalyst_voices/app/view/video_cache/app_video_precache.dart'; import 'package:catalyst_voices/common/ext/preferences_ext.dart'; +import 'package:catalyst_voices/notification/catalyst_messenger.dart'; import 'package:catalyst_voices/pages/campaign_phase_aware/widgets/bubble_campaign_phase_aware_background.dart'; import 'package:catalyst_voices/share/share_manager.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; @@ -76,19 +77,21 @@ final class _AppContent extends StatelessWidget { child: AppVideoManagerScope( child: AppVideoPrecache( child: GlobalPrecacheImages( - child: GlobalSessionListener( - // IMPORTANT: AppSplashScreenManager must be placed above all - // widgets that render visible UI elements. Any widget that - // displays content should be a descendant of - // AppSplashScreenManager to ensure proper splash - // screen behavior. - child: AppSplashScreenManager( - child: AppMobileAccessRestriction( - routerConfig: routerConfig, - child: DefaultShareManager( - child: _AppContentBackground( - key: const Key('AppContentBackground'), - child: child, + child: CatalystMessenger( + child: GlobalSessionListener( + // IMPORTANT: AppSplashScreenManager must be placed above all + // widgets that render visible UI elements. Any widget that + // displays content should be a descendant of + // AppSplashScreenManager to ensure proper splash + // screen behavior. + child: AppSplashScreenManager( + child: AppMobileAccessRestriction( + routerConfig: routerConfig, + child: DefaultShareManager( + child: _AppContentBackground( + key: const Key('AppContentBackground'), + child: child, + ), ), ), ), diff --git a/catalyst_voices/apps/voices/lib/app/view/app_session_listener.dart b/catalyst_voices/apps/voices/lib/app/view/app_session_listener.dart index 18e7d9ca8c01..6d5a044399e5 100644 --- a/catalyst_voices/apps/voices/lib/app/view/app_session_listener.dart +++ b/catalyst_voices/apps/voices/lib/app/view/app_session_listener.dart @@ -1,3 +1,6 @@ +import 'package:catalyst_voices/common/signal_handler.dart'; +import 'package:catalyst_voices/notification/catalyst_messenger.dart'; +import 'package:catalyst_voices/notification/specialized/account_needs_verification_banner.dart'; import 'package:catalyst_voices/routes/routes.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart'; @@ -21,11 +24,13 @@ class GlobalSessionListener extends StatefulWidget { State createState() => _GlobalSessionListenerState(); } -class _GlobalSessionListenerState extends State { +class _GlobalSessionListenerState extends State + with SignalHandlerStateMixin { String? _lastLocation; @override Widget build(BuildContext context) { + // TODO(damian-molinski): refactor it to use signals return BlocListener( listenWhen: _listenToSessionChangesWhen, listener: _onSessionChanged, @@ -33,6 +38,21 @@ class _GlobalSessionListenerState extends State { ); } + @override + void handleSignal(SessionSignal signal) { + switch (signal) { + case AccountNeedsVerificationSignal(:final isProposer): + final notification = isProposer + ? AccountProposerNeedsVerificationBanner() + : AccountContributorNeedsVerificationBanner(); + CatalystMessenger.of(context).add(notification); + case CancelAccountNeedsVerificationSignal(): + CatalystMessenger.of( + context, + ).cancelWhere((notification) => notification is AccountNeedsVerificationBanner); + } + } + 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. diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index bde4437be38e..b621eaded401 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -185,11 +185,6 @@ final class Dependencies extends DependencyProvider { get(), ); }) - ..registerFactory(() { - return PublicProfileEmailStatusCubit( - get(), - ); - }) ..registerFactory(() { return DocumentLookupBloc( get(), diff --git a/catalyst_voices/apps/voices/lib/notification/banner_close_button.dart b/catalyst_voices/apps/voices/lib/notification/banner_close_button.dart new file mode 100644 index 000000000000..8e97c9451d5a --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/banner_close_button.dart @@ -0,0 +1,27 @@ +import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class BannerCloseButton extends StatelessWidget { + const BannerCloseButton({super.key}); + + @override + Widget build(BuildContext context) { + return VoicesIconButton( + style: const ButtonStyle(iconSize: WidgetStatePropertyAll(18)), + onTap: () { + final messengerState = ScaffoldMessenger.maybeOf(context); + if (messengerState == null) { + if (kDebugMode) { + print('Can not dismiss banner because messenger key state is empty!'); + } + return; + } + + messengerState.hideCurrentMaterialBanner(reason: MaterialBannerClosedReason.dismiss); + }, + child: VoicesAssets.icons.x.buildIcon(), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/notification/banner_content.dart b/catalyst_voices/apps/voices/lib/notification/banner_content.dart new file mode 100644 index 000000000000..94d6e9959385 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/banner_content.dart @@ -0,0 +1,69 @@ +import 'package:catalyst_voices/notification/catalyst_notification.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +const _titleKey = 'title'; + +class BannerContent extends StatefulWidget { + final BannerNotification notification; + + const BannerContent({ + super.key, + required this.notification, + }); + + @override + State createState() => _BannerContentState(); +} + +class _BannerContentState extends State { + final _gestureRecognizers = {}; + + @override + Widget build(BuildContext context) { + final title = widget.notification.title(context); + final message = widget.notification.message(context); + + final text = '{$_titleKey}: ${message.text}'; + final placeholders = { + _titleKey: CatalystNotificationTextPart(text: title, bold: true), + ...message.placeholders, + }; + + return PlaceholderRichText( + text, + placeholderSpanBuilder: (context, placeholder) { + if (!placeholders.containsKey(placeholder)) { + return TextSpan(text: placeholder); + } + + final replacement = placeholders[placeholder]!; + final onTap = replacement.onTap; + + return TextSpan( + text: replacement.text, + style: TextStyle( + fontWeight: replacement.bold ? FontWeight.bold : null, + decoration: replacement.underlined ? TextDecoration.underline : null, + ), + recognizer: onTap != null + ? _gestureRecognizers.putIfAbsent( + placeholder, + () => TapGestureRecognizer()..onTap = () => onTap(context), + ) + : null, + ); + }, + ); + } + + @override + void dispose() { + final keys = List.of(_gestureRecognizers.keys); + for (final key in keys) { + _gestureRecognizers.remove(key)?.dispose(); + } + super.dispose(); + } +} diff --git a/catalyst_voices/apps/voices/lib/notification/banner_notification.dart b/catalyst_voices/apps/voices/lib/notification/banner_notification.dart new file mode 100644 index 000000000000..69a10f725e86 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/banner_notification.dart @@ -0,0 +1,14 @@ +part of 'catalyst_notification.dart'; + +abstract base class BannerNotification extends CatalystNotification { + const BannerNotification({ + required super.id, + super.priority, + super.type, + super.routerPredicate, + }); + + BannerNotificationMessage message(BuildContext context); + + String title(BuildContext context); +} diff --git a/catalyst_voices/apps/voices/lib/notification/catalyst_messenger.dart b/catalyst_voices/apps/voices/lib/notification/catalyst_messenger.dart new file mode 100644 index 000000000000..4e1ffb0288ec --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/catalyst_messenger.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/notification/banner_close_button.dart'; +import 'package:catalyst_voices/notification/banner_content.dart'; +import 'package:catalyst_voices/notification/catalyst_notification.dart'; +import 'package:catalyst_voices/routes/app_router_factory.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +final _logger = Logger('CatalystMessenger'); + +typedef CatalystNotificationPredicate = bool Function(CatalystNotification notification); + +class CatalystMessenger extends StatefulWidget { + final Widget child; + + const CatalystMessenger({ + super.key, + required this.child, + }); + + @override + State createState() => CatalystMessengerState(); + + static CatalystMessengerState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType(); + } + + static CatalystMessengerState of(BuildContext context) { + final state = maybeOf(context); + assert(state != null, 'CatalystMessenger not found in widget tree'); + return state!; + } +} + +class CatalystMessengerState extends State { + final _pending = []; + bool _isShowing = false; + CatalystNotification? _activeNotification; + + GoRouter? __router; + + GoRouter get _router { + return __router ??= _findRouter()..routerDelegate.addListener(_handleRouterChange); + } + + void add(CatalystNotification notification) { + _logger.finest('Adding $notification to queue'); + + _addSorted(notification); + _processQueue(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + void cancelWhere(CatalystNotificationPredicate test) { + _pending.removeWhere(test); + + final activeNotification = _activeNotification; + if (activeNotification != null && test(activeNotification)) { + _hideCurrentBanner(); + } + } + + @override + void dispose() { + __router?.routerDelegate.removeListener(_handleRouterChange); + __router = null; + + super.dispose(); + } + + void _addSorted(CatalystNotification notification) { + _pending + ..add(notification) + ..sort(); + } + + GoRouter _findRouter() { + final navigatorContext = AppRouterFactory.rootNavigatorKey.currentContext; + assert(navigatorContext != null, 'Navigation context not available'); + + return GoRouter.of(navigatorContext!); + } + + void _handleRouterChange() { + final activeNotification = _activeNotification; + if (activeNotification == null) { + if (_pending.isNotEmpty) { + _processQueue(); + } + return; + } + + final routerState = _router.state; + + // if active notification is still valid for router do nothing. + if (activeNotification.routerPredicate(routerState)) { + return; + } + + _logger.finer('Hiding notification(${activeNotification.id}). Not valid for router state'); + + _addSorted(activeNotification); + _hideCurrentBanner(); + } + + /// Hiding current banner will trigger _onNotificationCompleted and process queue. + void _hideCurrentBanner() { + final messengerState = ScaffoldMessenger.maybeOf(context); + if (messengerState == null) { + return; + } + messengerState.removeCurrentMaterialBanner(reason: MaterialBannerClosedReason.hide); + } + + void _onNotificationCompleted() { + assert(_activeNotification != null, 'Completed notification but active was null'); + final activeNotification = _activeNotification!; + + _logger.finer('Completed $activeNotification'); + + _isShowing = false; + _activeNotification = null; + + _processQueue(); + } + + void _processQueue() { + if (_isShowing) { + return; + } + + final routerState = _router.state; + final allowed = _pending.where((notification) => notification.routerPredicate(routerState)); + if (allowed.isEmpty) { + if (_pending.isNotEmpty) { + _logger.finest('Found ${_pending.length} notification but none allow for router state'); + } + return; + } + + final notification = allowed.first; + _pending.removeWhere((element) => element.id == notification.id); + _activeNotification = notification; + + _isShowing = true; + + _logger.finer('Showing $notification'); + + final future = switch (notification) { + BannerNotification() => _showBanner(notification), + }; + + unawaited(future.whenComplete(_onNotificationCompleted)); + } + + Future _showBanner(BannerNotification notification) async { + final messengerState = ScaffoldMessenger.maybeOf(context); + if (messengerState == null) { + return; + } + + final banner = MaterialBanner( + leading: notification.type.icon.buildIcon(size: 18), + content: BannerContent(notification: notification), + actions: const [BannerCloseButton()], + minActionBarHeight: 32, + backgroundColor: notification.type.backgroundColor(context), + contentTextStyle: (context.textTheme.labelLarge ?? const TextStyle()).copyWith( + color: notification.type.foregroundColor(context), + ), + padding: const EdgeInsetsDirectional.only(start: 24), + leadingPadding: const EdgeInsetsDirectional.only(end: 8), + ); + final controller = messengerState.showMaterialBanner(banner); + + return controller.closed.then( + (reason) { + _logger.finest('Notification(${notification.id}) closed with reason -> $reason'); + }, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/notification/catalyst_notification.dart b/catalyst_voices/apps/voices/lib/notification/catalyst_notification.dart new file mode 100644 index 000000000000..4fefefe80510 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/catalyst_notification.dart @@ -0,0 +1,68 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +part 'banner_notification.dart'; +part 'catalyst_notification_text.dart'; + +bool _alwaysAllowRouterPredicate(GoRouterState state) => true; + +typedef CatalystNotificationRouterPredicate = bool Function(GoRouterState state); + +sealed class CatalystNotification implements Comparable { + final String id; + final int priority; + final CatalystNotificationType type; + final CatalystNotificationRouterPredicate routerPredicate; + + const CatalystNotification({ + required this.id, + this.priority = 0, + this.type = CatalystNotificationType.info, + this.routerPredicate = _alwaysAllowRouterPredicate, + }); + + @override + int compareTo(CatalystNotification other) => priority.compareTo(other.priority); + + @override + String toString() { + return 'CatalystNotification(id[$id], priority[$priority], type[$type])'; + } +} + +enum CatalystNotificationType { + warning, + error, + success, + info; + + SvgGenImage get icon { + return switch (this) { + CatalystNotificationType.warning => VoicesAssets.icons.exclamation, + CatalystNotificationType.error => VoicesAssets.icons.exclamationCircle, + CatalystNotificationType.success => VoicesAssets.icons.checkCircle, + CatalystNotificationType.info => VoicesAssets.icons.informationCircle, + }; + } + + Color backgroundColor(BuildContext context) { + return switch (this) { + CatalystNotificationType.warning => Theme.of(context).colors.warningContainer, + CatalystNotificationType.error => Theme.of(context).colors.errorContainer, + CatalystNotificationType.success => Theme.of(context).colors.successContainer, + CatalystNotificationType.info => Theme.of(context).colors.primaryContainer, + }; + } + + Color foregroundColor(BuildContext context) { + return switch (this) { + CatalystNotificationType.warning => Theme.of(context).colors.onWarningContainer, + CatalystNotificationType.error => Theme.of(context).colors.onErrorContainer, + CatalystNotificationType.success => Theme.of(context).colors.onSuccessContainer, + CatalystNotificationType.info => Theme.of(context).colors.textOnPrimaryLevel0, + }; + } +} diff --git a/catalyst_voices/apps/voices/lib/notification/catalyst_notification_text.dart b/catalyst_voices/apps/voices/lib/notification/catalyst_notification_text.dart new file mode 100644 index 000000000000..3705c7f14ffa --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/catalyst_notification_text.dart @@ -0,0 +1,33 @@ +part of 'catalyst_notification.dart'; + +typedef CatalystNotificationTextPartOnTap = void Function(BuildContext context); + +class BannerNotificationMessage extends Equatable { + final String text; + final Map placeholders; + + const BannerNotificationMessage({ + required this.text, + this.placeholders = const {}, + }); + + @override + List get props => [text, placeholders]; +} + +class CatalystNotificationTextPart extends Equatable { + final String text; + final bool bold; + final CatalystNotificationTextPartOnTap? onTap; + + const CatalystNotificationTextPart({ + required this.text, + this.bold = false, + this.onTap, + }); + + @override + List get props => [text, bold, onTap]; + + bool get underlined => onTap != null; +} diff --git a/catalyst_voices/apps/voices/lib/notification/specialized/account_needs_verification_banner.dart b/catalyst_voices/apps/voices/lib/notification/specialized/account_needs_verification_banner.dart new file mode 100644 index 000000000000..91b1a33343a9 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/notification/specialized/account_needs_verification_banner.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:catalyst_voices/notification/catalyst_notification.dart'; +import 'package:catalyst_voices/routes/routes.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:uuid_plus/uuid_plus.dart'; + +const _showOnRoutes = [DiscoveryRoute.name, WorkspaceRoute.name]; +final _id = const Uuid().v4(); + +final class AccountContributorNeedsVerificationBanner extends AccountNeedsVerificationBanner { + AccountContributorNeedsVerificationBanner(); + + @override + BannerNotificationMessage message(BuildContext context) { + return BannerNotificationMessage( + text: context.l10n.emailNotVerifiedBannerContributorMessage, + ); + } +} + +abstract base class AccountNeedsVerificationBanner extends BannerNotification { + AccountNeedsVerificationBanner() + : super( + id: _id, + routerPredicate: (state) => _showOnRoutes.contains(state.name), + ); + + @override + String title(BuildContext context) { + return context.l10n.emailNotVerifiedBannerTitle; + } +} + +final class AccountProposerNeedsVerificationBanner extends AccountNeedsVerificationBanner { + AccountProposerNeedsVerificationBanner(); + + @override + BannerNotificationMessage message(BuildContext context) { + return BannerNotificationMessage( + text: context.l10n.emailNotVerifiedBannerProposerMessage, + placeholders: { + 'destination': CatalystNotificationTextPart( + text: context.l10n.myAccount, + onTap: (context) { + unawaited(const AccountRoute().push(context)); + }, + ), + }, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/account/account_page.dart b/catalyst_voices/apps/voices/lib/pages/account/account_page.dart index e1de748a451c..dc3ec0c0e46b 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/account_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/account_page.dart @@ -6,14 +6,12 @@ import 'package:catalyst_voices/pages/account/delete_keychain_dialog.dart'; import 'package:catalyst_voices/pages/account/keychain_deleted_dialog.dart'; import 'package:catalyst_voices/pages/account/pending_email_change_dialog.dart'; import 'package:catalyst_voices/pages/account/verification_email_send_dialog.dart'; -import 'package:catalyst_voices/pages/account/widgets/account_action_tile.dart'; import 'package:catalyst_voices/pages/account/widgets/account_email_tile.dart'; import 'package:catalyst_voices/pages/account/widgets/account_header_tile.dart'; import 'package:catalyst_voices/pages/account/widgets/account_keychain_tile.dart'; import 'package:catalyst_voices/pages/account/widgets/account_page_grid.dart'; import 'package:catalyst_voices/pages/account/widgets/account_page_title.dart'; import 'package:catalyst_voices/pages/account/widgets/account_roles_tile.dart'; -import 'package:catalyst_voices/pages/account/widgets/account_status_banner.dart'; import 'package:catalyst_voices/pages/account/widgets/account_username_tile.dart'; import 'package:catalyst_voices/pages/spaces/appbar/actions/account_settings_action.dart'; import 'package:catalyst_voices/pages/spaces/appbar/actions/session_cta_action.dart'; @@ -50,7 +48,6 @@ class _AccountPageState extends State body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const AccountStatusBanner(), Expanded( child: ListView( padding: const EdgeInsets.all(24), @@ -64,7 +61,7 @@ class _AccountPageState extends State key: ValueKey('AccountOverviewGrid'), children: [ AccountHeaderTile(), - AccountActionTile(), + Spacer(), ], ), ResponsiveChild( diff --git a/catalyst_voices/apps/voices/lib/pages/account/widgets/account_action_tile.dart b/catalyst_voices/apps/voices/lib/pages/account/widgets/account_action_tile.dart deleted file mode 100644 index 62f4e041c750..000000000000 --- a/catalyst_voices/apps/voices/lib/pages/account/widgets/account_action_tile.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:catalyst_voices/common/ext/build_context_ext.dart'; -import 'package:catalyst_voices/pages/account/widgets/account_status_title_text.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/material.dart'; - -class AccountActionTile extends StatelessWidget { - const AccountActionTile({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.status, - builder: (context, state) { - return Offstage( - offstage: state.type == MyAccountStatusNotificationType.offstage, - child: _AccountActionTile(status: state), - ); - }, - ); - } -} - -class _AccountActionTile extends StatelessWidget { - final MyAccountStatusNotification status; - - const _AccountActionTile({ - required this.status, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: status.type.backgroundColor(context), - borderRadius: BorderRadius.circular(8), - textStyle: context.textTheme.labelLarge?.copyWith( - color: status.type.foregroundColor(context), - ), - child: IconTheme( - data: IconThemeData( - size: 18, - color: status.type.foregroundColor(context), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 8, - children: [ - status.icon.buildIcon(), - Expanded(child: AccountStatusTitleText(data: status)), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: Text(status.message(context)), - ), - ], - ), - ), - ), - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/pages/account/widgets/account_status_banner.dart b/catalyst_voices/apps/voices/lib/pages/account/widgets/account_status_banner.dart deleted file mode 100644 index e1e65629cbd4..000000000000 --- a/catalyst_voices/apps/voices/lib/pages/account/widgets/account_status_banner.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:catalyst_voices/pages/account/widgets/account_status_title_text.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/material.dart'; - -class AccountStatusBanner extends StatelessWidget { - const AccountStatusBanner({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.status, - builder: (context, state) => _AccountStatusBanner(status: state), - ); - } -} - -class _AccountStatusBanner extends StatefulWidget { - final MyAccountStatusNotification status; - - const _AccountStatusBanner({ - required this.status, - }); - - @override - State<_AccountStatusBanner> createState() => _AccountStatusBannerState(); -} - -class _AccountStatusBannerState extends State<_AccountStatusBanner> { - late bool _offstage; - - @override - Widget build(BuildContext context) { - return Offstage( - offstage: _offstage, - child: Material( - color: widget.status.type.backgroundColor(context), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: IconTheme( - data: IconThemeData( - size: 18, - color: widget.status.type.foregroundColor(context), - ), - child: Row( - children: [ - widget.status.icon.buildIcon(), - Expanded(child: AccountStatusTitleText(data: widget.status)), - const SizedBox(width: 8), - GestureDetector( - onTap: _onDismissTap, - child: VoicesAssets.icons.x.buildIcon(), - ), - ], - ), - ), - ), - ), - ); - } - - @override - void didUpdateWidget(_AccountStatusBanner oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.status != oldWidget.status) { - _offstage = _isStatusTypeOffstage(); - } - } - - @override - void initState() { - super.initState(); - _offstage = _isStatusTypeOffstage(); - } - - bool _isStatusTypeOffstage() { - return widget.status.type == MyAccountStatusNotificationType.offstage; - } - - void _onDismissTap() { - setState(() { - _offstage = true; - }); - } -} diff --git a/catalyst_voices/apps/voices/lib/pages/account/widgets/account_status_title_text.dart b/catalyst_voices/apps/voices/lib/pages/account/widgets/account_status_title_text.dart deleted file mode 100644 index 13cea85438cf..000000000000 --- a/catalyst_voices/apps/voices/lib/pages/account/widgets/account_status_title_text.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:catalyst_voices/common/ext/build_context_ext.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/material.dart'; - -class AccountStatusTitleText extends StatelessWidget { - final MyAccountStatusNotification data; - - const AccountStatusTitleText({ - super.key, - required this.data, - }); - - @override - Widget build(BuildContext context) { - return Text.rich( - TextSpan( - children: [ - TextSpan( - text: data.title(context), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan(text: ': '), - TextSpan(text: data.titleDesc(context)), - ], - ), - style: context.textTheme.labelLarge?.copyWith( - color: data.type.foregroundColor(context), - ), - maxLines: 1, - overflow: TextOverflow.clip, - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart b/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart index 85bf128adb36..36b02123c5ef 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart @@ -9,7 +9,6 @@ import 'package:catalyst_voices/pages/discovery/sections/stay_involved.dart'; import 'package:catalyst_voices/pages/discovery/state_selectors/campaign_categories_state_selector.dart'; import 'package:catalyst_voices/pages/discovery/state_selectors/current_campaign_selector.dart'; import 'package:catalyst_voices/pages/discovery/state_selectors/most_recent_proposals_selector.dart'; -import 'package:catalyst_voices/widgets/banner/widgets/email_need_verification_banner.dart'; import 'package:catalyst_voices/widgets/common/infrastructure/voices_wide_screen_constrained.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:flutter/material.dart'; @@ -59,14 +58,9 @@ class _DiscoveryPageState extends State Widget build(BuildContext context) { return const ProposalSubmissionPhaseAware( activeChild: SelectionArea( - child: Stack( - children: [ - CustomScrollView( - slivers: [ - _Body(), - ], - ), - EmailNeedVerificationBanner(), + child: CustomScrollView( + slivers: [ + _Body(), ], ), ), diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/page/workspace_page.dart b/catalyst_voices/apps/voices/lib/pages/workspace/page/workspace_page.dart index 3aa4cf3f1b58..5e2b69b30bbf 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/page/workspace_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/page/workspace_page.dart @@ -9,7 +9,6 @@ import 'package:catalyst_voices/pages/workspace/page/workspace_loading.dart'; import 'package:catalyst_voices/pages/workspace/page/workspace_user_proposals.dart'; import 'package:catalyst_voices/pages/workspace/submission_closing_warning_dialog.dart'; import 'package:catalyst_voices/routes/routing/proposal_builder_route.dart'; -import 'package:catalyst_voices/widgets/banner/widgets/email_need_verification_banner.dart'; import 'package:catalyst_voices/widgets/snackbar/common_snackbars.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart'; import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart'; @@ -34,25 +33,20 @@ class _WorkspacePageState extends State return const ProposalSubmissionPhaseAware( activeChild: Scaffold( body: WorkspaceLoadingSelector( - child: Stack( - children: [ - SingleChildScrollView( - child: Column( + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: 10), + WorkspaceHeader(), + Stack( children: [ - SizedBox(height: 10), - WorkspaceHeader(), - Stack( - children: [ - WorkspaceErrorSelector(), - WorkspaceUserProposalsSelector(), - ], - ), - SizedBox(height: 50), + WorkspaceErrorSelector(), + WorkspaceUserProposalsSelector(), ], ), - ), - EmailNeedVerificationBanner(), - ], + SizedBox(height: 50), + ], + ), ), ), ), diff --git a/catalyst_voices/apps/voices/lib/routes/routing/proposal_route.dart b/catalyst_voices/apps/voices/lib/routes/routing/proposal_route.dart index 2a246060f1ec..a058fd8fae28 100644 --- a/catalyst_voices/apps/voices/lib/routes/routing/proposal_route.dart +++ b/catalyst_voices/apps/voices/lib/routes/routing/proposal_route.dart @@ -37,8 +37,4 @@ final class ProposalRoute extends GoRouteData with FadePageTransitionMixin { ); return ProposalPage(ref: ref); } - - static bool isPath(String path) { - return ($proposalRoute as GoRoute).path == path; - } } diff --git a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart index d7ccab9003bc..bb6e5a2b0b7b 100644 --- a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart +++ b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart @@ -39,6 +39,8 @@ final class CategoryDetailRoute extends GoRouteData with FadePageTransitionMixin } final class DiscoveryRoute extends GoRouteData with FadePageTransitionMixin { + static const name = 'discovery'; + final bool? $extra; const DiscoveryRoute({this.$extra}); @@ -100,7 +102,7 @@ final class ProposalsRoute extends GoRouteData with FadePageTransitionMixin { routes: >[ TypedGoRoute( path: '/discovery', - name: 'discovery', + name: DiscoveryRoute.name, routes: [ TypedGoRoute( path: 'proposals', @@ -114,7 +116,7 @@ final class ProposalsRoute extends GoRouteData with FadePageTransitionMixin { ), TypedGoRoute( path: '/workspace', - name: 'workspace', + name: WorkspaceRoute.name, ), TypedGoRoute( path: '/voting', @@ -211,6 +213,8 @@ final class VotingRoute extends GoRouteData with FadePageTransitionMixin, Compos final class WorkspaceRoute extends GoRouteData with FadePageTransitionMixin, CompositeRouteGuardMixin { + static const name = 'workspace'; + const WorkspaceRoute(); @override diff --git a/catalyst_voices/apps/voices/lib/widgets/banner/voices_banner.dart b/catalyst_voices/apps/voices/lib/widgets/banner/voices_banner.dart deleted file mode 100644 index 950cf95cb6d3..000000000000 --- a/catalyst_voices/apps/voices/lib/widgets/banner/voices_banner.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:catalyst_voices/common/ext/build_context_ext.dart'; -import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:flutter/material.dart'; - -class VoicesBanner extends StatefulWidget { - final VoicesBannerType type; - final VoidCallback? onClose; - final Widget child; - - const VoicesBanner({ - super.key, - this.type = VoicesBannerType.info, - this.onClose, - required this.child, - }); - - @override - State createState() => _VoicesBannerState(); -} - -enum VoicesBannerType { - info; - - const VoicesBannerType(); - - SvgGenImage get icon { - return switch (this) { - VoicesBannerType.info => VoicesAssets.icons.informationCircle, - }; - } - - Color color(BuildContext context) { - return switch (this) { - VoicesBannerType.info => context.colors.primaryContainer, - }; - } -} - -class _VoicesBannerState extends State { - bool _isClosed = false; - - @override - Widget build(BuildContext context) { - return Offstage( - offstage: _isClosed, - child: Container( - color: widget.type.color(context), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AffixDecorator( - prefix: widget.type.icon.buildIcon(size: 18), - child: widget.child, - ), - GestureDetector( - onTap: _onClose, - child: VoicesAssets.icons.x.buildIcon(size: 18), - ), - ], - ), - ), - ); - } - - void _onClose() { - setState(() { - _isClosed = true; - }); - _onClose.call(); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_banner.dart b/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_banner.dart deleted file mode 100644 index 0e907ac72b6c..000000000000 --- a/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_banner.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:catalyst_voices/widgets/banner/widgets/email_need_verification_contributor_banner.dart'; -import 'package:catalyst_voices/widgets/banner/widgets/email_need_verification_proposer_banner.dart'; -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:flutter/material.dart'; - -class EmailNeedVerificationBanner extends StatelessWidget { - const EmailNeedVerificationBanner({super.key}); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) { - return state.isVisible && !state.isEmailVerified && state.isUnlock; - }, - builder: (context, shouldShowBanner) { - if (!shouldShowBanner) { - return const SizedBox.shrink(); - } - - return BlocSelector( - selector: (state) => state.isProposer, - builder: (context, isProposer) { - if (isProposer) { - return const EmailNeedVerificationProposerBanner(); - } else { - return const EmailNeedVerificationContributorBanner(); - } - }, - ); - }, - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_contributor_banner.dart b/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_contributor_banner.dart deleted file mode 100644 index e135903a7c25..000000000000 --- a/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_contributor_banner.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:catalyst_voices/common/ext/build_context_ext.dart'; -import 'package:catalyst_voices/widgets/banner/voices_banner.dart'; -import 'package:catalyst_voices/widgets/rich_text/markdown_text.dart'; -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:flutter/material.dart'; - -class EmailNeedVerificationContributorBanner extends StatelessWidget { - const EmailNeedVerificationContributorBanner({super.key}); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) { - return state.showDiscoveryEmailVerificationBanner; - }, - builder: (context, show) { - return Offstage( - offstage: !show, - child: VoicesBanner( - child: MarkdownText( - MarkdownData( - context.l10n.emailNotVerifiedBannerContributor, - ), - pStyle: context.textTheme.labelLarge, - ), - ), - ); - }, - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_proposer_banner.dart b/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_proposer_banner.dart deleted file mode 100644 index 507d363d5722..000000000000 --- a/catalyst_voices/apps/voices/lib/widgets/banner/widgets/email_need_verification_proposer_banner.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:async'; - -import 'package:catalyst_voices/common/ext/build_context_ext.dart'; -import 'package:catalyst_voices/routes/routing/account_route.dart'; -import 'package:catalyst_voices/widgets/banner/voices_banner.dart'; -import 'package:catalyst_voices/widgets/rich_text/markdown_text.dart'; -import 'package:catalyst_voices/widgets/rich_text/placeholder_rich_text.dart'; -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -class EmailNeedVerificationProposerBanner extends StatefulWidget { - const EmailNeedVerificationProposerBanner({super.key}); - - @override - State createState() => - _EmailNeedVerificationProposerBannerState(); -} - -class _EmailNeedVerificationProposerBannerState extends State { - late final TapGestureRecognizer _recognizer; - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) { - return state.showProposerEmailVerificationBanner; - }, - builder: (context, state) { - return Offstage( - offstage: !state, - child: VoicesBanner( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - MarkdownText( - MarkdownData( - context.l10n.emailNotVerifiedBannerProposer, - ), - pStyle: context.textTheme.labelLarge, - ), - PlaceholderRichText( - context.l10n.goToMyAccountForEmailVerification('{destination}').withPrefix('- '), - placeholderSpanBuilder: (context, placeholder) { - return switch (placeholder) { - 'destination' => TextSpan( - text: context.l10n.myAccount, - recognizer: _recognizer, - style: context.textTheme.labelLarge?.copyWith( - decoration: TextDecoration.underline, - ), - ), - _ => throw ArgumentError('Unknown placeholder[$placeholder]'), - }; - }, - ), - ], - ), - ), - ); - }, - ); - } - - @override - void dispose() { - _recognizer.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - _recognizer = TapGestureRecognizer(); - _recognizer.onTap = () { - unawaited(const AccountRoute().push(context)); - }; - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/account_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/account_state.dart index 22c76b72f0c1..2d9dcaac7f55 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/account_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/account_state.dart @@ -16,7 +16,6 @@ final class AccountRolesState extends Equatable { } final class AccountState extends Equatable { - final MyAccountStatusNotification status; final CatalystId? catalystId; final Username username; final Email email; @@ -25,7 +24,6 @@ final class AccountState extends Equatable { final AccountPublicStatus accountPublicStatus; const AccountState({ - this.status = const None(), this.catalystId, this.username = const Username.pure(), this.email = const Email.pure(), @@ -36,7 +34,6 @@ final class AccountState extends Equatable { @override List get props => [ - status, catalystId, username, email, @@ -46,7 +43,6 @@ final class AccountState extends Equatable { ]; AccountState copyWith({ - MyAccountStatusNotification? status, Optional? catalystId, Username? username, Email? email, @@ -55,7 +51,6 @@ final class AccountState extends Equatable { AccountPublicStatus? accountPublicStatus, }) { return AccountState( - status: status ?? this.status, catalystId: catalystId.dataOr(this.catalystId), username: username ?? this.username, email: email ?? this.email, diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email.dart deleted file mode 100644 index 1974dc73a140..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'public_profile_email_cubit.dart'; -export 'public_profile_email_state.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email_cubit.dart deleted file mode 100644 index 6def241d8c09..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email_cubit.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:async'; - -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_services/catalyst_voices_services.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:rxdart/rxdart.dart'; - -/// Manages the email status of the active account. -final class PublicProfileEmailStatusCubit extends Cubit { - final _logger = Logger('PublicProfileEmailStatusCubit'); - final UserService _userService; - - StreamSubscription? _accountSub; - StreamSubscription? _keychainUnlockedSub; - - PublicProfileEmailStatusCubit(this._userService) : super(const PublicProfileEmailStatusState()) { - _accountSub = _userService.watchUser - .map((user) => user.activeAccount) - .distinct() - .listen(_handleActiveAccountChange); - - _keychainUnlockedSub = _userService.watchUser - .map((user) => user.activeAccount) - .switchMap((account) { - return account?.keychain.watchIsUnlocked ?? Stream.value(false); - }) - .distinct() - .listen(_onActiveKeychainUnlockChanged); - } - - @override - Future close() async { - await _accountSub?.cancel(); - _accountSub = null; - await _keychainUnlockedSub?.cancel(); - _keychainUnlockedSub = null; - - return super.close(); - } - - void _handleActiveAccountChange(Account? account) { - _logger.fine('Active account changed [$account]'); - emit( - state.copyWith( - email: Optional(account?.email), - isEmailVerified: account?.publicStatus.isVerified ?? false, - isProposer: account?.hasRole(AccountRole.proposer) ?? false, - isVisible: account != null, - ), - ); - } - - void _onActiveKeychainUnlockChanged(bool isUnlocked) { - _logger.fine('Keychain unlock changed [$isUnlocked]'); - emit( - state.copyWith( - isUnlock: isUnlocked, - ), - ); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email_state.dart deleted file mode 100644 index f4e10c4da2c0..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/account/public_profile/public_profile_email_state.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:equatable/equatable.dart'; - -final class PublicProfileEmailStatusState extends Equatable { - final String? email; - final bool isEmailVerified; - final bool isProposer; - final bool isVisible; - final bool isUnlock; - - const PublicProfileEmailStatusState({ - this.email, - this.isEmailVerified = false, - this.isProposer = false, - this.isVisible = false, - this.isUnlock = false, - }); - - @override - List get props => [email, isEmailVerified, isProposer, isVisible, isUnlock]; - - bool get showDiscoveryEmailVerificationBanner { - if (email == null) { - return false; - } - - final isEmailProvided = email?.isNotEmpty ?? false; - - if (isEmailProvided && !isEmailVerified && !isProposer) { - return true; - } - - return showProposerEmailVerificationBanner; - } - - bool get showProposerEmailVerificationBanner { - if (email != null && isEmailVerified) { - return false; - } - if (isProposer && !isEmailVerified) { - return true; - } - - return false; - } - - PublicProfileEmailStatusState copyWith({ - Optional? email, - bool? isEmailVerified, - bool? isProposer, - bool? isVisible, - bool? isUnlock, - }) { - return PublicProfileEmailStatusState( - email: email?.dataOr(this.email), - isEmailVerified: isEmailVerified ?? this.isEmailVerified, - isProposer: isProposer ?? this.isProposer, - isVisible: isVisible ?? this.isVisible, - isUnlock: isUnlock ?? this.isUnlock, - ); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart index 121c7b08fbb2..884926ee1794 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart @@ -1,5 +1,4 @@ export 'account/account.dart'; -export 'account/public_profile/public_profile_email.dart'; export 'admin_tools/admin_tools.dart'; export 'brand/brand.dart'; export 'campaign/campaign_builder/campaign_builder.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session.dart index 5cd346282294..2aa955e85771 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session.dart @@ -1,2 +1,3 @@ export 'session_cubit.dart'; +export 'session_signal.dart'; export 'session_state.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart index 201483c92af5..a47df41c49c2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_cubit.dart @@ -18,7 +18,8 @@ set alwaysAllowRegistration(bool newValue) { } /// Manages the user session and provides access to the user settings, account, and admin tools. -final class SessionCubit extends Cubit with BlocErrorEmitterMixin { +final class SessionCubit extends Cubit + with BlocErrorEmitterMixin, BlocSignalEmitterMixin { final UserService _userService; final RegistrationService _registrationService; final RegistrationProgressNotifier _registrationProgressNotifier; @@ -222,6 +223,22 @@ final class SessionCubit extends Cubit with BlocErrorEmitterMixin ); } + // TODO(damian-molinski): Refactor active account stream so it emits null when account + // keychain is locked. + void _emitAccountBasedSignal() { + final account = _account; + + if (account == null || !account.keychain.lastIsUnlocked) { + emitSignal(const CancelAccountNeedsVerificationSignal()); + return; + } + + if (account.email != null && !account.publicStatus.isVerified) { + final isProposer = account.hasRole(AccountRole.proposer); + emitSignal(AccountNeedsVerificationSignal(isProposer: isProposer)); + } + } + Future _getDummyAccount() async { final dummyAccount = _userService.user.accounts.firstWhereOrNull((e) => e.isDummy); @@ -239,12 +256,14 @@ final class SessionCubit extends Cubit with BlocErrorEmitterMixin _account = account; + _emitAccountBasedSignal(); _updateState(); } void _onActiveKeychainUnlockChanged(bool isUnlocked) { _logger.fine('Keychain unlock changed [$isUnlocked]'); + _emitAccountBasedSignal(); _updateState(); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_signal.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_signal.dart new file mode 100644 index 000000000000..dc79fa7779e3 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_signal.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +final class AccountNeedsVerificationSignal extends SessionSignal { + final bool isProposer; + + const AccountNeedsVerificationSignal({ + this.isProposer = false, + }); + + @override + List get props => [isProposer]; +} + +final class CancelAccountNeedsVerificationSignal extends SessionSignal { + const CancelAccountNeedsVerificationSignal(); +} + +sealed class SessionSignal extends Equatable { + const SessionSignal(); + + @override + List get props => []; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index a9fe59189187..e9870a14b69e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -1030,13 +1030,17 @@ "@emailAddress": { "description": "Label for email address input field" }, - "emailNotVerifiedBannerContributor": "**Email Not Verified:** Check your inbox to verify your email and unlock all features.", - "@emailNotVerifiedBannerContributor": { - "description": "Message used in banner to inform user that email is not verified when user is a contributor" + "emailNotVerifiedBannerTitle": "Email Not Verified", + "@emailNotVerifiedBannerTitle": { + "description": "Title of banner shown when user account is not verified" }, - "emailNotVerifiedBannerProposer": "**Email Not Verified**: You can’t publish a proposal until your email is verified.", - "@emailNotVerifiedBannerProposer": { - "description": "Message used in banner to inform user that email is not verified when user is a proposer" + "emailNotVerifiedBannerContributorMessage": "Check your inbox to verify your email and unlock all features.", + "@emailNotVerifiedBannerContributorMessage": { + "description": "Message of banner shown when user account is not verified" + }, + "emailNotVerifiedBannerProposerMessage": "You can’t publish a proposal until your email is verified. Go to {destination} to verify it.", + "@emailNotVerifiedBannerProposerMessage": { + "description": "Message of banner shown when user account is not verified" }, "emailNotVerifiedDialogAction": "Go to My Account", "@emailNotVerifiedDialogAction": { @@ -1473,16 +1477,6 @@ "@goMyProposals": { "description": "Action to go to my proposals" }, - "goToMyAccountForEmailVerification": "Go to {destination} to verify it.", - "@goToMyAccountForEmailVerification": { - "description": "Message used in banner for CTA to inform user to go to My Account to verify email", - "placeholders": { - "destination": { - "type": "String", - "description": "Text for the destination link" - } - } - }, "goodPasswordStrength": "Good password strength", "@goodPasswordStrength": { "description": "Describes a password that is strong." diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/account/my_account_status_notification.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/account/my_account_status_notification.dart deleted file mode 100644 index fdd7cc2bd2ac..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/account/my_account_status_notification.dart +++ /dev/null @@ -1,98 +0,0 @@ -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:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -final class AccountFinalized extends MyAccountStatusNotification { - const AccountFinalized() - : super( - type: MyAccountStatusNotificationType.success, - ); - - @override - SvgGenImage get icon => VoicesAssets.icons.check; - - @override - String message(BuildContext context) { - return context.l10n.accountFinishedNotificationMessage; - } - - @override - String title(BuildContext context) { - return context.l10n.accountFinishedNotificationTitle; - } - - @override - String titleDesc(BuildContext context) { - return context.l10n.accountFinishedNotificationTitleDesc; - } -} - -/// Base class that represents current user's account status. -/// -/// This class is used to display a notification to the user about the status of their account. -/// It contains the [type] of the notification and provides a localized title, description, and message. -sealed class MyAccountStatusNotification extends Equatable { - final MyAccountStatusNotificationType type; - - const MyAccountStatusNotification({ - required this.type, - }); - - SvgGenImage get icon; - - @override - @mustCallSuper - List get props => [type]; - - String message(BuildContext context); - - String title(BuildContext context); - - String titleDesc(BuildContext context); -} - -enum MyAccountStatusNotificationType { - offstage, - warning, - error, - success; - - Color backgroundColor(BuildContext context) { - return switch (this) { - MyAccountStatusNotificationType.offstage => Colors.transparent, - MyAccountStatusNotificationType.warning => Theme.of(context).colors.warningContainer, - MyAccountStatusNotificationType.error => Theme.of(context).colors.errorContainer, - MyAccountStatusNotificationType.success => Theme.of(context).colors.successContainer, - }; - } - - Color foregroundColor(BuildContext context) { - return switch (this) { - MyAccountStatusNotificationType.offstage => Colors.transparent, - MyAccountStatusNotificationType.warning => Theme.of(context).colors.onWarningContainer, - MyAccountStatusNotificationType.error => Theme.of(context).colors.onErrorContainer, - MyAccountStatusNotificationType.success => Theme.of(context).colors.onSuccessContainer, - }; - } -} - -final class None extends MyAccountStatusNotification { - const None() - : super( - type: MyAccountStatusNotificationType.offstage, - ); - - @override - SvgGenImage get icon => VoicesAssets.icons.check; - - @override - String message(BuildContext context) => ''; - - @override - String title(BuildContext context) => ''; - - @override - String titleDesc(BuildContext context) => ''; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index 5928568804aa..94619bb66450 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1,7 +1,6 @@ export 'account/exception/localized_active_account_not_found_exception.dart'; export 'account/exception/localized_email_already_use_exception.dart'; export 'account/my_account_role_item.dart'; -export 'account/my_account_status_notification.dart'; export 'api/exception/localized_api_exception.dart'; export 'authentication/authentication.dart'; export 'campaign/campaign_category_section.dart'; diff --git a/catalyst_voices/scripts/generate_licenses.dart b/catalyst_voices/scripts/generate_licenses.dart index 320a9b13abd0..cbea5e13d864 100644 --- a/catalyst_voices/scripts/generate_licenses.dart +++ b/catalyst_voices/scripts/generate_licenses.dart @@ -31,8 +31,7 @@ Future main(List args) async { final license = _replaceSpecialCharacters(entry.license ?? ''); final description = _replaceSpecialCharacters(entry.description); final authors = _replaceSpecialCharacters(entry.authors.join(' | ')); - outputSink.writeln( - '\n${name}${_sep}${description}${_sep}${authors}${_sep}${license}'); + outputSink.writeln('\n${name}${_sep}${description}${_sep}${authors}${_sep}${license}'); } await outputSink.flush();