diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 44179a1e01dd..8c93073d77c6 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -398,6 +398,7 @@ Utxos uuidv varint Vespr +vers vite vitss vkey diff --git a/catalyst_voices/Earthfile b/catalyst_voices/Earthfile index 2e8e1c2699da..0530b48ac6fd 100644 --- a/catalyst_voices/Earthfile +++ b/catalyst_voices/Earthfile @@ -32,12 +32,14 @@ code-generator: LET gen_code_path = lib/generated/api LET local_gen_code_path = packages/internal/catalyst_voices_repositories/lib/generated/api/ + LET local_gen_db_code_path = packages/internal/catalyst_voices_repositories/test/src/database/migration/catalyst_database/generated/ WORKDIR packages/internal/catalyst_voices_repositories WORKDIR /frontend RUN melos l10n + RUN melos build-db-migration RUN melos build-runner RUN melos build-runner-repository @@ -58,6 +60,11 @@ code-generator: -o -name "*.drift.dart" \)) SAVE ARTIFACT $generated_file AS LOCAL $generated_file END + + # Save database migration generated files + WORKDIR packages/internal/catalyst_voices_repositories/test/src/database/migration/catalyst_database + SAVE ARTIFACT generated/* AS LOCAL $local_gen_db_code_path + WORKDIR /frontend ELSE SAVE ARTIFACT . END diff --git a/catalyst_voices/apps/voices/lib/app/view/app.dart b/catalyst_voices/apps/voices/lib/app/view/app.dart index 025d70167c0f..72b8dbfb2fea 100644 --- a/catalyst_voices/apps/voices/lib/app/view/app.dart +++ b/catalyst_voices/apps/voices/lib/app/view/app.dart @@ -47,21 +47,9 @@ class _AppState extends State { BlocProvider( create: (_) => Dependencies.instance.get(), ), - BlocProvider( - create: (_) => Dependencies.instance.get(), - ), - BlocProvider( - create: (_) => Dependencies.instance.get(), - ), BlocProvider( create: (_) => Dependencies.instance.get(), ), - BlocProvider( - create: (context) => Dependencies.instance.get(), - ), - BlocProvider( - create: (context) => Dependencies.instance.get(), - ), BlocProvider( create: (_) => Dependencies.instance.get(), ), diff --git a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart index 65da6dcf5d7e..6a1848cacef8 100644 --- a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart +++ b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart @@ -91,8 +91,6 @@ Future bootstrap({ fromTo: DateRange(from: startConfigTimestamp, to: endConfigTimestamp), ); - final runtimeProfiler = CatalystRuntimeProfiler(profiler)..start(at: bootstrapStartTimestamp); - await Dependencies.instance.init( config: config, environment: environment, @@ -100,7 +98,6 @@ Future bootstrap({ reportingService: _reportingService, profiler: profiler, startupProfiler: startupProfiler, - runtimeProfiler: runtimeProfiler, ); final router = buildAppRouter(initialLocation: initialLocation); @@ -216,14 +213,16 @@ Future registerDependencies({ /// - [CatalystNoopProfiler] for debug mode (no overhead) CatalystProfiler _createProfiler(AppConfig config) { if (kProfileMode) { - return CatalystDeveloperProfiler.fromConfig(config.developerProfiler); + return config.profiler.console + ? const CatalystProfiler.console() + : CatalystProfiler.developer(config.profiler); } if (_shouldUseSentry) { - return const CatalystSentryProfiler(); + return const CatalystProfiler.sentry(); } - return const CatalystNoopProfiler(); + return const CatalystProfiler.noop(); } void _debugPrintStressTest() { diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index 342dd0eed4b7..22642094b4cf 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -42,7 +42,6 @@ final class Dependencies extends DependencyProvider { required ReportingService reportingService, CatalystProfiler? profiler, CatalystStartupProfiler? startupProfiler, - CatalystRuntimeProfiler? runtimeProfiler, }) async { DependencyProvider.instance = this; @@ -56,9 +55,6 @@ final class Dependencies extends DependencyProvider { if (startupProfiler != null) { registerSingleton(startupProfiler); } - if (runtimeProfiler != null) { - registerSingleton(runtimeProfiler); - } _registerStorages(); _registerUtils(); @@ -113,20 +109,18 @@ final class Dependencies extends DependencyProvider { blockchainConfig: get().blockchain, ); }) - ..registerLazySingleton( + ..registerFactory( () => ProposalsCubit( get(), get(), get(), ), ) - ..registerLazySingleton( + ..registerFactory( () => VotingCubit( get(), get(), get(), - get(), - get(), ), ) // TODO(LynxLynxx): add repository for campaign management @@ -249,10 +243,11 @@ final class Dependencies extends DependencyProvider { return BlockchainRepository(get()); }) ..registerLazySingleton(() { + final profiler = get(); return SignedDocumentManager( brotli: const CatalystBrotliCompressor(), zstd: const CatalystZstdCompressor(), - profiler: get(), + profiler: profiler is CatalystConsoleProfiler ? profiler : const CatalystNoopProfiler(), ); }) ..registerLazySingleton(() { @@ -263,6 +258,7 @@ final class Dependencies extends DependencyProvider { ..registerLazySingleton(() { return DatabaseDocumentsDataSource( get(), + get(), ); }) ..registerLazySingleton(() { @@ -276,9 +272,16 @@ final class Dependencies extends DependencyProvider { get(), ); }) - ..registerLazySingleton(CampaignRepository.new) + ..registerLazySingleton( + () { + return CampaignRepository( + get(), + ); + }, + ) ..registerLazySingleton(() { return DocumentRepository( + get(), get(), get(), get(), @@ -400,6 +403,7 @@ final class Dependencies extends DependencyProvider { get(), get(), get(), + get(), ); }); registerLazySingleton(() { @@ -520,6 +524,7 @@ final class Dependencies extends DependencyProvider { get(), get(), get(), + get(), ); }, dispose: (manager) async => manager.dispose(), @@ -542,7 +547,10 @@ final class Dependencies extends DependencyProvider { dispose: (observer) async => observer.dispose(), ); registerLazySingleton(CastedVotesObserverImpl.new); - registerLazySingleton(VotingBallotLocalBuilder.new); + registerLazySingleton( + VotingBallotLocalBuilder.new, + dispose: (builder) => builder.dispose(), + ); // Not a singleton registerFactory( diff --git a/catalyst_voices/apps/voices/lib/pages/category/card_information.dart b/catalyst_voices/apps/voices/lib/pages/category/card_information.dart index 3325efacbad6..25a31d1f3797 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/card_information.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/card_information.dart @@ -28,15 +28,15 @@ class CardInformation extends StatelessWidget { padding: padding, children: [ CategoryProposalsDetailsCard( - categoryId: category.id, + categoryRef: category.ref, categoryName: category.formattedName, - categoryProposalsCount: category.proposalsCount, + categoryFinalProposalsCount: category.finalProposalsCount, ), const SizedBox(height: 16), Offstage( offstage: !isActiveProposer, child: CreateProposalCard( - categoryId: category.id, + categoryRef: category.ref, categoryName: category.formattedName, categoryDos: category.dos, categoryDonts: category.donts, diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart b/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart index b10f8a50b7c4..ebe730b44756 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_compact_detail_view.dart @@ -32,7 +32,7 @@ class CategoryCompactDetailView extends StatelessWidget { _CategoryBrief( categoryName: category.formattedName, categoryDescription: category.description, - categoryRef: category.id, + categoryRef: category.ref, ), FundsDetailCard( allFunds: category.availableFunds, diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart b/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart index 92192e7c9a31..16ea3fdd04a5 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_detail_view.dart @@ -32,9 +32,9 @@ class CategoryDetailView extends StatelessWidget { _CategoryBrief( categoryName: category.formattedName, categoryDescription: category.description, - categoryRef: category.id, + categoryRef: category.ref, image: category.image, - proposalCount: category.proposalsCount, + finalProposalsCount: category.finalProposalsCount, ), const SizedBox(height: 64), FundsDetailCard( @@ -59,14 +59,14 @@ class _CategoryBrief extends StatelessWidget { final String categoryDescription; final SignedDocumentRef categoryRef; final SvgGenImage image; - final int proposalCount; + final int finalProposalsCount; const _CategoryBrief({ required this.categoryName, required this.categoryDescription, required this.categoryRef, required this.image, - required this.proposalCount, + required this.finalProposalsCount, }); @override @@ -98,7 +98,7 @@ class _CategoryBrief extends StatelessWidget { categoryName: categoryName, categoryDescription: categoryDescription, categoryRef: categoryRef, - showViewAllButton: proposalCount > 0, + showViewAllButton: finalProposalsCount > 0, ), ), ], diff --git a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart index f368ec5a8514..7dcf5535f469 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/category_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/category_page.dart @@ -16,9 +16,9 @@ import 'package:flutter/material.dart'; import 'package:skeletonizer/skeletonizer.dart'; class CategoryPage extends StatefulWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; - const CategoryPage({super.key, required this.categoryId}); + const CategoryPage({super.key, required this.categoryRef}); @override State createState() => _CategoryPageState(); @@ -106,6 +106,8 @@ class _CategoryDetailContent extends StatelessWidget { @override Widget build(BuildContext context) { + // TODO(damian-molinski): refactor it into single class object in category_detail_state.dart + // and do not rely on context.select here. return BlocSelector< CategoryDetailCubit, CategoryDetailState, @@ -114,7 +116,7 @@ class _CategoryDetailContent extends StatelessWidget { selector: (state) { return ( show: state.isLoading, - data: state.category ?? CampaignCategoryDetailsViewModel.placeholder(), + data: state.selectedCategoryDetails ?? CampaignCategoryDetailsViewModel.placeholder(), ); }, builder: (context, state) { @@ -139,9 +141,9 @@ class _CategoryDetailContent extends StatelessWidget { } class _CategoryDetailError extends StatelessWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; - const _CategoryDetailError({required this.categoryId}); + const _CategoryDetailError({required this.categoryRef}); @override Widget build(BuildContext context) { @@ -165,7 +167,7 @@ class _CategoryDetailError extends StatelessWidget { ? null : () { unawaited( - context.read().getCategoryDetail(categoryId), + context.read().getCategoryDetail(categoryRef), ); }, ), @@ -187,7 +189,7 @@ class _CategoryPageState extends State { children: [ const _CategoryDetailContent(), _CategoryDetailError( - categoryId: widget.categoryId, + categoryRef: widget.categoryRef, ), ].constrainedDelegate(), ), @@ -198,9 +200,9 @@ class _CategoryPageState extends State { void didUpdateWidget(CategoryPage oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.categoryId != oldWidget.categoryId) { + if (widget.categoryRef != oldWidget.categoryRef) { unawaited( - context.read().getCategoryDetail(widget.categoryId), + context.read().getCategoryDetail(widget.categoryRef), ); } } @@ -215,17 +217,16 @@ class _CategoryPageState extends State { @override void initState() { super.initState(); - unawaited(context.read().getCategories()); - unawaited( - context.read().getCategoryDetail(widget.categoryId), - ); - _listenForProposalRef(context.read()); + final cubit = context.read()..watchActiveCampaignCategories(); + unawaited(cubit.getCategoryDetail(widget.categoryRef)); + _listenForProposalRef(cubit); } + // TODO(damian-molinski): refactor it to signal pattern void _listenForProposalRef(CategoryDetailCubit cubit) { // listen for updates _categoryRefSub = cubit.stream - .map((event) => event.category?.id) + .map((event) => event.selectedCategoryRef) .distinct() .listen(_onCategoryRefChanged); } diff --git a/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart b/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart index c46ce9c5c1f4..82ce013348a1 100644 --- a/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart +++ b/catalyst_voices/apps/voices/lib/pages/category/change_category_button.dart @@ -14,27 +14,20 @@ class ChangeCategoryButton extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector< - CategoryDetailCubit, - CategoryDetailState, - List> - >( - selector: (state) { - final selectedCategory = state.category?.id ?? ''; - return state.categories - .map( - (e) => DropdownMenuViewModel( - value: ProposalsRefCategoryFilter(ref: e.id), - name: e.formattedName, - isSelected: e.id == selectedCategory, - ), - ) - .toList(); - }, + return BlocSelector( + selector: (state) => state.picker, builder: (context, state) { return CampaignCategoryPicker( onSelected: (value) => unawaited(_changeCategory(context, value)), - items: state, + items: state.items.map( + (item) { + return DropdownMenuViewModel( + value: ProposalsRefCategoryFilter(ref: item.ref), + name: item.name, + isSelected: item.isSelected, + ); + }, + ).toList(), buttonBuilder: ( context, diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart index efefedfa1f88..d61735d8e6f2 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_details/widgets/campaign_categories.dart @@ -32,7 +32,7 @@ class CampaignCategories extends StatelessWidget { .map( (e) => Skeletonizer( enabled: isLoading, - child: CampaignCategoryCard(key: ValueKey(e.id), category: e), + child: CampaignCategoryCard(key: ValueKey(e.ref), category: e), ), ) .toList(), diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_hero.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_hero.dart index 58ad2e5f656a..ba1551dcf250 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_hero.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/campaign_hero.dart @@ -1,10 +1,10 @@ +import 'package:catalyst_voices/common/ext/build_context_ext.dart'; import 'package:catalyst_voices/routes/routing/spaces_route.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_shared/catalyst_voices_shared.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; @@ -54,7 +54,7 @@ class _CampaignBrief extends StatelessWidget { key: const Key('CampaignBriefTitle'), context.l10n.heroSectionTitle, style: Theme.of(context).textTheme.displaySmall?.copyWith( - color: ThemeBuilder.buildTheme().colorScheme.primary, + color: context.colors.discoveryPrimary, ), ), const SizedBox(height: 32), @@ -62,7 +62,7 @@ class _CampaignBrief extends StatelessWidget { key: const Key('CampaignBriefDescription'), context.l10n.projectCatalystDescription, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: ThemeBuilder.buildTheme().colors.textOnPrimaryLevel0, + color: context.colors.discoveryTextOnPrimary, ), ), const SizedBox(height: 32), @@ -74,8 +74,8 @@ class _CampaignBrief extends StatelessWidget { const ProposalsRoute().go(context); }, style: FilledButton.styleFrom( - backgroundColor: ThemeBuilder.buildTheme().colorScheme.primary, - foregroundColor: ThemeBuilder.buildTheme().colorScheme.onPrimary, + backgroundColor: context.colors.discoveryPrimary, + foregroundColor: context.colors.discoveryOnPrimary, ), child: Text(context.l10n.viewProposals), ), @@ -103,8 +103,8 @@ class _DiscoveryMyProposalsButton extends StatelessWidget { const WorkspaceRoute().go(context); }, style: OutlinedButton.styleFrom( - backgroundColor: ThemeBuilder.buildTheme().colorScheme.primary, - foregroundColor: ThemeBuilder.buildTheme().colorScheme.onPrimary, + backgroundColor: context.colors.discoveryPrimary, + foregroundColor: context.colors.discoveryOnPrimary, ), child: Text(context.l10n.myProposals), ), diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/most_recent_proposals.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/most_recent_proposals.dart index 0565d3f25a9e..e6ae81988d78 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/most_recent_proposals.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/most_recent_proposals.dart @@ -1,7 +1,5 @@ -import 'package:catalyst_voices/pages/discovery/sections/most_recent_proposals/recent_proposals.dart'; -import 'package:catalyst_voices/widgets/indicators/voices_error_indicator.dart'; -import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices/pages/discovery/sections/most_recent_proposals/widgets/most_recent_offstage.dart'; +import 'package:catalyst_voices/pages/discovery/sections/most_recent_proposals/widgets/recent_proposals.dart'; import 'package:flutter/material.dart'; class MostRecentProposals extends StatelessWidget { @@ -9,91 +7,6 @@ class MostRecentProposals extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.proposals, - builder: (context, state) { - return _MostRecentProposals(data: state); - }, - ); - } -} - -class _MostRecentProposals extends StatelessWidget { - final DiscoveryMostRecentProposalsState data; - const _MostRecentProposals({ - required this.data, - }); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - _MostRecentProposalsError(data), - _ViewAllProposals( - offstage: data.showError || !data.hasMinProposalsToShow, - ), - _MostRecentProposalsData( - data, - minProposalsToShow: data.hasMinProposalsToShow, - ), - ], - ); - } -} - -class _MostRecentProposalsData extends StatelessWidget { - final DiscoveryMostRecentProposalsState state; - final bool minProposalsToShow; - - const _MostRecentProposalsData(this.state, {this.minProposalsToShow = false}); - - @override - Widget build(BuildContext context) { - return Offstage( - key: const Key('MostRecentProposalsData'), - offstage: state.showError || !minProposalsToShow, - child: RecentProposals(proposals: state.proposals), - ); - } -} - -class _MostRecentProposalsError extends StatelessWidget { - final DiscoveryMostRecentProposalsState state; - - const _MostRecentProposalsError(this.state); - - @override - Widget build(BuildContext context) { - final errorMessage = state.error?.message(context); - return Offstage( - key: const Key('MostRecentError'), - offstage: !state.showError, - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: VoicesErrorIndicator( - message: errorMessage ?? context.l10n.somethingWentWrong, - onRetry: () async { - await context.read().getMostRecentProposals(); - }, - ), - ), - ), - ); - } -} - -class _ViewAllProposals extends StatelessWidget { - final bool offstage; - - const _ViewAllProposals({this.offstage = true}); - - @override - Widget build(BuildContext context) { - return Offstage( - key: const Key('MostRecentProposalsData'), - offstage: !offstage, - child: const ViewAllProposals(), - ); + return const MostRecentOffstage(child: RecentProposals()); } } diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/recent_proposals.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/recent_proposals.dart deleted file mode 100644 index a89da9f9bcbd..000000000000 --- a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/recent_proposals.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'dart:async'; - -import 'package:catalyst_voices/common/ext/build_context_ext.dart'; -import 'package:catalyst_voices/routes/routes.dart'; -import 'package:catalyst_voices/widgets/scrollbar/voices_slider.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:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/material.dart'; - -class RecentProposals extends StatelessWidget { - final List proposals; - - const RecentProposals({ - super.key, - required this.proposals, - }); - - @override - Widget build(BuildContext context) { - return _Background( - constraints: const BoxConstraints.tightFor( - height: 800, - width: double.infinity, - ), - child: ResponsivePadding( - xs: const EdgeInsets.symmetric(horizontal: 48), - sm: const EdgeInsets.symmetric(horizontal: 48), - md: const EdgeInsets.symmetric(horizontal: 100), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 72), - const _ProposalsTitle(), - const SizedBox(height: 48), - _ProposalsScrollableList(proposals: proposals), - const SizedBox(height: 16), - const _ViewAllProposalsButton(), - const SizedBox(height: 72), - ], - ), - ), - ); - } -} - -class ViewAllProposals extends StatelessWidget { - const ViewAllProposals({super.key}); - - @override - Widget build(BuildContext context) { - return const _Background( - key: Key('MostRecentViewAllProposals'), - constraints: BoxConstraints(maxHeight: 184), - child: Center( - child: _ViewAllProposalsButton(), - ), - ); - } -} - -class _Background extends StatelessWidget { - final Widget child; - final BoxConstraints constraints; - - const _Background({ - super.key, - required this.child, - this.constraints = const BoxConstraints(maxHeight: 900), - }); - - @override - Widget build(BuildContext context) { - return Container( - key: const Key('RecentProposals'), - constraints: constraints, - decoration: BoxDecoration( - image: DecorationImage( - image: CatalystImage.asset( - VoicesAssets.images.campaignHero.path, - ).image, - fit: BoxFit.cover, - ), - ), - child: child, - ); - } -} - -class _ProposalsList extends StatelessWidget { - final ScrollController scrollController; - final List proposals; - - const _ProposalsList({ - required this.scrollController, - required this.proposals, - }); - - @override - Widget build(BuildContext context) { - return ListView.builder( - controller: scrollController, - physics: const ClampingScrollPhysics(), - scrollDirection: Axis.horizontal, - itemCount: proposals.length, - itemBuilder: (context, index) { - final proposal = proposals[index]; - final ref = proposal.selfRef; - return Padding( - key: Key('PendingProposalCard_$ref'), - padding: EdgeInsets.only(right: index < proposals.length - 1 ? 12 : 0), - child: ProposalBriefCard( - proposal: proposal, - onTap: () => _onCardTap(context, ref), - onFavoriteChanged: (value) => _onCardFavoriteChanged(context, ref, value), - ), - ); - }, - prototypeItem: Padding( - padding: const EdgeInsets.only(right: 12), - child: ProposalBriefCard(proposal: ProposalBrief.prototype()), - ), - ); - } - - Future _onCardFavoriteChanged( - BuildContext context, - DocumentRef ref, - bool isFavorite, - ) async { - final bloc = context.read(); - if (isFavorite) { - await bloc.addFavorite(ref); - } else { - await bloc.removeFavorite(ref); - } - } - - void _onCardTap(BuildContext context, DocumentRef ref) { - unawaited( - ProposalRoute( - proposalId: ref.id, - version: ref.version, - ).push(context), - ); - } -} - -class _ProposalsScrollableList extends StatefulWidget { - final List proposals; - - const _ProposalsScrollableList({required this.proposals}); - - @override - State<_ProposalsScrollableList> createState() => _ProposalsScrollableListState(); -} - -class _ProposalsScrollableListState extends State<_ProposalsScrollableList> { - late final ScrollController _scrollController; - final ValueNotifier _scrollPercentageNotifier = ValueNotifier(0); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - VoicesGestureDetector( - onHorizontalDragUpdate: _onHorizontalDrag, - child: SizedBox( - height: 440, - width: 1200, - child: Center( - child: _ProposalsList( - scrollController: _scrollController, - proposals: widget.proposals, - ), - ), - ), - ), - const SizedBox(height: 16), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: ValueListenableBuilder( - valueListenable: _scrollPercentageNotifier, - builder: (context, value, child) { - return VoicesSlider( - key: const Key('MostRecentProposalsSlider'), - value: value, - onChanged: _onSliderChanged, - ); - }, - ), - ), - ], - ); - } - - @override - void dispose() { - _scrollController.dispose(); - _scrollPercentageNotifier.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - _scrollController = ScrollController(); - _scrollController.addListener(_onScroll); - } - - void _onHorizontalDrag(DragUpdateDetails details) { - final offset = _scrollController.offset - details.delta.dx; - final overMax = offset > _scrollController.position.maxScrollExtent; - - if (offset < 0 || overMax) return; - - _scrollController.jumpTo(offset); - } - - void _onScroll() { - final scrollPosition = _scrollController.position.pixels; - final maxScroll = _scrollController.position.maxScrollExtent; - - if (maxScroll > 0) { - _scrollPercentageNotifier.value = scrollPosition / maxScroll; - } - } - - void _onSliderChanged(double value) { - final maxScroll = _scrollController.position.maxScrollExtent; - unawaited( - _scrollController.animateTo( - maxScroll * value, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ), - ); - } -} - -class _ProposalsTitle extends StatelessWidget { - const _ProposalsTitle(); - - @override - Widget build(BuildContext context) { - return Text( - key: const Key('MostRecentProposalsTitle'), - context.l10n.mostRecent, - style: context.textTheme.headlineLarge?.copyWith( - color: ThemeBuilder.buildTheme().colors.textOnPrimaryWhite, - ), - ); - } -} - -class _ViewAllProposalsButton extends StatelessWidget { - const _ViewAllProposalsButton(); - - @override - Widget build(BuildContext context) { - return VoicesFilledButton( - style: FilledButton.styleFrom( - backgroundColor: ThemeBuilder.buildTheme().colorScheme.onPrimary, - foregroundColor: ThemeBuilder.buildTheme().colorScheme.primary, - ), - child: Text( - key: const Key('ViewAllProposalsBtn'), - context.l10n.viewAllProposals, - ), - onTap: () => const ProposalsRoute().go(context), - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_offstage.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_offstage.dart new file mode 100644 index 000000000000..14878b12b573 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_offstage.dart @@ -0,0 +1,19 @@ +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:flutter/material.dart'; + +class MostRecentOffstage extends StatelessWidget { + final Widget child; + + const MostRecentOffstage({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => !state.proposals.showSection, + builder: (context, state) => Offstage(offstage: state, child: child), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_list.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_list.dart new file mode 100644 index 000000000000..d2c2962a5b8e --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_list.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:catalyst_voices/routes/routes.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class MostRecentProposalsList extends StatelessWidget { + final ScrollController? scrollController; + + const MostRecentProposalsList({ + super.key, + this.scrollController, + }); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.proposals, + builder: (context, state) { + return _MostRecentProposalsList( + scrollController: scrollController, + proposals: state.proposals, + ); + }, + ); + } +} + +class _MostRecentProposalsList extends StatelessWidget { + final List proposals; + final ScrollController? scrollController; + + const _MostRecentProposalsList({ + required this.proposals, + this.scrollController, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: scrollController, + physics: const ClampingScrollPhysics(), + scrollDirection: Axis.horizontal, + itemCount: proposals.length, + itemBuilder: (context, index) { + final proposal = proposals[index]; + final ref = proposal.selfRef; + return Padding( + key: Key('PendingProposalCard_$ref'), + padding: EdgeInsets.only(right: index < proposals.length - 1 ? 12 : 0), + child: ProposalBriefCard( + proposal: proposal, + onTap: () => _onCardTap(context, ref), + onFavoriteChanged: (value) => _onCardFavoriteChanged(context, ref, value), + ), + ); + }, + prototypeItem: Padding( + padding: const EdgeInsets.only(right: 12), + child: ProposalBriefCard(proposal: ProposalBrief.prototype()), + ), + ); + } + + Future _onCardFavoriteChanged( + BuildContext context, + DocumentRef ref, + bool isFavorite, + ) async { + final bloc = context.read(); + if (isFavorite) { + await bloc.addFavorite(ref); + } else { + await bloc.removeFavorite(ref); + } + } + + void _onCardTap(BuildContext context, DocumentRef ref) { + unawaited( + ProposalRoute( + proposalId: ref.id, + version: ref.version, + ).push(context), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_scrollable_list.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_scrollable_list.dart new file mode 100644 index 000000000000..fad129730c5c --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_scrollable_list.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:catalyst_voices/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_list.dart'; +import 'package:catalyst_voices/widgets/scrollbar/voices_slider.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:flutter/material.dart'; + +class MostRecentProposalsScrollableList extends StatefulWidget { + const MostRecentProposalsScrollableList({super.key}); + + @override + State createState() { + return _MostRecentProposalsScrollableListState(); + } +} + +class _MostRecentProposalsScrollableListState extends State { + late final ScrollController _scrollController; + final ValueNotifier _scrollPercentageNotifier = ValueNotifier(0); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + VoicesGestureDetector( + onHorizontalDragUpdate: _onHorizontalDrag, + child: SizedBox( + height: 440, + width: 1200, + child: Center(child: MostRecentProposalsList(scrollController: _scrollController)), + ), + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: ValueListenableBuilder( + valueListenable: _scrollPercentageNotifier, + builder: (context, value, child) { + return VoicesSlider( + key: const Key('MostRecentProposalsSlider'), + value: value, + onChanged: _onSliderChanged, + ); + }, + ), + ), + ], + ); + } + + @override + void dispose() { + _scrollController.dispose(); + _scrollPercentageNotifier.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); + } + + void _onHorizontalDrag(DragUpdateDetails details) { + final offset = _scrollController.offset - details.delta.dx; + final overMax = offset > _scrollController.position.maxScrollExtent; + + if (offset < 0 || overMax) return; + + _scrollController.jumpTo(offset); + } + + void _onScroll() { + final scrollPosition = _scrollController.position.pixels; + final maxScroll = _scrollController.position.maxScrollExtent; + + if (maxScroll > 0) { + _scrollPercentageNotifier.value = scrollPosition / maxScroll; + } + } + + void _onSliderChanged(double value) { + final maxScroll = _scrollController.position.maxScrollExtent; + unawaited( + _scrollController.animateTo( + maxScroll * value, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/recent_proposals.dart b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/recent_proposals.dart new file mode 100644 index 000000000000..30d3da945354 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/discovery/sections/most_recent_proposals/widgets/recent_proposals.dart @@ -0,0 +1,102 @@ +import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/pages/discovery/sections/most_recent_proposals/widgets/most_recent_proposals_scrollable_list.dart'; +import 'package:catalyst_voices/routes/routes.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_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class RecentProposals extends StatelessWidget { + const RecentProposals({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return _Background( + constraints: const BoxConstraints.tightFor( + height: 800, + width: double.infinity, + ), + child: ResponsivePadding( + xs: const EdgeInsets.symmetric(horizontal: 48), + sm: const EdgeInsets.symmetric(horizontal: 48), + md: const EdgeInsets.symmetric(horizontal: 100), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 72), + _ProposalsTitle(), + SizedBox(height: 48), + MostRecentProposalsScrollableList(), + SizedBox(height: 16), + _ViewAllProposalsButton(), + SizedBox(height: 72), + ], + ), + ), + ); + } +} + +class _Background extends StatelessWidget { + final Widget child; + final BoxConstraints constraints; + + const _Background({ + required this.child, + this.constraints = const BoxConstraints(maxHeight: 900), + }); + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('RecentProposals'), + constraints: constraints, + decoration: BoxDecoration( + image: DecorationImage( + image: CatalystImage.asset( + VoicesAssets.images.campaignHero.path, + ).image, + fit: BoxFit.cover, + ), + ), + child: child, + ); + } +} + +class _ProposalsTitle extends StatelessWidget { + const _ProposalsTitle(); + + @override + Widget build(BuildContext context) { + return Text( + key: const Key('MostRecentProposalsTitle'), + context.l10n.mostRecent, + style: context.textTheme.headlineLarge?.copyWith( + color: context.colors.discoveryTextOnPrimaryWhite, + ), + ); + } +} + +class _ViewAllProposalsButton extends StatelessWidget { + const _ViewAllProposalsButton(); + + @override + Widget build(BuildContext context) { + return VoicesFilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.colors.discoveryOnPrimary, + foregroundColor: context.colors.discoveryPrimary, + ), + child: Text( + key: const Key('ViewAllProposalsBtn'), + context.l10n.viewAllProposals, + ), + onTap: () => const ProposalsRoute().go(context), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart b/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart index 028451fe8874..46a2cbc843c7 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:catalyst_voices/common/error_handler.dart'; import 'package:catalyst_voices/common/signal_handler.dart'; +import 'package:catalyst_voices/dependency/dependencies.dart'; import 'package:catalyst_voices/pages/campaign_phase_aware/proposal_submission_phase_aware.dart'; import 'package:catalyst_voices/pages/proposals/widgets/proposals_content.dart'; import 'package:catalyst_voices/pages/proposals/widgets/proposals_header.dart'; @@ -17,7 +18,7 @@ import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; class ProposalsPage extends StatefulWidget { - final SignedDocumentRef? categoryId; + final String? categoryId; final ProposalsPageTab? tab; const ProposalsPage({ @@ -35,18 +36,28 @@ class _ProposalsPageState extends State TickerProviderStateMixin, ErrorHandlerStateMixin, SignalHandlerStateMixin { + late final _cubit = Dependencies.instance.get(); late VoicesTabController _tabController; late final PagingController _pagingController; late final StreamSubscription> _tabsSubscription; + @override + ProposalsCubit get errorEmitter => _cubit; + + @override + ProposalsCubit get signalEmitter => _cubit; + @override Widget build(BuildContext context) { - return ProposalSubmissionPhaseAware( - activeChild: HeaderAndContentLayout( - header: const ProposalsHeader(), - content: ProposalsContent( - tabController: _tabController, - pagingController: _pagingController, + return BlocProvider.value( + value: _cubit, + child: ProposalSubmissionPhaseAware( + activeChild: HeaderAndContentLayout( + header: const ProposalsHeader(), + content: ProposalsContent( + tabController: _tabController, + pagingController: _pagingController, + ), ), ), ); @@ -59,10 +70,9 @@ class _ProposalsPageState extends State final tab = widget.tab ?? ProposalsPageTab.total; if (widget.categoryId != oldWidget.categoryId || widget.tab != oldWidget.tab) { - context.read().changeFilters( - onlyMy: Optional(tab == ProposalsPageTab.my), - category: Optional(widget.categoryId), - type: tab.filter, + _cubit.changeFilters( + categoryId: Optional(widget.categoryId), + tab: Optional(tab), ); _doResetPagination(); @@ -75,6 +85,7 @@ class _ProposalsPageState extends State @override void dispose() { + unawaited(_cubit.close()); _tabController.dispose(); _pagingController.dispose(); unawaited(_tabsSubscription.cancel()); @@ -105,9 +116,8 @@ class _ProposalsPageState extends State void initState() { super.initState(); - final proposalsCubit = context.read(); final sessionCubit = context.read(); - final supportedTabs = _determineTabs(sessionCubit.state.isProposerUnlock, proposalsCubit.state); + final supportedTabs = _determineTabs(sessionCubit.state.isProposerUnlock, _cubit.state); final selectedTab = _determineTab(supportedTabs, widget.tab); _tabController = VoicesTabController( @@ -123,15 +133,13 @@ class _ProposalsPageState extends State _tabsSubscription = Rx.combineLatest2( sessionCubit.watchState().map((e) => e.isProposerUnlock), - proposalsCubit.watchState(), + _cubit.watchState(), _determineTabs, ).distinct().listen(_updateTabsIfNeeded); - proposalsCubit.init( - onlyMyProposals: selectedTab == ProposalsPageTab.my, - category: widget.categoryId, - type: selectedTab.filter, - order: const Alphabetical(), + _cubit.init( + categoryId: widget.categoryId, + tab: widget.tab ?? ProposalsPageTab.total, ); _pagingController @@ -167,7 +175,7 @@ class _ProposalsPageState extends State ProposalBrief? lastProposalId, ) async { final request = PageRequest(page: pageKey, size: pageSize); - await context.read().getProposals(request); + await _cubit.getProposals(request); } void _updateRoute({ @@ -175,7 +183,7 @@ class _ProposalsPageState extends State ProposalsPageTab? tab, }) { Router.neglect(context, () { - final effectiveCategoryId = categoryId.dataOr(widget.categoryId?.id); + final effectiveCategoryId = categoryId.dataOr(widget.categoryId); final effectiveTab = tab?.name ?? widget.tab?.name; ProposalsRoute( diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart index 17656049d741..eaacaa1dfe8c 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart @@ -24,9 +24,11 @@ class ProposalsPaginationTile extends StatelessWidget { unawaited(route.push(context)); }, onFavoriteChanged: (isFavorite) { - context.read().onChangeFavoriteProposal( - proposal.selfRef, - isFavorite: isFavorite, + unawaited( + context.read().onChangeFavoriteProposal( + proposal.selfRef, + isFavorite: isFavorite, + ), ); }, ); diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart index 4b7a64b6eb02..36a5ce9e7de3 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart @@ -3,7 +3,6 @@ import 'package:catalyst_voices/widgets/tabbar/voices_tab_bar.dart'; import 'package:catalyst_voices/widgets/tabbar/voices_tab_controller.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; @@ -15,29 +14,6 @@ class ProposalsTabs extends StatelessWidget { required this.controller, }); - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.count, - builder: (context, state) { - return _ProposalsTabs( - data: state, - controller: controller, - ); - }, - ); - } -} - -class _ProposalsTabs extends StatelessWidget { - final ProposalsCount data; - final VoicesTabController controller; - - const _ProposalsTabs({ - required this.data, - required this.controller, - }); - @override Widget build(BuildContext context) { return VoicesTabBar( @@ -51,13 +27,30 @@ class _ProposalsTabs extends StatelessWidget { VoicesTab( data: tab, key: tab.tabKey(), - child: VoicesTabText(tab.noOf(context, count: data.ofType(tab.filter))), + child: _TabText(key: ValueKey('${tab.name}Text'), tab: tab), ), ], ); } } +class _TabText extends StatelessWidget { + final ProposalsPageTab tab; + + const _TabText({ + required super.key, + required this.tab, + }); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.count[tab] ?? 0, + builder: (context, state) => VoicesTabText(tab.noOf(context, count: state)), + ); + } +} + extension on ProposalsPageTab { String noOf( BuildContext context, { diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart index 9dc43f2c0b11..0ff00141e464 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/spaces_appbar/voting_appbar.dart @@ -42,7 +42,7 @@ class _CategoryVotingAppbar extends StatelessWidget { @override Widget build(BuildContext context) { return BlocSelector( - selector: (state) => state.selectedCategory != null, + selector: (state) => state.hasSelectedCategory, builder: (context, hasCategory) { return _VotingAppbar( showLeading: isAppUnlock && hasCategory, diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_bloc_provider.dart b/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_bloc_provider.dart new file mode 100644 index 000000000000..10bf5fb760f2 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_bloc_provider.dart @@ -0,0 +1,30 @@ +import 'package:catalyst_voices/dependency/dependencies.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:flutter/material.dart'; + +class SpacesShellBlocProvider extends StatelessWidget { + final Widget child; + + const SpacesShellBlocProvider({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => Dependencies.instance.get(), + ), + BlocProvider( + create: (context) => Dependencies.instance.get(), + ), + BlocProvider( + create: (context) => Dependencies.instance.get(), + ), + ], + child: child, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart b/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart index a072b974be19..1089a2fe2dec 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/spaces_shell_page.dart @@ -5,6 +5,7 @@ import 'package:catalyst_voices/pages/campaign/admin_tools/campaign_admin_tools_ import 'package:catalyst_voices/pages/spaces/appbar/spaces_appbar.dart'; import 'package:catalyst_voices/pages/spaces/drawer/spaces_drawer.dart'; import 'package:catalyst_voices/pages/spaces/drawer/spaces_end_drawer.dart'; +import 'package:catalyst_voices/pages/spaces/spaces_shell_bloc_provider.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; @@ -65,40 +66,42 @@ class _SpacesShellPageState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listenWhen: (previous, current) => previous.enabled != current.enabled, - listener: (context, state) { - if (state.enabled) { - _insertAdminToolsOverlay(); - } else { - _removeAdminToolsOverlay(); - } - }, - child: _Shortcuts( - onToggleAdminTools: _toggleAdminTools, - child: BlocSelector( - selector: (state) => ( - isActive: state.isActive, - isProposer: state.account?.isProposer ?? false, + return SpacesShellBlocProvider( + child: BlocListener( + listenWhen: (previous, current) => previous.enabled != current.enabled, + listener: (context, state) { + if (state.enabled) { + _insertAdminToolsOverlay(); + } else { + _removeAdminToolsOverlay(); + } + }, + child: _Shortcuts( + onToggleAdminTools: _toggleAdminTools, + child: BlocSelector( + selector: (state) => ( + isActive: state.isActive, + isProposer: state.account?.isProposer ?? false, + ), + builder: (context, state) { + return Scaffold( + appBar: SpacesAppbar( + space: widget.space, + isAppUnlock: state.isActive, + isProposer: state.isProposer, + ), + drawer: state.isActive + ? SpacesDrawer( + space: widget.space, + spacesShortcutsActivators: AccessControl.allSpacesShortcutsActivators, + isUnlocked: state.isActive, + ) + : null, + endDrawer: SpacesEndDrawer(space: widget.space), + body: widget.child, + ); + }, ), - builder: (context, state) { - return Scaffold( - appBar: SpacesAppbar( - space: widget.space, - isAppUnlock: state.isActive, - isProposer: state.isProposer, - ), - drawer: state.isActive - ? SpacesDrawer( - space: widget.space, - spacesShortcutsActivators: AccessControl.allSpacesShortcutsActivators, - isUnlocked: state.isActive, - ) - : null, - endDrawer: SpacesEndDrawer(space: widget.space), - body: widget.child, - ); - }, ), ), ); @@ -120,12 +123,6 @@ class _SpacesShellPageState extends State { super.dispose(); } - @override - void initState() { - super.initState(); - context.read().add(const WatchUserProposalsEvent()); - } - OverlayEntry _createAdminToolsOverlay() { return OverlayEntry( builder: (BuildContext context) { diff --git a/catalyst_voices/apps/voices/lib/pages/voting/voting_page.dart b/catalyst_voices/apps/voices/lib/pages/voting/voting_page.dart index a37c88d7753f..1cb363340c24 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/voting_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/voting_page.dart @@ -19,7 +19,7 @@ import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; class VotingPage extends StatefulWidget { - final SignedDocumentRef? categoryId; + final String? categoryId; final VotingPageTab? tab; const VotingPage({ @@ -38,7 +38,7 @@ class _VotingPageState extends State ErrorHandlerStateMixin, SignalHandlerStateMixin { late VoicesTabController _tabController; - late final PagingController _pagingController; + late final PagingController _pagingController; late final StreamSubscription> _tabsSubscription; @override @@ -78,9 +78,8 @@ class _VotingPageState extends State if (widget.categoryId != oldWidget.categoryId || widget.tab != oldWidget.tab) { context.read().changeFilters( - onlyMy: Optional(tab == VotingPageTab.my), - category: Optional(widget.categoryId), - type: tab.filter, + categoryId: Optional(widget.categoryId), + tab: Optional(tab), ); _doResetPagination(); @@ -103,7 +102,7 @@ class _VotingPageState extends State void handleSignal(VotingSignal signal) { switch (signal) { case ChangeCategoryVotingSignal(:final to): - _updateRoute(categoryId: Optional(to?.id)); + _updateRoute(categoryId: Optional(to)); case ChangeTabVotingSignal(:final tab): _updateRoute(tab: tab); case ResetPaginationVotingSignal(): @@ -146,9 +145,8 @@ class _VotingPageState extends State ).distinct().listen(_updateTabsIfNeeded); votingCubit.init( - onlyMyProposals: selectedTab == VotingPageTab.my, - category: widget.categoryId, - type: selectedTab.filter, + categoryId: widget.categoryId, + tab: selectedTab, ); _pagingController @@ -181,7 +179,7 @@ class _VotingPageState extends State Future _handleProposalsPageRequest( int pageKey, int pageSize, - ProposalBriefVoting? lastProposalId, + ProposalBrief? lastProposalId, ) async { final request = PageRequest(page: pageKey, size: pageSize); await context.read().getProposals(request); @@ -192,7 +190,7 @@ class _VotingPageState extends State VotingPageTab? tab, }) { Router.neglect(context, () { - final effectiveCategoryId = categoryId.dataOr(widget.categoryId?.id); + final effectiveCategoryId = categoryId.dataOr(widget.categoryId); final effectiveTab = tab?.name ?? widget.tab?.name; VotingRoute( diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/content/voting_content.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/content/voting_content.dart index dea4d34a203e..d0b663e3922a 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/content/voting_content.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/content/voting_content.dart @@ -10,7 +10,7 @@ import 'package:flutter/material.dart'; class VotingContent extends StatelessWidget { final VoicesTabController tabController; - final PagingController pagingController; + final PagingController pagingController; const VotingContent({ super.key, diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination.dart index 8efd96492e53..a0aeefdaf6aa 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination.dart @@ -7,7 +7,7 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; class VotingProposalsPagination extends StatelessWidget { - final PagingController controller; + final PagingController controller; const VotingProposalsPagination({ super.key, @@ -16,9 +16,9 @@ class VotingProposalsPagination extends StatelessWidget { @override Widget build(BuildContext context) { - return PaginatedGridView( + return PaginatedGridView( pagingController: controller, - builderDelegate: PagedWrapChildBuilder( + builderDelegate: PagedWrapChildBuilder( builder: (context, item) { return VotingProposalsPaginationTile( key: ValueKey(item.selfRef), diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination_tile.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination_tile.dart index b6327d1c3fea..b55cc4493b03 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination_tile.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_pagination_tile.dart @@ -7,7 +7,7 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; class VotingProposalsPaginationTile extends StatelessWidget { - final ProposalBriefVoting proposal; + final ProposalBrief proposal; const VotingProposalsPaginationTile({ super.key, @@ -24,9 +24,11 @@ class VotingProposalsPaginationTile extends StatelessWidget { unawaited(route.push(context)); }, onFavoriteChanged: (isFavorite) { - context.read().onChangeFavoriteProposal( - proposal.selfRef, - isFavorite: isFavorite, + unawaited( + context.read().onChangeFavoriteProposal( + proposal.selfRef, + isFavorite: isFavorite, + ), ); }, onVoteAction: (action) { @@ -38,7 +40,6 @@ class VotingProposalsPaginationTile extends StatelessWidget { context.read().add(event); }, - readOnly: true, ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart index f67cb62d5c43..adb90e254906 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_sub_header.dart @@ -9,7 +9,7 @@ class VotingProposalsSubHeader extends StatelessWidget { @override Widget build(BuildContext context) { return BlocSelector( - selector: (state) => state.selectedCategory != null, + selector: (state) => state.hasSelectedCategory, builder: (context, hasCategory) { return Text( hasCategory ? context.l10n.categoryProposals : context.l10n.proposals, diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_tabs.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_tabs.dart index 5e80b3973184..516a1e0c0af4 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_tabs.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/grid/voting_proposals_tabs.dart @@ -3,7 +3,6 @@ import 'package:catalyst_voices/widgets/tabbar/voices_tab_bar.dart'; import 'package:catalyst_voices/widgets/tabbar/voices_tab_controller.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; @@ -15,29 +14,6 @@ class VotingProposalsTabs extends StatelessWidget { required this.controller, }); - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.count, - builder: (context, state) { - return _VotingProposalsTabs( - data: state, - controller: controller, - ); - }, - ); - } -} - -class _VotingProposalsTabs extends StatelessWidget { - final ProposalsCount data; - final VoicesTabController controller; - - const _VotingProposalsTabs({ - required this.data, - required this.controller, - }); - @override Widget build(BuildContext context) { return VoicesTabBar( @@ -51,13 +27,30 @@ class _VotingProposalsTabs extends StatelessWidget { VoicesTab( data: tab, key: tab.tabKey(), - child: VoicesTabText(tab.noOf(context, count: data.ofType(tab.filter))), + child: _TabText(key: ValueKey('${tab.name}Text'), tab: tab), ), ], ); } } +class _TabText extends StatelessWidget { + final VotingPageTab tab; + + const _TabText({ + required super.key, + required this.tab, + }); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.count[tab] ?? 0, + builder: (context, state) => VoicesTabText(tab.noOf(context, count: state)), + ); + } +} + extension on VotingPageTab { String noOf( BuildContext context, { diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart index 1f3e6465f25c..ced2e15aa476 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_header.dart @@ -1,13 +1,14 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; import 'package:catalyst_voices/pages/voting/widgets/header/voting_category_picker.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'; class VotingCategoryHeader extends StatelessWidget { - final CampaignCategoryDetailsViewModel category; + final VotingHeaderCategoryData category; const VotingCategoryHeader({ super.key, @@ -21,7 +22,7 @@ class VotingCategoryHeader extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: _Background(image: category.image), + child: _Background(image: CategoryImageUrl.image(category.imageUrl)), ), Padding( padding: const EdgeInsets.fromLTRB(28, 32, 32, 44), diff --git a/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_picker.dart b/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_picker.dart index 4e54efedb3b8..1096a733c4c7 100644 --- a/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_picker.dart +++ b/catalyst_voices/apps/voices/lib/pages/voting/widgets/header/voting_category_picker.dart @@ -46,7 +46,7 @@ class _CategorySelector extends StatelessWidget { for (final item in items) item.toDropdownItem(), ], onSelected: (value) { - context.read().changeSelectedCategory(value.ref); + context.read().changeSelectedCategory(value.ref?.id); }, menuTitle: context.l10n.catalystFundNo(fundNumber ?? 14), ); 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 890e49a53837..852eff0a35d5 100644 --- a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart +++ b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart @@ -33,7 +33,7 @@ final class CategoryDetailRoute extends GoRouteData with FadePageTransitionMixin @override Widget build(BuildContext context, GoRouterState state) { return CategoryPage( - categoryId: SignedDocumentRef(id: categoryId), + categoryRef: SignedDocumentRef(id: categoryId), ); } } @@ -84,13 +84,10 @@ final class ProposalsRoute extends GoRouteData with FadePageTransitionMixin { @override Widget build(BuildContext context, GoRouterState state) { - final categoryId = this.categoryId; - final categoryRef = categoryId != null ? SignedDocumentRef(id: categoryId) : null; - final tab = ProposalsPageTab.values.asNameMap()[this.tab]; return ProposalsPage( - categoryId: categoryRef, + categoryId: categoryId, tab: tab, ); } @@ -194,12 +191,11 @@ final class VotingRoute extends GoRouteData with FadePageTransitionMixin { @override Widget build(BuildContext context, GoRouterState state) { final categoryId = this.categoryId; - final categoryRef = categoryId != null ? SignedDocumentRef(id: categoryId) : null; final tab = VotingPageTab.values.asNameMap()[this.tab]; return VotingPage( - categoryId: categoryRef, + categoryId: categoryId, tab: tab, ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart b/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart index 03ec92b5c745..3d4e50a88f51 100644 --- a/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart +++ b/catalyst_voices/apps/voices/lib/widgets/campaign_timeline/campaign_timeline.dart @@ -35,16 +35,7 @@ class CampaignTimelineState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.horizontalPadding, - ...widget.timelineItems - .where((e) => !e.offstage) - .toList() - .asMap() - .entries - .map( - (entry) => CampaignTimelineCard( - timelineItem: entry.value, - ), - ), + ...widget.timelineItems.map((e) => CampaignTimelineCard(timelineItem: e)), widget.horizontalPadding, ], ), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart index 1ac46ebb8a6f..e8f0b25c6583 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/campaign_category_card.dart @@ -50,7 +50,7 @@ class CampaignCategoryCard extends StatelessWidget { const SizedBox(height: 16), _CampaignStats( availableFunds: category.availableFundsText, - proposalsCount: category.proposalsCount, + finalProposalsCount: category.finalProposalsCount, ), const SizedBox(height: 16), Flexible( @@ -61,7 +61,7 @@ class CampaignCategoryCard extends StatelessWidget { ), ), _Buttons( - categoryRef: category.id, + categoryRef: category.ref, ), ], ), @@ -162,11 +162,11 @@ class _Buttons extends StatelessWidget { class _CampaignStats extends StatelessWidget { final String availableFunds; - final int proposalsCount; + final int finalProposalsCount; const _CampaignStats({ required this.availableFunds, - required this.proposalsCount, + required this.finalProposalsCount, }); @override @@ -202,7 +202,7 @@ class _CampaignStats extends StatelessWidget { _TextStats( key: const Key('ProposalsCount'), text: context.l10n.proposals, - value: proposalsCount.toString(), + value: finalProposalsCount.toString(), ), ], ), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart index c0db570edfce..c9d24b036a09 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/category_proposals_details_card.dart @@ -8,15 +8,15 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; class CategoryProposalsDetailsCard extends StatelessWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; final String categoryName; - final int categoryProposalsCount; + final int categoryFinalProposalsCount; const CategoryProposalsDetailsCard({ super.key, - required this.categoryId, + required this.categoryRef, required this.categoryName, - required this.categoryProposalsCount, + required this.categoryFinalProposalsCount, }); @override @@ -28,7 +28,7 @@ class CategoryProposalsDetailsCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - context.l10n.proposalsSubmittedCount(categoryProposalsCount), + context.l10n.proposalsSubmittedCount(categoryFinalProposalsCount), style: context.textTheme.titleMedium?.copyWith( color: context.colors.textOnPrimaryLevel0, ), @@ -41,11 +41,11 @@ class CategoryProposalsDetailsCard extends StatelessWidget { ), const SizedBox(height: 16), Offstage( - offstage: categoryProposalsCount == 0, + offstage: categoryFinalProposalsCount == 0, child: VoicesOutlinedButton( child: Text(context.l10n.viewProposals), onTap: () { - ProposalsRoute.fromRef(categoryRef: categoryId).go(context); + ProposalsRoute.fromRef(categoryRef: categoryRef).go(context); }, ), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart index b56a72382382..93943ee23440 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/create_proposal_card.dart @@ -9,7 +9,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; class CreateProposalCard extends StatelessWidget { - final SignedDocumentRef categoryId; + final SignedDocumentRef categoryRef; final String categoryName; final List categoryDos; final List categoryDonts; @@ -17,7 +17,7 @@ class CreateProposalCard extends StatelessWidget { const CreateProposalCard({ super.key, - required this.categoryId, + required this.categoryRef, required this.categoryName, required this.categoryDos, required this.categoryDonts, @@ -51,7 +51,7 @@ class CreateProposalCard extends StatelessWidget { const SizedBox(height: 24), _SubmissionCloseAt(submissionCloseDate), const SizedBox(height: 24), - CreateProposalButton(categoryRef: categoryId), + CreateProposalButton(categoryRef: categoryRef), ], ), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart index 6815137f1ec6..dd5b1d55f896 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart @@ -16,8 +16,7 @@ class ProposalBriefCard extends StatefulWidget { final VoidCallback? onTap; final ValueChanged? onFavoriteChanged; final ValueChanged? onVoteAction; - // TODO(LynxxLynx): This should come from campaign settings - final bool readOnly; + final bool canVote; const ProposalBriefCard({ super.key, @@ -25,7 +24,7 @@ class ProposalBriefCard extends StatefulWidget { this.onTap, this.onFavoriteChanged, this.onVoteAction, - this.readOnly = false, + this.canVote = true, }); @override @@ -180,11 +179,13 @@ class _PropertyValue extends StatelessWidget { class _ProposalBriefCardState extends State { late final WidgetStatesController _statesController; + bool _isFavorite = false; + @override Widget build(BuildContext context) { final proposal = widget.proposal; - final voteData = proposal is ProposalBriefVoting ? proposal.voteData : null; + final voteData = proposal.voteData; final onVoteAction = widget.onVoteAction; return ConstrainedBox( @@ -212,8 +213,8 @@ class _ProposalBriefCardState extends State { children: [ _Topbar( proposalRef: proposal.selfRef, - isFavorite: proposal.isFavorite, - onFavoriteChanged: widget.onFavoriteChanged, + isFavorite: _isFavorite, + onFavoriteChanged: widget.onFavoriteChanged != null ? _onFavoriteChanged : null, ), const SizedBox(height: 2), _Category( @@ -240,13 +241,14 @@ class _ProposalBriefCardState extends State { updateDate: proposal.updateDate, commentsCount: proposal.commentsCount, ), - if (voteData?.hasVoted ?? false) const SizedBox(height: 12), - if (voteData != null && onVoteAction != null) + if (voteData != null && onVoteAction != null) ...[ + const SizedBox(height: 12), VoteButton( data: voteData, onSelected: onVoteAction, - readOnly: widget.readOnly, + readOnly: !widget.canVote, ), + ], ], ), ), @@ -256,6 +258,14 @@ class _ProposalBriefCardState extends State { ); } + @override + void didUpdateWidget(ProposalBriefCard oldWidget) { + super.didUpdateWidget(oldWidget); + + // Always override from proposal as its main source of truth + _isFavorite = widget.proposal.isFavorite; + } + @override void dispose() { _statesController.dispose(); @@ -266,6 +276,16 @@ class _ProposalBriefCardState extends State { void initState() { super.initState(); _statesController = WidgetStatesController(); + + _isFavorite = widget.proposal.isFavorite; + } + + // This method is here only because updating state locally gives faster feedback to the user. + void _onFavoriteChanged(bool isFavorite) { + setState(() { + _isFavorite = isFavorite; + widget.onFavoriteChanged?.call(isFavorite); + }); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart index 60ba400faf92..cc0174a7d479 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_category_selection.dart @@ -1,21 +1,18 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; import 'package:catalyst_voices/pages/category/category_compact_detail_view.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class CreateNewProposalCategorySelection extends StatefulWidget { - final List categories; - final SignedDocumentRef? selectedCategory; + final NewProposalStateCategories categories; final ValueChanged onCategorySelected; const CreateNewProposalCategorySelection({ super.key, required this.categories, - this.selectedCategory, required this.onCategorySelected, }); @@ -87,12 +84,11 @@ class _CategoryCard extends StatelessWidget { class _CreateNewProposalCategorySelectionState extends State { late final ScrollController _scrollController; - CampaignCategoryDetailsViewModel? get _selectedCategory { - return widget.categories.firstWhereOrNull((element) => element.id == widget.selectedCategory); - } - @override Widget build(BuildContext context) { + final categories = widget.categories.categories ?? []; + final selected = widget.categories.selected; + return Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -100,26 +96,26 @@ class _CreateNewProposalCategorySelectionState extends State _CategoryCard( - name: widget.categories[index].formattedName, - description: widget.categories[index].shortDescription, - ref: widget.categories[index].id, - isSelected: widget.categories[index].id == widget.selectedCategory, + name: categories[index].formattedName, + description: categories[index].shortDescription, + ref: categories[index].ref, + isSelected: categories[index].ref == selected?.ref, onCategorySelected: widget.onCategorySelected, ), separatorBuilder: (context, index) => const SizedBox(height: 16), - itemCount: widget.categories.length, + itemCount: categories.length, ), ), const SizedBox(width: 16), Expanded( flex: 2, - child: _selectedCategory != null + child: selected != null ? VoicesScrollbar( controller: _scrollController, alwaysVisible: true, child: SingleChildScrollView( controller: _scrollController, - child: CategoryCompactDetailView(category: _selectedCategory!), + child: CategoryCompactDetailView(category: selected), ), ) : const _NoneCategorySelected(), diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart index 8dfd63840fe1..884add2f7bf5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/proposals/create_new_proposal_dialog.dart @@ -14,11 +14,7 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -typedef _SelectedCategoryData = ({ - List categories, - SignedDocumentRef? value, -}); - +// TODO(damian-molinski): this widget have to be refactored into smaller files. class CreateNewProposalDialog extends StatefulWidget { final SignedDocumentRef? categoryRef; @@ -165,17 +161,11 @@ class _ProposalCategory extends StatelessWidget { text: context.l10n.selectCategory, description: context.l10n.categorySelectionDescription, ), - BlocSelector( - selector: (state) { - return ( - categories: state.categories, - value: state.categoryRef, - ); - }, + BlocSelector( + selector: (state) => state.categories, builder: (context, state) { return CreateNewProposalCategorySelection( - categories: state.categories, - selectedCategory: state.value, + categories: state, onCategorySelected: (value) => context.read().updateSelectedCategory(value), ); diff --git a/catalyst_voices/docs/performance/README.md b/catalyst_voices/docs/performance/README.md index 56ecf5ca3859..65a34e2241d6 100644 --- a/catalyst_voices/docs/performance/README.md +++ b/catalyst_voices/docs/performance/README.md @@ -16,6 +16,7 @@ flutter run --target=lib/configs/main_web.dart \ --dart-define=STRESS_TEST_PROPOSAL_INDEX_COUNT=0 \ --dart-define=STRESS_TEST_DECOMPRESSED=false \ --dart-define=STRESS_TEST_CLEAR_DB=true \ +--dart-define=CONSOLE_PROFILE=true \ --web-port=5554 \ --web-header=Cross-Origin-Opener-Policy=same-origin \ --web-header=Cross-Origin-Embedder-Policy=require-corp @@ -32,3 +33,10 @@ Be aware that number of produced documents will be higher then number of proposa * `STRESS_TEST_PROPOSAL_INDEX_COUNT`=1000, `STRESS_TEST_CLEAR_DB`=false * `STRESS_TEST_PROPOSAL_INDEX_COUNT`=2000, `STRESS_TEST_CLEAR_DB`=true * `STRESS_TEST_PROPOSAL_INDEX_COUNT`=2000, `STRESS_TEST_CLEAR_DB`=false + +## Proposals Query + +### Machine + +Current results are captured on **MacBook Pro, OS Version 15.6.1 (24G90), M1 Pro, 32 GB**. +Measurements from different machines should be in separate .csv files. diff --git a/catalyst_voices/docs/performance/indexing.csv b/catalyst_voices/docs/performance/indexing.csv index 0fcce8df41b5..8829048f09cc 100644 --- a/catalyst_voices/docs/performance/indexing.csv +++ b/catalyst_voices/docs/performance/indexing.csv @@ -1,15 +1,42 @@ -proposals_count,stored_docs_count,new_docs_count,compressed,avg_duration, PR, note -100, ,0 ,583 ,true ,0:00:04.008009 ,- ,- -100, ,559 ,548 ,true ,0:00:04.530291 ,- ,- -1000, ,0 ,5479 ,true ,0:00:32.248981 ,- ,- -1000, ,5398 ,5480 ,true ,0:00:51.579570 ,- ,- -2000, ,0 ,10976 ,true ,0:01:30.453520 ,- ,Queries start to problem -100, ,0 ,712 ,true ,0:00:01.406726 ,#3555 ,- -100, ,0 ,712 ,false ,0:00:01.182925 ,#3555 ,- -100, ,712 ,704 ,true ,0:00:02.227715 ,#3555 ,- -1000, ,0 ,7008 ,true ,0:00:09.487206 ,#3555 ,- -1000, ,0 ,7008 ,false ,0:00:09.075270 ,#3555 ,- -1000, ,7008 ,7000 ,true ,0:00:44.159021 ,#3555 ,- -2000, ,0 ,14008 ,true ,0:00:19.701200 ,#3555 ,- -2000, ,0 ,14008 ,false ,0:00:17.898250 ,#3555 ,- -2000, ,14008 ,14000 ,true ,0:01:02.166005 ,#3555 ,Failed on count query \ No newline at end of file +proposals_count ,stored_docs_count ,new_docs_count ,compressed ,avg_duration ,PR ,note +100 ,0 ,583 ,true ,0:00:04.008009 ,- ,- +100 ,559 ,548 ,true ,0:00:04.530291 ,- ,- +1000 ,0 ,5479 ,true ,0:00:32.248981 ,- ,- +1000 ,5398 ,5480 ,true ,0:00:51.579570 ,- ,- +2000 ,0 ,10976 ,true ,0:01:30.453520 ,- ,Queries start to problem +100 ,0 ,712 ,true ,0:00:01.406726 ,#3555 ,- +100 ,0 ,712 ,false ,0:00:01.182925 ,#3555 ,- +100 ,712 ,704 ,true ,0:00:02.227715 ,#3555 ,- +1000 ,0 ,7008 ,true ,0:00:09.487206 ,#3555 ,- +1000 ,0 ,7008 ,false ,0:00:09.075270 ,#3555 ,- +1000 ,7008 ,7000 ,true ,0:00:44.159021 ,#3555 ,- +2000 ,0 ,14008 ,true ,0:00:19.701200 ,#3555 ,- +2000 ,0 ,14008 ,false ,0:00:17.898250 ,#3555 ,- +2000 ,14008 ,14000 ,true ,0:01:02.166005 ,#3555 ,Failed on count query +100 ,0 ,712 ,true ,0:00:00.942015 ,#3614 ,- +100 ,0 ,712 ,false ,0:00:00.666475 ,#3614 ,- +100 ,712 ,704 ,true ,0:00:01.007421 ,#3614 ,- +1000 ,0 ,7008 ,true ,0:00:04.720250 ,#3614 ,- +1000 ,0 ,7008 ,false ,0:00:03.808820 ,#3614 ,- +1000 ,7008 ,7000 ,true ,0:00:04.811015 ,#3614 ,- +2000 ,0 ,14008 ,true ,0:00:08.978641 ,#3614 ,- +2000 ,0 ,14008 ,false ,0:00:07.245110 ,#3614 ,- +2000 ,14008 ,14000 ,true ,0:00:09.089615 ,#3614 ,- +100 ,0 ,712 ,true ,0:00:00.906899 ,#3672 ,DocumentAuthors table (2x more db entries) +100 ,0 ,712 ,false ,0:00:00.661565 ,#3672 ,- +100 ,712 ,704 ,true ,0:00:01.160340 ,#3672 ,- +1000 ,0 ,7008 ,true ,0:00:04.722740 ,#3672 ,- +1000 ,0 ,7008 ,false ,0:00:03.891115 ,#3672 ,- +1000 ,7008 ,7000 ,true ,0:00:05.157430 ,#3672 ,- +2000 ,0 ,14008 ,true ,0:00:09.569875 ,#3672 ,- +2000 ,0 ,14008 ,false ,0:00:07.510065 ,#3672 ,- +2000 ,14008 ,14000 ,true ,0:00:09.254585 ,#3672 ,- +100 ,0 ,712 ,true ,0:00:00.921730 ,#3622 ,wasm +100 ,0 ,712 ,false ,0:00:00.676296 ,#3622 ,- +100 ,712 ,704 ,true ,0:00:01.077855 ,#3622 ,- +1000 ,0 ,7008 ,true ,0:00:04.164745 ,#3622 ,- +1000 ,0 ,7008 ,false ,0:00:03.238630 ,#3622 ,- +1000 ,7008 ,7000 ,true ,0:00:04.314010 ,#3622 ,- +2000 ,0 ,14008 ,true ,0:00:07.858330 ,#3622 ,- +2000 ,0 ,14008 ,false ,0:00:06.202460 ,#3622 ,- +2000 ,14008 ,14000 ,true ,0:00:07.949531 ,#3622 ,- \ No newline at end of file diff --git a/catalyst_voices/docs/performance/proposals_count_query.csv b/catalyst_voices/docs/performance/proposals_count_query.csv new file mode 100644 index 000000000000..31c83ce2e318 --- /dev/null +++ b/catalyst_voices/docs/performance/proposals_count_query.csv @@ -0,0 +1,37 @@ +docs_count ,filer ,avg_duration ,PR ,note +712 ,categories ,0:00:00.264954 ,#3555 ,- +712 ,categories:drafts ,0:00:00.188969 ,#3555 ,- +712 ,categories:finals ,0:00:00.207445 ,#3555 ,- +7008 ,categories ,0:00:01.416110 ,#3555 ,- +7008 ,categories:drafts ,0:00:01.474370 ,#3555 ,- +7008 ,categories:finals ,0:00:01.466851 ,#3555 ,- +14008 ,categories ,0:00:04.123159 ,#3555 ,- +14008 ,categories:drafts ,0:00:04.306970 ,#3555 ,- +14008 ,categories:finals ,0:00:04.242760 ,#3555 ,- +712 ,categories ,0:00:00.146675 ,#3614 ,- +712 ,categories:drafts ,0:00:00.161375 ,#3614 ,- +712 ,categories:finals ,0:00:00.175615 ,#3614 ,- +7008 ,categories ,0:00:00.275420 ,#3614 ,- +7008 ,categories:drafts ,0:00:00.343510 ,#3614 ,- +7008 ,categories:finals ,0:00:00.392119 ,#3614 ,- +14008 ,categories ,0:00:00.551765 ,#3614 ,- +14008 ,categories:drafts ,0:00:00.692240 ,#3614 ,- +14008 ,categories:finals ,0:00:00.832700 ,#3614 ,- +712 ,categories ,0:00:00.072415 ,#3672 ,DocumentAuthors table (2x more db entries) +712 ,categories:drafts ,0:00:00.081024 ,#3672 ,- +712 ,categories:finals ,0:00:00.081195 ,#3672 ,- +7008 ,categories ,0:00:00.116349 ,#3672 ,- +7008 ,categories:drafts ,0:00:00.174726 ,#3672 ,- +7008 ,categories:finals ,0:00:00.221865 ,#3672 ,- +14008 ,categories ,0:00:00.189561 ,#3672 ,- +14008 ,categories:drafts ,0:00:00.307680 ,#3672 ,- +14008 ,categories:finals ,0:00:00.468149 ,#3672 ,- +712 ,categories ,0:00:00.063385 ,#3622 ,wasm +712 ,categories:drafts ,0:00:00.066660 ,#3622 ,- +712 ,categories:finals ,0:00:00.071410 ,#3622 ,- +7008 ,categories ,0:00:00.112590 ,#3622 ,- +7008 ,categories:drafts ,0:00:00.160765 ,#3622 ,- +7008 ,categories:finals ,0:00:00.216990 ,#3622 ,- +14008 ,categories ,0:00:00.162824 ,#3622 ,- +14008 ,categories:drafts ,0:00:00.278720 ,#3622 ,- +14008 ,categories:finals ,0:00:00.439105 ,#3622 ,- \ No newline at end of file diff --git a/catalyst_voices/docs/performance/proposals_query.csv b/catalyst_voices/docs/performance/proposals_query.csv new file mode 100644 index 000000000000..88039092a5fc --- /dev/null +++ b/catalyst_voices/docs/performance/proposals_query.csv @@ -0,0 +1,37 @@ +docs_count ,filer ,avg_duration ,PR ,note +712 ,categories ,0:00:00.342165 ,#3555 ,- +712 ,categories:drafts ,0:00:00.336929 ,#3555 ,- +712 ,categories:finals ,0:00:00.347465 ,#3555 ,- +7008 ,categories ,0:00:02.576425 ,#3555 ,- +7008 ,categories:drafts ,0:00:02.642546 ,#3555 ,- +7008 ,categories:finals ,0:00:02.626661 ,#3555 ,- +14008 ,categories ,0:00:07.763035 ,#3555 ,- +14008 ,categories:drafts ,0:00:07.942379 ,#3555 ,- +14008 ,categories:finals ,0:00:07.895095 ,#3555 ,- +712 ,categories ,0:00:00.130960 ,#3614 ,- +712 ,categories:drafts ,0:00:00.134510 ,#3614 ,- +712 ,categories:finals ,0:00:00.138945 ,#3614 ,- +7008 ,categories ,0:00:00.220099 ,#3614 ,- +7008 ,categories:drafts ,0:00:00.239930 ,#3614 ,- +7008 ,categories:finals ,0:00:01.165869 ,#3614 ,- +14008 ,categories ,0:00:00.372244 ,#3614 ,- +14008 ,categories:drafts ,0:00:00.380780 ,#3614 ,- +14008 ,categories:finals ,0:00:04.466084 ,#3614 ,- +712 ,categories ,0:00:00.076324 ,#3672 ,DocumentAuthors table (2x more db entries) +712 ,categories:drafts ,0:00:00.097465 ,#3672 ,- +712 ,categories:finals ,0:00:00.094671 ,#3672 ,- +7008 ,categories ,0:00:00.567814 ,#3672 ,- +7008 ,categories:drafts ,0:00:00.474589 ,#3672 ,- +7008 ,categories:finals ,0:00:01.290510 ,#3672 ,- +14008 ,categories ,0:00:02.114651 ,#3672 ,- +14008 ,categories:drafts ,0:00:01.690294 ,#3672 ,- +14008 ,categories:finals ,0:00:05.336655 ,#3672 ,- +712 ,categories ,0:00:00.134385 ,#3622 ,wasm +712 ,categories:drafts ,0:00:00.132170 ,#3622 ,- +712 ,categories:finals ,0:00:00.141990 ,#3622 ,- +7008 ,categories ,0:00:00.654435 ,#3622 ,- +7008 ,categories:drafts ,0:00:00.534680 ,#3622 ,- +7008 ,categories:finals ,0:00:01.294485 ,#3622 ,- +14008 ,categories ,0:00:02.108506 ,#3622 ,- +14008 ,categories:drafts ,0:00:01.585000 ,#3622 ,- +14008 ,categories:finals ,0:00:05.024950 ,#3622 ,- \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart index 2f3bccba981d..d38540a387b3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit.dart @@ -1,66 +1,68 @@ +import 'dart:async'; + import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_blocs/src/category/category_detail_cubit_cache.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/foundation.dart'; + +final _logger = Logger('CategoryDetailCubit'); /// Manages the category detail. /// Allows to get the category detail and list of categories. class CategoryDetailCubit extends Cubit { final CampaignService _campaignService; - Campaign? _cachedCampaign; + + CategoryDetailCubitCache _cache = const CategoryDetailCubitCache(); + + StreamSubscription?>? _categoriesSub; + StreamSubscription? _selectedCategoryTotalAskSub; CategoryDetailCubit( this._campaignService, ) : super(const CategoryDetailState(isLoading: true)); - Future getCategories() async { - if (_cachedCampaign != null) return; + @override + Future close() async { + await _categoriesSub?.cancel(); + _categoriesSub = null; - if (!state.isLoading) { - emit(state.copyWith(isLoading: true)); - } + await _selectedCategoryTotalAskSub?.cancel(); + _selectedCategoryTotalAskSub = null; - final campaign = await _campaignService.getActiveCampaign(); - if (campaign == null) { - _cachedCampaign = campaign; - return emit( - state.copyWith( - isLoading: false, - error: const Optional.of(LocalizedUnknownException()), - ), - ); + await super.close(); + } + + Future getCategoryDetail(SignedDocumentRef categoryRef) async { + if (categoryRef.id == _cache.selectedCategoryRef?.id) { + emit(state.copyWith(isLoading: false)); + return; } - final categoriesModels = campaign.categories - .map(CampaignCategoryDetailsViewModel.fromModel) - .toList(); + await _selectedCategoryTotalAskSub?.cancel(); + _selectedCategoryTotalAskSub = null; - emit( - state.copyWith( - categories: categoriesModels, - error: const Optional.empty(), - ), + _cache = _cache.copyWith( + selectedCategoryRef: Optional(categoryRef), + selectedCategory: const Optional.empty(), + selectedCategoryTotalAsk: const Optional.empty(), ); - } - Future getCategoryDetail(SignedDocumentRef categoryId) async { - if (categoryId.id == state.category?.id.id) { - return emit(state.copyWith(isLoading: false)); - } + emit(state.copyWith(selectedCategoryRef: Optional(categoryRef))); + _updateCategoriesState(); if (!state.isLoading) { emit(state.copyWith(isLoading: true)); } try { - final category = await _campaignService.getCategory(categoryId); - emit( - state.copyWith( - isLoading: false, - category: CampaignCategoryDetailsViewModel.fromModel(category), - error: const Optional.empty(), - ), - ); + final category = await _campaignService.getCategory(categoryRef); + _cache = _cache.copyWith(selectedCategory: Optional(category)); + + _watchCategoryTotalAsk(categoryRef); + _updateSelectedCategoryState(); } catch (error) { emit( state.copyWith( @@ -70,4 +72,69 @@ class CategoryDetailCubit extends Cubit { ); } } + + void watchActiveCampaignCategories() { + unawaited(_categoriesSub?.cancel()); + _categoriesSub = _campaignService.watchActiveCampaign + .map((event) => event?.categories) + .distinct(listEquals) + .listen(_handleCategoriesChange); + } + + void _handleCategoriesChange(List? categories) { + _cache = _cache.copyWith(categories: Optional(categories)); + _updateCategoriesState(); + } + + void _handleCategoryTotalAskChange(CampaignCategoryTotalAsk data) { + _logger.finest('Category total ask changed: $data'); + _cache = _cache.copyWith(selectedCategoryTotalAsk: Optional(data)); + _updateSelectedCategoryState(); + } + + void _updateCategoriesState() { + final selectedCategoryRef = _cache.selectedCategoryRef; + final categories = _cache.categories ?? []; + + final items = categories.map( + (category) { + return CategoryDetailStatePickerItem( + ref: category.selfRef, + name: category.formattedCategoryName, + isSelected: category.selfRef == selectedCategoryRef, + ); + }, + ).toList(); + + emit(state.copyWith(picker: CategoryDetailStatePicker(items: items))); + } + + void _updateSelectedCategoryState() { + final selectedCategory = _cache.selectedCategory; + final selectedCategoryTotalAsk = _cache.selectedCategoryTotalAsk; + + final selectedCategoryState = selectedCategory != null + ? CampaignCategoryDetailsViewModel.fromModel( + selectedCategory, + finalProposalsCount: selectedCategoryTotalAsk?.finalProposalsCount ?? 0, + totalAsk: selectedCategoryTotalAsk?.totalAsk ?? MultiCurrencyAmount.zero(), + ) + : null; + + final updatedState = state.copyWith( + isLoading: selectedCategory == null || selectedCategoryTotalAsk == null, + selectedCategoryDetails: Optional(selectedCategoryState), + error: const Optional.empty(), + ); + + emit(updatedState); + } + + void _watchCategoryTotalAsk(SignedDocumentRef ref) { + unawaited(_selectedCategoryTotalAskSub?.cancel()); + _selectedCategoryTotalAskSub = _campaignService + .watchCategoryTotalAsk(ref: ref) + .distinct() + .listen(_handleCategoryTotalAskChange); + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart new file mode 100644 index 000000000000..e3c10bf72f42 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_cubit_cache.dart @@ -0,0 +1,43 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CategoryDetailCubitCache extends Equatable { + final Campaign? activeCampaign; + final List? categories; + final SignedDocumentRef? selectedCategoryRef; + final CampaignCategory? selectedCategory; + final CampaignCategoryTotalAsk? selectedCategoryTotalAsk; + + const CategoryDetailCubitCache({ + this.activeCampaign, + this.categories, + this.selectedCategoryRef, + this.selectedCategory, + this.selectedCategoryTotalAsk, + }); + + @override + List get props => [ + activeCampaign, + categories, + selectedCategoryRef, + selectedCategory, + selectedCategoryTotalAsk, + ]; + + CategoryDetailCubitCache copyWith({ + Optional? activeCampaign, + Optional>? categories, + Optional? selectedCategoryRef, + Optional? selectedCategory, + Optional? selectedCategoryTotalAsk, + }) { + return CategoryDetailCubitCache( + activeCampaign: activeCampaign.dataOr(this.activeCampaign), + categories: categories.dataOr(this.categories), + selectedCategoryRef: selectedCategoryRef.dataOr(this.selectedCategoryRef), + selectedCategory: selectedCategory.dataOr(this.selectedCategory), + selectedCategoryTotalAsk: selectedCategoryTotalAsk.dataOr(this.selectedCategoryTotalAsk), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart index 0997a4c5d156..cb7428600bbd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/category/category_detail_state.dart @@ -3,37 +3,68 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; class CategoryDetailState extends Equatable { - final CampaignCategoryDetailsViewModel? category; - final List categories; + final SignedDocumentRef? selectedCategoryRef; + final CampaignCategoryDetailsViewModel? selectedCategoryDetails; + final CategoryDetailStatePicker picker; final bool isLoading; final LocalizedException? error; const CategoryDetailState({ - this.category, - this.categories = const [], + this.selectedCategoryRef, + this.selectedCategoryDetails, + this.picker = const CategoryDetailStatePicker(), this.isLoading = false, this.error, }); @override List get props => [ - category, - categories, + selectedCategoryRef, + selectedCategoryDetails, + picker, isLoading, error, ]; CategoryDetailState copyWith({ - CampaignCategoryDetailsViewModel? category, - List? categories, + Optional? selectedCategoryRef, + Optional? selectedCategoryDetails, + CategoryDetailStatePicker? picker, bool? isLoading, Optional? error, }) { return CategoryDetailState( - category: category ?? this.category, - categories: categories ?? this.categories, + selectedCategoryRef: selectedCategoryRef.dataOr(this.selectedCategoryRef), + selectedCategoryDetails: selectedCategoryDetails.dataOr(this.selectedCategoryDetails), + picker: picker ?? this.picker, isLoading: isLoading ?? this.isLoading, error: error.dataOr(this.error), ); } } + +final class CategoryDetailStatePicker extends Equatable { + final List items; + + const CategoryDetailStatePicker({ + this.items = const [], + }); + + @override + List get props => [items]; +} + +final class CategoryDetailStatePickerItem extends Equatable { + final SignedDocumentRef ref; + final String name; + final bool isSelected; + + const CategoryDetailStatePickerItem({ + required this.ref, + required this.name, + required this.isSelected, + }); + + @override + List get props => [ref, name, isSelected]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart index 29fe82c257ae..50eff2b2bba0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_blocs/src/discovery/discovery_cubit_cache.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; @@ -9,6 +10,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; const _maxRecentProposalsCount = 7; +const List _offstagePhases = [CampaignPhaseType.reviewRegistration]; + final _logger = Logger('DiscoveryCubit'); /// Manages all data for the discovery screen. @@ -18,10 +21,16 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { final CampaignService _campaignService; final ProposalService _proposalService; - StreamSubscription>? _proposalsSub; - StreamSubscription>? _favoritesProposalsIdsSub; + DiscoveryCubitCache _cache = const DiscoveryCubitCache(); + + StreamSubscription>? _proposalsV2Sub; + StreamSubscription? _activeCampaignSub; + StreamSubscription? _activeCampaignTotalAskSub; - DiscoveryCubit(this._campaignService, this._proposalService) : super(const DiscoveryState()); + DiscoveryCubit( + this._campaignService, + this._proposalService, + ) : super(const DiscoveryState()); Future addFavorite(DocumentRef ref) async { try { @@ -34,94 +43,47 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { @override Future close() async { - await _proposalsSub?.cancel(); - _proposalsSub = null; + await _proposalsV2Sub?.cancel(); + _proposalsV2Sub = null; + + await _activeCampaignSub?.cancel(); + _activeCampaignSub = null; - await _favoritesProposalsIdsSub?.cancel(); - _favoritesProposalsIdsSub = null; + await _activeCampaignTotalAskSub?.cancel(); + _activeCampaignTotalAskSub = null; return super.close(); } Future getAllData() async { - await Future.wait([ - getCurrentCampaign(), - getMostRecentProposals(), - ]); + getMostRecentProposals(); + await getCurrentCampaign(); } Future getCurrentCampaign() async { try { - emit( - state.copyWith( - campaign: const DiscoveryCampaignState(), - ), - ); - final campaign = (await _campaignService.getActiveCampaign())!; - final timeline = campaign.timeline.phases.map(CampaignTimelineViewModel.fromModel).toList(); - final currentCampaign = CurrentCampaignInfoViewModel.fromModel(campaign); - final categoriesModel = campaign.categories - .map(CampaignCategoryDetailsViewModel.fromModel) - .toList(); - final datesEvents = _buildCampaignDatesEvents(timeline); + emit(state.copyWith(campaign: const DiscoveryCampaignState(isLoading: true))); + + final campaign = await _campaignService.getActiveCampaign(); if (!isClosed) { - emit( - state.copyWith( - campaign: DiscoveryCampaignState( - currentCampaign: currentCampaign, - campaignTimeline: timeline, - categories: categoriesModel, - datesEvents: datesEvents, - isLoading: false, - ), - ), - ); + _handleActiveCampaignChange(campaign); + _watchActiveCampaign(); } } catch (e, st) { _logger.severe('Error getting current campaign', e, st); if (!isClosed) { - emit( - state.copyWith( - campaign: DiscoveryCampaignState(error: LocalizedException.create(e)), - ), - ); + final campaignState = DiscoveryCampaignState(error: LocalizedException.create(e)); + emit(state.copyWith(campaign: campaignState)); } } } - Future getMostRecentProposals() async { - try { - unawaited(_proposalsSub?.cancel()); - unawaited(_favoritesProposalsIdsSub?.cancel()); - - emit(state.copyWith(proposals: const DiscoveryMostRecentProposalsState())); - final campaign = await _campaignService.getActiveCampaign(); - if (!isClosed) { - _proposalsSub = _buildProposalsSub(); - _favoritesProposalsIdsSub = _buildFavoritesProposalsIdsSub(); - - emit( - state.copyWith( - proposals: state.proposals.copyWith( - isLoading: false, - showComments: campaign?.supportsComments ?? false, - ), - ), - ); - } - } catch (e, st) { - _logger.severe('Error getting most recent proposals', e, st); + void getMostRecentProposals() { + emit(state.copyWith(proposals: const DiscoveryMostRecentProposalsState())); - if (!isClosed) { - emit( - state.copyWith( - proposals: DiscoveryMostRecentProposalsState(error: LocalizedException.create(e)), - ), - ); - } - } + _watchMostRecentProposals(); } Future removeFavorite(DocumentRef ref) async { @@ -133,44 +95,22 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { } } - CampaignDatesEventsState _buildCampaignDatesEvents( - List campaignTimeline, - ) { - final reviewItems = - [ - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.reviewRegistration, - ), - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.communityReview, - ), - ] - .whereType() - .map( - (e) => CampaignTimelineEventWithTitle( - dateRange: e.timeline, - type: e.type, - ), - ) - .toList(); - - final votingItems = - [ - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.votingRegistration, - ), - campaignTimeline.firstWhereOrNull( - (e) => e.type == CampaignPhaseType.communityVoting, - ), - ] - .whereType() - .map( - (e) => CampaignTimelineEventWithTitle( - dateRange: e.timeline, - type: e.type, - ), - ) - .toList(); + CampaignDatesEventsState _buildCampaignDatesEventsState(List data) { + final reviewReg = data.firstWhereOrNull((e) => e.type.isReviewRegistration); + final communityRev = data.firstWhereOrNull((e) => e.type.isCommunityReview); + final reviewPhases = [?reviewReg, ?communityRev]; + + final reviewItems = reviewPhases + .map((e) => CampaignTimelineEventWithTitle(dateRange: e.timeline, type: e.type)) + .toList(); + + final votingReg = data.firstWhereOrNull((e) => e.type.isVotingRegistration); + final communityVoting = data.firstWhereOrNull((e) => e.type.isCommunityVoting); + final votingPhases = [?votingReg, ?communityVoting]; + + final votingItems = votingPhases + .map((e) => CampaignTimelineEventWithTitle(dateRange: e.timeline, type: e.type)) + .toList(); return CampaignDatesEventsState( reviewTimelineItems: reviewItems, @@ -178,73 +118,110 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { ); } - StreamSubscription> _buildFavoritesProposalsIdsSub() { - _logger.info('Building favorites proposals ids subscription'); + void _handleActiveCampaignChange(Campaign? campaign) { + if (_cache.activeCampaign?.selfRef == campaign?.selfRef) { + return; + } + + _cache = _cache.copyWith( + activeCampaign: Optional(campaign), + campaignTotalAsk: const Optional.empty(), + ); - return _proposalService - .watchFavoritesProposalsIds() - .distinct(listEquals) - .listen( - _emitFavoritesIds, - onError: _emitMostRecentError, - ); - } + _updateCampaignState(); - StreamSubscription> _buildProposalsSub() { - _logger.fine('Building proposals subscription'); + unawaited(_activeCampaignTotalAskSub?.cancel()); + _activeCampaignTotalAskSub = null; - return _proposalService - .watchProposalsPage( - request: const PageRequest(page: 0, size: _maxRecentProposalsCount), - filters: ProposalsFilters.forActiveCampaign(), - order: const UpdateDate(isAscending: false), - ) - .map((event) => event.items) - .distinct(listEquals) - .listen(_handleProposals, onError: _emitMostRecentError); + if (campaign != null) _watchCampaignTotalAsk(campaign); } - void _emitFavoritesIds(List ids) { - emit(state.copyWith(proposals: state.proposals.updateFavorites(ids))); + void _handleCampaignTotalAskChange(CampaignTotalAsk data) { + _logger.finest('Campaign total ask changed: $data'); + _cache = _cache.copyWith(campaignTotalAsk: Optional(data)); + _updateCampaignState(); } - void _emitMostRecentError(Object error, StackTrace stackTrace) { - _logger.severe('Loading recent proposals emitted', error, stackTrace); - - emit( - state.copyWith( - proposals: state.proposals.copyWith( - isLoading: false, - error: LocalizedException.create(error), - proposals: const [], - ), - ), + void _handleProposalsChange(List proposals) { + _logger.finest('Got proposals[${proposals.length}]'); + + final updatedProposalsState = state.proposals.copyWith( + proposals: proposals, + showSection: proposals.length == _maxRecentProposalsCount, ); + + emit(state.copyWith(proposals: updatedProposalsState)); } - void _emitMostRecentProposals(List proposals) { - final proposalList = proposals - .map( - (e) => ProposalBrief.fromProposal( - e, - isFavorite: state.proposals.favoritesIds.contains(e.selfRef.id), - showComments: state.proposals.showComments, - ), - ) + void _updateCampaignState() { + final campaign = _cache.activeCampaign; + final campaignTotalAsk = _cache.campaignTotalAsk ?? const CampaignTotalAsk(categoriesAsks: {}); + + final phases = campaign?.timeline.phases ?? []; + final timeline = phases + .where((phase) => !_offstagePhases.contains(phase.type)) + .map(CampaignTimelineViewModel.fromModel) .toList(); + final datesEvents = _buildCampaignDatesEventsState(timeline); - emit( - state.copyWith( - proposals: state.proposals.copyWith( - proposals: proposalList, - ), - ), + final currentCampaign = CurrentCampaignInfoViewModel( + title: campaign?.name ?? '', + allFunds: campaign?.allFunds ?? MultiCurrencyAmount.zero(), + totalAsk: campaignTotalAsk.totalAsk, + timeline: timeline, ); + + final categories = campaign?.categories ?? []; + final categoriesModel = categories.map( + (category) { + final categoryTotalAsk = + campaignTotalAsk.categoriesAsks[category.selfRef] ?? + CampaignCategoryTotalAsk.zero(category.selfRef); + + return CampaignCategoryDetailsViewModel.fromModel( + category, + finalProposalsCount: categoryTotalAsk.finalProposalsCount, + totalAsk: categoryTotalAsk.totalAsk, + ); + }, + ).toList(); + + final campaignState = DiscoveryCampaignState( + currentCampaign: currentCampaign, + campaignTimeline: timeline, + categories: categoriesModel, + datesEvents: datesEvents, + ); + + emit(state.copyWith(campaign: campaignState)); + } + + void _watchActiveCampaign() { + unawaited(_activeCampaignSub?.cancel()); + + _activeCampaignSub = _campaignService.watchActiveCampaign + .distinct((previous, next) => previous?.selfRef != next?.selfRef) + .listen(_handleActiveCampaignChange); + } + + void _watchCampaignTotalAsk(Campaign campaign) { + final filters = ProposalsTotalAskFilters(campaign: CampaignFilters.from(campaign)); + _activeCampaignTotalAskSub = _campaignService + .watchCampaignTotalAsk(filters: filters) + .distinct() + .listen(_handleCampaignTotalAskChange); } - Future _handleProposals(List proposals) async { - _logger.info('Got proposals: ${proposals.length}'); + void _watchMostRecentProposals() { + unawaited(_proposalsV2Sub?.cancel()); - _emitMostRecentProposals(proposals); + _proposalsV2Sub = _proposalService + .watchProposalsBriefPageV2( + request: const PageRequest(page: 0, size: _maxRecentProposalsCount), + ) + .map((page) => page.items) + .distinct(listEquals) + .map((items) => items.map(ProposalBrief.fromData).toList()) + .listen(_handleProposalsChange); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart new file mode 100644 index 000000000000..977a3d726cb7 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit_cache.dart @@ -0,0 +1,28 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class DiscoveryCubitCache extends Equatable { + final Campaign? activeCampaign; + final CampaignTotalAsk? campaignTotalAsk; + + const DiscoveryCubitCache({ + this.activeCampaign, + this.campaignTotalAsk, + }); + + @override + List get props => [ + activeCampaign, + campaignTotalAsk, + ]; + + DiscoveryCubitCache copyWith({ + Optional? activeCampaign, + Optional? campaignTotalAsk, + }) { + return DiscoveryCubitCache( + activeCampaign: activeCampaign.dataOr(this.activeCampaign), + campaignTotalAsk: campaignTotalAsk.dataOr(this.campaignTotalAsk), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart index f674bb43d3ff..9cfb5866de4f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_state.dart @@ -27,7 +27,7 @@ final class DiscoveryCampaignState extends Equatable { final CampaignDatesEventsState datesEvents; const DiscoveryCampaignState({ - this.isLoading = true, + this.isLoading = false, this.error, this.currentCampaign, this.campaignTimeline = const [], @@ -67,55 +67,27 @@ final class DiscoveryCampaignState extends Equatable { } final class DiscoveryMostRecentProposalsState extends Equatable { - static const _minProposalsToShowRecent = 6; - - final LocalizedException? error; final List proposals; - final List favoritesIds; - final bool showComments; + final bool showSection; const DiscoveryMostRecentProposalsState({ - this.error, this.proposals = const [], - this.favoritesIds = const [], - this.showComments = false, + this.showSection = false, }); - bool get hasMinProposalsToShow => proposals.length > _minProposalsToShowRecent; - @override List get props => [ - error, proposals, - favoritesIds, - showComments, + showSection, ]; - bool get showError => error != null; - DiscoveryMostRecentProposalsState copyWith({ - bool? isLoading, - LocalizedException? error, List? proposals, - List? favoritesIds, - bool? showComments, + bool? showSection, }) { return DiscoveryMostRecentProposalsState( - error: error ?? this.error, proposals: proposals ?? this.proposals, - favoritesIds: favoritesIds ?? this.favoritesIds, - showComments: showComments ?? this.showComments, - ); - } - - DiscoveryMostRecentProposalsState updateFavorites(List ids) { - final updatedProposals = [ - ...proposals, - ].map((e) => e.copyWith(isFavorite: ids.contains(e.selfRef.id))).toList(); - - return copyWith( - proposals: updatedProposals, - favoritesIds: ids, + showSection: showSection ?? this.showSection, ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart new file mode 100644 index 000000000000..c26e14d8ef36 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cache.dart @@ -0,0 +1,33 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class NewProposalCache extends Equatable { + final SignedDocumentRef? categoryRef; + final Campaign? activeCampaign; + final CampaignTotalAsk? campaignTotalAsk; + + const NewProposalCache({ + this.categoryRef, + this.activeCampaign, + this.campaignTotalAsk, + }); + + @override + List get props => [ + categoryRef, + activeCampaign, + campaignTotalAsk, + ]; + + NewProposalCache copyWith({ + Optional? categoryRef, + Optional? activeCampaign, + Optional? campaignTotalAsk, + }) { + return NewProposalCache( + categoryRef: categoryRef.dataOr(this.categoryRef), + activeCampaign: activeCampaign.dataOr(this.activeCampaign), + campaignTotalAsk: campaignTotalAsk.dataOr(this.campaignTotalAsk), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart index fc8db7cd088b..71987dd2f433 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:catalyst_voices_blocs/src/common/bloc_error_emitter_mixin.dart'; +import 'package:catalyst_voices_blocs/src/proposal_builder/new_proposal/new_proposal_cache.dart'; import 'package:catalyst_voices_blocs/src/proposal_builder/new_proposal/new_proposal_state.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; @@ -17,6 +18,11 @@ class NewProposalCubit extends Cubit final ProposalService _proposalService; final DocumentMapper _documentMapper; + NewProposalCache _cache = const NewProposalCache(); + + StreamSubscription? _activeCampaignSub; + StreamSubscription? _activeCampaignTotalAskSub; + NewProposalCubit( this._campaignService, this._proposalService, @@ -27,6 +33,17 @@ class NewProposalCubit extends Cubit ), ); + @override + Future close() async { + await _activeCampaignSub?.cancel(); + _activeCampaignSub = null; + + await _activeCampaignTotalAskSub?.cancel(); + _activeCampaignTotalAskSub = null; + + return super.close(); + } + Future createDraft() async { try { emit(state.copyWith(isCreatingProposal: true)); @@ -70,53 +87,19 @@ class NewProposalCubit extends Cubit } Future load({SignedDocumentRef? categoryRef}) async { - try { - emit(NewProposalState.loading()); - final step = categoryRef == null - ? const CreateProposalWithoutPreselectedCategoryStep() - : const CreateProposalWithPreselectedCategoryStep(); - final campaign = await _campaignService.getActiveCampaign(); - if (campaign == null) { - throw StateError('Cannot load proposal, active campaign not found'); - } + _cache = _cache.copyWith(categoryRef: Optional(categoryRef)); - // TODO(LynxLynxx): when we have separate proposal template for generic questions use it here - // right now user can start creating proposal without selecting category. - // Right now every category have the same requirements for title so we can do a fallback for - // first category from the list. - final templateRef = campaign.categories - .cast() - .firstWhere( - (e) => e?.selfRef == categoryRef, - orElse: () => campaign.categories.firstOrNull, - ) - ?.proposalTemplateRef; - - final template = templateRef != null - ? await _proposalService.getProposalTemplate(ref: templateRef) - : null; - final titleRange = template?.title?.strLengthRange; - - final categories = campaign.categories - .map(CampaignCategoryDetailsViewModel.fromModel) - .toList(); - - final newState = state.copyWith( - isLoading: false, - step: step, - categoryRef: Optional(categoryRef), - titleLengthRange: Optional(titleRange), - categories: categories, - ); + emit(NewProposalState.loading()); - emit(newState); - } catch (error, stackTrace) { - _logger.severe('Load', error, stackTrace); + if (_cache.activeCampaign == null) { + await _getActiveCampaign(); + } - // TODO(dt-iohk): handle error state as dialog content, - // don't emit the error - emitError(LocalizedException.create(error)); + if (_activeCampaignSub == null) { + _watchActiveCampaign(); } + + await _updateCampaignCategoriesState(); } void selectCategoryStage() { @@ -135,11 +118,14 @@ class NewProposalCubit extends Cubit } void updateSelectedCategory(SignedDocumentRef? categoryRef) { + _cache = _cache.copyWith(categoryRef: Optional(categoryRef)); + emit( state.copyWith( - categoryRef: Optional(categoryRef), isAgreeToCategoryCriteria: false, isAgreeToNoFurtherCategoryChange: false, + categoryRef: Optional(categoryRef), + categories: state.categories.copyWith(selectedRef: Optional(categoryRef)), ), ); } @@ -152,4 +138,116 @@ class NewProposalCubit extends Cubit const stage = CreateProposalWithoutPreselectedCategoryStep(); emit(state.copyWith(step: stage)); } + + Future _getActiveCampaign() async { + try { + final campaign = await _campaignService.getActiveCampaign(); + + _handleActiveCampaignChange(campaign); + } catch (error, stackTrace) { + _logger.severe('Load', error, stackTrace); + + // TODO(dt-iohk): handle error state as dialog content, + // don't emit the error + emitError(LocalizedException.create(error)); + } + } + + void _handleActiveCampaignChange(Campaign? campaign) { + if (_cache.activeCampaign?.selfRef == campaign?.selfRef) { + return; + } + + _cache = _cache.copyWith( + activeCampaign: Optional(campaign), + campaignTotalAsk: const Optional.empty(), + ); + + unawaited(_updateCampaignCategoriesState()); + + unawaited(_activeCampaignTotalAskSub?.cancel()); + _activeCampaignTotalAskSub = null; + + if (campaign != null) _watchCampaignTotalAsk(campaign); + } + + void _handleCampaignTotalAskChange(CampaignTotalAsk data) { + _cache = _cache.copyWith(campaignTotalAsk: Optional(data)); + + unawaited(_updateCampaignCategoriesState()); + } + + Future _updateCampaignCategoriesState() async { + final campaign = _cache.activeCampaign; + final campaignTotalAsk = _cache.campaignTotalAsk ?? const CampaignTotalAsk(categoriesAsks: {}); + final preselectedCategory = _cache.categoryRef; + + // TODO(LynxLynxx): when we have separate proposal template for generic questions use it here + // right now user can start creating proposal without selecting category. + // Right now every category have the same requirements for title so we can do a fallback for + // first category from the list. + final campaignCategories = campaign?.categories ?? []; + final templateRef = campaignCategories + .cast() + .firstWhere( + (e) => e?.selfRef == preselectedCategory, + orElse: () => campaignCategories.firstOrNull, + ) + ?.proposalTemplateRef; + + final template = templateRef != null + ? await _proposalService.getProposalTemplate(ref: templateRef) + : null; + final titleRange = template?.title?.strLengthRange; + + final mappedCategories = campaignCategories.map( + (category) { + final categoryTotalAsk = + campaignTotalAsk.categoriesAsks[category.selfRef] ?? + CampaignCategoryTotalAsk.zero(category.selfRef); + + return CampaignCategoryDetailsViewModel.fromModel( + category, + finalProposalsCount: categoryTotalAsk.finalProposalsCount, + totalAsk: categoryTotalAsk.totalAsk, + ); + }, + ).toList(); + + final categoriesState = NewProposalStateCategories( + categories: mappedCategories, + selectedRef: _cache.categoryRef, + ); + + final step = _cache.categoryRef == null + ? const CreateProposalWithoutPreselectedCategoryStep() + : const CreateProposalWithPreselectedCategoryStep(); + + final newState = state.copyWith( + isLoading: false, + step: step, + categoryRef: Optional(_cache.categoryRef), + titleLengthRange: Optional(titleRange), + categories: categoriesState, + ); + + if (!isClosed) { + emit(newState); + } + } + + void _watchActiveCampaign() { + unawaited(_activeCampaignSub?.cancel()); + _activeCampaignSub = _campaignService.watchActiveCampaign + .distinct((previous, next) => previous?.selfRef != next?.selfRef) + .listen(_handleActiveCampaignChange); + } + + void _watchCampaignTotalAsk(Campaign campaign) { + final filters = ProposalsTotalAskFilters(campaign: CampaignFilters.from(campaign)); + _activeCampaignTotalAskSub = _campaignService + .watchCampaignTotalAsk(filters: filters) + .distinct() + .listen(_handleCampaignTotalAskChange); + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart index d227d70d0de8..287e3ec1f20f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/new_proposal/new_proposal_state.dart @@ -13,7 +13,7 @@ class NewProposalState extends Equatable { final ProposalTitle title; final Range? titleLengthRange; final SignedDocumentRef? categoryRef; - final List categories; + final NewProposalStateCategories categories; const NewProposalState({ this.isLoading = false, @@ -24,7 +24,7 @@ class NewProposalState extends Equatable { required this.title, this.titleLengthRange, this.categoryRef, - this.categories = const [], + this.categories = const NewProposalStateCategories(), }); factory NewProposalState.loading() { @@ -48,8 +48,8 @@ class NewProposalState extends Equatable { categoryRef, categories, ]; - String? get selectedCategoryName => - categories.firstWhereOrNull((e) => e.id == categoryRef)?.formattedName; + + String? get selectedCategoryName => categories.selected?.formattedName; bool get _isAgreementValid => isAgreeToCategoryCriteria && isAgreeToNoFurtherCategoryChange; @@ -63,7 +63,7 @@ class NewProposalState extends Equatable { ProposalTitle? title, Optional>? titleLengthRange, Optional? categoryRef, - List? categories, + NewProposalStateCategories? categories, }) { return NewProposalState( isLoading: isLoading ?? this.isLoading, @@ -79,3 +79,32 @@ class NewProposalState extends Equatable { ); } } + +final class NewProposalStateCategories extends Equatable { + final List? categories; + final SignedDocumentRef? selectedRef; + + CampaignCategoryDetailsViewModel? get selected => + categories?.firstWhereOrNull((element) => element.ref.id == selectedRef?.id); + + const NewProposalStateCategories({ + this.categories, + this.selectedRef, + }); + + NewProposalStateCategories copyWith({ + Optional>? categories, + Optional? selectedRef, + }) { + return NewProposalStateCategories( + categories: categories.dataOr(this.categories), + selectedRef: selectedRef.dataOr(this.selectedRef), + ); + } + + @override + List get props => [ + categories, + selectedRef, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart index 57bfba8e6689..47075ced6493 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart @@ -149,6 +149,7 @@ final class ProposalBuilderBloc extends Bloc comments, required CommentsState commentsState, @@ -174,7 +175,11 @@ final class ProposalBuilderBloc extends Bloc? comments; final AccountPublicStatus? accountPublicStatus; @@ -19,6 +20,7 @@ final class ProposalBuilderBlocCache extends Equatable { this.proposalDocument, this.proposalMetadata, this.category, + this.categoryTotalAsk, this.commentTemplate, this.comments, this.accountPublicStatus, @@ -34,6 +36,7 @@ final class ProposalBuilderBlocCache extends Equatable { proposalDocument, proposalMetadata, category, + categoryTotalAsk, commentTemplate, comments, accountPublicStatus, @@ -46,6 +49,7 @@ final class ProposalBuilderBlocCache extends Equatable { Optional? proposalDocument, Optional? proposalMetadata, Optional? category, + Optional? categoryTotalAsk, Optional? commentTemplate, Optional>? comments, Optional? accountPublicStatus, @@ -57,6 +61,7 @@ final class ProposalBuilderBlocCache extends Equatable { proposalDocument: proposalDocument.dataOr(this.proposalDocument), proposalMetadata: proposalMetadata.dataOr(this.proposalMetadata), category: category.dataOr(this.category), + categoryTotalAsk: categoryTotalAsk.dataOr(this.categoryTotalAsk), commentTemplate: commentTemplate.dataOr(this.commentTemplate), comments: comments.dataOr(this.comments), accountPublicStatus: accountPublicStatus.dataOr(this.accountPublicStatus), 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 b40e8908d102..f4a0cb30b79d 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 @@ -6,7 +6,7 @@ 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:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; const _recentProposalsMaxAge = Duration(hours: 72); final _logger = Logger('ProposalsCubit'); @@ -23,11 +23,15 @@ final class ProposalsCubit extends Cubit final CampaignService _campaignService; final ProposalService _proposalService; - ProposalsCubitCache _cache = const ProposalsCubitCache(); + ProposalsCubitCache _cache = ProposalsCubitCache( + filters: ProposalsFiltersV2(campaign: ProposalsCampaignFilters.active()), + ); StreamSubscription? _activeAccountIdSub; - StreamSubscription>? _favoritesProposalsIdsSub; - StreamSubscription? _proposalsCountSub; + StreamSubscription>? _proposalsCountSub; + StreamSubscription>? _proposalsPageSub; + + Completer? _proposalsRequestCompleter; ProposalsCubit( this._userService, @@ -35,70 +39,91 @@ final class ProposalsCubit extends Cubit this._proposalService, ) : super(const ProposalsState(recentProposalsMaxAge: _recentProposalsMaxAge)) { _resetCache(); + _rebuildProposalsCountSubs(); _activeAccountIdSub = _userService.watchUser .map((event) => event.activeAccount?.catalystId) .distinct() .listen(_handleActiveAccountIdChange); + } + + Future get _campaign async { + final cachedCampaign = _cache.campaign; + if (cachedCampaign != null) { + return cachedCampaign; + } - _favoritesProposalsIdsSub = _proposalService - .watchFavoritesProposalsIds() - .distinct(listEquals) - .listen(_handleFavoriteProposalsIds); + final campaign = await _campaignService.getActiveCampaign(); + _cache = _cache.copyWith(campaign: Optional(campaign)); + return campaign; } void changeFilters({ - Optional? author, - Optional? onlyMy, - Optional? category, - ProposalsFilterType? type, + Optional? categoryId, + Optional? tab, Optional? searchQuery, bool? isRecentEnabled, bool resetProposals = false, }) { + _cache = _cache.copyWith(tab: tab); + emit(state.copyWith(isOrderEnabled: _cache.tab == ProposalsPageTab.total)); + + final status = switch (_cache.tab) { + ProposalsPageTab.drafts => ProposalStatusFilter.draft, + ProposalsPageTab.finals => ProposalStatusFilter.aFinal, + ProposalsPageTab.total || ProposalsPageTab.favorites || ProposalsPageTab.my || null => null, + }; + final filters = _cache.filters.copyWith( - type: type, - author: author, - onlyAuthor: onlyMy, - category: category, + status: Optional(status), + isFavorite: _cache.tab == ProposalsPageTab.favorites + ? const Optional(true) + : const Optional.empty(), + author: Optional(_cache.tab == ProposalsPageTab.my ? _cache.activeAccountId : null), + categoryId: categoryId, searchQuery: searchQuery, - maxAge: isRecentEnabled != null + latestUpdate: isRecentEnabled != null ? Optional(isRecentEnabled ? _recentProposalsMaxAge : null) : null, + campaign: Optional(ProposalsCampaignFilters.active()), ); if (_cache.filters == filters) { return; } + final statusChanged = _cache.filters.status != filters.status; + final categoryChanged = _cache.filters.categoryId != filters.categoryId; + final searchQueryChanged = _cache.filters.searchQuery != filters.searchQuery; + final latestUpdateChanged = _cache.filters.latestUpdate != filters.latestUpdate; + final campaignChanged = _cache.filters.campaign != filters.campaign; + + final shouldRebuildCountSubs = + categoryChanged || searchQueryChanged || latestUpdateChanged || campaignChanged; + _cache = _cache.copyWith(filters: filters); emit( state.copyWith( - isOrderEnabled: _cache.filters.type == ProposalsFilterType.total, - isRecentProposalsEnabled: _cache.filters.maxAge != null, + isRecentProposalsEnabled: _cache.filters.latestUpdate != null, ), ); - if (category != null) _rebuildCategories(); - if (type != null) _rebuildOrder(); - - _watchProposalsCount(filters: filters.toCountFilters()); - - if (resetProposals) { - emitSignal(const ResetPaginationProposalsSignal()); - } + if (categoryId != null) _rebuildCategories(); + if (statusChanged) _rebuildOrder(); + if (shouldRebuildCountSubs) _rebuildProposalsCountSubs(); + if (resetProposals) emitSignal(const ResetPaginationProposalsSignal()); } void changeOrder( ProposalsOrder? order, { bool resetProposals = false, }) { - if (_cache.selectedOrder == order) { + if (_cache.order == order) { return; } - _cache = _cache.copyWith(selectedOrder: Optional(order)); + _cache = _cache.copyWith(order: Optional(order)); _rebuildOrder(); @@ -116,81 +141,70 @@ final class ProposalsCubit extends Cubit await _activeAccountIdSub?.cancel(); _activeAccountIdSub = null; - await _favoritesProposalsIdsSub?.cancel(); - _favoritesProposalsIdsSub = null; - await _proposalsCountSub?.cancel(); _proposalsCountSub = null; + await _proposalsPageSub?.cancel(); + _proposalsPageSub = null; + + if (_proposalsRequestCompleter != null && !_proposalsRequestCompleter!.isCompleted) { + _proposalsRequestCompleter!.complete(); + } + _proposalsRequestCompleter = null; + return super.close(); } Future getProposals(PageRequest request) async { - try { - if (_cache.campaign == null) { - final campaign = await _campaignService.getActiveCampaign(); - _cache = _cache.copyWith(campaign: Optional(campaign)); - } - - final filters = _cache.filters; - final order = _resolveEffectiveOrder(); - - _logger.finer('Proposals request[$request], filters[$filters], order[$order]'); + final filters = _cache.filters; + final order = _resolveEffectiveOrder(); - final page = await _proposalService.getProposalsPage( - request: request, - filters: filters, - order: order, - ); + if (_proposalsRequestCompleter != null && !_proposalsRequestCompleter!.isCompleted) { + _proposalsRequestCompleter!.complete(); + } + _proposalsRequestCompleter = Completer(); - _cache = _cache.copyWith(page: Optional(page)); + await _proposalsPageSub?.cancel(); + _proposalsPageSub = _proposalService + .watchProposalsBriefPageV2(request: request, order: order, filters: filters) + .map((page) => page.map(ProposalBrief.fromData)) + .distinct() + .listen(_handleProposalsChange); - _emitCachedProposalsPage(); - } catch (error, stackTrace) { - _logger.severe('Failed loading page $request', error, stackTrace); - } + await _proposalsRequestCompleter?.future; } void init({ - required bool onlyMyProposals, - required SignedDocumentRef? category, - required ProposalsFilterType type, - required ProposalsOrder order, + String? categoryId, + ProposalsPageTab? tab, + ProposalsOrder order = const Alphabetical(), }) { _resetCache(); _rebuildOrder(); unawaited(_loadCampaignCategories()); - changeFilters(onlyMy: Optional(onlyMyProposals), category: Optional(category), type: type); + changeFilters(categoryId: Optional(categoryId), tab: Optional(tab)); changeOrder(order); } /// Changes the favorite status of the proposal with [ref]. - void onChangeFavoriteProposal( + Future onChangeFavoriteProposal( DocumentRef ref, { required bool isFavorite, - }) { - final favoritesIds = List.of(state.favoritesIds); - - if (isFavorite) { - favoritesIds.add(ref.id); - } else { - favoritesIds.removeWhere((element) => element == ref.id); - } - - emit(state.copyWith(favoritesIds: favoritesIds)); + }) async { + _updateFavoriteProposalLocally(ref, isFavorite); - if (!isFavorite && _cache.filters.type.isFavorite) { - final page = _cache.page; - if (page != null) { - final proposals = page.items.where((element) => element.proposal.selfRef != ref).toList(); - final updatedPage = page.copyWithItems(proposals); - _cache = _cache.copyWith(page: Optional(updatedPage)); - _emitCachedProposalsPage(); + try { + if (isFavorite) { + await _proposalService.addFavoriteProposal(ref: ref); + } else { + await _proposalService.removeFavoriteProposal(ref: ref); } - } + } catch (error, stack) { + _logger.severe('Updating proposal[$ref] favorite failed', error, stack); - unawaited(_updateFavoriteProposal(ref, isFavorite: isFavorite)); + emitError(LocalizedException.create(error)); + } } void updateSearchQuery(String query) { @@ -205,49 +219,67 @@ final class ProposalsCubit extends Cubit emit(state.copyWith(hasSearchQuery: !asOptional.isEmpty)); } - void _emitCachedProposalsPage() { - final campaign = _cache.campaign; - final page = _cache.page; - final showComments = campaign?.supportsComments ?? false; - - if (campaign == null || page == null) { - return; - } - - final mappedPage = page.map( - // TODO(damian-molinski): refactor page to return ProposalWithContext instead. - (e) => ProposalBrief.fromProposal( - e.proposal, - isFavorite: state.favoritesIds.contains(e.proposal.selfRef.id), - categoryName: campaign.categories - .firstWhere((element) => element.selfRef == e.proposal.categoryRef) - .formattedCategoryName, - showComments: showComments, + ProposalsFiltersV2 _buildProposalsCountFilters(ProposalsPageTab tab) { + return switch (tab) { + ProposalsPageTab.total => _cache.filters.copyWith( + status: const Optional.empty(), + isFavorite: const Optional.empty(), + author: const Optional.empty(), ), - ); - - final signal = PageReadyProposalsSignal(page: mappedPage); - - emitSignal(signal); + ProposalsPageTab.drafts => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.draft), + isFavorite: const Optional.empty(), + author: const Optional.empty(), + ), + ProposalsPageTab.finals => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: const Optional.empty(), + author: const Optional.empty(), + ), + ProposalsPageTab.favorites => _cache.filters.copyWith( + status: const Optional.empty(), + isFavorite: const Optional(true), + author: const Optional.empty(), + ), + ProposalsPageTab.my => _cache.filters.copyWith( + status: const Optional.empty(), + isFavorite: const Optional.empty(), + author: Optional(_cache.activeAccountId), + ), + }; } void _handleActiveAccountIdChange(CatalystId? id) { - changeFilters(author: Optional(id), resetProposals: true); + _cache = _cache.copyWith(activeAccountId: Optional(id)); + final isMyTab = _cache.tab == ProposalsPageTab.my; + + changeFilters(resetProposals: isMyTab); } - void _handleFavoriteProposalsIds(List ids) { - emit(state.copyWith(favoritesIds: ids)); - _emitCachedProposalsPage(); + void _handleProposalsChange(Page page) { + _logger.finest( + 'Got page[${page.page}] with proposals[${page.items.length}]. ' + 'Total[${page.total}]', + ); + + final requestCompleter = _proposalsRequestCompleter; + if (requestCompleter != null && !requestCompleter.isCompleted) { + requestCompleter.complete(); + } + + _cache = _cache.copyWith(page: Optional(page)); + + emitSignal(PageReadyProposalsSignal(page: page)); } - void _handleProposalsCount(ProposalsCount count) { - _cache = _cache.copyWith(count: count); + void _handleProposalsCountChange(Map data) { + _logger.finest('Proposals count changed: $data'); - emit(state.copyWith(count: count)); + emit(state.copyWith(count: Map.unmodifiable(data))); } Future _loadCampaignCategories() async { - final campaign = await _campaignService.getActiveCampaign(); + final campaign = await _campaign; _cache = _cache.copyWith(categories: Optional(campaign?.categories)); @@ -257,14 +289,14 @@ final class ProposalsCubit extends Cubit } void _rebuildCategories() { - final selectedCategory = _cache.filters.category; + final selectedCategory = _cache.filters.categoryId; final categories = _cache.categories ?? const []; final items = categories.map((e) { return ProposalsCategorySelectorItem( ref: e.selfRef, name: e.formattedCategoryName, - isSelected: e.selfRef.id == selectedCategory?.id, + isSelected: e.selfRef.id == selectedCategory, ); }).toList(); @@ -274,10 +306,10 @@ final class ProposalsCubit extends Cubit } void _rebuildOrder() { - final filterType = _cache.filters.type; + final isNoStatusFilter = _cache.filters.status == null; final selectedOrder = _resolveEffectiveOrder(); - final options = filterType == ProposalsFilterType.total + final options = isNoStatusFilter ? const [ Alphabetical(), Budget(isAscending: false), @@ -296,48 +328,73 @@ final class ProposalsCubit extends Cubit emit(state.copyWith(order: order)); } + void _rebuildProposalsCountSubs() { + final streams = ProposalsPageTab.values.map((tab) { + final filters = _buildProposalsCountFilters(tab); + return _proposalService + .watchProposalsCountV2(filters: filters) + .distinct() + .map((count) => MapEntry(tab, count)); + }); + + unawaited(_proposalsCountSub?.cancel()); + _proposalsCountSub = Rx.combineLatest( + streams, + Map.fromEntries, + ).startWith({}).listen(_handleProposalsCountChange); + } + void _resetCache() { - final activeAccount = _userService.user.activeAccount; - final filters = ProposalsFilters.forActiveCampaign(author: activeAccount?.catalystId); - _cache = ProposalsCubitCache(filters: filters); + final activeAccountId = _userService.user.activeAccount?.catalystId; + final filters = ProposalsFiltersV2(campaign: ProposalsCampaignFilters.active()); + + _cache = ProposalsCubitCache( + filters: filters, + activeAccountId: activeAccountId, + ); } ProposalsOrder _resolveEffectiveOrder() { - final filterType = _cache.filters.type; - final selectedOrder = _cache.selectedOrder; + final isTotalTab = _cache.tab == ProposalsPageTab.total; + final selectedOrder = _cache.order; // skip order for non total - if (filterType != ProposalsFilterType.total) { + if (!isTotalTab) { return const UpdateDate(isAscending: false); } return selectedOrder ?? const Alphabetical(); } - Future _updateFavoriteProposal( - DocumentRef ref, { - required bool isFavorite, - }) async { - try { - if (isFavorite) { - await _proposalService.addFavoriteProposal(ref: ref); + void _updateFavoriteProposalLocally(DocumentRef ref, bool isFavorite) { + final count = Map.of(state.count) + ..update( + ProposalsPageTab.favorites, + (value) => value + (isFavorite ? 1 : -1), + ifAbsent: () => (isFavorite ? 1 : 0), + ); + + emit(state.copyWith(count: Map.unmodifiable(count))); + + final page = _cache.page; + if (page != null) { + var items = List.of(page.items); + if (_cache.tab != ProposalsPageTab.favorites || isFavorite) { + items = items + .map((e) => e.selfRef == ref ? e.copyWith(isFavorite: isFavorite) : e) + .toList(); } else { - await _proposalService.removeFavoriteProposal(ref: ref); + items = items.where((element) => element.selfRef != ref).toList(); } - } catch (error, stack) { - _logger.severe('Updating proposal[$ref] favorite failed', error, stack); - emitError(LocalizedException.create(error)); - } - } + final diff = page.items.length - items.length; - void _watchProposalsCount({ - required ProposalsCountFilters filters, - }) { - unawaited(_proposalsCountSub?.cancel()); - _proposalsCountSub = _proposalService - .watchProposalsCount(filters: filters) - .distinct() - .listen(_handleProposalsCount); + final updatedPage = page.copyWith( + items: items, + total: page.total - diff, + ); + + _handleProposalsChange(updatedPage); + } } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart index b6a8ac3641d8..09c8085dc59b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart @@ -1,48 +1,55 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; final class ProposalsCubitCache extends Equatable { final Campaign? campaign; - final Page? page; - final ProposalsFilters filters; - final ProposalsOrder? selectedOrder; + final CatalystId? activeAccountId; + final ProposalsFiltersV2 filters; + final ProposalsPageTab? tab; + final ProposalsOrder? order; final List? categories; - final ProposalsCount count; + final Page? page; const ProposalsCubitCache({ this.campaign, - this.page, - this.filters = const ProposalsFilters(), - this.selectedOrder, + this.activeAccountId, + this.tab, + this.filters = const ProposalsFiltersV2(), + this.order, this.categories, - this.count = const ProposalsCount(), + this.page, }); @override List get props => [ campaign, - page, + activeAccountId, + tab, filters, - selectedOrder, + order, categories, - count, + page, ]; ProposalsCubitCache copyWith({ Optional? campaign, - Optional>? page, - ProposalsFilters? filters, - Optional? selectedOrder, + Optional? activeAccountId, + Optional? tab, + ProposalsFiltersV2? filters, + Optional? order, Optional>? categories, - ProposalsCount? count, + Map? proposalsCountFilters, + Optional>? page, }) { return ProposalsCubitCache( campaign: campaign.dataOr(this.campaign), - page: page.dataOr(this.page), + activeAccountId: activeAccountId.dataOr(this.activeAccountId), + tab: tab.dataOr(this.tab), filters: filters ?? this.filters, - selectedOrder: selectedOrder.dataOr(this.selectedOrder), + order: order.dataOr(this.order), categories: categories.dataOr(this.categories), - count: count ?? this.count, + page: page.dataOr(this.page), ); } } 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 489037855810..9a4ecd9ff5c1 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 @@ -28,8 +28,7 @@ final class ProposalsOrderState extends Equatable { /// The state of available proposals. class ProposalsState extends Equatable { final bool hasSearchQuery; - final List favoritesIds; - final ProposalsCount count; + final Map count; final ProposalsCategoryState category; final Duration recentProposalsMaxAge; final bool isRecentProposalsEnabled; @@ -38,8 +37,7 @@ class ProposalsState extends Equatable { const ProposalsState({ this.hasSearchQuery = false, - this.favoritesIds = const [], - this.count = const ProposalsCount(), + this.count = const {}, this.category = const ProposalsCategoryState(), required this.recentProposalsMaxAge, this.isRecentProposalsEnabled = false, @@ -61,7 +59,6 @@ class ProposalsState extends Equatable { @override List get props => [ hasSearchQuery, - favoritesIds, count, category, recentProposalsMaxAge, @@ -72,8 +69,7 @@ class ProposalsState extends Equatable { ProposalsState copyWith({ bool? hasSearchQuery, - List? favoritesIds, - ProposalsCount? count, + Map? count, ProposalsCategoryState? category, Duration? recentProposalsMaxAge, bool? isRecentProposalsEnabled, @@ -82,7 +78,6 @@ class ProposalsState extends Equatable { }) { return ProposalsState( hasSearchQuery: hasSearchQuery ?? this.hasSearchQuery, - favoritesIds: favoritesIds ?? this.favoritesIds, count: count ?? this.count, category: category ?? this.category, recentProposalsMaxAge: recentProposalsMaxAge ?? this.recentProposalsMaxAge, @@ -92,8 +87,6 @@ class ProposalsState extends Equatable { ); } - bool isFavorite(String proposalId) => favoritesIds.contains(proposalId); - List tabs({required bool isProposerUnlock}) { return ProposalsPageTab.values .where((tab) => tab != ProposalsPageTab.my || isProposerUnlock) diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart index bd928ae16fb7..5fcb2098f6de 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit.dart @@ -7,7 +7,7 @@ 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/foundation.dart'; +import 'package:rxdart/rxdart.dart'; final _logger = Logger('VotingCubit'); @@ -17,80 +17,86 @@ final class VotingCubit extends Cubit final UserService _userService; final CampaignService _campaignService; final ProposalService _proposalService; - final VotingBallotBuilder _ballotBuilder; - final VotingService _votingService; VotingCubitCache _cache = const VotingCubitCache(); late Timer _countdownTimer; StreamSubscription? _activeAccountSub; - StreamSubscription>? _favoritesProposalsIdsSub; - StreamSubscription? _proposalsCountSub; - StreamSubscription>? _watchedCastedVotesSub; + StreamSubscription>? _proposalsCountSub; + StreamSubscription>? _proposalsPageSub; + + Completer? _proposalsRequestCompleter; VotingCubit( this._userService, this._campaignService, this._proposalService, - this._ballotBuilder, - this._votingService, ) : super(const VotingState()) { _resetCache(); + _rebuildProposalsCountSubs(); _activeAccountSub = _userService.watchUser .map((event) => event.activeAccount) .distinct() .listen(_handleActiveAccountChange); - _favoritesProposalsIdsSub = _proposalService - .watchFavoritesProposalsIds() - .distinct(listEquals) - .listen(_handleFavoriteProposalsIds); - - _watchedCastedVotesSub = _votingService - .watchedCastedVotes() - .distinct(listEquals) - .listen(_handleLastCastedChange); - - _ballotBuilder.addListener(_handleBallotBuilderChange); - _countdownTimer = Timer.periodic( const Duration(seconds: 1), (_) => _dispatchState(), ); } + Future get _campaign async { + final cachedCampaign = _cache.campaign; + if (cachedCampaign != null) { + return cachedCampaign; + } + + final campaign = await _campaignService.getActiveCampaign(); + _cache = _cache.copyWith(campaign: Optional(campaign)); + return campaign; + } + void changeFilters({ - Optional? author, - Optional? onlyMy, - Optional? category, - ProposalsFilterType? type, + Optional? categoryId, + Optional? tab, Optional? searchQuery, bool resetProposals = false, }) { + _cache = _cache.copyWith(tab: tab); + final filters = _cache.filters.copyWith( - type: type, - author: author, - onlyAuthor: onlyMy, - category: category, + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: _cache.tab == VotingPageTab.favorites + ? const Optional(true) + : const Optional.empty(), + author: Optional(_cache.tab == VotingPageTab.my ? _cache.activeAccountId : null), + categoryId: categoryId, searchQuery: searchQuery, + latestUpdate: const Optional.empty(), + campaign: Optional(ProposalsCampaignFilters.active()), + voteBy: _cache.tab == VotingPageTab.votedOn + ? Optional(_cache.activeAccountId) + : const Optional.empty(), ); if (_cache.filters == filters) { return; } - _cache = _cache.copyWith(filters: filters); + final categoryChanged = _cache.filters.categoryId != filters.categoryId; + final searchQueryChanged = _cache.filters.searchQuery != filters.searchQuery; + final campaignChanged = _cache.filters.campaign != filters.campaign; - _dispatchState(); - _watchProposalsCount(filters: filters.toCountFilters()); + final shouldRebuildCountSubs = categoryChanged || searchQueryChanged || campaignChanged; - if (resetProposals) { - emitSignal(const ResetPaginationVotingSignal()); - } + _cache = _cache.copyWith(filters: filters); + + if (shouldRebuildCountSubs) _rebuildProposalsCountSubs(); + if (resetProposals) emitSignal(const ResetPaginationVotingSignal()); } - void changeSelectedCategory(SignedDocumentRef? categoryId) { + void changeSelectedCategory(String? categoryId) { emitSignal(ChangeCategoryVotingSignal(to: categoryId)); } @@ -99,90 +105,71 @@ final class VotingCubit extends Cubit await _activeAccountSub?.cancel(); _activeAccountSub = null; - await _favoritesProposalsIdsSub?.cancel(); - _favoritesProposalsIdsSub = null; - await _proposalsCountSub?.cancel(); _proposalsCountSub = null; - await _watchedCastedVotesSub?.cancel(); - _watchedCastedVotesSub = null; + await _proposalsPageSub?.cancel(); + _proposalsPageSub = null; - _ballotBuilder.removeListener(_handleBallotBuilderChange); _countdownTimer.cancel(); + if (_proposalsRequestCompleter != null && !_proposalsRequestCompleter!.isCompleted) { + _proposalsRequestCompleter!.complete(); + } + _proposalsRequestCompleter = null; + return super.close(); } Future getProposals(PageRequest request) async { - try { - if (_cache.campaign == null) { - final campaign = await _campaignService.getActiveCampaign(); - _cache = _cache.copyWith(campaign: Optional(campaign)); - } - - final filters = _cache.filters; + final filters = _cache.filters; + const order = Alphabetical(); - _logger.finer('Proposals request[$request], filters[$filters]'); + if (_proposalsRequestCompleter != null && !_proposalsRequestCompleter!.isCompleted) { + _proposalsRequestCompleter!.complete(); + } + _proposalsRequestCompleter = Completer(); - final page = await _proposalService.getProposalsPage( - request: request, - filters: filters, - order: const Alphabetical(), - ); + await _proposalsPageSub?.cancel(); + _proposalsPageSub = _proposalService + .watchProposalsBriefPageV2(request: request, order: order, filters: filters) + .map((page) => page.map(ProposalBrief.fromData)) + .distinct() + .listen(_handleProposalsChange); - _cache = _cache.copyWith(page: Optional(page)); - _emitCachedProposalsPage(); - } catch (error, stackTrace) { - _logger.severe('Failed loading page $request', error, stackTrace); - } + await _proposalsRequestCompleter?.future; } void init({ - required bool onlyMyProposals, - required SignedDocumentRef? category, - required ProposalsFilterType type, + required String? categoryId, + required VotingPageTab? tab, }) { _resetCache(); - unawaited(_loadCampaign()); + unawaited(_loadVotingPower()); - unawaited(_loadFavoriteProposals()); - unawaited(_loadLastCastedVotes()); + unawaited(_loadCampaign()); - changeFilters( - onlyMy: Optional(onlyMyProposals), - category: Optional(category), - type: type, - ); + changeFilters(categoryId: Optional(categoryId), tab: Optional(tab)); } /// Changes the favorite status of the proposal with [ref]. - void onChangeFavoriteProposal( + Future onChangeFavoriteProposal( DocumentRef ref, { required bool isFavorite, - }) { - final favoritesIds = List.of(state.favoritesIds); - - if (isFavorite) { - favoritesIds.add(ref.id); - } else { - favoritesIds.removeWhere((element) => element == ref.id); - } - - _cache = _cache.copyWith(favoriteIds: Optional(favoritesIds)); - _dispatchState(); + }) async { + _updateFavoriteProposalLocally(ref, isFavorite); - if (!isFavorite && _cache.filters.type.isFavorite) { - final page = _cache.page; - if (page != null) { - final proposals = page.items.where((element) => element.proposal.selfRef != ref).toList(); - final updatedPage = page.copyWithItems(proposals); - _cache = _cache.copyWith(page: Optional(updatedPage)); - _emitCachedProposalsPage(); + try { + if (isFavorite) { + await _proposalService.addFavoriteProposal(ref: ref); + } else { + await _proposalService.removeFavoriteProposal(ref: ref); } - } + } catch (error, stack) { + _logger.severe('Updating proposal[$ref] favorite failed', error, stack); - unawaited(_updateFavoriteProposal(ref, isFavorite: isFavorite)); + emitError(LocalizedException.create(error)); + } } void updateSearchQuery(String query) { @@ -193,21 +180,49 @@ final class VotingCubit extends Cubit } changeFilters(searchQuery: asOptional, resetProposals: true); + + emit(state.copyWith(hasSearchQuery: !asOptional.isEmpty)); } List _buildCategorySelectorItems( List categories, - SignedDocumentRef? selectedCategory, + String? selectedCategoryId, ) { return categories.map((e) { return ProposalsCategorySelectorItem( ref: e.selfRef, name: e.formattedCategoryName, - isSelected: e.selfRef.id == selectedCategory?.id, + isSelected: e.selfRef.id == selectedCategoryId, ); }).toList(); } + ProposalsFiltersV2 _buildProposalsCountFilters(VotingPageTab tab) { + return switch (tab) { + VotingPageTab.total => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: const Optional.empty(), + author: const Optional.empty(), + ), + VotingPageTab.favorites => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: const Optional(true), + author: const Optional.empty(), + ), + VotingPageTab.my => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: const Optional.empty(), + author: Optional(_cache.activeAccountId), + ), + VotingPageTab.votedOn => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: const Optional.empty(), + author: const Optional.empty(), + voteBy: Optional(_cache.activeAccountId), + ), + }; + } + VotingPhaseProgressViewModel? _buildVotingPhase(Campaign? campaign) { final campaignVotingPhase = campaign?.phaseStateTo(CampaignPhaseType.communityVoting); final campaignStartDate = campaign?.startDate; @@ -235,40 +250,10 @@ final class VotingCubit extends Cubit } } - void _emitCachedProposalsPage() { - final campaign = _cache.campaign; - final page = _cache.page; - final favoriteIds = _cache.favoriteIds ?? []; - final lastCastedVote = _cache.lastCastedVote ?? []; - final showComments = campaign?.supportsComments ?? false; - - if (campaign == null || page == null) { - return; - } - - final mappedPage = page.map( - (proposalContext) { - final proposal = proposalContext.proposal; - final proposalWithContext = proposalContext.copyWith( - user: proposalContext.user.copyWith( - isFavorite: favoriteIds.contains(proposal.selfRef.id), - lastCastedVote: Optional(lastCastedVote.forProposal(proposal.selfRef)), - ), - ); - return ProposalBriefVoting.fromProposalWithContext( - proposalWithContext, - draftVote: _ballotBuilder.getVoteOn(proposal.selfRef), - showComments: showComments, - ); - }, - ); - final signal = PageReadyVotingSignal(page: mappedPage); - emitSignal(signal); - } - void _handleActiveAccountChange(Account? account) { - if (account?.catalystId != _cache.filters.author) { - changeFilters(author: Optional(account?.catalystId), resetProposals: true); + if (account?.catalystId != _cache.activeAccountId) { + _cache = _cache.copyWith(activeAccountId: Optional(account?.catalystId)); + changeFilters(resetProposals: true); } if (_cache.votingPower != account?.votingPower) { @@ -277,121 +262,126 @@ final class VotingCubit extends Cubit } } - void _handleBallotBuilderChange() { - _emitCachedProposalsPage(); - } + void _handleProposalsChange(Page page) { + _logger.finest( + 'Got page[${page.page}] with proposals[${page.items.length}]. ' + 'Total[${page.total}]', + ); - void _handleFavoriteProposalsIds(List ids) { - _cache = _cache.copyWith(favoriteIds: Optional(ids)); - _emitCachedProposalsPage(); - _dispatchState(); - } + final requestCompleter = _proposalsRequestCompleter; + if (requestCompleter != null && !requestCompleter.isCompleted) { + requestCompleter.complete(); + } - void _handleLastCastedChange(List? vote) { - _cache = _cache.copyWith(lastCastedVote: Optional(vote)); - } + _cache = _cache.copyWith(page: Optional(page)); - void _handleProposalsCount(ProposalsCount count) { - _cache = _cache.copyWith(count: count); - _dispatchState(); + emitSignal(PageReadyVotingSignal(page: page)); } - Future _loadCampaign() async { - final campaign = await _campaignService.getActiveCampaign(); - _cache = _cache.copyWith(campaign: Optional(campaign)); - - if (!isClosed) { - _dispatchState(); - } - } + void _handleProposalsCountChange(Map data) { + _logger.finest('Proposals count changed: $data'); - Future _loadFavoriteProposals() async { - final favorites = await _proposalService.getFavoritesProposalsIds(); - if (!isClosed) { - _handleFavoriteProposalsIds(favorites); - } + emit(state.copyWith(count: Map.unmodifiable(data))); } - Future _loadLastCastedVotes() async { - final lastCastedVotes = await _votingService.watchedCastedVotes().first; - if (!isClosed) { - _handleLastCastedChange(lastCastedVotes); - } - } + Future _loadCampaign() => _campaign; Future _loadVotingPower() async { await _userService.refreshActiveAccountVotingPower(); } + void _rebuildProposalsCountSubs() { + final streams = VotingPageTab.values.map((tab) { + final filters = _buildProposalsCountFilters(tab); + return _proposalService + .watchProposalsCountV2(filters: filters) + .distinct() + .map((count) => MapEntry(tab, count)); + }); + + unawaited(_proposalsCountSub?.cancel()); + _proposalsCountSub = Rx.combineLatest( + streams, + Map.fromEntries, + ).startWith({}).listen(_handleProposalsCountChange); + } + VotingState _rebuildState() { final campaign = _cache.campaign; - final favoriteIds = _cache.favoriteIds ?? const []; final votingPower = _cache.votingPower; final categories = campaign?.categories ?? const []; - final selectedCategoryRef = _cache.filters.category; + final selectedCategoryId = _cache.filters.categoryId; final filters = _cache.filters; - final count = _cache.count; final selectedCategory = campaign?.categories.firstWhereOrNull( - (e) => e.selfRef.id == selectedCategoryRef?.id, + (e) => e.selfRef.id == selectedCategoryId, ); - final selectedCategoryViewModel = selectedCategory != null - ? CampaignCategoryDetailsViewModel.fromModel(selectedCategory) - : null; + final fundNumber = campaign?.fundNumber; final votingPowerViewModel = votingPower != null ? VotingPowerViewModel.fromModel(votingPower) : const VotingPowerViewModel(); final votingPhaseViewModel = _buildVotingPhaseDetails(campaign); final hasSearchQuery = filters.searchQuery != null; - final categorySelectorItems = _buildCategorySelectorItems(categories, selectedCategoryRef); + final categorySelectorItems = _buildCategorySelectorItems(categories, selectedCategoryId); + + final header = VotingHeaderData( + showCategoryPicker: votingPhaseViewModel?.status.isActive ?? false, + category: selectedCategory != null + ? VotingHeaderCategoryData.fromModel(selectedCategory) + : null, + ); return state.copyWith( - selectedCategory: Optional(selectedCategoryViewModel), + header: header, fundNumber: Optional(fundNumber), votingPower: votingPowerViewModel, votingPhase: Optional(votingPhaseViewModel), hasSearchQuery: hasSearchQuery, - favoritesIds: favoriteIds, - count: count, categorySelectorItems: categorySelectorItems, ); } void _resetCache() { final activeAccount = _userService.user.activeAccount; - final filters = ProposalsFilters.forActiveCampaign(author: activeAccount?.catalystId); + final filters = ProposalsFiltersV2(campaign: ProposalsCampaignFilters.active()); _cache = VotingCubitCache( filters: filters, votingPower: activeAccount?.votingPower, + activeAccountId: activeAccount?.catalystId, ); } - Future _updateFavoriteProposal( - DocumentRef ref, { - required bool isFavorite, - }) async { - try { - if (isFavorite) { - await _proposalService.addFavoriteProposal(ref: ref); + void _updateFavoriteProposalLocally(DocumentRef ref, bool isFavorite) { + final count = Map.of(state.count) + ..update( + VotingPageTab.favorites, + (value) => value + (isFavorite ? 1 : -1), + ifAbsent: () => (isFavorite ? 1 : 0), + ); + + emit(state.copyWith(count: Map.unmodifiable(count))); + + final page = _cache.page; + if (page != null) { + var items = List.of(page.items); + if (_cache.tab != VotingPageTab.favorites || isFavorite) { + items = items + .map((e) => e.selfRef == ref ? e.copyWith(isFavorite: isFavorite) : e) + .toList(); } else { - await _proposalService.removeFavoriteProposal(ref: ref); + items = items.where((element) => element.selfRef != ref).toList(); } - } catch (error, stack) { - _logger.severe('Updating proposal[$ref] favorite failed', error, stack); - emitError(LocalizedException.create(error)); - } - } + final diff = page.items.length - items.length; - void _watchProposalsCount({ - required ProposalsCountFilters filters, - }) { - unawaited(_proposalsCountSub?.cancel()); - _proposalsCountSub = _proposalService - .watchProposalsCount(filters: filters) - .distinct() - .listen(_handleProposalsCount); + final updatedPage = page.copyWith( + items: items, + total: page.total - diff, + ); + + _handleProposalsChange(updatedPage); + } } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit_cache.dart index f640308d6a0f..9cce1600b3cc 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit_cache.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_cubit_cache.dart @@ -1,23 +1,22 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; final class VotingCubitCache extends Equatable { final Campaign? campaign; final VotingPower? votingPower; - final Page? page; - final ProposalsFilters filters; - final ProposalsCount count; - final List? favoriteIds; - final List? lastCastedVote; + final Page? page; + final ProposalsFiltersV2 filters; + final VotingPageTab? tab; + final CatalystId? activeAccountId; const VotingCubitCache({ this.campaign, this.votingPower, this.page, - this.filters = const ProposalsFilters(), - this.count = const ProposalsCount(), - this.favoriteIds, - this.lastCastedVote, + this.filters = const ProposalsFiltersV2(), + this.tab, + this.activeAccountId, }); @override @@ -26,28 +25,25 @@ final class VotingCubitCache extends Equatable { votingPower, page, filters, - count, - favoriteIds, - lastCastedVote, + tab, + activeAccountId, ]; VotingCubitCache copyWith({ Optional? campaign, Optional? votingPower, - Optional>? page, - ProposalsFilters? filters, - ProposalsCount? count, - Optional>? favoriteIds, - Optional>? lastCastedVote, + Optional>? page, + ProposalsFiltersV2? filters, + Optional? tab, + Optional? activeAccountId, }) { return VotingCubitCache( campaign: campaign.dataOr(this.campaign), votingPower: votingPower.dataOr(this.votingPower), page: page.dataOr(this.page), filters: filters ?? this.filters, - count: count ?? this.count, - favoriteIds: favoriteIds.dataOr(this.favoriteIds), - lastCastedVote: lastCastedVote.dataOr(this.lastCastedVote), + tab: tab.dataOr(this.tab), + activeAccountId: activeAccountId.dataOr(this.activeAccountId), ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_signal.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_signal.dart index 696b6eb61706..ce3a61a7ce99 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_signal.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_signal.dart @@ -3,7 +3,7 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; final class ChangeCategoryVotingSignal extends VotingSignal { - final SignedDocumentRef? to; + final String? to; const ChangeCategoryVotingSignal({ this.to, @@ -23,7 +23,7 @@ final class ChangeTabVotingSignal extends VotingSignal { } final class PageReadyVotingSignal extends VotingSignal { - final Page page; + final Page page; const PageReadyVotingSignal({required this.page}); diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart index e4ea08105d6b..9aa9ccdc3efd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/voting/voting_state.dart @@ -3,9 +3,35 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +final class VotingHeaderCategoryData extends Equatable { + final String formattedName; + final String description; + final String imageUrl; + + const VotingHeaderCategoryData({ + required this.formattedName, + required this.description, + required this.imageUrl, + }); + + VotingHeaderCategoryData.fromModel(CampaignCategory data) + : this( + formattedName: data.formattedCategoryName, + description: data.description, + imageUrl: data.imageUrl, + ); + + @override + List get props => [ + formattedName, + description, + imageUrl, + ]; +} + final class VotingHeaderData extends Equatable { final bool showCategoryPicker; - final CampaignCategoryDetailsViewModel? category; + final VotingHeaderCategoryData? category; const VotingHeaderData({ this.showCategoryPicker = false, @@ -18,41 +44,33 @@ final class VotingHeaderData extends Equatable { /// The state of available proposals in the voting page. class VotingState extends Equatable { - final CampaignCategoryDetailsViewModel? selectedCategory; + final VotingHeaderData header; final int? fundNumber; final VotingPowerViewModel votingPower; final VotingPhaseProgressDetailsViewModel? votingPhase; final bool hasSearchQuery; - final List favoritesIds; - final ProposalsCount count; + final Map count; final List categorySelectorItems; const VotingState({ - this.selectedCategory, + this.header = const VotingHeaderData(), this.fundNumber, this.votingPower = const VotingPowerViewModel(), this.votingPhase, this.hasSearchQuery = false, - this.favoritesIds = const [], - this.count = const ProposalsCount(), + this.count = const {}, this.categorySelectorItems = const [], }); - VotingHeaderData get header { - return VotingHeaderData( - showCategoryPicker: votingPhase?.status.isActive ?? false, - category: selectedCategory, - ); - } + bool get hasSelectedCategory => categorySelectorItems.any((element) => element.isSelected); @override List get props => [ - selectedCategory, + header, fundNumber, votingPower, votingPhase, hasSearchQuery, - favoritesIds, count, categorySelectorItems, ]; @@ -62,22 +80,20 @@ class VotingState extends Equatable { } VotingState copyWith({ - Optional? selectedCategory, + VotingHeaderData? header, Optional? fundNumber, VotingPowerViewModel? votingPower, Optional? votingPhase, bool? hasSearchQuery, - List? favoritesIds, - ProposalsCount? count, + Map? count, List? categorySelectorItems, }) { return VotingState( - selectedCategory: selectedCategory.dataOr(this.selectedCategory), + header: header ?? this.header, fundNumber: fundNumber.dataOr(this.fundNumber), votingPower: votingPower ?? this.votingPower, votingPhase: votingPhase.dataOr(this.votingPhase), hasSearchQuery: hasSearchQuery ?? this.hasSearchQuery, - favoritesIds: favoritesIds ?? this.favoritesIds, count: count ?? this.count, categorySelectorItems: categorySelectorItems ?? this.categorySelectorItems, ); diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart index 1f0ca4d6cbaa..8c26add7f4a7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/test/workspace/workspace_bloc_test.dart @@ -68,7 +68,6 @@ void main() { name: 'Catalyst Fund14', description: 'Description', allFunds: MultiCurrencyAmount.single(_adaMajorUnits(20000000)), - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), fundNumber: 14, timeline: const CampaignTimeline(phases: []), publish: CampaignPublish.published, @@ -81,10 +80,8 @@ void main() { categorySubname: 'Test Subname', description: 'Test description', shortDescription: 'Test short description', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(_adaMajorUnits(1000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), range: Range( min: _adaMajorUnits(10), max: _adaMajorUnits(100), @@ -123,7 +120,6 @@ void main() { name: 'Catalyst Fund14', description: 'Description', allFunds: MultiCurrencyAmount.single(_adaMajorUnits(20000000)), - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), // TODO(LynxLynxx): refactor it when _mapProposalToViewModel will be refactored fundNumber: 0, timeline: const CampaignTimeline(phases: []), @@ -137,10 +133,8 @@ void main() { categorySubname: 'Test Subname', description: 'Test description', shortDescription: 'Test short description', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(_adaMajorUnits(1000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(_adaMajorUnits(0)), range: Range( min: _adaMajorUnits(10), max: _adaMajorUnits(100), 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 40348a0c23e0..8d0037644ae7 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 @@ -82,6 +82,10 @@ class VoicesColorScheme extends ThemeExtension { final Color votingNegativeHover; final Color votingNegativeVoted; final Color votingOverlay; + final Color discoveryPrimary; + final Color discoveryTextOnPrimary; + final Color discoveryOnPrimary; + final Color discoveryTextOnPrimaryWhite; const VoicesColorScheme({ required this.textPrimary, @@ -159,6 +163,10 @@ class VoicesColorScheme extends ThemeExtension { required this.votingNegativeHover, required this.votingNegativeVoted, required this.votingOverlay, + required this.discoveryPrimary, + required this.discoveryOnPrimary, + required this.discoveryTextOnPrimary, + required this.discoveryTextOnPrimaryWhite, }); @visibleForTesting @@ -238,6 +246,10 @@ class VoicesColorScheme extends ThemeExtension { this.votingNegativeHover = Colors.black, this.votingNegativeVoted = Colors.black, this.votingOverlay = Colors.black, + this.discoveryPrimary = Colors.black, + this.discoveryOnPrimary = Colors.black, + this.discoveryTextOnPrimary = Colors.black, + this.discoveryTextOnPrimaryWhite = Colors.black, }); @override @@ -317,6 +329,10 @@ class VoicesColorScheme extends ThemeExtension { Color? votingNegativeHover, Color? votingNegativeVoted, Color? votingOverlay, + Color? discoveryPrimary, + Color? discoveryOnPrimary, + Color? discoveryTextOnPrimary, + Color? discoveryTextOnPrimaryWhite, }) { return VoicesColorScheme( textPrimary: textPrimary ?? this.textPrimary, @@ -398,6 +414,10 @@ class VoicesColorScheme extends ThemeExtension { votingNegativeHover: votingNegativeHover ?? this.votingNegativeHover, votingNegativeVoted: votingNegativeVoted ?? this.votingNegativeVoted, votingOverlay: votingOverlay ?? this.votingOverlay, + discoveryPrimary: discoveryPrimary ?? this.discoveryPrimary, + discoveryOnPrimary: discoveryOnPrimary ?? this.discoveryOnPrimary, + discoveryTextOnPrimary: discoveryTextOnPrimary ?? this.discoveryTextOnPrimary, + discoveryTextOnPrimaryWhite: discoveryTextOnPrimaryWhite ?? this.discoveryTextOnPrimaryWhite, ); } @@ -550,6 +570,14 @@ class VoicesColorScheme extends ThemeExtension { votingNegativeHover: Color.lerp(votingNegativeHover, other.votingNegativeHover, t)!, votingNegativeVoted: Color.lerp(votingNegativeVoted, other.votingNegativeVoted, t)!, votingOverlay: Color.lerp(votingOverlay, other.votingOverlay, t)!, + discoveryPrimary: Color.lerp(discoveryPrimary, other.discoveryPrimary, t)!, + discoveryOnPrimary: Color.lerp(discoveryOnPrimary, other.discoveryOnPrimary, t)!, + discoveryTextOnPrimary: Color.lerp(discoveryTextOnPrimary, other.discoveryTextOnPrimary, t)!, + discoveryTextOnPrimaryWhite: Color.lerp( + discoveryTextOnPrimaryWhite, + other.discoveryTextOnPrimaryWhite, + 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 567a9af27869..ac256a81b0a2 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 @@ -120,6 +120,10 @@ const VoicesColorScheme darkVoicesColorScheme = VoicesColorScheme( votingNegativeHover: Color(0xFFFF6666), votingNegativeVoted: Color(0xFFFF6666), votingOverlay: Color(0xA6000000), + discoveryPrimary: VoicesColors.lightPrimary, + discoveryOnPrimary: VoicesColors.lightOnPrimary, + discoveryTextOnPrimary: VoicesColors.lightTextOnPrimaryLevel0, + discoveryTextOnPrimaryWhite: VoicesColors.lightTextOnPrimaryWhite, ); const BrandAssets lightBrandAssets = BrandAssets( @@ -231,8 +235,19 @@ const VoicesColorScheme lightVoicesColorScheme = VoicesColorScheme( votingNegativeHover: Color(0xFFF50000), votingNegativeVoted: Color(0xFFFF6666), votingOverlay: Color(0x33000000), + discoveryPrimary: VoicesColors.lightPrimary, + discoveryOnPrimary: VoicesColors.lightOnPrimary, + discoveryTextOnPrimary: VoicesColors.lightTextOnPrimaryLevel0, + discoveryTextOnPrimaryWhite: VoicesColors.lightTextOnPrimaryWhite, ); +/// A safe font family set to act as a fallback in case +/// a glyph cannot be rendered with the default font. +const List _fontFamilyFallback = [ + 'sans-serif', + 'Arial', +]; + /// [ThemeData] for the `catalyst` brand. final ThemeData catalyst = _buildThemeData( lightColorScheme, @@ -247,13 +262,6 @@ final ThemeData darkCatalyst = _buildThemeData( darkBrandAssets, ); -/// A safe font family set to act as a fallback in case -/// a glyph cannot be rendered with the default font. -const List _fontFamilyFallback = [ - 'sans-serif', - 'Arial', -]; - TextTheme _buildTextTheme(VoicesColorScheme voicesColorScheme) { return TextTheme( displayLarge: GoogleFonts.notoSans( diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart index 09e1ba2876ee..3c1da5468e7b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign.dart @@ -24,7 +24,6 @@ final class Campaign extends Equatable { final String name; final String description; final MultiCurrencyAmount allFunds; - final MultiCurrencyAmount totalAsk; final int fundNumber; final CampaignTimeline timeline; final List categories; @@ -35,7 +34,6 @@ final class Campaign extends Equatable { required this.name, required this.description, required this.allFunds, - required this.totalAsk, required this.fundNumber, required this.timeline, required this.categories, @@ -49,7 +47,6 @@ final class Campaign extends Equatable { description: ''' Project Catalyst turns economic power into innovation power by using the Cardano Treasury to incentivize and fund community-approved ideas.''', allFunds: MultiCurrencyAmount.single(Currencies.ada.amount(20000000)), - totalAsk: MultiCurrencyAmount.single(Money.zero(currency: Currencies.ada)), fundNumber: 14, timeline: f14StaticCampaignTimeline, publish: CampaignPublish.published, @@ -66,10 +63,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano Currencies.ada.amount(20000000), Currencies.usdm.amount(250000), ]), - totalAsk: MultiCurrencyAmount.list([ - Money.zero(currency: Currencies.ada), - Money.zero(currency: Currencies.usdm), - ]), fundNumber: 15, timeline: f15StaticCampaignTimeline, publish: CampaignPublish.published, @@ -112,7 +105,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano name, description, allFunds, - totalAsk, fundNumber, timeline, publish, @@ -165,7 +157,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano String? name, String? description, MultiCurrencyAmount? allFunds, - MultiCurrencyAmount? totalAsk, int? fundNumber, CampaignTimeline? timeline, CampaignPublish? publish, @@ -176,7 +167,6 @@ Project Catalyst turns economic power into innovation power by using the Cardano name: name ?? this.name, description: description ?? this.description, allFunds: allFunds ?? this.allFunds, - totalAsk: totalAsk ?? this.totalAsk, fundNumber: fundNumber ?? this.fundNumber, timeline: timeline ?? this.timeline, publish: publish ?? this.publish, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart index 3186e890bd73..b09d2dacd247 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category.dart @@ -13,15 +13,15 @@ class CampaignCategory extends Equatable { final String categorySubname; final String description; final String shortDescription; - final int proposalsCount; final MultiCurrencyAmount availableFunds; - final MultiCurrencyAmount totalAsk; final Range range; final Currency currency; final List descriptions; final String imageUrl; final List dos; final List donts; + + // TODO(damian-molinski): remove this final DateTime submissionCloseDate; const CampaignCategory({ @@ -32,10 +32,8 @@ class CampaignCategory extends Equatable { required this.categorySubname, required this.description, required this.shortDescription, - required this.proposalsCount, required this.availableFunds, required this.imageUrl, - required this.totalAsk, required this.range, required this.currency, required this.descriptions, @@ -55,10 +53,8 @@ class CampaignCategory extends Equatable { categorySubname, description, shortDescription, - proposalsCount, availableFunds, imageUrl, - totalAsk, range, descriptions, dos, @@ -74,10 +70,8 @@ class CampaignCategory extends Equatable { String? categorySubname, String? description, String? shortDescription, - int? proposalsCount, MultiCurrencyAmount? availableFunds, String? imageUrl, - MultiCurrencyAmount? totalAsk, Range? range, Currency? currency, List? descriptions, @@ -93,10 +87,8 @@ class CampaignCategory extends Equatable { categorySubname: categorySubname ?? this.categorySubname, description: description ?? this.description, shortDescription: shortDescription ?? this.shortDescription, - proposalsCount: proposalsCount ?? this.proposalsCount, availableFunds: availableFunds ?? this.availableFunds, imageUrl: imageUrl ?? this.imageUrl, - totalAsk: totalAsk ?? this.totalAsk, range: range ?? this.range, currency: currency ?? this.currency, descriptions: descriptions ?? this.descriptions, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart new file mode 100644 index 000000000000..238634b53fa0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_category_total_ask.dart @@ -0,0 +1,49 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignCategoryTotalAsk extends Equatable { + final DocumentRef ref; + final int finalProposalsCount; + final List money; + + const CampaignCategoryTotalAsk({ + required this.ref, + required this.finalProposalsCount, + required this.money, + }); + + const CampaignCategoryTotalAsk.zero(this.ref) : finalProposalsCount = 0, money = const []; + + @override + List get props => [ + ref, + finalProposalsCount, + money, + ]; + + MultiCurrencyAmount get totalAsk => MultiCurrencyAmount.list(money); + + CampaignCategoryTotalAsk operator +(CampaignCategoryTotalAsk other) { + assert(ref == other.ref, 'Refs do not match'); + + final finalProposalsCount = this.finalProposalsCount + other.finalProposalsCount; + final money = [...this.money, ...other.money]; + + return copyWith( + finalProposalsCount: finalProposalsCount, + money: money, + ); + } + + CampaignCategoryTotalAsk copyWith({ + DocumentRef? ref, + int? finalProposalsCount, + List? money, + }) { + return CampaignCategoryTotalAsk( + ref: ref ?? this.ref, + finalProposalsCount: finalProposalsCount ?? this.finalProposalsCount, + money: money ?? this.money, + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart index 9969674c7b5b..705285e48f16 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_filters.dart @@ -13,6 +13,14 @@ final class CampaignFilters extends Equatable { return CampaignFilters(categoriesIds: categoriesIds); } + factory CampaignFilters.from(Campaign campaign) { + final categoriesIds = campaign.categories.map((e) => e.selfRef.id).toList(); + return CampaignFilters(categoriesIds: categoriesIds); + } + @override List get props => [categoriesIds]; + + @override + String toString() => 'CampaignFilters($categoriesIds)'; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart index ba173c7de557..c30ba1306150 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_phase.dart @@ -44,5 +44,13 @@ enum CampaignPhaseType { votingRegistration, reviewRegistration, votingResults, - projectOnboarding, + projectOnboarding; + + bool get isCommunityReview => this == CampaignPhaseType.communityReview; + + bool get isCommunityVoting => this == CampaignPhaseType.communityVoting; + + bool get isReviewRegistration => this == CampaignPhaseType.reviewRegistration; + + bool get isVotingRegistration => this == CampaignPhaseType.votingRegistration; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart new file mode 100644 index 000000000000..611acf482c85 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/campaign_total_ask.dart @@ -0,0 +1,27 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignTotalAsk extends Equatable { + final Map categoriesAsks; + + const CampaignTotalAsk({ + required this.categoriesAsks, + }); + + @override + List get props => [categoriesAsks]; + + MultiCurrencyAmount get totalAsk { + final categoriesMoney = categoriesAsks.values.map((e) => e.money).flattened.toList(); + return MultiCurrencyAmount.list(categoriesMoney); + } + + CampaignCategoryTotalAsk? category(SignedDocumentRef ref) { + return categoriesAsks.entries.firstWhereOrNull((entry) => entry.key.id == ref.id)?.value; + } + + CampaignCategoryTotalAsk categoryOrZero(SignedDocumentRef ref) { + return category(ref) ?? CampaignCategoryTotalAsk.zero(ref); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart index fe0dd0e86f9a..f4ac9086cfd0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f14_static_campaign_categories.dart @@ -17,10 +17,8 @@ final f14StaticCampaignCategories = [ '''Cardano Use Cases: Partners & Products empowers exceptional applications and enterprise collaborations to enhance products and services with capabilities that drive high-volume transactions and accelerates mainstream adoption.''', shortDescription: 'For Tier-1 collaborations and real-world pilots that scale Cardano adoption through high-impact use cases.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(8500000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(250000), max: Currencies.ada.amount(1000000), @@ -101,10 +99,8 @@ The following will **not** be funded: '''Cardano Use Cases: Concepts funds novel, early-stage Cardano-based concepts developing proof of concept prototypes through deploying minimum viable products (MVP) to validate innovative products, services, or business models driving Cardano adoption.''', shortDescription: 'For early-stage ideas to create, test, and validate Cardano-based prototypes to MVP innovations.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(4000000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(100000), @@ -185,10 +181,8 @@ Funds open source tools and environments to enhance the Cardano developer experi ''', shortDescription: 'For developers to build open-source tools that enhance the Cardano developer experience.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(3100000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(100000), @@ -270,10 +264,8 @@ Funds non-technical initiatives like marketing, education, and community buildin ''', shortDescription: 'For non-tech projects like marketing, education, or community growth to expand Cardano’s reach.', - proposalsCount: 0, availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(3000000)), imageUrl: '', - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(60000), diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart index 524469d21007..e95b3abc649f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_categories.dart @@ -19,7 +19,6 @@ final f15StaticCampaignCategories = [ shortDescription: 'To fund high-impact R&D pilots and integrations led by or in collaboration with established Tier-1 enterprises, driving mainstream Cardano adoption and creating significant co-marketing opportunities.', availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(10000000)), - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(250000), max: Currencies.ada.amount(750000), @@ -132,7 +131,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Use vague team bios; provide links.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), @@ -148,7 +146,6 @@ Use this checklist to ensure your proposal meets all foundational and content re shortDescription: 'To provide entrepreneurial individuals and teams with funding to build and launch novel prototypes that have already undergone foundational research and design, accelerating the delivery of new on-chain utility for Cardano.', availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(6000000)), - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(200000), @@ -241,7 +238,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Budget for giveaways/re-grants.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), // //Cardano Open: Ecosystem @@ -256,7 +252,6 @@ Use this checklist to ensure your proposal meets all foundational and content re shortDescription: 'Funds non-technical initiatives like marketing, education, research, and community building to grow Cardano’s ecosystem and onboard new users globally.', availableFunds: MultiCurrencyAmount.single(Currencies.ada.amount(2500000)), - totalAsk: MultiCurrencyAmount.single(Currencies.ada.amount(0)), range: Range( min: Currencies.ada.amount(15000), max: Currencies.ada.amount(60000), @@ -348,7 +343,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Focus on giveaways/incentives.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), //Midnight: Compact DApps @@ -363,7 +357,6 @@ Use this checklist to ensure your proposal meets all foundational and content re shortDescription: 'To accelerate developer adoption of Midnight by funding essential open-source reference DApps. This category is seeking reference DApps, and funding will be sponsored by the Midnight Foundation.', availableFunds: MultiCurrencyAmount.single(Currencies.usdm.amount(250000)), - totalAsk: MultiCurrencyAmount.single(Currencies.usdm.amount(0)), range: Range( min: Currencies.usdm.amount(2500), max: Currencies.usdm.amount(10000), @@ -479,7 +472,6 @@ Use this checklist to ensure your proposal meets all foundational and content re 'Outsource the core development.', ], submissionCloseDate: DateTimeExt.now(), - proposalsCount: 0, imageUrl: '', ), ]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_timeline.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_timeline.dart index 0fbd1310bcee..e7bff56b3011 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_timeline.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/campaign/constant/f15_static_campaign_timeline.dart @@ -46,7 +46,7 @@ final f15StaticCampaignTimeline = CampaignTimeline( title: 'Community Voting', description: '''Community members cast their votes using the Catalyst Voting app.''', timeline: DateRange( - from: DateTime.utc(2027, 07, 05, 18), + from: DateTime.utc(2025, 07, 05, 18), to: DateTime.utc(2027, 07, 12, 10), ), type: CampaignPhaseType.communityVoting, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 8ba456721b5e..58fbc1b94f41 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -12,9 +12,11 @@ export 'auth/seed_phrase.dart'; export 'blockchain/blockchain_slot_config.dart'; export 'campaign/campaign.dart'; export 'campaign/campaign_category.dart'; +export 'campaign/campaign_category_total_ask.dart'; export 'campaign/campaign_filters.dart'; export 'campaign/campaign_phase.dart'; export 'campaign/campaign_timeline.dart'; +export 'campaign/campaign_total_ask.dart'; export 'common/hi_lo/hi_lo.dart'; export 'common/hi_lo/uuid_hi_lo.dart'; export 'common/markdown_data.dart'; @@ -75,11 +77,15 @@ export 'logging/logging_settings.dart'; export 'money/currencies.dart'; export 'money/currency.dart'; export 'money/money.dart'; +export 'money/money_format.dart'; export 'money/multi_currency_amount.dart'; export 'pagination/page.dart'; export 'pagination/page_request.dart'; export 'permissions/exceptions/permission_exceptions.dart'; export 'proposal/core_proposal.dart'; +export 'proposal/data/joined_proposal_brief_data.dart'; +export 'proposal/data/proposal_brief_data.dart'; +export 'proposal/data/proposals_total_ask.dart'; export 'proposal/detail_proposal.dart'; export 'proposal/exception/proposal_limit_reached_exception.dart'; export 'proposal/proposal.dart'; @@ -87,13 +93,16 @@ export 'proposal/proposal_creation_step.dart'; export 'proposal/proposal_data.dart'; export 'proposal/proposal_enums.dart'; export 'proposal/proposal_forget_actions.dart'; +export 'proposal/proposal_or_document.dart'; export 'proposal/proposal_version.dart'; export 'proposal/proposal_votes.dart'; export 'proposal/proposal_with_context.dart'; export 'proposals/proposals_count.dart'; export 'proposals/proposals_count_filters.dart'; export 'proposals/proposals_filters.dart'; +export 'proposals/proposals_filters_v2.dart'; export 'proposals/proposals_order.dart'; +export 'proposals/proposals_total_ask_filters.dart'; export 'registration/account_submit_data.dart'; export 'registration/registration.dart'; export 'share/share_channel.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/app_config.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/app_config.dart index 3ae3522034e7..f74c295a5c4a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/app_config.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/app_config.dart @@ -28,7 +28,7 @@ final class AppConfig extends Equatable { final SentryConfig sentry; final BlockchainConfig blockchain; final StressTestConfig stressTest; - final CatalystDeveloperProfilerConfig developerProfiler; + final CatalystDeveloperProfilerConfig profiler; const AppConfig({ required this.version, @@ -37,7 +37,7 @@ final class AppConfig extends Equatable { required this.sentry, required this.blockchain, required this.stressTest, - required this.developerProfiler, + required this.profiler, }); AppConfig.dev() @@ -69,7 +69,7 @@ final class AppConfig extends Equatable { slotNumberConfig: BlockchainSlotNumberConfig.testnet(), ), stressTest: const StressTestConfig(), - developerProfiler: const CatalystDeveloperProfilerConfig(), + profiler: const CatalystDeveloperProfilerConfig(), ); factory AppConfig.env(AppEnvironmentType env) { @@ -110,7 +110,7 @@ final class AppConfig extends Equatable { slotNumberConfig: BlockchainSlotNumberConfig.testnet(), ), stressTest: const StressTestConfig(), - developerProfiler: const CatalystDeveloperProfilerConfig(), + profiler: const CatalystDeveloperProfilerConfig(), ); AppConfig.prod() @@ -142,7 +142,7 @@ final class AppConfig extends Equatable { slotNumberConfig: BlockchainSlotNumberConfig.mainnet(), ), stressTest: const StressTestConfig(), - developerProfiler: const CatalystDeveloperProfilerConfig(), + profiler: const CatalystDeveloperProfilerConfig(), ); @override @@ -153,7 +153,7 @@ final class AppConfig extends Equatable { sentry, blockchain, stressTest, - developerProfiler, + profiler, ]; AppConfig copyWith({ @@ -163,7 +163,7 @@ final class AppConfig extends Equatable { SentryConfig? sentry, BlockchainConfig? blockchain, StressTestConfig? stressTest, - CatalystDeveloperProfilerConfig? developerProfiler, + CatalystDeveloperProfilerConfig? profiler, }) { return AppConfig( version: version ?? this.version, @@ -172,7 +172,7 @@ final class AppConfig extends Equatable { sentry: sentry ?? this.sentry, blockchain: blockchain ?? this.blockchain, stressTest: stressTest ?? this.stressTest, - developerProfiler: developerProfiler ?? this.developerProfiler, + profiler: profiler ?? this.profiler, ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/catalyst_developer_profiler_config.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/catalyst_developer_profiler_config.dart index 7ab923cf4842..5e3c538475e3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/catalyst_developer_profiler_config.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/config/catalyst_developer_profiler_config.dart @@ -18,6 +18,8 @@ final class CatalystDeveloperProfilerConfig extends Equatable { bool get debugProfilePaintsEnabledConfig => const bool.fromEnvironment('DEBUG_PROFILE_PAINTS_ENABLED'); + bool get console => const bool.fromEnvironment('CONSOLE_PROFILE'); + @override List get props => []; @@ -28,7 +30,8 @@ final class CatalystDeveloperProfilerConfig extends Equatable { 'debugProfileBuildsEnabledUserWidgetsConfig: $debugProfileBuildsEnabledUserWidgetsConfig, ' 'debugProfileDeveloperProfilerEnableAll: $debugProfileDeveloperProfilerEnableAll, ' 'debugProfileLayoutsEnabledConfig: $debugProfileLayoutsEnabledConfig, ' - 'debugProfilePaintsEnabledConfig: $debugProfilePaintsEnabledConfig' + 'debugProfilePaintsEnabledConfig: $debugProfilePaintsEnabledConfig, ' + 'console: $console' '}'; } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/data/document_data.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/data/document_data.dart index b74281e5f730..12f6b35adedd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/data/document_data.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/data/document_data.dart @@ -35,7 +35,9 @@ final class DocumentData extends Equatable implements Comparable { } /// Update document data with a new [ref]. - DocumentData copyWithSelfRef({required DocumentRef selfRef}) { + DocumentData copyWith({ + required DocumentRef selfRef, + }) { return DocumentData( metadata: metadata.copyWith(selfRef: selfRef), content: content, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart index 8622c2ee03b4..ac66666f2576 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/enums/document_property_format.dart @@ -28,74 +28,19 @@ base class DocumentCurrencyFormat extends DocumentPropertyFormat { @override List get props => super.props + [currency, moneyUnits]; - /// Parses the [DocumentCurrencyFormat] from a [format]. - /// Returns `null` if format is unrecognized. - /// - /// Format: - /// - token|fiat[:$brand]:$code[:$cent] - /// - /// Examples: - /// - token:cardano:ada - /// - token:cardano:ada:lovelace - /// - token:usdm - /// - token:usdm:cent - /// - fiat:usd - /// - fiat:usd:cent - /// - fiat:eur - /// - fiat:eur:cent + /// Parses the [DocumentCurrencyFormat] from a [format] unit [MoneyFormat]. static DocumentCurrencyFormat? parse(String format) { - final parts = format.split(':'); - return switch (parts) { - [final type, _, final code, final minor] - when _isValidType(type) && _isValidMinorUnits(minor) => - _createFormat(format, code, MoneyUnits.minorUnits), - - [final type, final code, final minor] when _isValidType(type) && _isValidMinorUnits(minor) => - _createFormat(format, code, MoneyUnits.minorUnits), - - [final type, _, final code] when _isValidType(type) => _createFormat( - format, - code, - MoneyUnits.majorUnits, - ), - - [final type, final code] when _isValidType(type) => _createFormat( - format, - code, - MoneyUnits.majorUnits, - ), - _ => null, - }; - } - - static DocumentCurrencyFormat? _createFormat( - String format, - String currencyCode, - MoneyUnits moneyUnits, - ) { - final currency = Currency.fromCode(currencyCode); - if (currency == null) { + final moneyFormat = MoneyFormat.parse(format); + if (moneyFormat == null) { return null; } + return DocumentCurrencyFormat( format, - currency: currency, - moneyUnits: moneyUnits, + currency: moneyFormat.currency, + moneyUnits: moneyFormat.moneyUnits, ); } - - /// Checks if a string identifies a minor currency unit. - static bool _isValidMinorUnits(String minorUnits) { - return switch (minorUnits) { - 'cent' || 'penny' || 'lovelace' || 'sat' || 'wei' => true, - _ => false, - }; - } - - /// Checks if the type is 'fiat' or 'token'. - static bool _isValidType(String type) { - return type == 'fiat' || type == 'token'; - } } final class DocumentDropdownSingleSelectFormat extends DocumentPropertyFormat { diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart index 60bde983410f..2c2ce38a19b3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/specialized/proposal_template.dart @@ -11,16 +11,21 @@ final class ProposalTemplate extends Equatable { required this.schema, }); - DocumentStringSchema? get title { - final property = schema.getPropertySchema(ProposalDocument.titleNodeId); - return property is DocumentStringSchema ? property : null; - } - @override List get props => [ metadata, schema, ]; + + DocumentCurrencySchema? get requestedFunds { + final property = schema.getPropertySchema(ProposalDocument.requestedFundsNodeId); + return property is DocumentCurrencySchema ? property : null; + } + + DocumentStringSchema? get title { + final property = schema.getPropertySchema(ProposalDocument.titleNodeId); + return property is DocumentStringSchema ? property : null; + } } final class ProposalTemplateMetadata extends DocumentMetadata { diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart new file mode 100644 index 000000000000..d645b560e2d0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money_format.dart @@ -0,0 +1,83 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class MoneyFormat extends Equatable { + final Currency currency; + final MoneyUnits moneyUnits; + + const MoneyFormat({ + required this.currency, + required this.moneyUnits, + }); + + @override + List get props => [ + currency, + moneyUnits, + ]; + + /// Parses the [MoneyFormat] from a [format]. + /// Returns `null` if format is unrecognized. + /// + /// Format: + /// - token|fiat[:$brand]:$code[:$cent] + /// + /// Examples: + /// - token:cardano:ada + /// - token:cardano:ada:lovelace + /// - token:usdm + /// - token:usdm:cent + /// - fiat:usd + /// - fiat:usd:cent + /// - fiat:eur + /// - fiat:eur:cent + static MoneyFormat? parse(String format) { + final parts = format.split(':'); + return switch (parts) { + [final type, _, final code, final minor] + when _isValidType(type) && _isValidMinorUnits(minor) => + _createFormat(code, MoneyUnits.minorUnits), + + [final type, final code, final minor] when _isValidType(type) && _isValidMinorUnits(minor) => + _createFormat(code, MoneyUnits.minorUnits), + + [final type, _, final code] when _isValidType(type) => _createFormat( + code, + MoneyUnits.majorUnits, + ), + + [final type, final code] when _isValidType(type) => _createFormat( + code, + MoneyUnits.majorUnits, + ), + _ => null, + }; + } + + static MoneyFormat? _createFormat( + String currencyCode, + MoneyUnits moneyUnits, + ) { + final currency = Currency.fromCode(currencyCode); + if (currency == null) { + return null; + } + return MoneyFormat( + currency: currency, + moneyUnits: moneyUnits, + ); + } + + /// Checks if a string identifies a minor currency unit. + static bool _isValidMinorUnits(String minorUnits) { + return switch (minorUnits) { + 'cent' || 'penny' || 'lovelace' || 'sat' || 'wei' => true, + _ => false, + }; + } + + /// Checks if the type is 'fiat' or 'token'. + static bool _isValidType(String type) { + return type == 'fiat' || type == 'token'; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart index 5315baa34e62..12a9e5e1328f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/multi_currency_amount.dart @@ -1,5 +1,5 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_models/src/money/currency_code.dart'; -import 'package:catalyst_voices_models/src/money/money.dart'; import 'package:equatable/equatable.dart'; /// Represents a collection of monetary amounts in multiple currencies. @@ -14,7 +14,7 @@ final class MultiCurrencyAmount extends Equatable { /// Creates an empty [MultiCurrencyAmount] or initializes with an optional map. MultiCurrencyAmount({ Map? map, - }) : _map = map ?? {}; + }) : _map = Map.unmodifiable(map ?? {}); /// Creates a [MultiCurrencyAmount] from a list of [Money] values. /// @@ -23,11 +23,21 @@ final class MultiCurrencyAmount extends Equatable { /// final amounts = MultiCurrencyAmount.list([usdmMoney, adaMoney]); /// ``` factory MultiCurrencyAmount.list(List money) { - final group = MultiCurrencyAmount(); + final map = {}; + for (final item in money) { - group.add(item); + final currency = item.currency; + + map.update( + currency.code, + (value) => value + item, + ifAbsent: () => item, + ); } - return group; + + map.removeWhere((key, value) => value.isZero); + + return MultiCurrencyAmount(map: map); } /// Creates a [MultiCurrencyAmount] with a single [Money] value. @@ -36,13 +46,19 @@ final class MultiCurrencyAmount extends Equatable { /// ```dart /// final amount = MultiCurrencyAmount.single(usdmMoney); /// ``` - factory MultiCurrencyAmount.single(Money money) { - final group = MultiCurrencyAmount()..add(money); - return group; + factory MultiCurrencyAmount.single(Money money) => MultiCurrencyAmount.list([money]); + + /// Creates a [MultiCurrencyAmount] with a single zero [Money] value. + /// + /// The currency defaults to [Currencies.fallback] if not specified. + factory MultiCurrencyAmount.zero({ + Currency currency = Currencies.fallback, + }) { + return MultiCurrencyAmount.single(Money.zero(currency: currency)); } /// Returns all [Money] values in this collection as a list. - List get list => _map.values.toList(); + List get list => List.unmodifiable(_map.values); @override List get props => [_map]; @@ -52,17 +68,6 @@ final class MultiCurrencyAmount extends Equatable { return _map[isoCode]; } - /// Adds [money] to the collection. - /// - /// If an amount for the same currency already exists, it sums them. - /// Zero amounts are automatically removed from the collection. - void add(Money money) { - final currency = money.currency; - final current = _map[currency.code]; - final updated = (current ?? Money.zero(currency: currency)) + money; - _updateMap(updated); - } - /// Creates a deep copy of this [MultiCurrencyAmount]. /// /// Modifications to the copy will not affect the original. @@ -70,26 +75,6 @@ final class MultiCurrencyAmount extends Equatable { return MultiCurrencyAmount(map: Map.of(_map)); } - /// Subtracts [money] from the collection. - /// - /// If an amount for the same currency exists, it subtracts it. - /// Zero amounts are automatically removed from the collection. - void subtract(Money money) { - final currency = money.currency; - final current = _map[currency.code]; - final updated = (current ?? Money.zero(currency: currency)) - money; - _updateMap(updated); - } - - /// Updates the internal map with [money]. - /// - /// If the amount is zero, the entry is removed. Otherwise, it is added/updated. - void _updateMap(Money money) { - final isoCode = money.currency.code; - if (money.isZero) { - _map.remove(isoCode); - } else { - _map[isoCode] = money; - } - } + @override + String toString() => 'MultiCurrencyAmount(${_map.values.join(', ')})'; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart index a7539766ad25..b8d4c07b03b9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart @@ -14,13 +14,16 @@ base class Page extends Equatable { required this.items, }); - const Page.empty() - : this( - page: 0, - maxPerPage: 0, - total: 0, - items: const [], - ); + const Page.empty({ + int page = 0, + int maxPerPage = 0, + int total = 0, + }) : this( + page: page, + maxPerPage: maxPerPage, + total: total, + items: const [], + ); @override List get props => [ @@ -39,6 +42,18 @@ base class Page extends Equatable { ); } + Page copyWith({ + int? total, + List? items, + }) { + return Page( + page: page, + maxPerPage: maxPerPage, + total: total ?? this.total, + items: items ?? this.items, + ); + } + Page copyWithItems(List items) { return Page( page: page, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page_request.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page_request.dart index 34ced150b7ec..5f4740b2e807 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page_request.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page_request.dart @@ -15,4 +15,7 @@ final class PageRequest extends Equatable { page, size, ]; + + @override + String toString() => 'PageRequest(page[$page], size[$size])'; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/joined_proposal_brief_data.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/joined_proposal_brief_data.dart new file mode 100644 index 000000000000..e3d06537931b --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/joined_proposal_brief_data.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class JoinedProposalBriefData extends Equatable { + final ProposalOrDocument proposal; + final ProposalSubmissionAction? actionType; + final List versionIds; + final int commentsCount; + final bool isFavorite; + + const JoinedProposalBriefData({ + required this.proposal, + this.actionType, + this.versionIds = const [], + this.commentsCount = 0, + this.isFavorite = false, + }); + + bool get isFinal => actionType == ProposalSubmissionAction.aFinal; + + int get iteration => versionIds.indexOf(proposal.version) + 1; + + @override + List get props => [ + proposal, + actionType, + versionIds, + commentsCount, + isFavorite, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart new file mode 100644 index 000000000000..9cfa44d8b399 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart @@ -0,0 +1,64 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class ProposalBriefData extends Equatable { + final DocumentRef selfRef; + final String authorName; + final String title; + final String description; + final String categoryName; + final int durationInMonths; + final Money fundsRequested; + final DateTime createdAt; + final int iteration; + final int? commentsCount; + final bool isFinal; + final bool isFavorite; + final ProposalBriefDataVotes? votes; + + const ProposalBriefData({ + required this.selfRef, + required this.authorName, + required this.title, + required this.description, + required this.categoryName, + this.durationInMonths = 0, + required this.fundsRequested, + required this.createdAt, + this.iteration = 1, + this.commentsCount, + this.isFinal = false, + this.isFavorite = false, + this.votes, + }); + + @override + List get props => [ + selfRef, + authorName, + title, + description, + categoryName, + durationInMonths, + fundsRequested, + createdAt, + iteration, + commentsCount, + isFinal, + isFavorite, + votes, + ]; +} + +final class ProposalBriefDataVotes extends Equatable { + final Vote? draft; + final Vote? casted; + + const ProposalBriefDataVotes({ + this.draft, + this.casted, + }); + + @override + List get props => [draft, casted]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart new file mode 100644 index 000000000000..917e90c8372a --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposals_total_ask.dart @@ -0,0 +1,43 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// A container for the total funding asked by proposals, organized by templates. +/// +/// The [data] map uses a [DocumentRef] (representing a template) as the key +/// and a [ProposalsTotalAskPerTemplate] object as the value. +final class ProposalsTotalAsk extends Equatable { + /// A map where each key is a reference to a template document and + /// the value contains the aggregated ask data for that template's proposals. + final Map data; + + const ProposalsTotalAsk(this.data); + + @override + List get props => [ + data, + ]; +} + +/// Represents the aggregated funding information for proposals +/// within a single template. +final class ProposalsTotalAskPerTemplate extends Equatable { + /// The sum of the `amountAsked` for all final proposals in a template. + final int totalAsk; + + /// The total number of final proposals counted in a template. + final int finalProposalsCount; + + /// Creates an instance of [ProposalsTotalAskPerTemplate]. + /// + /// Both [totalAsk] and [finalProposalsCount] are required. + const ProposalsTotalAskPerTemplate({ + required this.totalAsk, + required this.finalProposalsCount, + }); + + @override + List get props => [ + totalAsk, + finalProposalsCount, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart new file mode 100644 index 000000000000..5a0697c99206 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart @@ -0,0 +1,133 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; + +/// A sealed class that represents either a full proposal document with a +/// defined template (`ProposalDocument`) or a generic document without a +/// specific template (`DocumentData`). +/// +/// This class provides a unified interface to access common properties +/// like [title], [authorName], [description], etc., regardless of the +/// underlying data type. +/// +/// It's useful when dealing with list of proposals and some of them may not have templates +/// loaded yet. +sealed class ProposalOrDocument extends Equatable { + const ProposalOrDocument(); + + /// Creates a [ProposalOrDocument] from a generic [DocumentData]. + const factory ProposalOrDocument.data(DocumentData data) = _Document; + + /// Creates a [ProposalOrDocument] from a structured [ProposalDocument]. + const factory ProposalOrDocument.proposal(ProposalDocument data) = _Proposal; + + /// The name of the proposal's author. + String? get authorName; + + // TODO(damian-molinski): Category name should come from query but atm those are not documents. + /// The name of the proposal's category. + String? get categoryName { + return Campaign.all + .map((e) => e.categories) + .flattened + .firstWhereOrNull((category) => category.selfRef == _category) + ?.formattedCategoryName; + } + + /// A brief description of the proposal. + String? get description; + + /// The duration of the proposal in months. + int? get durationInMonths; + + /// The amount of funds requested by the proposal. + Money? get fundsRequested; + + /// A reference to the document itself. + DocumentRef get selfRef; + + /// The title of the proposal. + String? get title; + + /// The version of the document. + String get version; + + /// A private getter for the category reference, used to find the + /// [categoryName]. + SignedDocumentRef? get _category; +} + +final class _Document extends ProposalOrDocument { + final DocumentData data; + + const _Document(this.data); + + @override + String? get authorName => data.metadata.authors?.firstOrNull?.username; + + @override + String? get description => ProposalDocument.titleNodeId.from(data.content.data); + + @override + int? get durationInMonths => ProposalDocument.descriptionNodeId.from(data.content.data); + + @override + // without template we don't know currency so we can't Currencies.fallback or + // assume major unit status + Money? get fundsRequested => null; + + @override + List get props => [data]; + + @override + DocumentRef get selfRef => data.metadata.selfRef; + + @override + String? get title => ProposalDocument.titleNodeId.from(data.content.data); + + @override + String get version => data.metadata.selfRef.version!; + + @override + SignedDocumentRef? get _category => data.metadata.categoryId; +} + +final class _Proposal extends ProposalOrDocument { + final ProposalDocument data; + + const _Proposal(this.data); + + @override + String? get authorName => data.authorName; + + @override + String? get description => data.description; + + @override + int? get durationInMonths => data.duration; + + @override + Money? get fundsRequested => data.fundsRequested; + + @override + List get props => [data]; + + @override + DocumentRef get selfRef => data.metadata.selfRef; + + @override + String? get title => data.title; + + @override + String get version => data.metadata.selfRef.version!; + + @override + SignedDocumentRef? get _category => data.metadata.categoryId; +} + +extension on DocumentNodeId { + T? from(Map data) { + return DocumentNodeTraverser.getValue(this, data); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart new file mode 100644 index 000000000000..a500d97c32f0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_template_currency.dart @@ -0,0 +1,18 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +final class ProposalTemplateMoneyFormats extends Equatable { + final DocumentRef ref; + final Map formats; + + const ProposalTemplateMoneyFormats({ + required this.ref, + required this.formats, + }); + + @override + List get props => [ + ref, + formats, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters_v2.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters_v2.dart new file mode 100644 index 000000000000..e2d2bf298af0 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters_v2.dart @@ -0,0 +1,165 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// A set of filters to be applied when querying for campaign proposals. +final class ProposalsCampaignFilters extends Equatable { + /// Filters proposals by their category IDs. + final Set categoriesIds; + + /// Creates a set of filters for querying campaign proposals. + const ProposalsCampaignFilters({ + required this.categoriesIds, + }); + + /// Currently hardcoded active campaign helper constructor. + factory ProposalsCampaignFilters.active() { + final categoriesIds = activeConstantDocumentRefs.map((e) => e.category.id).toSet(); + return ProposalsCampaignFilters(categoriesIds: categoriesIds); + } + + @override + List get props => [categoriesIds]; + + @override + String toString() => 'categoriesIds: $categoriesIds'; +} + +/// A set of filters to be applied when querying for proposals. +final class ProposalsFiltersV2 extends Equatable { + /// Filters proposals by their effective status. If null, this filter is not applied. + final ProposalStatusFilter? status; + + /// Filters proposals based on whether they are marked as a favorite. + /// If null, this filter is not applied. + final bool? isFavorite; + + /// Filters proposals to only include those signed by this [author]. + /// If null, this filter is not applied. + final CatalystId? author; + + /// Filters proposals by their category ID. + /// If null, this filter is not applied. + final String? categoryId; + + /// A search term to match against proposal titles or author names. + /// If null, no text search is performed. + final String? searchQuery; + + /// Filters proposals to only include those created within the [latestUpdate] duration + /// from the current time. + /// If null, this filter is not applied. + final Duration? latestUpdate; + + /// Filters proposals based on their campaign categories. + /// If [campaign] is not null and [categoryId] is not included, empty list will be returned. + /// If null, this filter is not applied. + final ProposalsCampaignFilters? campaign; + + /// Filters proposals based on whether a specific user has voted on them. + /// The value is the [CatalystId] of the user. + /// If null, this filter is not applied. + final CatalystId? voteBy; + + // TODO(damian-molinski): Remove this when voteBy is implemented + /// Temporary filter only for mocked implementation of [voteBy]. + final List? ids; + + /// Creates a set of filters for querying proposals. + const ProposalsFiltersV2({ + this.status, + this.isFavorite, + this.author, + this.categoryId, + this.searchQuery, + this.latestUpdate, + this.campaign, + this.voteBy, + this.ids, + }); + + @override + List get props => [ + status, + isFavorite, + author, + categoryId, + searchQuery, + latestUpdate, + campaign, + voteBy, + ids, + ]; + + ProposalsFiltersV2 copyWith({ + Optional? status, + Optional? isFavorite, + Optional? author, + Optional? categoryId, + Optional? searchQuery, + Optional? latestUpdate, + Optional? campaign, + Optional? voteBy, + Optional>? ids, + }) { + return ProposalsFiltersV2( + status: status.dataOr(this.status), + isFavorite: isFavorite.dataOr(this.isFavorite), + author: author.dataOr(this.author), + categoryId: categoryId.dataOr(this.categoryId), + searchQuery: searchQuery.dataOr(this.searchQuery), + latestUpdate: latestUpdate.dataOr(this.latestUpdate), + campaign: campaign.dataOr(this.campaign), + voteBy: voteBy.dataOr(this.voteBy), + ids: ids.dataOr(this.ids), + ); + } + + @override + String toString() { + final buffer = StringBuffer('ProposalsFiltersV2('); + final parts = []; + + if (status != null) { + parts.add('status: $status'); + } + if (isFavorite != null) { + parts.add('isFavorite: $isFavorite'); + } + if (author != null) { + parts.add('author: $author'); + } + if (categoryId != null) { + parts.add('categoryId: $categoryId'); + } + if (searchQuery != null) { + parts.add('searchQuery: "$searchQuery"'); + } + if (latestUpdate != null) { + parts.add('latestUpdate: $latestUpdate'); + } + if (campaign != null) { + parts.add('campaign: $campaign'); + } + if (voteBy != null) { + parts.add('votedBy: $voteBy'); + } + if (ids != null) { + parts.add('ids: ${ids!.join(',')}'); + } + + buffer + ..write(parts.isNotEmpty ? parts.join(', ') : 'no filters') + ..write(')'); + + return buffer.toString(); + } +} + +/// An enum representing the status of a proposal for filtering purposes. +enum ProposalStatusFilter { + /// Represents a final, submitted proposal. + aFinal, + + /// Represents a proposal that is still in draft state. + draft, +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart index 399bec665141..9231027095e2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart @@ -1,7 +1,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:equatable/equatable.dart'; -/// Orders base on [Proposal.title]. +/// Orders proposals based on their [Proposal.title] in alphabetical order. final class Alphabetical extends ProposalsOrder { const Alphabetical(); @@ -9,12 +9,14 @@ final class Alphabetical extends ProposalsOrder { String toString() => 'Alphabetical'; } -/// Order base on [Proposal.fundsRequested]. +/// Orders proposals based on their [Proposal.fundsRequested]. /// -/// The [isAscending] parameter determines the direction of the sort: -/// - true: Lowest budget to highest budget. -/// - false: Highest budget to lowest budget. +/// The sorting direction can be specified as ascending or descending. final class Budget extends ProposalsOrder { + /// Determines the sorting direction. + /// + /// If `true`, proposals are sorted from the lowest budget to the highest. + /// If `false`, they are sorted from the highest budget to the lowest. final bool isAscending; const Budget({ @@ -28,10 +30,11 @@ final class Budget extends ProposalsOrder { String toString() => 'Budget(${isAscending ? 'asc' : 'desc'})'; } -/// A base sealed class representing different ways to order [Proposal]. +/// A base sealed class that defines different strategies for ordering a list of [Proposal] objects. /// -/// This allows us to define a fixed set of ordering types, -/// where each type can potentially hold its own specific data or logic. +/// Subclasses of [ProposalsOrder] represent specific ordering methods, +/// such as by title, budget, or update date. This sealed class ensures that only a +/// predefined set of ordering types can be created, providing type safety. sealed class ProposalsOrder extends Equatable { const ProposalsOrder(); @@ -39,18 +42,30 @@ sealed class ProposalsOrder extends Equatable { List get props => []; } -/// Orders base on [Proposal] version. +/// Orders proposals based on their last update date, which corresponds to the [Proposal] version. /// -/// The [isAscending] parameter determines the direction of the sort: -/// - true: Oldest to newest. -/// - false: Newest to oldest. +/// The sorting direction can be specified as ascending (oldest to newest) +/// or descending (newest to oldest). final class UpdateDate extends ProposalsOrder { + /// Determines the sorting direction. + /// + /// If `true`, proposals are sorted from oldest to newest. + /// If `false`, they are sorted from newest to oldest. final bool isAscending; + /// Creates an instance of [UpdateDate] order. + /// + /// The [isAscending] parameter is required to specify the sorting direction. const UpdateDate({ required this.isAscending, }); + /// Creates an instance that sorts proposals in ascending order (oldest to newest). + const UpdateDate.asc() : this(isAscending: true); + + /// Creates an instance that sorts proposals in descending order (newest to oldest). + const UpdateDate.desc() : this(isAscending: false); + @override List get props => [isAscending]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart new file mode 100644 index 000000000000..2f6e50daff20 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_total_ask_filters.dart @@ -0,0 +1,24 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// A class that encapsulates the filters used to calculate the total +/// ask for a set of proposals. +final class ProposalsTotalAskFilters extends Equatable { + /// The general campaign filters to apply. + final CampaignFilters? campaign; + + /// The specific category ID to filter by. + final String? categoryId; + + /// Creates an instance of [ProposalsTotalAskFilters]. + const ProposalsTotalAskFilters({ + this.campaign, + this.categoryId, + }); + + @override + List get props => [ + campaign, + categoryId, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart index f9282a75f2c2..5d045914952e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart @@ -9,13 +9,16 @@ import 'package:flutter/foundation.dart'; /// See: https://input-output-hk.github.io/catalyst-libs/architecture/08_concepts/rbac_id_uri/catalyst-id-uri/ final class CatalystId extends Equatable { /// The default scheme for the Catalyst ID. - static const String scheme = 'id.catalyst'; + static const String idScheme = 'id.catalyst'; /// [Uri.fragment] if the key is for encryption. /// /// If the fragment is not present in the uri then the key is for signing. static const String encryptFragment = 'encrypt'; + /// The scheme part of the URI. + final String scheme; + /// The host refers to the network type where the RBAC registration was made. /// /// It IS NOT resolvable with DNS, and IS NOT a public host name. @@ -49,6 +52,7 @@ final class CatalystId extends Equatable { /// The default constructor that builds [CatalystId] from [Uri] parts. const CatalystId({ + this.scheme = CatalystId.idScheme, required this.host, this.username, this.nonce, @@ -65,10 +69,12 @@ final class CatalystId extends Equatable { /// Parses the [CatalystId] from [Uri]. factory CatalystId.fromUri(Uri uri) { + final scheme = uri.scheme; final (username, nonce) = _parseUserInfo(uri.userInfo); final (role0Key, role, rotation) = _parsePath(uri.path); return CatalystId( + scheme: scheme, host: uri.host, username: username, nonce: nonce, @@ -79,6 +85,16 @@ final class CatalystId extends Equatable { ); } + /// A convenience factory for parsing a [CatalystId] from a string. + /// + /// This method is a wrapper around [CatalystId.fromUri] that first parses + /// the input [data] string into a [Uri] and then constructs the [CatalystId]. + /// + /// Throws a [FormatException] if the [data] string is not a valid URI. + factory CatalystId.parse(String data) { + return CatalystId.fromUri(Uri.parse(data)); + } + @override List get props => [ host, @@ -91,6 +107,7 @@ final class CatalystId extends Equatable { ]; CatalystId copyWith({ + String? scheme, String? host, Optional? username, Optional? nonce, @@ -100,6 +117,7 @@ final class CatalystId extends Equatable { bool? encrypt, }) { return CatalystId( + scheme: scheme ?? this.scheme, host: host ?? this.host, username: username.dataOr(this.username), nonce: nonce.dataOr(this.nonce), @@ -113,7 +131,7 @@ final class CatalystId extends Equatable { /// Objects which holds [CatalystId] can be uniquely identified only by /// comparing [role0Key] and [host] thus they're significant parts of /// [CatalystId]. - CatalystId toSignificant() => CatalystId(host: host, role0Key: role0Key); + CatalystId toSignificant() => CatalystId(scheme: scheme, host: host, role0Key: role0Key); @override String toString() => toUri().toString(); @@ -157,6 +175,19 @@ final class CatalystId extends Equatable { return userInfo.isNotEmpty ? userInfo : null; } + /// A convenience method that wraps [CatalystId.parse] in a `try-catch` + /// block. + /// + /// If [data] is a valid [CatalystId] string, it will be parsed and + /// a [CatalystId] instance will be returned. Otherwise, `null` is returned. + static CatalystId? tryParse(String data) { + try { + return CatalystId.parse(data); + } catch (_) { + return null; + } + } + /// Parses the data from [Uri.path]. /// /// Format: role0Key[/roleNumber][/rotation] diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_builder.dart index 456b386d2026..6c9dece0a3aa 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_builder.dart @@ -9,6 +9,9 @@ abstract interface class VotingBallotBuilder implements Listenable { /// Returns unmodifiable copy of votes made. List get votes; + /// Emits list of votes when changed + Stream> get watchVotes; + /// Adds [vote] to the list. void addVote(Vote vote); @@ -18,6 +21,9 @@ abstract interface class VotingBallotBuilder implements Listenable { /// Removes votes from this builder. void clear(); + /// Release resources. + void dispose(); + /// Returns [Vote] made on [proposal]. Vote? getVoteOn(DocumentRef proposal); diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_local_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_local_builder.dart index 03006e12c216..da91ffcee96b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_local_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/voting/voting_ballot_local_builder.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -6,6 +8,8 @@ final class VotingBallotLocalBuilder with ChangeNotifier implements VotingBallot /// A map of votes casted on ref. final Map _votes; + final _streamController = StreamController>.broadcast(); + factory VotingBallotLocalBuilder({ List votes = const [], }) { @@ -24,6 +28,12 @@ final class VotingBallotLocalBuilder with ChangeNotifier implements VotingBallot @override List get votes => List.unmodifiable(_votes.values); + @override + Stream> get watchVotes async* { + yield votes; + yield* _streamController.stream; + } + @override void addVote(Vote vote) { assert(!vote.isCasted, 'Can not add already casted vote!'); @@ -50,12 +60,24 @@ final class VotingBallotLocalBuilder with ChangeNotifier implements VotingBallot notifyListeners(); } + @override + void dispose() { + unawaited(_streamController.close()); + super.dispose(); + } + @override Vote? getVoteOn(DocumentRef proposal) => _votes[proposal]; @override bool hasVotedOn(DocumentRef proposal) => _votes.containsKey(proposal); + @override + void notifyListeners() { + _streamController.add(votes); + super.notifyListeners(); + } + @override Vote? removeVoteOn(DocumentRef proposal) { final vote = _votes.remove(proposal); diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart index ae5dfd96bb37..eb1d16930162 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/campaign/campaign_test.dart @@ -111,7 +111,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -156,7 +155,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -224,7 +222,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -285,7 +282,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -346,7 +342,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( @@ -421,7 +416,6 @@ void main() { name: 'Campaign 1', description: 'Description 1', allFunds: _multiCurrency(100), - totalAsk: _multiCurrency(0), fundNumber: 1, categories: const [], timeline: CampaignTimeline( diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/money/multi_currency_amount_test.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/money/multi_currency_amount_test.dart index 3a8a7d1022ff..aaafd69a146d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/test/money/multi_currency_amount_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/money/multi_currency_amount_test.dart @@ -30,20 +30,6 @@ void main() { expect(multi[ada.code], ada200); }); - test('adding money sums amounts for same currency', () { - final multi = MultiCurrencyAmount.single(usdm100)..add(usdm50); - final total = multi[usdm.code]!; - expect(total.minorUnits, usdm100.minorUnits + usdm50.minorUnits); - }); - - test('subtracting money decreases amount and removes zero amounts', () { - final multi = MultiCurrencyAmount.single(usdm100)..subtract(usdm50); - expect(multi[usdm.code]!.minorUnits, usdm50.minorUnits); - - multi.subtract(usdm50); - expect(multi[usdm.code], isNull); - }); - test('operator[] returns null for missing currency', () { final multi = MultiCurrencyAmount(); expect(multi[usdm.code], isNull); @@ -51,24 +37,28 @@ void main() { test('deepCopy produces independent copy', () { final multi = MultiCurrencyAmount.list([usdm100, ada200]); - final copy = multi.deepCopy() - ..add(Money.fromMajorUnits(currency: usdm, majorUnits: BigInt.from(10))); + final copy = MultiCurrencyAmount.list([ + ...multi.deepCopy().list, + Money.fromMajorUnits(currency: usdm, majorUnits: BigInt.from(10)), + ]); expect(copy[usdm.code]!.minorUnits, usdm100.minorUnits + BigInt.from(10 * 100)); expect(multi[usdm.code]!.minorUnits, usdm100.minorUnits); // original unchanged }); test('adding multiple currencies works independently', () { - final multi = MultiCurrencyAmount() - ..add(usdm100) - ..add(ada200); + final multi = MultiCurrencyAmount.list([usdm100, ada200]); expect(multi[usdm.code]!.minorUnits, usdm100.minorUnits); expect(multi[ada.code]!.minorUnits, ada200.minorUnits); }); test('zero amounts are automatically removed', () { - final multi = MultiCurrencyAmount.single(usdm50)..subtract(usdm50); + final negativeUsdm50 = Money.fromMajorUnits( + currency: usdm, + majorUnits: usdm50.majorUnits * BigInt.from(-1), + ); + final multi = MultiCurrencyAmount.list([usdm50, negativeUsdm50]); expect(multi.list, isEmpty); }); }); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/README.md b/catalyst_voices/packages/internal/catalyst_voices_repositories/README.md index 392025fcb276..5c2a56fcc27a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/README.md +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/README.md @@ -56,7 +56,7 @@ melos build-runner-repository Build migration test files with ```bash -melos db-make-migration +melos build-db-schema ``` #### Web diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/drift_schemas/catalyst_database/drift_schema_v3.json b/catalyst_voices/packages/internal/catalyst_voices_repositories/drift_schemas/catalyst_database/drift_schema_v3.json new file mode 100644 index 000000000000..6d62a43f96b9 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/drift_schemas/catalyst_database/drift_schema_v3.json @@ -0,0 +1,495 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.2.0" + }, + "options": { + "store_date_time_values_as_text": true + }, + "entities": [ + { + "id": 0, + "references": [ + ], + "type": "table", + "data": { + "name": "documents", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id_hi", + "getter_name": "idHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id_lo", + "getter_name": "idLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_hi", + "getter_name": "verHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_lo", + "getter_name": "verLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.content", + "dart_type_name": "DocumentDataContent" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.metadata", + "dart_type_name": "DocumentDataMetadata" + } + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id_hi", + "id_lo", + "ver_hi", + "ver_lo" + ] + } + }, + { + "id": 1, + "references": [ + ], + "type": "table", + "data": { + "name": "documents_metadata", + "was_declared_in_moor": false, + "columns": [ + { + "name": "ver_hi", + "getter_name": "verHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_lo", + "getter_name": "verLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "field_key", + "getter_name": "fieldKey", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "const EnumNameConverter(DocumentMetadataFieldKey.values)", + "dart_type_name": "DocumentMetadataFieldKey" + } + }, + { + "name": "field_value", + "getter_name": "fieldValue", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "ver_hi", + "ver_lo", + "field_key" + ] + } + }, + { + "id": 2, + "references": [ + ], + "type": "table", + "data": { + "name": "documents_favorites", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id_hi", + "getter_name": "idHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id_lo", + "getter_name": "idLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "is_favorite", + "getter_name": "isFavorite", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_favorite\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id_hi", + "id_lo" + ] + } + }, + { + "id": 3, + "references": [ + ], + "type": "table", + "data": { + "name": "drafts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id_hi", + "getter_name": "idHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id_lo", + "getter_name": "idLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_hi", + "getter_name": "verHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_lo", + "getter_name": "verLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.content", + "dart_type_name": "DocumentDataContent" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.metadata", + "dart_type_name": "DocumentDataMetadata" + } + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id_hi", + "id_lo", + "ver_hi", + "ver_lo" + ] + } + }, + { + "id": 4, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_doc_type", + "sql": null, + "unique": false, + "columns": [ + "type" + ] + } + }, + { + "id": 5, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_unique_ver", + "sql": null, + "unique": true, + "columns": [ + "ver_hi", + "ver_lo" + ] + } + }, + { + "id": 6, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_doc_metadata_key_value", + "sql": null, + "unique": false, + "columns": [ + "field_key", + "field_value" + ] + } + }, + { + "id": 7, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "idx_fav_type", + "sql": null, + "unique": false, + "columns": [ + "type" + ] + } + }, + { + "id": 8, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "idx_fav_unique_id", + "sql": null, + "unique": true, + "columns": [ + "id_hi", + "id_lo" + ] + } + }, + { + "id": 9, + "references": [ + 3 + ], + "type": "index", + "data": { + "on": 3, + "name": "idx_draft_type", + "sql": null, + "unique": false, + "columns": [ + "type" + ] + } + } + ] +} \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/drift_schemas/catalyst_database/drift_schema_v4.json b/catalyst_voices/packages/internal/catalyst_voices_repositories/drift_schemas/catalyst_database/drift_schema_v4.json new file mode 100644 index 000000000000..abe9ff5fb1cb --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/drift_schemas/catalyst_database/drift_schema_v4.json @@ -0,0 +1,1193 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.2.0" + }, + "options": { + "store_date_time_values_as_text": true + }, + "entities": [ + { + "id": 0, + "references": [ + ], + "type": "table", + "data": { + "name": "documents", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id_hi", + "getter_name": "idHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id_lo", + "getter_name": "idLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_hi", + "getter_name": "verHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_lo", + "getter_name": "verLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.content", + "dart_type_name": "DocumentDataContent" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.metadata", + "dart_type_name": "DocumentDataMetadata" + } + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id_hi", + "id_lo", + "ver_hi", + "ver_lo" + ] + } + }, + { + "id": 1, + "references": [ + ], + "type": "table", + "data": { + "name": "documents_metadata", + "was_declared_in_moor": false, + "columns": [ + { + "name": "ver_hi", + "getter_name": "verHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_lo", + "getter_name": "verLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "field_key", + "getter_name": "fieldKey", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "const EnumNameConverter(DocumentMetadataFieldKey.values)", + "dart_type_name": "DocumentMetadataFieldKey" + } + }, + { + "name": "field_value", + "getter_name": "fieldValue", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "ver_hi", + "ver_lo", + "field_key" + ] + } + }, + { + "id": 2, + "references": [ + ], + "type": "table", + "data": { + "name": "documents_favorites", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id_hi", + "getter_name": "idHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id_lo", + "getter_name": "idLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "is_favorite", + "getter_name": "isFavorite", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_favorite\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id_hi", + "id_lo" + ] + } + }, + { + "id": 3, + "references": [ + ], + "type": "table", + "data": { + "name": "drafts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id_hi", + "getter_name": "idHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id_lo", + "getter_name": "idLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_hi", + "getter_name": "verHi", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ver_lo", + "getter_name": "verLo", + "moor_type": "bigInt", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.content", + "dart_type_name": "DocumentDataContent" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.metadata", + "dart_type_name": "DocumentDataMetadata" + } + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id_hi", + "id_lo", + "ver_hi", + "ver_lo" + ] + } + }, + { + "id": 4, + "references": [ + ], + "type": "table", + "data": { + "name": "documents_v2", + "was_declared_in_moor": false, + "columns": [ + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.content", + "dart_type_name": "DocumentDataContent" + } + }, + { + "name": "authors", + "getter_name": "authors", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "category_id", + "getter_name": "categoryId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "category_ver", + "getter_name": "categoryVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ref_id", + "getter_name": "refId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ref_ver", + "getter_name": "refVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "reply_id", + "getter_name": "replyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "reply_ver", + "getter_name": "replyVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "section", + "getter_name": "section", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "template_id", + "getter_name": "templateId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "template_ver", + "getter_name": "templateVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + }, + { + "name": "ver", + "getter_name": "ver", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id", + "ver" + ] + } + }, + { + "id": 5, + "references": [ + ], + "type": "table", + "data": { + "name": "document_authors", + "was_declared_in_moor": false, + "columns": [ + { + "name": "author_id", + "getter_name": "authorId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "author_id_significant", + "getter_name": "authorIdSignificant", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "author_username", + "getter_name": "authorUsername", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "document_id", + "getter_name": "documentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "document_ver", + "getter_name": "documentVer", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "FOREIGN KEY (document_id, document_ver) REFERENCES documents_v2(id, ver) ON DELETE CASCADE" + ], + "explicit_pk": [ + "document_id", + "document_ver", + "author_id" + ] + } + }, + { + "id": 6, + "references": [ + ], + "type": "table", + "data": { + "name": "documents_local_metadata", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "is_favorite", + "getter_name": "isFavorite", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_favorite\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_favorite\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [ + ], + "type": "table", + "data": { + "name": "local_documents_drafts", + "was_declared_in_moor": false, + "columns": [ + { + "name": "content", + "getter_name": "content", + "moor_type": "blob", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.content", + "dart_type_name": "DocumentDataContent" + } + }, + { + "name": "authors", + "getter_name": "authors", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "category_id", + "getter_name": "categoryId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "category_ver", + "getter_name": "categoryVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ref_id", + "getter_name": "refId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "ref_ver", + "getter_name": "refVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "reply_id", + "getter_name": "replyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "reply_ver", + "getter_name": "replyVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "section", + "getter_name": "section", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "template_id", + "getter_name": "templateId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "template_ver", + "getter_name": "templateVer", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ], + "type_converter": { + "dart_expr": "DocumentConverters.type", + "dart_type_name": "DocumentType" + } + }, + { + "name": "ver", + "getter_name": "ver", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + ] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + ], + "explicit_pk": [ + "id", + "ver" + ] + } + }, + { + "id": 8, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_doc_type", + "sql": null, + "unique": false, + "columns": [ + "type" + ] + } + }, + { + "id": 9, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_unique_ver", + "sql": null, + "unique": true, + "columns": [ + "ver_hi", + "ver_lo" + ] + } + }, + { + "id": 10, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_doc_metadata_key_value", + "sql": null, + "unique": false, + "columns": [ + "field_key", + "field_value" + ] + } + }, + { + "id": 11, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "idx_fav_type", + "sql": null, + "unique": false, + "columns": [ + "type" + ] + } + }, + { + "id": 12, + "references": [ + 2 + ], + "type": "index", + "data": { + "on": 2, + "name": "idx_fav_unique_id", + "sql": null, + "unique": true, + "columns": [ + "id_hi", + "id_lo" + ] + } + }, + { + "id": 13, + "references": [ + 3 + ], + "type": "index", + "data": { + "on": 3, + "name": "idx_draft_type", + "sql": null, + "unique": false, + "columns": [ + "type" + ] + } + }, + { + "id": 14, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_id", + "sql": null, + "unique": false, + "columns": [ + "type", + "id" + ] + } + }, + { + "id": 15, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_id_ver", + "sql": null, + "unique": false, + "columns": [ + "type", + "id", + "ver" + ] + } + }, + { + "id": 16, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_ref_id", + "sql": null, + "unique": false, + "columns": [ + "type", + "ref_id" + ] + } + }, + { + "id": 17, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_ref_id_ver", + "sql": null, + "unique": false, + "columns": [ + "type", + "ref_id", + "ver" + ] + } + }, + { + "id": 18, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_ref_id_ver", + "sql": null, + "unique": false, + "columns": [ + "ref_id", + "ver" + ] + } + }, + { + "id": 19, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_id_created_at", + "sql": null, + "unique": false, + "columns": [ + "type", + "id", + "created_at" + ] + } + }, + { + "id": 20, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_category_id", + "sql": null, + "unique": false, + "columns": [ + "type", + "category_id" + ] + } + }, + { + "id": 21, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_documents_v2_type_ref_id_ref_ver", + "sql": null, + "unique": false, + "columns": [ + "type", + "ref_id", + "ref_ver" + ] + } + }, + { + "id": 22, + "references": [ + 5 + ], + "type": "index", + "data": { + "on": 5, + "name": "idx_document_authors_composite", + "sql": null, + "unique": false, + "columns": [ + "document_id", + "document_ver", + "author_id_significant" + ] + } + }, + { + "id": 23, + "references": [ + 5 + ], + "type": "index", + "data": { + "on": 5, + "name": "idx_document_authors_identity", + "sql": null, + "unique": false, + "columns": [ + "author_id_significant" + ] + } + }, + { + "id": 24, + "references": [ + 5 + ], + "type": "index", + "data": { + "on": 5, + "name": "idx_document_authors_username", + "sql": null, + "unique": false, + "columns": [ + "author_username" + ] + } + } + ] +} \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart index 7c687810da8c..9e60560a2b43 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart @@ -26,7 +26,7 @@ String _testAccountAuthorGetter(DocumentRef ref) { } String _v7() { - final config = u.V7Options(_time--, null); + final config = u.V7Options(_time -= 2000, null); return const u.Uuid().v7(config: config); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart index 540d26d46fa2..81ee49848a12 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/campaign/campaign_repository.dart @@ -1,19 +1,32 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/document/source/proposal_document_data_local_source.dart'; import 'package:collection/collection.dart'; /// Allows access to campaign data, categories, and timeline. abstract interface class CampaignRepository { - const factory CampaignRepository() = CampaignRepositoryImpl; + const factory CampaignRepository(ProposalDocumentDataLocalSource source) = CampaignRepositoryImpl; Future getCampaign({ required String id, }); Future getCategory(SignedDocumentRef ref); + + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); } final class CampaignRepositoryImpl implements CampaignRepository { - const CampaignRepositoryImpl(); + final ProposalDocumentDataLocalSource _source; + + const CampaignRepositoryImpl(this._source); @override Future getCampaign({ @@ -34,4 +47,20 @@ final class CampaignRepositoryImpl implements CampaignRepository { .expand((element) => element.categories) .firstWhereOrNull((e) => e.selfRef.id == ref.id); } + + @override + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _source.getProposalsTotalTask(nodeId: nodeId, filters: filters); + } + + @override + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _source.watchProposalsTotalTask(nodeId: nodeId, filters: filters); + } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart index 451e78b2a294..63ff2104eaf5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.dart @@ -1,16 +1,22 @@ import 'package:catalyst_voices_repositories/src/database/catalyst_database.drift.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database_config.dart'; import 'package:catalyst_voices_repositories/src/database/dao/documents_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/drafts_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/favorites_dao.dart'; import 'package:catalyst_voices_repositories/src/database/dao/proposals_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.dart'; import 'package:catalyst_voices_repositories/src/database/migration/drift_migration_strategy.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents.drift.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_favorite.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; import 'package:catalyst_voices_repositories/src/database/table/documents_metadata.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.dart'; import 'package:catalyst_voices_repositories/src/database/table/drafts.dart'; import 'package:catalyst_voices_repositories/src/database/table/drafts.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.dart'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter/foundation.dart'; @@ -32,6 +38,8 @@ abstract interface class CatalystDatabase { /// Do not confuse it with other documents. DocumentsDao get documentsDao; + DocumentsV2Dao get documentsV2Dao; + /// Contains all operations related to [DocumentDraftEntity] which is db /// specific. Do not confuse it with other documents / drafts. DraftsDao get draftsDao; @@ -48,6 +56,10 @@ abstract interface class CatalystDatabase { /// Specialized version of [DocumentsDao]. ProposalsDao get proposalsDao; + ProposalsV2Dao get proposalsV2Dao; + + Future analyze(); + /// Removes all data from this db. Future clear(); @@ -63,12 +75,18 @@ abstract interface class CatalystDatabase { DocumentsMetadata, DocumentsFavorites, Drafts, + DocumentsV2, + DocumentAuthors, + DocumentsLocalMetadata, + LocalDocumentsDrafts, ], daos: [ DriftDocumentsDao, DriftFavoritesDao, DriftDraftsDao, DriftProposalsDao, + DriftDocumentsV2Dao, + DriftProposalsV2Dao, ], queries: {}, views: [], @@ -106,6 +124,9 @@ class DriftCatalystDatabase extends $DriftCatalystDatabase implements CatalystDa @override DocumentsDao get documentsDao => driftDocumentsDao; + @override + DocumentsV2Dao get documentsV2Dao => driftDocumentsV2Dao; + @override DraftsDao get draftsDao => driftDraftsDao; @@ -130,7 +151,15 @@ class DriftCatalystDatabase extends $DriftCatalystDatabase implements CatalystDa ProposalsDao get proposalsDao => driftProposalsDao; @override - int get schemaVersion => 3; + ProposalsV2Dao get proposalsV2Dao => driftProposalsV2Dao; + + @override + int get schemaVersion => 4; + + @override + Future analyze() async { + await customStatement('ANALYZE'); + } @override Future clear() { diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.steps.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.steps.dart new file mode 100644 index 000000000000..a41ba1f3bbd3 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/catalyst_database.steps.dart @@ -0,0 +1,601 @@ +// dart format width=80 +import 'package:drift/internal/versioned_schema.dart' as i0; +import 'package:drift/drift.dart' as i1; +import 'dart:typed_data' as i2; +import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import + +// GENERATED BY drift_dev, DO NOT MODIFY. +final class Schema4 extends i0.VersionedSchema { + Schema4({required super.database}) : super(version: 4); + @override + late final List entities = [ + documents, + documentsMetadata, + documentsFavorites, + drafts, + documentsV2, + documentAuthors, + documentsLocalMetadata, + localDocumentsDrafts, + idxDocType, + idxUniqueVer, + idxDocMetadataKeyValue, + idxFavType, + idxFavUniqueId, + idxDraftType, + idxDocumentsV2TypeId, + idxDocumentsV2TypeIdVer, + idxDocumentsV2TypeRefId, + idxDocumentsV2TypeRefIdVer, + idxDocumentsV2RefIdVer, + idxDocumentsV2TypeIdCreatedAt, + idxDocumentsV2TypeCategoryId, + idxDocumentsV2TypeRefIdRefVer, + idxDocumentAuthorsComposite, + idxDocumentAuthorsIdentity, + idxDocumentAuthorsUsername, + ]; + late final Shape0 documents = Shape0( + source: i0.VersionedTable( + entityName: 'documents', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(id_hi, id_lo, ver_hi, ver_lo)'], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape1 documentsMetadata = Shape1( + source: i0.VersionedTable( + entityName: 'documents_metadata', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(ver_hi, ver_lo, field_key)'], + columns: [_column_2, _column_3, _column_8, _column_9], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 documentsFavorites = Shape2( + source: i0.VersionedTable( + entityName: 'documents_favorites', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(id_hi, id_lo)'], + columns: [_column_0, _column_1, _column_10, _column_6], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 drafts = Shape3( + source: i0.VersionedTable( + entityName: 'drafts', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(id_hi, id_lo, ver_hi, ver_lo)'], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_11, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 documentsV2 = Shape4( + source: i0.VersionedTable( + entityName: 'documents_v2', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(id, ver)'], + columns: [ + _column_4, + _column_12, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_22, + _column_6, + _column_23, + _column_7, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 documentAuthors = Shape5( + source: i0.VersionedTable( + entityName: 'document_authors', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'PRIMARY KEY(document_id, document_ver, author_id)', + 'FOREIGN KEY (document_id, document_ver) REFERENCES documents_v2(id, ver) ON DELETE CASCADE', + ], + columns: [_column_24, _column_25, _column_26, _column_27, _column_28], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 documentsLocalMetadata = Shape6( + source: i0.VersionedTable( + entityName: 'documents_local_metadata', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_15, _column_10], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 localDocumentsDrafts = Shape4( + source: i0.VersionedTable( + entityName: 'local_documents_drafts', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(id, ver)'], + columns: [ + _column_4, + _column_12, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_22, + _column_6, + _column_23, + _column_7, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxDocType = i1.Index( + 'idx_doc_type', + 'CREATE INDEX idx_doc_type ON documents (type)', + ); + final i1.Index idxUniqueVer = i1.Index( + 'idx_unique_ver', + 'CREATE UNIQUE INDEX idx_unique_ver ON documents (ver_hi, ver_lo)', + ); + final i1.Index idxDocMetadataKeyValue = i1.Index( + 'idx_doc_metadata_key_value', + 'CREATE INDEX idx_doc_metadata_key_value ON documents_metadata (field_key, field_value)', + ); + final i1.Index idxFavType = i1.Index( + 'idx_fav_type', + 'CREATE INDEX idx_fav_type ON documents_favorites (type)', + ); + final i1.Index idxFavUniqueId = i1.Index( + 'idx_fav_unique_id', + 'CREATE UNIQUE INDEX idx_fav_unique_id ON documents_favorites (id_hi, id_lo)', + ); + final i1.Index idxDraftType = i1.Index( + 'idx_draft_type', + 'CREATE INDEX idx_draft_type ON drafts (type)', + ); + final i1.Index idxDocumentsV2TypeId = i1.Index( + 'idx_documents_v2_type_id', + 'CREATE INDEX idx_documents_v2_type_id ON documents_v2 (type, id)', + ); + final i1.Index idxDocumentsV2TypeIdVer = i1.Index( + 'idx_documents_v2_type_id_ver', + 'CREATE INDEX idx_documents_v2_type_id_ver ON documents_v2 (type, id, ver)', + ); + final i1.Index idxDocumentsV2TypeRefId = i1.Index( + 'idx_documents_v2_type_ref_id', + 'CREATE INDEX idx_documents_v2_type_ref_id ON documents_v2 (type, ref_id)', + ); + final i1.Index idxDocumentsV2TypeRefIdVer = i1.Index( + 'idx_documents_v2_type_ref_id_ver', + 'CREATE INDEX idx_documents_v2_type_ref_id_ver ON documents_v2 (type, ref_id, ver)', + ); + final i1.Index idxDocumentsV2RefIdVer = i1.Index( + 'idx_documents_v2_ref_id_ver', + 'CREATE INDEX idx_documents_v2_ref_id_ver ON documents_v2 (ref_id, ver)', + ); + final i1.Index idxDocumentsV2TypeIdCreatedAt = i1.Index( + 'idx_documents_v2_type_id_created_at', + 'CREATE INDEX idx_documents_v2_type_id_created_at ON documents_v2 (type, id, created_at)', + ); + final i1.Index idxDocumentsV2TypeCategoryId = i1.Index( + 'idx_documents_v2_type_category_id', + 'CREATE INDEX idx_documents_v2_type_category_id ON documents_v2 (type, category_id)', + ); + final i1.Index idxDocumentsV2TypeRefIdRefVer = i1.Index( + 'idx_documents_v2_type_ref_id_ref_ver', + 'CREATE INDEX idx_documents_v2_type_ref_id_ref_ver ON documents_v2 (type, ref_id, ref_ver)', + ); + final i1.Index idxDocumentAuthorsComposite = i1.Index( + 'idx_document_authors_composite', + 'CREATE INDEX idx_document_authors_composite ON document_authors (document_id, document_ver, author_id_significant)', + ); + final i1.Index idxDocumentAuthorsIdentity = i1.Index( + 'idx_document_authors_identity', + 'CREATE INDEX idx_document_authors_identity ON document_authors (author_id_significant)', + ); + final i1.Index idxDocumentAuthorsUsername = i1.Index( + 'idx_document_authors_username', + 'CREATE INDEX idx_document_authors_username ON document_authors (author_username)', + ); +} + +class Shape0 extends i0.VersionedTable { + Shape0({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get idHi => + columnsByName['id_hi']! as i1.GeneratedColumn; + i1.GeneratedColumn get idLo => + columnsByName['id_lo']! as i1.GeneratedColumn; + i1.GeneratedColumn get verHi => + columnsByName['ver_hi']! as i1.GeneratedColumn; + i1.GeneratedColumn get verLo => + columnsByName['ver_lo']! as i1.GeneratedColumn; + i1.GeneratedColumn get content => + columnsByName['content']! as i1.GeneratedColumn; + i1.GeneratedColumn get metadata => + columnsByName['metadata']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn( + 'id_hi', + aliasedName, + false, + type: i1.DriftSqlType.bigInt, + ); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn( + 'id_lo', + aliasedName, + false, + type: i1.DriftSqlType.bigInt, + ); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn( + 'ver_hi', + aliasedName, + false, + type: i1.DriftSqlType.bigInt, + ); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn( + 'ver_lo', + aliasedName, + false, + type: i1.DriftSqlType.bigInt, + ); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn( + 'content', + aliasedName, + false, + type: i1.DriftSqlType.blob, + ); +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn( + 'metadata', + aliasedName, + false, + type: i1.DriftSqlType.blob, + ); +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn( + 'type', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i1.DriftSqlType.dateTime, + ); + +class Shape1 extends i0.VersionedTable { + Shape1({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get verHi => + columnsByName['ver_hi']! as i1.GeneratedColumn; + i1.GeneratedColumn get verLo => + columnsByName['ver_lo']! as i1.GeneratedColumn; + i1.GeneratedColumn get fieldKey => + columnsByName['field_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get fieldValue => + columnsByName['field_value']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_8(String aliasedName) => + i1.GeneratedColumn( + 'field_key', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_9(String aliasedName) => + i1.GeneratedColumn( + 'field_value', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); + +class Shape2 extends i0.VersionedTable { + Shape2({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get idHi => + columnsByName['id_hi']! as i1.GeneratedColumn; + i1.GeneratedColumn get idLo => + columnsByName['id_lo']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_10(String aliasedName) => + i1.GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + ); + +class Shape3 extends i0.VersionedTable { + Shape3({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get idHi => + columnsByName['id_hi']! as i1.GeneratedColumn; + i1.GeneratedColumn get idLo => + columnsByName['id_lo']! as i1.GeneratedColumn; + i1.GeneratedColumn get verHi => + columnsByName['ver_hi']! as i1.GeneratedColumn; + i1.GeneratedColumn get verLo => + columnsByName['ver_lo']! as i1.GeneratedColumn; + i1.GeneratedColumn get content => + columnsByName['content']! as i1.GeneratedColumn; + i1.GeneratedColumn get metadata => + columnsByName['metadata']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_11(String aliasedName) => + i1.GeneratedColumn( + 'title', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get content => + columnsByName['content']! as i1.GeneratedColumn; + i1.GeneratedColumn get authors => + columnsByName['authors']! as i1.GeneratedColumn; + i1.GeneratedColumn get categoryId => + columnsByName['category_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get categoryVer => + columnsByName['category_ver']! as i1.GeneratedColumn; + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get refId => + columnsByName['ref_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get refVer => + columnsByName['ref_ver']! as i1.GeneratedColumn; + i1.GeneratedColumn get replyId => + columnsByName['reply_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get replyVer => + columnsByName['reply_ver']! as i1.GeneratedColumn; + i1.GeneratedColumn get section => + columnsByName['section']! as i1.GeneratedColumn; + i1.GeneratedColumn get templateId => + columnsByName['template_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get templateVer => + columnsByName['template_ver']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get ver => + columnsByName['ver']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_12(String aliasedName) => + i1.GeneratedColumn( + 'authors', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn( + 'category_id', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn( + 'category_ver', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn( + 'id', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_16(String aliasedName) => + i1.GeneratedColumn( + 'ref_id', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_17(String aliasedName) => + i1.GeneratedColumn( + 'ref_ver', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_18(String aliasedName) => + i1.GeneratedColumn( + 'reply_id', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_19(String aliasedName) => + i1.GeneratedColumn( + 'reply_ver', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_20(String aliasedName) => + i1.GeneratedColumn( + 'section', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_21(String aliasedName) => + i1.GeneratedColumn( + 'template_id', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_22(String aliasedName) => + i1.GeneratedColumn( + 'template_ver', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_23(String aliasedName) => + i1.GeneratedColumn( + 'ver', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); + +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get authorId => + columnsByName['author_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get authorIdSignificant => + columnsByName['author_id_significant']! as i1.GeneratedColumn; + i1.GeneratedColumn get authorUsername => + columnsByName['author_username']! as i1.GeneratedColumn; + i1.GeneratedColumn get documentId => + columnsByName['document_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get documentVer => + columnsByName['document_ver']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_24(String aliasedName) => + i1.GeneratedColumn( + 'author_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_25(String aliasedName) => + i1.GeneratedColumn( + 'author_id_significant', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_26(String aliasedName) => + i1.GeneratedColumn( + 'author_username', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_27(String aliasedName) => + i1.GeneratedColumn( + 'document_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); +i1.GeneratedColumn _column_28(String aliasedName) => + i1.GeneratedColumn( + 'document_ver', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); + +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; +} + +i0.MigrationStepWithVersion migrationSteps({ + required Future Function(i1.Migrator m, Schema4 schema) from3To4, +}) { + return (currentVersion, database) async { + switch (currentVersion) { + case 3: + final schema = Schema4(database: database); + final migrator = i1.Migrator(database, schema); + await from3To4(migrator, schema); + return 4; + default: + throw ArgumentError.value('Unknown migration from $currentVersion'); + } + }; +} + +i1.OnUpgrade stepByStep({ + required Future Function(i1.Migrator m, Schema4 schema) from3To4, +}) => i0.VersionedSchema.stepByStepHelper( + step: migrationSteps(from3To4: from3To4), +); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart new file mode 100644 index 000000000000..bac519b03dc8 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/documents_v2_dao.dart @@ -0,0 +1,237 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; + +abstract interface class DocumentsV2Dao { + /// Returns the total number of documents in the table. + Future count(); + + /// Checks if a document exists by its reference. + /// + /// If [ref] is exact (has version), checks for the specific version. + /// If loose (no version), checks if any version with the id exists. + /// Returns true if the document exists, false otherwise. + Future exists(DocumentRef ref); + + /// Filters and returns only the DocumentRefs from [refs] that exist in the database. + /// + /// Optimized for performance: Uses a single query to fetch all relevant (id, ver) pairs + /// for unique ids in [refs], then checks existence in memory. + /// - For exact refs: Matches specific id and ver. + /// - For loose refs: Checks if any version for the id exists. + /// Suitable for synchronizing many documents with minimal database round-trips. + Future> filterExisting(List refs); + + /// Retrieves a document by its reference. + /// + /// If [ref] is exact (has version), returns the specific version. + /// If loose (no version), returns the latest version by createdAt. + /// Returns null if no matching document is found. + Future getDocument(DocumentRef ref); + + /// Finds the latest version of a document. + /// + /// Takes a [ref] (which can be loose or exact) and returns a [DocumentRef] + /// pointing to the latest known version of that document. + Future getLatestOf(DocumentRef ref); + + /// Saves a single document, ignoring if it conflicts on {id, ver}. + /// + /// Delegates to [saveAll] for consistent conflict handling and reuse. + Future save(DocumentWithAuthorsEntity entity); + + /// Saves multiple documents in a batch operation, ignoring conflicts. + /// + /// [entries] is a list of DocumentEntity instances. + /// Uses insertOrIgnore to skip on primary key conflicts ({id, ver}). + Future saveAll(List entries); + + /// Watches for a list of documents that match the given criteria. + /// + /// This method returns a stream that emits a new list of documents whenever + /// the underlying data changes. + /// - [type]: Optional filter to only include documents of a specific [DocumentType]. + /// - [filters]: Optional campaign filter. + /// - [latestOnly] is true only newest version per id is returned. + /// - [limit]: The maximum number of documents to return. + /// - [offset]: The number of documents to skip for pagination. + Stream> watchDocuments({ + DocumentType? type, + CampaignFilters? filters, + bool latestOnly, + int limit, + int offset, + }); +} + +@DriftAccessor( + tables: [ + DocumentsV2, + DocumentAuthors, + ], +) +class DriftDocumentsV2Dao extends DatabaseAccessor + with $DriftDocumentsV2DaoMixin + implements DocumentsV2Dao { + DriftDocumentsV2Dao(super.attachedDatabase); + + @override + Future count() { + return documentsV2.count().getSingleOrNull().then((value) => value ?? 0); + } + + @override + Future exists(DocumentRef ref) { + final query = selectOnly(documentsV2) + ..addColumns([const Constant(1)]) + ..where(documentsV2.id.equals(ref.id)); + + if (ref.isExact) { + query.where(documentsV2.ver.equals(ref.version!)); + } + + query.limit(1); + + return query.getSingleOrNull().then((result) => result != null); + } + + @override + Future> filterExisting(List refs) async { + if (refs.isEmpty) return []; + + final uniqueIds = refs.map((ref) => ref.id).toSet(); + + // Single query: Fetch all (id, ver) for matching ids + final query = selectOnly(documentsV2) + ..addColumns([documentsV2.id, documentsV2.ver]) + ..where(documentsV2.id.isIn(uniqueIds)); + + final rows = await query.map( + (row) { + final id = row.read(documentsV2.id)!; + final ver = row.read(documentsV2.ver)!; + return (id: id, ver: ver); + }, + ).get(); + + final idToVers = >{}; + for (final pair in rows) { + idToVers.update( + pair.id, + (value) => value..add(pair.ver), + ifAbsent: () => {pair.ver}, + ); + } + + return refs.where((ref) { + final vers = idToVers[ref.id]; + if (vers == null || vers.isEmpty) return false; + + return !ref.isExact || vers.contains(ref.version); + }).toList(); + } + + @override + Future getDocument(DocumentRef ref) { + final query = select(documentsV2)..where((tbl) => tbl.id.equals(ref.id)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } else { + query + ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) + ..limit(1); + } + + return query.getSingleOrNull(); + } + + @override + Future getLatestOf(DocumentRef ref) { + final query = selectOnly(documentsV2) + ..addColumns([documentsV2.id, documentsV2.ver]) + ..where(documentsV2.id.equals(ref.id)) + ..orderBy([OrderingTerm.desc(documentsV2.createdAt)]) + ..limit(1); + + return query + .map( + (row) => SignedDocumentRef.exact( + id: row.read(documentsV2.id)!, + version: row.read(documentsV2.ver)!, + ), + ) + .getSingleOrNull(); + } + + @override + Future save(DocumentWithAuthorsEntity entity) => saveAll([entity]); + + @override + Future saveAll(List entries) async { + if (entries.isEmpty) return; + + final docs = entries.map((e) => e.doc); + final authors = entries.map((e) => e.authors).flattened; + + await batch((batch) { + batch.insertAll( + documentsV2, + docs, + mode: InsertMode.insertOrIgnore, + ); + + if (authors.isNotEmpty) { + batch.insertAll( + documentAuthors, + authors, + mode: InsertMode.insertOrIgnore, + ); + } + }); + } + + @override + Stream> watchDocuments({ + DocumentType? type, + CampaignFilters? filters, + bool latestOnly = false, + int limit = 200, + int offset = 0, + }) { + final effectiveLimit = limit.clamp(0, 999); + + final query = select(documentsV2); + + if (filters != null) { + query.where((tbl) => tbl.categoryId.isIn(filters.categoriesIds)); + } + + if (type != null) { + query.where((tbl) => tbl.type.equalsValue(type)); + } + + if (latestOnly) { + final inner = alias(documentsV2, 'inner'); + + query.where((tbl) { + final maxCreatedAt = subqueryExpression( + selectOnly(inner) + ..addColumns([inner.createdAt.max()]) + ..where(inner.id.equalsExp(tbl.id)), + ); + return tbl.createdAt.equalsExp(maxCreatedAt); + }); + } + + query.limit(effectiveLimit, offset: offset); + + return query.watch(); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart new file mode 100644 index 000000000000..5da89ee5609f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart @@ -0,0 +1,946 @@ +import 'dart:math' as math; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/model/joined_proposal_brief_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; +import 'package:drift/drift.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Data Access Object for Proposal-specific queries. +/// +/// Handles complex queries for retrieving proposals with proper status handling +/// based on proposal actions (draft/final/hide). +/// +/// **Status Resolution Logic:** +/// - Draft (default): No action exists, or latest action is 'draft' +/// - Final: Latest action is 'final' with optional ref_ver pointing to specific version +/// - Hide: Latest action is 'hide' - excludes all versions of the proposal +/// +/// **Version Selection:** +/// - For draft: Returns latest version by createdAt +/// - For final: Returns version specified in action's ref_ver, or latest if ref_ver is null/empty +/// - For hide: Returns nothing (filtered out) +/// +/// **Performance Characteristics:** +/// - Uses composite indices for efficient GROUP BY and JOIN operations +/// - Single-query CTE approach (no N+1 queries) +@DriftAccessor( + tables: [ + DocumentsV2, + DocumentAuthors, + DocumentsLocalMetadata, + ], +) +class DriftProposalsV2Dao extends DatabaseAccessor + with $DriftProposalsV2DaoMixin + implements ProposalsV2Dao { + DriftProposalsV2Dao(super.attachedDatabase); + + @override + Future getProposal(DocumentRef ref) async { + final query = select(documentsV2) + ..where((tbl) => tbl.id.equals(ref.id) & tbl.type.equals(DocumentType.proposalDocument.uuid)); + + if (ref.isExact) { + query.where((tbl) => tbl.ver.equals(ref.version!)); + } else { + query + ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) + ..limit(1); + } + + return query.getSingleOrNull(); + } + + /// Retrieves a paginated list of proposal briefs with filtering, ordering, and status handling. + /// + /// **Query Logic:** + /// 1. Finds latest version of each proposal using MAX(ver) GROUP BY id + /// 2. Finds latest action for each proposal using MAX(ver) GROUP BY ref_id + /// 3. Determines effective version based on action type: + /// - Hide action: Excludes all versions of that proposal + /// - Final action with ref_ver: Uses specific version pointed to by action + /// - Final action without ref_ver OR Draft action OR no action: Uses latest version + /// 4. Joins with templates, comments count, and favorite status + /// 5. Applies all filters and ordering + /// 6. Returns paginated results + /// + /// **Indices Used:** + /// - idx_documents_v2_type_id: For latest_proposals CTE (GROUP BY optimization) + /// - idx_documents_v2_type_ref_id: For latest_actions CTE (GROUP BY optimization) + /// - idx_documents_v2_type_ref_id_ver: For action_status JOIN + /// - idx_documents_v2_type_id_ver: For final document retrieval + /// + /// **Performance:** + /// - Single query with CTEs (no N+1 queries) + /// + /// **Parameters:** + /// - [request]: Pagination parameters (page number and size) + /// - [order]: Sort order for results (default: UpdateDate.desc()) + /// - [filters]: Optional filters. + /// + /// **Returns:** Page object containing items, total count, and pagination metadata + @override + Future> getProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) async { + final effectivePage = math.max(request.page, 0); + final effectiveSize = request.size.clamp(0, 999); + + final shouldReturn = _shouldReturnEarlyFor(filters: filters, size: effectiveSize); + if (shouldReturn) { + return Page.empty(page: effectivePage, maxPerPage: effectiveSize); + } + + final items = await _queryVisibleProposalsPage( + effectivePage, + effectiveSize, + order: order, + filters: filters, + ).get(); + final total = await _countVisibleProposals(filters: filters).getSingle(); + + return Page( + items: items, + total: total, + page: effectivePage, + maxPerPage: effectiveSize, + ); + } + + @override + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) async { + if (_totalAskShouldReturnEarlyFor(filters: filters)) { + return const ProposalsTotalAsk({}); + } + + return _queryProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ).get().then(Map.fromEntries).then(ProposalsTotalAsk.new); + } + + @override + Future getVisibleProposalsCount({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final shouldReturn = _shouldReturnEarlyFor(filters: filters); + if (shouldReturn) { + return Future.value(0); + } + + return _countVisibleProposals(filters: filters).getSingle(); + } + + @override + Future updateProposalFavorite({ + required String id, + required bool isFavorite, + }) async { + await transaction( + () async { + if (!isFavorite) { + await (delete(documentsLocalMetadata)..where((tbl) => tbl.id.equals(id))).go(); + return; + } + + final entity = DocumentLocalMetadataEntity(id: id, isFavorite: isFavorite); + + await into(documentsLocalMetadata).insert(entity); + }, + ); + } + + @override + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final effectivePage = math.max(request.page, 0); + final effectiveSize = request.size.clamp(0, 999); + + final shouldReturn = _shouldReturnEarlyFor(filters: filters, size: effectiveSize); + if (shouldReturn) { + return Stream.value(Page.empty(page: effectivePage, maxPerPage: effectiveSize)); + } + + final itemsStream = _queryVisibleProposalsPage( + effectivePage, + effectiveSize, + order: order, + filters: filters, + ).watch(); + final totalStream = _countVisibleProposals(filters: filters).watchSingle(); + + return Rx.combineLatest2, int, Page>( + itemsStream, + totalStream, + (items, total) => Page( + items: items, + total: total, + page: effectivePage, + maxPerPage: effectiveSize, + ), + ); + } + + @override + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + if (_totalAskShouldReturnEarlyFor(filters: filters)) { + return Stream.value(const ProposalsTotalAsk({})); + } + + return _queryProposalsTotalTask( + nodeId: nodeId, + filters: filters, + ).watch().map(Map.fromEntries).map(ProposalsTotalAsk.new); + } + + @override + Stream watchVisibleProposalsCount({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final shouldReturn = _shouldReturnEarlyFor(filters: filters); + if (shouldReturn) { + return Stream.value(0); + } + + return _countVisibleProposals(filters: filters).watchSingle(); + } + + /// Builds SQL WHERE clauses from the provided filters. + /// + /// Translates high-level filter objects into SQL conditions that can be + /// injected into the main query. + /// + /// **Security:** + /// - Uses _escapeForSqlLike for LIKE patterns + /// - Uses _escapeSqlString for direct string comparisons + /// - Protects against SQL injection through proper escaping + /// + /// **Returns:** List of SQL WHERE clause strings (without leading WHERE/AND) + List _buildFilterClauses(ProposalsFiltersV2 filters) { + final clauses = []; + + if (filters.status != null) { + if (filters.status == ProposalStatusFilter.draft) { + // NULL = no action = draft (default), or explicit 'draft' action + clauses.add("(ep.action_type IS NULL OR ep.action_type = 'draft')"); + } else { + // Final requires explicit 'final' action + clauses.add("ep.action_type = 'final'"); + } + } + + if (filters.isFavorite != null) { + clauses.add( + filters.isFavorite! + ? 'dlm.is_favorite = 1' + : '(dlm.is_favorite IS NULL OR dlm.is_favorite = 0)', + ); + } + + if (filters.author != null) { + final significant = filters.author!.toSignificant(); + final escapedSignificant = _escapeSqlString(significant.toString()); + + clauses.add(''' + EXISTS ( + SELECT 1 FROM document_authors da + WHERE da.document_id = p.id + AND da.document_ver = p.ver + AND da.author_id_significant = '$escapedSignificant' + ) + '''); + } + + if (filters.categoryId != null) { + final escapedCategory = _escapeSqlString(filters.categoryId!); + clauses.add("p.category_id = '$escapedCategory'"); + } else if (filters.campaign != null) { + final escapedIds = filters.campaign!.categoriesIds + .map((id) => "'${_escapeSqlString(id)}'") + .join(', '); + clauses.add('p.category_id IN ($escapedIds)'); + } + + if (filters.searchQuery != null && filters.searchQuery!.isNotEmpty) { + final escapedQuery = _escapeForSqlLike(filters.searchQuery!); + clauses.add( + ''' + ( + EXISTS ( + SELECT 1 FROM document_authors da + WHERE da.document_id = p.id + AND da.document_ver = p.ver + AND da.author_username LIKE '%$escapedQuery%' ESCAPE '\\' + ) OR + json_extract(p.content, '\$.setup.proposer.applicant') LIKE '%$escapedQuery%' ESCAPE '\\' OR + json_extract(p.content, '\$.setup.title.title') LIKE '%$escapedQuery%' ESCAPE '\\' + )''', + ); + } + + if (filters.latestUpdate != null) { + final cutoffTime = DateTime.now().subtract(filters.latestUpdate!); + final escapedTimestamp = _escapeSqlString(cutoffTime.toIso8601String()); + clauses.add("p.created_at >= '$escapedTimestamp'"); + } + + if (filters.ids != null) { + final escapedIds = filters.ids!.map((id) => "'${_escapeSqlString(id)}'").join(', '); + clauses.add('p.id IN ($escapedIds)'); + } + + return clauses; + } + + List _buildFilterTotalAskClauses(ProposalsTotalAskFilters filters) { + final clauses = []; + + if (filters.categoryId != null) { + final escapedCategory = _escapeSqlString(filters.categoryId!); + clauses.add("p.category_id = '$escapedCategory'"); + } else if (filters.campaign != null) { + final escapedIds = filters.campaign!.categoriesIds + .map((id) => "'${_escapeSqlString(id)}'") + .join(', '); + clauses.add('p.category_id IN ($escapedIds)'); + } + + return clauses; + } + + /// Builds the ORDER BY clause based on the provided ordering. + /// + /// Supports multiple ordering strategies: + /// - UpdateDate: Sort by createdAt (newest first or oldest first) + /// - Funds: Sort by requested funds amount extracted from JSON content + /// + /// **Returns:** SQL ORDER BY clause string (without leading "ORDER BY") + String _buildOrderByClause(ProposalsOrder order) { + return switch (order) { + Alphabetical() => + "LOWER(NULLIF(json_extract(p.content, '\$.${ProposalDocument.titleNodeId.value}'), '')) ASC NULLS LAST", + Budget(:final isAscending) => + isAscending + ? "CAST(json_extract(p.content, '\$.${ProposalDocument.requestedFundsNodeId.value}') AS INTEGER) ASC NULLS LAST" + : "CAST(json_extract(p.content, '\$.${ProposalDocument.requestedFundsNodeId.value}') AS INTEGER) DESC NULLS LAST", + UpdateDate(:final isAscending) => isAscending ? 'p.ver ASC' : 'p.ver DESC', + }; + } + + /// Generates a SQL string of aliased column names for a given table. + /// + /// This is used in complex `JOIN` queries where two tables of the same type + /// (e.g., `documents_v2` for proposals and `documents_v2` for templates) are + /// joined. To avoid column name collisions in the result set, this function + /// prefixes each column with a unique identifier. + /// + /// Example: `_buildPrefixedColumns('p', 'p')` might produce: + /// `"p.id as p_id, p.ver as p_ver, ..."` + /// + /// - [tableAlias]: The alias used for the table in the SQL query (e.g., 'p'). + /// - [prefix]: The prefix to add to each column name in the `AS` clause (e.g., 'p'). + /// + /// Returns: A comma-separated string of aliased column names. + String _buildPrefixedColumns(String tableAlias, String prefix) { + return documentsV2.$columns + .map((col) => '$tableAlias.${col.$name} as ${prefix}_${col.$name}') + .join(', \n '); + } + + /// Counts total number of visible (non-hidden) proposals matching the filters. + /// + /// Uses the same CTE logic as the main pagination query but stops after + /// determining the effective proposals set. This ensures the count matches + /// exactly what would be returned across all pages. + /// + /// **Query Strategy:** + /// - Reuses CTE structure from main query up to effective_proposals + /// - Applies same filter logic to ensure consistency + /// - Counts DISTINCT proposal ids (not versions) + /// - Faster than pagination query since no document joining needed + /// + /// **Returns:** Selectable that can be used with getSingle() or watchSingle() + Selectable _countVisibleProposals({ + required ProposalsFiltersV2 filters, + }) { + final filterClauses = _buildFilterClauses(filters); + final whereClause = filterClauses.isEmpty ? '' : 'AND ${filterClauses.join(' AND ')}'; + + final cteQuery = + ''' + WITH latest_proposals AS ( + SELECT id, MAX(ver) as max_ver + FROM documents_v2 + WHERE type = ? + GROUP BY id + ), + latest_actions AS ( + SELECT ref_id, MAX(ver) as max_action_ver + FROM documents_v2 + WHERE type = ? + GROUP BY ref_id + ), + action_status AS ( + SELECT + a.ref_id, + a.ref_ver, + COALESCE(json_extract(a.content, '\$.action'), 'draft') as action_type + FROM documents_v2 a + INNER JOIN latest_actions la ON a.ref_id = la.ref_id AND a.ver = la.max_action_ver + WHERE a.type = ? + ), + effective_proposals AS ( + SELECT + lp.id, + CASE + WHEN ast.action_type = 'final' AND ast.ref_ver IS NOT NULL AND ast.ref_ver != '' THEN ast.ref_ver + ELSE lp.max_ver + END as ver, + ast.action_type + FROM latest_proposals lp + LEFT JOIN action_status ast ON lp.id = ast.ref_id + WHERE NOT EXISTS ( + SELECT 1 FROM action_status hidden + WHERE hidden.ref_id = lp.id AND hidden.action_type = 'hide' + ) + ) + SELECT COUNT(DISTINCT ep.id) as total + FROM effective_proposals ep + INNER JOIN documents_v2 p ON ep.id = p.id AND ep.ver = p.ver + LEFT JOIN documents_local_metadata dlm ON p.id = dlm.id + WHERE p.type = ? $whereClause + '''; + + return customSelect( + cteQuery, + variables: [ + Variable.withString(DocumentType.proposalDocument.uuid), + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalDocument.uuid), + ], + readsFrom: { + documentsV2, + if (filters.isFavorite != null) documentsLocalMetadata, + }, + ).map((row) => row.readNullable('total') ?? 0); + } + + /// Escapes a string for use in a SQL `LIKE` clause with a custom escape character. + /// + /// This method prepares an input string to be safely used as a pattern in a + /// 'LIKE' query. It escapes the standard SQL `LIKE` wildcards (`%` and `_`) + /// and the chosen escape character (`\`) itself, preventing them from being + /// interpreted as wildcards. It also escapes single quotes to prevent SQL + /// injection. + /// + /// The `ESCAPE '\'` clause must be used in the SQL query where the output of + /// this function is used. + /// + /// Escapes: + /// - `\` is replaced with `\\` + /// - `%` is replaced with `\%` + /// - `_` is replaced with `\_` + /// - `'` is replaced with `''` + /// + /// - [input]: The string to escape. + /// + /// Returns: The escaped string, safe for use in a `LIKE` clause. + String _escapeForSqlLike(String input) { + return input + .replaceAll(r'\', r'\\') + .replaceAll('%', r'\%') + .replaceAll('_', r'\_') + .replaceAll("'", "''"); + } + + /// Escapes single quotes in a string for safe use in an SQL query. + /// + /// Replaces each single quote (`'`) with two single quotes (`''`). This is the + /// standard way to escape single quotes in SQL strings, preventing unterminated + /// string literals and SQL injection. + /// + /// - [input]: The string to escape. + /// + /// Returns: The escaped string. + String _escapeSqlString(String input) { + return input.replaceAll("'", "''"); + } + + Selectable> _queryProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + final filterClauses = _buildFilterTotalAskClauses(filters); + final filterWhereClause = filterClauses.isEmpty ? '' : 'AND ${filterClauses.join(' AND ')}'; + + final query = + ''' + WITH latest_actions AS ( + SELECT ref_id, MAX(ver) as max_action_ver + FROM documents_v2 + WHERE type = ? + GROUP BY ref_id + ), + action_status AS ( + SELECT + a.ref_id, + a.ref_ver, + COALESCE(json_extract(a.content, '\$.action'), 'draft') as action_type + FROM documents_v2 a + INNER JOIN latest_actions la ON a.ref_id = la.ref_id AND a.ver = la.max_action_ver + WHERE a.type = ? + ), + effective_final_proposals AS ( + SELECT + ast.ref_id as id, + ast.ref_ver as ver + FROM action_status ast + WHERE ast.action_type = 'final' + AND ast.ref_ver IS NOT NULL + AND ast.ref_ver != '' + ) + SELECT + p.template_id, + p.template_ver, + SUM(COALESCE(CAST(json_extract(p.content, '\$.${nodeId.value}') AS INTEGER), 0)) as total_ask, + COUNT(*) as final_proposals_count + FROM documents_v2 p + INNER JOIN effective_final_proposals efp ON p.id = efp.id AND p.ver = efp.ver + WHERE p.type = ? + $filterWhereClause + AND p.template_id IS NOT NULL + AND p.template_ver IS NOT NULL + GROUP BY p.template_id, p.template_ver + '''; + + return customSelect( + query, + variables: [ + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalDocument.uuid), + ], + readsFrom: {documentsV2}, + ).map((row) { + final templateId = row.read('template_id'); + final templateVer = row.read('template_ver'); + final totalAsk = row.read('total_ask'); + final finalProposalsCount = row.read('final_proposals_count'); + + final ref = SignedDocumentRef( + id: templateId, + version: templateVer, + ); + + final value = ProposalsTotalAskPerTemplate( + totalAsk: totalAsk, + finalProposalsCount: finalProposalsCount, + ); + + return MapEntry(ref, value); + }); + } + + /// Fetches a page of visible proposals using multi-stage CTE logic. + /// + /// **CTE Pipeline:** + /// + /// 1. **latest_proposals** + /// - Groups all proposals by id and finds MAX(ver) + /// - Identifies the newest version of each proposal + /// - Uses: idx_documents_v2_type_id + /// + /// 2. **version_lists** + /// - Collects all version ids for each proposal into comma-separated string + /// - Ordered by ver ASC for consistent version history + /// - Used to show version dropdown in UI + /// + /// 3. **latest_actions** + /// - Groups all proposal actions by ref_id and finds MAX(ver) + /// - Ensures we only check the most recent action per proposal + /// - Uses: idx_documents_v2_type_ref_id + /// + /// 4. **action_status** + /// - Joins actual action documents with latest_actions + /// - Extracts action type ('draft'/'final'/'hide') from JSON content + /// - Extracts ref_ver which may point to specific proposal version + /// - COALESCE defaults to 'draft' when action field is missing + /// - Uses: idx_documents_v2_type_ref_id_ver + /// + /// 5. **effective_proposals** + /// - Applies version resolution logic: + /// * Hide action: Filtered out by WHERE NOT EXISTS + /// * Final action with ref_ver: Uses ref_ver (specific pinned version) + /// * Final action without ref_ver OR draft OR no action: Uses max_ver (latest) + /// - LEFT JOIN ensures proposals without actions are included (default to draft) + /// + /// 6. **comments_count** + /// - Counts comments per proposal version + /// - Joins on both ref_id and ref_ver for version-specific counts + /// + /// **Final Query:** + /// - Joins documents_v2 with effective_proposals to get full document data + /// - LEFT JOINs with comments, favorites, and template for enrichment + /// - Applies all user-specified filters + /// - Orders and paginates results + /// + /// **Index Usage:** + /// - idx_documents_v2_type_id: For GROUP BY in latest_proposals + /// - idx_documents_v2_type_ref_id: For GROUP BY in latest_actions + /// - idx_documents_v2_type_ref_id_ver: For action_status JOIN + /// - idx_documents_v2_type_id_ver: For final document retrieval + /// + /// **Returns:** Selectable that can be used with .get() or .watch() + Selectable _queryVisibleProposalsPage( + int page, + int size, { + required ProposalsOrder order, + required ProposalsFiltersV2 filters, + }) { + final proposalColumns = _buildPrefixedColumns('p', 'p'); + final templateColumns = _buildPrefixedColumns('t', 't'); + final orderByClause = _buildOrderByClause(order); + final filterClauses = _buildFilterClauses(filters); + final whereClause = filterClauses.isEmpty ? '' : 'AND ${filterClauses.join(' AND ')}'; + + final cteQuery = + ''' + WITH latest_proposals AS ( + SELECT id, MAX(ver) as max_ver + FROM documents_v2 + WHERE type = ? + GROUP BY id + ), + version_lists AS ( + SELECT + id, + GROUP_CONCAT(ver, ',') as version_ids_str + FROM ( + SELECT id, ver + FROM documents_v2 + WHERE type = ? + ORDER BY id, ver ASC + ) + GROUP BY id + ), + latest_actions AS ( + SELECT ref_id, MAX(ver) as max_action_ver + FROM documents_v2 + WHERE type = ? + GROUP BY ref_id + ), + action_status AS ( + SELECT + a.ref_id, + a.ref_ver, + COALESCE(json_extract(a.content, '\$.action'), 'draft') as action_type + FROM documents_v2 a + INNER JOIN latest_actions la ON a.ref_id = la.ref_id AND a.ver = la.max_action_ver + WHERE a.type = ? + ), + effective_proposals AS ( + SELECT + lp.id, + CASE + WHEN ast.action_type = 'final' AND ast.ref_ver IS NOT NULL AND ast.ref_ver != '' THEN ast.ref_ver + ELSE lp.max_ver + END as ver, + ast.action_type, + vl.version_ids_str + FROM latest_proposals lp + LEFT JOIN action_status ast ON lp.id = ast.ref_id + LEFT JOIN version_lists vl ON lp.id = vl.id + WHERE NOT EXISTS ( + SELECT 1 FROM action_status hidden + WHERE hidden.ref_id = lp.id AND hidden.action_type = 'hide' + ) + ), + comments_count AS ( + SELECT + c.ref_id, + c.ref_ver, + COUNT(*) as count + FROM documents_v2 c + WHERE c.type = ? + GROUP BY c.ref_id, c.ref_ver + ) + SELECT + $proposalColumns, + $templateColumns, + ep.action_type, + ep.version_ids_str, + COALESCE(cc.count, 0) as comments_count, + COALESCE(dlm.is_favorite, 0) as is_favorite + FROM documents_v2 p + INNER JOIN effective_proposals ep ON p.id = ep.id AND p.ver = ep.ver + LEFT JOIN comments_count cc ON p.id = cc.ref_id AND p.ver = cc.ref_ver + LEFT JOIN documents_local_metadata dlm ON p.id = dlm.id + LEFT JOIN documents_v2 t ON p.template_id = t.id AND p.template_ver = t.ver AND t.type = ? + WHERE p.type = ? $whereClause + ORDER BY $orderByClause + LIMIT ? OFFSET ? + '''; + + final readsFromTables = >{ + documentsV2, + documentsLocalMetadata, + }; + + if (filters.author != null || + (filters.searchQuery != null && filters.searchQuery!.isNotEmpty)) { + readsFromTables.add(documentAuthors); + } + + return customSelect( + cteQuery, + variables: [ + Variable.withString(DocumentType.proposalDocument.uuid), + Variable.withString(DocumentType.proposalDocument.uuid), + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.commentDocument.uuid), + Variable.withString(DocumentType.proposalTemplate.uuid), + Variable.withString(DocumentType.proposalDocument.uuid), + Variable.withInt(size), + Variable.withInt(page * size), + ], + readsFrom: readsFromTables, + ).map((row) { + final proposalData = { + for (final col in documentsV2.$columns) + col.$name: row.readNullableWithType(col.type, 'p_${col.$name}'), + }; + final proposal = documentsV2.map(proposalData); + + final templateData = { + for (final col in documentsV2.$columns) + col.$name: row.readNullableWithType(col.type, 't_${col.$name}'), + }; + + final template = templateData['id'] != null ? documentsV2.map(templateData) : null; + + final actionTypeRaw = row.readNullable('action_type') ?? ''; + final actionType = ProposalSubmissionActionDto.fromJson(actionTypeRaw)?.toModel(); + + final versionIdsRaw = row.readNullable('version_ids_str') ?? ''; + final versionIds = versionIdsRaw.split(','); + + final commentsCount = row.readNullable('comments_count') ?? 0; + final isFavorite = (row.readNullable('is_favorite') ?? 0) == 1; + + return JoinedProposalBriefEntity( + proposal: proposal, + template: template, + actionType: actionType, + versionIds: versionIds, + commentsCount: commentsCount, + isFavorite: isFavorite, + ); + }); + } + + bool _shouldReturnEarlyFor({ + required ProposalsFiltersV2 filters, + int? size, + }) { + if (size != null && size == 0) { + return true; + } + + final campaign = filters.campaign; + if (campaign != null) { + assert( + campaign.categoriesIds.length <= 100, + 'Campaign filter with more than 100 categories may impact performance. ' + 'Consider pagination or alternative filtering strategy.', + ); + + if (campaign.categoriesIds.isEmpty) { + return true; + } + + if (filters.categoryId != null && !campaign.categoriesIds.contains(filters.categoryId)) { + return true; + } + } + + // TODO(damian-molinski): remove when voting is implemented. + if (filters.voteBy != null) { + return true; + } + + return false; + } + + bool _totalAskShouldReturnEarlyFor({ + required ProposalsTotalAskFilters filters, + }) { + final campaign = filters.campaign; + if (campaign != null) { + assert( + campaign.categoriesIds.length <= 100, + 'Campaign filter with more than 100 categories may impact performance. ' + 'Consider pagination or alternative filtering strategy.', + ); + + if (campaign.categoriesIds.isEmpty) { + return true; + } + + if (filters.categoryId != null && !campaign.categoriesIds.contains(filters.categoryId)) { + return true; + } + } + + return false; + } +} + +/// Public interface for proposal queries. +/// +/// Defines the contract for proposal data access with proper status handling. +/// +/// **Status Semantics:** +/// - Draft: Proposal is work-in-progress, shows latest version +/// - Final: Proposal is submitted, shows specific or latest version +/// - Hide: Proposal is hidden from all views +abstract interface class ProposalsV2Dao { + /// Retrieves a single proposal by its reference. + /// + /// Filters by type == proposalDocument. + /// + /// **Parameters:** + /// - ref: Document reference with id (required) and version (optional) + /// + /// **Behavior:** + /// - If ref.isExact (has version): Returns specific version + /// - If ref.isLoose (no version): Returns latest version by createdAt + /// - Returns null if no matching proposal found + /// + /// **Note:** This method does NOT respect proposal actions (draft/final/hide). + /// It returns the raw document data. Use getProposalsBriefPage for status-aware queries. + /// + /// **Returns:** [DocumentEntityV2] or null + Future getProposal(DocumentRef ref); + + /// Retrieves a paginated page of proposal briefs with filtering and ordering. + /// + /// Filters by type == proposalDocument. + /// Returns latest effective version per id, respecting proposal actions. + /// + /// **Status Handling:** + /// - Draft (default): Display latest version + /// - Final: Display specific version if ref_ver set, else latest + /// - Hide: Exclude all versions + /// + /// **Pagination:** + /// - request.page: 0-based page number + /// - request.size: Items per page (clamped to 999 max) + /// + /// **Performance:** + /// - Single query with CTEs (no N+1 queries) + /// + /// **Returns:** Page object with items, total count, and pagination metadata + Future> getProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + + /// Counts the total number of visible proposals that match the given filters. + /// + /// This method respects the same status handling logic as [getProposalsBriefPage], + /// ensuring the count is consistent with the total items that would be paginated. + /// It is more efficient than fetching all pages to get a total count. + /// + /// **Parameters:** + /// - [filters]: Optional filters to apply before counting. + /// + /// **Returns:** The total number of visible proposals. + Future getVisibleProposalsCount({ + ProposalsFiltersV2 filters, + }); + + /// Updates the favorite status of a proposal. + /// + /// Manages local metadata to mark proposals as favorites. + /// Operates within a transaction for atomicity. + /// + /// **Parameters:** + /// - [id]: The unique identifier of the proposal + /// - [isFavorite]: Whether to mark as favorite (true) or unfavorite (false) + /// + /// **Behavior:** + /// - If isFavorite is true: Inserts/updates record in documents_local_metadata + /// - If isFavorite is false: Deletes record from documents_local_metadata + Future updateProposalFavorite({ + required String id, + required bool isFavorite, + }); + + /// Watches for changes and emits paginated pages of proposal briefs. + /// + /// Provides a reactive stream that emits a new [Page] whenever the + /// underlying data changes in the database. + /// + /// **Reactivity:** + /// - Emits new page when documents_v2 changes (proposals, actions) + /// - Emits new page when documents_local_metadata changes (favorites) + /// - Combines items and count streams for consistent pagination + /// + /// **Performance:** + /// - Same query optimization as [getProposalsBriefPage] + /// + /// **Returns:** Stream of Page objects with current state + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + + /// Watches for changes and emits the total count of visible proposals. + /// + /// Provides a reactive stream that emits a new integer count whenever the + /// underlying data changes in a way that affects the total number of + /// visible proposals matching the filters. + /// + /// **Parameters:** + /// - [filters]: Optional filters to apply before counting. + /// + /// **Reactivity:** + /// - Emits new count when documents_v2 changes (proposals, actions) + /// that match the filter criteria. + Stream watchVisibleProposalsCount({ + ProposalsFiltersV2 filters, + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/drift_migration_strategy.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/drift_migration_strategy.dart index bb3dfaf358da..299086516b9c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/drift_migration_strategy.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/drift_migration_strategy.dart @@ -1,4 +1,7 @@ +import 'package:catalyst_voices_repositories/src/database/migration/from_3_to_4.dart'; +import 'package:catalyst_voices_repositories/src/database/migration/schema_versions.g.dart'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; /// Migration strategy for drift database. final class DriftMigrationStrategy extends MigrationStrategy { @@ -6,16 +9,19 @@ final class DriftMigrationStrategy extends MigrationStrategy { required GeneratedDatabase database, required MigrationStrategy destructiveFallback, }) : super( - onCreate: (m) async { - await m.createAll(); - }, + onCreate: (m) => m.createAll(), onUpgrade: (m, from, to) async { - await database.customStatement('PRAGMA foreign_keys = OFF'); + final delegate = from < 3 + ? destructiveFallback.onUpgrade + : stepByStep(from3To4: from3To4); - /// Provide non destructive migration when schema changes - await destructiveFallback.onUpgrade(m, from, to); + await database.customStatement('PRAGMA foreign_keys = OFF'); + await delegate(m, from, to); - await database.customStatement('PRAGMA foreign_keys = ON;'); + if (kDebugMode) { + final wrongForeignKeys = await database.customSelect('PRAGMA foreign_key_check').get(); + assert(wrongForeignKeys.isEmpty, '${wrongForeignKeys.map((e) => e.data)}'); + } }, beforeOpen: (details) async { await database.customStatement('PRAGMA foreign_keys = ON'); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/from_3_to_4.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/from_3_to_4.dart new file mode 100644 index 000000000000..731dc283109e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/migration/from_3_to_4.dart @@ -0,0 +1,409 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/migration/schema_versions.g.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/local_documents_drafts.drift.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:convert/convert.dart' show hex; +import 'package:drift/drift.dart' hide JsonKey; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:sqlite3/common.dart' as sqlite3 show jsonb; + +part 'from_3_to_4.g.dart'; + +const _batchSize = 300; +final _logger = Logger('Migration[3-4]'); + +Future from3To4(Migrator m, Schema4 schema) async { + await m.database.transaction(() async { + await m.createTable(schema.documentsV2); + await m.createTable(schema.documentAuthors); + await m.createTable(schema.documentsLocalMetadata); + await m.createTable(schema.localDocumentsDrafts); + + await m.createIndex(schema.idxDocumentsV2TypeId); + await m.createIndex(schema.idxDocumentsV2TypeIdVer); + await m.createIndex(schema.idxDocumentsV2TypeRefId); + await m.createIndex(schema.idxDocumentsV2TypeRefIdVer); + await m.createIndex(schema.idxDocumentsV2RefIdVer); + await m.createIndex(schema.idxDocumentsV2TypeIdCreatedAt); + await m.createIndex(schema.idxDocumentsV2TypeCategoryId); + await m.createIndex(schema.idxDocumentsV2TypeRefIdRefVer); + await m.createIndex(schema.idxDocumentAuthorsComposite); + await m.createIndex(schema.idxDocumentAuthorsIdentity); + await m.createIndex(schema.idxDocumentAuthorsUsername); + + await _migrateDocs(m, schema, batchSize: _batchSize); + await _migrateDrafts(m, schema, batchSize: _batchSize); + await _migrateFavorites(m, schema, batchSize: _batchSize); + + // TODO(damian-molinski): uncomment when migration is done + /*await m.drop(schema.documents); + await m.drop(schema.drafts); + await m.drop(schema.documentsMetadata); + await m.drop(schema.documentsFavorites); + + await m.drop(schema.idxDocType); + await m.drop(schema.idxUniqueVer); + await m.drop(schema.idxDocMetadataKeyValue); + await m.drop(schema.idxFavType); + await m.drop(schema.idxFavUniqueId); + await m.drop(schema.idxDraftType);*/ + }); +} + +Future _migrateDocs( + Migrator m, + Schema4 schema, { + required int batchSize, +}) async { + final docsCount = await schema.documents.count().getSingleOrNull().then((e) => e ?? 0); + var docsOffset = 0; + + while (docsOffset < docsCount) { + await m.database.batch((batch) async { + final query = schema.documents.select()..limit(batchSize, offset: docsOffset); + final oldDocs = await query.get(); + + final rows = >[]; + final authors = >[]; + for (final oldDoc in oldDocs) { + final rawContent = oldDoc.read('content'); + final content = sqlite3.jsonb.decode(rawContent)! as Map; + + final rawMetadata = oldDoc.read('metadata'); + final encodedMetadata = sqlite3.jsonb.decode(rawMetadata)! as Map; + final metadata = DocumentDataMetadataDtoDbV3.fromJson(encodedMetadata); + + final entity = metadata.toDocEntity(content: content); + + final insertable = RawValuesInsertable(entity.toColumns(true)); + + rows.add(insertable); + + final authorsInjectable = metadata.toAuthorEntity().map( + (entity) => RawValuesInsertable(entity.toColumns(true)), + ); + + authors.addAll(authorsInjectable); + } + + batch + ..insertAll(schema.documentsV2, rows) + ..insertAll(schema.documentAuthors, authors); + + docsOffset += oldDocs.length; + }); + } + + _logger.info('Finished migrating docs[$docsOffset], totalCount[$docsCount]'); +} + +Future _migrateDrafts( + Migrator m, + Schema4 schema, { + required int batchSize, +}) async { + final localDraftsCount = await schema.drafts.count().getSingleOrNull().then( + (value) => value ?? 0, + ); + var localDraftsOffset = 0; + + while (localDraftsOffset < localDraftsCount) { + await m.database.batch((batch) async { + final query = schema.drafts.select()..limit(batchSize, offset: localDraftsOffset); + final oldDrafts = await query.get(); + + final rows = >[]; + for (final oldDoc in oldDrafts) { + final rawContent = oldDoc.read('content'); + final content = sqlite3.jsonb.decode(rawContent)! as Map; + + final rawMetadata = oldDoc.read('metadata'); + final encodedMetadata = sqlite3.jsonb.decode(rawMetadata)! as Map; + final metadata = DocumentDataMetadataDtoDbV3.fromJson(encodedMetadata); + + final entity = metadata.toDraftEntity(content: content); + + final insertable = RawValuesInsertable(entity.toColumns(true)); + + rows.add(insertable); + } + + batch.insertAll(schema.localDocumentsDrafts, rows); + localDraftsOffset += oldDrafts.length; + }); + } + + if (kDebugMode) { + print('Finished migrating drafts[$localDraftsOffset], totalCount[$localDraftsCount]'); + } +} + +Future _migrateFavorites( + Migrator m, + Schema4 schema, { + required int batchSize, +}) async { + final favCount = await schema.documentsFavorites.count().getSingleOrNull().then( + (value) => value ?? 0, + ); + var favOffset = 0; + + while (favOffset < favCount) { + await m.database.batch((batch) async { + final query = schema.documentsFavorites.select()..limit(batchSize, offset: favOffset); + final oldFav = await query.get(); + + final rows = >[]; + + for (final oldDoc in oldFav) { + final idHi = oldDoc.read('id_hi'); + final idLo = oldDoc.read('id_lo'); + final isFavorite = oldDoc.read('is_favorite'); + + final id = UuidHiLo(high: idHi, low: idLo).uuid; + + final entity = DocumentLocalMetadataEntity( + id: id, + isFavorite: isFavorite, + ); + + final insertable = RawValuesInsertable(entity.toColumns(true)); + + rows.add(insertable); + } + + batch.insertAll(schema.documentsLocalMetadata, rows); + favOffset += oldFav.length; + }); + } + + if (kDebugMode) { + print('Finished migrating fav[$favOffset], totalCount[$favCount]'); + } +} + +@JsonSerializable() +class DocumentDataMetadataDtoDbV3 { + final String type; + final DocumentRefDtoDbV3 selfRef; + final DocumentRefDtoDbV3? ref; + final SecuredDocumentRefDtoDbV3? refHash; + final DocumentRefDtoDbV3? template; + final DocumentRefDtoDbV3? reply; + final String? section; + final DocumentRefDtoDbV3? brandId; + final DocumentRefDtoDbV3? campaignId; + final String? electionId; + final DocumentRefDtoDbV3? categoryId; + final List? authors; + + DocumentDataMetadataDtoDbV3({ + required this.type, + required this.selfRef, + this.ref, + this.refHash, + this.template, + this.reply, + this.section, + this.brandId, + this.campaignId, + this.electionId, + this.categoryId, + this.authors, + }); + + factory DocumentDataMetadataDtoDbV3.fromJson(Map json) { + var migrated = _migrateJson1(json); + migrated = _migrateJson2(migrated); + + return _$DocumentDataMetadataDtoDbV3FromJson(migrated); + } + + DocumentDataMetadataDtoDbV3.fromModel(DocumentDataMetadata data) + : this( + type: data.type.uuid, + selfRef: data.selfRef.toDto(), + ref: data.ref?.toDto(), + refHash: data.refHash?.toDto(), + template: data.template?.toDto(), + reply: data.reply?.toDto(), + section: data.section, + brandId: data.brandId?.toDto(), + campaignId: data.campaignId?.toDto(), + electionId: data.electionId, + categoryId: data.categoryId?.toDto(), + authors: data.authors?.map((e) => e.toString()).toList(), + ); + + Map toJson() => _$DocumentDataMetadataDtoDbV3ToJson(this); + + static Map _migrateJson1(Map json) { + final modified = Map.from(json); + + if (modified.containsKey('id') && modified.containsKey('version')) { + final id = modified.remove('id') as String; + final version = modified.remove('version') as String; + + modified['selfRef'] = { + 'id': id, + 'version': version, + 'type': DocumentRefDtoTypeDbV3.signed.name, + }; + } + + return modified; + } + + static Map _migrateJson2(Map json) { + final modified = Map.from(json); + + if (modified['brandId'] is String) { + final id = modified.remove('brandId') as String; + final dto = DocumentRefDtoDbV3( + id: id, + type: DocumentRefDtoTypeDbV3.signed, + ); + modified['brandId'] = dto.toJson(); + } + if (modified['campaignId'] is String) { + final id = modified.remove('campaignId') as String; + final dto = DocumentRefDtoDbV3( + id: id, + type: DocumentRefDtoTypeDbV3.signed, + ); + modified['campaignId'] = dto.toJson(); + } + + return modified; + } +} + +@JsonSerializable() +final class DocumentRefDtoDbV3 { + final String id; + final String? version; + @JsonKey(unknownEnumValue: DocumentRefDtoTypeDbV3.signed) + final DocumentRefDtoTypeDbV3 type; + + const DocumentRefDtoDbV3({ + required this.id, + this.version, + required this.type, + }); + + factory DocumentRefDtoDbV3.fromJson(Map json) { + return _$DocumentRefDtoDbV3FromJson(json); + } + + factory DocumentRefDtoDbV3.fromModel(DocumentRef data) { + final type = switch (data) { + SignedDocumentRef() => DocumentRefDtoTypeDbV3.signed, + DraftRef() => DocumentRefDtoTypeDbV3.draft, + }; + + return DocumentRefDtoDbV3( + id: data.id, + version: data.version, + type: type, + ); + } + + Map toJson() => _$DocumentRefDtoDbV3ToJson(this); +} + +enum DocumentRefDtoTypeDbV3 { signed, draft } + +@JsonSerializable() +final class SecuredDocumentRefDtoDbV3 { + final DocumentRefDtoDbV3 ref; + final String hash; + + const SecuredDocumentRefDtoDbV3({ + required this.ref, + required this.hash, + }); + + factory SecuredDocumentRefDtoDbV3.fromJson(Map json) { + return _$SecuredDocumentRefDtoDbV3FromJson(json); + } + + SecuredDocumentRefDtoDbV3.fromModel(SecuredDocumentRef data) + : this( + ref: DocumentRefDtoDbV3.fromModel(data.ref), + hash: hex.encode(data.hash), + ); + + Map toJson() => _$SecuredDocumentRefDtoDbV3ToJson(this); +} + +extension on DocumentRef { + DocumentRefDtoDbV3 toDto() => DocumentRefDtoDbV3.fromModel(this); +} + +extension on SecuredDocumentRef { + SecuredDocumentRefDtoDbV3 toDto() { + return SecuredDocumentRefDtoDbV3.fromModel(this); + } +} + +extension on DocumentDataMetadataDtoDbV3 { + List toAuthorEntity() { + return (authors ?? const []).map(CatalystId.parse).map((catId) { + return DocumentAuthorEntity( + documentId: selfRef.id, + documentVer: selfRef.version!, + authorId: catId.toUri().toString(), + authorIdSignificant: catId.toSignificant().toUri().toString(), + authorUsername: catId.username, + ); + }).toList(); + } + + DocumentEntityV2 toDocEntity({ + required Map content, + }) { + return DocumentEntityV2( + id: selfRef.id, + ver: selfRef.version!, + type: DocumentType.fromJson(type), + createdAt: selfRef.version!.dateTime, + refId: ref?.id, + refVer: ref?.version, + replyId: reply?.id, + replyVer: reply?.version, + section: section, + categoryId: categoryId?.id, + categoryVer: categoryId?.version, + templateId: template?.id, + templateVer: template?.version, + authors: authors?.join(',') ?? '', + content: DocumentDataContent(content), + ); + } + + LocalDocumentDraftEntity toDraftEntity({ + required Map content, + }) { + return LocalDocumentDraftEntity( + id: selfRef.id, + ver: selfRef.version!, + type: DocumentType.fromJson(type), + createdAt: selfRef.version!.dateTime, + refId: ref?.id, + refVer: ref?.version, + replyId: reply?.id, + replyVer: reply?.version, + section: section, + categoryId: categoryId?.id, + categoryVer: categoryId?.version, + templateId: template?.id, + templateVer: template?.version, + authors: authors?.join(',') ?? '', + content: DocumentDataContent(content), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/document_with_authors_entity.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/document_with_authors_entity.dart new file mode 100644 index 000000000000..f457ca224c96 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/document_with_authors_entity.dart @@ -0,0 +1,9 @@ +import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; + +final class DocumentWithAuthorsEntity { + final DocumentEntityV2 doc; + final List authors; + + const DocumentWithAuthorsEntity(this.doc, this.authors); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_brief_entity.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_brief_entity.dart new file mode 100644 index 000000000000..d43901cf8627 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/model/joined_proposal_brief_entity.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:equatable/equatable.dart'; + +class JoinedProposalBriefEntity extends Equatable { + final DocumentEntityV2 proposal; + final DocumentEntityV2? template; + final ProposalSubmissionAction? actionType; + final List versionIds; + final int commentsCount; + final bool isFavorite; + + const JoinedProposalBriefEntity({ + required this.proposal, + required this.template, + required this.actionType, + required this.versionIds, + required this.commentsCount, + required this.isFavorite, + }); + + @override + List get props => [ + proposal, + template, + actionType, + versionIds, + commentsCount, + isFavorite, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/document_authors.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/document_authors.dart new file mode 100644 index 000000000000..5d5b8425732c --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/document_authors.dart @@ -0,0 +1,54 @@ +import 'package:drift/drift.dart'; + +/// Normalized junction table for document authors with efficient querying capabilities. +/// +/// **Purpose:** +/// This table extracts and normalizes authors information from documents to enable +/// efficient author-based queries without parsing JSON or scanning large content blobs. +/// +/// **Why Normalization?** +/// Documents store authors as comma-separated URIs in the `authors` metadata field: +/// /* cSpell:disable */ +/// ``` +/// "id.catalyst://john@preprod.cardano/FftxFn...,id.catalyst://alice@cardano/AbcDef..." +/// ``` +/// /* cSpell:enable */ +/// +/// Direct querying against this field would require: +/// - JSON extraction or string parsing on every row +/// - LIKE queries with leading wildcards (no index usage) +/// - Full table scans even with indexes +/// - ~100-200ms query time for 10k documents +/// +/// By normalizing into this table: +/// - Pre-parsed CatalystId components stored in indexed columns +/// - Direct index lookups instead of scans +/// - ~5-10ms for exact author matches +/// - ~20-30ms for username searches +/// - **20-40x performance improvement** +@DataClassName('DocumentAuthorEntity') +@TableIndex( + name: 'idx_document_authors_composite', + columns: {#documentId, #documentVer, #authorIdSignificant}, +) +@TableIndex(name: 'idx_document_authors_identity', columns: {#authorIdSignificant}) +@TableIndex(name: 'idx_document_authors_username', columns: {#authorUsername}) +class DocumentAuthors extends Table { + TextColumn get authorId => text()(); + + TextColumn get authorIdSignificant => text()(); + + TextColumn get authorUsername => text().nullable()(); + + @override + List get customConstraints => [ + 'FOREIGN KEY (document_id, document_ver) REFERENCES documents_v2(id, ver) ON DELETE CASCADE', + ]; + + TextColumn get documentId => text()(); + + TextColumn get documentVer => text()(); + + @override + Set get primaryKey => {documentId, documentVer, authorId}; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_local_metadata.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_local_metadata.dart new file mode 100644 index 000000000000..fa303fcb8bfd --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_local_metadata.dart @@ -0,0 +1,11 @@ +import 'package:drift/drift.dart'; + +@DataClassName('DocumentLocalMetadataEntity') +class DocumentsLocalMetadata extends Table { + TextColumn get id => text()(); + + BoolColumn get isFavorite => boolean()(); + + @override + Set get primaryKey => {id}; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_v2.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_v2.dart new file mode 100644 index 000000000000..99f8e094d05f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/documents_v2.dart @@ -0,0 +1,47 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/table/mixin/document_table_content_mixin.dart'; +import 'package:catalyst_voices_repositories/src/database/table/mixin/document_table_metadata_mixin.dart'; +import 'package:drift/drift.dart'; + +/// This table stores a record of each document with its content and flattened metadata fields. +/// +/// Its representation of [DocumentData] class. +/// +/// Identity & Versioning: +/// - [id]: Document identifier (UUIDv7). Multiple records can share same id (versioning). +/// - [ver]: Document version identifier (UUIDv7). Composite key with id. +/// - [createdAt]: Timestamp extracted from `ver` for sorting and filtering. +/// +/// Versioning Model: +/// - Each ([id], [ver]) pair is unique (composite primary key). +/// - Multiple versions of same document coexist in table. +/// - Latest version is determined by comparing [createdAt] timestamps or `ver` UUIDv7 values. +/// - Example: Proposal with id='abc' can have ver='v1', ver='v2', ver='v3', etc. +/// +/// Reference Relationships: +/// - proposal: uses [templateId] to reference the template's [id] +/// - proposal: uses [templateVer] to reference specific template [ver] +/// - proposalActionDocument: uses [refId] to reference the proposal's [id] +/// - proposalActionDocument: uses [refVer] to pin final action to specific proposal [ver] +/// - commentDocument: uses [refId] to reference commented proposal +@DataClassName('DocumentEntityV2') +@TableIndex(name: 'idx_documents_v2_type_id', columns: {#type, #id}) +@TableIndex(name: 'idx_documents_v2_type_id_ver', columns: {#type, #id, #ver}) +@TableIndex(name: 'idx_documents_v2_type_ref_id', columns: {#type, #refId}) +@TableIndex(name: 'idx_documents_v2_type_ref_id_ver', columns: {#type, #refId, #ver}) +@TableIndex(name: 'idx_documents_v2_ref_id_ver', columns: {#refId, #ver}) +@TableIndex(name: 'idx_documents_v2_type_id_created_at', columns: {#type, #id, #createdAt}) +@TableIndex(name: 'idx_documents_v2_type_category_id', columns: {#type, #categoryId}) +@TableIndex(name: 'idx_documents_v2_type_ref_id_ref_ver', columns: {#type, #refId, #refVer}) +class DocumentsV2 extends Table with DocumentTableContentMixin, DocumentTableMetadataMixin { + /// Timestamp extracted from [ver] field. + /// Represents when this version was created. + /// Used for sorting (ORDER BY createdAt DESC) and filtering by date range. + DateTimeColumn get createdAt => dateTime()(); + + /// Composite primary key: ([id], [ver]) + /// This allows multiple versions of the same document to coexist. + /// SQLite enforces uniqueness on this combination. + @override + Set>? get primaryKey => {id, ver}; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/local_documents_drafts.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/local_documents_drafts.dart new file mode 100644 index 000000000000..6632c7b53902 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/local_documents_drafts.dart @@ -0,0 +1,20 @@ +import 'package:catalyst_voices_repositories/src/database/table/mixin/document_table_content_mixin.dart'; +import 'package:catalyst_voices_repositories/src/database/table/mixin/document_table_metadata_mixin.dart'; +import 'package:drift/drift.dart'; + +/// This table holds in-progress (draft) versions of documents that are not yet +/// been made public or submitted. +/// +/// [content] will be encrypted in future. +@DataClassName('LocalDocumentDraftEntity') +class LocalDocumentsDrafts extends Table + with DocumentTableContentMixin, DocumentTableMetadataMixin { + /// Timestamp extracted from [ver]. + DateTimeColumn get createdAt => dateTime()(); + + @override + Set>? get primaryKey => { + id, + ver, + }; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_content_mixin.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_content_mixin.dart new file mode 100644 index 000000000000..52b653aff692 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_content_mixin.dart @@ -0,0 +1,6 @@ +import 'package:catalyst_voices_repositories/src/database/table/converter/document_converters.dart'; +import 'package:drift/drift.dart'; + +mixin DocumentTableContentMixin on Table { + BlobColumn get content => blob().map(DocumentConverters.content)(); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_metadata_mixin.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_metadata_mixin.dart new file mode 100644 index 000000000000..95aa0b8537b2 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/table/mixin/document_table_metadata_mixin.dart @@ -0,0 +1,30 @@ +import 'package:catalyst_voices_repositories/src/database/table/converter/document_converters.dart'; +import 'package:drift/drift.dart'; + +mixin DocumentTableMetadataMixin on Table { + TextColumn get authors => text()(); + + TextColumn get categoryId => text().nullable()(); + + TextColumn get categoryVer => text().nullable()(); + + TextColumn get id => text()(); + + TextColumn get refId => text().nullable()(); + + TextColumn get refVer => text().nullable()(); + + TextColumn get replyId => text().nullable()(); + + TextColumn get replyVer => text().nullable()(); + + TextColumn get section => text().nullable()(); + + TextColumn get templateId => text().nullable()(); + + TextColumn get templateVer => text().nullable()(); + + TextColumn get type => text().map(DocumentConverters.type)(); + + TextColumn get ver => text()(); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart index 98432b41ff5d..1f4e5bfaaf0d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/document_repository.dart @@ -21,12 +21,20 @@ DocumentRef _templateResolver(DocumentData data) => data.metadata.template!; /// document type. abstract interface class DocumentRepository { factory DocumentRepository( + CatalystDatabase db, DraftDataSource drafts, SignedDocumentDataSource localDocuments, DocumentDataRemoteSource remoteDocuments, DocumentFavoriteSource favoriteDocuments, ) = DocumentRepositoryImpl; + /// Analyzes the database to gather statistics and potentially optimize it. + /// + /// This can be a long-running operation, so it should be used judiciously, + /// for example, during application startup or in a background process + /// for maintenance. + Future analyzeDatabase(); + /// Deletes a document draft from the local storage. Future deleteDocumentDraft({ required DraftRef ref, @@ -77,6 +85,9 @@ abstract interface class DocumentRepository { CatalystId? authorId, }); + /// Returns latest matching [DocumentRef] version with same id as [ref]. + Future getLatestOf({required DocumentRef ref}); + /// Returns count of documents matching [ref] id and [type]. Future getRefCount({ required DocumentRef ref, @@ -97,9 +108,9 @@ abstract interface class DocumentRepository { required DocumentIndexFilters filters, }); - /// Looks up local source if matching document exists. - Future isCached({ - required DocumentRef ref, + /// Filters and returns only the DocumentRefs from [refs] which are cached. + Future> isCachedBulk({ + required List refs, }); /// Similar to [watchIsDocumentFavorite] but stops after first emit. @@ -234,6 +245,7 @@ abstract interface class DocumentRepository { } final class DocumentRepositoryImpl implements DocumentRepository { + final CatalystDatabase _db; final DraftDataSource _drafts; final SignedDocumentDataSource _localDocuments; final DocumentDataRemoteSource _remoteDocuments; @@ -242,12 +254,16 @@ final class DocumentRepositoryImpl implements DocumentRepository { final _documentDataLock = Lock(); DocumentRepositoryImpl( + this._db, this._drafts, this._localDocuments, this._remoteDocuments, this._favoriteDocuments, ); + @override + Future analyzeDatabase() => _db.analyze(); + @override Future deleteDocumentDraft({required DraftRef ref}) { return _drafts.delete(ref: ref); @@ -307,6 +323,17 @@ final class DocumentRepositoryImpl implements DocumentRepository { return [latestDocument, latestDraft].nonNulls.sorted((a, b) => a.compareTo(b)).firstOrNull; } + // TODO(damian-molinski): consider also checking with remote source. + @override + Future getLatestOf({required DocumentRef ref}) async { + final draft = await _drafts.getLatestOf(ref: ref); + if (draft != null) { + return draft; + } + + return _localDocuments.getLatestOf(ref: ref); + } + @override Future getRefCount({ required DocumentRef ref, @@ -337,11 +364,17 @@ final class DocumentRepositoryImpl implements DocumentRepository { } @override - Future isCached({required DocumentRef ref}) { - return switch (ref) { - DraftRef() => _drafts.exists(ref: ref), - SignedDocumentRef() => _localDocuments.exists(ref: ref), - }; + Future> isCachedBulk({required List refs}) { + final signedRefs = refs.whereType().toList(); + final localDraftsRefs = refs.whereType().toList(); + + final signedDocsSave = _localDocuments.filterExisting(signedRefs); + final draftsDocsSave = _drafts.filterExisting(localDraftsRefs); + + return [ + signedDocsSave, + draftsDocsSave, + ].wait.then((value) => value.expand((refs) => refs).toList()); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart index 6bcc3f25b12a..49f7a91e63f4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart @@ -1,13 +1,26 @@ +import 'dart:async'; + import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/model/joined_proposal_brief_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; import 'package:catalyst_voices_repositories/src/document/source/proposal_document_data_local_source.dart'; +import 'package:catalyst_voices_repositories/src/proposal/proposal_document_factory.dart'; +import 'package:catalyst_voices_repositories/src/proposal/proposal_template_factory.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; final class DatabaseDocumentsDataSource implements SignedDocumentDataSource, ProposalDocumentDataLocalSource { final CatalystDatabase _database; + final CatalystProfiler _profiler; DatabaseDocumentsDataSource( this._database, + this._profiler, ); @override @@ -22,12 +35,17 @@ final class DatabaseDocumentsDataSource @override Future exists({required DocumentRef ref}) { - return _database.documentsDao.count(ref: ref).then((count) => count > 0); + return _database.documentsV2Dao.exists(ref); + } + + @override + Future> filterExisting(List refs) { + return _database.documentsV2Dao.filterExisting(refs); } @override Future get({required DocumentRef ref}) async { - final entity = await _database.documentsDao.query(ref: ref); + final entity = await _database.documentsV2Dao.getDocument(ref); if (entity == null) { throw DocumentNotFoundException(ref: ref); } @@ -51,6 +69,11 @@ final class DatabaseDocumentsDataSource .then((value) => value?.toModel()); } + @override + Future getLatestOf({required DocumentRef ref}) { + return _database.documentsV2Dao.getLatestOf(ref); + } + @override Future> getProposals({ SignedDocumentRef? categoryRef, @@ -75,6 +98,14 @@ final class DatabaseDocumentsDataSource .then((page) => page.map((e) => e.toModel())); } + @override + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _database.proposalsV2Dao.getProposalsTotalTask(filters: filters, nodeId: nodeId); + } + @override Future getRefCount({ required DocumentRef ref, @@ -104,32 +135,19 @@ final class DatabaseDocumentsDataSource @override Future saveAll(Iterable data) async { - final documentsWithMetadata = data.map( - (data) { - final idHiLo = UuidHiLo.from(data.metadata.id); - final verHiLo = UuidHiLo.from(data.metadata.version); - - final document = DocumentEntity( - idHi: idHiLo.high, - idLo: idHiLo.low, - verHi: verHiLo.high, - verLo: verHiLo.low, - type: data.metadata.type, - content: data.content, - metadata: data.metadata, - createdAt: DateTime.timestamp(), - ); - - // TODO(damian-molinski): Need to decide what goes into metadata table. - final metadata = [ - // - ]; - - return (document: document, metadata: metadata); - }, - ).toList(); + final entries = data + .map((e) => DocumentWithAuthorsEntity(e.toDocEntity(), e.toAuthorEntities())) + .toList(); - await _database.documentsDao.saveAll(documentsWithMetadata); + await _database.documentsV2Dao.saveAll(entries); + } + + @override + Future updateProposalFavorite({ + required String id, + required bool isFavorite, + }) async { + await _database.proposalsV2Dao.updateProposalFavorite(id: id, isFavorite: isFavorite); } @override @@ -169,6 +187,24 @@ final class DatabaseDocumentsDataSource ); } + @override + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final tr = _profiler.startTransaction('Query proposals: $request:$order:$filters'); + + return _database.proposalsV2Dao + .watchProposalsBriefPage(request: request, order: order, filters: filters) + .doOnData( + (_) { + if (!tr.finished) unawaited(tr.finish()); + }, + ) + .map((page) => page.map((data) => data.toModel())); + } + @override Stream watchProposalsCount({ required ProposalsCountFilters filters, @@ -176,6 +212,19 @@ final class DatabaseDocumentsDataSource return _database.proposalsDao.watchCount(filters: filters); } + @override + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final tr = _profiler.startTransaction('Query proposals count: $filters'); + + return _database.proposalsV2Dao.watchVisibleProposalsCount(filters: filters).doOnData( + (_) { + if (!tr.finished) unawaited(tr.finish()); + }, + ); + } + @override Stream> watchProposalsPage({ required PageRequest request, @@ -187,6 +236,26 @@ final class DatabaseDocumentsDataSource .map((page) => page.map((e) => e.toModel())); } + @override + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }) { + return _database.proposalsV2Dao + .watchProposalsTotalTask(filters: filters, nodeId: nodeId) + .distinct(); + } + + @override + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }) { + return _database.documentsV2Dao + .watchDocuments(type: DocumentType.proposalTemplate, filters: filters) + .distinct(listEquals) + .map((event) => event.map((e) => e.toModel()).toList()); + } + @override Stream watchRefToDocumentData({ required DocumentRef refTo, @@ -207,6 +276,70 @@ extension on DocumentEntity { } } +extension on DocumentEntityV2 { + DocumentData toModel() { + return DocumentData( + metadata: DocumentDataMetadata( + type: type, + selfRef: SignedDocumentRef(id: id, version: ver), + ref: refId.toRef(refVer), + template: templateId.toRef(templateVer), + reply: replyId.toRef(replyVer), + section: section, + categoryId: categoryId.toRef(categoryVer), + // TODO(damian-molinski): Make sure to add unit tests + authors: authors.isEmpty ? null : authors.split(',').map(CatalystId.parse).toList(), + ), + content: content, + ); + } +} + +extension on String? { + SignedDocumentRef? toRef([String? ver]) { + final id = this; + if (id == null) { + return null; + } + + return SignedDocumentRef(id: id, version: ver); + } +} + +extension on DocumentData { + List toAuthorEntities() { + return (metadata.authors ?? const []).map((catId) { + return DocumentAuthorEntity( + documentId: metadata.id, + documentVer: metadata.version, + authorId: catId.toUri().toString(), + authorIdSignificant: catId.toSignificant().toUri().toString(), + authorUsername: catId.username, + ); + }).toList(); + } + + DocumentEntityV2 toDocEntity() { + return DocumentEntityV2( + content: content, + id: metadata.id, + ver: metadata.version, + type: metadata.type, + refId: metadata.ref?.id, + refVer: metadata.ref?.version, + replyId: metadata.reply?.id, + replyVer: metadata.reply?.version, + section: metadata.section, + categoryId: metadata.categoryId?.id, + categoryVer: metadata.categoryId?.version, + templateId: metadata.template?.id, + templateVer: metadata.template?.version, + authors: metadata.authors?.map((e) => e.toString()).join(',') ?? '', + createdAt: metadata.version.dateTime, + ); + } +} + extension on JoinedProposalEntity { ProposalDocumentData toModel() { return ProposalDocumentData( @@ -218,3 +351,30 @@ extension on JoinedProposalEntity { ); } } + +extension on JoinedProposalBriefEntity { + JoinedProposalBriefData toModel() { + final proposalDocumentData = proposal.toModel(); + final templateDocumentData = template?.toModel(); + + final proposalOrDocument = templateDocumentData == null + ? ProposalOrDocument.data(proposalDocumentData) + : () { + final template = ProposalTemplateFactory.create(templateDocumentData); + final proposal = ProposalDocumentFactory.create( + proposalDocumentData, + template: template, + ); + + return ProposalOrDocument.proposal(proposal); + }(); + + return JoinedProposalBriefData( + proposal: proposalOrDocument, + actionType: actionType, + versionIds: versionIds, + commentsCount: commentsCount, + isFavorite: isFavorite, + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart index 579173990587..a18412ea1547 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_drafts_data_source.dart @@ -22,6 +22,12 @@ final class DatabaseDraftsDataSource implements DraftDataSource { return _database.draftsDao.count(ref: ref).then((count) => count > 0); } + @override + Future> filterExisting(List refs) { + // TODO(damian-molinski): not implemented + return Future(() => []); + } + @override Future get({required DocumentRef ref}) async { final entity = await _database.draftsDao.query(ref: ref); @@ -44,6 +50,12 @@ final class DatabaseDraftsDataSource implements DraftDataSource { return _database.draftsDao.queryLatest(authorId: authorId).then((value) => value?.toModel()); } + @override + Future getLatestOf({required DocumentRef ref}) async { + // TODO(damian-molinski): not implemented + return null; + } + @override Future> queryVersionsOfId({required String id}) async { final documentEntities = await _database.draftsDao.queryVersionsOfId(id: id); @@ -55,25 +67,10 @@ final class DatabaseDraftsDataSource implements DraftDataSource { @override Future saveAll(Iterable data) async { - final entities = data.map( - (data) { - final idHiLo = UuidHiLo.from(data.metadata.id); - final verHiLo = UuidHiLo.from(data.metadata.version); - - return DocumentDraftEntity( - idHi: idHiLo.high, - idLo: idHiLo.low, - verHi: verHiLo.high, - verLo: verHiLo.low, - type: data.metadata.type, - content: data.content, - metadata: data.metadata, - title: data.content.title ?? '', - ); - }, - ); + // TODO(damian-molinski): migrate to V2 + /*final entries = data.map((e) => e.toEntity()).toList(); - await _database.draftsDao.saveAll(entities); + await _database.localDraftsV2Dao.saveAll(entries);*/ } @override @@ -116,3 +113,25 @@ extension on DocumentDraftEntity { ); } } + +extension on DocumentData { + /*LocalDocumentDraftEntity toEntity() { + return LocalDocumentDraftEntity( + content: content, + id: metadata.id, + ver: metadata.version, + type: metadata.type, + refId: metadata.ref?.id, + refVer: metadata.ref?.version, + replyId: metadata.reply?.id, + replyVer: metadata.reply?.version, + section: metadata.section, + categoryId: metadata.categoryId?.id, + categoryVer: metadata.categoryId?.version, + templateId: metadata.template?.id, + templateVer: metadata.template?.version, + authors: metadata.authors?.map((e) => e.toUri().toString()).join(',') ?? '', + createdAt: metadata.version.dateTime, + ); + }*/ +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart index 0794ca89bcb7..9e45b5e0de0d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_local_source.dart @@ -7,6 +7,8 @@ abstract interface class DocumentDataLocalSource implements DocumentDataSource { Future exists({required DocumentRef ref}); + Future> filterExisting(List refs); + Future> getAll({required DocumentRef ref}); Future getLatest({ diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart index 19cb52637359..cd8db83d71c0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_remote_source.dart @@ -30,6 +30,16 @@ final class CatGatewayDocumentDataSource implements DocumentDataRemoteSource { return DocumentDataFactory.create(signedDocument); } + @override + Future getLatestOf({required DocumentRef ref}) async { + final ver = await getLatestVersion(ref.id); + if (ver == null) { + return null; + } + + return SignedDocumentRef(id: ref.id, version: ver); + } + @override Future getLatestVersion(String id) { final ver = allConstantDocumentRefs diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart index 4ff8140690db..2135bb86bdf8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/document_data_source.dart @@ -3,4 +3,6 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; //ignore: one_member_abstracts abstract interface class DocumentDataSource { Future get({required DocumentRef ref}); + + Future getLatestOf({required DocumentRef ref}); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart index fe5cd6c5a2d0..2ef41dfff69c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart @@ -20,13 +20,42 @@ abstract interface class ProposalDocumentDataLocalSource { required ProposalsOrder order, }); + Future getProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + + Future updateProposalFavorite({ + required String id, + required bool isFavorite, + }); + + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + Stream watchProposalsCount({ required ProposalsCountFilters filters, }); + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); + Stream> watchProposalsPage({ required PageRequest request, required ProposalsFilters filters, required ProposalsOrder order, }); + + Stream watchProposalsTotalTask({ + required NodeId nodeId, + required ProposalsTotalAskFilters filters, + }); + + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/document_data_dto.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/document_data_dto.dart index 9d9c898f404a..2523934e85cb 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/document_data_dto.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/document_data_dto.dart @@ -146,7 +146,7 @@ final class DocumentDataMetadataDto { campaignId: campaignId?.toModel().toSignedDocumentRef(), electionId: electionId, categoryId: categoryId?.toModel().toSignedDocumentRef(), - authors: authors?.map((e) => CatalystId.fromUri(e.getUri())).toList(), + authors: authors?.map(CatalystId.parse).toList(), ); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/user/account_dto.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/user/account_dto.dart index 754172482f27..3b11f846a9ae 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/user/account_dto.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/user/account_dto.dart @@ -65,7 +65,7 @@ final class AccountDto { final address = this.address; return Account( - catalystId: CatalystId.fromUri(Uri.parse(catalystId)), + catalystId: CatalystId.parse(catalystId), email: email, keychain: keychain, roles: roles, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_document_factory.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_document_factory.dart new file mode 100644 index 000000000000..123a2d8e6f95 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_document_factory.dart @@ -0,0 +1,33 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/dto/document/document_data_dto.dart'; +import 'package:catalyst_voices_repositories/src/dto/document/document_dto.dart'; + +abstract final class ProposalDocumentFactory { + ProposalDocumentFactory._(); + + static ProposalDocument create( + DocumentData documentData, { + required ProposalTemplate template, + }) { + assert( + documentData.metadata.type == DocumentType.proposalDocument, + 'Not a proposalDocument document data type', + ); + + final metadata = ProposalMetadata( + selfRef: documentData.metadata.selfRef, + templateRef: documentData.metadata.template!, + categoryId: documentData.metadata.categoryId!, + authors: documentData.metadata.authors ?? [], + ); + + final schema = template.schema; + final content = DocumentDataContentDto.fromModel(documentData.content); + final document = DocumentDto.fromJsonSchema(content, schema).toModel(); + + return ProposalDocument( + metadata: metadata, + document: document, + ); + } +} 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 index 19697fea1fbb..bfc50c44b47b 100644 --- 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 @@ -3,10 +3,9 @@ import 'dart:typed_data'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_repositories/src/document/source/proposal_document_data_local_source.dart'; -import 'package:catalyst_voices_repositories/src/dto/document/document_data_dto.dart'; -import 'package:catalyst_voices_repositories/src/dto/document/document_dto.dart'; -import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_dto.dart'; import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; +import 'package:catalyst_voices_repositories/src/proposal/proposal_document_factory.dart'; +import 'package:catalyst_voices_repositories/src/proposal/proposal_template_factory.dart'; import 'package:rxdart/rxdart.dart'; /// Base interface to interact with proposals. A specialized version of [DocumentRepository] which @@ -72,6 +71,11 @@ abstract interface class ProposalRepository { bool includeLocalDrafts = false, }); + Future updateProposalFavorite({ + required String id, + required bool isFavorite, + }); + Future upsertDraftProposal({required DocumentData document}); Stream watchCommentsCount({ @@ -89,16 +93,30 @@ abstract interface class ProposalRepository { required DocumentRef refTo, }); + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + Stream watchProposalsCount({ required ProposalsCountFilters filters, }); + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); + Stream> watchProposalsPage({ required PageRequest request, required ProposalsFilters filters, required ProposalsOrder order, }); + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }); + Stream> watchUserProposals({ required CatalystId authorId, }); @@ -194,9 +212,9 @@ final class ProposalRepositoryImpl implements ProposalRepository { Future getProposalTemplate({ required DocumentRef ref, }) async { - final proposalDocument = await _documentRepository.getDocumentData(ref: ref); + final documentData = await _documentRepository.getDocumentData(ref: ref); - return _buildProposalTemplate(documentData: proposalDocument); + return ProposalTemplateFactory.create(documentData); } @override @@ -264,6 +282,14 @@ final class ProposalRepositoryImpl implements ProposalRepository { .toList(); } + @override + Future updateProposalFavorite({ + required String id, + required bool isFavorite, + }) { + return _proposalsLocalSource.updateProposalFavorite(id: id, isFavorite: isFavorite); + } + @override Future upsertDraftProposal({required DocumentData document}) { return _documentRepository.upsertDocument(document: document); @@ -318,6 +344,19 @@ final class ProposalRepositoryImpl implements ProposalRepository { }); } + @override + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + return _proposalsLocalSource.watchProposalsBriefPage( + request: request, + order: order, + filters: filters, + ); + } + @override Stream watchProposalsCount({ required ProposalsCountFilters filters, @@ -325,6 +364,13 @@ final class ProposalRepositoryImpl implements ProposalRepository { return _proposalsLocalSource.watchProposalsCount(filters: filters); } + @override + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + return _proposalsLocalSource.watchProposalsCountV2(filters: filters); + } + @override Stream> watchProposalsPage({ required PageRequest request, @@ -336,6 +382,15 @@ final class ProposalRepositoryImpl implements ProposalRepository { .map((value) => value.map(_buildProposalData)); } + @override + Stream> watchProposalTemplates({ + required CampaignFilters filters, + }) { + return _proposalsLocalSource + .watchProposalTemplates(filters: filters) + .map((event) => event.map(ProposalTemplateFactory.create).toList()); + } + @override Stream> watchUserProposals({ required CatalystId authorId, @@ -402,48 +457,9 @@ final class ProposalRepositoryImpl implements ProposalRepository { required DocumentData documentData, required DocumentData templateData, }) { - assert( - documentData.metadata.type == DocumentType.proposalDocument, - 'Not a proposalDocument document data type', - ); - - final metadata = ProposalMetadata( - selfRef: documentData.metadata.selfRef, - templateRef: documentData.metadata.template!, - categoryId: documentData.metadata.categoryId!, - authors: documentData.metadata.authors ?? [], - ); - - final template = _buildProposalTemplate(documentData: templateData); - final schema = template.schema; - final content = DocumentDataContentDto.fromModel(documentData.content); - final document = DocumentDto.fromJsonSchema(content, schema).toModel(); - - return ProposalDocument( - metadata: metadata, - document: document, - ); - } - - ProposalTemplate _buildProposalTemplate({ - required DocumentData documentData, - }) { - assert( - documentData.metadata.type == DocumentType.proposalTemplate, - 'Not a proposalTemplate document data type', - ); - - final metadata = ProposalTemplateMetadata( - selfRef: documentData.metadata.selfRef, - ); - - final contentData = documentData.content.data; - final schema = DocumentSchemaDto.fromJson(contentData).toModel(); - - return ProposalTemplate( - metadata: metadata, - schema: schema, - ); + final template = ProposalTemplateFactory.create(templateData); + final proposal = ProposalDocumentFactory.create(documentData, template: template); + return proposal; } SignedDocumentMetadata _createProposalMetadata( diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart new file mode 100644 index 000000000000..cf72974beb8a --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_template_factory.dart @@ -0,0 +1,26 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_dto.dart'; + +abstract final class ProposalTemplateFactory { + ProposalTemplateFactory._(); + + static ProposalTemplate create(DocumentData documentData) { + assert( + documentData.metadata.type == DocumentType.proposalTemplate, + 'Not a proposalTemplate document data type', + ); + + final metadata = ProposalTemplateMetadata( + selfRef: documentData.metadata.selfRef, + categoryId: documentData.metadata.categoryId, + ); + + final contentData = documentData.content.data; + final schema = DocumentSchemaDto.fromJson(contentData).toModel(); + + return ProposalTemplate( + metadata: metadata, + schema: schema, + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager.dart index 7c66512c1db8..4fe6bdd3a00d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager.dart @@ -11,7 +11,7 @@ abstract interface class SignedDocumentManager { const factory SignedDocumentManager({ required CatalystCompressor brotli, required CatalystCompressor zstd, - CatalystRuntimeProfiler? profiler, + CatalystProfiler profiler, }) = SignedDocumentManagerImpl; /// Parses the document from the [bytes] representation. diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager_impl.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager_impl.dart index b51fcb158036..2493ba2d0a77 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager_impl.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/signed_document/signed_document_manager_impl.dart @@ -14,18 +14,26 @@ const _brotliEncoding = StringValue(CoseValues.brotliContentEncoding); final class SignedDocumentManagerImpl implements SignedDocumentManager { final CatalystCompressor brotli; final CatalystCompressor zstd; - final CatalystRuntimeProfiler? profiler; + final CatalystProfiler profiler; const SignedDocumentManagerImpl({ required this.brotli, required this.zstd, - this.profiler, + this.profiler = const CatalystNoopProfiler(), }); @override Future parseDocument(Uint8List bytes) async { - final cborValue = await _profileCborDecode(() async => cbor.decode(bytes)); - final coseSign = await _profileCoseParse(() async => CoseSign.fromCbor(cborValue)); + final cborValue = await profiler.timeWithResult( + 'cbor_decode_doc', + () => cbor.decode(bytes), + debounce: true, + ); + final coseSign = await profiler.timeWithResult( + 'cose_decode', + () => CoseSign.fromCbor(cborValue), + debounce: true, + ); final metadata = _SignedDocumentMetadataExt.fromCose( protectedHeaders: coseSign.protectedHeaders, @@ -55,13 +63,17 @@ final class SignedDocumentManagerImpl implements SignedDocumentManager { }) async { final compressedPayload = await _brotliCompressPayload(document.toBytes()); - final coseSign = await _profileCoseSign( - () async => CoseSign.sign( - protectedHeaders: metadata.asCoseProtectedHeaders, - unprotectedHeaders: metadata.asCoseUnprotectedHeaders, - payload: compressedPayload, - signers: [_CatalystSigner(catalystId, privateKey)], - ), + final coseSign = await profiler.timeWithResult( + 'cose_sign_doc', + () { + return CoseSign.sign( + protectedHeaders: metadata.asCoseProtectedHeaders, + unprotectedHeaders: metadata.asCoseUnprotectedHeaders, + payload: compressedPayload, + signers: [_CatalystSigner(catalystId, privateKey)], + ); + }, + debounce: true, ); return _CoseSignedDocument( @@ -73,62 +85,26 @@ final class SignedDocumentManagerImpl implements SignedDocumentManager { } Future _brotliCompressPayload(Uint8List payload) async { - return _profileBrotliCompress(() async { - final compressed = await brotli.compress(payload); - return Uint8List.fromList(compressed); - }); + final compressed = await profiler.timeWithResult( + 'brotli_compress', + () => brotli.compress(payload), + debounce: true, + ); + return Uint8List.fromList(compressed); } Future _brotliDecompressPayload(CoseSign coseSign) async { if (coseSign.protectedHeaders.contentEncoding == _brotliEncoding) { - return _profileBrotliDecompress(() async { - final decompressed = await brotli.decompress(coseSign.payload); - return Uint8List.fromList(decompressed); - }); + final decompressed = await profiler.timeWithResult( + 'brotli_decompress', + () => brotli.decompress(coseSign.payload), + debounce: true, + ); + return Uint8List.fromList(decompressed); } else { return coseSign.payload; } } - - Future _profileBrotliCompress(AsyncValueGetter body) async { - final profiler = this.profiler; - if (profiler != null && profiler.ongoing) { - return profiler.brotliCompress(body: body); - } - return body(); - } - - Future _profileBrotliDecompress(AsyncValueGetter body) async { - final profiler = this.profiler; - if (profiler != null && profiler.ongoing) { - return profiler.brotliDecompress(body: body); - } - return body(); - } - - Future _profileCborDecode(AsyncValueGetter body) async { - final profiler = this.profiler; - if (profiler != null && profiler.ongoing) { - return profiler.cborDecode(body: body); - } - return body(); - } - - Future _profileCoseParse(AsyncValueGetter body) async { - final profiler = this.profiler; - if (profiler != null && profiler.ongoing) { - return profiler.coseParse(name: 'document', body: body); - } - return body(); - } - - Future _profileCoseSign(AsyncValueGetter body) async { - final profiler = this.profiler; - if (profiler != null && profiler.ongoing) { - return profiler.coseSign(name: 'document', body: body); - } - return body(); - } } final class _CatalystSigner implements CatalystCoseSigner { diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml index 75bbf82f12d3..97b86302c205 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: rxdart: ^0.28.0 sentry_flutter: ^9.6.0 shared_preferences: ^2.5.3 + sqlite3: ^2.8.0 synchronized: ^3.4.0 uuid_plus: ^0.1.0 @@ -58,5 +59,4 @@ dev_dependencies: json_serializable: ^6.9.5 mocktail: ^1.0.4 shared_preferences_platform_interface: ^2.4.1 - sqlite3: ^2.8.0 swagger_dart_code_generator: ^3.0.3 diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/fixture/voices_document_templates.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/fixture/voices_document_templates.dart index 424993a066a7..e6e500e81554 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/fixture/voices_document_templates.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/fixture/voices_document_templates.dart @@ -23,8 +23,11 @@ class VoicesDocumentsTemplates { static Future _getDocsRoot() async { var dir = Directory.current; + final blacklisted = ['catalyst_voices']; + while (true) { - final list = dir.listSync(); + final skip = blacklisted.any((path) => dir.path.endsWith(path)); + final list = skip ? [] : dir.listSync(); final docs = list.firstWhereOrNull((e) => e.path.endsWith('/docs')); if (docs != null) { return Directory(docs.path); diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart index 2505c2ac9aaa..329a1af31118 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/connection/test_connection.dart @@ -3,12 +3,21 @@ import 'package:drift/drift.dart'; import '../executor/unsupported.dart' if (dart.library.js_interop) '../executor/web.dart' if (dart.library.ffi) '../executor/native.dart'; +import '../logging_db_interceptor.dart'; -Future buildTestConnection() async { +Future buildTestConnection({ + bool logQueries = false, +}) async { final executor = await buildExecutor(); - return DatabaseConnection( + var connection = DatabaseConnection( executor, closeStreamsSynchronously: true, ); + + if (logQueries) { + connection = connection.interceptWith(LoggingDbInterceptor()); + } + + return connection; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart new file mode 100644 index 000000000000..6fcf37bfc12e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/documents_v2_dao_test.dart @@ -0,0 +1,1077 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/documents_v2_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:uuid_plus/uuid_plus.dart'; + +import '../connection/test_connection.dart'; + +void main() { + late DriftCatalystDatabase db; + late DocumentsV2Dao dao; + + setUp(() async { + final connection = await buildTestConnection(); + db = DriftCatalystDatabase(connection); + dao = db.documentsV2Dao; + }); + + tearDown(() async { + await db.close(); + }); + + group(DocumentsV2Dao, () { + group('count', () { + test('returns zero for empty database', () async { + // Given: An empty database + + // When: count is called + final result = await dao.count(); + + // Then: Returns 0 + expect(result, 0); + }); + + test('returns correct count after inserting new documents', () async { + // Given + final entities = [ + _createTestDocumentEntity(id: 'test-id-1', ver: 'test-ver-1'), + _createTestDocumentEntity(id: 'test-id-2', ver: 'test-ver-2'), + ]; + await dao.saveAll(entities); + + // When + final result = await dao.count(); + + // Then + expect(result, 2); + }); + + test('ignores conflicts and returns accurate count', () async { + // Given + final existing = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(existing); + + final entities = [ + _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'), // Conflict + _createTestDocumentEntity(id: 'new-id', ver: 'new-ver'), // New + ]; + await dao.saveAll(entities); + + // When + final result = await dao.count(); + + // Then + expect(result, 2); + }); + }); + + group('exists', () { + test('returns false for non-existing ref in empty database', () async { + // Given + const ref = SignedDocumentRef.exact(id: 'non-existent-id', version: 'non-existent-ver'); + + // When + final result = await dao.exists(ref); + + // Then + expect(result, isFalse); + }); + + test('returns true for existing exact ref', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.exact(id: 'test-id', version: 'test-ver'); + + // When + final result = await dao.exists(ref); + + // Then + expect(result, isTrue); + }); + + test('returns false for non-existing exact ref', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.exact(id: 'test-id', version: 'wrong-ver'); + + // When + final result = await dao.exists(ref); + + // Then: Returns false (ver mismatch) + expect(result, isFalse); + }); + + test('returns true for loose ref if any version exists', () async { + // Given + final entityV1 = _createTestDocumentEntity(id: 'test-id', ver: 'ver-1'); + final entityV2 = _createTestDocumentEntity(id: 'test-id', ver: 'ver-2'); + await dao.saveAll([entityV1, entityV2]); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.exists(ref); + + // Then + expect(result, isTrue); + }); + + test('returns false for loose ref if no versions exist', () async { + // Given + final entity = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'non-existent-id'); + + // When + final result = await dao.exists(ref); + + // Then + expect(result, isFalse); + }); + + test('handles null version in exact ref (treats as loose)', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.exists(ref); + + // Then: Returns true (any version matches) + expect(result, isTrue); + }); + + test('performs efficiently for large batches (no N+1 queries)', () async { + // Given: 1000 entities inserted (simulate large sync) + final entities = List.generate( + 1000, + (i) => _createTestDocumentEntity(id: 'batch-$i', ver: 'ver-$i'), + ); + await dao.saveAll(entities); + + // And: A ref for an existing id + const ref = SignedDocumentRef.loose(id: 'batch-500'); + + // When: exists is called (should use single query) + final stopwatch = Stopwatch()..start(); + final result = await dao.exists(ref); + stopwatch.stop(); + + // Then: Returns true, and executes quickly (<10ms expected) + expect(result, isTrue); + expect(stopwatch.elapsedMilliseconds, lessThan(10)); + }); + }); + + group('filterExisting', () { + test('returns empty list for empty input', () async { + // Given + final refs = []; + + // When + final result = await dao.filterExisting(refs); + + // Then + expect(result, isEmpty); + }); + + test('returns all refs if they exist (mixed exact and loose)', () async { + // Given + final entity1 = _createTestDocumentEntity(id: 'id-1', ver: 'ver-1'); + final entity2 = _createTestDocumentEntity(id: 'id-2', ver: 'ver-2'); + await dao.saveAll([entity1, entity2]); + + // And + final refs = [ + const SignedDocumentRef.exact(id: 'id-1', version: 'ver-1'), + const SignedDocumentRef.loose(id: 'id-2'), + ]; + + // When + final result = await dao.filterExisting(refs); + + // Then + expect(result.length, 2); + expect(result[0].id, 'id-1'); + expect(result[0].version, 'ver-1'); + expect(result[1].id, 'id-2'); + expect(result[1].version, isNull); + }); + + test('filters out non-existing refs (mixed exact and loose)', () async { + // Given + final entity = _createTestDocumentEntity(id: 'existing-id', ver: 'existing-ver'); + await dao.save(entity); + + // And + final refs = [ + const SignedDocumentRef.exact(id: 'existing-id', version: 'existing-ver'), + const SignedDocumentRef.exact(id: 'non-id', version: 'non-ver'), + const SignedDocumentRef.loose(id: 'existing-id'), + const SignedDocumentRef.loose(id: 'non-id'), + ]; + + // When + final result = await dao.filterExisting(refs); + + // Then + expect(result.length, 2); + expect(result[0].id, 'existing-id'); + expect(result[0].version, 'existing-ver'); + expect(result[1].id, 'existing-id'); + expect(result[1].version, isNull); + }); + + test('handles multiple versions for loose refs', () async { + // Given + final entityV1 = _createTestDocumentEntity(id: 'multi-id', ver: 'ver-1'); + final entityV2 = _createTestDocumentEntity(id: 'multi-id', ver: 'ver-2'); + await dao.saveAll([entityV1, entityV2]); + + // And + final refs = [ + const SignedDocumentRef.loose(id: 'multi-id'), + const SignedDocumentRef.exact(id: 'multi-id', version: 'ver-1'), + const SignedDocumentRef.exact(id: 'multi-id', version: 'wrong-ver'), + ]; + + // When + final result = await dao.filterExisting(refs); + + // Then + expect(result.length, 2); + expect(result[0].version, isNull); + expect(result[1].version, 'ver-1'); + }); + + test('performs efficiently for large lists (single query)', () async { + // Given + final entities = List.generate( + 1000, + (i) => _createTestDocumentEntity(id: 'batch-${i % 500}', ver: 'ver-$i'), + ); + await dao.saveAll(entities); + + // And + final refs = List.generate( + 1000, + (i) => i.isEven + ? SignedDocumentRef.exact(id: 'batch-${i % 500}', version: 'ver-$i') + : SignedDocumentRef.loose(id: 'non-$i'), + ); + + // When + final stopwatch = Stopwatch()..start(); + final result = await dao.filterExisting(refs); + stopwatch.stop(); + + // Then + expect(result.length, 500); + expect(stopwatch.elapsedMilliseconds, lessThan(100)); + }); + }); + + group('getDocument', () { + test('returns null for non-existing ref in empty database', () async { + // Given + const ref = SignedDocumentRef.exact(id: 'non-existent-id', version: 'non-existent-ver'); + + // When + final result = await dao.getDocument(ref); + + // Then + expect(result, isNull); + }); + + test('returns entity for existing exact ref', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.exact(id: 'test-id', version: 'test-ver'); + + // When + final result = await dao.getDocument(ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.ver, 'test-ver'); + }); + + test('returns null for non-existing exact ref', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.exact(id: 'test-id', version: 'wrong-ver'); + + // When: getDocument is called + final result = await dao.getDocument(ref); + + // Then: Returns null + expect(result, isNull); + }); + + test('returns latest entity for loose ref if versions exist', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 2, 2); + final newerCreatedAt = DateTime.utc(2024, 2, 2); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getDocument(ref); + + // Then + expect(result, isNotNull); + expect(result!.ver, newerVer); + expect(result.createdAt, newerCreatedAt); + }); + + test('returns null for loose ref if no versions exist', () async { + // Given + final entity = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'non-existent-id'); + + // When + final result = await dao.getDocument(ref); + + // Then + expect(result, isNull); + }); + }); + + group('saveAll', () { + test('does nothing for empty list', () async { + // Given + final entities = []; + + // When + await dao.saveAll(entities); + + // Then + final count = await dao.count(); + expect(count, 0); + }); + + test('inserts new documents', () async { + // Given + final entities = [ + _createTestDocumentEntity(), + _createTestDocumentEntity(), + ]; + + // When + await dao.saveAll(entities); + + // Then + final saved = await db.select(db.documentsV2).get(); + final savedIds = saved.map((e) => e.id); + final expectedIds = entities.map((e) => e.doc.id); + + expect(savedIds, expectedIds); + }); + + test('ignores conflicts on existing {id, ver}', () async { + // Given + final existing = _createTestDocumentEntity( + id: 'test-id', + ver: 'test-ver', + contentData: {'key': 'original'}, + ); + await dao.save(existing); + + // And + final entities = [ + _createTestDocumentEntity( + id: 'test-id', + ver: 'test-ver', + contentData: {'key': 'modified'}, + ), + _createTestDocumentEntity(id: 'new-id', ver: 'new-ver'), + ]; + + // When + await dao.saveAll(entities); + + // Then + final saved = await db.select(db.documentsV2).get(); + expect(saved.length, 2); + final existingAfter = saved.firstWhere((e) => e.id == 'test-id'); + expect(existingAfter.content.data['key'], 'original'); + expect(saved.any((e) => e.id == 'new-id'), true); + }); + + test('handles mixed inserts and ignores atomically', () async { + // Given + final existing1 = _createTestDocumentEntity(id: 'existing-1', ver: 'ver-1'); + final existing2 = _createTestDocumentEntity(id: 'existing-2', ver: 'ver-2'); + await dao.save(existing1); + await dao.save(existing2); + + // And: + final entities = [ + _createTestDocumentEntity(id: 'existing-1', ver: 'ver-1'), + _createTestDocumentEntity(id: 'new-1', ver: 'new-ver-1'), + _createTestDocumentEntity(id: 'existing-2', ver: 'ver-2'), + _createTestDocumentEntity(id: 'new-2', ver: 'new-ver-2'), + ]; + + // When + await dao.saveAll(entities); + + // Then + final saved = await db.select(db.documentsV2).get(); + expect(saved.length, 4); + expect(saved.map((e) => e.id).toSet(), {'existing-1', 'existing-2', 'new-1', 'new-2'}); + }); + }); + + group('save', () { + test('inserts new document', () async { + // Given + final entity = _createTestDocumentEntity( + id: 'test-id', + ver: '0194d492-1daa-7371-8bd3-c15811b2b063', + ); + + // When + await dao.save(entity); + + // Then + final saved = await db.select(db.documentsV2).get(); + expect(saved.length, 1); + expect(saved[0].id, 'test-id'); + expect(saved[0].ver, '0194d492-1daa-7371-8bd3-c15811b2b063'); + }); + + test('ignores conflict on existing {id, ver}', () async { + // Given + final existing = _createTestDocumentEntity( + id: 'test-id', + ver: '0194d492-1daa-7371-8bd3-c15811b2b063', + contentData: {'key': 'original'}, + ); + await dao.save(existing); + + // And + final conflicting = _createTestDocumentEntity( + id: 'test-id', + ver: '0194d492-1daa-7371-8bd3-c15811b2b063', + contentData: {'key': 'modified'}, + ); + + // When + await dao.save(conflicting); + + // Then + final saved = await db.select(db.documentsV2).get(); + expect(saved.length, 1); + expect(saved[0].content.data['key'], 'original'); + }); + }); + + group('watchDocuments', () { + test('emits all documents when no filters applied', () async { + final doc1 = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final doc2 = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalTemplate); + final doc3 = _createTestDocumentEntity( + id: 'id3', + type: DocumentType.proposalActionDocument, + ); + + await dao.saveAll([doc1, doc2, doc3]); + + final stream = dao.watchDocuments(); + + await expectLater( + stream, + emits(hasLength(3)), + ); + }); + + test('filters documents by type', () async { + final proposal1 = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final proposal2 = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalDocument); + final template = _createTestDocumentEntity(id: 'id3', type: DocumentType.proposalTemplate); + + await dao.saveAll([proposal1, proposal2, template]); + + final stream = dao.watchDocuments(type: DocumentType.proposalDocument); + + await expectLater( + stream, + emits( + predicate>((docs) { + return docs.length == 2 && docs.every((d) => d.type == DocumentType.proposalDocument); + }), + ), + ); + }); + + test('respects limit parameter', () async { + final docs = List.generate( + 10, + (i) => _createTestDocumentEntity(id: 'id$i', type: DocumentType.proposalDocument), + ); + + await dao.saveAll(docs); + + final stream = dao.watchDocuments(limit: 5); + + await expectLater( + stream, + emits(hasLength(5)), + ); + }); + + test('respects offset parameter', () async { + final docs = List.generate( + 10, + (i) => _createTestDocumentEntity(id: 'id$i', type: DocumentType.proposalDocument), + ); + + await dao.saveAll(docs); + + final streamFirst = dao.watchDocuments(limit: 5, offset: 0); + final streamSecond = dao.watchDocuments(limit: 5, offset: 5); + + final firstBatch = await streamFirst.first; + final secondBatch = await streamSecond.first; + + expect(firstBatch.length, 5); + expect(secondBatch.length, 5); + expect( + firstBatch + .map((d) => d.id) + .toSet() + .intersection( + secondBatch.map((d) => d.id).toSet(), + ), + isEmpty, + ); + }); + + test('clamps limit to 999 when exceeds maximum', () async { + final docs = List.generate( + 1000, + (i) => _createTestDocumentEntity(id: 'id$i', type: DocumentType.proposalDocument), + ); + + await dao.saveAll(docs); + + final stream = dao.watchDocuments(limit: 1500); + + await expectLater( + stream, + emits(hasLength(999)), + ); + }); + + test('handles limit of 0', () async { + final doc = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + + await dao.save(doc); + + final stream = dao.watchDocuments(limit: 0); + + await expectLater( + stream, + emits(isEmpty), + ); + }); + + test('emits empty list when no documents exist', () async { + final stream = dao.watchDocuments(); + + await expectLater( + stream, + emits(isEmpty), + ); + }); + + test('emits empty list when type filter matches nothing', () async { + final doc = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + + await dao.save(doc); + + final stream = dao.watchDocuments(type: DocumentType.proposalTemplate); + + await expectLater( + stream, + emits(isEmpty), + ); + }); + + test('emits new values when documents are added', () async { + final stream = dao.watchDocuments(); + + final doc1 = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final doc2 = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalDocument); + + final expectation = expectLater( + stream, + emitsInOrder([ + isEmpty, + hasLength(1), + hasLength(2), + ]), + ); + + await pumpEventQueue(); + await dao.save(doc1); + await pumpEventQueue(); + await dao.save(doc2); + await pumpEventQueue(); + + await expectation; + }); + + test('does not emit duplicate when same document saved twice', () async { + final doc = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + + await dao.save(doc); + + final stream = dao.watchDocuments(); + + final expectation = expectLater( + stream, + emitsInOrder([ + hasLength(1), + hasLength(1), + ]), + ); + + await pumpEventQueue(); + await dao.save(doc); + await pumpEventQueue(); + + await expectation; + }); + + test('combines type filter with limit and offset', () async { + final proposals = List.generate( + 20, + (i) => _createTestDocumentEntity(id: 'proposal$i', type: DocumentType.proposalDocument), + ); + + final templates = List.generate( + 10, + (i) => _createTestDocumentEntity(id: 'template$i', type: DocumentType.proposalTemplate), + ); + + await dao.saveAll([...proposals, ...templates]); + + final stream = dao.watchDocuments( + type: DocumentType.proposalDocument, + limit: 5, + offset: 10, + ); + + await expectLater( + stream, + emits( + predicate>((docs) { + return docs.length == 5 && docs.every((d) => d.type == DocumentType.proposalDocument); + }), + ), + ); + }); + + test('emits updates when filtered documents change', () async { + final proposal = _createTestDocumentEntity(id: 'id1', type: DocumentType.proposalDocument); + final template = _createTestDocumentEntity(id: 'id2', type: DocumentType.proposalTemplate); + + final stream = dao.watchDocuments(type: DocumentType.proposalDocument); + + final expectation = expectLater( + stream, + emitsInOrder([ + isEmpty, + hasLength(1), + hasLength(1), + ]), + ); + + await pumpEventQueue(); + await dao.save(proposal); + await pumpEventQueue(); + await dao.save(template); + await pumpEventQueue(); + + await expectation; + }); + + test('returns all versions when latestOnly is false', () async { + final v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + final v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ); + final v3 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 3)), + ); + + await dao.saveAll([v1, v2, v3]); + + final stream = dao.watchDocuments(latestOnly: false); + + await expectLater( + stream, + emits(hasLength(3)), + ); + }); + + test('returns only latest version of each document when latestOnly is true', () async { + final doc1v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + final doc1v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ); + final doc2v1 = _createTestDocumentEntity( + id: 'id2', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + + await dao.saveAll([doc1v1, doc1v2, doc2v1]); + + final stream = dao.watchDocuments(latestOnly: true); + + final result = await stream.first; + + expect(result.length, 2); + expect(result.any((d) => d.id == 'id1' && d.ver == doc1v1.doc.ver), isFalse); + expect(result.any((d) => d.id == 'id1' && d.ver == doc1v2.doc.ver), isTrue); + expect(result.any((d) => d.id == 'id2' && d.ver == doc2v1.doc.ver), isTrue); + }); + + test('combines latestOnly with type filter', () async { + final proposal1v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + type: DocumentType.proposalDocument, + ); + final proposal1v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + type: DocumentType.proposalDocument, + ); + final template1v1 = _createTestDocumentEntity( + id: 'id2', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + type: DocumentType.proposalTemplate, + ); + final template1v2 = _createTestDocumentEntity( + id: 'id2', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + type: DocumentType.proposalTemplate, + ); + + await dao.saveAll([proposal1v1, proposal1v2, template1v1, template1v2]); + + final stream = dao.watchDocuments( + type: DocumentType.proposalDocument, + latestOnly: true, + ); + + final result = await stream.first; + + expect(result.length, 1); + expect(result.first.id, 'id1'); + expect(result.first.ver, proposal1v2.doc.ver); + expect(result.first.type, DocumentType.proposalDocument); + }); + + test('combines latestOnly with limit and offset', () async { + final docs = []; + for (var i = 0; i < 10; i++) { + docs + ..add( + _createTestDocumentEntity( + id: 'id$i', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ), + ) + ..add( + _createTestDocumentEntity( + id: 'id$i', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ), + ); + } + + await dao.saveAll(docs); + + final stream = dao.watchDocuments( + latestOnly: true, + limit: 5, + offset: 3, + ); + + final result = await stream.first; + + expect(result.length, 5); + expect( + result.every((d) => d.createdAt.isAtSameMomentAs(DateTime.utc(2024, 1, 2))), + isTrue, + ); + }); + + test('emits updates when new version added with latestOnly', () async { + final doc1v1 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 1)), + ); + final doc1v2 = _createTestDocumentEntity( + id: 'id1', + ver: _buildUuidV7At(DateTime.utc(2024, 1, 2)), + ); + + final stream = dao.watchDocuments(latestOnly: true); + + final expectation = expectLater( + stream, + emitsInOrder([ + isEmpty, + predicate>( + (docs) => docs.length == 1 && docs.first.ver == doc1v1.doc.ver, + ), + predicate>( + (docs) => docs.length == 1 && docs.first.ver == doc1v2.doc.ver, + ), + ]), + ); + + await pumpEventQueue(); + await dao.save(doc1v1); + await pumpEventQueue(); + await dao.save(doc1v2); + await pumpEventQueue(); + + await expectation; + }); + }); + + group('getLatestOf', () { + test('returns null for non-existing id in empty database', () async { + // Given + const ref = SignedDocumentRef.exact(id: 'non-existent-id', version: 'non-existent-ver'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNull); + }); + + test('returns the document ref when only one version exists', () async { + // Given + final entity = _createTestDocumentEntity(id: 'test-id', ver: 'test-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.version, 'test-ver'); + expect(result.isExact, isTrue); + }); + + test('returns latest version when multiple versions exist (loose ref input)', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 6, 15); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And + const ref = SignedDocumentRef.loose(id: 'test-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.version, newerVer); + }); + + test('returns latest version even when exact ref points to older version', () async { + // Given + final oldCreatedAt = DateTime.utc(2023, 1, 1); + final newerCreatedAt = DateTime.utc(2024, 6, 15); + + final oldVer = _buildUuidV7At(oldCreatedAt); + final newerVer = _buildUuidV7At(newerCreatedAt); + final entityOld = _createTestDocumentEntity(id: 'test-id', ver: oldVer); + final entityNew = _createTestDocumentEntity(id: 'test-id', ver: newerVer); + await dao.saveAll([entityOld, entityNew]); + + // And: exact ref pointing to older version + final ref = SignedDocumentRef.exact(id: 'test-id', version: oldVer); + + // When + final result = await dao.getLatestOf(ref); + + // Then: still returns the latest version + expect(result, isNotNull); + expect(result!.id, 'test-id'); + expect(result.version, newerVer); + }); + + test('returns null for non-existing id when other documents exist', () async { + // Given + final entity = _createTestDocumentEntity(id: 'other-id', ver: 'other-ver'); + await dao.save(entity); + + // And + const ref = SignedDocumentRef.loose(id: 'non-existent-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then + expect(result, isNull); + }); + + test('returns latest among many versions', () async { + // Given + final dates = [ + DateTime.utc(2023, 1, 1), + DateTime.utc(2023, 6, 15), + DateTime.utc(2024, 3, 10), + DateTime.utc(2024, 12, 25), + DateTime.utc(2024, 8, 1), + ]; + final versions = dates.map(_buildUuidV7At).toList(); + final entities = versions + .map((ver) => _createTestDocumentEntity(id: 'multi-ver-id', ver: ver)) + .toList(); + await dao.saveAll(entities); + + // And + const ref = SignedDocumentRef.loose(id: 'multi-ver-id'); + + // When + final result = await dao.getLatestOf(ref); + + // Then: returns the version with latest createdAt (2024-12-25) + expect(result, isNotNull); + expect(result!.version, versions[3]); + }); + }); + }); +} + +String _buildUuidV7At(DateTime dateTime) { + final ts = dateTime.millisecondsSinceEpoch; + final rand = Uint8List.fromList([42, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + return const UuidV7().generate(options: V7Options(ts, rand)); +} + +DocumentWithAuthorsEntity _createTestDocumentEntity({ + String? id, + String? ver, + Map contentData = const {}, + DocumentType type = DocumentType.proposalDocument, + String? authors, + String? categoryId, + String? categoryVer, + String? refId, + String? refVer, + String? replyId, + String? replyVer, + String? section, + String? templateId, + String? templateVer, +}) { + id ??= DocumentRefFactory.randomUuidV7(); + ver ??= id; + authors ??= ''; + + final docEntity = DocumentEntityV2( + id: id, + ver: ver, + content: DocumentDataContent(contentData), + createdAt: ver.tryDateTime ?? DateTime.timestamp(), + type: type, + authors: authors, + categoryId: categoryId, + categoryVer: categoryVer, + refId: refId, + refVer: refVer, + replyId: replyId, + replyVer: replyVer, + section: section, + templateId: templateId, + templateVer: templateVer, + ); + + final authorsEntities = authors + .split(',') + .where((element) => element.trim().isNotEmpty) + .map(CatalystId.tryParse) + .nonNulls + .map( + (e) => DocumentAuthorEntity( + documentId: docEntity.id, + documentVer: docEntity.ver, + authorId: e.toUri().toString(), + authorIdSignificant: e.toSignificant().toUri().toString(), + authorUsername: e.username, + ), + ) + .toList(); + + return DocumentWithAuthorsEntity(docEntity, authorsEntities); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart new file mode 100644 index 000000000000..71a07956a167 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart @@ -0,0 +1,5083 @@ +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_dynamic_calls + +import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/dao/proposals_v2_dao.dart'; +import 'package:catalyst_voices_repositories/src/database/model/document_with_authors_entity.dart'; +import 'package:catalyst_voices_repositories/src/database/table/document_authors.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_local_metadata.drift.dart'; +import 'package:catalyst_voices_repositories/src/database/table/documents_v2.drift.dart'; +import 'package:catalyst_voices_repositories/src/dto/proposal/proposal_submission_action_dto.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:uuid_plus/uuid_plus.dart'; + +import '../connection/test_connection.dart'; + +void main() { + late DriftCatalystDatabase db; + late ProposalsV2Dao dao; + + setUp(() async { + final connection = await buildTestConnection(); + db = DriftCatalystDatabase(connection); + dao = db.proposalsV2Dao; + }); + + tearDown(() async { + await db.close(); + }); + + group(ProposalsV2Dao, () { + group('getVisibleProposalsCount', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('returns 0 for empty database', () async { + final result = await dao.getVisibleProposalsCount(); + + expect(result, 0); + }); + + test('returns correct count for proposals without actions', () async { + final entities = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'p-$i', + ver: _buildUuidV7At(earliest.add(Duration(hours: i))), + ), + ); + await db.documentsV2Dao.saveAll(entities); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 5); + }); + + test('counts only latest version of proposals with multiple versions', () async { + final entityOldV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + ); + final entityNewV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + final entityOldV2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(earliest), + ); + final entityNewV2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + ); + await db.documentsV2Dao.saveAll([entityOldV1, entityNewV1, entityOldV2, entityNewV2]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 2); + }); + + test('excludes hidden proposals from count', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + ); + + final hideAction = _createTestDocumentEntity( + id: 'action-hide', + ver: _buildUuidV7At(earliest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, hideAction]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 1); + }); + + test('excludes all versions when latest action is hide', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + + final proposal2V1 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(earliest), + ); + final proposal2V2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + ); + final proposal2V3 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(latest), + ); + + final hideAction = _createTestDocumentEntity( + id: 'action-hide', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2V1, + proposal2V2, + proposal2V3, + hideAction, + ]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 1); + }); + + test('counts only proposals matching category filter', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2(categoryId: 'cat-1'), + ); + + expect(result, 1); + }); + + test('respects campaign categories filter', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + ), + ); + + expect(result, 2); + }); + + test('returns 0 for empty campaign categories', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + await db.documentsV2Dao.saveAll([proposal1]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {}), + ), + ); + + expect(result, 0); + }); + + test('returns 0 when categoryId not in campaign categories', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + await db.documentsV2Dao.saveAll([proposal1]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-2', 'cat-3'}), + categoryId: 'cat-1', + ), + ); + + expect(result, 0); + }); + + test('respects status filter for draft proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-p', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-p', + ver: finalProposalVer, + ); + + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(earliest), + type: DocumentType.proposalActionDocument, + refId: 'final-p', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.draft), + ); + + expect(result, 1); + }); + + test('respects status filter for final proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-p', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-p', + ver: finalProposalVer, + ); + + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(earliest), + type: DocumentType.proposalActionDocument, + refId: 'final-p', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.aFinal), + ); + + expect(result, 1); + }); + + test('counts proposals with authors filter', () async { + final author1 = _createTestAuthor(name: 'author1'); + final author2 = _createTestAuthor(name: 'author2', role0KeySeed: 1); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: author1.toString(), + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: author2.toString(), + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + authors: author1.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + final result = await dao.getVisibleProposalsCount( + filters: ProposalsFiltersV2(author: author1), + ); + + expect(result, 2); + }); + + test('ignores non-proposal documents in count', () async { + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + + final comment = _createTestDocumentEntity( + id: 'c1', + ver: _buildUuidV7At(latest), + type: DocumentType.commentDocument, + ); + + final template = _createTestDocumentEntity( + id: 't1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalTemplate, + ); + + await db.documentsV2Dao.saveAll([proposal, comment, template]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 1); + }); + }); + + group('updateProposalFavorite', () { + test('marks proposal as favorite when isFavorite is true', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + + final metadata = await (db.select( + db.documentsLocalMetadata, + )..where((tbl) => tbl.id.equals('p1'))).getSingleOrNull(); + + expect(metadata, isNotNull); + expect(metadata!.id, 'p1'); + expect(metadata.isFavorite, true); + }); + + test('removes favorite status when isFavorite is false', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + + await dao.updateProposalFavorite(id: 'p1', isFavorite: false); + + final metadata = await (db.select( + db.documentsLocalMetadata, + )..where((tbl) => tbl.id.equals('p1'))).getSingleOrNull(); + + expect(metadata, isNull); + }); + + test('does nothing when removing non-existent favorite', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: false); + + final metadata = await (db.select( + db.documentsLocalMetadata, + )..where((tbl) => tbl.id.equals('p1'))).getSingleOrNull(); + + expect(metadata, isNull); + }); + + test('can mark multiple proposals as favorites', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + await dao.updateProposalFavorite(id: 'p2', isFavorite: true); + await dao.updateProposalFavorite(id: 'p3', isFavorite: true); + + final favorites = await db.select(db.documentsLocalMetadata).get(); + + expect(favorites.length, 3); + expect(favorites.map((e) => e.id).toSet(), {'p1', 'p2', 'p3'}); + }); + }); + + group('getProposalsBriefPage', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('returns empty page for empty database', () async { + // Given + const request = PageRequest(page: 0, size: 10); + + // When + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items, isEmpty); + expect(result.total, 0); + expect(result.page, 0); + expect(result.maxPerPage, 10); + }); + + test('returns paginated latest proposals', () async { + // Given + final entity1 = _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + ); + final entity2 = _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(latest), + ); + final entity3 = _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(middle), + ); + await db.documentsV2Dao.saveAll([entity1, entity2, entity3]); + + // And + const request = PageRequest(page: 0, size: 2); + + // When + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 2); + expect(result.total, 3); + expect(result.items[0].proposal.id, 'id-2'); + expect(result.items[1].proposal.id, 'id-3'); + }); + + test('returns partial page for out-of-bounds request', () async { + // Given + final entities = List.generate( + 3, + (i) { + final ts = earliest.add(Duration(milliseconds: i * 100)); + return _createTestDocumentEntity( + id: 'id-$i', + ver: _buildUuidV7At(ts), + ); + }, + ); + await db.documentsV2Dao.saveAll(entities); + + // And: A request for page beyond total (e.g., page 1, size 2 -> last 1) + const request = PageRequest(page: 1, size: 2); + + // When + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Returns remaining items (1), total unchanged + expect(result.items.length, 1); + expect(result.total, 3); + expect(result.page, 1); + expect(result.maxPerPage, 2); + }); + + test('returns latest version per id with multiple versions', () async { + // Given + final entityOld = _createTestDocumentEntity( + id: 'multi-id', + ver: _buildUuidV7At(earliest), + contentData: {'title': 'old'}, + ); + final entityNew = _createTestDocumentEntity( + id: 'multi-id', + ver: _buildUuidV7At(latest), + contentData: {'title': 'new'}, + ); + final otherEntity = _createTestDocumentEntity( + id: 'other-id', + ver: _buildUuidV7At(middle), + ); + await db.documentsV2Dao.saveAll([entityOld, entityNew, otherEntity]); + + // And + const request = PageRequest(page: 0, size: 10); + + // When + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 2); + expect(result.total, 2); + expect(result.items[0].proposal.id, 'multi-id'); + expect(result.items[0].proposal.ver, _buildUuidV7At(latest)); + expect(result.items[0].proposal.content.data['title'], 'new'); + expect(result.items[1].proposal.id, 'other-id'); + }); + + test('ignores non-proposal documents in count and items', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'proposal-id', + ver: _buildUuidV7At(latest), + ); + final other = _createTestDocumentEntity( + id: 'other-id', + ver: _buildUuidV7At(earliest), + type: DocumentType.commentDocument, + ); + await db.documentsV2Dao.saveAll([proposal, other]); + + // And + const request = PageRequest(page: 0, size: 10); + + // When + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.type, DocumentType.proposalDocument); + }); + + test('excludes hidden proposals based on latest action', () async { + // Given + final proposal1Ver = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final actionOldVer = _buildUuidV7At(middle); + final actionOld = _createTestDocumentEntity( + id: 'action-old', + ver: actionOldVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + final actionHideVer = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final actionHide = _createTestDocumentEntity( + id: 'action-hide', + ver: actionHideVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, actionOld, actionHide]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Only visible (p1); total=1. + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('excludes hidden proposals, even later versions, based on latest action', () async { + // Given + final proposal1Ver = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final proposal3Ver = _buildUuidV7At(latest.add(const Duration(days: 1))); + final proposal3 = _createTestDocumentEntity(id: 'p2', ver: proposal3Ver); + + final actionOldVer = _buildUuidV7At(middle); + final actionOld = _createTestDocumentEntity( + id: 'action-old', + ver: actionOldVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + final actionHideVer = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final actionHide = _createTestDocumentEntity( + id: 'action-hide', + ver: actionHideVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3, actionOld, actionHide]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Only visible (p1); total=1. + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('latest, non hide, action, overrides previous hide', () async { + // Given + final proposal1Ver = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final proposal3Ver = _buildUuidV7At(latest.add(const Duration(days: 1))); + final proposal3 = _createTestDocumentEntity(id: 'p2', ver: proposal3Ver); + + final actionOldHideVer = _buildUuidV7At(middle); + final actionOldHide = _createTestDocumentEntity( + id: 'action-hide', + ver: actionOldHideVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + final actionDraftVer = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final actionDraft = _createTestDocumentEntity( + id: 'action-draft', + ver: actionDraftVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + replyVer: proposal3Ver, + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + proposal3, + actionOldHide, + actionDraft, + ]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: total=2, both are visible + expect(result.items.length, 2); + expect(result.total, 2); + expect(result.items[0].proposal.id, 'p2'); + expect(result.items[1].proposal.id, 'p1'); + }); + + test( + 'excludes hidden proposals based on latest version only, ' + 'fails without latestProposalSubquery join', + () async { + // Given: Multiple versions for one proposal, with hide action on latest version only. + final earliest = DateTime(2025, 2, 5, 5, 23, 27); + final middle = DateTime(2025, 2, 5, 5, 25, 33); + final latest = DateTime(2025, 8, 11, 11, 20, 18); + + // Proposal A: Old version (visible, no hide action for this ver). + final proposalAOldVer = _buildUuidV7At(earliest); + final proposalAOld = _createTestDocumentEntity( + id: 'proposal-a', + ver: proposalAOldVer, + ); + + // Proposal A: Latest version (hidden, with hide action for this ver). + final proposalALatestVer = _buildUuidV7At(latest); + final proposalALatest = _createTestDocumentEntity( + id: 'proposal-a', + ver: proposalALatestVer, + ); + + // Hide action for latest version only (refVer = latestVer, ver after latest proposal). + final actionHideVer = _buildUuidV7At(latest.add(const Duration(seconds: 1))); + final actionHide = _createTestDocumentEntity( + id: 'action-hide', + ver: actionHideVer, + type: DocumentType.proposalActionDocument, + refId: 'proposal-a', + refVer: proposalALatestVer, + // Specific to latest ver. + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + // Proposal B: Single version, visible (no action). + final proposalBVer = _buildUuidV7At(middle); + final proposalB = _createTestDocumentEntity( + id: 'proposal-b', + ver: proposalBVer, + ); + + await db.documentsV2Dao.saveAll([proposalAOld, proposalALatest, actionHide, proposalB]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: With join, latest A is hidden → exclude A, total =1 (B only), items =1 (B). + expect(result.total, 1); + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'proposal-b'); + }, + ); + + test('returns specific version when final action points to ref_ver', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old version'}, + ); + + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new version'}, + ); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final actionFinalVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final actionFinal = _createTestDocumentEntity( + id: 'action-final', + ver: actionFinalVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1OldVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New, proposal2, actionFinal]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 2); + expect(result.total, 2); + final p1Result = result.items.firstWhere((item) => item.proposal.id == 'p1'); + expect(p1Result.proposal.ver, proposal1OldVer); + expect(p1Result.proposal.content.data['title'], 'old version'); + }); + + test('returns latest version when final action has no ref_ver', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old version'}, + ); + + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new version'}, + ); + + final actionFinalVer = _buildUuidV7At(latest); + final actionFinal = _createTestDocumentEntity( + id: 'action-final', + ver: actionFinalVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: null, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New, actionFinal]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + expect(result.items[0].proposal.content.data['title'], 'new version'); + }); + + test('draft action shows latest version of proposal', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old version'}, + ); + + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new version'}, + ); + + final actionDraftVer = _buildUuidV7At(latest); + final actionDraft = _createTestDocumentEntity( + id: 'action-draft', + ver: actionDraftVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1OldVer, + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New, actionDraft]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + expect(result.items[0].proposal.content.data['title'], 'new version'); + }); + + test('final action with ref_ver overrides later proposal versions', () async { + // Given + final proposal1Ver1 = _buildUuidV7At(earliest); + final proposal1V1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver1, + contentData: {'version': 1}, + ); + + final proposal1Ver2 = _buildUuidV7At(middle); + final proposal1V2 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver2, + contentData: {'version': 2}, + ); + + final proposal1Ver3 = _buildUuidV7At(latest); + final proposal1V3 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver3, + contentData: {'version': 3}, + ); + + final actionFinalVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final actionFinal = _createTestDocumentEntity( + id: 'action-final', + ver: actionFinalVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver2, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1V1, proposal1V2, proposal1V3, actionFinal]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.ver, proposal1Ver2); + expect(result.items[0].proposal.content.data['version'], 2); + }); + + group('NOT IN with NULL values', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('action with NULL ref_id does not break query', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + await db.documentsV2Dao.saveAll([proposal]); + + // And: Action with NULL ref_id + final actionVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final actionNullRef = _createTestDocumentEntity( + id: 'action-null-ref', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: null, + contentData: {'action': 'hide'}, + ); + await db.documentsV2Dao.saveAll([actionNullRef]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should still return the proposal (NOT IN with NULL should not fail) + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + expect(result.total, 1); + }); + + test('multiple proposals with NULL ref_id actions return all visible proposals', () async { + // Given + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + ); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(latest), + ); + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + // And: Multiple actions with NULL ref_id + final actions = []; + for (var i = 0; i < 3; i++) { + final actionVer = _buildUuidV7At(latest.add(Duration(hours: i))); + actions.add( + _createTestDocumentEntity( + id: 'action-null-$i', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: null, + contentData: {'action': 'hide'}, + ), + ); + } + await db.documentsV2Dao.saveAll(actions); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then + expect(result.items.length, 2); + expect(result.total, 2); + }); + }); + + group('JSON extraction NULL safety', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('action with malformed JSON does not crash query', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + // Action with malformed JSON + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-malformed', + ver: actionVer, + refId: 'p1', + type: DocumentType.proposalActionDocument, + contentData: {'wrong': true}, + ); + + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New, action]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should treat as draft and return latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + expect(result.items[0].proposal.content.data['title'], 'new'); + }); + + test('action without action field treats as draft', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Action without 'action' field + final actionVer = _buildUuidV7At(latest); + final actionNoField = _createTestDocumentEntity( + id: 'action-no-field', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'status': 'pending'}, + ); + await db.documentsV2Dao.saveAll([actionNoField]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should treat as draft and return latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + + test('action with null action value treats as draft', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Action with null value + final actionVer = _buildUuidV7At(latest); + final actionNullValue = _createTestDocumentEntity( + id: 'action-null-value', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'action': null}, + ); + await db.documentsV2Dao.saveAll([actionNullValue]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should treat as draft and return latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + + test('action with empty string action treats as draft', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Action with empty string + final actionVer = _buildUuidV7At(latest); + final actionEmpty = _createTestDocumentEntity( + id: 'action-empty', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'action': ''}, + ); + await db.documentsV2Dao.saveAll([actionEmpty]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should treat as draft and return latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + + test('action with wrong type (number) handles gracefully', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Action with number instead of string + final actionVer = _buildUuidV7At(latest); + final actionNumber = _createTestDocumentEntity( + id: 'action-number', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'action': 42}, + ); + await db.documentsV2Dao.saveAll([actionNumber]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should handle gracefully and return latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + + test('action with boolean value handles gracefully', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Action with boolean value + final actionVer = _buildUuidV7At(latest); + final actionBool = _createTestDocumentEntity( + id: 'action-bool', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'action': true}, + ); + await db.documentsV2Dao.saveAll([actionBool]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should handle gracefully and return latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + + test('action with nested JSON structure extracts correctly', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + ); + await db.documentsV2Dao.saveAll([proposal]); + + // And: Action with nested structure (should extract $.action, not nested value) + final actionVer = _buildUuidV7At(latest); + final actionNested = _createTestDocumentEntity( + id: 'action-nested', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: { + 'metadata': {'action': 'ignore'}, + 'action': 'hide', + }, + ); + await db.documentsV2Dao.saveAll([actionNested]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should be hidden based on top-level action field + expect(result.items.length, 0); + expect(result.total, 0); + }); + }); + + group('Ordering by createdAt vs UUID string', () { + test('proposals ordered by createdAt not ver string', () async { + // Given: Three proposals with specific createdAt times + final time1 = DateTime.utc(2025, 1, 1, 10, 0, 0); + final time2 = DateTime.utc(2025, 6, 15, 14, 30, 0); + final time3 = DateTime.utc(2025, 12, 31, 23, 59, 59); + + final ver1 = _buildUuidV7At(time1); + final ver2 = _buildUuidV7At(time2); + final ver3 = _buildUuidV7At(time3); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + contentData: {'order': 'oldest'}, + ); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: ver2, + contentData: {'order': 'middle'}, + ); + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: ver3, + contentData: {'order': 'newest'}, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should be ordered newest first by createdAt + expect(result.items.length, 3); + expect(result.items[0].proposal.content.data['order'], 'newest'); + expect(result.items[1].proposal.content.data['order'], 'middle'); + expect(result.items[2].proposal.content.data['order'], 'oldest'); + }); + + test('proposals with manually set createdAt respect createdAt not ver', () async { + // Given: Non-UUIDv7 versions with explicit createdAt + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: '00000000-0000-0000-0000-000000000001', + createdAt: DateTime.utc(2025, 1, 1), + contentData: {'when': 'second'}, + ); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: '00000000-0000-0000-0000-000000000002', + createdAt: DateTime.utc(2025, 12, 31), + contentData: {'when': 'first'}, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should order by createdAt (Dec 31 first), not ver string + expect(result.items.length, 2); + expect(result.items[0].proposal.content.data['when'], 'first'); + expect(result.items[1].proposal.content.data['when'], 'second'); + }); + }); + + group('Count consistency', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('count matches items in complex scenario', () async { + // Given: Multiple proposals with various actions + final proposal1Ver = _buildUuidV7At(earliest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final proposal3Ver = _buildUuidV7At(latest); + final proposal3 = _createTestDocumentEntity(id: 'p3', ver: proposal3Ver); + + final actionHideVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final actionHide = _createTestDocumentEntity( + id: 'action-hide', + ver: actionHideVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'action': 'hide'}, + ); + + final actionFinalVer = _buildUuidV7At(latest.add(const Duration(hours: 2))); + final actionFinal = _createTestDocumentEntity( + id: 'action-final', + ver: actionFinalVer, + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: {'action': 'final'}, + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + proposal3, + actionHide, + actionFinal, + ]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Count should match visible items (p2 final, p3 draft) + expect(result.items.length, 2); + expect(result.total, 2); + }); + + test('count remains consistent across pagination', () async { + // Given: 25 proposals + final proposals = []; + for (var i = 0; i < 25; i++) { + final time = DateTime.utc(2025, 1, 1).add(Duration(hours: i)); + final ver = _buildUuidV7At(time); + proposals.add( + _createTestDocumentEntity( + id: 'p$i', + ver: ver, + contentData: {'index': i}, + ), + ); + } + await db.documentsV2Dao.saveAll(proposals); + + // When: Query multiple pages + final page1 = await dao.getProposalsBriefPage( + request: const PageRequest(page: 0, size: 10), + ); + final page2 = await dao.getProposalsBriefPage( + request: const PageRequest(page: 1, size: 10), + ); + final page3 = await dao.getProposalsBriefPage( + request: const PageRequest(page: 2, size: 10), + ); + + // Then: Total should be consistent across all pages + expect(page1.total, 25); + expect(page2.total, 25); + expect(page3.total, 25); + + expect(page1.items.length, 10); + expect(page2.items.length, 10); + expect(page3.items.length, 5); + }); + }); + + group('NULL ref_ver handling', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('final action with NULL ref_ver uses latest version', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Final action with NULL ref_ver + final actionVer = _buildUuidV7At(latest); + final actionFinal = _createTestDocumentEntity( + id: 'action-final', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: null, + contentData: {'action': 'final'}, + ); + await db.documentsV2Dao.saveAll([actionFinal]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should use latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + expect(result.items[0].proposal.content.data['title'], 'new'); + }); + + test('final action with empty string ref_ver uses latest version', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(middle); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Final action with empty string ref_ver + final actionVer = _buildUuidV7At(latest); + final actionFinal = _createTestDocumentEntity( + id: 'action-final', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: '', + contentData: {'action': 'final'}, + ); + await db.documentsV2Dao.saveAll([actionFinal]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should use latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + }); + + group('Case sensitivity', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('uppercase HIDE action does not hide proposal', () async { + // Given + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + ); + await db.documentsV2Dao.saveAll([proposal]); + + // And: Action with uppercase HIDE + final actionVer = _buildUuidV7At(latest); + final actionUpper = _createTestDocumentEntity( + id: 'action-upper', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: {'action': 'HIDE'}, + ); + await db.documentsV2Dao.saveAll([actionUpper]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should NOT hide (case sensitive) + expect(result.items.length, 1); + }); + + test('mixed case Final action does not treat as final', () async { + // Given + final proposal1OldVer = _buildUuidV7At(earliest); + final proposal1Old = _createTestDocumentEntity( + id: 'p1', + ver: proposal1OldVer, + contentData: {'title': 'old'}, + ); + final proposal1NewVer = _buildUuidV7At(latest); + final proposal1New = _createTestDocumentEntity( + id: 'p1', + ver: proposal1NewVer, + contentData: {'title': 'new'}, + ); + await db.documentsV2Dao.saveAll([proposal1Old, proposal1New]); + + // And: Action with mixed case + final actionVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final actionMixed = _createTestDocumentEntity( + id: 'action-mixed', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1OldVer, + contentData: {'action': 'Final'}, + ); + await db.documentsV2Dao.saveAll([actionMixed]); + + // When + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + // Then: Should treat as draft and use latest version + expect(result.items.length, 1); + expect(result.items[0].proposal.ver, proposal1NewVer); + }); + }); + + group('ActionType', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('proposal with no action has null actionType', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + ); + await db.documentsV2Dao.save(proposal); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.id, 'p1'); + expect(result.items.first.actionType, isNull); + }); + + test('proposal with draft action has draft actionType', () async { + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalVer, + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + await db.documentsV2Dao.saveAll([proposal, action]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.id, 'p1'); + expect(result.items.first.actionType, ProposalSubmissionAction.draft); + }); + + test('proposal with final action has final_ actionType', () async { + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + await db.documentsV2Dao.saveAll([proposal, action]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.id, 'p1'); + expect(result.items.first.actionType, ProposalSubmissionAction.aFinal); + }); + + test('proposal with hide action is excluded and has no actionType', () async { + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + await db.documentsV2Dao.saveAll([proposal, action]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items, isEmpty); + expect(result.total, 0); + }); + + test('multiple actions uses latest action for actionType', () async { + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final action1Ver = _buildUuidV7At(middle); + final action1 = _createTestDocumentEntity( + id: 'action-1', + ver: action1Ver, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalVer, + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + + final action2Ver = _buildUuidV7At(latest); + final action2 = _createTestDocumentEntity( + id: 'action-2', + ver: action2Ver, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + await db.documentsV2Dao.saveAll([proposal, action1, action2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.id, 'p1'); + expect(result.items.first.actionType, ProposalSubmissionAction.aFinal); + }); + + test('multiple proposals have correct individual actionTypes', () async { + final proposal1Ver = _buildUuidV7At(earliest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(earliest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final proposal3Ver = _buildUuidV7At(earliest); + final proposal3 = _createTestDocumentEntity(id: 'p3', ver: proposal3Ver); + + final action1Ver = _buildUuidV7At(latest); + final action1 = _createTestDocumentEntity( + id: 'action-1', + ver: action1Ver, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + + final action2Ver = _buildUuidV7At(latest); + final action2 = _createTestDocumentEntity( + id: 'action-2', + ver: action2Ver, + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + proposal3, + action1, + action2, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 3); + + final p1 = result.items.firstWhere((e) => e.proposal.id == 'p1'); + final p2 = result.items.firstWhere((e) => e.proposal.id == 'p2'); + final p3 = result.items.firstWhere((e) => e.proposal.id == 'p3'); + + expect(p1.actionType, ProposalSubmissionAction.draft); + expect(p2.actionType, ProposalSubmissionAction.aFinal); + expect(p3.actionType, isNull); + }); + + test('invalid action value results in null actionType', () async { + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalVer, + contentData: {'action': 'invalid_action'}, + ); + await db.documentsV2Dao.saveAll([proposal, action]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.id, 'p1'); + expect(result.items.first.actionType, isNull); + }); + + test('missing action field in content defaults to draft actionType', () async { + final proposalVer = _buildUuidV7At(earliest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalVer, + contentData: {}, + ); + await db.documentsV2Dao.saveAll([proposal, action]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.id, 'p1'); + expect(result.items.first.actionType, ProposalSubmissionAction.draft); + }); + }); + + group('VersionIds', () { + test('returns single version for proposal with one version', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.versionIds.length, 1); + expect(result.items.first.proposal.ver, proposalVer); + expect(result.items.first.versionIds, [proposalVer]); + }); + + test( + 'returns all versions ordered by ver ASC for proposal with multiple versions', + () async { + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(middle); + final ver3 = _buildUuidV7At(latest); + + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: ver1); + final proposal2 = _createTestDocumentEntity(id: 'p1', ver: ver2); + final proposal3 = _createTestDocumentEntity(id: 'p1', ver: ver3); + await db.documentsV2Dao.saveAll([proposal3, proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.ver, ver3); + expect(result.items.first.versionIds.length, 3); + expect(result.items.first.versionIds, [ver1, ver2, ver3]); + }, + ); + }); + + group('CommentsCount', () { + test('returns zero comments for proposal without comments', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.commentsCount, 0); + }); + + test('returns correct count for proposal with comments on effective version', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final comment1Ver = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final comment1 = _createTestDocumentEntity( + id: 'c1', + ver: comment1Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposalVer, + ); + + final comment2Ver = _buildUuidV7At(earliest.add(const Duration(hours: 2))); + final comment2 = _createTestDocumentEntity( + id: 'c2', + ver: comment2Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposalVer, + ); + + await db.documentsV2Dao.saveAll([proposal, comment1, comment2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.commentsCount, 2); + }); + + test( + 'counts comments only for effective version when proposal has multiple versions', + () async { + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: ver1); + final proposal2 = _createTestDocumentEntity(id: 'p1', ver: ver2); + + final comment1Ver = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final comment1 = _createTestDocumentEntity( + id: 'c1', + ver: comment1Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver1, + ); + + final comment2Ver = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final comment2 = _createTestDocumentEntity( + id: 'c2', + ver: comment2Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver2, + ); + + final comment3Ver = _buildUuidV7At(latest.add(const Duration(hours: 2))); + final comment3 = _createTestDocumentEntity( + id: 'c3', + ver: comment3Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver2, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, comment1, comment2, comment3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.ver, ver2); + expect(result.items.first.commentsCount, 2); + }, + ); + + test('counts comments for final action version when specified', () async { + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(middle); + final ver3 = _buildUuidV7At(latest); + + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: ver1); + final proposal2 = _createTestDocumentEntity(id: 'p1', ver: ver2); + final proposal3 = _createTestDocumentEntity(id: 'p1', ver: ver3); + + final actionVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: ver2, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final comment1Ver = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final comment1 = _createTestDocumentEntity( + id: 'c1', + ver: comment1Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver1, + ); + + final comment2Ver = _buildUuidV7At(middle.add(const Duration(hours: 1))); + final comment2 = _createTestDocumentEntity( + id: 'c2', + ver: comment2Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver2, + ); + + final comment3Ver = _buildUuidV7At(latest.add(const Duration(hours: 2))); + final comment3 = _createTestDocumentEntity( + id: 'c3', + ver: comment3Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: ver3, + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + proposal3, + action, + comment1, + comment2, + comment3, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.ver, ver2); + expect(result.items.first.commentsCount, 1); + }); + + test('excludes comments from other proposals', () async { + final proposal1Ver = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final comment1Ver = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final comment1 = _createTestDocumentEntity( + id: 'c1', + ver: comment1Ver, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposal1Ver, + ); + + final comment2Ver = _buildUuidV7At(earliest.add(const Duration(hours: 2))); + final comment2 = _createTestDocumentEntity( + id: 'c2', + ver: comment2Ver, + type: DocumentType.commentDocument, + refId: 'p2', + refVer: proposal2Ver, + ); + + final comment3Ver = _buildUuidV7At(earliest.add(const Duration(hours: 3))); + final comment3 = _createTestDocumentEntity( + id: 'c3', + ver: comment3Ver, + type: DocumentType.commentDocument, + refId: 'p2', + refVer: proposal2Ver, + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + comment1, + comment2, + comment3, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 2); + + final p1 = result.items.firstWhere((e) => e.proposal.id == 'p1'); + final p2 = result.items.firstWhere((e) => e.proposal.id == 'p2'); + + expect(p1.commentsCount, 1); + expect(p2.commentsCount, 2); + }); + + test('excludes non-comment documents from count', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + + final commentVer = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final comment = _createTestDocumentEntity( + id: 'c1', + ver: commentVer, + type: DocumentType.commentDocument, + refId: 'p1', + refVer: proposalVer, + ); + + final otherDocVer = _buildUuidV7At(earliest.add(const Duration(hours: 2))); + final otherDoc = _createTestDocumentEntity( + id: 'other1', + ver: otherDocVer, + type: DocumentType.reviewDocument, + refId: 'p1', + refVer: proposalVer, + ); + + await db.documentsV2Dao.saveAll([proposal, comment, otherDoc]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.commentsCount, 1); + }); + }); + + group('IsFavorite', () { + test('returns false when no local metadata exists', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.isFavorite, false); + }); + + test('returns false when local metadata exists but isFavorite is false', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + await db.documentsV2Dao.saveAll([proposal]); + + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'p1', + isFavorite: false, + ), + ); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.isFavorite, false); + }); + + test('returns true when local metadata exists and isFavorite is true', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + await db.documentsV2Dao.saveAll([proposal]); + + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'p1', + isFavorite: true, + ), + ); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.isFavorite, true); + }); + + test('returns correct isFavorite for proposal with multiple versions', () async { + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: ver1); + final proposal2 = _createTestDocumentEntity(id: 'p1', ver: ver2); + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'p1', + isFavorite: true, + ), + ); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.ver, ver2); + expect(result.items.first.isFavorite, true); + }); + + test('returns correct individual isFavorite values for multiple proposals', () async { + final proposal1Ver = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: proposal1Ver); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity(id: 'p2', ver: proposal2Ver); + + final proposal3Ver = _buildUuidV7At(latest); + final proposal3 = _createTestDocumentEntity(id: 'p3', ver: proposal3Ver); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'p1', + isFavorite: true, + ), + ); + + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'p2', + isFavorite: false, + ), + ); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 3); + + final p1 = result.items.firstWhere((e) => e.proposal.id == 'p1'); + final p2 = result.items.firstWhere((e) => e.proposal.id == 'p2'); + final p3 = result.items.firstWhere((e) => e.proposal.id == 'p3'); + + expect(p1.isFavorite, true); + expect(p2.isFavorite, false); + expect(p3.isFavorite, false); + }); + + test('isFavorite matches on id regardless of version', () async { + final ver1 = _buildUuidV7At(earliest); + final ver2 = _buildUuidV7At(middle); + final ver3 = _buildUuidV7At(latest); + + final proposal1 = _createTestDocumentEntity(id: 'p1', ver: ver1); + final proposal2 = _createTestDocumentEntity(id: 'p1', ver: ver2); + final proposal3 = _createTestDocumentEntity(id: 'p1', ver: ver3); + + final actionVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: ver1, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3, action]); + + await db + .into(db.documentsLocalMetadata) + .insert( + DocumentsLocalMetadataCompanion.insert( + id: 'p1', + isFavorite: true, + ), + ); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.ver, ver1); + expect(result.items.first.isFavorite, true); + }); + }); + + group('Template', () { + test('returns null when proposal has no template', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity(id: 'p1', ver: proposalVer); + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.template, isNull); + }); + + test('returns null when template does not exist in database', () async { + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + templateId: 'template-1', + templateVer: 'template-ver-1', + ); + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.template, isNull); + }); + + test('returns template when it exists with matching id and ver', () async { + final templateVer = _buildUuidV7At(earliest); + final template = _createTestDocumentEntity( + id: 'template-1', + ver: templateVer, + type: DocumentType.proposalTemplate, + contentData: {'title': 'Template Title'}, + ); + + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + templateId: 'template-1', + templateVer: templateVer, + ); + + await db.documentsV2Dao.saveAll([template, proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.template, isNotNull); + expect(result.items.first.template!.id, 'template-1'); + expect(result.items.first.template!.ver, templateVer); + expect(result.items.first.template!.type, DocumentType.proposalTemplate); + expect(result.items.first.template!.content.data['title'], 'Template Title'); + }); + + test('returns null when template id matches but ver does not', () async { + final templateVer1 = _buildUuidV7At(earliest); + final template1 = _createTestDocumentEntity( + id: 'template-1', + ver: templateVer1, + type: DocumentType.proposalTemplate, + ); + + final templateVer2 = _buildUuidV7At(middle); + final template2 = _createTestDocumentEntity( + id: 'template-1', + ver: templateVer2, + type: DocumentType.proposalTemplate, + ); + + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + templateId: 'template-1', + templateVer: templateVer1, + ); + + await db.documentsV2Dao.saveAll([template1, template2, proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.template, isNotNull); + expect(result.items.first.template!.ver, templateVer1); + }); + + test('returns null when document type is not proposalTemplate', () async { + final templateVer = _buildUuidV7At(earliest); + final template = _createTestDocumentEntity( + id: 'template-1', + ver: templateVer, + type: DocumentType.commentDocument, + ); + + final proposalVer = _buildUuidV7At(latest); + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: proposalVer, + templateId: 'template-1', + templateVer: templateVer, + ); + + await db.documentsV2Dao.saveAll([template, proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.template, isNull); + }); + + test('returns correct templates for multiple proposals with different templates', () async { + final template1Ver = _buildUuidV7At(earliest); + final template1 = _createTestDocumentEntity( + id: 'template-1', + ver: template1Ver, + type: DocumentType.proposalTemplate, + contentData: {'title': 'Template 1'}, + ); + + final template2Ver = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final template2 = _createTestDocumentEntity( + id: 'template-2', + ver: template2Ver, + type: DocumentType.proposalTemplate, + contentData: {'title': 'Template 2'}, + ); + + final proposal1Ver = _buildUuidV7At(latest); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + templateId: 'template-1', + templateVer: template1Ver, + ); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + templateId: 'template-2', + templateVer: template2Ver, + ); + + final proposal3Ver = _buildUuidV7At(latest); + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: proposal3Ver, + ); + + await db.documentsV2Dao.saveAll([ + template1, + template2, + proposal1, + proposal2, + proposal3, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 3); + + final p1 = result.items.firstWhere((e) => e.proposal.id == 'p1'); + final p2 = result.items.firstWhere((e) => e.proposal.id == 'p2'); + final p3 = result.items.firstWhere((e) => e.proposal.id == 'p3'); + + expect(p1.template, isNotNull); + expect(p1.template!.id, 'template-1'); + expect(p1.template!.content.data['title'], 'Template 1'); + + expect(p2.template, isNotNull); + expect(p2.template!.id, 'template-2'); + expect(p2.template!.content.data['title'], 'Template 2'); + + expect(p3.template, isNull); + }); + + test('template is associated with effective proposal version', () async { + final template1Ver = _buildUuidV7At(earliest); + final template1 = _createTestDocumentEntity( + id: 'template-1', + ver: template1Ver, + type: DocumentType.proposalTemplate, + contentData: {'title': 'Template 1'}, + ); + + final template2Ver = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final template2 = _createTestDocumentEntity( + id: 'template-2', + ver: template2Ver, + type: DocumentType.proposalTemplate, + contentData: {'title': 'Template 2'}, + ); + + final ver1 = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + templateId: 'template-1', + templateVer: template1Ver, + ); + + final ver2 = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + templateId: 'template-2', + templateVer: template2Ver, + ); + + final actionVer = _buildUuidV7At(latest.add(const Duration(hours: 1))); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: ver1, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + template1, + template2, + proposal1, + proposal2, + action, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage(request: request); + + expect(result.items.length, 1); + expect(result.items.first.proposal.ver, ver1); + expect(result.items.first.template, isNotNull); + expect(result.items.first.template!.id, 'template-1'); + expect(result.items.first.template!.content.data['title'], 'Template 1'); + }); + }); + + group('Ordering', () { + test('sorts alphabetically by title', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'Zebra Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Alpha Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Middle Project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha Project', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Middle Project', + ); + expect( + result.items[2].proposal.content.data['setup']['title']['title'], + 'Zebra Project', + ); + }); + + test('sorts alphabetically case-insensitively', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'zebra project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Alpha PROJECT'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'MIDDLE project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha PROJECT', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'MIDDLE project', + ); + expect( + result.items[2].proposal.content.data['setup']['title']['title'], + 'zebra project', + ); + }); + + test('sorts alphabetically with missing titles at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'Zebra Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: {}, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Alpha Project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha Project', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Zebra Project', + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts alphabetically with empty string titles at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'Zebra Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': ''}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Alpha Project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha Project', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Zebra Project', + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts by budget ascending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + expect( + result.items[2].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + }); + + test('sorts by budget descending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: false), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + expect( + result.items[2].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + }); + + test('sorts by budget ascending with missing values at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: {}, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts by budget descending with missing values at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: {}, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: false), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts by update date ascending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(latest), + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(earliest), + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(middle), + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const UpdateDate.asc(), + ); + + expect(result.items.length, 3); + expect(result.items[0].proposal.id, 'id-2'); + expect(result.items[1].proposal.id, 'id-3'); + expect(result.items[2].proposal.id, 'id-1'); + }); + + test('sorts by update date descending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(latest), + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(middle), + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const UpdateDate.desc(), + ); + + expect(result.items.length, 3); + expect(result.items[0].proposal.id, 'id-2'); + expect(result.items[1].proposal.id, 'id-3'); + expect(result.items[2].proposal.id, 'id-1'); + }); + + test('respects pagination', () async { + final entities = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'id-$i', + ver: _buildUuidV7At(earliest.add(Duration(hours: i))), + contentData: { + 'setup': { + 'title': {'title': 'Project ${String.fromCharCode(65 + i)}'}, + }, + }, + ), + ); + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 1, size: 2); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 2); + expect(result.total, 5); + expect(result.page, 1); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Project C', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Project D', + ); + }); + + test('works with multiple versions of same proposal', () async { + final oldVer = _buildUuidV7At(earliest); + final oldProposal = _createTestDocumentEntity( + id: 'multi-id', + ver: oldVer, + contentData: { + 'setup': { + 'title': {'title': 'Old Title'}, + }, + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final newVer = _buildUuidV7At(latest); + final newProposal = _createTestDocumentEntity( + id: 'multi-id', + ver: newVer, + contentData: { + 'setup': { + 'title': {'title': 'New Title'}, + }, + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ); + + final otherVer = _buildUuidV7At(middle); + final otherProposal = _createTestDocumentEntity( + id: 'other-id', + ver: otherVer, + contentData: { + 'setup': { + 'title': {'title': 'Middle Title'}, + }, + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([oldProposal, newProposal, otherProposal]); + + const request = PageRequest(page: 0, size: 10); + final resultAlphabetical = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(resultAlphabetical.items.length, 2); + expect( + resultAlphabetical.items[0].proposal.content.data['setup']['title']['title'], + 'Middle Title', + ); + expect( + resultAlphabetical.items[1].proposal.content.data['setup']['title']['title'], + 'New Title', + ); + + final resultBudget = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(resultBudget.items.length, 2); + expect( + resultBudget.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + expect( + resultBudget.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + }); + + test('works with final action pointing to specific version', () async { + final ver1 = _buildUuidV7At(earliest); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + contentData: { + 'setup': { + 'title': {'title': 'Version 1'}, + }, + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final ver2 = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + contentData: { + 'setup': { + 'title': {'title': 'Version 2'}, + }, + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: ver1, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final otherVer = _buildUuidV7At(middle); + final otherProposal = _createTestDocumentEntity( + id: 'other-id', + ver: otherVer, + contentData: { + 'setup': { + 'title': {'title': 'Other Proposal'}, + }, + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, action, otherProposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(result.items.length, 2); + expect(result.items[0].proposal.ver, ver1); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + }); + }); + + group('Filtering', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + group('by status', () { + test('filters draft proposals without action documents', () async { + final draftProposal1 = _createTestDocumentEntity( + id: 'draft-no-action', + ver: _buildUuidV7At(latest), + ); + + final draftProposal2 = _createTestDocumentEntity( + id: 'draft-with-action', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + ); + + final draftActionVer = _buildUuidV7At(middle); + final draftAction = _createTestDocumentEntity( + id: 'action-draft', + ver: draftActionVer, + type: DocumentType.proposalActionDocument, + refId: 'draft-with-action', + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + + final finalProposalVer = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final finalProposal = _createTestDocumentEntity( + id: 'final-id', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-id', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + draftProposal1, + draftProposal2, + draftAction, + finalProposal, + finalAction, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.draft), + ); + + expect(result.items.length, 2); + expect(result.total, 2); + expect( + result.items.map((e) => e.proposal.id).toSet(), + {'draft-no-action', 'draft-with-action'}, + ); + }); + + test('filters draft proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-id', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-id', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-id', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.draft), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'draft-id'); + }); + + test('filters final proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-id', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-id', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-id', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.aFinal), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'final-id'); + }); + }); + + group('by favorite', () { + test('filters favorite proposals', () async { + final favoriteProposal = _createTestDocumentEntity( + id: 'favorite-id', + ver: _buildUuidV7At(latest), + ); + + final notFavoriteProposal = _createTestDocumentEntity( + id: 'not-favorite-id', + ver: _buildUuidV7At(middle), + ); + + await db.documentsV2Dao.saveAll([favoriteProposal, notFavoriteProposal]); + + await dao.updateProposalFavorite(id: 'favorite-id', isFavorite: true); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(isFavorite: true), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'favorite-id'); + expect(result.items[0].isFavorite, true); + }); + + test('filters non-favorite proposals', () async { + final favoriteProposal = _createTestDocumentEntity( + id: 'favorite-id', + ver: _buildUuidV7At(latest), + ); + + final notFavoriteProposal = _createTestDocumentEntity( + id: 'not-favorite-id', + ver: _buildUuidV7At(middle), + ); + + await db.documentsV2Dao.saveAll([favoriteProposal, notFavoriteProposal]); + + await dao.updateProposalFavorite(id: 'favorite-id', isFavorite: true); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(isFavorite: false), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'not-favorite-id'); + expect(result.items[0].isFavorite, false); + }); + }); + + group('by author', () { + test('filters proposals by author CatalystId', () async { + final author1 = _createTestAuthor(name: 'john_doe', role0KeySeed: 1); + final author2 = _createTestAuthor(name: 'alice', role0KeySeed: 2); + final author3 = _createTestAuthor(name: 'bob', role0KeySeed: 3); + + final p1Authors = [author1, author2].map((e) => e.toUri().toString()).join(','); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: p1Authors, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: author3.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2(author: author1), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('filters proposals by different author CatalystId', () async { + final author1 = _createTestAuthor(name: 'john_doe', role0KeySeed: 1); + final author2 = _createTestAuthor(name: 'alice', role0KeySeed: 2); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: author1.toString(), + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: author2.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2(author: author2), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p2'); + }); + + test('handles author with special characters in username', () async { + final authorWithSpecialChars = _createTestAuthor( + /* cSpell:disable */ + name: "test'user_100%", + /* cSpell:enable */ + role0KeySeed: 1, + ); + final normalAuthor = _createTestAuthor(name: 'normal', role0KeySeed: 2); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: authorWithSpecialChars.toString(), + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: normalAuthor.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2(author: authorWithSpecialChars), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + }); + + group('by category', () { + test('filters proposals by category id', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'category-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'category-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(categoryId: 'category-1'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + }); + + group('by search query', () { + test('searches in authors field', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: _createTestAuthors(['john-doe', 'jane-smith']), + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + 'proposer': {'applicant': 'Other Name'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: _createTestAuthors(['alice-wonder']), + contentData: { + 'setup': { + 'title': {'title': 'Different Title'}, + 'proposer': {'applicant': 'Different Name'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'john'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('searches in applicant name from JSON content', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: _createTestAuthors(['other-author']), + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + 'proposer': {'applicant': 'John Doe'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: _createTestAuthors(['different-author']), + contentData: { + 'setup': { + 'title': {'title': 'Different Title'}, + 'proposer': {'applicant': 'Jane Smith'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'John'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('searches in title from JSON content', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: _createTestAuthors(['other-author']), + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Revolution'}, + 'proposer': {'applicant': 'Other Name'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: _createTestAuthors(['different-author']), + contentData: { + 'setup': { + 'title': {'title': 'Smart Contracts Study'}, + 'proposer': {'applicant': 'Different Name'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'Revolution'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('searches case-insensitively', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Revolution'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'blockchain'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('returns multiple matches from different fields', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: _createTestAuthors(['tech-author']), + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Tech Innovation'}, + }, + }, + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'proposer': {'applicant': 'Tech Expert'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'tech'), + ); + + expect(result.items.length, 3); + expect(result.total, 3); + }); + }); + + group('SQL injection protection', () { + test('escapes single quotes in search query', () async { + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': "Project with 'quotes'"}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: "Project with 'quotes'"), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('prevents SQL injection via search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Legitimate Title'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + searchQuery: "' OR '1'='1", + ), + ); + + expect(result.items.length, 0); + expect(result.total, 0); + }); + + test('escapes LIKE wildcards in search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': '100% Complete'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': '100X Complete'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: '100%'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('escapes underscores in search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'test_case'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + /* cSpell:disable */ + 'title': {'title': 'testXcase'}, + /* cSpell:enable */ + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'test_case'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('escapes backslashes in search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': r'path\to\file'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'path/to/file'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: r'path\to\file'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('escapes special characters in category id', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + /* cSpell:disable */ + categoryId: "cat'egory-1", + /* cSpell:enable */ + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'category-2', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + /* cSpell:disable */ + filters: const ProposalsFiltersV2(categoryId: "cat'egory-1"), + /* cSpell:enable */ + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + }); + + group('by latest update', () { + test('filters proposals created within duration', () async { + final now = DateTime.now(); + final oneHourAgo = now.subtract(const Duration(hours: 1)); + final twoDaysAgo = now.subtract(const Duration(days: 2)); + + final recentProposal = _createTestDocumentEntity( + id: 'recent', + ver: _buildUuidV7At(oneHourAgo), + createdAt: oneHourAgo, + ); + + final oldProposal = _createTestDocumentEntity( + id: 'old', + ver: _buildUuidV7At(twoDaysAgo), + createdAt: twoDaysAgo, + ); + + await db.documentsV2Dao.saveAll([recentProposal, oldProposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(latestUpdate: Duration(days: 1)), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'recent'); + }); + }); + + group('combined filters', () { + test('applies status and favorite filters together', () async { + final draftFavorite = _createTestDocumentEntity( + id: 'draft-fav', + ver: _buildUuidV7At(latest), + ); + + final draftNotFavorite = _createTestDocumentEntity( + id: 'draft-not-fav', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalFavorite = _createTestDocumentEntity( + id: 'final-fav', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-fav', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + draftFavorite, + draftNotFavorite, + finalFavorite, + finalAction, + ]); + + await dao.updateProposalFavorite(id: 'draft-fav', isFavorite: true); + await dao.updateProposalFavorite(id: 'final-fav', isFavorite: true); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + status: ProposalStatusFilter.draft, + isFavorite: true, + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'draft-fav'); + }); + + test('applies author, category, and search filters together', () async { + final author1 = _createTestAuthor(name: 'john', role0KeySeed: 1); + final author2 = _createTestAuthor(name: 'jane', role0KeySeed: 2); + + final matchingProposal = _createTestDocumentEntity( + id: 'matching', + ver: _buildUuidV7At(latest), + authors: author1.toString(), + categoryId: 'cat-1', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Project'}, + }, + }, + ); + + final wrongAuthor = _createTestDocumentEntity( + id: 'wrong-author', + ver: _buildUuidV7At(middle.add(const Duration(hours: 2))), + authors: author2.toString(), + categoryId: 'cat-1', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Project'}, + }, + }, + ); + + final wrongCategory = _createTestDocumentEntity( + id: 'wrong-category', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + authors: author1.toString(), + categoryId: 'cat-2', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Project'}, + }, + }, + ); + + final wrongTitle = _createTestDocumentEntity( + id: 'wrong-title', + ver: _buildUuidV7At(middle), + authors: author1.toString(), + categoryId: 'cat-1', + contentData: { + 'setup': { + 'title': {'title': 'Other Project'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([ + matchingProposal, + wrongAuthor, + wrongCategory, + wrongTitle, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2( + author: author1, + categoryId: 'cat-1', + searchQuery: 'Blockchain', + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'matching'); + }); + }); + + group('by campaign', () { + test('filters proposals by campaign categories', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + ), + ); + + expect(result.items.length, 2); + expect(result.total, 2); + expect(result.items.map((e) => e.proposal.id).toSet(), {'p1', 'p2'}); + }); + + test('returns empty when campaign categories is empty', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'cat-2', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {}), + ), + ); + + expect(result.items.length, 0); + expect(result.total, 0); + }); + + test('combines categoryId with campaign filter when compatible', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + categoryId: 'cat-1', + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + expect(result.items[0].proposal.categoryId, 'cat-1'); + }); + + test('returns empty when categoryId not in campaign', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + categoryId: 'cat-3', + ), + ); + + expect(result.items.length, 0); + expect(result.total, 0); + }); + + test('ignores campaign filter when null', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(campaign: null), + ); + + expect(result.items.length, 3); + expect(result.total, 3); + }); + + test('handles null category_id in database', () async { + final proposalWithCategory = _createTestDocumentEntity( + id: 'p-with-cat', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposalWithoutCategory = _createTestDocumentEntity( + id: 'p-without-cat', + ver: _buildUuidV7At(middle), + categoryId: null, + ); + + await db.documentsV2Dao.saveAll([proposalWithCategory, proposalWithoutCategory]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p-with-cat'); + }); + + test('handles multiple categories efficiently', () async { + final proposals = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'p-$i', + ver: _buildUuidV7At(earliest.add(Duration(hours: i))), + categoryId: 'cat-$i', + ), + ); + + await db.documentsV2Dao.saveAll(proposals); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters( + categoriesIds: {'cat-0', 'cat-2', 'cat-4'}, + ), + ), + ); + + expect(result.items.length, 3); + expect(result.total, 3); + expect( + result.items.map((e) => e.proposal.categoryId).toSet(), + {'cat-0', 'cat-2', 'cat-4'}, + ); + }); + + test('campaign filter respects status filter', () async { + final draftProposalVer = _buildUuidV7At(latest); + final draftProposal = _createTestDocumentEntity( + id: 'draft-p', + ver: draftProposalVer, + categoryId: 'cat-1', + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-p', + ver: finalProposalVer, + categoryId: 'cat-1', + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-p', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1'}), + status: ProposalStatusFilter.draft, + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'draft-p'); + }); + }); + }); + }); + + group('getProposalsTotalTask', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + final nodeId = DocumentNodeId.fromString('summary.budget.requestedFunds'); + + test('returns empty map when categories list is empty', () async { + const filters = ProposalsTotalAskFilters(); + + final result = await dao.getProposalsTotalTask( + nodeId: nodeId, + filters: filters, + ); + + expect(result, const ProposalsTotalAsk({})); + }); + + test('returns empty map when no final proposals exist', () async { + final draftProposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([draftProposal]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + expect(result, const ProposalsTotalAsk({})); + }); + + test('aggregates budget from single template with final proposals', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 25000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + final templateResult = result.data[templateRef]; + + expect(result.data.length, 1); + expect(templateResult, isNotNull); + expect(templateResult!.totalAsk, 35000); + expect(templateResult.finalProposalsCount, 2); + }); + + test('groups by different template versions separately', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-v1', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-v2', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 20000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef1 = SignedDocumentRef( + id: 'template-1', + version: 'template-1-v1', + ); + const templateRef2 = SignedDocumentRef( + id: 'template-1', + version: 'template-1-v2', + ); + + expect(result.data.length, 2); + expect(result.data[templateRef1]!.totalAsk, 10000); + expect(result.data[templateRef1]!.finalProposalsCount, 1); + expect(result.data[templateRef2]!.totalAsk, 20000); + expect(result.data[templateRef2]!.finalProposalsCount, 1); + }); + + test('groups by different templates separately', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-2', + templateVer: 'template-2-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef1 = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + const templateRef2 = SignedDocumentRef( + id: 'template-2', + version: 'template-2-ver', + ); + + expect(result.data.length, 2); + expect(result.data[templateRef1]!.totalAsk, 10000); + expect(result.data[templateRef1]!.finalProposalsCount, 1); + expect(result.data[templateRef2]!.totalAsk, 30000); + expect(result.data[templateRef2]!.finalProposalsCount, 1); + }); + + test('treats non-integer budget values as 0', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 'not-a-number'}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 15000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.data[templateRef]!.totalAsk, 15000); + expect(result.data[templateRef]!.finalProposalsCount, 2); + }); + + test('respects category filter', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-2', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.data.length, 1); + expect(result.data[templateRef]!.totalAsk, 10000); + expect(result.data[templateRef]!.finalProposalsCount, 1); + }); + + test('handles multiple categories in filter', () async { + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-2', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 20000}, + }, + }, + ); + + final proposal3Ver = _buildUuidV7At(middle); + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: proposal3Ver, + categoryId: 'cat-3', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction3 = _createTestDocumentEntity( + id: 'action-3', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p3', + refVer: proposal3Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2, + proposal3, + finalAction1, + finalAction2, + finalAction3, + ]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1', 'cat-2']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.data.length, 1); + expect(result.data[templateRef]!.totalAsk, 30000); + expect(result.data[templateRef]!.finalProposalsCount, 2); + }); + + test('uses correct version when final action points to specific version', () async { + final proposalV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposalV2Ver = _buildUuidV7At(middle); + final proposalV2 = _createTestDocumentEntity( + id: 'p1', + ver: proposalV2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 25000}, + }, + }, + ); + + final proposalV3 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ); + + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposalV2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposalV1, proposalV2, proposalV3, finalAction]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.data[templateRef]!.totalAsk, 25000); + expect(result.data[templateRef]!.finalProposalsCount, 1); + }); + + test('excludes final actions without valid ref_ver', () async { + final proposalV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final proposalV2Ver = _buildUuidV7At(latest); + final proposalV2 = _createTestDocumentEntity( + id: 'p1', + ver: proposalV2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + final finalActionWithoutRefVer = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: null, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposalV1, proposalV2, finalActionWithoutRefVer]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + expect(result, const ProposalsTotalAsk({})); + }); + + test('extracts value from custom nodeId path', () async { + final customNodeId = DocumentNodeId.fromString('custom.path.value'); + + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'custom': { + 'path': {'value': 5000}, + }, + }, + ); + + final proposal2Ver = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: 'template-1', + templateVer: 'template-1-ver', + contentData: { + 'custom': { + 'path': {'value': 7500}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, finalAction1, finalAction2]); + + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final result = await dao.getProposalsTotalTask( + filters: filters, + nodeId: customNodeId, + ); + + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + expect(result.data[templateRef]!.totalAsk, 12500); + expect(result.data[templateRef]!.finalProposalsCount, 2); + }); + }); + + group('watchProposalTemplatesTotalTask', () { + // ignore: unused_local_variable + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + final nodeId = DocumentNodeId.fromString('summary.budget.requestedFunds'); + + test('returns empty map when categories list is empty', () async { + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: []), + ); + + final stream = dao.watchProposalsTotalTask( + filters: filters, + nodeId: nodeId, + ); + + await expectLater( + stream, + emits(const ProposalsTotalAsk({})), + ); + }); + + test('stream emits updated values when data changes', () async { + const templateRef = SignedDocumentRef( + id: 'template-1', + version: 'template-1-ver', + ); + + final proposal1Ver = _buildUuidV7At(middle); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: proposal1Ver, + categoryId: 'cat-1', + templateId: templateRef.id, + templateVer: templateRef.version, + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final finalAction1 = _createTestDocumentEntity( + id: 'action-1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: proposal1Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, finalAction1]); + + final emissions = >[]; + const filters = ProposalsTotalAskFilters( + campaign: CampaignFilters(categoriesIds: ['cat-1']), + ); + + final subscription = dao + .watchProposalsTotalTask(filters: filters, nodeId: nodeId) + .listen((event) => emissions.add(event.data)); + + await pumpEventQueue(); + expect(emissions.length, 1); + expect(emissions[0][templateRef]!.totalAsk, 10000); + + final proposal2Ver = _buildUuidV7At(middle.add(const Duration(hours: 1))); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + categoryId: 'cat-1', + templateId: templateRef.id, + templateVer: templateRef.version, + contentData: { + 'summary': { + 'budget': {'requestedFunds': 20000}, + }, + }, + ); + + final finalAction2 = _createTestDocumentEntity( + id: 'action-2', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p2', + refVer: proposal2Ver, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal2, finalAction2]); + await pumpEventQueue(); + + expect(emissions.length, 2); + expect(emissions[1][templateRef]!.totalAsk, 30000); + + await subscription.cancel(); + }); + }); + }); +} + +String _buildUuidV7At(DateTime dateTime) { + final ts = dateTime.millisecondsSinceEpoch; + final rand = Uint8List.fromList([42, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + return const UuidV7().generate(options: V7Options(ts, rand)); +} + +CatalystId _createTestAuthor({ + String? name, + int role0KeySeed = 0, +}) { + final buffer = StringBuffer('id.catalyst://'); + final role0Key = Uint8List.fromList(List.filled(32, role0KeySeed)); + + if (name != null) { + buffer + ..write(name) + ..write('@'); + } + + buffer + ..write('preprod.cardano/') + ..write(base64UrlNoPadEncode(role0Key)); + + return CatalystId.parse(buffer.toString()); +} + +String _createTestAuthors( + List names, { + // ignore: unused_element_parameter + int Function(String) role0KeySeed = _seedRole0KeySeedGetter, +}) { + return names + .map((e) => _createTestAuthor(name: e, role0KeySeed: _seedRole0KeySeedGetter(e))) + .map((e) => e.toUri().toString()) + .join(','); +} + +DocumentWithAuthorsEntity _createTestDocumentEntity({ + String? id, + String? ver, + Map contentData = const {}, + DocumentType type = DocumentType.proposalDocument, + DateTime? createdAt, + String? authors, + String? categoryId, + String? categoryVer, + String? refId, + String? refVer, + String? replyId, + String? replyVer, + String? section, + String? templateId, + String? templateVer, +}) { + id ??= DocumentRefFactory.randomUuidV7(); + ver ??= id; + authors ??= ''; + + final docEntity = DocumentEntityV2( + id: id, + ver: ver, + content: DocumentDataContent(contentData), + createdAt: createdAt ?? ver.tryDateTime ?? DateTime.now(), + type: type, + authors: authors, + categoryId: categoryId, + categoryVer: categoryVer, + refId: refId, + refVer: refVer, + replyId: replyId, + replyVer: replyVer, + section: section, + templateId: templateId, + templateVer: templateVer, + ); + + final authorsEntities = authors + .split(',') + .where((element) => element.trim().isNotEmpty) + .map(CatalystId.tryParse) + .nonNulls + .map( + (e) => DocumentAuthorEntity( + documentId: docEntity.id, + documentVer: docEntity.ver, + authorId: e.toUri().toString(), + authorIdSignificant: e.toSignificant().toUri().toString(), + authorUsername: e.username, + ), + ) + .toList(); + + return DocumentWithAuthorsEntity(docEntity, authorsEntities); +} + +int _seedRole0KeySeedGetter(String name) => 0; + +extension on ProposalSubmissionActionDto { + Map toJson() { + return ProposalSubmissionActionDocumentDto(action: this).toJson(); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart new file mode 100644 index 000000000000..8c069354ca1e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/logging_db_interceptor.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/cupertino.dart'; + +const _clauseKeywords = [ + 'SELECT', + 'FROM', + 'INNER JOIN', + 'LEFT JOIN', + 'RIGHT JOIN', + 'JOIN', + 'ON', + 'WHERE', + 'GROUP BY', + 'ORDER BY', + 'HAVING', + 'LIMIT', + 'OFFSET', +]; +const _indent = ' '; + +/// Interceptor that logs all database operations. +final class LoggingDbInterceptor extends QueryInterceptor { + LoggingDbInterceptor(); + + @override + Future commitTransaction(TransactionExecutor inner) { + return _run( + () => 'commit', + () => super.commitTransaction(inner), + ); + } + + @override + Future rollbackTransaction(TransactionExecutor inner) { + return _run( + () => 'rollback', + () => super.rollbackTransaction(inner), + ); + } + + @override + Future runBatched( + QueryExecutor executor, + BatchedStatements statements, + ) { + return _run( + () => 'batch with ${_prettyBatch(statements)}', + () => super.runBatched(executor, statements), + ); + } + + @override + Future runCustom( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runCustom(executor, statement, args), + ); + } + + @override + Future runDelete( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runDelete(executor, statement, args), + ); + } + + @override + Future runInsert( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runInsert(executor, statement, args), + ); + } + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runSelect(executor, statement, args), + ); + } + + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) { + return _run( + () => _prettyFormat(statement, args), + () => super.runUpdate(executor, statement, args), + ); + } + + void _log( + String message, [ + Object? error, + StackTrace? stack, + ]) { + debugPrint(message); + if (error != null) { + debugPrint('$error'); + } + if (stack != null) { + debugPrintStack(stackTrace: stack); + } + } + + String _prettyBatch(BatchedStatements statements) { + return statements.statements + .mapIndexed( + (index, statement) { + final args = statements.arguments + .firstWhereOrNull((args) => args.statementIndex == index) + ?.arguments; + + return _prettyFormat(statement, args ?? []); + }, + ) + .join(', '); + } + + String _prettyFormat(String statement, List args) { + var formatted = statement + // Insert args + .replaceAllMappedIndexed( + '?', + (match, index) { + final arg = args.elementAtOrNull(index); + final formattedArg = arg is Uint8List ? '*bytes*' : arg; + + return formattedArg.toString(); + }, + ) + // Normalize spacing + .replaceAll(RegExp(r'\s+'), ' '); + + for (final keyword in _clauseKeywords) { + final pattern = RegExp('\\b$keyword\\b', caseSensitive: false); + formatted = formatted.replaceAllMapped( + pattern, + (match) => '\n${match.group(0)}', + ); + } + + // Line breaks for AND/OR within WHERE and ON + formatted = formatted.replaceAllMapped( + RegExp(r'\b(AND|OR)\b', caseSensitive: false), + (match) => '\n ${match.group(0)}', + ); + + // New lines after commas outside parentheses (e.g., SELECT, GROUP BY) + formatted = formatted.replaceAllMapped( + RegExp(r',(?![^()]*\))'), + (match) => ',\n ', + ); + + // Indentation + final lines = formatted.split('\n'); + final buffer = StringBuffer(); + + var indentLevel = 0; + + for (var line in lines) { + line = line.trim(); + + // Adjust indent level based on parentheses + final openParens = '('.allMatches(line).length; + final closeParens = ')'.allMatches(line).length; + + if (closeParens > openParens) { + indentLevel = (indentLevel - (closeParens - openParens)).clamp(0, indentLevel); + } + + buffer.writeln('${_indent * indentLevel}$line'); + + if (openParens > closeParens) { + indentLevel += (openParens - closeParens); + } + } + + return buffer.toString().trim(); + } + + Future _run( + String Function() description, + FutureOr Function() operation, + ) async { + _log('Running ${description()}'); + + return await operation(); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/migration/catalyst_database/migration_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/migration/catalyst_database/migration_test.dart new file mode 100644 index 000000000000..fa05beb8f2a3 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/migration/catalyst_database/migration_test.dart @@ -0,0 +1,365 @@ +// dart format width=80 +// ignore_for_file: unused_local_variable, unused_import +import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; +import 'package:catalyst_voices_repositories/src/database/migration/from_3_to_4.dart'; +import 'package:catalyst_voices_repositories/src/dto/document/document_data_dto.dart'; +import 'package:catalyst_voices_repositories/src/dto/document/document_ref_dto.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/common.dart' as sqlite3 show jsonb; + +import '../../drift_test_platforms.dart'; +import 'generated/schema.dart'; +import 'generated/schema_v3.dart' as v3; +import 'generated/schema_v4.dart' as v4; + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late SchemaVerifier verifier; + + setUpAll(() { + verifier = SchemaVerifier(GeneratedHelper()); + }); + + group('database migrations', () { + const versions = GeneratedHelper.versions; + for (final (i, fromVersion) in versions.indexed) { + group('from $fromVersion', () { + for (final toVersion in versions.skip(i + 1)) { + test( + 'to $toVersion', + () async { + final schema = await verifier.schemaAt(fromVersion); + final db = DriftCatalystDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }, + onPlatform: driftOnPlatforms, + ); + } + }); + } + }); + + test( + 'migration from v3 to v4 does not corrupt data', + () async { + // 1. Documents + final docs = _generateDocuments(10); + final oldDocumentsData = docs.map((e) => e.v3()).toList(); + final expectedNewDocumentsData = docs.map((e) => e.v4()).toList(); + + // 2. Document Favorites + final oldDocumentsFavoritesData = docs + .mapIndexed( + (index, e) => _buildDocFavV3(id: e.id, isFavorite: index.isEven), + ) + .toList(); + final expectedNewDocumentsFavoritesData = docs + .mapIndexed( + (index, e) => _buildDocFavV4(id: e.id, isFavorite: index.isEven), + ) + .toList(); + + // 3. Drafts + final drafts = _generateDocuments(5, isDraft: true); + final oldDraftsData = drafts.map((e) => e.v3Draft()).toList(); + final expectedNewDraftsData = drafts.map((e) => e.v4Draft()).toList(); + + // 4. Authors + final expectedAuthors = docs.map((e) => e.v4Authors()).flattened.toList(); + + await verifier.testWithDataIntegrity( + oldVersion: 3, + newVersion: 4, + createOld: v3.DatabaseAtV3.new, + createNew: v4.DatabaseAtV4.new, + openTestedDatabase: DriftCatalystDatabase.new, + createItems: (batch, oldDb) { + batch + ..insertAll(oldDb.documents, oldDocumentsData) + ..insertAll(oldDb.documentsFavorites, oldDocumentsFavoritesData) + ..insertAll(oldDb.drafts, oldDraftsData); + }, + validateItems: (newDb) async { + // 1. Documents + final migratedDocs = await newDb.documentsV2.select().get(); + expect( + migratedDocs.length, + expectedNewDocumentsData.length, + reason: 'Should migrate the same number of documents', + ); + // Using a collection matcher for a more readable assertion + expect( + migratedDocs, + orderedEquals(expectedNewDocumentsData), + reason: + 'Migrated documents should match expected ' + 'format and data in the correct order', + ); + + // 2. LocalMetadata (eg. fav) + final migratedFavorites = await newDb.documentsLocalMetadata + .select() + .get(); + expect( + migratedFavorites.length, + expectedNewDocumentsFavoritesData.length, + reason: 'Should migrate the same number of favorites', + ); + expect( + migratedFavorites, + // Use unorderedEquals if the insertion order is not guaranteed + unorderedEquals(expectedNewDocumentsFavoritesData), + reason: 'All favorites should be migrated correctly', + ); + + // 3. Local drafts + final migratedDrafts = await newDb.localDocumentsDrafts + .select() + .get(); + expect( + migratedDrafts.length, + expectedNewDraftsData.length, + reason: 'Should migrate the same number of drafts', + ); + expect( + migratedDrafts, + orderedEquals(expectedNewDraftsData), + reason: 'Migrated drafts should match expected format and data', + ); + + // 4. Authors + final authors = await newDb.documentAuthors.select().get(); + expect( + authors.length, + expectedAuthors.length, + reason: 'Should migrate the same number of authors', + ); + expect( + authors, + orderedEquals(expectedAuthors), + reason: 'Migrated authors should match expected format and data', + ); + }, + ); + }, + onPlatform: driftOnPlatforms, + ); +} + +/* cSpell:disable */ +const _testOrgCatalystIdUri = + 'id.catalyst://cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE='; + +const _testUserCatalystIdUri = + 'id.catalyst://john@preprod.cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE='; +/* cSpell:enable */ + +DocumentData _buildDoc({ + String? id, + String? ver, + DocumentType type = DocumentType.proposalDocument, + Map content = const {}, + String? section, + DocumentRef? ref, + SignedDocumentRef? reply, + SignedDocumentRef? template, + SignedDocumentRef? categoryId, + List? authors, + bool isDraft = false, +}) { + id ??= DocumentRefFactory.randomUuidV7(); + ver ??= id; + + final metadata = DocumentDataMetadata( + type: type, + selfRef: DocumentRef.build(id: id, version: ver, isDraft: isDraft), + section: section, + ref: ref, + reply: reply, + template: template, + categoryId: categoryId, + authors: authors, + ); + + return DocumentData( + content: DocumentDataContent(content), + metadata: metadata, + ); +} + +v3.DocumentsFavoritesData _buildDocFavV3({ + required String id, + required bool isFavorite, +}) { + final idHiLo = UuidHiLo.from(id); + + return v3.DocumentsFavoritesData( + idHi: idHiLo.high, + idLo: idHiLo.low, + isFavorite: isFavorite, + type: DocumentType.proposalDocument.uuid, + ); +} + +v4.DocumentsLocalMetadataData _buildDocFavV4({ + required String id, + required bool isFavorite, +}) { + return v4.DocumentsLocalMetadataData( + id: id, + isFavorite: isFavorite, + ); +} + +List _generateDocuments( + int count, { + bool isDraft = false, +}) { + return List.generate(count, (index) { + return _buildDoc( + isDraft: isDraft, + ref: index.isEven ? DocumentRefFactory.signedDocumentRef() : null, + reply: index.isOdd ? DocumentRefFactory.signedDocumentRef() : null, + template: DocumentRefFactory.signedDocumentRef(), + categoryId: DocumentRefFactory.signedDocumentRef(), + type: index.isEven + ? DocumentType.proposalDocument + : DocumentType.commentDocument, + /* cSpell:disable */ + authors: [ + CatalystId.parse(_testUserCatalystIdUri), + if (index.isEven) CatalystId.parse(_testOrgCatalystIdUri), + ], + /* cSpell:enable */ + ); + }); +} + +typedef _NewDocumentAuthor = v4.DocumentAuthorsData; + +typedef _NewDocumentData = v4.DocumentsV2Data; + +typedef _NewDraftData = v4.LocalDocumentsDraftsData; + +typedef _OldDocumentData = v3.DocumentsData; + +typedef _OldDraftData = v3.DraftsData; + +extension on DocumentData { + String get id => metadata.id; +} + +extension on DocumentData { + _OldDocumentData v3() { + final idHiLo = UuidHiLo.from(metadata.id); + final verHiLo = UuidHiLo.from(metadata.version); + + final metadataJson = DocumentDataMetadataDtoDbV3.fromModel( + metadata, + ).toJson(); + + return _OldDocumentData( + idHi: idHiLo.high, + idLo: idHiLo.low, + verHi: verHiLo.high, + verLo: verHiLo.low, + content: sqlite3.jsonb.encode(content.data), + metadata: sqlite3.jsonb.encode(metadataJson), + type: metadata.type.uuid, + createdAt: metadata.version.tryDateTime ?? DateTime.timestamp(), + ); + } + + _OldDraftData v3Draft() { + final idHiLo = UuidHiLo.from(metadata.id); + final verHiLo = UuidHiLo.from(metadata.version); + + final metadataJson = DocumentDataMetadataDtoDbV3.fromModel( + metadata, + ).toJson(); + + return _OldDraftData( + idHi: idHiLo.high, + idLo: idHiLo.low, + verHi: verHiLo.high, + verLo: verHiLo.low, + content: sqlite3.jsonb.encode(content.data), + metadata: sqlite3.jsonb.encode(metadataJson), + type: metadata.type.uuid, + title: '', + ); + } + + _NewDocumentData v4() { + return _NewDocumentData( + content: sqlite3.jsonb.encode(content.data), + id: metadata.id, + type: metadata.type.uuid, + ver: metadata.version, + authors: + metadata.authors?.map((e) => e.toUri().toString()).join(',') ?? '', + refId: metadata.ref?.id, + refVer: metadata.ref?.version, + replyId: metadata.reply?.id, + replyVer: metadata.reply?.version, + section: metadata.section, + templateId: metadata.template?.id, + templateVer: metadata.template?.version, + categoryId: metadata.categoryId?.id, + categoryVer: metadata.categoryId?.version, + createdAt: metadata.version.tryDateTime ?? DateTime.timestamp(), + ); + } + + List<_NewDocumentAuthor> v4Authors() { + final documentId = metadata.selfRef.id; + final documentVer = metadata.selfRef.version!; + + return (metadata.authors ?? []).map( + (catId) { + return _NewDocumentAuthor( + documentId: documentId, + documentVer: documentVer, + authorId: catId.toUri().toString(), + authorIdSignificant: catId.toSignificant().toUri().toString(), + authorUsername: catId.username, + ); + }, + ).toList(); + } + + _NewDraftData v4Draft() { + final idHiLo = UuidHiLo.from(metadata.id); + final verHiLo = UuidHiLo.from(metadata.version); + + final metadataJson = DocumentDataMetadataDtoDbV3.fromModel( + metadata, + ).toJson(); + + return _NewDraftData( + content: sqlite3.jsonb.encode(content.data), + id: metadata.id, + type: metadata.type.uuid, + ver: metadata.version, + authors: + metadata.authors?.map((e) => e.toUri().toString()).join(',') ?? '', + refId: metadata.ref?.id, + refVer: metadata.ref?.version, + replyId: metadata.reply?.id, + replyVer: metadata.reply?.version, + section: metadata.section, + templateId: metadata.template?.id, + templateVer: metadata.template?.version, + categoryId: metadata.categoryId?.id, + categoryVer: metadata.categoryId?.version, + createdAt: metadata.version.tryDateTime ?? DateTime.timestamp(), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart index 48a0bc040b14..104c71096fcb 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/document_repository_test.dart @@ -4,6 +4,7 @@ import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_repositories/src/database/catalyst_database.dart'; import 'package:catalyst_voices_repositories/src/document/document_repository.dart'; import 'package:catalyst_voices_repositories/src/dto/document_data_with_ref_dat.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,11 +30,12 @@ void main() { database = DriftCatalystDatabase(connection); draftsSource = DatabaseDraftsDataSource(database); - localDocuments = DatabaseDocumentsDataSource(database); + localDocuments = DatabaseDocumentsDataSource(database, const CatalystProfiler.noop()); remoteDocuments = _MockDocumentDataRemoteSource(); favoriteDocuments = DatabaseDocumentFavoriteSource(database); repository = DocumentRepositoryImpl( + database, draftsSource, localDocuments, remoteDocuments, @@ -269,28 +271,32 @@ void main() { ); }); - group('insertDocument', () { - test( - 'draft document data is saved', - () async { - // Given - final documentDataToSave = DocumentDataFactory.build( - selfRef: DocumentRefFactory.draftRef(), - ); - - // When - await repository.upsertDocument(document: documentDataToSave); - - // Then - final savedDocumentData = await repository.getDocumentData( - ref: documentDataToSave.metadata.selfRef, - ); - - expect(savedDocumentData, equals(documentDataToSave)); - }, - onPlatform: driftOnPlatforms, - ); - }); + group( + 'insertDocument', + () { + test( + 'draft document data is saved', + () async { + // Given + final documentDataToSave = DocumentDataFactory.build( + selfRef: DocumentRefFactory.draftRef(), + ); + + // When + await repository.upsertDocument(document: documentDataToSave); + + // Then + final savedDocumentData = await repository.getDocumentData( + ref: documentDataToSave.metadata.selfRef, + ); + + expect(savedDocumentData, equals(documentDataToSave)); + }, + onPlatform: driftOnPlatforms, + ); + }, + skip: 'V2 drafts are not yet migrated', + ); test( 'updating proposal draft ' @@ -341,6 +347,7 @@ void main() { ]), ); }, + skip: 'V2 drafts are not yet migrated', onPlatform: driftOnPlatforms, ); @@ -382,6 +389,7 @@ void main() { ]), ); }, + skip: 'V2 drafts are not yet migrated', onPlatform: driftOnPlatforms, ); }); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart index 67815e1a1918..6ea8b131d73c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/campaign/campaign_service.dart @@ -1,7 +1,11 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/src/campaign/active_campaign_observer.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; + +final _logger = Logger('CampaignService'); Campaign? _mockedActiveCampaign; @@ -13,6 +17,11 @@ set mockedActiveCampaign(Campaign? campaign) { _mockedActiveCampaign = campaign; } +typedef _ProposalTemplateCategoryAndMoneyFormat = ({ + SignedDocumentRef? category, + MoneyFormat? moneyFormat, +}); + /// CampaignService provides campaign-related functionality. /// /// [CampaignRepository] is used to get the campaign data. @@ -36,6 +45,12 @@ abstract interface class CampaignService { Future getCampaignPhaseTimeline(CampaignPhaseType stage); Future getCategory(SignedDocumentRef ref); + + Future getCategoryTotalAsk({required SignedDocumentRef ref}); + + Stream watchCampaignTotalAsk({required ProposalsTotalAskFilters filters}); + + Stream watchCategoryTotalAsk({required SignedDocumentRef ref}); } final class CampaignServiceImpl implements CampaignService { @@ -68,22 +83,17 @@ final class CampaignServiceImpl implements CampaignService { required String id, }) async { final campaign = await _campaignRepository.getCampaign(id: id); - final campaignProposals = await _proposalRepository.getProposals( - type: ProposalsFilterType.finals, - ); final proposalSubmissionTime = campaign .phaseStateTo(CampaignPhaseType.proposalSubmission) .phase .timeline .to; - final totalAsk = _calculateTotalAsk(campaignProposals); - final updatedCategories = await _updateCategories( - campaign.categories, - proposalSubmissionTime, - ); + + final updatedCategories = campaign.categories + .map((e) => e.copyWith(submissionCloseDate: proposalSubmissionTime)) + .toList(); return campaign.copyWith( - totalAsk: totalAsk, categories: updatedCategories, ); } @@ -97,7 +107,7 @@ final class CampaignServiceImpl implements CampaignService { final timelineStage = campaign.timeline.phases.firstWhere( (element) => element.type == type, - orElse: () => throw (StateError('Type $type not found')), + orElse: () => throw StateError('Type $type not found'), ); return timelineStage; } @@ -111,53 +121,99 @@ final class CampaignServiceImpl implements CampaignService { ); } - final categoryProposals = await _proposalRepository.getProposals( - type: ProposalsFilterType.finals, - categoryRef: ref, - ); final proposalSubmissionStage = await getCampaignPhaseTimeline( CampaignPhaseType.proposalSubmission, ); - final totalAsk = _calculateTotalAsk(categoryProposals); return category.copyWith( - totalAsk: totalAsk, - proposalsCount: categoryProposals.length, submissionCloseDate: proposalSubmissionStage.timeline.to, ); } - MultiCurrencyAmount _calculateTotalAsk(List proposals) { - final totalAmount = MultiCurrencyAmount(); - for (final proposal in proposals) { - final fundsRequested = proposal.document.fundsRequested; - if (fundsRequested != null) { - totalAmount.add(fundsRequested); - } - } - return totalAmount; + @override + Future getCategoryTotalAsk({required SignedDocumentRef ref}) { + return watchCategoryTotalAsk(ref: ref).first; } - Future> _updateCategories( - List categories, - DateTime? proposalSubmissionTime, - ) async { - final updatedCategories = []; + @override + Stream watchCampaignTotalAsk({required ProposalsTotalAskFilters filters}) { + return _proposalRepository + .watchProposalTemplates(filters: filters.campaign ?? CampaignFilters.active()) + .map((templates) => templates.map((template) => template.toMapEntry())) + .map(Map.fromEntries) + .switchMap((templatesMoneyFormat) { + // This could come from templates + final nodeId = ProposalDocument.requestedFundsNodeId; + + return _campaignRepository + .watchProposalsTotalTask(nodeId: nodeId, filters: filters) + .map((totalAsk) => _calculateCampaignTotalAsk(templatesMoneyFormat, totalAsk)); + }); + } - for (final category in categories) { - final categoryProposals = await _proposalRepository.getProposals( - type: ProposalsFilterType.finals, - categoryRef: category.selfRef, + @override + Stream watchCategoryTotalAsk({required SignedDocumentRef ref}) { + final activeCampaign = _activeCampaignObserver.campaign; + final campaignFilters = activeCampaign != null ? CampaignFilters.from(activeCampaign) : null; + + final filters = ProposalsTotalAskFilters( + categoryId: ref.id, + campaign: campaignFilters, + ); + + return watchCampaignTotalAsk( + filters: filters, + ).map((campaignTotalAsk) => campaignTotalAsk.categoryOrZero(ref)); + } + + CampaignTotalAsk _calculateCampaignTotalAsk( + Map templatesMoneyFormat, + ProposalsTotalAsk totalAsk, + ) { + final categoriesAsks = {}; + + for (final entry in totalAsk.data.entries) { + final templateRef = entry.key; + final categoryRef = templatesMoneyFormat[templateRef]?.category; + final moneyFormat = templatesMoneyFormat[templateRef]?.moneyFormat; + + if (categoryRef == null || moneyFormat == null) { + if (categoryRef == null) _logger.info('Template[$templateRef] do not have category'); + if (moneyFormat == null) _logger.info('Template[$templateRef] do not have moneyFormat'); + continue; + } + + final proposalTotalAsk = entry.value; + final finalProposalsCount = proposalTotalAsk.finalProposalsCount; + final money = Money.fromUnits( + currency: moneyFormat.currency, + amount: BigInt.from(proposalTotalAsk.totalAsk), + moneyUnits: moneyFormat.moneyUnits, ); - final totalAsk = _calculateTotalAsk(categoryProposals); - final updatedCategory = category.copyWith( - totalAsk: totalAsk, - proposalsCount: categoryProposals.length, - submissionCloseDate: proposalSubmissionTime, + final ask = CampaignCategoryTotalAsk( + ref: categoryRef, + finalProposalsCount: finalProposalsCount, + money: [money], ); - updatedCategories.add(updatedCategory); + + categoriesAsks.update(categoryRef, (value) => value + ask, ifAbsent: () => ask); } - return updatedCategories; + + return CampaignTotalAsk(categoriesAsks: Map.unmodifiable(categoriesAsks)); + } +} + +extension on ProposalTemplate { + MapEntry toMapEntry() { + final ref = metadata.selfRef; + final category = metadata.categoryId; + + final currencySchema = requestedFunds; + final moneyFormat = currencySchema != null + ? MoneyFormat(currency: currencySchema.currency, moneyUnits: currencySchema.moneyUnits) + : null; + + return MapEntry(ref, (category: category, moneyFormat: moneyFormat)); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart index 1366b021068f..95408a343777 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/documents/documents_service.dart @@ -140,6 +140,11 @@ final class DocumentsServiceImpl implements DocumentsService { onProgress?.call(1); } + // Analyze is kind of expensive so run it when significant amount of docs were added + if (syncResult.newDocumentsCount > 100) { + await _documentRepository.analyzeDatabase(); + } + return syncResult; } @@ -254,20 +259,19 @@ final class DocumentsServiceImpl implements DocumentsService { DocumentIndex index, Set exclude, Set excludeIds, - ) { - return index.docs + ) async { + final refs = index.docs .map((e) => e.refs(exclude: exclude)) .expand((refs) => refs) .where((ref) => !excludeIds.contains(ref.id)) .toSet() - .map((ref) { - return _documentRepository - .isCached(ref: ref) - .onError((_, _) => false) - .then((value) => value ? null : ref); - }) - .wait - .then((refs) => refs.nonNulls.toList()); + .toList(); + + final cachedRefs = await _documentRepository.isCachedBulk(refs: refs); + + refs.removeWhere(cachedRefs.contains); + + return refs.toList(); } /// Fetches the [DocumentData] for a list of [SignedDocumentRef]s concurrently. diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart index 9ece65eec2f7..bc5f71efd2a5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart @@ -1,6 +1,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; @@ -15,6 +16,7 @@ abstract interface class ProposalService { SignerService signerService, ActiveCampaignObserver activeCampaignObserver, CastedVotesObserver castedVotesObserver, + VotingBallotBuilder ballotBuilder, ) = ProposalServiceImpl; Future addFavoriteProposal({ @@ -130,10 +132,20 @@ abstract interface class ProposalService { /// Streams changes to [isMaxProposalsLimitReached]. Stream watchMaxProposalsLimitReached(); + Stream> watchProposalsBriefPageV2({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + Stream watchProposalsCount({ required ProposalsCountFilters filters, }); + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); + Stream> watchProposalsPage({ required PageRequest request, required ProposalsFilters filters, @@ -152,6 +164,7 @@ final class ProposalServiceImpl implements ProposalService { final SignerService _signerService; final ActiveCampaignObserver _activeCampaignObserver; final CastedVotesObserver _castedVotesObserver; + final VotingBallotBuilder _ballotBuilder; const ProposalServiceImpl( this._proposalRepository, @@ -160,15 +173,12 @@ final class ProposalServiceImpl implements ProposalService { this._signerService, this._activeCampaignObserver, this._castedVotesObserver, + this._ballotBuilder, ); @override Future addFavoriteProposal({required DocumentRef ref}) { - return _documentRepository.updateDocumentFavorite( - ref: ref.toLoose(), - type: DocumentType.proposalDocument, - isFavorite: true, - ); + return _proposalRepository.updateProposalFavorite(id: ref.id, isFavorite: true); } @override @@ -240,17 +250,13 @@ final class ProposalServiceImpl implements ProposalService { } @override - Future getLatestProposalVersion({ - required DocumentRef ref, - }) async { - final proposalVersions = await _documentRepository.getAllVersionsOfId( - id: ref.id, - ); - final refList = List.from( - proposalVersions.map((e) => e.metadata.selfRef).toList(), - )..sort(); + Future getLatestProposalVersion({required DocumentRef ref}) async { + final latest = await _documentRepository.getLatestOf(ref: ref); + if (latest == null) { + throw DocumentNotFoundException(ref: ref); + } - return refList.last; + return latest; } @override @@ -384,7 +390,7 @@ final class ProposalServiceImpl implements ProposalService { // where version timestamp is not older than a predefined interval. // Because of it we're regenerating a version just before publishing. final freshRef = originalRef.freshVersion(); - final freshDocument = document.copyWithSelfRef(selfRef: freshRef); + final freshDocument = document.copyWith(selfRef: freshRef); await _signerService.useProposerCredentials( (catalystId, privateKey) { @@ -405,11 +411,7 @@ final class ProposalServiceImpl implements ProposalService { @override Future removeFavoriteProposal({required DocumentRef ref}) { - return _documentRepository.updateDocumentFavorite( - ref: ref.toLoose(), - type: DocumentType.proposalDocument, - isFavorite: false, - ); + return _proposalRepository.updateProposalFavorite(id: ref.id, isFavorite: false); } @override @@ -509,6 +511,37 @@ final class ProposalServiceImpl implements ProposalService { }); } + @override + Stream> watchProposalsBriefPageV2({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final proposals = _adaptFilters(filters).switchMap( + (effectiveFilters) { + return _proposalRepository.watchProposalsBriefPage( + request: request, + order: order, + filters: effectiveFilters, + ); + }, + ); + + final draftVotes = _ballotBuilder.watchVotes; + final castedVotes = _castedVotesObserver.watchCastedVotes; + + return Rx.combineLatest3( + proposals, + draftVotes, + castedVotes, + (page, draftVotes, castedVotes) { + return page.map( + (proposal) => _mapJoinedProposalBriefData(proposal, draftVotes, castedVotes), + ); + }, + ); + } + @override Stream watchProposalsCount({ required ProposalsCountFilters filters, @@ -525,6 +558,17 @@ final class ProposalServiceImpl implements ProposalService { }); } + @override + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + return _adaptFilters(filters).switchMap( + (effectiveFilters) { + return _proposalRepository.watchProposalsCountV2(filters: effectiveFilters); + }, + ); + } + @override Stream> watchProposalsPage({ required PageRequest request, @@ -628,6 +672,17 @@ final class ProposalServiceImpl implements ProposalService { }); } + // TODO(damian-molinski): Remove this when voteBy is implemented. + Stream _adaptFilters(ProposalsFiltersV2 filters) { + if (filters.voteBy == null) { + return Stream.value(filters); + } + + return _castedVotesObserver.watchCastedVotes + .map((votes) => votes.map((e) => e.proposal.id).toList()) + .map((ids) => filters.copyWith(voteBy: const Optional.empty(), ids: Optional(ids))); + } + Future> _createProposalDataStream( ProposalDocument doc, ) async { @@ -695,6 +750,38 @@ final class ProposalServiceImpl implements ProposalService { return user.activeAccount?.roles.contains(AccountRole.proposer) ?? false; } + ProposalBriefData _mapJoinedProposalBriefData( + JoinedProposalBriefData data, + List draftVotes, + List castedVotes, + ) { + final proposal = data.proposal; + final isFinal = data.isFinal; + + final draftVote = isFinal + ? draftVotes.firstWhereOrNull((vote) => vote.proposal == proposal.selfRef) + : null; + final castedVote = isFinal + ? castedVotes.firstWhereOrNull((vote) => vote.proposal == proposal.selfRef) + : null; + + return ProposalBriefData( + selfRef: proposal.selfRef, + authorName: proposal.authorName ?? '', + title: proposal.title ?? '', + description: proposal.description ?? '', + categoryName: proposal.categoryName ?? '', + durationInMonths: proposal.durationInMonths ?? 0, + fundsRequested: proposal.fundsRequested ?? Money.zero(currency: Currencies.fallback), + createdAt: proposal.selfRef.version!.dateTime, + iteration: data.iteration, + commentsCount: isFinal ? null : data.commentsCount, + isFinal: isFinal, + isFavorite: data.isFavorite, + votes: isFinal ? ProposalBriefDataVotes(draft: draftVote, casted: castedVote) : null, + ); + } + Future> _mapProposalDataPage(Page page) async { final proposals = await page.items.map( (item) async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/sync/sync_manager.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/sync/sync_manager.dart index 73fa0847f8c2..332547039f0e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/sync/sync_manager.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/sync/sync_manager.dart @@ -15,6 +15,7 @@ abstract interface class SyncManager { SyncStatsStorage statsStorage, DocumentsService documentsService, CampaignService campaignService, + CatalystProfiler profiler, ) = SyncManagerImpl; /// Stream of synchronization progress (0.0 to 1.0). @@ -32,6 +33,7 @@ final class SyncManagerImpl implements SyncManager { final SyncStatsStorage _statsStorage; final DocumentsService _documentsService; final CampaignService _campaignService; + final CatalystProfiler _profiler; final _lock = Lock(); final _progressController = StreamController.broadcast(); @@ -44,6 +46,7 @@ final class SyncManagerImpl implements SyncManager { this._statsStorage, this._documentsService, this._campaignService, + this._profiler, ); @override @@ -88,7 +91,12 @@ final class SyncManagerImpl implements SyncManager { _synchronizationCompleter = Completer(); } + final timeline = _profiler.startTransaction('sync'); + final timelineArgs = CatalystProfilerTimelineFinishArguments(); final stopwatch = Stopwatch()..start(); + + var syncResult = const DocumentsSyncResult(); + try { _logger.fine('Synchronization started'); @@ -104,7 +112,7 @@ final class SyncManagerImpl implements SyncManager { _progressController.add(0); } - final result = await _documentsService.sync( + syncResult = await _documentsService.sync( campaign: activeCampaign, onProgress: (value) { if (!_progressController.isClosed) { @@ -113,22 +121,28 @@ final class SyncManagerImpl implements SyncManager { }, ); - stopwatch.stop(); - - await _updateSuccessfulSyncStats( - newRefsCount: result.newDocumentsCount, - duration: stopwatch.elapsed, - ); + unawaited(timeline.finish()); - _logger.fine('Synchronization completed. New documents: ${result.newDocumentsCount}'); + _logger.fine('Synchronization completed. New documents: ${syncResult.newDocumentsCount}'); - if (result.failedDocumentsCount > 0) { - _logger.info('Synchronization failed for documents: ${result.failedDocumentsCount}'); + if (syncResult.failedDocumentsCount > 0) { + _logger.info('Synchronization failed for documents: ${syncResult.failedDocumentsCount}'); } + timelineArgs + ..status = 'success' + ..hint = + 'new docs[${syncResult.newDocumentsCount}], ' + 'failed docs[${syncResult.failedDocumentsCount}]'; + _synchronizationCompleter.complete(true); } catch (error, stack) { - _logger.fine('Synchronization failed after ${stopwatch.elapsed}', error, stack); + _logger.fine('Synchronization failed', error, stack); + + timelineArgs + ..status = 'failed' + ..throwable = error; + _synchronizationCompleter.complete(false); rethrow; @@ -138,8 +152,15 @@ final class SyncManagerImpl implements SyncManager { } stopwatch.stop(); - - _logger.fine('Synchronization took ${stopwatch.elapsed}'); + timelineArgs.took = stopwatch.elapsed; + + unawaited(timeline.finish(arguments: timelineArgs)); + unawaited( + _updateSuccessfulSyncStats( + newRefsCount: syncResult.newDocumentsCount, + duration: stopwatch.elapsed, + ), + ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart index acd21bfb1ebf..64aac0907c43 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/proposal/proposal_service_test.dart @@ -15,6 +15,7 @@ void main() { late MockUserService mockUserService; late MockSignerService mockSignerService; late MockCastedVotesObserver mockCastedVotesObserver; + late VotingBallotBuilder ballotBuilder; late ProposalService proposalService; @@ -25,6 +26,7 @@ void main() { mockUserService = MockUserService(); mockActiveCampaignObserver = MockActiveCampaignObserver(); mockCastedVotesObserver = MockCastedVotesObserver(); + ballotBuilder = VotingBallotLocalBuilder(); proposalService = ProposalService( mockProposalRepository, @@ -33,6 +35,7 @@ void main() { mockSignerService, mockActiveCampaignObserver, mockCastedVotesObserver, + ballotBuilder, ); registerFallbackValue(const SignedDocumentRef(id: 'fallback-id')); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/sync/sync_manager_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/sync/sync_manager_test.dart index bfec89ba8a91..e894e6a6940b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/sync/sync_manager_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/sync/sync_manager_test.dart @@ -4,6 +4,7 @@ import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -41,6 +42,7 @@ void main() { statsStorage, documentsService, campaignService, + const CatalystProfiler.noop(), ); }); diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/utils/document_node_traverser.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/utils/document_node_traverser.dart index 6be71c1a86b9..c29d3c49f97d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/utils/document_node_traverser.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/utils/document_node_traverser.dart @@ -30,7 +30,7 @@ final class DocumentNodeTraverser { /// the paths defined in the [nodeId]. If the specified path exists, the /// corresponding property value is returned. If the path is invalid or does /// not exist, the method returns `null`. - static Object? getValue(DocumentNodeId nodeId, Map data) { + static T? getValue(DocumentNodeId nodeId, Map data) { Object? object = data; for (final path in nodeId.paths) { if (object is Map) { @@ -47,6 +47,6 @@ final class DocumentNodeTraverser { } } - return object; + return object is T ? object : null; } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_console_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_console_profiler.dart new file mode 100644 index 000000000000..6cacad925ec4 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_console_profiler.dart @@ -0,0 +1,168 @@ +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; + +final _debounce = Debouncer(); +final _debounceMaxDuration = {}; + +final class CatalystConsoleProfiler implements CatalystProfiler { + const CatalystConsoleProfiler(); + + @override + CatalystProfilerTimeline startTransaction( + String name, { + CatalystProfilerTimelineArguments? arguments, + }) { + return _Timeline(name, arguments: arguments); + } + + @override + Future time( + String name, + AsyncOrValueGetter body, { + CatalystProfilerTimelineArguments? arguments, + }) { + return _Timeline('', arguments: arguments).timeWithResult(name, body); + } + + @override + Future timeWithResult( + String name, + AsyncOrValueGetter body, { + CatalystProfilerTimelineArguments? arguments, + bool debounce = false, + }) { + return _Timeline('', arguments: arguments).timeWithResult(name, body, debounce: debounce); + } +} + +class _Timeline implements CatalystProfilerTimeline { + final String name; + final CatalystProfilerTimelineArguments? arguments; + final Stopwatch _stopwatch; + + _Timeline( + this.name, { + this.arguments, + }) : _stopwatch = Stopwatch()..start(); + + @override + bool get finished => !_stopwatch.isRunning; + + @override + Future finish({CatalystProfilerTimelineFinishArguments? arguments}) async { + _stopwatch.stop(); + + final buffer = StringBuffer(name); + + final startArgs = this.arguments?.toMap() ?? {}; + if (startArgs.isNotEmpty) { + buffer.write(' $startArgs'); + } + + final effectiveArguments = (arguments ?? CatalystProfilerTimelineFinishArguments()) + ..took = _stopwatch.elapsed; + + final args = effectiveArguments.toMap(); + if (args.isNotEmpty) { + buffer.write(' $args'); + } + + debugPrint('$buffer'); + } + + @override + CatalystProfilerTimelineTask startTask( + String name, { + CatalystProfilerTimelineTaskArguments? arguments, + }) { + return _TimelineTask('${this.name}.$name', arguments: arguments); + } + + @override + Future time( + String name, + AsyncOrValueGetter body, { + CatalystProfilerTimelineTaskArguments? arguments, + }) { + return timeWithResult(name, body, arguments: arguments); + } + + @override + Future timeWithResult( + String name, + AsyncOrValueGetter body, { + CatalystProfilerTimelineTaskArguments? arguments, + bool debounce = false, + }) async { + final buffer = StringBuffer(name); + final args = arguments?.toMap() ?? {}; + if (args.isNotEmpty) { + buffer.write(' $args'); + } + + final stopwatch = Stopwatch()..start(); + try { + return await body(); + } finally { + stopwatch.stop(); + + final name = buffer.toString(); + final elapsed = stopwatch.elapsed; + + if (debounce) { + _debounceMaxDuration.update( + name, + (value) => value < elapsed ? elapsed : value, + ifAbsent: () => elapsed, + ); + _debounce.run( + () { + final elapsed = _debounceMaxDuration.remove(name); + debugPrint('$name took $elapsed'); + }, + ); + } else { + debugPrint('$name took $elapsed'); + } + } + } +} + +class _TimelineTask implements CatalystProfilerTimelineTask { + final String name; + final Stopwatch _stopwatch; + final CatalystProfilerTimelineTaskArguments? arguments; + + _TimelineTask( + this.name, { + this.arguments, + }) : _stopwatch = Stopwatch()..start(); + + @override + Future finish({ + CatalystProfilerTimelineTaskFinishArguments? arguments, + }) async { + _stopwatch.stop(); + + final buffer = StringBuffer(name); + + final effectiveArguments = arguments ?? CatalystProfilerTimelineTaskFinishArguments() + ..took ??= _stopwatch.elapsed; + + final args = effectiveArguments.toMap(); + + if (args.isNotEmpty) { + buffer.write(' $args'); + } + + debugPrint('$buffer'); + } + + @override + CatalystProfilerTimelineTask startTask( + String name, { + CatalystProfilerTimelineTaskArguments? arguments, + }) { + return _TimelineTask('${this.name}.$name', arguments: arguments); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_developer_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_developer_profiler.dart index cc193fa3173d..5ff81a646b30 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_developer_profiler.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_developer_profiler.dart @@ -99,6 +99,7 @@ final class CatalystDeveloperProfiler implements CatalystProfiler { String name, AsyncOrValueGetter body, { CatalystProfilerTimelineArguments? arguments, + bool debounce = false, }) async { final transaction = startTransaction(name, arguments: arguments); final finishArgs = CatalystProfilerTimelineFinishArguments(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_noop_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_noop_profiler.dart index 5182382b615f..c01f801ee953 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_noop_profiler.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_noop_profiler.dart @@ -28,6 +28,7 @@ final class CatalystNoopProfiler implements CatalystProfiler { String name, FutureOr Function() body, { CatalystProfilerTimelineArguments? arguments, + bool debounce = false, }) async { return body(); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_profiler.dart index d30fd0a7658f..4125546658fa 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_profiler.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_profiler.dart @@ -1,8 +1,18 @@ import 'dart:async'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; abstract interface class CatalystProfiler { + const factory CatalystProfiler.console() = CatalystConsoleProfiler; + + factory CatalystProfiler.developer(CatalystDeveloperProfilerConfig config) = + CatalystDeveloperProfiler.fromConfig; + + const factory CatalystProfiler.noop() = CatalystNoopProfiler; + + const factory CatalystProfiler.sentry() = CatalystSentryProfiler; + CatalystProfilerTimeline startTransaction( String name, { CatalystProfilerTimelineArguments? arguments, @@ -18,6 +28,7 @@ abstract interface class CatalystProfiler { String name, AsyncOrValueGetter body, { CatalystProfilerTimelineArguments? arguments, + bool debounce, }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_runtime_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_runtime_profiler.dart deleted file mode 100644 index f7004502923d..000000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_runtime_profiler.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:flutter/foundation.dart'; - -class CatalystRuntimeProfiler extends CatalystBaseProfiler { - CatalystRuntimeProfiler(super.delegate); - - Future brotliCompress({ - String? name, - required AsyncValueGetter body, - }) async { - assert(ongoing, 'Runtime profiler already finished'); - - final taskName = name != null ? 'brotli_compress_$name' : 'brotli_compress'; - return timeline!.timeWithResult(taskName, body); - } - - Future brotliDecompress({ - String? name, - required AsyncValueGetter body, - }) async { - assert(ongoing, 'Runtime profiler already finished'); - - final taskName = name != null ? 'brotli_decompress_$name' : 'brotli_decompress'; - return timeline!.timeWithResult(taskName, body); - } - - Future cborDecode({ - String? name, - required AsyncValueGetter body, - }) async { - assert(ongoing, 'Runtime profiler already finished'); - - final taskName = name != null ? 'cbor_decode_$name' : 'cbor_decode'; - return timeline!.timeWithResult(taskName, body); - } - - Future coseParse({ - String? name, - required AsyncValueGetter body, - }) async { - assert(ongoing, 'Runtime profiler already finished'); - - final taskName = name != null ? 'cose_parse_$name' : 'cose_parse'; - return timeline!.timeWithResult(taskName, body); - } - - Future coseSign({ - String? name, - required AsyncValueGetter body, - }) async { - assert(ongoing, 'Runtime profiler already finished'); - - final taskName = name != null ? 'cose_sign_$name' : 'cose_sign'; - return timeline!.timeWithResult(taskName, body); - } - - @override - void start({ - required DateTime at, - }) { - assert(!ongoing, 'Runtime profiler already initialized'); - - timeline = delegate.startTransaction( - 'Runtime', - arguments: CatalystProfilerTimelineArguments( - operation: 'runtime_operations', - description: 'Measuring performance of runtime operations throughout app lifecycle', - startTimestamp: at, - ), - ); - } -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_sentry_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_sentry_profiler.dart index 7dd84fe38bc3..7996ebd1f619 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_sentry_profiler.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_sentry_profiler.dart @@ -51,6 +51,7 @@ final class CatalystSentryProfiler implements CatalystProfiler { String name, FutureOr Function() body, { CatalystProfilerTimelineArguments? arguments, + bool debounce = false, }) async { final transaction = startTransaction(name, arguments: arguments); final clock = Stopwatch(); diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_startup_profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_startup_profiler.dart index 4736694910eb..e3e03f206e43 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_startup_profiler.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/catalyst_startup_profiler.dart @@ -34,7 +34,7 @@ class CatalystStartupProfiler extends CatalystBaseProfiler { }) async { assert(ongoing, 'Startup profiler already finished'); - return timeline!.time('documents_sync', body); + return timeline!.time('startup_documents_sync', body); } Future imagesCache({ diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/profiler.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/profiler.dart index 09a1d6109436..7e12126571a7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/profiler.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/profiler/profiler.dart @@ -1,8 +1,8 @@ export 'catalyst_base_profiler.dart'; +export 'catalyst_console_profiler.dart'; export 'catalyst_developer_profiler.dart'; export 'catalyst_noop_profiler.dart'; export 'catalyst_profiler.dart'; export 'catalyst_profiler_arguments.dart'; -export 'catalyst_runtime_profiler.dart'; export 'catalyst_sentry_profiler.dart'; export 'catalyst_startup_profiler.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart index e8e9c22f0ece..8866ed41098d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category_view_model.dart @@ -15,7 +15,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { final String subname; final String description; final String shortDescription; - final int proposalsCount; + final int finalProposalsCount; final MultiCurrencyAmount availableFunds; final MultiCurrencyAmount totalAsk; final Range range; @@ -26,12 +26,12 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { final DateTime submissionCloseDate; const CampaignCategoryDetailsViewModel({ - required super.id, + required super.ref, required super.name, required this.subname, required this.description, required this.shortDescription, - required this.proposalsCount, + required this.finalProposalsCount, required this.availableFunds, required this.image, required this.totalAsk, @@ -42,17 +42,21 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { required this.submissionCloseDate, }); - factory CampaignCategoryDetailsViewModel.fromModel(CampaignCategory model) { + factory CampaignCategoryDetailsViewModel.fromModel( + CampaignCategory model, { + required int finalProposalsCount, + required MultiCurrencyAmount totalAsk, + }) { return CampaignCategoryDetailsViewModel( - id: model.selfRef, + ref: model.selfRef, name: model.categoryName, subname: model.categorySubname, description: model.description, shortDescription: model.shortDescription, - proposalsCount: model.proposalsCount, + finalProposalsCount: finalProposalsCount, availableFunds: model.availableFunds, image: CategoryImageUrl.image(model.selfRef.id), - totalAsk: model.totalAsk, + totalAsk: totalAsk, range: model.range, descriptions: model.descriptions.map(CategoryDescriptionViewModel.fromModel).toList(), dos: model.dos, @@ -67,13 +71,13 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { /// such as when wrapping widgets with Skeletonizer during data loading. factory CampaignCategoryDetailsViewModel.placeholder({String? id}) { return CampaignCategoryDetailsViewModel( - id: SignedDocumentRef(id: id ?? const Uuid().v7()), + ref: SignedDocumentRef(id: id ?? const Uuid().v7()), name: 'Cardano Open:', subname: 'Developers', description: '''Supports development of open source technology, centered around improving the Cardano developer experience and creating developer-friendly tooling that streamlines an integrated development environment.''', shortDescription: '', - proposalsCount: 263, + finalProposalsCount: 263, availableFunds: MultiCurrencyAmount.single( Money.fromMajorUnits( currency: Currencies.ada, @@ -118,7 +122,7 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { ...super.props, subname, description, - proposalsCount, + finalProposalsCount, availableFunds, totalAsk, range, @@ -131,16 +135,16 @@ final class CampaignCategoryDetailsViewModel extends CampaignCategoryViewModel { } final class CampaignCategoryViewModel extends Equatable { - final SignedDocumentRef id; + final SignedDocumentRef ref; final String name; const CampaignCategoryViewModel({ - required this.id, + required this.ref, required this.name, }); @override - List get props => [id, name]; + List get props => [ref, name]; } final class CategoryImageUrl { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart index f90b66b7b7b6..f8d75d013b70 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_timeline_view_model.dart @@ -6,23 +6,16 @@ import 'package:equatable/equatable.dart'; /// /// This view model is used to display the timeline of a campaign. class CampaignTimelineViewModel extends Equatable { - static const List offstagePhases = [ - CampaignPhaseType.reviewRegistration, - ]; - final String title; final String description; final DateRange timeline; final CampaignPhaseType type; - final bool offstage; - const CampaignTimelineViewModel({ required this.title, required this.description, required this.timeline, required this.type, - this.offstage = false, }); factory CampaignTimelineViewModel.fromModel(CampaignPhase model) => CampaignTimelineViewModel( @@ -33,7 +26,6 @@ class CampaignTimelineViewModel extends Equatable { to: model.timeline.to, ), type: model.type, - offstage: offstagePhases.contains(model.type), ); @override @@ -42,6 +34,5 @@ class CampaignTimelineViewModel extends Equatable { description, timeline, type, - offstage, ]; } 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 index 113099cbdb6f..96418ec65bcb 100644 --- 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 @@ -36,15 +36,6 @@ class CurrentCampaignInfoViewModel extends Equatable { ); } - factory CurrentCampaignInfoViewModel.fromModel(Campaign model) { - return CurrentCampaignInfoViewModel( - title: model.name, - allFunds: model.allFunds, - totalAsk: model.totalAsk, - timeline: model.timeline.phases.map(CampaignTimelineViewModel.fromModel).toList(), - ); - } - @override List get props => [ allFunds, diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart index ced184a30124..9797005c557d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart @@ -16,6 +16,7 @@ class ProposalBrief extends Equatable { final DateTime updateDate; final int? commentsCount; final bool isFavorite; + final VoteButtonData? voteData; const ProposalBrief({ required this.selfRef, @@ -30,27 +31,24 @@ class ProposalBrief extends Equatable { required this.updateDate, this.commentsCount, this.isFavorite = false, + this.voteData, }); - factory ProposalBrief.fromProposal( - Proposal proposal, { - bool isFavorite = false, - bool showComments = true, - String categoryName = '', - }) { + factory ProposalBrief.fromData(ProposalBriefData data) { return ProposalBrief( - selfRef: proposal.selfRef, - title: proposal.title, - categoryName: categoryName, - author: proposal.author, - fundsRequested: proposal.fundsRequested, - duration: proposal.duration, - publish: proposal.publish, - description: proposal.description, - versionNumber: proposal.versionNumber, - updateDate: proposal.updateDate, - commentsCount: showComments ? proposal.commentsCount : null, - isFavorite: isFavorite, + selfRef: data.selfRef, + title: data.title, + categoryName: data.categoryName, + author: data.authorName, + fundsRequested: data.fundsRequested, + duration: data.durationInMonths, + publish: data.isFinal ? ProposalPublish.submittedProposal : ProposalPublish.publishedDraft, + description: data.description, + versionNumber: data.iteration, + updateDate: data.createdAt, + commentsCount: data.commentsCount, + isFavorite: data.isFavorite, + voteData: data.votes.toViewModel(), ); } @@ -88,6 +86,7 @@ class ProposalBrief extends Equatable { updateDate, commentsCount, isFavorite, + voteData, ]; ProposalBrief copyWith({ @@ -103,6 +102,7 @@ class ProposalBrief extends Equatable { DateTime? updateDate, Optional? commentsCount, bool? isFavorite, + Optional? voteData, }) { return ProposalBrief( selfRef: selfRef ?? this.selfRef, @@ -117,122 +117,20 @@ class ProposalBrief extends Equatable { updateDate: updateDate ?? this.updateDate, commentsCount: commentsCount.dataOr(this.commentsCount), isFavorite: isFavorite ?? this.isFavorite, + voteData: voteData.dataOr(this.voteData), ); } } -class ProposalBriefVoting extends ProposalBrief { - final VoteButtonData voteData; - - const ProposalBriefVoting({ - required super.selfRef, - required super.title, - required super.categoryName, - required super.fundsRequested, - required super.duration, - required super.publish, - required super.description, - required super.versionNumber, - required super.updateDate, - super.commentsCount, - super.isFavorite, - super.author, - required this.voteData, - }); - - factory ProposalBriefVoting.fromProposal( - Proposal proposal, { - bool isFavorite = false, - bool showComments = true, - String categoryName = '', - Vote? draftVote, - Vote? lastCastedVote, - }) { - return ProposalBriefVoting( - selfRef: proposal.selfRef, - title: proposal.title, - categoryName: categoryName, - author: proposal.author, - fundsRequested: proposal.fundsRequested, - duration: proposal.duration, - publish: proposal.publish, - description: proposal.description, - versionNumber: proposal.versionNumber, - updateDate: proposal.updateDate, - commentsCount: showComments ? proposal.commentsCount : null, - isFavorite: isFavorite, - voteData: VoteButtonData.fromVotes( - currentDraft: draftVote, - lastCasted: lastCastedVote, - ), - ); - } - - factory ProposalBriefVoting.fromProposalWithContext( - ProposalWithContext data, { - Vote? draftVote, - bool showComments = true, - }) { - final proposal = data.proposal; - final category = data.category; - final userContext = data.user; - - return ProposalBriefVoting( - selfRef: proposal.selfRef, - title: proposal.title, - categoryName: category.formattedCategoryName, - author: proposal.author, - fundsRequested: proposal.fundsRequested, - duration: proposal.duration, - publish: proposal.publish, - description: proposal.description, - versionNumber: proposal.versionNumber, - updateDate: proposal.updateDate, - commentsCount: showComments ? proposal.commentsCount : null, - isFavorite: userContext.isFavorite, - voteData: VoteButtonData.fromVotes( - currentDraft: draftVote, - lastCasted: userContext.lastCastedVote, - ), - ); - } - - @override - List get props => [ - ...super.props, - voteData, - ]; - - @override - ProposalBriefVoting copyWith({ - DocumentRef? selfRef, - String? title, - String? categoryName, - Optional? author, - Money? fundsRequested, - int? duration, - ProposalPublish? publish, - String? description, - int? versionNumber, - DateTime? updateDate, - Optional? commentsCount, - bool? isFavorite, - VoteButtonData? voteData, - }) { - return ProposalBriefVoting( - selfRef: selfRef ?? this.selfRef, - title: title ?? this.title, - categoryName: categoryName ?? this.categoryName, - author: author.dataOr(this.author), - fundsRequested: fundsRequested ?? this.fundsRequested, - duration: duration ?? this.duration, - publish: publish ?? this.publish, - description: description ?? this.description, - versionNumber: versionNumber ?? this.versionNumber, - updateDate: updateDate ?? this.updateDate, - commentsCount: commentsCount.dataOr(this.commentsCount), - isFavorite: isFavorite ?? this.isFavorite, - voteData: voteData ?? this.voteData, +extension on ProposalBriefDataVotes? { + VoteButtonData? toViewModel() { + final instance = this; + if (instance == null) { + return null; + } + return VoteButtonData.fromVotes( + currentDraft: instance.draft, + lastCasted: instance.casted, ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart index 3952e2db981a..20f5f50cf52d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart @@ -1,13 +1 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; - -enum ProposalsPageTab { - total(filter: ProposalsFilterType.total), - drafts(filter: ProposalsFilterType.drafts), - finals(filter: ProposalsFilterType.finals), - favorites(filter: ProposalsFilterType.favorites), - my(filter: ProposalsFilterType.my); - - final ProposalsFilterType filter; - - const ProposalsPageTab({required this.filter}); -} +enum ProposalsPageTab { total, drafts, finals, favorites, my } diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/voting/voting_page_tab.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/voting/voting_page_tab.dart index ef6dd17968d6..796df148cf9d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/voting/voting_page_tab.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/voting/voting_page_tab.dart @@ -1,12 +1 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; - -enum VotingPageTab { - total(filter: ProposalsFilterType.finals), - favorites(filter: ProposalsFilterType.favoritesFinals), - my(filter: ProposalsFilterType.myFinals), - votedOn(filter: ProposalsFilterType.voted); - - final ProposalsFilterType filter; - - const VotingPageTab({required this.filter}); -} +enum VotingPageTab { total, favorites, my, votedOn } diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart index 7cce7f4495c5..91239748ac9f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/test/campaign/campaign_stage_test.dart @@ -12,9 +12,6 @@ void main() { allFunds: MultiCurrencyAmount.single( Money.zero(currency: Currencies.ada), ), - totalAsk: MultiCurrencyAmount.single( - Money.zero(currency: Currencies.ada), - ), fundNumber: 1, timeline: const CampaignTimeline(phases: []), categories: const [], diff --git a/catalyst_voices/pubspec.yaml b/catalyst_voices/pubspec.yaml index 205412e1a223..6d89394cee8f 100644 --- a/catalyst_voices/pubspec.yaml +++ b/catalyst_voices/pubspec.yaml @@ -118,6 +118,22 @@ melos: description: | Run `build_runner` in catalyst_voices_repositories package + build-db-schema: + run: | + melos exec --scope="catalyst_voices_repositories" -- dart run drift_dev make-migrations + description: | + Run `make-migrations` in catalyst_voices_repositories package and generates schema migration + classes + + build-db-migration: + run: | + melos exec --scope="catalyst_voices_repositories" -- dart run drift_dev schema steps drift_schemas/catalyst_database lib/src/database/migration/schema_versions.g.dart + melos exec --scope="catalyst_voices_repositories" -- dart run drift_dev schema generate drift_schemas/catalyst_database test/src/database/migration/catalyst_database/generated/ + melos exec --scope="catalyst_voices_repositories" -- dart run drift_dev schema generate --data-classes --companions drift_schemas/catalyst_database/ test/src/database/migration/catalyst_database/generated/ + description: | + Run `drift_dev schema steps` in catalyst_voices_repositories package and generates schema migration steps + classes + compile_svg: run: | cd packages/internal/catalyst_voices_assets && \ @@ -126,13 +142,6 @@ melos: Compile copied SVGs into vector_graphics binary format assets via packages/internal/catalyst_voices_assets/compile_svg.dart. Temporary workaround see packages/internal/catalyst_voices_assets/README.md for context. - db-make-migration: - run: | - melos exec --scope="catalyst_voices_repositories" -- dart run drift_dev make-migrations - description: | - Run `make-migrations` in catalyst_voices_repositories package and generates schema migration - classes - metrics: run: | melos exec -- flutter pub run dart_code_metrics:metrics analyze