From f39aac713a72f0401cdad1739d5362a3e491353f Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:30:56 +0100 Subject: [PATCH] feat(cat-voices): discovery page mve3 (#1281) * Feat: update testplan template (#1243) * chore: update testplan * fix * fix * fix * fix * fix: testplan template (#1245) * feat(cat-gateway): Finliaze CIP36 Endpoint Cleanup (#1241) * fix: api endpoint draft Signed-off-by: bkioshn * fix: api health endpoint v1 Signed-off-by: bkioshn * fix: remove bad request from errorResponses Signed-off-by: bkioshn * fix: add bad req to get /registration Signed-off-by: bkioshn * fix: error logging Signed-off-by: bkioshn * fix: remove validation error Signed-off-by: bkioshn * fix: registration get error name Signed-off-by: bkioshn * chore:format Signed-off-by: bkioshn * fix: get json schema from openapi spec Signed-off-by: bkioshn * fix: move schema utils Signed-off-by: bkioshn * fix: optional field Signed-off-by: bkioshn * fix: config key Signed-off-by: bkioshn * fix: cat-gateway code gen Signed-off-by: bkioshn * fix: api name in cat-voice Signed-off-by: bkioshn * fix: cat-voice format Signed-off-by: bkioshn * chore: fix spacing Signed-off-by: bkioshn * chore: fix spacing Signed-off-by: bkioshn * chore: change tag config description * test: add test for default validator * fix: add spectral ruleset Signed-off-by: bkioshn * fix(cat-gateway): Sort the spelling words, and use latest deny.toml * fix(cat-gateway): Fix broken pre-push justfile target * docs(cat-gateway): cleanup * docs(cat-gateway): Fix API Groups and document them better * docs(cat-gateway): Add documentation to the health/inspection endpoint * docs(cat-gateway): Add descriptions for cardano/cip36/latest_registration/stake_addr * docs(cat-gateway): Document stake key hash and vote key endpoints for cardano * docs(cat-gateway): add documentation to config/frontend * docs(cat-gateway): Add api docs for frontend schema * docs(cat-gateway): Move legacy registration endpoints into the Legacy TAG. * docs(cat-gateway): Remaining documentable entities documented * fix: update openapi linter Signed-off-by: bkioshn * docs(cat-gateway): Add more constraints to parameters and json bodies * fix: openapi lint FUNCTION name Signed-off-by: bkioshn * fix: CIP36 example and description Signed-off-by: bkioshn * fix(cat-gateway): cleanup error handling, and add a global 429 response to all endpoints. * fix: config endpoint example, desc, and return Signed-off-by: bkioshn * chore: remove todo Signed-off-by: bkioshn * fix: move config object Signed-off-by: bkioshn * fix: move cip36 object Signed-off-by: bkioshn * docs(cat-gateway): Add missing headers to responses * docs(cat-gateway): Cleanup the rest of the documentation in the api * fix(cat-gateway): Fix OpenAPI linting and add autogenerated api file for dart. * refactor(cat-gateway): Better generalize the OpenAPI simple string type creation macro. * fix(cat-gateway): Add APIKey and CatToken auth to some endpoints. Add 401 and 403 common responses. * fix(cat-gateway): Add universal 422 response to all endpoints, and try and make all endpoint validation use it. * fix: add cardano stake address type Signed-off-by: bkioshn * fix(cat-gateway): stake address type Signed-off-by: bkioshn * fix(cat-gateway): Refactor the RBAC Token auth, so it's easier to maintain. * fix(cat-gateway): stake address name Signed-off-by: bkioshn * fix(cat-gateway): Add no auth and no-auth+rbac auth schemes * fix(cat-gateway): format + stake addr example Signed-off-by: bkioshn * fix(cat-gateway): code format * fix(cat-gateway): openapi spectral example rules Signed-off-by: bkioshn * fix(cat-gateway): Move legacy registration endpoint under Legacy Tag * fix(cat-gateway): Add Auth to all endpoints * fix(docs): Remove obsolete lint config file * fix(cat-gateway): Make config.toml match upstream * docs(docs): update project dictionary * feat(cat-gateway): add target to make it quick to check openapi lints locally * fix(cat-gateway): Remove reference to hermes * fix(cat-gateway): Add auth to rbac endpoints * docs(cat-gateway): Add full docs for v1/votes/plan/account-votes * docs(cat-gateway): Add example for ip address query argument * fix(cat-gateway): Define and abstract Ed25519 Public Keys as hex encoded parameters * fix(cat-gateway): Make sure string api types do not directly expose the internal string * fix(cat-gateway): Make conversion from a Ed25519 pub key hex value to a Verifyingkey infallible * fix(cat-gateway): Fix native asset response types * docs(cat-gateway): fix comments * fix(cat-gateway): Autogenerate flutter files * fix(cat-gateway): Exclude legacy endpoints from needing api examples * fix(cat-gateway): WIP improving cip36 endpoint docs * fix(docs): Make targets to re-check the generated schema easy. * fix: spectral ruleset for linting query params description * feat: parameter rule * fix: debug function * docs(cat-gateway): Make schema lint accept description inside a schema in a query parameter * fix(cat-gateway): remove debug logic from api docs lint * fix(cat-gateway): Don't put expanded program into git * Make error response comments consistent * test(cat-gateway): Add local operation to easily expand macros in the service code * fix(cat-gateway): CIP36 Structured endpoint * fix: speling * fix(rust): cleanup/normalize nonce validation * fix(rust): code format * Update catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> * Update catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> --------- Signed-off-by: bkioshn Co-authored-by: bkioshn Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Co-authored-by: Apisit Ritreungroj * feat: update segment names * feat: add new discovery page * feat: add empty state for proposals * fix: rename * feat: add proposals cubit * feat: add tests * chore: spelling * chore: cleanup * chore: cleanup * chore: revert unwanted changes * chore: revert merge conflicts * fix: formatting --------- Signed-off-by: bkioshn Co-authored-by: Stefano Cunego <93382903+kukkok3@users.noreply.github.com> Co-authored-by: Steven Johnson Co-authored-by: bkioshn Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> Co-authored-by: Apisit Ritreungroj --- .config/dictionaries/project.dic | 1 + .../apps/voices/lib/app/view/app.dart | 5 +- .../lib/common/formatters/date_formatter.dart | 10 +- .../voices/lib/dependency/dependencies.dart | 8 +- .../pages/account/unlock_keychain_dialog.dart | 1 + .../pages/discovery/current_status_text.dart | 10 +- .../lib/pages/discovery/discovery_page.dart | 362 +++++++++++++++++- .../workspace/workspace_guidance_view.dart | 1 + .../widgets/cards/pending_proposal_card.dart | 74 ++-- .../lib/widgets/empty_state/empty_state.dart | 77 ++++ .../widgets/images/voices_image_scheme.dart | 23 ++ .../voices/lib/widgets/menu/voices_menu.dart | 34 +- .../lib/widgets/menu/voices_modal_menu.dart | 179 +++++++++ .../campaign/campaign_categories_tile.dart | 165 ++++++++ .../campaign/campaign_details_tile.dart | 219 +++++++++++ .../voices_password_text_field.dart | 5 + .../widgets/text_field/voices_text_field.dart | 5 + .../widgets/tiles/voices_expansion_tile.dart | 107 ++++++ .../apps/voices/lib/widgets/widgets.dart | 2 + .../formatters/date_formatter_test.dart | 115 ++++-- .../widgets/empty_state/empty_state_test.dart | 131 +++++++ .../test/widgets/menu/voices_menu_test.dart | 16 +- .../assets/images/no_proposal_foreground.svg | 41 ++ .../lib/src/catalyst_voices_blocs.dart | 1 + .../lib/src/proposals/proposals.dart | 2 + .../lib/src/proposals/proposals_cubit.dart | 62 +++ .../lib/src/proposals/proposals_state.dart | 29 ++ .../test/proposals/proposals_cubit_test.dart | 90 +++++ .../lib/l10n/intl_en.arb | 14 +- .../lib/src/catalyst_voices_repositories.dart | 1 + .../lib/src/proposal/proposal_repository.dart | 61 +++ .../catalyst_voices_repositories/pubspec.yaml | 2 + .../lib/src/campaign/campaign_category.dart | 14 + .../lib/src/campaign/campaign_section.dart | 31 ++ .../lib/src/catalyst_voices_view_models.dart | 4 + .../lib/src/menu/menu_item.dart | 31 ++ .../lib/src/menu/popup_menu_item.dart | 32 ++ .../lib/examples/voices_menu_example.dart | 30 +- .../api/cat-gateway/stoplight_template.html | 7 +- 39 files changed, 1864 insertions(+), 138 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart create mode 100644 catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_blocs/test/proposals/proposals_cubit_test.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 9d6b4663996..4e86429b15d 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -294,6 +294,7 @@ unawaited unchunk Unlogged unmanaged +Unmarks Unstaked upskilling UTXO diff --git a/catalyst_voices/apps/voices/lib/app/view/app.dart b/catalyst_voices/apps/voices/lib/app/view/app.dart index cadb91549c8..d4deeb1894a 100644 --- a/catalyst_voices/apps/voices/lib/app/view/app.dart +++ b/catalyst_voices/apps/voices/lib/app/view/app.dart @@ -45,7 +45,10 @@ class _AppState extends State { create: (_) => Dependencies.instance.get(), ), BlocProvider( - create: (_) => Dependencies.instance.get(), + create: (_) => Dependencies.instance.get(), + ), + BlocProvider( + create: (_) => Dependencies.instance.get(), ), ]; } diff --git a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart index f54e615fcb3..17e6ba3860c 100644 --- a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart +++ b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart @@ -10,10 +10,14 @@ abstract class DateFormatter { /// - Yesterday /// - 2 days ago /// - Other cases: yMMMMd date format. - static String formatRecentDate(VoicesLocalizations l10n, DateTime dateTime) { - final now = DateTimeExt.now(); + static String formatRecentDate( + VoicesLocalizations l10n, + DateTime dateTime, { + DateTime? from, + }) { + from ??= DateTimeExt.now(); - final today = DateTime(now.year, now.month, now.day, 12); + final today = DateTime(from.year, from.month, from.day, 12); if (dateTime.isSameDateAs(today)) return l10n.today; final tomorrow = today.plusDays(1); diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index e93ed705f77..f5ad9db9fb8 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -49,7 +49,10 @@ final class Dependencies extends DependencyProvider { registrationService: get(), progressNotifier: get(), ); - }); + }) + ..registerLazySingleton( + () => ProposalsCubit(proposalRepository: get()), + ); } void _registerRepositories() { @@ -62,6 +65,9 @@ final class Dependencies extends DependencyProvider { ) ..registerLazySingleton( TransactionConfigRepository.new, + ) + ..registerLazySingleton( + ProposalRepository.new, ); } diff --git a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart index 2c3b3385e45..deb0a32412e 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart @@ -155,6 +155,7 @@ class _UnlockPassword extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( controller: controller, + autofocus: true, decoration: VoicesTextFieldDecoration( labelText: context.l10n.unlockDialogHint, errorText: error?.message(context), diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/current_status_text.dart b/catalyst_voices/apps/voices/lib/pages/discovery/current_status_text.dart index 297e9adb42d..03fb1dcc3b0 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/current_status_text.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/current_status_text.dart @@ -5,9 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; // Note. This widget will be removed so its not localized class CurrentUserStatusText extends StatelessWidget { - const CurrentUserStatusText({ - super.key, - }); + const CurrentUserStatusText({super.key}); @override Widget build(BuildContext context) { @@ -15,9 +13,9 @@ class CurrentUserStatusText extends StatelessWidget { final sessionBloc = context.watch(); final stateDesc = switch (sessionBloc.state) { - VisitorSessionState() => 'visitor', - GuestSessionState() => 'guest', - ActiveAccountSessionState() => 'user', + VisitorSessionState() => 'Visitor / no key', + GuestSessionState() => 'Guest / locked', + ActiveAccountSessionState() => 'Actor / unlocked', }; return Text( 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 7049932a0dd..0269fe56dc9 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart @@ -2,18 +2,65 @@ import 'dart:async'; import 'package:catalyst_voices/pages/discovery/current_status_text.dart'; import 'package:catalyst_voices/pages/discovery/toggle_state_text.dart'; +import 'package:catalyst_voices/widgets/cards/pending_proposal_card.dart'; +import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.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:flutter_bloc/flutter_bloc.dart'; -class DiscoveryPage extends StatelessWidget { - const DiscoveryPage({ - super.key, - }); +class DiscoveryPage extends StatefulWidget { + const DiscoveryPage({super.key}); + + @override + State createState() => _DiscoveryPageState(); +} + +class _DiscoveryPageState extends State { + @override + void initState() { + super.initState(); + unawaited(context.read().load()); + } @override Widget build(BuildContext context) { - return CustomScrollView( + return const CustomScrollView( + slivers: [ + _Body(), + _Footer(), + ], + ); + } +} + +class _Body extends StatelessWidget { + const _Body(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return switch (state) { + VisitorSessionState() => const _GuestVisitorBody(), + GuestSessionState() => const _GuestVisitorBody(), + ActiveAccountSessionState() => const _ActiveAccountBody(), + }; + }, + ); + } +} + +class _GuestVisitorBody extends StatelessWidget { + const _GuestVisitorBody(); + + @override + Widget build(BuildContext context) { + return SliverMainAxisGroup( slivers: [ const SliverToBoxAdapter(child: _SpacesNavigationLocation()), SliverPadding( @@ -31,15 +78,6 @@ class DiscoveryPage extends StatelessWidget { ), ), ), - const SliverFillRemaining( - hasScrollBody: false, - child: Column( - children: [ - Spacer(), - StandardLinksPageFooter(), - ], - ), - ), ], ); } @@ -60,9 +98,7 @@ class _SpacesNavigationLocation extends StatelessWidget { } class _Segment extends StatelessWidget { - const _Segment({ - super.key, - }); + const _Segment({super.key}); @override Widget build(BuildContext context) { @@ -103,3 +139,295 @@ class _Segment extends StatelessWidget { ); } } + +class _ActiveAccountBody extends StatelessWidget { + const _ActiveAccountBody(); + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 32) + .add(const EdgeInsets.only(bottom: 32)), + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + const SizedBox(height: 16), + const _Header(), + const SizedBox(height: 40), + const _Tabs(), + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.spaceDiscoveryName, + style: Theme.of(context).textTheme.headlineLarge!.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Text( + context.l10n.discoverySpaceTitle, + style: Theme.of(context).textTheme.displayMedium, + ), + const SizedBox(height: 16), + Text( + context.l10n.discoverySpaceDescription, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 32), + OutlinedButton.icon( + onPressed: () { + // TODO(dtscalac): show campaign details dialog + }, + label: Text(context.l10n.campaignDetails), + icon: VoicesAssets.icons.arrowsExpand.buildIcon(), + ), + ], + ), + ), + ); + } +} + +class _Tabs extends StatelessWidget { + const _Tabs(); + + @override + Widget build(BuildContext context) { + return const DefaultTabController( + length: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TabBar(), + SizedBox(height: 24), + TabBarStackView( + children: [ + _AllProposals(), + _FavoriteProposals(), + ], + ), + SizedBox(height: 12), + ], + ), + ); + } +} + +class _TabBar extends StatelessWidget { + const _TabBar(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => + state is LoadedProposalsState ? state.proposals.length : 0, + builder: (context, proposalsCount) { + return TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: [ + Tab( + text: context.l10n.noOfAllProposals(proposalsCount), + ), + Tab( + child: Row( + children: [ + VoicesAssets.icons.starOutlined.buildIcon(), + const SizedBox(width: 8), + Text(context.l10n.favorites), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _AllProposals extends StatelessWidget { + const _AllProposals(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return switch (state) { + LoadingProposalsState() => const _LoadingProposals(), + LoadedProposalsState(:final proposals, :final favoriteProposals) => + proposals.isEmpty + ? const _EmptyProposals() + : _AllProposalsList( + proposals: proposals, + favoriteProposals: favoriteProposals, + ), + }; + }, + ); + } +} + +class _AllProposalsList extends StatelessWidget { + final List proposals; + final List favoriteProposals; + + const _AllProposalsList({ + required this.proposals, + required this.favoriteProposals, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final proposal in proposals) + PendingProposalCard( + image: _generateImageForProposal(proposal.id), + proposal: proposal, + showStatus: false, + showLastUpdate: false, + showComments: false, + showSegments: false, + isFavorite: favoriteProposals.contains(proposal), + onFavoriteChanged: (isFavorite) async { + if (isFavorite) { + await context + .read() + .onFavoriteProposal(proposal.id); + } else { + await context + .read() + .onUnfavoriteProposal(proposal.id); + } + }, + ), + ], + ); + } +} + +class _FavoriteProposals extends StatelessWidget { + const _FavoriteProposals(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return switch (state) { + LoadingProposalsState() => const _LoadingProposals(), + LoadedProposalsState(:final favoriteProposals) => + favoriteProposals.isEmpty + ? const _EmptyProposals() + : _FavoriteProposalsList( + proposals: favoriteProposals, + ), + }; + }, + ); + } +} + +class _FavoriteProposalsList extends StatelessWidget { + final List proposals; + + const _FavoriteProposalsList({required this.proposals}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final proposal in proposals) + PendingProposalCard( + image: _generateImageForProposal(proposal.id), + proposal: proposal, + showStatus: false, + showLastUpdate: false, + showComments: false, + showSegments: false, + isFavorite: true, + onFavoriteChanged: (isFavorite) async { + if (isFavorite) { + await context + .read() + .onFavoriteProposal(proposal.id); + } else { + await context + .read() + .onUnfavoriteProposal(proposal.id); + } + }, + ), + ], + ); + } +} + +class _LoadingProposals extends StatelessWidget { + const _LoadingProposals(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(64), + child: VoicesCircularProgressIndicator(), + ), + ); + } +} + +class _EmptyProposals extends StatelessWidget { + const _EmptyProposals(); + + @override + Widget build(BuildContext context) { + return EmptyState( + description: context.l10n.discoverySpaceEmptyProposals, + ); + } +} + +class _Footer extends StatelessWidget { + const _Footer(); + + @override + Widget build(BuildContext context) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Column( + children: [ + Spacer(), + StandardLinksPageFooter(), + ], + ), + ); + } +} + +AssetGenImage _generateImageForProposal(String id) { + return id.codeUnits.last.isEven + ? VoicesAssets.images.proposalBackground1 + : VoicesAssets.images.proposalBackground2; +} diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart index fd580e3ac84..8538e239967 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; class GuidanceView extends StatefulWidget { final List guidances; + const GuidanceView(this.guidances, {super.key}); @override diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/pending_proposal_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/pending_proposal_card.dart index 78db52bdffc..f361f010402 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/pending_proposal_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/pending_proposal_card.dart @@ -12,6 +12,10 @@ import 'package:flutter/material.dart'; class PendingProposalCard extends StatelessWidget { final AssetGenImage image; final PendingProposal proposal; + final bool showStatus; + final bool showLastUpdate; + final bool showComments; + final bool showSegments; final bool isFavorite; final ValueChanged? onFavoriteChanged; @@ -19,6 +23,10 @@ class PendingProposalCard extends StatelessWidget { super.key, required this.image, required this.proposal, + this.showStatus = true, + this.showLastUpdate = true, + this.showComments = true, + this.showSegments = true, this.isFavorite = false, this.onFavoriteChanged, }); @@ -29,7 +37,7 @@ class PendingProposalCard extends StatelessWidget { width: 326, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, borderRadius: BorderRadius.circular(12), ), child: Column( @@ -37,6 +45,7 @@ class PendingProposalCard extends StatelessWidget { children: [ _Header( image: image, + showStatus: showStatus, isFavorite: isFavorite, onFavoriteChanged: onFavoriteChanged, ), @@ -51,22 +60,26 @@ class PendingProposalCard extends StatelessWidget { ), const SizedBox(height: 4), _Title(text: proposal.title), - const SizedBox(height: 4), - _LastUpdateDate(dateTime: proposal.lastUpdateDate), + if (showLastUpdate) ...[ + const SizedBox(height: 4), + _LastUpdateDate(dateTime: proposal.lastUpdateDate), + ], const SizedBox(height: 24), _FundsAndComments( funds: proposal.fundsRequested, commentsCount: proposal.commentsCount, + showComments: showComments, ), const SizedBox(height: 24), _Description(text: proposal.description), ], ), ), - _CompletedSegments( - completed: proposal.completedSegments, - total: proposal.totalSegments, - ), + if (showSegments) + _CompletedSegments( + completed: proposal.completedSegments, + total: proposal.totalSegments, + ), ], ), ); @@ -75,11 +88,13 @@ class PendingProposalCard extends StatelessWidget { class _Header extends StatelessWidget { final AssetGenImage image; + final bool showStatus; final bool isFavorite; final ValueChanged? onFavoriteChanged; const _Header({ required this.image, + required this.showStatus, required this.isFavorite, required this.onFavoriteChanged, }); @@ -112,18 +127,19 @@ class _Header extends StatelessWidget { ), ), ), - Positioned( - left: 12, - bottom: 12, - child: VoicesChip.rectangular( - padding: const EdgeInsets.fromLTRB(10, 6, 10, 4), - leading: VoicesAssets.icons.briefcase.buildIcon( - color: Theme.of(context).colorScheme.primary, + if (showStatus) + Positioned( + left: 12, + bottom: 12, + child: VoicesChip.rectangular( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 4), + leading: VoicesAssets.icons.briefcase.buildIcon( + color: Theme.of(context).colorScheme.primary, + ), + content: Text(context.l10n.publishedProposal), + backgroundColor: Theme.of(context).colors.primary98, ), - content: Text(context.l10n.publishedProposal), - backgroundColor: Theme.of(context).colors.primary98, ), - ), ], ), ); @@ -193,10 +209,12 @@ class _LastUpdateDate extends StatelessWidget { class _FundsAndComments extends StatelessWidget { final Coin funds; final int commentsCount; + final bool showComments; const _FundsAndComments({ required this.funds, required this.commentsCount, + required this.showComments, }); @override @@ -222,19 +240,21 @@ class _FundsAndComments extends StatelessWidget { ), ], ), - VoicesChip.rectangular( - padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), - leading: VoicesAssets.icons.checkCircle.buildIcon( - color: Theme.of(context).colorScheme.primary, - ), - content: Text( - context.l10n.noOfComments(commentsCount), - style: TextStyle( + if (showComments) + VoicesChip.rectangular( + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), + leading: VoicesAssets.icons.checkCircle.buildIcon( color: Theme.of(context).colorScheme.primary, ), + content: Text( + context.l10n.noOfComments(commentsCount), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + backgroundColor: + Theme.of(context).colors.onSurfaceNeutralOpaqueLv1, ), - backgroundColor: Theme.of(context).colors.onSurfaceNeutralOpaqueLv1, - ), ], ), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart new file mode 100644 index 00000000000..a2bb1e959b1 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart @@ -0,0 +1,77 @@ +import 'package:catalyst_voices/widgets/images/voices_image_scheme.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:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final String? title; + final String? description; + final Widget? image; + final Widget? imageBackground; + + const EmptyState({ + super.key, + this.title, + this.description, + this.image, + this.imageBackground, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + image ?? + VoicesImagesScheme( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + background: imageBackground ?? + Container( + height: 180, + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutral08, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: 430, + child: Column( + children: [ + Text( + _buildTitle(context), + style: textTheme.titleMedium + ?.copyWith(color: theme.colors.textOnPrimaryLevel1), + ), + const SizedBox(height: 8), + Text( + _buildDescription(context), + style: textTheme.bodyMedium + ?.copyWith(color: theme.colors.textOnPrimaryLevel1), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _buildDescription(BuildContext context) { + return description ?? context.l10n.noProposalStateDescription; + } + + String _buildTitle(BuildContext context) { + return title ?? context.l10n.noProposalStateTitle; + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart new file mode 100644 index 00000000000..ddf0bf9bce3 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class VoicesImagesScheme extends StatelessWidget { + final Widget image; + final Widget? background; + + const VoicesImagesScheme({ + super.key, + required this.image, + required this.background, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + if (background != null) background!, + image, + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart index 0a98b997093..178e9bbccc8 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; /// A menu of the app that @@ -68,7 +69,7 @@ class _MenuButton extends StatelessWidget { final textStyle = textTheme.bodyMedium?.copyWith( color: - menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, + menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, ); final children = menuChildren; @@ -85,7 +86,7 @@ class _MenuButton extends StatelessWidget { child: IconTheme( data: IconThemeData( size: 24, - color: menuItem.enabled + color: menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, ), @@ -138,32 +139,29 @@ class _MenuButton extends StatelessWidget { } /// Model representing Menu Item -class MenuItem { - final int id; - final String label; - final Widget? icon; - final bool showDivider; - final bool enabled; - - MenuItem({ - required this.id, - required this.label, - this.icon, - this.showDivider = false, - this.enabled = true, +final class MenuItem extends BasicPopupMenuItem { + const MenuItem({ + required super.id, + required super.label, + super.isEnabled = true, + super.icon, + super.showDivider = false, }); } /// Model representing Submenu Item /// and extending from MenuItem -class SubMenuItem extends MenuItem { - List children; +final class SubMenuItem extends MenuItem { + final List children; - SubMenuItem({ + const SubMenuItem({ required super.id, required super.label, required this.children, super.icon, super.showDivider, }); + + @override + List get props => super.props + [children]; } diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart new file mode 100644 index 00000000000..61b24038779 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart @@ -0,0 +1,179 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class VoicesModalMenu extends StatelessWidget { + final String? selectedId; + final List menuItems; + final ValueChanged? onTap; + + const VoicesModalMenu({ + super.key, + this.selectedId, + required this.menuItems, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final onTap = this.onTap; + + return Column( + mainAxisSize: MainAxisSize.min, + children: menuItems + .map( + (item) { + return _VoicesModalMenuItemTile( + key: ValueKey('VoicesModalMenu[${item.id}]Key'), + label: item.label, + isSelected: selectedId == item.id, + isEnabled: item.isEnabled, + onTap: onTap != null ? () => onTap(item.id) : null, + ); + }, + ) + .separatedBy(const SizedBox(height: 8)) + .toList(), + ); + } +} + +class _VoicesModalMenuItemTile extends StatefulWidget { + final String label; + final bool isSelected; + final bool isEnabled; + final VoidCallback? onTap; + + const _VoicesModalMenuItemTile({ + required super.key, + required this.label, + required this.isSelected, + required this.isEnabled, + this.onTap, + }); + + @override + State<_VoicesModalMenuItemTile> createState() { + return _VoicesModalMenuItemTileState(); + } +} + +class _VoicesModalMenuItemTileState extends State<_VoicesModalMenuItemTile> { + late _BackgroundColor _backgroundColor; + late _ForegroundColor _foregroundColor; + late _LabelTextStyle _labelTextStyle; + late _BorderColor _border; + + Set get _states => { + if (!widget.isEnabled) WidgetState.disabled, + if (widget.isSelected) WidgetState.selected, + }; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final theme = Theme.of(context); + + _backgroundColor = _BackgroundColor(theme.colorScheme.brightness); + _foregroundColor = _ForegroundColor(theme.colors); + _labelTextStyle = _LabelTextStyle(theme.textTheme); + _border = _BorderColor(theme.colorScheme.brightness); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: widget.isEnabled ? widget.onTap : null, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + constraints: const BoxConstraints(minWidth: 320), + decoration: BoxDecoration( + color: _backgroundColor.resolve(_states), + border: _border.resolve(_states), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16) + .add(const EdgeInsets.only(bottom: 2)), + child: Text( + widget.label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: _labelTextStyle + .resolve(_states) + .copyWith(color: _foregroundColor.resolve(_states)), + ), + ), + ); + } +} + +class _BackgroundColor extends WidgetStateProperty { + final Brightness _brightness; + + _BackgroundColor(this._brightness); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.selected)) { + // TODO(damian-molinski): Those colors are not using properties. + // TODO(damian-molinski): Dark/Transparent/On primary surface P40 016 + // TODO(damian-molinski): Light/Transparent/On surface P40 08 + return switch (_brightness) { + Brightness.dark => const Color(0x29123cd3), + Brightness.light => const Color(0x1f123cd3), + }; + } + + return null; + } +} + +class _ForegroundColor extends WidgetStateProperty { + final VoicesColorScheme _colors; + + _ForegroundColor(this._colors); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.disabled)) { + return _colors.textDisabled; + } + + return _colors.textOnPrimaryLevel1; + } +} + +class _LabelTextStyle extends WidgetStateProperty { + final TextTheme _textTheme; + + _LabelTextStyle(this._textTheme); + + @override + TextStyle resolve(Set states) { + if (states.contains(WidgetState.selected)) { + return _textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold); + } + + return _textTheme.bodyLarge!; + } +} + +class _BorderColor extends WidgetStateProperty { + final Brightness _brightness; + + _BorderColor(this._brightness); + + @override + BoxBorder resolve(Set states) { + // TODO(damian-molinski): Those colors are not using properties. + // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 + // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 + return switch (_brightness) { + Brightness.dark => Border.all(color: const Color(0x1fbfc8d9)), + Brightness.light => Border.all(color: const Color(0x14212a3d)), + }; + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart new file mode 100644 index 00000000000..644ffcb9438 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart @@ -0,0 +1,165 @@ +import 'package:catalyst_voices/widgets/menu/voices_modal_menu.dart'; +import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class CampaignCategoriesTile extends StatefulWidget { + final List sections; + + const CampaignCategoriesTile({ + super.key, + required this.sections, + }); + + @override + State createState() => _CampaignCategoriesTileState(); +} + +class _CampaignCategoriesTileState extends State { + String? _selectedSectionId; + + @override + void initState() { + super.initState(); + + _selectedSectionId = widget.sections.firstOrNull?.id; + } + + @override + void didUpdateWidget(covariant CampaignCategoriesTile oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!listEquals(widget.sections, oldWidget.sections)) { + if (!widget.sections.any((element) => element.id == _selectedSectionId)) { + _selectedSectionId = widget.sections.firstOrNull?.id; + } + } + } + + @override + Widget build(BuildContext context) { + final selectedSection = widget.sections + .singleWhereOrNull((element) => element.id == _selectedSectionId); + + return VoicesExpansionTile( + initiallyExpanded: true, + title: Text(context.l10n.campaignCategories), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Menu( + selectedId: _selectedSectionId, + menuItems: widget.sections, + onTap: _updateSelection, + ), + const SizedBox(width: 32), + Expanded( + child: selectedSection != null + ? _Details(section: selectedSection) + : const SizedBox(), + ), + ], + ), + ], + ); + } + + void _updateSelection(String id) { + setState(() { + _selectedSectionId = id; + }); + } +} + +class _Menu extends StatelessWidget { + final String? selectedId; + final List menuItems; + final ValueChanged onTap; + + const _Menu({ + this.selectedId, + required this.menuItems, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + context.l10n.cardanoUseCases, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 12), + VoicesModalMenu( + selectedId: selectedId, + menuItems: menuItems, + onTap: onTap, + ), + const SizedBox(height: 16), + ], + ); + } +} + +class _Details extends StatelessWidget { + final CampaignSection section; + + const _Details({ + required this.section, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + Text( + section.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.headlineMedium?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 24), + Text( + section.title, + style: textTheme.titleLarge?.copyWith( + color: colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 16), + Text( + section.body, + style: textTheme.bodyLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 32), + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart new file mode 100644 index 00000000000..f07a02a6e4e --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart @@ -0,0 +1,219 @@ +import 'package:catalyst_voices/common/formatters/date_formatter.dart'; +import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.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'; + +class CampaignDetailsTile extends StatelessWidget { + final String description; + final DateTime publishDate; + final DateTime startDate; + final DateTime endDate; + final int categoriesCount; + final int proposalsCount; + + const CampaignDetailsTile({ + super.key, + required this.description, + required this.publishDate, + required this.startDate, + required this.endDate, + required this.categoriesCount, + required this.proposalsCount, + }); + + @override + Widget build(BuildContext context) { + return VoicesExpansionTile( + initiallyExpanded: true, + title: Text(context.l10n.campaignDetails), + children: [ + _Body( + description: description, + ), + const SizedBox(height: 16 + 24), + _CampaignData( + publishDate: publishDate, + startDate: startDate, + endDate: endDate, + categoriesCount: categoriesCount, + proposalsCount: proposalsCount, + ), + ], + ); + } +} + +class _Body extends StatelessWidget { + final String description; + + const _Body({ + required this.description, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel1, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 14), + Text( + description, + style: textTheme.bodyLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + ], + ); + } +} + +class _CampaignData extends StatelessWidget { + final DateTime publishDate; + final DateTime startDate; + final DateTime endDate; + final int categoriesCount; + final int proposalsCount; + + const _CampaignData({ + required this.publishDate, + required this.startDate, + required this.endDate, + required this.categoriesCount, + required this.proposalsCount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colors; + final l10n = context.l10n; + + return Container( + decoration: BoxDecoration( + color: colors.onSurfacePrimary012, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + _CampaignDataTile( + key: const ValueKey('StartDateTileKey'), + title: l10n.startDate, + subtitle: DateFormatter.formatInDays(l10n, startDate), + value: startDate.day, + valueSuffix: DateFormatter.formatShortMonth(l10n, startDate), + ), + _CampaignDataTile( + key: const ValueKey('EndDateTileKey'), + title: l10n.endDate, + subtitle: DateFormatter.formatInDays(l10n, endDate), + value: endDate.day, + valueSuffix: DateFormatter.formatShortMonth(l10n, endDate), + ), + _CampaignDataTile( + key: const ValueKey('CategoriesTileKey'), + title: l10n.categories, + subtitle: DateFormatter.formatInDays( + l10n, + DateTime.now(), + from: publishDate, + ), + value: categoriesCount, + ), + _CampaignDataTile( + key: const ValueKey('ProposalsTileKey'), + title: l10n.proposals, + subtitle: l10n.totalSubmitted, + value: proposalsCount, + ), + ] + .map((e) => Expanded(child: e)) + .separatedBy(const SizedBox(width: 16)) + .toList(), + ), + ); + } +} + +class _CampaignDataTile extends StatelessWidget { + final String title; + final String subtitle; + final int value; + final String? valueSuffix; + + const _CampaignDataTile({ + super.key, + required this.title, + required this.subtitle, + required this.value, + this.valueSuffix, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + // TODO(damian-molinski): This color does not have property. + // Colors/sys color neutral md ref/N60 + color: const Color(0xFF7F90B3), + ), + ), + const SizedBox(height: 16), + Row( + textBaseline: TextBaseline.alphabetic, + crossAxisAlignment: valueSuffix != null + ? CrossAxisAlignment.baseline + : CrossAxisAlignment.end, + children: [ + Text( + '$value', + style: textTheme.headlineLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + if (valueSuffix != null) ...[ + const SizedBox(width: 4), + Text( + valueSuffix!, + style: textTheme.titleMedium?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + ], + ], + ), + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart index e7c20a7810b..190bfaa79b4 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart @@ -19,6 +19,9 @@ final class VoicesPasswordTextField extends StatelessWidget { /// Optional decoration. See [VoicesTextField] for more details. final VoicesTextFieldDecoration? decoration; + /// [VoicesTextField.autofocus]. + final bool autofocus; + const VoicesPasswordTextField({ super.key, this.controller, @@ -26,6 +29,7 @@ final class VoicesPasswordTextField extends StatelessWidget { this.onChanged, this.onSubmitted, this.decoration, + this.autofocus = false, }); @override @@ -33,6 +37,7 @@ final class VoicesPasswordTextField extends StatelessWidget { return VoicesTextField( controller: controller, keyboardType: TextInputType.visiblePassword, + autofocus: autofocus, obscureText: true, textInputAction: textInputAction, onChanged: onChanged, diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index 31220324918..e63a838bf80 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -32,6 +32,9 @@ class VoicesTextField extends StatefulWidget { /// [TextField.style] final TextStyle? style; + /// [TextField.autofocus] + final bool autofocus; + /// [TextField.obscureText] final bool obscureText; @@ -77,6 +80,7 @@ class VoicesTextField extends StatefulWidget { this.textInputAction, this.textCapitalization = TextCapitalization.none, this.style, + this.autofocus = false, this.obscureText = false, this.maxLength, this.maxLines = 1, @@ -176,6 +180,7 @@ class _VoicesTextFieldState extends State { resizableVertically: resizable, child: TextFormField( textAlignVertical: TextAlignVertical.top, + autofocus: widget.autofocus, expands: resizable, controller: _obtainController(), focusNode: widget.focusNode, diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart new file mode 100644 index 00000000000..46aca16a6a8 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart @@ -0,0 +1,107 @@ +import 'package:catalyst_voices/widgets/buttons/voices_buttons.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesExpansionTile extends StatefulWidget { + final Widget title; + final List children; + final bool initiallyExpanded; + + const VoicesExpansionTile({ + super.key, + required this.title, + this.children = const [], + this.initiallyExpanded = false, + }); + + @override + State createState() => _VoicesExpansionTileState(); +} + +class _VoicesExpansionTileState extends State { + final _controller = ExpansionTileController(); + + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _isExpanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return _ThemeOverride( + child: Builder( + builder: (context) { + final theme = Theme.of(context); + + return ExpansionTile( + title: DefaultTextStyle( + style: theme.textTheme.titleLarge ?? const TextStyle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: widget.title, + ), + trailing: ChevronExpandButton( + isExpanded: _isExpanded, + onTap: _toggleExpand, + ), + controller: _controller, + initiallyExpanded: _isExpanded, + onExpansionChanged: _updateExpended, + children: widget.children, + ); + }, + ), + ); + } + + void _updateExpended(bool value) { + setState(() { + _isExpanded = value; + }); + } + + void _toggleExpand() { + if (_controller.isExpanded) { + _controller.collapse(); + } else { + _controller.expand(); + } + } +} + +class _ThemeOverride extends StatelessWidget { + final Widget child; + + const _ThemeOverride({ + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Theme( + data: theme.copyWith( + // listTileTheme is required here because ExpansionTile does not let + // us set shape or ripple used internally by ListTile. + listTileTheme: const ListTileThemeData(shape: RoundedRectangleBorder()), + expansionTileTheme: ExpansionTileThemeData( + backgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, + collapsedBackgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, + tilePadding: const EdgeInsets.fromLTRB(24, 8, 12, 8), + childrenPadding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + textColor: theme.colors.textOnPrimaryLevel1, + collapsedTextColor: theme.colors.textOnPrimaryLevel1, + iconColor: theme.colors.iconsForeground, + collapsedIconColor: theme.colors.iconsForeground, + shape: const RoundedRectangleBorder(), + collapsedShape: const RoundedRectangleBorder(), + ), + ), + child: child, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d3ad43f3ba5..a43e5437a5a 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -46,6 +46,7 @@ export 'indicators/voices_status_indicator.dart'; export 'list/bullet_list.dart'; export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; +export 'menu/voices_modal_menu.dart'; export 'menu/voices_node_menu.dart'; export 'menu/voices_wallet_tile.dart'; export 'modals/voices_alert_dialog.dart'; @@ -71,6 +72,7 @@ export 'text_field/voices_autocomplete.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; +export 'tiles/voices_expansion_tile.dart'; export 'tiles/voices_nav_tile.dart'; export 'toggles/voices_checkbox.dart'; export 'toggles/voices_checkbox_group.dart'; diff --git a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart index 0b163672d45..a07b0dee89f 100644 --- a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart +++ b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart @@ -1,53 +1,94 @@ import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations_en.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; -class _FakeVoicesLocalizations extends Fake implements VoicesLocalizations { - @override - String get today => 'Today'; - @override - String get tomorrow => 'Tomorrow'; - @override - String get yesterday => 'Yesterday'; - @override - String get twoDaysAgo => '2 days ago'; -} - void main() { group(DateFormatter, () { - final l10n = _FakeVoicesLocalizations(); + final l10n = VoicesLocalizationsEn(); - test('should return "Today" for today\'s date', () { - final today = DateTimeExt.now(); - final result = DateFormatter.formatRecentDate(l10n, today); - expect(result, l10n.today); - }); + group('formatRecentDate', () { + test('should return "Today" for today\'s date', () { + final today = DateTimeExt.now(); + final result = DateFormatter.formatRecentDate(l10n, today); + expect(result, l10n.today); + }); - test('should return "Tomorrow" for tomorrow\'s date', () { - final tomorrow = DateTimeExt.now().plusDays(1); - final result = DateFormatter.formatRecentDate(l10n, tomorrow); - expect(result, l10n.tomorrow); - }); + test('should return "Tomorrow" for tomorrow\'s date', () { + final tomorrow = DateTimeExt.now().plusDays(1); + final result = DateFormatter.formatRecentDate(l10n, tomorrow); + expect(result, l10n.tomorrow); + }); - test('should return "Yesterday" for yesterday\'s date', () { - final yesterday = DateTimeExt.now().minusDays(1); - final result = DateFormatter.formatRecentDate(l10n, yesterday); - expect(result, l10n.yesterday); - }); + test('should return "Yesterday" for yesterday\'s date', () { + final yesterday = DateTimeExt.now().minusDays(1); + final result = DateFormatter.formatRecentDate(l10n, yesterday); + expect(result, l10n.yesterday); + }); + + test('should return "2 days ago" for a date 2 days ago', () { + final twoDaysAgo = DateTimeExt.now().minusDays(2); + final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); + expect(result, l10n.twoDaysAgo); + }); - test('should return "2 days ago" for a date 2 days ago', () { - final twoDaysAgo = DateTimeExt.now().minusDays(2); - final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); - expect(result, l10n.twoDaysAgo); + test('should return formatted date for older dates', () { + final pastDate = DateTimeExt.now().minusDays(10); + final result = DateFormatter.formatRecentDate(l10n, pastDate); + final expectedFormat = DateFormat.yMMMMd().format(pastDate); + expect(result, expectedFormat); + }); }); - test('should return formatted date for older dates', () { - final pastDate = DateTimeExt.now().minusDays(10); - final result = DateFormatter.formatRecentDate(l10n, pastDate); - final expectedFormat = DateFormat.yMMMMd().format(pastDate); - expect(result, expectedFormat); + group('formatInDays', () { + test('returns 20 days when in comparing to 20 days in future', () { + // Given + final publishDate = DateTime(2024, 11, 20); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 20 days'); + }); + + test('returns 0 days when in comparing to past', () { + // Given + final publishDate = DateTime(2024, 2, 10); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 0 days'); + }); + + test('returns 1 day when in comparing to 1 day in future', () { + // Given + final publishDate = DateTime(2024, 11, 1); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 1 day'); + }); }); }); } diff --git a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart new file mode 100644 index 00000000000..d19e3df0b8b --- /dev/null +++ b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart @@ -0,0 +1,131 @@ +import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; +import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('EmptyState Widget Tests', () { + testWidgets('Renders correctly with default values', (tester) async { + await tester.pumpApp( + const EmptyState(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.byType(Text), findsNWidgets(2)); + expect(find.text('No draft proposals yet'), findsOneWidget); + expect( + find.text( + // ignore: lines_longer_than_80_chars + 'Discovery space will show draft proposals you can comment on, currently there are no draft proposals.', + ), + findsOneWidget, + ); + }); + + testWidgets('Renders correctly with custom values', (tester) async { + await tester.pumpApp( + const EmptyState( + title: 'Custom Title', + description: 'Custom Description', + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.text('Custom Title'), findsOneWidget); + expect(find.text('Custom Description'), findsOneWidget); + }); + + testWidgets('Uses correct custom color scheme', (tester) async { + const colors = + VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); + await tester.pumpApp( + voicesColors: colors, + const EmptyState( + title: 'Custom Title', + description: 'Custom Description', + ), + ); + await tester.pumpAndSettle(); + + final titleText = tester.widget( + find.byType(Text).first, + ); + + expect( + titleText.style?.color, + colors.textOnPrimaryLevel1, + ); + + final descriptionText = tester.widget( + find.byType(Text).last, + ); + + expect( + descriptionText.style?.color, + colors.textOnPrimaryLevel1, + ); + }); + + testWidgets( + 'Proposal image changes depending on theme brightness', + (tester) async { + // Given + const widget = EmptyState(); + + // When - Light theme + await tester.pumpApp( + widget, + theme: ThemeData(brightness: Brightness.light), + voicesColors: const VoicesColorScheme.optional(), + ); + await tester.pumpAndSettle(); + + // Then - Light theme + final lightThemeImage = tester.widget( + find.byType(CatalystSvgPicture), + ); + expect( + lightThemeImage, + isA(), + ); + + // When - Dark theme + await tester.pumpApp( + widget, + theme: ThemeData(brightness: Brightness.dark), + voicesColors: const VoicesColorScheme.optional(), + ); + await tester.pumpAndSettle(); + + // Then - Dark theme + final darkThemeImage = tester.widget( + find.byType(CatalystSvgPicture), + ); + expect( + darkThemeImage, + isA(), + ); + }, + ); + + testWidgets('Renders correctly with custom image', (tester) async { + await tester.pumpApp( + EmptyState( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.byType(VoicesImagesScheme), findsNothing); + }); + }); +} diff --git a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart index 4ceb87006f3..2e09d8fb64a 100644 --- a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart @@ -9,34 +9,34 @@ import '../../helpers/helpers.dart'; void main() { final menu = [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 2, + id: '2', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ + children: const [ MenuItem( - id: 3, + id: '3', label: 'Team 1: The Vikings', ), MenuItem( - id: 4, + id: '4', label: 'Team 2: Pure Hearts', ), ], ), MenuItem( - id: 5, + id: '5', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - enabled: false, + isEnabled: false, ), MenuItem( - id: 6, + id: '6', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg new file mode 100644 index 00000000000..df9c9749f62 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 12654d8b2f3..560d23a636e 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 @@ -2,5 +2,6 @@ export 'authentication/authentication.dart'; export 'bloc_error_emitter_mixin.dart'; export 'brand/brand.dart'; export 'login/login.dart'; +export 'proposals/proposals.dart'; export 'registration/registration.dart'; export 'session/session.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals.dart new file mode 100644 index 00000000000..d073c3c941c --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals.dart @@ -0,0 +1,2 @@ +export 'proposals_cubit.dart'; +export 'proposals_state.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart new file mode 100644 index 00000000000..850b222325c --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:catalyst_voices_blocs/src/proposals/proposals_state.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Manages the proposals. +final class ProposalsCubit extends Cubit { + final ProposalRepository proposalRepository; + + ProposalsCubit({required this.proposalRepository}) + : super(const LoadingProposalsState()); + + /// Loads the proposals. + Future load() async { + final proposals = await proposalRepository.getDraftProposals(); + + emit( + LoadedProposalsState( + proposals: proposals, + favoriteProposals: const [], + ), + ); + } + + /// Marks the proposal with [proposalId] as favorite. + Future onFavoriteProposal(String proposalId) async { + final loadedState = state; + if (loadedState is! LoadedProposalsState) return; + + final proposals = loadedState.proposals; + final favoriteProposal = + proposals.firstWhereOrNull((e) => e.id == proposalId); + if (favoriteProposal == null) return; + + emit( + LoadedProposalsState( + proposals: loadedState.proposals, + favoriteProposals: [ + ...loadedState.favoriteProposals, + favoriteProposal, + ], + ), + ); + } + + /// Unmarks the proposal with [proposalId] as favorite. + Future onUnfavoriteProposal(String proposalId) async { + final loadedState = state; + if (loadedState is! LoadedProposalsState) return; + + emit( + LoadedProposalsState( + proposals: loadedState.proposals, + favoriteProposals: loadedState.favoriteProposals + .whereNot((e) => e.id == proposalId) + .toList(), + ), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart new file mode 100644 index 00000000000..bfc277b949d --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart @@ -0,0 +1,29 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// The state of available proposals. +sealed class ProposalsState extends Equatable { + const ProposalsState(); +} + +/// The proposals are loading. +final class LoadingProposalsState extends ProposalsState { + const LoadingProposalsState(); + + @override + List get props => []; +} + +/// The loaded proposals. +final class LoadedProposalsState extends ProposalsState { + final List proposals; + final List favoriteProposals; + + const LoadedProposalsState({ + required this.proposals, + required this.favoriteProposals, + }); + + @override + List get props => [proposals, favoriteProposals]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/proposals/proposals_cubit_test.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/proposals/proposals_cubit_test.dart new file mode 100644 index 00000000000..21c152caefe --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/proposals/proposals_cubit_test.dart @@ -0,0 +1,90 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.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'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ProposalsCubit, () { + final proposal = PendingProposal( + id: '1', + title: 'Proposal 1', + description: 'Description 1', + fund: 'F14', + category: '', + lastUpdateDate: DateTime.now(), + fundsRequested: const Coin(100000), + commentsCount: 0, + completedSegments: 1, + totalSegments: 3, + ); + + blocTest( + 'initial state is $LoadingProposalsState', + build: () { + return ProposalsCubit( + proposalRepository: _FakeProposalRepository([]), + ); + }, + verify: (cubit) { + expect(cubit.state, equals(const LoadingProposalsState())); + }, + ); + + blocTest( + 'load emits $LoadedProposalsState with proposals', + build: () { + return ProposalsCubit( + proposalRepository: _FakeProposalRepository([proposal]), + ); + }, + act: (cubit) async => cubit.load(), + expect: () => [ + LoadedProposalsState( + proposals: [proposal], + favoriteProposals: const [], + ), + ], + ); + + blocTest( + 'onFavoriteProposal / onUnfavoriteProposal adds/removes proposal from favorites', + build: () { + return ProposalsCubit( + proposalRepository: _FakeProposalRepository([proposal]), + ); + }, + act: (cubit) async { + await cubit.load(); + await cubit.onFavoriteProposal(proposal.id); + await cubit.onUnfavoriteProposal(proposal.id); + }, + expect: () => [ + LoadedProposalsState( + proposals: [proposal], + favoriteProposals: const [], + ), + LoadedProposalsState( + proposals: [proposal], + favoriteProposals: [proposal], + ), + LoadedProposalsState( + proposals: [proposal], + favoriteProposals: const [], + ), + ], + ); + }); +} + +class _FakeProposalRepository extends Fake implements ProposalRepository { + final List proposals; + + _FakeProposalRepository(this.proposals); + + @override + Future> getDraftProposals() async { + return proposals; + } +} 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 071d24efd27..e9ea58be2e2 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 @@ -1055,5 +1055,17 @@ } }, "campaignCategories": "Campaign Categories", - "cardanoUseCases": "Cardano Use Cases" + "cardanoUseCases": "Cardano Use Cases", + "discoverySpaceTitle": "Boost Social Entrepreneurship", + "@discoverySpaceTitle": { + "description": "Title for the discovery space for actor (unlocked) user." + }, + "discoverySpaceDescription": "Project Catalyst is built on the ingenuity of our global network. Ideas can come from anyone, from any background, anywhere in the world. Proposers pitch their ideas to the community by submitting proposals onto the Catalyst collaboration platform.", + "@discoverySpaceDescription": { + "description": "Description for the discovery space for actor (unlocked) user." + }, + "discoverySpaceEmptyProposals": "Once this campaign launches draft proposals will be shared here.", + "@discoverySpaceEmptyProposals": { + "description": "Description for empty state on discovery space when there are no draft proposals." + } } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart index e5729b5689a..7e8039c5fa1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart @@ -1,3 +1,4 @@ export 'authentication_repository.dart'; export 'credentials_storage_repository.dart'; +export 'proposal/proposal_repository.dart'; export 'transaction/transaction_config_repository.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart new file mode 100644 index 00000000000..abd84a0dd22 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart @@ -0,0 +1,61 @@ +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; + +class ProposalRepository { + const ProposalRepository(); + + /// Fetches all draft proposals. + Future> getDraftProposals() async { + // simulate network delay + await Future.delayed(const Duration(seconds: 1)); + return _proposals; + } +} + +final _proposalDescription = """ +Zanzibar is becoming one of the hotspots for DID's through +World Mobile and PRISM, but its potential is only barely exploited. +Zanzibar is becoming one of the hotspots for DID's through World Mobile +and PRISM, but its potential is only barely exploited. +""" + .replaceAll('\n', ' '); + +final _proposals = [ + PendingProposal( + id: 'f14/0', + fund: 'F14', + category: 'Cardano Use Cases / MVP', + title: 'Proposal Title that rocks the world', + lastUpdateDate: DateTime.now().minusDays(2), + fundsRequested: Coin.fromAda(100000), + commentsCount: 0, + description: _proposalDescription, + completedSegments: 0, + totalSegments: 13, + ), + PendingProposal( + id: 'f14/1', + fund: 'F14', + category: 'Cardano Use Cases / MVP', + title: 'Proposal Title that rocks the world', + lastUpdateDate: DateTime.now().minusDays(2), + fundsRequested: Coin.fromAda(100000), + commentsCount: 0, + description: _proposalDescription, + completedSegments: 7, + totalSegments: 13, + ), + PendingProposal( + id: 'f14/2', + fund: 'F14', + category: 'Cardano Use Cases / MVP', + title: 'Proposal Title that rocks the world', + lastUpdateDate: DateTime.now().minusDays(2), + fundsRequested: Coin.fromAda(100000), + commentsCount: 0, + description: _proposalDescription, + completedSegments: 13, + totalSegments: 13, + ), +]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml index a9f17992966..62e5a463212 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: path: ../catalyst_voices_models catalyst_voices_services: path: ../catalyst_voices_services + catalyst_voices_shared: + path: ../catalyst_voices_shared chopper: ^7.2.0 flutter: sdk: flutter diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart new file mode 100644 index 00000000000..fd2ca82d607 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +final class CampaignCategory extends Equatable { + final String id; + final String name; + + const CampaignCategory({ + required this.id, + required this.name, + }); + + @override + List get props => [id, name]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart new file mode 100644 index 00000000000..0e8a346e34f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignSection extends Equatable implements MenuItem { + @override + final String id; + final CampaignCategory category; + final String title; + final String body; + + const CampaignSection({ + required this.id, + required this.category, + required this.title, + required this.body, + }); + + @override + String get label => category.name; + + @override + bool get isEnabled => true; + + @override + List get props => [ + id, + category, + title, + body, + ]; +} 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 f9fc437926d..2b86a26c535 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,6 +1,10 @@ export 'authentication/authentication.dart'; +export 'campaign/campaign_category.dart'; +export 'campaign/campaign_section.dart'; export 'exception/localized_exception.dart'; export 'exception/localized_unknown_exception.dart'; +export 'menu/menu_item.dart'; +export 'menu/popup_menu_item.dart'; export 'navigation/sections_list_view_item.dart'; export 'navigation/sections_navigation.dart'; export 'proposal/comment.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart new file mode 100644 index 00000000000..1481dba245e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +abstract interface class MenuItem { + String get id; + + String get label; + + bool get isEnabled; +} + +base class BasicMenuItem extends Equatable implements MenuItem { + @override + final String id; + @override + final String label; + @override + final bool isEnabled; + + const BasicMenuItem({ + required this.id, + required this.label, + this.isEnabled = true, + }); + + @override + List get props => [ + id, + label, + isEnabled, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart new file mode 100644 index 00000000000..9299e44e0f5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart @@ -0,0 +1,32 @@ +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/widgets.dart'; + +abstract interface class PopupMenuItem implements MenuItem { + Widget? get icon; + + bool get showDivider; +} + +base class BasicPopupMenuItem extends BasicMenuItem implements PopupMenuItem { + @override + final Widget? icon; + + @override + final bool showDivider; + + const BasicPopupMenuItem({ + required super.id, + required super.label, + super.isEnabled, + this.icon, + this.showDivider = false, + }); + + @override + List get props => + super.props + + [ + icon, + showDivider, + ]; +} diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart index 3dcfa8fb0fa..b85aa136885 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart @@ -71,28 +71,28 @@ class _MenuExample1 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 4, + id: '4', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ - MenuItem(id: 5, label: 'Team 1: The Vikings'), - MenuItem(id: 6, label: 'Team 2: Pure Hearts'), + children: const [ + MenuItem(id: '5', label: 'Team 1: The Vikings'), + MenuItem(id: '6', label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: 2, + id: '2', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - enabled: false, + isEnabled: false, ), MenuItem( - id: 3, + id: '3', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), @@ -118,27 +118,27 @@ class _MenuExample2 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 4, + id: '4', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ - MenuItem(id: 5, label: 'Team 1: The Vikings'), - MenuItem(id: 6, label: 'Team 2: Pure Hearts'), + children: const [ + MenuItem(id: '5', label: 'Team 1: The Vikings'), + MenuItem(id: '6', label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: 2, + id: '2', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, ), MenuItem( - id: 3, + id: '3', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/docs/src/api/cat-gateway/stoplight_template.html b/docs/src/api/cat-gateway/stoplight_template.html index d0c263bc938..582d1db5a62 100644 --- a/docs/src/api/cat-gateway/stoplight_template.html +++ b/docs/src/api/cat-gateway/stoplight_template.html @@ -22,10 +22,9 @@ Catalyst Gateway Rust Docs - - + +