diff --git a/catalyst_voices/lib/common/ext/account_role_ext.dart b/catalyst_voices/lib/common/ext/account_role_ext.dart new file mode 100644 index 0000000000..070bc36808 --- /dev/null +++ b/catalyst_voices/lib/common/ext/account_role_ext.dart @@ -0,0 +1,16 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +extension AccountRoleExt on AccountRole { + String getName(BuildContext context) { + switch (this) { + case AccountRole.voter: + return context.l10n.voter; + case AccountRole.proposer: + return context.l10n.proposer; + case AccountRole.drep: + return context.l10n.drep; + } + } +} diff --git a/catalyst_voices/lib/pages/account/account.dart b/catalyst_voices/lib/pages/account/account.dart new file mode 100644 index 0000000000..343bf63edb --- /dev/null +++ b/catalyst_voices/lib/pages/account/account.dart @@ -0,0 +1 @@ +export 'account_page.dart'; diff --git a/catalyst_voices/lib/pages/account/account_page.dart b/catalyst_voices/lib/pages/account/account_page.dart new file mode 100644 index 0000000000..f591404d9f --- /dev/null +++ b/catalyst_voices/lib/pages/account/account_page.dart @@ -0,0 +1,251 @@ +import 'package:catalyst_voices/common/ext/account_role_ext.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_text_button.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_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +final class AccountPage extends StatelessWidget { + const AccountPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + const _Header(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 32), + const _Tab(), + const SizedBox(height: 48), + _KeychainCard( + connectedWallet: 'Lace', + roles: const [ + AccountRole.voter, + AccountRole.proposer, + AccountRole.drep, + ], + defaultRole: AccountRole.voter, + onRemoveKeychain: () => debugPrint('Keychain removed'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: CatalystImage.asset( + VoicesAssets.images.accountBg.path, + ).image, + fit: BoxFit.cover, + ), + ), + child: SizedBox( + width: double.infinity, + height: 350, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 24, + left: 8, + ), + child: VoicesIconButton.filled( + onTap: () { + GoRouter.of(context).pop(); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, + ), + foregroundColor: WidgetStateProperty.all( + Theme.of(context).colors.iconsForeground, + ), + ), + child: VoicesAssets.icons.arrowNarrowLeft.buildIcon(), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + context.l10n.myAccountProfileKeychain, + style: Theme.of(context).textTheme.displayMedium?.copyWith( + color: Colors.white, + ), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + context.l10n.yourCatalystKeychainAndRoleRegistration, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab(); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 1, + child: TabBar( + padding: const EdgeInsets.symmetric(horizontal: 20), + isScrollable: true, + tabs: [ + Tab(text: context.l10n.profileAndKeychain), + ], + ), + ); + } +} + +class _KeychainCard extends StatelessWidget { + final String? connectedWallet; + final List roles; + final AccountRole? defaultRole; + final VoidCallback? onRemoveKeychain; + + const _KeychainCard({ + this.connectedWallet, + this.roles = const [], + this.defaultRole, + this.onRemoveKeychain, + }); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + border: Border.all( + color: Theme.of(context).colors.outlineBorderVariant!, + width: 1, + ), + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + context.l10n.catalystKeychain, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + VoicesTextButton.custom( + leading: VoicesAssets.icons.x.buildIcon(), + color: Theme.of(context).colors.iconsError, + onTap: onRemoveKeychain, + child: Text( + context.l10n.removeKeychain, + ), + ), + ], + ), + if (connectedWallet != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + context.l10n.walletConnected, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (connectedWallet != null) + Row( + children: [ + VoicesIconButton.filled( + style: ButtonStyle( + padding: WidgetStateProperty.all(EdgeInsets.zero), + foregroundColor: WidgetStateProperty.all( + Theme.of(context).colors.successContainer, + ), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colors.success, + ), + ), + child: VoicesAssets.icons.check.buildIcon(), + ), + const SizedBox(width: 12), + Text( + connectedWallet!, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + if (roles.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + top: 40, + bottom: 24, + ), + child: Text( + context.l10n.currentRoleRegistrations, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (roles.isNotEmpty) + Text( + roles + .map((e) => _formatRoleBullet(e, defaultRole, context)) + .join('\n'), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ); + } + + String _formatRoleBullet( + AccountRole role, + AccountRole? defaultRole, + BuildContext context, + ) { + String label; + if (role == defaultRole) { + label = '${role.getName(context)} (${context.l10n.defaultRole})'; + } else { + label = role.getName(context); + } + return ' • $label'; + } +} diff --git a/catalyst_voices/lib/pages/account/creation/get_started/account_create_dialog.dart b/catalyst_voices/lib/pages/account/creation/get_started/account_create_dialog.dart new file mode 100644 index 0000000000..ea69f13db6 --- /dev/null +++ b/catalyst_voices/lib/pages/account/creation/get_started/account_create_dialog.dart @@ -0,0 +1,187 @@ +import 'package:catalyst_voices/pages/account/creation/task_picture.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_buttons.dart'; +import 'package:catalyst_voices/widgets/modals/voices_desktop_dialog.dart'; +import 'package:catalyst_voices/widgets/modals/voices_dialog.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'; + +enum AccountCreateType { + createNew, + recover; + + SvgGenImage get _icon => switch (this) { + AccountCreateType.createNew => VoicesAssets.icons.colorSwatch, + AccountCreateType.recover => VoicesAssets.icons.download, + }; + + String _getTitle(VoicesLocalizations l10n) => switch (this) { + AccountCreateType.createNew => l10n.accountCreationCreate, + AccountCreateType.recover => l10n.accountCreationRecover, + }; + + String _getSubtitle(VoicesLocalizations l10n) { + return l10n.accountCreationOnThisDevice; + } +} + +class AccountCreateDialog extends StatelessWidget { + const AccountCreateDialog._(); + + static Future show(BuildContext context) { + return VoicesDialog.show( + context: context, + builder: (context) => const AccountCreateDialog._(), + ); + } + + @override + Widget build(BuildContext context) { + return const VoicesDesktopPanelsDialog( + left: _LeftPanel(), + right: _RightPanel(), + ); + } +} + +class _LeftPanel extends StatelessWidget { + const _LeftPanel(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.getStarted, + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 12), + const Expanded(child: Center(child: TaskKeychainPicture())), + const SizedBox(height: 32), + // TODO(damian-molinski): External url redirect + VoicesLearnMoreButton(onTap: () {}), + ], + ); + } +} + +class _RightPanel extends StatelessWidget { + const _RightPanel(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Text( + context.l10n.accountCreationGetStartedTitle, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 12), + Text( + context.l10n.accountCreationGetStatedDesc, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 32), + Text( + context.l10n.accountCreationGetStatedWhatNext, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 24), + Column( + mainAxisSize: MainAxisSize.min, + children: AccountCreateType.values + .map((type) { + return _AccountCreateTypeTile( + key: ValueKey(type), + type: type, + onTap: () => Navigator.of(context).pop(type), + ); + }) + .separatedBy(const SizedBox(height: 12)) + .toList(), + ), + ], + ); + } +} + +class _AccountCreateTypeTile extends StatelessWidget { + final AccountCreateType type; + final VoidCallback? onTap; + + const _AccountCreateTypeTile({ + super.key, + required this.type, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 80), + child: Material( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + type._icon.buildIcon( + size: 48, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + type._getTitle(context.l10n), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + Text( + type._getSubtitle(context.l10n), + maxLines: 1, + overflow: TextOverflow.clip, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/pages/account/creation/task_picture.dart b/catalyst_voices/lib/pages/account/creation/task_picture.dart index a6927de0c0..04d9cb038d 100644 --- a/catalyst_voices/lib/pages/account/creation/task_picture.dart +++ b/catalyst_voices/lib/pages/account/creation/task_picture.dart @@ -46,21 +46,45 @@ class TaskKeychainPicture extends StatelessWidget { } class TaskPicture extends StatelessWidget { + final Size preferredSize; final Widget child; + // Original size is 125 but we want to have it scale with overall picture + static const _childSizeFactor = 125 / 354; + const TaskPicture({ super.key, + // Original asset sizes. "Magic number" from figma. + this.preferredSize = const Size(354, 340), required this.child, }); @override Widget build(BuildContext context) { - return Stack( - alignment: Alignment.topRight, - children: [ - CatalystImage.asset(VoicesAssets.images.taskIllustration.path), - child, - ], + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints + .constrainSizeAndAttemptToPreserveAspectRatio(preferredSize); + final childSize = Size.square(size.width * _childSizeFactor); + + return SizedBox.fromSize( + size: size, + child: Stack( + alignment: Alignment.topRight, + children: [ + CatalystImage.asset( + VoicesAssets.images.taskIllustration.path, + width: size.width, + height: size.height, + ), + ConstrainedBox( + constraints: BoxConstraints.tight(childSize), + child: child, + ), + ], + ), + ); + }, ); } } @@ -87,7 +111,6 @@ class TaskPictureIconBox extends StatelessWidget { return IconTheme( data: iconThemeData, child: Container( - constraints: BoxConstraints.tight(const Size.square(125)), decoration: BoxDecoration( color: backgroundColor, shape: BoxShape.circle, diff --git a/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart b/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart index 26b10d01de..8f5b6f9c00 100644 --- a/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart +++ b/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices/common/ext/ext.dart'; +import 'package:catalyst_voices/pages/account/creation/get_started/account_create_dialog.dart'; import 'package:catalyst_voices/pages/spaces/drawer/spaces_drawer.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; @@ -7,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class SpacesShellPage extends StatelessWidget { +class SpacesShellPage extends StatefulWidget { final Space space; final Widget child; @@ -40,6 +41,11 @@ class SpacesShellPage extends StatelessWidget { required this.child, }); + @override + State createState() => _SpacesShellPageState(); +} + +class _SpacesShellPageState extends State { @override Widget build(BuildContext context) { final sessionBloc = context.watch(); @@ -48,27 +54,50 @@ class SpacesShellPage extends StatelessWidget { return CallbackShortcuts( bindings: { - for (final entry in _spacesShortcutsActivators.entries) + for (final entry in SpacesShellPage._spacesShortcutsActivators.entries) entry.value: () => entry.key.go(context), }, child: Scaffold( appBar: VoicesAppBar( leading: isVisitor ? null : const DrawerToggleButton(), automaticallyImplyLeading: false, - actions: const [ - SessionActionHeader(), - SessionStateHeader(), + actions: [ + SessionActionHeader( + onGetStartedTap: _showAccountGetStarted, + ), + const SessionStateHeader(), ], ), drawer: isVisitor ? null : SpacesDrawer( - space: space, - spacesShortcutsActivators: _spacesShortcutsActivators, + space: widget.space, + spacesShortcutsActivators: + SpacesShellPage._spacesShortcutsActivators, isUnlocked: isUnlocked, ), - body: child, + body: widget.child, ), ); } + + Future _showAccountGetStarted() async { + final type = await AccountCreateDialog.show(context); + if (type == null) { + return; + } + + if (mounted) { + switch (type) { + case AccountCreateType.createNew: + _showCreateAccountFlow().ignore(); + case AccountCreateType.recover: + _showRecoverAccountFlow().ignore(); + } + } + } + + Future _showCreateAccountFlow() async {} + + Future _showRecoverAccountFlow() async {} } diff --git a/catalyst_voices/lib/routes/routing/account_route.dart b/catalyst_voices/lib/routes/routing/account_route.dart new file mode 100644 index 0000000000..75cfb4ed9a --- /dev/null +++ b/catalyst_voices/lib/routes/routing/account_route.dart @@ -0,0 +1,19 @@ +import 'package:catalyst_voices/pages/account/account_page.dart'; +import 'package:catalyst_voices/routes/routing/routes.dart'; +import 'package:catalyst_voices/routes/routing/transitions/fade_page_transition_mixin.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +part 'account_route.g.dart'; + +@TypedGoRoute( + path: '/${Routes.currentMilestone}/account', +) +final class AccountRoute extends GoRouteData with FadePageTransitionMixin { + const AccountRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const AccountPage(); + } +} diff --git a/catalyst_voices/lib/routes/routing/account_route.g.dart b/catalyst_voices/lib/routes/routing/account_route.g.dart new file mode 100644 index 0000000000..c6fd453950 --- /dev/null +++ b/catalyst_voices/lib/routes/routing/account_route.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_route.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $accountRoute, + ]; + +RouteBase get $accountRoute => GoRouteData.$route( + path: '/m4/account', + factory: $AccountRouteExtension._fromState, + ); + +extension $AccountRouteExtension on AccountRoute { + static AccountRoute _fromState(GoRouterState state) => const AccountRoute(); + + String get location => GoRouteData.$location( + '/m4/account', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/catalyst_voices/lib/routes/routing/routes.dart b/catalyst_voices/lib/routes/routing/routes.dart index 11d75a1638..e533df0b65 100644 --- a/catalyst_voices/lib/routes/routing/routes.dart +++ b/catalyst_voices/lib/routes/routing/routes.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/routes/routing/account_route.dart' as account; import 'package:catalyst_voices/routes/routing/coming_soon_route.dart' as coming_soon; import 'package:catalyst_voices/routes/routing/login_route.dart' as login; @@ -12,6 +13,7 @@ abstract final class Routes { static const currentMilestone = 'm4'; static final List routes = [ + ...account.$appRoutes, ...coming_soon.$appRoutes, ...login.$appRoutes, ...spaces.$appRoutes, diff --git a/catalyst_voices/lib/routes/routing/routing.dart b/catalyst_voices/lib/routes/routing/routing.dart index fbec95ce4c..e531151988 100644 --- a/catalyst_voices/lib/routes/routing/routing.dart +++ b/catalyst_voices/lib/routes/routing/routing.dart @@ -1,3 +1,4 @@ +export 'account_route.dart' hide $appRoutes; export 'coming_soon_route.dart' hide $appRoutes; export 'login_route.dart' hide $appRoutes; export 'overall_spaces_route.dart' hide $appRoutes; 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 7d52d730a2..d354771106 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 @@ -8,14 +8,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// Displays current session action and toggling to next when clicked. class SessionActionHeader extends StatelessWidget { - const SessionActionHeader({super.key}); + final VoidCallback? onGetStartedTap; + + const SessionActionHeader({ + super.key, + this.onGetStartedTap, + }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return switch (state) { - VisitorSessionState() => const _GetStartedButton(), + VisitorSessionState() => _GetStartedButton(onTap: onGetStartedTap), GuestSessionState() => const _UnlockButton(), ActiveUserSessionState() => const _LockButton(), }; @@ -25,14 +30,16 @@ class SessionActionHeader extends StatelessWidget { } class _GetStartedButton extends StatelessWidget { - const _GetStartedButton(); + final VoidCallback? onTap; + + const _GetStartedButton({ + this.onTap, + }); @override Widget build(BuildContext context) { return VoicesFilledButton( - onTap: () { - context.read().add(const ActiveUserSessionEvent()); - }, + onTap: onTap, child: Text(context.l10n.getStarted), ); } 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 a84223a205..79eb2e0fb0 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 @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:catalyst_voices/pages/account/account_popup.dart'; +import 'package:catalyst_voices/routes/routing/account_route.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'; @@ -19,7 +22,9 @@ class SessionStateHeader extends StatelessWidget { ActiveUserSessionState(:final user) => AccountPopup( avatarLetter: user.acronym ?? 'A', onLockAccountTap: () => debugPrint('Lock account'), - onProfileKeychainTap: () => debugPrint('Open Profile screen'), + onProfileKeychainTap: () => unawaited( + const AccountRoute().push(context), + ), ), }; }, diff --git a/catalyst_voices/lib/widgets/buttons/voices_text_button.dart b/catalyst_voices/lib/widgets/buttons/voices_text_button.dart index d9ce555aaa..0e1c8b540a 100644 --- a/catalyst_voices/lib/widgets/buttons/voices_text_button.dart +++ b/catalyst_voices/lib/widgets/buttons/voices_text_button.dart @@ -2,7 +2,7 @@ import 'package:catalyst_voices/widgets/buttons/voices_button_affix_decoration.d import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; -enum _Variant { primary, neutral, secondary } +enum _Variant { primary, neutral, secondary, custom } /// A button that combines a `TextButton` with optional leading and trailing /// elements. @@ -22,6 +22,9 @@ class VoicesTextButton extends StatelessWidget { /// The main content of the button. final Widget child; + /// The foreground color of the button. + final Color? color; + final _Variant _variant; const VoicesTextButton({ @@ -29,6 +32,7 @@ class VoicesTextButton extends StatelessWidget { this.onTap, this.leading, this.trailing, + this.color, required this.child, }) : _variant = _Variant.primary; @@ -37,6 +41,7 @@ class VoicesTextButton extends StatelessWidget { this.onTap, this.leading, this.trailing, + this.color, required this.child, }) : _variant = _Variant.neutral; @@ -45,9 +50,19 @@ class VoicesTextButton extends StatelessWidget { this.onTap, this.leading, this.trailing, + this.color, required this.child, }) : _variant = _Variant.secondary; + const VoicesTextButton.custom({ + super.key, + this.onTap, + this.leading, + this.trailing, + required this.color, + required this.child, + }) : _variant = _Variant.custom; + @override Widget build(BuildContext context) { return TextButton( @@ -75,6 +90,9 @@ class VoicesTextButton extends StatelessWidget { _Variant.secondary => TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.secondary, ), + _Variant.custom => TextButton.styleFrom( + foregroundColor: color, + ), }; } } diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/account_bg.png b/catalyst_voices/packages/catalyst_voices_assets/assets/images/account_bg.png new file mode 100644 index 0000000000..8a3c87e0c3 Binary files /dev/null and b/catalyst_voices/packages/catalyst_voices_assets/assets/images/account_bg.png differ diff --git a/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart b/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart index 58c87d3180..c58ee27460 100644 --- a/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart +++ b/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart @@ -7,10 +7,10 @@ // ignore_for_file: type=lint // ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use -import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:vector_graphics/vector_graphics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart' as _svg; +import 'package:vector_graphics/vector_graphics.dart' as _vg; class $AssetsIconsGen { const $AssetsIconsGen(); @@ -1157,6 +1157,10 @@ class $AssetsIconsGen { class $AssetsImagesGen { const $AssetsImagesGen(); + /// File path: assets/images/account_bg.png + AssetGenImage get accountBg => + const AssetGenImage('assets/images/account_bg.png'); + /// File path: assets/images/catalyst_logo.svg SvgGenImage get catalystLogo => const SvgGenImage('assets/images/catalyst_logo.svg'); @@ -1229,6 +1233,7 @@ class $AssetsImagesGen { /// List of all assets List get values => [ + accountBg, catalystLogo, catalystLogoIcon, catalystLogoIconWhite, @@ -1357,7 +1362,7 @@ class SvgGenImage { final Set flavors; final bool _isVecFormat; - SvgPicture svg({ + _svg.SvgPicture svg({ Key? key, bool matchTextDirection = false, AssetBundle? bundle, @@ -1370,29 +1375,29 @@ class SvgGenImage { WidgetBuilder? placeholderBuilder, String? semanticsLabel, bool excludeFromSemantics = false, - SvgTheme? theme, + _svg.SvgTheme? theme, ColorFilter? colorFilter, Clip clipBehavior = Clip.hardEdge, @deprecated Color? color, @deprecated BlendMode colorBlendMode = BlendMode.srcIn, @deprecated bool cacheColorFilter = false, }) { - final BytesLoader loader; + final _svg.BytesLoader loader; if (_isVecFormat) { - loader = AssetBytesLoader( + loader = _vg.AssetBytesLoader( _assetName, assetBundle: bundle, packageName: package, ); } else { - loader = SvgAssetLoader( + loader = _svg.SvgAssetLoader( _assetName, assetBundle: bundle, packageName: package, theme: theme, ); } - return SvgPicture( + return _svg.SvgPicture( loader, key: key, matchTextDirection: matchTextDirection, 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 949518e234..77545c8e8a 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 @@ -5,12 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// Manages the user session. final class SessionBloc extends Bloc { - SessionBloc() - : super( - const ActiveUserSessionState( - user: User(name: 'Account'), - ), - ) { + SessionBloc() : super(const VisitorSessionState()) { on(_handleSessionEvent); } 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 cf70a944c9..b7f6afd7ab 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 @@ -699,6 +699,108 @@ abstract class VoicesLocalizations { /// In en, this message translates to: /// **'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'** String get walletLink_intro_content; + + /// No description provided for @accountCreationCreate. + /// + /// In en, this message translates to: + /// **'Create a new 
Catalyst Keychain'** + String get accountCreationCreate; + + /// No description provided for @accountCreationRecover. + /// + /// In en, this message translates to: + /// **'Recover your
Catalyst Keychain'** + String get accountCreationRecover; + + /// Indicates that created keychain will be stored in this device only + /// + /// In en, this message translates to: + /// **'On this device'** + String get accountCreationOnThisDevice; + + /// No description provided for @accountCreationGetStartedTitle. + /// + /// In en, this message translates to: + /// **'Welcome to Catalyst'** + String get accountCreationGetStartedTitle; + + /// No description provided for @accountCreationGetStatedDesc. + /// + /// In en, this message translates to: + /// **'If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.'** + String get accountCreationGetStatedDesc; + + /// No description provided for @accountCreationGetStatedWhatNext. + /// + /// In en, this message translates to: + /// **'What do you want to do?'** + String get accountCreationGetStatedWhatNext; + + /// Title of My Account page + /// + /// In en, this message translates to: + /// **'My Account / Profile & Keychain'** + String get myAccountProfileKeychain; + + /// Subtitle of My Account page + /// + /// In en, this message translates to: + /// **'Your Catalyst keychain & role registration'** + String get yourCatalystKeychainAndRoleRegistration; + + /// Tab on My Account page + /// + /// In en, this message translates to: + /// **'Profile & Keychain'** + String get profileAndKeychain; + + /// Title of Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Catalyst Keychain'** + String get catalystKeychain; + + /// Action on Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Remove Keychain'** + String get removeKeychain; + + /// Describes that wallet is connected on Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Wallet connected'** + String get walletConnected; + + /// Describes roles on Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Current Role registrations'** + String get currentRoleRegistrations; + + /// Account role + /// + /// In en, this message translates to: + /// **'Voter'** + String get voter; + + /// Account role + /// + /// In en, this message translates to: + /// **'Proposer'** + String get proposer; + + /// Account role + /// + /// In en, this message translates to: + /// **'Drep'** + String get drep; + + /// Related to account role + /// + /// In en, this message translates to: + /// **'Default'** + String get defaultRole; } class _VoicesLocalizationsDelegate extends LocalizationsDelegate { 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 08a982619f..da28f6cac2 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 @@ -363,4 +363,55 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get walletLink_intro_content => 'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'; + + @override + String get accountCreationCreate => 'Create a new 
Catalyst Keychain'; + + @override + String get accountCreationRecover => 'Recover your
Catalyst Keychain'; + + @override + String get accountCreationOnThisDevice => 'On this device'; + + @override + String get accountCreationGetStartedTitle => 'Welcome to Catalyst'; + + @override + String get accountCreationGetStatedDesc => 'If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.'; + + @override + String get accountCreationGetStatedWhatNext => 'What do you want to do?'; + + @override + String get myAccountProfileKeychain => 'My Account / Profile & Keychain'; + + @override + String get yourCatalystKeychainAndRoleRegistration => 'Your Catalyst keychain & role registration'; + + @override + String get profileAndKeychain => 'Profile & Keychain'; + + @override + String get catalystKeychain => 'Catalyst Keychain'; + + @override + String get removeKeychain => 'Remove Keychain'; + + @override + String get walletConnected => 'Wallet connected'; + + @override + String get currentRoleRegistrations => 'Current Role registrations'; + + @override + String get voter => 'Voter'; + + @override + String get proposer => 'Proposer'; + + @override + String get drep => 'Drep'; + + @override + String get defaultRole => 'Default'; } 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 33a5b51913..b319a90a12 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 @@ -363,4 +363,55 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get walletLink_intro_content => 'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'; + + @override + String get accountCreationCreate => 'Create a new 
Catalyst Keychain'; + + @override + String get accountCreationRecover => 'Recover your
Catalyst Keychain'; + + @override + String get accountCreationOnThisDevice => 'On this device'; + + @override + String get accountCreationGetStartedTitle => 'Welcome to Catalyst'; + + @override + String get accountCreationGetStatedDesc => 'If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.'; + + @override + String get accountCreationGetStatedWhatNext => 'What do you want to do?'; + + @override + String get myAccountProfileKeychain => 'My Account / Profile & Keychain'; + + @override + String get yourCatalystKeychainAndRoleRegistration => 'Your Catalyst keychain & role registration'; + + @override + String get profileAndKeychain => 'Profile & Keychain'; + + @override + String get catalystKeychain => 'Catalyst Keychain'; + + @override + String get removeKeychain => 'Remove Keychain'; + + @override + String get walletConnected => 'Wallet connected'; + + @override + String get currentRoleRegistrations => 'Current Role registrations'; + + @override + String get voter => 'Voter'; + + @override + String get proposer => 'Proposer'; + + @override + String get drep => 'Drep'; + + @override + String get defaultRole => 'Default'; } 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 22a2c7f2fe..f09843eabb 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 @@ -449,5 +449,58 @@ "walletLink_intro_content": "You're almost there! This is the final and most important step in your account setup.\n\nWe're going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe'll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.", "@walletLink_intro_content": { "description": "A message (content) in link wallet flow on intro screen." + }, + "accountCreationCreate": "Create a new \u2028Catalyst Keychain", + "accountCreationRecover": "Recover your\u2028Catalyst Keychain", + "accountCreationOnThisDevice": "On this device", + "@accountCreationOnThisDevice": { + "description": "Indicates that created keychain will be stored in this device only" + }, + "accountCreationGetStartedTitle": "Welcome to Catalyst", + "accountCreationGetStatedDesc": "If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.", + "accountCreationGetStatedWhatNext": "What do you want to do?", + "myAccountProfileKeychain": "My Account / Profile & Keychain", + "@myAccountProfileKeychain": { + "description": "Title of My Account page" + }, + "yourCatalystKeychainAndRoleRegistration": "Your Catalyst keychain & role registration", + "@yourCatalystKeychainAndRoleRegistration": { + "description": "Subtitle of My Account page" + }, + "profileAndKeychain": "Profile & Keychain", + "@profileAndKeychain": { + "description": "Tab on My Account page" + }, + "catalystKeychain": "Catalyst Keychain", + "@catalystKeychain": { + "description": "Title of Catalyst Keychain card" + }, + "removeKeychain": "Remove Keychain", + "@removeKeychain": { + "description": "Action on Catalyst Keychain card" + }, + "walletConnected": "Wallet connected", + "@walletConnected": { + "description": "Describes that wallet is connected on Catalyst Keychain card" + }, + "currentRoleRegistrations": "Current Role registrations", + "@currentRoleRegistrations": { + "description": "Describes roles on Catalyst Keychain card" + }, + "voter": "Voter", + "@voter": { + "description": "Account role" + }, + "proposer": "Proposer", + "@proposer": { + "description": "Account role" + }, + "drep": "Drep", + "@drep": { + "description": "Account role" + }, + "defaultRole": "Default", + "@defaultRole": { + "description": "Related to account role" } } \ No newline at end of file diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/account/account_role.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/account/account_role.dart new file mode 100644 index 0000000000..bdeafd803e --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/account/account_role.dart @@ -0,0 +1,5 @@ +enum AccountRole { + voter, + proposer, + drep, +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 072d1267fe..3afed13082 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -1,5 +1,6 @@ library catalyst_voices_models; +export 'account/account_role.dart'; export 'auth/authentication_status.dart'; export 'auth/password_strength.dart'; export 'errors/errors.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart index 1b49fe942a..977ee1cdd3 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart @@ -4,7 +4,9 @@ import 'dart:typed_data'; import 'package:bip39/bip39.dart' as bip39; import 'package:bip39/src/wordlists/english.dart'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; import 'package:convert/convert.dart'; +import 'package:ed25519_hd_key/ed25519_hd_key.dart'; /// Represents a seed phrase consisting of a mnemonic and provides methods for /// generating and deriving cryptographic data from the mnemonic. @@ -65,6 +67,27 @@ class SeedPhrase { /// The mnemonic phrase as a list of individual words. List get mnemonicWords => mnemonic.split(' '); + /// Derives an Ed25519 key pair from a seed. + /// + /// Throws a [RangeError] If the provided [offset] is negative or exceeds + /// the length of the seed (64). + /// + /// [offset]: The offset is applied + /// to the seed to adjust where key derivation starts. It defaults to 0. + Future deriveKeyPair([int offset = 0]) async { + final modifiedSeed = uint8ListSeed.sublist(offset); + + final masterKey = await ED25519_HD_KEY.getMasterKeyFromSeed(modifiedSeed); + final privateKey = masterKey.key; + + final publicKey = await ED25519_HD_KEY.getPublicKey(privateKey, false); + + return Ed25519KeyPair( + publicKey: Ed25519PublicKey.fromBytes(publicKey), + privateKey: Ed25519PrivateKey.fromBytes(privateKey), + ); + } + /// The full list of BIP-39 mnemonic words in English. static List get wordList => WORDLIST; } diff --git a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml index 0a336b0d57..733581ca59 100644 --- a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml @@ -10,6 +10,7 @@ dependencies: bip39: ^1.0.6 catalyst_cardano_serialization: ^0.4.0 convert: ^3.1.1 + ed25519_hd_key: ^2.3.0 equatable: ^2.0.5 flutter_quill: ^10.5.13 meta: ^1.10.0 diff --git a/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart b/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart index de075d6356..f102708cb1 100644 --- a/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart +++ b/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart @@ -70,5 +70,23 @@ void main() { final expectedWords = mnemonic.split(' '); expect(seedPhrase.mnemonicWords, expectedWords); }); + + test('should generate key pair with different valid offsets', () async { + for (final offset in [0, 4, 28, 32, 64]) { + final keyPair = await SeedPhrase().deriveKeyPair(offset); + + expect(keyPair, isNotNull); + } + }); + + test('should throw an error for key pair with out of range offset', + () async { + for (final offset in [-1, 65]) { + expect( + () async => SeedPhrase().deriveKeyPair(offset), + throwsA(isA()), + ); + } + }); }); } diff --git a/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart index d6147faf0b..546c726a96 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart @@ -15,6 +15,7 @@ class VoicesTabsExample extends StatelessWidget { child: Column( children: [ const TabBar( + isScrollable: true, tabs: [ Tab(text: 'Sections'), Tab(text: 'Comments'), diff --git a/melos.yaml b/melos.yaml index e8d7e36277..cc86a5442f 100644 --- a/melos.yaml +++ b/melos.yaml @@ -9,14 +9,14 @@ packages: - utilities/** permittedLicenses: -- MIT -- Apache-2.0 -- Unicode-DFS-2016 -- BSD-3-Clause -- BSD-2-Clause -- BlueOak-1.0.0 -- Apache-2.0 WITH LLVM-exception -- CC0-1.0 + - MIT + - Apache-2.0 + - Unicode-DFS-2016 + - BSD-3-Clause + - BSD-2-Clause + - BlueOak-1.0.0 + - Apache-2.0 WITH LLVM-exception + - CC0-1.0 packageLicenseOverride: fuchsia_remote_debug_protocol: BSD-3-Clause @@ -84,6 +84,7 @@ command: bloc_concurrency: ^0.2.2 collection: ^1.18.0 cryptography: ^2.7.0 + ed25519_hd_key: ^2.3.0 equatable: ^2.0.5 flutter_bloc: ^8.1.5 flutter_localized_locales: ^2.0.5 @@ -115,6 +116,23 @@ command: mocktail: ^1.0.1 scripts: + l10n: + run: | + melos exec -c 1 --scope="catalyst_voices_localization" -- flutter gen-l10n + description: | + Run `flutter gen-l10n` in catalyst_voices_localization package to generate l10n bindings. + + build_runner: + run: | + melos exec -c 1 \ + --depends-on="build_runner" \ + --ignore="catalyst_voices_services" -- \ + dart run build_runner build --delete-conflicting-outputs + description: | + Run `build_runner` in every package which contains the build_runner dependency. + The catalyst_voices_services is skipped because to run a build_runner there you + must generate first swagger docs (see related Earthfile). + metrics: run: | melos exec -c 1 -- \