From d7db9f8c939204f0ef01dbf8508960f8bbc8b262 Mon Sep 17 00:00:00 2001 From: Ryszard Schossler <51096731+LynxLynxx@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:30:28 +0100 Subject: [PATCH] feat(cat-voices): Proposal setup section (#1177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Adding needed translation * feat: Adding models for guidance and comment * feat: Adding widgets for guidance and comment * feat: Guidance cubit for filtering guidance in the view * test: Adding tests to VoicesDropdown Widget * feat: reacting to changing state of proposal navigation * feat: add new dictionary entry for ryszard-schossler and update guidance extension comments for clarity * fix: empty list of guidance * refactor: applying sugestion from code review * chore: update .gitignore to exclude devtools_options.yaml and remove obsolete files from voices and uikit_example directories * refactor: naming cleanup * Delete catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart * Delete catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart * Delete catalyst_voices/packages/internal/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Co-authored-by: Damian Molinski Co-authored-by: Damian Moliński <47773413+damian-molinski@users.noreply.github.com> --- .config/dictionaries/project.dic | 1 + .../voices/lib/common/ext/guidance_ext.dart | 21 +++ .../workspace/workspace_guidance_view.dart | 87 +++++++++ .../lib/pages/workspace/workspace_page.dart | 22 ++- .../workspace/workspace_setup_panel.dart | 82 ++++++++- .../lib/widgets/cards/comment_card.dart | 51 ++++++ .../lib/widgets/cards/guidance_card.dart | 62 +++++++ .../lib/widgets/dropdown/voices_dropdown.dart | 74 ++++++++ .../navigation/sections_controller.dart | 30 +++ .../dropdown/voices_dropdown_test.dart | 172 ++++++++++++++++++ .../lib/l10n/intl_en.arb | 30 ++- .../lib/src/catalyst_voices_view_models.dart | 3 + .../src/navigation/sections_navigation.dart | 5 + .../lib/src/proposal/comment.dart | 20 ++ .../lib/src/proposal/guidance/guidance.dart | 44 +++++ .../src/proposal/guidance/guidance_type.dart | 11 ++ .../lib/src/workspace/proposal_setup.dart | 1 + .../lib/src/workspace/proposal_solution.dart | 3 + .../lib/src/workspace/proposal_summary.dart | 3 + .../lib/src/workspace/workspace_sections.dart | 2 + 20 files changed, 709 insertions(+), 15 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/common/ext/guidance_ext.dart create mode 100644 catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/cards/comment_card.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/cards/guidance_card.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart create mode 100644 catalyst_voices/apps/voices/test/widgets/dropdown/voices_dropdown_test.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/comment.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance_type.dart diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index f07f6deb191..9288b68c6b9 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -286,6 +286,7 @@ trailings TXNZD txos Typer +ryszard-schossler unawaited unchunk Unlogged diff --git a/catalyst_voices/apps/voices/lib/common/ext/guidance_ext.dart b/catalyst_voices/apps/voices/lib/common/ext/guidance_ext.dart new file mode 100644 index 00000000000..e4ab1f54554 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/common/ext/guidance_ext.dart @@ -0,0 +1,21 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; + +extension GuidanceExt on GuidanceType { + String localizedType(VoicesLocalizations localizations) => switch (this) { + GuidanceType.mandatory => localizations.mandatoryGuidanceType, + GuidanceType.education => localizations.educationGuidanceType, + GuidanceType.tips => localizations.tipsGuidanceType, + }; + + // TODO(ryszard-schossler): when designers will + // provide us with icon, change here accordingly + SvgGenImage get icon { + return switch (this) { + GuidanceType.education => VoicesAssets.icons.newspaper, + GuidanceType.mandatory => VoicesAssets.icons.newspaper, + GuidanceType.tips => VoicesAssets.icons.newspaper, + }; + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart new file mode 100644 index 00000000000..8da86854bf8 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart @@ -0,0 +1,87 @@ +import 'package:catalyst_voices/common/ext/guidance_ext.dart'; +import 'package:catalyst_voices/widgets/cards/guidance_card.dart'; +import 'package:catalyst_voices/widgets/dropdown/voices_dropdown.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 GuidanceView extends StatefulWidget { + final List guidances; + const GuidanceView(this.guidances); + + @override + State createState() => _GuidanceViewState(); +} + +class _GuidanceViewState extends State { + final List filteredGuidances = []; + + GuidanceType? selectedType; + + @override + void initState() { + super.initState(); + filteredGuidances + ..clear() + ..addAll(widget.guidances); + } + + @override + void didUpdateWidget(GuidanceView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.guidances != widget.guidances) { + filteredGuidances + ..clear() + ..addAll(widget.guidances); + _filterGuidances(selectedType); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + VoicesDropdown( + items: GuidanceType.values + .map( + (e) => VoicesDropdownMenuEntry( + label: e.localizedType(context.l10n), + value: e, + context: context, + ), + ) + .toList(), + onChanged: (value) { + setState(() { + _filterGuidances(value); + }); + }, + value: selectedType, + ), + if (filteredGuidances.isEmpty) + Center( + child: Text(context.l10n.noGuidanceOfThisType), + ), + Column( + children: filteredGuidances + .sortedByWeight() + .toList() + .map((e) => GuidanceCard(guidance: e)) + .toList(), + ), + ], + ); + } + + void _filterGuidances(GuidanceType? type) { + selectedType = type; + filteredGuidances + ..clear() + ..addAll( + type == null + ? widget.guidances + : widget.guidances.where((e) => e.type == type).toList(), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_page.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_page.dart index 45db388c0f0..5e06dc2e530 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_page.dart @@ -17,14 +17,15 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -const sections = [ - ProposalSetup( +final sections = [ + const ProposalSetup( id: 0, steps: [ TitleStep( id: 0, sectionId: 0, data: DocumentJson(title), + guidances: mockGuidance, ), ], ), @@ -34,24 +35,29 @@ const sections = [ ProblemStep( id: 0, sectionId: 1, - data: DocumentJson(problemStatement), + data: const DocumentJson(problemStatement), charsLimit: 200, + guidances: [ + mockGuidance[0], + ], ), - SolutionStep( + const SolutionStep( id: 1, sectionId: 1, data: DocumentJson(solutionStatement), charsLimit: 200, + guidances: mockGuidance, ), - PublicDescriptionStep( + const PublicDescriptionStep( id: 2, sectionId: 1, data: DocumentJson(publicDescription), charsLimit: 3000, + guidances: mockGuidance, ), ], ), - ProposalSolution( + const ProposalSolution( id: 2, steps: [ ProblemPerspectiveStep( @@ -74,7 +80,7 @@ const sections = [ ), ], ), - ProposalImpact( + const ProposalImpact( id: 3, steps: [ BonusMarkUpStep( @@ -91,7 +97,7 @@ const sections = [ ), ], ), - CompatibilityAndFeasibility( + const CompatibilityAndFeasibility( id: 4, steps: [ DeliveryAndAccountabilityStep( diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_setup_panel.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_setup_panel.dart index 7d20a385c49..279dc34265d 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_setup_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_setup_panel.dart @@ -1,7 +1,40 @@ +import 'package:catalyst_voices/pages/workspace/workspace_guidance_view.dart'; +import 'package:catalyst_voices/widgets/cards/comment_card.dart'; import 'package:catalyst_voices/widgets/widgets.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'; +const List mockGuidance = [ + Guidance( + title: 'Use a Compelling Hook or Unique Angle', + description: + '''Adding an element of intrigue or a unique approach can make your title stand out. For example, “Revolutionizing Urban Mobility with Eco-Friendly Innovation” not only describes the proposal but also piques curiosity.''', + type: GuidanceType.tips, + weight: 1, + ), + Guidance( + title: 'Be Specific and Solution-Oriented', + description: + '''Use keywords that pinpoint the problem you’re solving or the opportunity you’re capitalizing on. A title like “Streamlining Supply Chains for Cost-Effective and Rapid Delivery” instantly tells the reader what the proposal aims to achieve.''', + type: GuidanceType.mandatory, + weight: 2, + ), + Guidance( + title: 'Highlight the Benefit or Outcome', + description: + '''Make sure the reader can immediately see the value or the end result of your proposal. A title like “Boosting Engagement and Growth through Targeted Digital Strategies” puts the focus on the positive outcomes.''', + type: GuidanceType.mandatory, + weight: 1, + ), + Guidance( + title: 'Education', + description: 'Use keywords that pinpoint the problem yo', + type: GuidanceType.education, + weight: 1, + ), +]; + class WorkspaceSetupPanel extends StatelessWidget { const WorkspaceSetupPanel({super.key}); @@ -14,17 +47,54 @@ class WorkspaceSetupPanel extends StatelessWidget { tabs: [ SpaceSidePanelTab( name: 'Guidance', - body: const Offstage(), + body: SetupSectionListener( + SectionsControllerScope.of(context), + ), ), SpaceSidePanelTab( name: 'Comments', - body: const Offstage(), - ), - SpaceSidePanelTab( - name: 'Actions', - body: const Offstage(), + body: CommentCard( + comment: Comment( + text: 'Lacks clarity on key objectives and measurable outcomes.', + date: DateTime.now(), + userName: 'Community Member', + ), + ), ), + //No actions for now + // SpaceSidePanelTab( + // name: 'Actions', + // body: const Offstage(), + // ), ], ); } } + +class SetupSectionListener extends StatelessWidget { + final SectionsController _controller; + + const SetupSectionListener( + this._controller, { + super.key, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) { + final activeStepId = value.activeStepId; + final activeStepGuidances = value.activeStepGuidances; + + if (activeStepId == null) { + return Text(context.l10n.selectASection); + } else if (activeStepGuidances == null || activeStepGuidances.isEmpty) { + return Text(context.l10n.noGuidanceForThisSection); + } else { + return GuidanceView(activeStepGuidances); + } + }, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/comment_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/comment_card.dart new file mode 100644 index 00000000000..e1df06de0f1 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/cards/comment_card.dart @@ -0,0 +1,51 @@ +import 'package:catalyst_voices/widgets/avatars/voices_avatar.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class CommentCard extends StatelessWidget { + final Comment comment; + + const CommentCard({ + super.key, + required this.comment, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(.38), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + VoicesAssets.icons.chatAlt.buildIcon(), + const SizedBox(height: 10), + Text(comment.text), + const SizedBox(height: 10), + Row( + children: [ + VoicesAvatar(icon: VoicesAssets.icons.user.buildIcon()), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(comment.userName), + Text(comment.date.toString()), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/guidance_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/guidance_card.dart new file mode 100644 index 00000000000..17e2aae73e0 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/cards/guidance_card.dart @@ -0,0 +1,62 @@ +import 'package:catalyst_voices/common/ext/guidance_ext.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class GuidanceCard extends StatelessWidget { + final Guidance guidance; + + const GuidanceCard({ + super.key, + required this.guidance, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colors.onSurfacePrimary08, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + guidance.type.icon.buildIcon(), + const SizedBox(width: 8), + Text( + _buildTypeTitle(context), + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + const SizedBox(height: 10), + Text( + guidance.title, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(color: Theme.of(context).colors.textOnPrimary), + ), + const SizedBox(height: 10), + Text( + guidance.description, + ), + ], + ), + ), + ), + ); + } + + String _buildTypeTitle(BuildContext context) => + '${guidance.type.localizedType(context.l10n)} ${guidance.weightText}'; +} diff --git a/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart b/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart new file mode 100644 index 00000000000..0caa1b48e3d --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart @@ -0,0 +1,74 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class VoicesDropdown extends StatelessWidget { + final List> items; + final ValueChanged? onChanged; + final T? value; + const VoicesDropdown({ + super.key, + required this.items, + this.onChanged, + this.value, + }); + + @override + Widget build(BuildContext context) { + final ctx = Theme.of(context); + return DropdownMenu( + dropdownMenuEntries: [ + VoicesDropdownMenuEntry( + value: null, + label: context.l10n.all, + context: context, + ), + ...items, + ], + onSelected: onChanged, + initialSelection: value, + enableSearch: false, + requestFocusOnTap: false, + enableFilter: false, + inputDecorationTheme: const InputDecorationTheme( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + menuStyle: MenuStyle( + padding: WidgetStateProperty.all(EdgeInsets.zero), + visualDensity: VisualDensity.compact, + ), + trailingIcon: VoicesAssets.icons.chevronDown + .buildIcon(color: ctx.colorScheme.primary), + selectedTrailingIcon: VoicesAssets.icons.chevronUp + .buildIcon(color: ctx.colorScheme.primary), + textAlign: TextAlign.end, + textStyle: + ctx.textTheme.labelLarge?.copyWith(color: ctx.colorScheme.primary), + ); + } +} + +class VoicesDropdownMenuEntry extends DropdownMenuEntry { + final BuildContext context; + VoicesDropdownMenuEntry({ + required super.value, + required super.label, + required this.context, + ButtonStyle? style, + }) : super( + style: style ?? _createButtonStyle(context), + ); + + static ButtonStyle _createButtonStyle(BuildContext context) => ButtonStyle( + textStyle: WidgetStateProperty.all( + Theme.of(context).textTheme.labelLarge, + ), + foregroundColor: + WidgetStateProperty.all(Theme.of(context).colorScheme.primary), + visualDensity: VisualDensity.compact, + ); +} diff --git a/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart b/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart index 2de195aff90..f3dc14e24be 100644 --- a/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart +++ b/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart @@ -12,16 +12,31 @@ final class SectionsControllerState extends Equatable { final Set openedSections; final SectionStepId? activeStepId; final Set editStepsIds; + final GuidanceType? activeGuidance; const SectionsControllerState({ this.sections = const [], this.openedSections = const {}, this.activeStepId, this.editStepsIds = const {}, + this.activeGuidance, }); int? get activeSectionId => activeStepId?.sectionId; + int? get activeStep => activeStepId?.stepId; + + List? get activeStepGuidances { + final activeStepId = this.activeStepId; + if (activeStepId == null) { + return null; + } else { + return sections[activeStepId.sectionId] + .steps[activeStepId.stepId] + .guidances; + } + } + bool get allSegmentsClosed => openedSections.isEmpty; List get listItems { @@ -61,12 +76,14 @@ final class SectionsControllerState extends Equatable { Set? openedSections, Optional? activeStepId, Set? editStepsIds, + Optional? activeGuidance, }) { return SectionsControllerState( sections: sections ?? this.sections, openedSections: openedSections ?? this.openedSections, activeStepId: activeStepId.dataOr(this.activeStepId), editStepsIds: editStepsIds ?? this.editStepsIds, + activeGuidance: activeGuidance?.dataOr(this.activeGuidance), ); } @@ -76,6 +93,7 @@ final class SectionsControllerState extends Equatable { openedSections, activeStepId, editStepsIds, + activeGuidance, ]; } @@ -121,6 +139,14 @@ final class SectionsController extends ValueNotifier { } } + //If user want to expand/hide segment and active step is not in the same section + //it will not change the active section. + //check if activeStepId is not null because if it is it should select + //the first section of this segment id. + if (value.activeSectionId != id && value.activeStepId != null) { + activeStepId = Optional.of(value.activeStepId!); + } + value = value.copyWith( openedSections: openedSections, activeStepId: activeStepId, @@ -157,6 +183,10 @@ final class SectionsController extends ValueNotifier { ); } + void setActiveGuidance(GuidanceType? type) { + value = value.copyWith(activeGuidance: Optional(type)); + } + @override void dispose() { detachItemsScrollController(); diff --git a/catalyst_voices/apps/voices/test/widgets/dropdown/voices_dropdown_test.dart b/catalyst_voices/apps/voices/test/widgets/dropdown/voices_dropdown_test.dart new file mode 100644 index 00000000000..f2d9bba5d70 --- /dev/null +++ b/catalyst_voices/apps/voices/test/widgets/dropdown/voices_dropdown_test.dart @@ -0,0 +1,172 @@ +import 'package:catalyst_voices/widgets/dropdown/voices_dropdown.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('VoicesDropdown Widget Tests', () { + late List> items; + setUp(() { + items = [ + const DropdownMenuEntry( + value: 'item1', + label: 'Item 1', + ), + const DropdownMenuEntry( + value: 'item2', + label: 'Item 2', + ), + ]; + }); + + testWidgets('renders correctly with initial value', (tester) async { + await tester.pumpApp( + Scaffold( + body: VoicesDropdown( + items: items, + value: 'item1', + onChanged: (value) {}, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify the dropdown is rendered + expect(find.byType(DropdownMenu), findsOneWidget); + + // Find the input field showing the selected value + expect( + find.byWidgetPredicate( + (widget) => + widget is EditableText && widget.controller.text == 'Item 1', + ), + findsOneWidget, + ); + }); + + testWidgets('renders correctly without initial value', (tester) async { + await tester.pumpApp( + Scaffold( + body: VoicesDropdown( + items: items, + onChanged: (value) {}, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify the dropdown is rendered + expect(find.byType(DropdownMenu), findsOneWidget); + + // Find the input field showing the selected value + expect( + find.byWidgetPredicate( + (widget) => widget is EditableText && widget.controller.text == 'All', + ), + findsOneWidget, + ); + }); + + testWidgets('renders correctly without initial value', (tester) async { + await tester.pumpApp( + Scaffold( + body: VoicesDropdown( + items: items, + onChanged: (value) {}, + ), + ), + ); + await tester.pumpAndSettle(); + // Verify the dropdown is rendered + expect(find.byType(DropdownMenu), findsOneWidget); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + // // Find the input field showing the selected value + expect( + find.text('All').last, + findsOneWidget, + ); + expect( + find.text('Item 1').last, + findsOneWidget, + ); + expect( + find.text('Item 2').last, + findsOneWidget, + ); + }); + + testWidgets('Changes value correctly', (tester) async { + await tester.pumpApp( + Scaffold( + body: VoicesDropdown( + items: items, + onChanged: (value) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownMenu), findsOneWidget); + + // Find the input field showing the selected value + expect( + find.byWidgetPredicate( + (widget) => widget is EditableText && widget.controller.text == 'All', + ), + findsOneWidget, + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap( + find.text('Item 1').last, + ); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate( + (widget) => + widget is EditableText && widget.controller.text == 'Item 1', + ), + findsOneWidget, + ); + }); + + testWidgets('On change callback is called correctly', (tester) async { + final log = []; + await tester.pumpApp( + Scaffold( + body: VoicesDropdown( + items: items, + onChanged: (value) { + log.add(0); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(log.length, 0); + expect(find.byType(DropdownMenu), findsOneWidget); + + // Find the input field showing the selected value + expect( + find.byWidgetPredicate( + (widget) => widget is EditableText && widget.controller.text == 'All', + ), + findsOneWidget, + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap( + find.text('Item 1').last, + ); + await tester.pumpAndSettle(); + expect(log.length, 1); + }); + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index 1b6dd0ca5bd..de5f9c1f9a1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -958,5 +958,33 @@ "@reviewRegistrationTransaction": { "description": "A button label to review the registration transaction in wallet detail panel." }, - "saveBeforeEditingErrorText": "Please save before editing something else" + "saveBeforeEditingErrorText": "Please save before editing something else", + "mandatoryGuidanceType": "Mandatory", + "@mandatoryGuidanceType": { + "description": "Guidance type label for mandatory guidance" + }, + "educationGuidanceType": "Education", + "@educationGuidanceType": { + "description": "Guidance type label for education guidance" + }, + "tipsGuidanceType": "Tips", + "@tipsGuidanceType": { + "description": "Guidance type label for tips guidance" + }, + "all": "All", + "@all": { + "description": "Primary used to select all object. To display object without any filter" + }, + "noGuidanceOfThisType": "There is no guidance of this type", + "@noGuidanceOfThisType": { + "description": "Message when there is no guidance of this type" + }, + "selectASection": "Select a section", + "@selectASection": { + "description": "Message when there is no section selected" + }, + "noGuidanceForThisSection": "There is no guidance for this section", + "@noGuidanceForThisSection": { + "description": "Message when there is no guidance for this section" + } } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index 6932612b432..f745b4c9141 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -3,6 +3,9 @@ export 'exception/localized_exception.dart'; export 'exception/localized_unknown_exception.dart'; export 'navigation/sections_list_view_item.dart'; export 'navigation/sections_navigation.dart'; +export 'proposal/comment.dart'; +export 'proposal/guidance/guidance.dart'; +export 'proposal/guidance/guidance_type.dart'; export 'registration/exception/localized_registration_exception.dart'; export 'registration/registration.dart'; export 'treasury/treasury_sections.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/navigation/sections_navigation.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/navigation/sections_navigation.dart index 3edb249e211..45a82fb464f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/navigation/sections_navigation.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/navigation/sections_navigation.dart @@ -26,6 +26,8 @@ abstract interface class SectionStep implements SectionsListViewItem { bool get isEditable; + List get guidances; + String localizedName(BuildContext context); } @@ -63,12 +65,15 @@ abstract base class BaseSectionStep extends Equatable implements SectionStep { final bool isEnabled; @override final bool isEditable; + @override + final List guidances; const BaseSectionStep({ required this.id, required this.sectionId, this.isEnabled = true, this.isEditable = true, + this.guidances = const [], }); @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/comment.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/comment.dart new file mode 100644 index 00000000000..ebc514a8171 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/comment.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; + +final class Comment extends Equatable { + final String text; + final DateTime date; + final String userName; + + const Comment({ + required this.text, + required this.date, + required this.userName, + }); + + @override + List get props => [ + text, + date, + userName, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance.dart new file mode 100644 index 00000000000..7cf394c461f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance.dart @@ -0,0 +1,44 @@ +import 'package:catalyst_voices_view_models/src/proposal/guidance/guidance_type.dart'; +import 'package:equatable/equatable.dart'; + +final class Guidance extends Equatable implements Comparable { + final String title; + final String description; + final GuidanceType type; + final int? weight; // This represents how important the guidance is in + //specific [GuidanceType]. + + const Guidance({ + required this.title, + required this.description, + required this.type, + this.weight, + }); + + String get weightText => weight?.toString() ?? ''; + + @override + int compareTo(Guidance other) { + final typeComparison = type.priority.compareTo(other.type.priority); + if (typeComparison != 0) { + return typeComparison; + } + if (weight == null && other.weight == null) return 0; + if (weight == null) return 1; + if (other.weight == null) return -1; + return weight!.compareTo(other.weight!); + } + + @override + List get props => [ + title, + description, + type, + ]; +} + +extension GuidanceExt on List { + List sortedByWeight() { + return [...this]..sort(); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance_type.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance_type.dart new file mode 100644 index 00000000000..7b6ccc6990e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/guidance/guidance_type.dart @@ -0,0 +1,11 @@ +enum GuidanceType { mandatory, education, tips } + +extension GuidanceTypeExt on GuidanceType { + int get priority { + return switch (this) { + GuidanceType.mandatory => 0, // Highest priority + GuidanceType.education => 1, + GuidanceType.tips => 2, // Lowest priority + }; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_setup.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_setup.dart index ea97c9c286c..179ea21663a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_setup.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_setup.dart @@ -17,6 +17,7 @@ final class TitleStep extends RichTextStep { required super.id, required super.sectionId, required super.data, + super.guidances, }); @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_solution.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_solution.dart index fecbfa6cf87..013e1ed37b2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_solution.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_solution.dart @@ -18,6 +18,7 @@ final class ProblemPerspectiveStep extends RichTextStep { required super.sectionId, required super.data, super.charsLimit, + super.guidances, }); @override @@ -37,6 +38,7 @@ final class PerspectiveRationaleStep extends RichTextStep { required super.sectionId, required super.data, super.charsLimit, + super.guidances, }); @override @@ -56,6 +58,7 @@ final class ProjectEngagementStep extends RichTextStep { required super.sectionId, required super.data, super.charsLimit, + super.guidances, }); @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_summary.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_summary.dart index f32ba3c2af0..bed4f23ffcd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_summary.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/proposal_summary.dart @@ -18,6 +18,7 @@ final class ProblemStep extends RichTextStep { required super.sectionId, required super.data, super.charsLimit, + super.guidances, }); @override @@ -32,6 +33,7 @@ final class SolutionStep extends RichTextStep { required super.sectionId, required super.data, super.charsLimit, + super.guidances, }); @override @@ -46,6 +48,7 @@ final class PublicDescriptionStep extends RichTextStep { required super.sectionId, required super.data, super.charsLimit, + super.guidances, }); @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/workspace_sections.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/workspace_sections.dart index d595a6c809f..658e273775f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/workspace_sections.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/workspace/workspace_sections.dart @@ -21,6 +21,7 @@ sealed class WorkspaceSectionStep extends BaseSectionStep { required super.sectionId, super.isEnabled, super.isEditable, + super.guidances, }); } @@ -34,6 +35,7 @@ abstract base class RichTextStep extends WorkspaceSectionStep { required this.data, this.charsLimit, super.isEditable, + super.guidances, }); String localizedDesc(BuildContext context) => localizedName(context);