diff --git a/catalyst_voices/apps/voices/lib/common/ext/build_context_ext.dart b/catalyst_voices/apps/voices/lib/common/ext/build_context_ext.dart new file mode 100644 index 00000000000..d081573369e --- /dev/null +++ b/catalyst_voices/apps/voices/lib/common/ext/build_context_ext.dart @@ -0,0 +1,9 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +extension BuildContextThemeExt on BuildContext { + ThemeData get theme => Theme.of(this); + TextTheme get textTheme => theme.textTheme; + ColorScheme get colorScheme => theme.colorScheme; + VoicesColorScheme get colors => theme.colors; +} diff --git a/catalyst_voices/apps/voices/lib/common/ext/ext.dart b/catalyst_voices/apps/voices/lib/common/ext/ext.dart index c02a9339d66..ba03fcf4ef1 100644 --- a/catalyst_voices/apps/voices/lib/common/ext/ext.dart +++ b/catalyst_voices/apps/voices/lib/common/ext/ext.dart @@ -1,3 +1,4 @@ export 'brand_ext.dart'; +export 'build_context_ext.dart'; export 'space_ext.dart'; export 'string_ext.dart'; diff --git a/catalyst_voices/apps/voices/lib/common/formatters/amount_formatter.dart b/catalyst_voices/apps/voices/lib/common/formatters/amount_formatter.dart new file mode 100644 index 00000000000..c2856ad997b --- /dev/null +++ b/catalyst_voices/apps/voices/lib/common/formatters/amount_formatter.dart @@ -0,0 +1,7 @@ +import 'package:intl/intl.dart'; + +abstract class AmountFormatter { + static String decimalFormat(num value) { + return NumberFormat.decimalPattern().format(value); + } +} 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 74ee6f28bb5..29b7f7dbabc 100644 --- a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart +++ b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart @@ -1,5 +1,6 @@ import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; /// A [DateTime] formatter. @@ -95,4 +96,27 @@ abstract class DateFormatter { return '${nf.format(hours)}:${nf.format(minutes)}'; } + + static String formatDateRange( + MaterialLocalizations localizations, + VoicesLocalizations l10n, + DateRange range, + ) { + final from = range.from; + final to = range.to; + if (from != null && to != null) { + if (range.areDatesInSameWeek(localizations.firstDayOfWeekIndex)) { + return '${l10n.weekOf} ${DateFormat.MMMd().format(from)}'; + } + + // ignore: lines_longer_than_80_chars + return '${DateFormat.MMMd().format(from)} - ${DateFormat.MMMd().format(to)}'; + } else if (to == null && from != null) { + return '${l10n.from} ${DateFormat.MMMd().format(from)}'; + } else if (to != null && from == null) { + return '${l10n.to} ${DateFormat.MMMd().format(to)}'; + } + + return ''; + } } diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/current_campaign.dart b/catalyst_voices/apps/voices/lib/pages/discovery/current_campaign.dart new file mode 100644 index 00000000000..26aa17d5b8b --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/discovery/current_campaign.dart @@ -0,0 +1,442 @@ +import 'package:catalyst_voices/common/ext/ext.dart'; +import 'package:catalyst_voices/common/formatters/amount_formatter.dart'; +import 'package:catalyst_voices/common/formatters/date_formatter.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class CurrentCampaign extends StatelessWidget { + final CurrentCampaignInfoViewModel currentCampaignInfo; + + const CurrentCampaign({ + super.key, + required this.currentCampaignInfo, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 120, top: 64, right: 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const _Header(), + const SizedBox(height: 32), + _CurrentCampaignDetails( + allFunds: currentCampaignInfo.allFunds, + totalAsk: currentCampaignInfo.totalAsk, + askRange: currentCampaignInfo.askRange, + ), + const SizedBox(height: 80), + const _SubTitle(), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 120, top: 32), + child: _CampaignTimeline(mockCampaignTimeline), + ), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 568), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.currentCampaign, + style: context.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + context.l10n.catalystF14, + style: context.textTheme.displayMedium?.copyWith( + color: context.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.currentCampaignDescription, + style: context.textTheme.bodyLarge, + ), + ], + ), + ); + } +} + +class _CurrentCampaignDetails extends StatelessWidget { + final int allFunds; + final int totalAsk; + final Range askRange; + + const _CurrentCampaignDetails({ + required this.allFunds, + required this.totalAsk, + required this.askRange, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: context.colors.elevationsOnSurfaceNeutralLv2, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: context.colors.outlineBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + VoicesAssets.icons.library.buildIcon(), + const SizedBox(height: 32), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CampaignFundsDetail( + title: context.l10n.campaignTreasury, + description: context.l10n.campaignTreasuryDescription, + funds: allFunds, + ), + _CampaignFundsDetail( + title: context.l10n.campaignTotalAsk, + description: context.l10n.campaignTotalAskDescription, + funds: totalAsk, + largeFundsText: false, + ), + _RangeAsk(range: askRange), + ], + ), + ], + ), + ); + } +} + +class _CampaignFundsDetail extends StatelessWidget { + final String title; + final String description; + final int funds; + final bool largeFundsText; + + const _CampaignFundsDetail({ + required this.title, + required this.description, + required this.funds, + this.largeFundsText = true, + }); + + String get _formattedFunds => AmountFormatter.decimalFormat(funds); + + @override + Widget build(BuildContext context) { + return Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: context.textTheme.titleMedium?.copyWith( + color: context.colors.textOnPrimaryLevel1, + ), + ), + Text( + description, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.sysColorsNeutralN60, + ), + ), + const SizedBox(height: 16), + Text( + '${const Currency.ada().symbol} $_formattedFunds', + style: _foundsTextStyle(context)?.copyWith( + color: context.colors.textOnPrimaryLevel1, + ), + ), + ], + ), + ); + } + + TextStyle? _foundsTextStyle(BuildContext context) { + if (largeFundsText) { + return context.textTheme.headlineLarge; + } else { + return context.textTheme.headlineSmall; + } + } +} + +class _RangeAsk extends StatelessWidget { + final Range range; + + const _RangeAsk({ + required this.range, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: context.colors.outlineBorder, + width: 1, + ), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _RangeValue( + title: context.l10n.maximumAsk, + value: range.max ?? 0, + ), + const SizedBox(height: 19), + _RangeValue( + title: context.l10n.minimumAsk, + value: range.min ?? 0, + ), + ], + ), + ), + ); + } +} + +class _RangeValue extends StatelessWidget { + final String title; + final int value; + + const _RangeValue({ + required this.title, + required this.value, + }); + + String get _formattedValue => AmountFormatter.decimalFormat(value); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + title, + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.sysColorsNeutralN60, + ), + ), + Text( + '${const Currency.ada().symbol} $_formattedValue', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.sysColorsNeutralN60, + ), + ), + ], + ); + } +} + +class _SubTitle extends StatelessWidget { + const _SubTitle(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 592), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.ideaJourney, + style: context.textTheme.headlineMedium, + ), + const SizedBox(height: 12), + MarkdownText(MarkdownData(context.l10n.ideaJourneyDescription)), + ], + ), + ); + } +} + +class _CampaignTimeline extends StatefulWidget { + final List timelineItem; + + const _CampaignTimeline(this.timelineItem); + + @override + State<_CampaignTimeline> createState() => _CampaignTimelineState(); +} + +class _CampaignTimelineState extends State<_CampaignTimeline> { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 300, + child: GestureDetector( + onHorizontalDragUpdate: (details) { + _scrollController.jumpTo( + _scrollController.offset - details.delta.dx, + ); + }, + //When using ListView, child were expanding + // in full height of the parent + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: + widget.timelineItem.map(_CampaignTimelineCard.new).toList(), + ), + ), + ), + ); + } +} + +class _CampaignTimelineCard extends StatefulWidget { + final CampaignTimeline timelineItem; + + const _CampaignTimelineCard(this.timelineItem); + + @override + State<_CampaignTimelineCard> createState() => _CampaignTimelineCardState(); +} + +class _CampaignTimelineCardState extends State<_CampaignTimelineCard> { + bool isExpanded = false; + + bool get isOngoing => widget.timelineItem.timeline.isTodayInRange(); + SvgGenImage get _expandedIcon => isExpanded + ? VoicesAssets.icons.chevronDown + : VoicesAssets.icons.chevronRight; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _toggleExpanded, + child: SizedBox( + width: 280, + child: Card( + shape: OutlineInputBorder( + borderSide: BorderSide( + color: isOngoing ? context.colorScheme.primary : Colors.white, + width: isOngoing ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + VoicesAssets.icons.calendar.buildIcon( + color: context.colorScheme.primary, + ), + _expandedIcon.buildIcon( + color: context.colorScheme.primaryContainer, + ), + ], + ), + const SizedBox(height: 16), + Text( + widget.timelineItem.title, + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.textOnPrimaryLevel1, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormatter.formatDateRange( + MaterialLocalizations.of(context), + context.l10n, + widget.timelineItem.timeline, + ), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.sysColorsNeutralN60, + ), + ), + Offstage( + offstage: !isOngoing, + child: VoicesChip.round( + content: Text( + context.l10n.ongoing, + style: context.textTheme.labelSmall?.copyWith( + color: Colors.white, + ), + ), + backgroundColor: context.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + AnimatedSwitcher( + duration: Durations.medium4, + child: isExpanded + ? Text( + widget.timelineItem.description, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.sysColorsNeutralN60, + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ), + ); + } + + void _toggleExpanded() { + setState(() { + isExpanded = !isExpanded; + }); + } +} 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 ece5d20da1e..e9d959d2654 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/discovery_page.dart @@ -1,41 +1,23 @@ -import 'dart:async'; - -import 'package:catalyst_voices/pages/campaign/details/campaign_details_dialog.dart'; -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/campaign_stage_card.dart'; -import 'package:catalyst_voices/widgets/cards/proposal_card.dart'; -import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; -import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices/pages/discovery/current_campaign.dart'; +import 'package:catalyst_voices/pages/discovery/how_it_works.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_outlined_button.dart'; +import 'package:catalyst_voices/widgets/heroes/section_hero.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_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class DiscoveryPage extends StatefulWidget { +class DiscoveryPage extends StatelessWidget { const DiscoveryPage({super.key}); - @override - State createState() => _DiscoveryPageState(); -} - -class _DiscoveryPageState extends State { - @override - void initState() { - super.initState(); - unawaited(context.read().load()); - unawaited(context.read().load()); - } - @override Widget build(BuildContext context) { return const CustomScrollView( slivers: [ _Body(), - _Footer(), ], ); } @@ -46,37 +28,38 @@ class _Body extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocSelector( + selector: (state) => state.account?.isProposer ?? false, builder: (context, state) { - return switch (state) { - VisitorSessionState() => const _GuestVisitorBody(), - GuestSessionState() => const _GuestVisitorBody(), - ActiveAccountSessionState() => const _ActiveAccountBody(), - }; + return _GuestVisitorBody( + isProposer: state, + ); }, ); } } class _GuestVisitorBody extends StatelessWidget { - const _GuestVisitorBody(); + final bool isProposer; + + const _GuestVisitorBody({required this.isProposer}); @override Widget build(BuildContext context) { return SliverMainAxisGroup( slivers: [ - const SliverToBoxAdapter(child: _SpacesNavigationLocation()), SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 32) - .add(const EdgeInsets.only(bottom: 32)), + padding: const EdgeInsets.only(bottom: 32), sliver: SliverList( delegate: SliverChildListDelegate( [ - const _Segment(key: ValueKey('Segment1Key')), - const SizedBox(height: 24), - const _Segment(key: ValueKey('Segment2Key')), - const SizedBox(height: 24), - const _Segment(key: ValueKey('Segment3Key')), + const _CampaignHeroSection(), + const HowItWorks(), + CurrentCampaign( + currentCampaignInfo: CurrentCampaignInfoViewModel.dummy(), + ), + const _CampaignCategories(), + const _LatestProposals(), ], ), ), @@ -86,414 +69,97 @@ class _GuestVisitorBody extends StatelessWidget { } } -class _SpacesNavigationLocation extends StatelessWidget { - const _SpacesNavigationLocation(); +class _CampaignHeroSection extends StatelessWidget { + const _CampaignHeroSection(); @override Widget build(BuildContext context) { - return const NavigationLocation( - parts: [ - 'Discovery Space', - 'Homepage', - ], - ); - } -} - -class _Segment extends StatelessWidget { - const _Segment({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return AspectRatio( - aspectRatio: 1376 / 673, - child: Container( - decoration: BoxDecoration( - color: theme.colors.elevationsOnSurfaceNeutralLv1White, - border: Border.all(color: theme.colors.outlineBorderVariant), - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const CurrentUserStatusText(), - const SizedBox(height: 8), - const ToggleStateText(), - const Spacer(), - VoicesFilledButton( - child: const Text('CTA to Model'), - onTap: () { - unawaited( - VoicesDialog.show( - context: context, - builder: (context) { - return const VoicesDesktopInfoDialog(title: Text('')); - }, - ), - ); - }, - ), - ], + return HeroSection( + asset: VoicesAssets.videos.heroDesktop, + assetPackageName: 'catalyst_voices_assets', + child: Padding( + padding: const EdgeInsets.only( + left: 120, + bottom: 64, + top: 32, ), - ), - ); - } -} - -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(), - ], + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 450, + ), + child: const _CampaignBrief(), ), ), ); } } -class _Header extends StatelessWidget { - const _Header(); +class _CampaignBrief extends StatelessWidget { + const _CampaignBrief(); @override Widget build(BuildContext context) { return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.spaceDiscoveryName, - style: Theme.of(context).textTheme.headlineLarge!.copyWith( + context.l10n.heroSectionTitle, + style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), - const SizedBox(height: 24), - const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: _FundInfo()), - Expanded(child: _CampaignStage()), - ], - ), - ], - ); - } -} - -class _FundInfo extends StatelessWidget { - const _FundInfo(); - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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 _CampaignDetailsButton(), - ], - ), - ); - } -} - -class _CampaignDetailsButton extends StatelessWidget { - const _CampaignDetailsButton(); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.campaign?.id, - builder: (context, campaignId) { - if (campaignId == null) { - return const Offstage(); - } - - return Padding( - padding: const EdgeInsets.only(top: 32), - child: OutlinedButton.icon( - onPressed: () { - unawaited( - CampaignDetailsDialog.show(context, id: campaignId), - ); - }, - label: Text(context.l10n.campaignDetails), - icon: VoicesAssets.icons.arrowsExpand.buildIcon(), - ), - ); - }, - ); - } -} - -class _CampaignStage extends StatelessWidget { - const _CampaignStage(); - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.topRight, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: BlocBuilder( - builder: (context, state) { - final campaign = state.campaign; - return campaign != null - ? CampaignStageCard(campaign: campaign) - : const Offstage(); - }, + const SizedBox(height: 32), + Text( + context.l10n.projectCatalystDescription, + style: Theme.of(context).textTheme.bodyLarge, ), - ), - ); - } -} - -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), + const SizedBox(height: 32), + Row( + children: [ + VoicesFilledButton( + onTap: () { + // TODO(LynxxLynx): implement redirect to current campaign + }, + child: Text(context.l10n.viewCurrentCampaign), ), - Tab( - child: Row( - children: [ - VoicesAssets.icons.starOutlined.buildIcon(), - const SizedBox(width: 8), - Text(context.l10n.favorites), - ], + const SizedBox(width: 8), + Offstage( + offstage: true, + child: VoicesOutlinedButton( + onTap: () { + // TODO(LynxxLynx): implement redirect to my proposals + }, + child: Text(context.l10n.myProposals), ), ), ], - ); - }, - ); - } -} - -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) - ProposalCard( - 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) - ProposalCard( - 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(); +class _CampaignCategories extends StatelessWidget { + const _CampaignCategories(); @override Widget build(BuildContext context) { - return Center( - child: EmptyState( - description: context.l10n.discoverySpaceEmptyProposals, - ), + return const Placeholder( + fallbackHeight: 1440, + child: Text('Campaign Categories'), ); } } -class _Footer extends StatelessWidget { - const _Footer(); +class _LatestProposals extends StatelessWidget { + const _LatestProposals(); @override Widget build(BuildContext context) { - return const SliverFillRemaining( - hasScrollBody: false, - child: Column( - children: [ - Spacer(), - StandardLinksPageFooter(), - ], - ), + return const Placeholder( + color: Colors.blueGrey, + child: Text('Latest Proposals'), ); } } - -AssetGenImage _generateImageForProposal(String id) { - return id.codeUnits.last.isEven - ? VoicesAssets.images.proposalBackground1 - : VoicesAssets.images.proposalBackground2; -} diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart b/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart new file mode 100644 index 00000000000..735b5c70069 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart @@ -0,0 +1,356 @@ +import 'dart:async'; + +import 'package:catalyst_voices/pages/campaign/details/campaign_details_dialog.dart'; +import 'package:catalyst_voices/widgets/cards/campaign_stage_card.dart'; +import 'package:catalyst_voices/widgets/cards/proposal_card.dart'; +import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; +import 'package:catalyst_voices/widgets/common/tab_bar_stack_view.dart'; +import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; +import 'package:catalyst_voices/widgets/indicators/voices_circular_progress_indicator.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProposalsPage extends StatefulWidget { + const ProposalsPage({super.key}); + + @override + State createState() => _ProposalsPageState(); +} + +class _ProposalsPageState extends State { + @override + void initState() { + super.initState(); + unawaited(context.read().load()); + unawaited(context.read().load()); + } + + @override + Widget build(BuildContext context) { + return const CustomScrollView( + slivers: [ + _ActiveAccountBody(), + ], + ); + } +} + +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 const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _FundInfo()), + Expanded(child: _CampaignStage()), + ], + ), + ], + ); + } +} + +class _FundInfo extends StatelessWidget { + const _FundInfo(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 _CampaignDetailsButton(), + ], + ), + ); + } +} + +class _CampaignDetailsButton extends StatelessWidget { + const _CampaignDetailsButton(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.campaign?.id, + builder: (context, campaignId) { + return Offstage( + offstage: campaignId == null, + child: Padding( + padding: const EdgeInsets.only(top: 32), + child: OutlinedButton.icon( + onPressed: () { + if (campaignId == null) { + throw ArgumentError('Campaign ID is null'); + } + unawaited( + CampaignDetailsDialog.show(context, id: campaignId), + ); + }, + label: Text(context.l10n.campaignDetails), + icon: VoicesAssets.icons.arrowsExpand.buildIcon(), + ), + ), + ); + }, + ); + } +} + +class _CampaignStage extends StatelessWidget { + const _CampaignStage(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topRight, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: BlocBuilder( + builder: (context, state) { + final campaign = state.campaign; + return campaign != null + ? CampaignStageCard(campaign: campaign) + : const Offstage(); + }, + ), + ), + ); + } +} + +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: AffixDecorator( + gap: 8, + prefix: VoicesAssets.icons.starOutlined.buildIcon(), + child: 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) => proposals.isEmpty + ? const _EmptyProposals() + : _AllProposalsList( + proposals: proposals, + ), + }; + }, + ); + } +} + +class _AllProposalsList extends StatelessWidget { + final List proposals; + + const _AllProposalsList({ + required this.proposals, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final proposal in proposals) + ProposalCard( + image: _generateImageForProposal(proposal.id), + proposal: proposal, + showStatus: false, + showLastUpdate: false, + showComments: false, + showSegments: false, + isFavorite: proposal.isFavorite, + onFavoriteChanged: (isFavorite) async { + await context.read().onChangeFavoriteProposal( + proposal.id, + isFavorite: isFavorite, + ); + }, + ), + ], + ); + } +} + +class _FavoriteProposals extends StatelessWidget { + const _FavoriteProposals(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return switch (state) { + LoadingProposalsState() => const _LoadingProposals(), + LoadedProposalsState(:final proposals) => proposals.favorites.isEmpty + ? const _EmptyProposals() + : _FavoriteProposalsList( + proposals: proposals.favorites, + ), + }; + }, + ); + } +} + +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) + ProposalCard( + image: _generateImageForProposal(proposal.id), + proposal: proposal, + showStatus: false, + showLastUpdate: false, + showComments: false, + showSegments: false, + isFavorite: true, + onFavoriteChanged: (isFavorite) async { + await context.read().onChangeFavoriteProposal( + proposal.id, + isFavorite: isFavorite, + ); + }, + ), + ], + ); + } +} + +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 Center( + child: EmptyState( + description: context.l10n.discoverySpaceEmptyProposals, + ), + ); + } +} + +AssetGenImage _generateImageForProposal(String id) { + return id.codeUnits.last.isEven + ? VoicesAssets.images.proposalBackground1 + : VoicesAssets.images.proposalBackground2; +} diff --git a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart index ed98c88d338..4b38b8c83f6 100644 --- a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart +++ b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart @@ -1,6 +1,7 @@ import 'package:catalyst_voices/pages/discovery/discovery.dart'; import 'package:catalyst_voices/pages/funded_projects/funded_projects_page.dart'; import 'package:catalyst_voices/pages/proposal_builder/proposal_builder.dart'; +import 'package:catalyst_voices/pages/proposals/proposals_page.dart'; import 'package:catalyst_voices/pages/spaces/spaces.dart'; import 'package:catalyst_voices/pages/treasury/treasury.dart'; import 'package:catalyst_voices/pages/voting/voting_page.dart'; @@ -21,7 +22,14 @@ const _prefix = Routes.currentMilestone; @TypedShellRoute( routes: >[ - TypedGoRoute(path: '/$_prefix/discovery'), + TypedGoRoute( + path: '/$_prefix/discovery', + routes: [ + TypedGoRoute( + path: 'proposals', + ), + ], + ), TypedGoRoute( path: '/$_prefix/workspace', routes: [ @@ -99,6 +107,13 @@ final class WorkspaceRoute extends GoRouteData } } +final class ProposalsRoute extends GoRouteData with FadePageTransitionMixin { + @override + Widget build(BuildContext context, GoRouterState state) { + return const ProposalsPage(); + } +} + final class ProposalBuilderDraftRoute extends GoRouteData with FadePageTransitionMixin, CompositeRouteGuardMixin { final String? templateId; diff --git a/catalyst_voices/apps/voices/lib/widgets/heroes/section_hero.dart b/catalyst_voices/apps/voices/lib/widgets/heroes/section_hero.dart new file mode 100644 index 00000000000..8985da321ee --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/heroes/section_hero.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class HeroSection extends StatefulWidget { + final AlignmentGeometry alignment; + final Widget child; + final String asset; + final String? assetPackageName; + + const HeroSection({ + super.key, + this.alignment = Alignment.bottomLeft, + required this.child, + required this.asset, + this.assetPackageName, + }); + + @override + State createState() => _HeroSectionState(); +} + +class _HeroSectionState extends State + with AutomaticKeepAliveClientMixin { + VideoPlayerController? _controller; + + VideoPlayerController get _effectiveController { + return _controller ?? + (_controller ??= VideoPlayerController.asset( + widget.asset, + package: widget.assetPackageName, + )); + } + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.asset( + widget.asset, + package: widget.assetPackageName, + ); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + await _initalizedVideoPlayer(); + } + }); + } + + @override + Future didUpdateWidget(covariant HeroSection oldWidget) async { + super.didUpdateWidget(oldWidget); + + if (oldWidget.asset != widget.asset || + oldWidget.assetPackageName != widget.assetPackageName) { + await _controller?.dispose(); + _controller = VideoPlayerController.asset( + widget.asset, + package: widget.assetPackageName, + ); + if (mounted) { + await _initalizedVideoPlayer(); + } + } + } + + @override + Future dispose() async { + await _controller?.dispose(); + _controller = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return Stack( + fit: StackFit.passthrough, + alignment: widget.alignment, + children: [ + _Background( + controller: _effectiveController, + ), + Align( + alignment: widget.alignment, + child: widget.child, + ), + ], + ); + } + + Future _initalizedVideoPlayer() async { + await _controller?.initialize().then((_) async { + await _controller?.setVolume(0); + await _controller?.play(); + await _controller?.setLooping(true); + }); + if (mounted) { + setState(() {}); + } + } +} + +class _Background extends StatelessWidget { + final VideoPlayerController controller; + const _Background({ + required this.controller, + }); + @override + Widget build(BuildContext context) { + return controller.value.isInitialized + ? ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 650), + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: controller.value.size.width, + height: controller.value.size.height, + child: VideoPlayer(controller), + ), + ), + ) + : const SizedBox.shrink(); + } +} diff --git a/catalyst_voices/apps/voices/pubspec.yaml b/catalyst_voices/apps/voices/pubspec.yaml index b1f48f06ce5..d336de6465e 100644 --- a/catalyst_voices/apps/voices/pubspec.yaml +++ b/catalyst_voices/apps/voices/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: shared_preferences: ^2.3.3 url_launcher: ^6.2.2 url_strategy: ^0.3.0 + video_player: ^2.9.2 # TODO(dtscalac): win32 dependency is just a transitive dependency and shouldn't be imported # but here we import it explicitly to make sure the latest version is used which addresses # the problem from here: https://github.com/jonataslaw/get_cli/issues/263 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..5bbe3e6b37b 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,6 +1,7 @@ import 'package:catalyst_voices/common/formatters/date_formatter.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; @@ -13,11 +14,28 @@ class _FakeVoicesLocalizations extends Fake implements VoicesLocalizations { String get yesterday => 'Yesterday'; @override String get twoDaysAgo => '2 days ago'; + @override + String get weekOf => 'Week of'; + @override + String get from => 'From'; + @override + String get to => 'To'; +} + +class _FakeMaterialLocalizations extends Fake implements MaterialLocalizations { + int _firstDayOfWeekIndex = 0; + @override + int get firstDayOfWeekIndex => _firstDayOfWeekIndex; + + set firstDayOfWeekIndex(int value) { + _firstDayOfWeekIndex = value; + } } void main() { group(DateFormatter, () { final l10n = _FakeVoicesLocalizations(); + final mockLocalizations = _FakeMaterialLocalizations(); test('should return "Today" for today\'s date', () { final today = DateTimeExt.now(); @@ -49,5 +67,123 @@ void main() { final expectedFormat = DateFormat.yMMMMd().format(pastDate); expect(result, expectedFormat); }); + + test( + 'Dates are in the same week for Local when Sunday is first day of week', + () { + // Set Sunday as first day of week + mockLocalizations.firstDayOfWeekIndex = 0; + final dateRangeWhenSundayFirst = DateRange( + from: DateTime(2025, 1, 19), + to: DateTime(2025, 1, 25), + ); + final dateRangeWhenMondayFirst = DateRange( + from: DateTime(2025, 1, 20), + to: DateTime(2025, 1, 26), + ); + + final resultSunday = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + dateRangeWhenSundayFirst, + ); + final resultMonday = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + dateRangeWhenMondayFirst, + ); + + expect(resultSunday, 'Week of Jan 19'); + expect(resultMonday, 'Jan 20 - Jan 26'); + }); + + test( + 'Dates are in the same week for Local when Monday is first day of week', + () { + // Set Monday as first day of week + mockLocalizations.firstDayOfWeekIndex = 1; + final dateRangeWhenSundayFirst = DateRange( + from: DateTime(2025, 1, 19), + to: DateTime(2025, 1, 25), + ); + final dateRangeWhenMondayFirst = DateRange( + from: DateTime(2025, 1, 20), + to: DateTime(2025, 1, 26), + ); + + final resultSunday = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + dateRangeWhenSundayFirst, + ); + final resultMonday = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + dateRangeWhenMondayFirst, + ); + + expect(resultSunday, 'Jan 19 - Jan 25'); + expect(resultMonday, 'Week of Jan 20'); + }); + + test('Return range when both dates are not null', () { + final range = DateRange( + from: DateTime(2025, 1, 18), + to: DateTime(2025, 2, 25), + ); + + final result = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + range, + ); + + expect(result, 'Jan 18 - Feb 25'); + }); + + test('Return only from', () { + final range = DateRange( + from: DateTime(2025, 1, 18), + to: null, + ); + + final result = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + range, + ); + + expect(result, 'From Jan 18'); + }); + + test('Return only to', () { + final range = DateRange( + from: null, + to: DateTime(2025, 1, 18), + ); + + final result = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + range, + ); + + expect(result, 'To Jan 18'); + }); + + test('Return empty string when both dates are null', () { + const range = DateRange( + from: null, + to: null, + ); + + final result = DateFormatter.formatDateRange( + mockLocalizations, + l10n, + range, + ); + + expect(result, ''); + }); }); } diff --git a/catalyst_voices/apps/voices/test/helpers/pump_app.dart b/catalyst_voices/apps/voices/test/helpers/pump_app.dart index 0e8ad8de6fe..9405585aba4 100644 --- a/catalyst_voices/apps/voices/test/helpers/pump_app.dart +++ b/catalyst_voices/apps/voices/test/helpers/pump_app.dart @@ -9,6 +9,8 @@ extension PumpApp on WidgetTester { Widget widget, { ThemeData? theme, VoicesColorScheme voicesColors = const VoicesColorScheme.optional(), + Locale? locale, + GlobalKey? navigatorKey, }) { final effectiveTheme = (theme ?? ThemeData()).copyWith( extensions: [ @@ -18,11 +20,13 @@ extension PumpApp on WidgetTester { return pumpWidget( MaterialApp( + navigatorKey: navigatorKey, localizationsDelegates: const [ ...VoicesLocalizations.localizationsDelegates, LocaleNamesLocalizationsDelegate(), ], supportedLocales: VoicesLocalizations.supportedLocales, + locale: locale, localeListResolutionCallback: basicLocaleListResolution, theme: effectiveTheme, home: widget, diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/videos/hero_desktop.mp4 b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/videos/hero_desktop.mp4 new file mode 100644 index 00000000000..83ec82fbc55 Binary files /dev/null and b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/videos/hero_desktop.mp4 differ diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_assets/pubspec.yaml index 39bf40c0c20..87f4243fcf3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_assets/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_assets/pubspec.yaml @@ -24,6 +24,7 @@ flutter: assets: - assets/images/ - assets/icons/ + - assets/videos/ - assets/document_templates/proposal/F14-Generic/ fonts: - family: SF-Pro 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 index f5a3dea974a..93bdf872c3a 100644 --- 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 @@ -5,7 +5,6 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Manages the proposals. @@ -40,45 +39,25 @@ final class ProposalsCubit extends Cubit { emit( LoadedProposalsState( proposals: proposals, - favoriteProposals: const [], ), ); } - /// Marks the proposal with [proposalId] as favorite. - Future onFavoriteProposal(String proposalId) async { + /// Changes the favorite status of the proposal with [proposalId]. + Future onChangeFavoriteProposal( + String proposalId, { + required bool isFavorite, + }) 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; + final proposals = List.of(loadedState.proposals); + final favoriteProposal = proposals.indexWhere((e) => e.id == proposalId); + if (favoriteProposal == -1) return; + proposals[favoriteProposal] = + proposals[favoriteProposal].copyWith(isFavorite: isFavorite); - emit( - LoadedProposalsState( - proposals: loadedState.proposals, - favoriteProposals: loadedState.favoriteProposals - .whereNot((e) => e.id == proposalId) - .toList(), - ), - ); + emit(LoadedProposalsState(proposals: proposals)); } @override 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 index d1a433e28f5..2066a0edb85 100644 --- 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 @@ -17,13 +17,11 @@ final class LoadingProposalsState extends ProposalsState { /// The loaded proposals. final class LoadedProposalsState extends ProposalsState { final List proposals; - final List favoriteProposals; const LoadedProposalsState({ this.proposals = const [], - this.favoriteProposals = const [], }); @override - List get props => [proposals, favoriteProposals]; + List get props => [proposals]; } 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 index ae031e972d7..08701cb6538 100644 --- 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 @@ -94,7 +94,6 @@ void main() { const LoadingProposalsState(), LoadedProposalsState( proposals: [pendingProposal], - favoriteProposals: const [], ), ], ); @@ -121,7 +120,6 @@ void main() { const LoadingProposalsState(), const LoadedProposalsState( proposals: [], - favoriteProposals: [], ), ], ); @@ -148,7 +146,6 @@ void main() { const LoadingProposalsState(), LoadedProposalsState( proposals: [pendingProposal], - favoriteProposals: const [], ), ], ); @@ -164,22 +161,19 @@ void main() { }, act: (cubit) async { await cubit.load(); - await cubit.onFavoriteProposal(proposal.id); - await cubit.onUnfavoriteProposal(proposal.id); + await cubit.onChangeFavoriteProposal(proposal.id, isFavorite: true); + await cubit.onChangeFavoriteProposal(proposal.id, isFavorite: false); }, expect: () => [ const LoadingProposalsState(), LoadedProposalsState( proposals: [pendingProposal], - favoriteProposals: const [], ), LoadedProposalsState( - proposals: [pendingProposal], - favoriteProposals: [pendingProposal], + proposals: [pendingProposal.copyWith(isFavorite: true)], ), LoadedProposalsState( proposals: [pendingProposal], - favoriteProposals: const [], ), ], ); diff --git a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart index 0724bf7f5e3..c32a62dc0fd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart @@ -67,6 +67,7 @@ class VoicesColorScheme extends ThemeExtension { final Color onErrorContainer; final Color overlay; final Color dropShadow; + final Color sysColorsNeutralN60; const VoicesColorScheme({ required this.textPrimary, @@ -129,6 +130,7 @@ class VoicesColorScheme extends ThemeExtension { required this.onErrorContainer, required this.overlay, required this.dropShadow, + required this.sysColorsNeutralN60, }); @visibleForTesting @@ -193,6 +195,7 @@ class VoicesColorScheme extends ThemeExtension { this.onErrorContainer = Colors.black, this.overlay = Colors.black, this.dropShadow = Colors.black, + this.sysColorsNeutralN60 = Colors.black, }); @override @@ -257,6 +260,7 @@ class VoicesColorScheme extends ThemeExtension { Color? onErrorContainer, Color? overlay, Color? dropShadow, + Color? sysColorsNeutralN60, }) { return VoicesColorScheme( textPrimary: textPrimary ?? this.textPrimary, @@ -331,6 +335,7 @@ class VoicesColorScheme extends ThemeExtension { onErrorContainer: onErrorContainer ?? this.onErrorContainer, overlay: overlay ?? this.overlay, dropShadow: dropShadow ?? this.dropShadow, + sysColorsNeutralN60: sysColorsNeutralN60 ?? this.sysColorsNeutralN60, ); } @@ -479,6 +484,8 @@ class VoicesColorScheme extends ThemeExtension { Color.lerp(onErrorContainer, other.onErrorContainer, t)!, overlay: Color.lerp(overlay, other.overlay, t)!, dropShadow: Color.lerp(dropShadow, other.dropShadow, t)!, + sysColorsNeutralN60: + Color.lerp(sysColorsNeutralN60, other.sysColorsNeutralN60, t)!, ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart index 3a01026e09b..8cc5e442bb9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart @@ -88,6 +88,7 @@ const VoicesColorScheme darkVoicesColorScheme = VoicesColorScheme( onErrorContainer: VoicesColors.darkOnErrorContainer, overlay: Color(0xA610141C), dropShadow: Color(0xA610141C), + sysColorsNeutralN60: Color(0xFF7F90B3), ); const ColorScheme lightColorScheme = ColorScheme.light( @@ -172,6 +173,7 @@ const VoicesColorScheme lightVoicesColorScheme = VoicesColorScheme( onErrorContainer: VoicesColors.lightOnErrorContainer, overlay: Color(0x9904080F), dropShadow: Color(0x9904080F), + sysColorsNeutralN60: Color(0xFF7F90B3), ); /// [ThemeData] for the `catalyst` brand. 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 b3f944a3271..70dfe2f182c 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 @@ -1345,6 +1345,28 @@ "description": "Used in discovery page in how it works section" }, "howItWorksFollowDescription": "Receive regular updates on all the funded ideas, so you can follow along and see how things are progressing.", + "heroSectionTitle": "Create, fund and deliver the future of Cardano.", + "projectCatalystDescription": "Project Catalyst is an experiment in community innovation, providing a framework to turn ideas into impactful real world projects.\n\nWe're putting the community at the heart of Cardano's future development. Are you ready for the Challenge?", + "viewCurrentCampaign": "View Current Campaign", + + "catalystF14": "Catalyst Found 14", + "@catalystF14": { + "description": "Used in discovery page in hero section" + }, + "currentCampaign": "Current Campaign", + "currentCampaignDescription": "Project Catalyst turns economic power into innovation power by using the Cardano Treasury to incentivize and fund community-approved ideas.", + "campaignTreasury": "Campaign Treasury", + "campaignTreasuryDescription": "Total budget, including ecosystem incentives", + "campaignTotalAsk": "Campaign Total Ask", + "campaignTotalAskDescription": "Funds requested by all submitted projects", + "maximumAsk": "Maximum Ask", + "minimumAsk": "Minimum Ask", + "ideaJourney": "Idea Journey", + "ideaJourneyDescription": "#### Ideas comes to life in Catalyst through its key stages below. For the full timeline, deadlines and latest updates, visit the [fund timeline](https://docs.projectcatalyst.io/current-fund/fund-basics/fund-timeline) Gitbook page.", + "ongoing": "Ongoing", + "from": "From", + "to": "To", + "weekOf": "Week of", "errorDisplayNameValidationEmpty": "Display name can not be empty", "errorDisplayNameValidationOutOfRange": "Invalid length", "headsUp": "Heads up", diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart index e86304c0dc8..f4a2855db9e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart @@ -21,6 +21,7 @@ export 'keychain/vault_keychain_provider.dart'; export 'logging/logging_service.dart'; export 'platform/catalyst_platform.dart'; export 'platform_aware_builder/platform_aware_builder.dart'; +export 'range/date_range.dart'; export 'range/range.dart'; export 'responsive/responsive_builder.dart'; export 'responsive/responsive_child.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/date_range.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/date_range.dart new file mode 100644 index 00000000000..e1f2e60e20d --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/date_range.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; + +class DateRange extends Equatable { + final DateTime? from; + final DateTime? to; + + const DateRange({ + required this.from, + required this.to, + }); + + bool isInRange(DateTime value) { + final min = from?.millisecondsSinceEpoch ?? 0; + final max = + to?.millisecondsSinceEpoch ?? DateTime(2099).millisecondsSinceEpoch; + final valueMillis = value.millisecondsSinceEpoch; + + return min <= valueMillis && valueMillis <= max; + } + + bool isTodayInRange() { + return isInRange(DateTime.now()); + } + + /// Checks if the date [from] is the first day of the week + /// and [to] is the last day + /// of the same week, considering the first day of the week as specified + /// by [firstDayOfWeekIndex]. + /// + /// The [firstDayOfWeekIndex] is an integer representing + /// the first day of the week according to the locale or desired convention, + /// where 0 represents Sunday and 1 represents Monday, and so on. + /// + /// Returns `true` if [from] is the first day and + /// [to] is the last day of the same week, and `false` otherwise. + /// + /// If either [from] or [to] is `null`, the function returns `false`. + bool areDatesInSameWeek(int firstDayOfWeekIndex) { + if (from == null || to == null) { + return false; + } + + var adjustedFromWeekday = (from!.weekday - firstDayOfWeekIndex) % 7; + var adjustedToWeekday = (to!.weekday - firstDayOfWeekIndex) % 7; + + if (adjustedFromWeekday < 0) adjustedFromWeekday += 7; + if (adjustedToWeekday < 0) adjustedToWeekday += 7; + + return adjustedFromWeekday == 0 && adjustedToWeekday == 6; + } + + @override + List get props => [from, to]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/test/src/range/date_range_test.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/test/src/range/date_range_test.dart new file mode 100644 index 00000000000..b27c48dcbc7 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/test/src/range/date_range_test.dart @@ -0,0 +1,129 @@ +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:test/test.dart'; + +void main() { + group(DateRange, () { + test('is not in range', () { + // Given + final range = + DateRange(from: DateTime(2025, 1, 20), to: DateTime(2025, 1, 22)); + + // When + final isInRageBeforFromDate = range.isInRange(DateTime(2025, 1, 19)); + final isInRageAfterToDate = range.isInRange(DateTime(2025, 1, 23)); + + // Then + expect(isInRageBeforFromDate, isFalse); + expect(isInRageAfterToDate, isFalse); + }); + + test('isInRage if value is in between from to and they are not null', () { + // Given + final range = + DateRange(from: DateTime(2025, 1, 20), to: DateTime(2025, 1, 22)); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 21)); + final sameDayAsFrom = range.isInRange(DateTime(2025, 1, 20)); + final sameDayAsTo = range.isInRange(DateTime(2025, 1, 22)); + + // Then + expect(isInRange, isTrue); + expect(sameDayAsFrom, isTrue); + expect(sameDayAsTo, isTrue); + }); + + test('isInRange if value is the same day as from', () { + // Given + final range = + DateRange(from: DateTime(2025, 1, 20), to: DateTime(2025, 1, 22)); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 20)); + + // Then + expect(isInRange, isTrue); + }); + + test('isInRange if value is the same day as from', () { + // Given + final range = DateRange(from: DateTime(2025, 1, 20), to: null); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 20)); + + // Then + expect(isInRange, isTrue); + }); + + test('is in range when value is after from and to is null', () { + // Given + final range = DateRange(from: DateTime(2025, 1, 20), to: null); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 22)); + + // Then + expect(isInRange, isTrue); + }); + + test('is in range when value is before to date and from is null', () { + // Given + final range = DateRange(from: null, to: DateTime(2025, 1, 22)); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 20)); + + // Then + expect(isInRange, isTrue); + }); + + test('is in range when both from and to are null', () { + // Given + const range = DateRange(from: null, to: null); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 20)); + + // Then + expect(isInRange, isTrue); + }); + + test('is out of range', () { + //Given + final range = + DateRange(from: DateTime(2025, 1, 20), to: DateTime(2025, 1, 22)); + + // When + final isOutOfRange = range.isInRange(DateTime(2025, 1, 19)); + final isOutOfRange2 = range.isInRange(DateTime(2025, 1, 23)); + + // Then + expect(isOutOfRange, isFalse); + expect(isOutOfRange2, isFalse); + }); + + test('is out of range even when from is null', () { + // Given + final range = DateRange(from: null, to: DateTime(2025, 1, 22)); + + // When + final isOutOfRange = range.isInRange(DateTime(2025, 1, 23)); + + // Then + expect(isOutOfRange, isFalse); + }); + + test('is in range when from and to are the same', () { + // Given + final range = + DateRange(from: DateTime(2025, 1, 20), to: DateTime(2025, 1, 20)); + + // When + final isInRange = range.isInRange(DateTime(2025, 1, 20)); + + // Then + expect(isInRange, isTrue); + }); + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline.dart new file mode 100644 index 00000000000..45d1418dc2f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline.dart @@ -0,0 +1,75 @@ +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:equatable/equatable.dart'; + +class CampaignTimeline extends Equatable { + final String title; + final String description; + final DateRange timeline; + + const CampaignTimeline({ + required this.title, + required this.description, + required this.timeline, + }); + + factory CampaignTimeline.dummy() => CampaignTimeline( + title: 'Proposal Submission', + description: + '''Community members share ideas and insights to refine the proposals. This stage consists of two distinct parts: reviews by LV0 & LV1s reviewers, as well as moderation by LV2s moderators.''', + timeline: DateRange( + from: DateTime(2024, 9, 26), + to: DateTime(2024, 10, 10), + ), + ); + + @override + List get props => [title, description, timeline]; +} + +final mockCampaignTimeline = [ + CampaignTimeline( + title: 'Proposal Submission', + description: + '''Participants submit initial proposals for ideas to solve challenges. A set amount of ada is allocated to the new funding round.''', + timeline: DateRange( + from: DateTime(2024, 9, 26), + to: DateTime(2024, 10, 10), + ), + ), + CampaignTimeline( + title: 'Community Review', + description: + '''Community members share ideas and insights to refine the proposals. This stage consists of two distinct parts: reviews by LV0 & LV1s reviewers, as well as moderation by LV2s moderators.''', + timeline: DateRange( + from: DateTime(2024, 10, 26), + to: DateTime(2024, 12, 10), + ), + ), + CampaignTimeline( + title: 'Community Voting', + description: + '''Community members vote using the Project Catalyst voting app. Votes are weighted based on voter's token holding.''', + timeline: DateRange( + from: DateTime(2025, 1, 1), + to: DateTime(2025, 1, 31), + ), + ), + CampaignTimeline( + title: 'Voting Results', + description: + '''Votes are tallied and the results revealed. Voters and community reviewers receive their rewards.''', + timeline: DateRange( + from: DateTime(2025, 2, 2), + to: DateTime(2025, 2, 8), + ), + ), + CampaignTimeline( + title: 'Project Onboarding', + description: + '''Votes are tallied and the results revealed. Voters and community reviewers receive their rewards.''', + timeline: DateRange( + from: DateTime(2025, 2, 19), + to: null, + ), + ), +]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart new file mode 100644 index 00000000000..aaaaf17de96 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/current_campaign_info_view_model.dart @@ -0,0 +1,25 @@ +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:equatable/equatable.dart'; + +class CurrentCampaignInfoViewModel extends Equatable { + final int allFunds; + final int totalAsk; + final Range askRange; + + const CurrentCampaignInfoViewModel({ + required this.allFunds, + required this.totalAsk, + required this.askRange, + }); + + factory CurrentCampaignInfoViewModel.dummy() { + return const CurrentCampaignInfoViewModel( + allFunds: 50000000, + totalAsk: 4020000, + askRange: Range(min: 30000, max: 150000), + ); + } + + @override + List get props => [allFunds, totalAsk, askRange]; +} 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 2382564c264..c48aa1e735d 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 @@ -5,6 +5,8 @@ export 'campaign/campaign_category_section.dart'; export 'campaign/campaign_info.dart'; export 'campaign/campaign_list_item.dart'; export 'campaign/campaign_stage.dart'; +export 'campaign/campaign_timeline.dart'; +export 'campaign/current_campaign_info_view_model.dart'; export 'campaign/exception/active_campaign_not_found_exception.dart'; export 'document/validation/localized_document_validation_result.dart'; export 'exception/localized_exception.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_view_model.dart index c60e353158c..5470f366138 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_view_model.dart @@ -7,8 +7,12 @@ import 'package:equatable/equatable.dart'; /// A proposal view model spanning proposals in different stages. abstract base class ProposalViewModel extends Equatable { final String id; + final bool isFavorite; - const ProposalViewModel({required this.id}); + const ProposalViewModel({ + required this.id, + required this.isFavorite, + }); factory ProposalViewModel.fromProposalAtStage({ required Proposal proposal, @@ -34,8 +38,12 @@ abstract base class ProposalViewModel extends Equatable { } } + ProposalViewModel copyWith({ + bool? isFavorite, + }); + @override - List get props => [id]; + List get props => [id, isFavorite]; } /// Defines the pending proposal that is not funded yet. @@ -52,6 +60,7 @@ final class PendingProposal extends ProposalViewModel { const PendingProposal({ required super.id, + super.isFavorite = false, required this.campaignName, required this.category, required this.title, @@ -83,6 +92,34 @@ final class PendingProposal extends ProposalViewModel { return CryptocurrencyFormatter.formatAmount(_fundsRequested); } + @override + PendingProposal copyWith({ + bool? isFavorite, + String? campaignName, + String? category, + String? title, + DateTime? lastUpdateDate, + Coin? fundsRequested, + int? commentsCount, + String? description, + int? completedSegments, + int? totalSegments, + }) { + return PendingProposal( + id: id, + isFavorite: isFavorite ?? this.isFavorite, + campaignName: campaignName ?? this.campaignName, + category: category ?? this.category, + title: title ?? this.title, + lastUpdateDate: lastUpdateDate ?? this.lastUpdateDate, + fundsRequested: fundsRequested ?? _fundsRequested, + commentsCount: commentsCount ?? this.commentsCount, + description: description ?? this.description, + completedSegments: completedSegments ?? this.completedSegments, + totalSegments: totalSegments ?? this.totalSegments, + ); + } + @override List get props => [ ...super.props, @@ -110,6 +147,7 @@ final class FundedProposal extends ProposalViewModel { const FundedProposal({ required super.id, + super.isFavorite = false, required this.campaignName, required this.category, required this.title, @@ -137,6 +175,30 @@ final class FundedProposal extends ProposalViewModel { return CryptocurrencyFormatter.formatAmount(_fundsRequested); } + @override + FundedProposal copyWith({ + bool? isFavorite, + String? campaignName, + String? category, + String? title, + DateTime? fundedDate, + Coin? fundsRequested, + int? commentsCount, + String? description, + }) { + return FundedProposal( + id: id, + isFavorite: isFavorite ?? this.isFavorite, + campaignName: campaignName ?? this.campaignName, + category: category ?? this.category, + title: title ?? this.title, + fundedDate: fundedDate ?? this.fundedDate, + fundsRequested: fundsRequested ?? _fundsRequested, + commentsCount: commentsCount ?? this.commentsCount, + description: description ?? this.description, + ); + } + @override List get props => [ ...super.props, @@ -149,3 +211,9 @@ final class FundedProposal extends ProposalViewModel { description, ]; } + +extension ListProposalViewModelExt on List { + List get favorites { + return where((proposal) => proposal.isFavorite).toList(); + } +}