diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index 98e91faaeb9..41884130b70 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -146,10 +146,10 @@ final class Dependencies extends DependencyProvider { ); registerLazySingleton(() { return RegistrationService( - transactionConfigRepository: get(), - keychainProvider: get(), - cardano: get(), - keyDerivation: get(), + get(), + get(), + get(), + get(), ); }); registerLazySingleton( diff --git a/catalyst_voices/apps/voices/lib/pages/account/account_popup.dart b/catalyst_voices/apps/voices/lib/pages/account/account_popup.dart index 93e9057d5fb..3b461021ad7 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/account_popup.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/account_popup.dart @@ -7,13 +7,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class AccountPopup extends StatelessWidget { - final String avatarLetter; + final String displayName; final VoidCallback? onProfileKeychainTap; final VoidCallback? onLockAccountTap; const AccountPopup({ super.key, - required this.avatarLetter, + required this.displayName, this.onProfileKeychainTap, this.onLockAccountTap, }); @@ -40,7 +40,7 @@ class AccountPopup extends StatelessWidget { value: null, key: const Key('PopUpMenuAccountHeader'), child: _Header( - accountLetter: avatarLetter, + displayName: displayName, walletName: 'Wallet name', walletBalance: '₳ 1,750,000', accountType: 'Basis', @@ -84,7 +84,7 @@ class AccountPopup extends StatelessWidget { offset: const Offset(0, kToolbarHeight), child: IgnorePointer( child: VoicesAvatar( - icon: Text(avatarLetter), + icon: Text(displayName), ), ), ); @@ -92,14 +92,14 @@ class AccountPopup extends StatelessWidget { } class _Header extends StatelessWidget { - final String accountLetter; + final String displayName; final String walletName; final String walletBalance; final String accountType; final ShelleyAddress walletAddress; const _Header({ - required this.accountLetter, + required this.displayName, required this.walletName, required this.walletBalance, required this.accountType, @@ -115,7 +115,11 @@ class _Header extends StatelessWidget { child: Row( children: [ VoicesAvatar( - icon: Text(accountLetter), + icon: Text( + displayName.isNotEmpty + ? displayName.substring(0, 1).toUpperCase() + : '', + ), ), Expanded( child: Padding( diff --git a/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/session_action_header.dart similarity index 87% rename from catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart rename to catalyst_voices/apps/voices/lib/pages/spaces/appbar/session_action_header.dart index 4c09f181e65..455ad60a990 100644 --- a/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/session_action_header.dart @@ -21,10 +21,13 @@ class SessionActionHeader extends StatelessWidget { return BlocBuilder( builder: (context, state) { return switch (state) { - VisitorSessionState(:final isRegistrationInProgress) => + VisitorSessionState( + :final isRegistrationInProgress, + :final canCreateAccount, + ) => isRegistrationInProgress ? const _FinishRegistrationButton() - : const _GetStartedButton(), + : _GetStartedButton(isEnabled: canCreateAccount), GuestSessionState() => const _UnlockButton(), ActiveAccountSessionState() => const _LockButton(), }; @@ -34,13 +37,17 @@ class SessionActionHeader extends StatelessWidget { } class _GetStartedButton extends StatelessWidget { - const _GetStartedButton(); + final bool isEnabled; + + const _GetStartedButton({ + required this.isEnabled, + }); @override Widget build(BuildContext context) { return VoicesFilledButton( key: const Key('GetStartedButton'), - onTap: () => unawaited(RegistrationDialog.show(context)), + onTap: isEnabled ? () async => RegistrationDialog.show(context) : null, child: Text(context.l10n.getStarted), ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_state_header.dart b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/session_state_header.dart similarity index 97% rename from catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_state_header.dart rename to catalyst_voices/apps/voices/lib/pages/spaces/appbar/session_state_header.dart index 5a5e896186f..039329e3422 100644 --- a/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_state_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/session_state_header.dart @@ -20,10 +20,10 @@ class SessionStateHeader extends StatelessWidget { VisitorSessionState() => const _VisitorButton(), GuestSessionState() => const _GuestButton(), ActiveAccountSessionState(:final account) => AccountPopup( - avatarLetter: account?.acronym ?? '', + key: const Key('AccountPopupButton'), + displayName: account?.displayName ?? '', onLockAccountTap: () => _onLockAccount(context), onProfileKeychainTap: () => _onSeeProfile(context), - key: const Key('AccountPopupButton'), ), }; }, diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart b/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart index 86e21b05b60..a13aa404ad2 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:catalyst_voices/common/ext/ext.dart'; import 'package:catalyst_voices/pages/campaign/admin_tools/campaign_admin_tools_dialog.dart'; import 'package:catalyst_voices/pages/campaign/details/widgets/campaign_management.dart'; +import 'package:catalyst_voices/pages/spaces/appbar/session_action_header.dart'; +import 'package:catalyst_voices/pages/spaces/appbar/session_state_header.dart'; import 'package:catalyst_voices/pages/spaces/appbar/spaces_theme_mode_switch.dart'; import 'package:catalyst_voices/pages/spaces/drawer/spaces_drawer.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; diff --git a/catalyst_voices/apps/voices/lib/routes/guards/user_access_guard.dart b/catalyst_voices/apps/voices/lib/routes/guards/user_access_guard.dart index e917195eea6..1a78c2d16d7 100644 --- a/catalyst_voices/apps/voices/lib/routes/guards/user_access_guard.dart +++ b/catalyst_voices/apps/voices/lib/routes/guards/user_access_guard.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:catalyst_voices/routes/guards/route_guard.dart'; import 'package:catalyst_voices/routes/routes.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -25,15 +24,18 @@ final class UserAccessGuard implements RouteGuard { state.path == const FundedProjectsRoute().location) { return const DiscoveryRoute().location; } - if (account.roles.any( - (role) => [AccountRole.proposer, AccountRole.drep].contains(role), - )) return null; + + if (account.isProposer || account.isDrep) { + return null; + } + return const DiscoveryRoute().location; } } final class AdminAccessGuard implements RouteGuard { const AdminAccessGuard(); + @override FutureOr redirect(BuildContext context, GoRouterState state) { final account = context.read().state.account; diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d85c7d030b0..395bd23f3eb 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -1,5 +1,3 @@ -export 'app_bar/session/session_action_header.dart'; -export 'app_bar/session/session_state_header.dart'; export 'app_bar/voices_app_bar.dart'; export 'avatars/space_avatar.dart'; export 'avatars/voices_avatar.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 7c355ae7251..28da3acdce6 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 @@ -21,6 +21,7 @@ final class SessionCubit extends Cubit Account? _account; AdminToolsState _adminToolsState; + bool _hasWallets = false; StreamSubscription? _keychainUnlockedSub; StreamSubscription? _accountSub; @@ -44,6 +45,8 @@ final class SessionCubit extends Cubit _accountSub = _userService.watchAccount.listen(_onActiveAccountChanged); _adminToolsSub = _adminTools.stream.listen(_onAdminToolsChanged); + + unawaited(_checkAvailableWallets()); } Future unlock(LockFactor lockFactor) async { @@ -119,15 +122,21 @@ final class SessionCubit extends Cubit _updateState(); } + Future _checkAvailableWallets() async { + final wallets = await _registrationService + .getCardanoWallets() + .onError((_, __) => const []); + + _hasWallets = wallets.isNotEmpty; + + if (!isClosed) { + _updateState(); + } + } + void _updateState() { if (_adminToolsState.enabled) { - unawaited( - _createMockedSessionState().then((value) { - if (!isClosed) { - emit(value); - } - }), - ); + emit(_createMockedSessionState()); } else { emit(_createSessionState()); } @@ -136,44 +145,51 @@ final class SessionCubit extends Cubit SessionState _createSessionState() { final account = _account; final isUnlocked = _account?.keychain.lastIsUnlocked ?? false; + final hasWallets = _hasWallets; if (account == null) { final isEmpty = _registrationProgressNotifier.value.isEmpty; - return VisitorSessionState(isRegistrationInProgress: !isEmpty); + return VisitorSessionState( + canCreateAccount: hasWallets, + isRegistrationInProgress: !isEmpty, + ); } if (!isUnlocked) { - return const GuestSessionState(); + return GuestSessionState(canCreateAccount: hasWallets); } + final sessionAccount = SessionAccount.fromAccount(account); final spaces = _accessControl.spacesAccess(account); final overallSpaces = _accessControl.overallSpaces(account); final spacesShortcuts = _accessControl.spacesShortcutsActivators(account); return ActiveAccountSessionState( - account: account, + account: sessionAccount, spaces: spaces, overallSpaces: overallSpaces, spacesShortcuts: spacesShortcuts, + canCreateAccount: hasWallets, ); } - Future _createMockedSessionState() async { + SessionState _createMockedSessionState() { switch (_adminToolsState.sessionStatus) { case SessionStatus.actor: - // TODO(damian-molinski): Limiting exposed Account so its not future. - final dummyAccount = await _getDummyAccount(); - return ActiveAccountSessionState( - account: dummyAccount, + account: const SessionAccount.mocked(), spaces: Space.values, overallSpaces: Space.values, spacesShortcuts: AccessControl.allSpacesShortcutsActivators, + canCreateAccount: true, ); case SessionStatus.guest: - return const GuestSessionState(); + return const GuestSessionState(canCreateAccount: true); case SessionStatus.visitor: - return const VisitorSessionState(isRegistrationInProgress: false); + return const VisitorSessionState( + isRegistrationInProgress: false, + canCreateAccount: true, + ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_state.dart index 08d97a3262a..b5708e90d7d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/session/session_state.dart @@ -1,17 +1,38 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; /// Determines the state of the user session. sealed class SessionState extends Equatable { - const SessionState(); + /// Currently used account by this session. Null when not active. + final SessionAccount? account; /// Returns a list of all available spaces /// corresponding to the current session state. - List get spaces; + final List spaces; /// Returns a list of [spaces] that should be shown in overall spaces menu. - List get overallSpaces; + final List overallSpaces; + + /// On Web it can mean that user don't have valid extensions installed. + final bool canCreateAccount; + + const SessionState({ + this.account, + required this.spaces, + this.overallSpaces = const [], + this.canCreateAccount = false, + }); + + @override + @mustCallSuper + List get props => [ + account, + spaces, + overallSpaces, + canCreateAccount, + ]; } /// The user hasn't registered yet nor setup the keychain. @@ -20,67 +41,42 @@ final class VisitorSessionState extends SessionState { const VisitorSessionState({ required this.isRegistrationInProgress, - }); - - @override - List get spaces => const [Space.discovery]; - - @override - List get overallSpaces => const [ - // not supported - ]; + super.canCreateAccount, + }) : super( + spaces: const [Space.discovery], + ); @override List get props => [ + ...super.props, isRegistrationInProgress, ]; } /// The user has registered the keychain but it's locked. final class GuestSessionState extends SessionState { - const GuestSessionState(); - - @override - List get spaces => const [Space.discovery]; - - @override - List get overallSpaces => const [ - // not supported - ]; - - @override - List get props => []; + const GuestSessionState({ + super.canCreateAccount, + }) : super( + spaces: const [Space.discovery], + ); } /// The user has registered and unlocked the keychain. final class ActiveAccountSessionState extends SessionState { - // TODO(damian-molinski): Try limiting exposed Account to something smaller. - final Account? account; - @override - final List spaces; - @override - final List overallSpaces; final Map spacesShortcuts; const ActiveAccountSessionState({ - this.account, - required this.spaces, - required this.overallSpaces, + required SessionAccount super.account, + required super.spaces, + required super.overallSpaces, + super.canCreateAccount, required this.spacesShortcuts, }); @override List get props => [ - account, - spaces, - overallSpaces, + ...super.props, spacesShortcuts, ]; } - -extension SessionStateExt on SessionState { - Account? get account => switch (this) { - ActiveAccountSessionState(:final account) => account, - _ => null, - }; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart index c4acaa934ba..c7d1d1f15af 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/session/session_cubit_test.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_cardano/catalyst_cardano.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; @@ -42,7 +43,12 @@ void main() { userService = UserService( userRepository: userRepository, ); - registrationService = _MockRegistrationService(keychainProvider); + registrationService = _MockRegistrationService( + keychainProvider, + [ + _MockCardanoWallet(), + ], + ); notifier = RegistrationProgressNotifier(); accessControl = const AccessControl(); }); @@ -51,6 +57,11 @@ void main() { // each test might emit using this cubit, therefore we reset it here adminToolsCubit = AdminToolsCubit(); + // restart list of wallets to default one found. + (registrationService as _MockRegistrationService).cardanoWallets = [ + _MockCardanoWallet(), + ]; + sessionCubit = SessionCubit( userService, registrationService, @@ -71,6 +82,8 @@ void main() { await const FlutterSecureStorage().deleteAll(); await SharedPreferencesAsync().clear(); + notifier.value = const RegistrationProgress(); + reset(registrationService); }); @@ -106,7 +119,10 @@ void main() { expect(sessionCubit.state, isA()); expect( sessionCubit.state, - const VisitorSessionState(isRegistrationInProgress: false), + const VisitorSessionState( + isRegistrationInProgress: false, + canCreateAccount: true, + ), ); }); @@ -135,7 +151,10 @@ void main() { expect(sessionCubit.state, isA()); expect( sessionCubit.state, - const VisitorSessionState(isRegistrationInProgress: true), + const VisitorSessionState( + isRegistrationInProgress: true, + canCreateAccount: true, + ), ); }); @@ -201,13 +220,84 @@ void main() { expect(sessionCubit.state, isA()); }); + + group('can create account', () { + test('is disabled when no cardano wallets are found', () async { + // Given + const cardanoWallets = []; + const expectedState = VisitorSessionState( + isRegistrationInProgress: false, + canCreateAccount: false, + ); + final mockedService = (registrationService as _MockRegistrationService); + + // When + + // ignore: cascade_invocations + mockedService.cardanoWallets = cardanoWallets; + + sessionCubit = SessionCubit( + userService, + registrationService, + notifier, + accessControl, + adminToolsCubit, + ); + + // Gives time for stream to emit. + await Future.delayed(const Duration(milliseconds: 100)); + + // Then + expect(sessionCubit.state, expectedState); + }); + + test('is enabled when at least one cardano wallets is found', () async { + // Given + final cardanoWallets = [ + _MockCardanoWallet(), + ]; + const expectedState = VisitorSessionState( + isRegistrationInProgress: false, + canCreateAccount: true, + ); + final mockedService = (registrationService as _MockRegistrationService); + + // When + + // ignore: cascade_invocations + mockedService.cardanoWallets = cardanoWallets; + + sessionCubit = SessionCubit( + userService, + registrationService, + notifier, + accessControl, + adminToolsCubit, + ); + + // Gives time for stream to emit. + await Future.delayed(const Duration(milliseconds: 100)); + + // Then + expect(sessionCubit.state, expectedState); + }); + }); }); } class _MockRegistrationService extends Mock implements RegistrationService { final KeychainProvider keychainProvider; + List cardanoWallets; + + _MockRegistrationService( + this.keychainProvider, + this.cardanoWallets, + ); - _MockRegistrationService(this.keychainProvider); + @override + Future> getCardanoWallets() { + return Future.value(cardanoWallets); + } @override Future registerTestAccount({ @@ -223,3 +313,7 @@ class _MockRegistrationService extends Mock implements RegistrationService { return Account.dummy(keychain: keychain); } } + +class _MockCardanoWallet extends Mock implements CardanoWallet { + _MockCardanoWallet(); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart index d67454ad140..7c239240be2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_service.dart @@ -20,19 +20,12 @@ final _testNetAddress = ShelleyAddress.fromBech32( final _logger = Logger('RegistrationService'); abstract interface class RegistrationService { - factory RegistrationService({ - required TransactionConfigRepository transactionConfigRepository, - required KeychainProvider keychainProvider, - required CatalystCardano cardano, - required KeyDerivation keyDerivation, - }) { - return RegistrationServiceImpl( - transactionConfigRepository, - keychainProvider, - cardano, - keyDerivation, - ); - } + factory RegistrationService( + TransactionConfigRepository transactionConfigRepository, + KeychainProvider keychainProvider, + CatalystCardano cardano, + KeyDerivation keyDerivation, + ) = RegistrationServiceImpl; /// Returns the available cardano wallet extensions. Future> getCardanoWallets(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/account/session_account.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/account/session_account.dart new file mode 100644 index 00000000000..54a0c11e24d --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/account/session_account.dart @@ -0,0 +1,40 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class SessionAccount extends Equatable { + final String displayName; + final bool isAdmin; + final bool isProposer; + final bool isDrep; + + const SessionAccount({ + this.displayName = '', + this.isAdmin = false, + this.isProposer = false, + this.isDrep = false, + }); + + const SessionAccount.mocked() + : this( + displayName: 'Catalyst', + isAdmin: true, + isProposer: true, + ); + + factory SessionAccount.fromAccount(Account account) { + return SessionAccount( + displayName: account.acronym, + isAdmin: account.isAdmin, + isProposer: account.roles.contains(AccountRole.proposer), + isDrep: account.roles.contains(AccountRole.drep), + ); + } + + @override + List get props => [ + displayName, + isAdmin, + isProposer, + isDrep, + ]; +} 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 0ff8337fa4d..2382564c264 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,3 +1,4 @@ +export 'account/session_account.dart'; export 'authentication/authentication.dart'; export 'campaign/campaign_category.dart'; export 'campaign/campaign_category_section.dart';