From f78eff25aefeab44f2b2dbe2d98cac4f034018fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:56:09 +0100 Subject: [PATCH 1/6] feat: VoicesModalMenu (#1227) Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../lib/widgets/menu/voices_modal_menu.dart | 198 ++++++++++++++++++ .../apps/voices/lib/widgets/widgets.dart | 1 + 2 files changed, 199 insertions(+) create mode 100644 catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart new file mode 100644 index 00000000000..71d217670e5 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart @@ -0,0 +1,198 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class ModalMenuItem extends Equatable { + final String id; + final String label; + final bool isEnabled; + + const ModalMenuItem({ + required this.id, + required this.label, + this.isEnabled = true, + }); + + @override + List get props => [ + id, + label, + isEnabled, + ]; +} + +class VoicesModalMenu extends StatelessWidget { + final String? selectedId; + final List menuItems; + final ValueChanged? onTap; + + const VoicesModalMenu({ + super.key, + this.selectedId, + required this.menuItems, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final onTap = this.onTap; + + return Column( + mainAxisSize: MainAxisSize.min, + children: menuItems + .map( + (item) { + return _VoicesModalMenuItemTile( + key: ValueKey('VoicesModalMenu[${item.id}]Key'), + label: item.label, + isSelected: selectedId == item.id, + isEnabled: item.isEnabled, + onTap: onTap != null ? () => onTap(item.id) : null, + ); + }, + ) + .separatedBy(const SizedBox(height: 8)) + .toList(), + ); + } +} + +class _VoicesModalMenuItemTile extends StatefulWidget { + final String label; + final bool isSelected; + final bool isEnabled; + final VoidCallback? onTap; + + const _VoicesModalMenuItemTile({ + required super.key, + required this.label, + required this.isSelected, + required this.isEnabled, + this.onTap, + }); + + @override + State<_VoicesModalMenuItemTile> createState() { + return _VoicesModalMenuItemTileState(); + } +} + +class _VoicesModalMenuItemTileState extends State<_VoicesModalMenuItemTile> { + late _BackgroundColor _backgroundColor; + late _ForegroundColor _foregroundColor; + late _LabelTextStyle _labelTextStyle; + late _BorderColor _border; + + Set get _states => { + if (!widget.isEnabled) WidgetState.disabled, + if (widget.isSelected) WidgetState.selected, + }; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final theme = Theme.of(context); + + _backgroundColor = _BackgroundColor(theme.colorScheme.brightness); + _foregroundColor = _ForegroundColor(theme.colors); + _labelTextStyle = _LabelTextStyle(theme.textTheme); + _border = _BorderColor(theme.colorScheme.brightness); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: widget.isEnabled ? widget.onTap : null, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + constraints: const BoxConstraints(minWidth: 320), + decoration: BoxDecoration( + color: _backgroundColor.resolve(_states), + border: _border.resolve(_states), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16) + .add(const EdgeInsets.only(bottom: 2)), + child: Text( + widget.label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: _labelTextStyle + .resolve(_states) + .copyWith(color: _foregroundColor.resolve(_states)), + ), + ), + ); + } +} + +class _BackgroundColor extends WidgetStateProperty { + final Brightness _brightness; + + _BackgroundColor(this._brightness); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.selected)) { + // TODO(damian-molinski): Those colors are not using properties. + // TODO(damian-molinski): Dark/Transparent/On primary surface P40 016 + // TODO(damian-molinski): Light/Transparent/On surface P40 08 + return switch (_brightness) { + Brightness.dark => const Color(0x29123cd3), + Brightness.light => const Color(0x1f123cd3), + }; + } + + return null; + } +} + +class _ForegroundColor extends WidgetStateProperty { + final VoicesColorScheme _colors; + + _ForegroundColor(this._colors); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.disabled)) { + return _colors.textDisabled; + } + + return _colors.textOnPrimaryLevel1; + } +} + +class _LabelTextStyle extends WidgetStateProperty { + final TextTheme _textTheme; + + _LabelTextStyle(this._textTheme); + + @override + TextStyle resolve(Set states) { + if (states.contains(WidgetState.selected)) { + return _textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold); + } + + return _textTheme.bodyLarge!; + } +} + +class _BorderColor extends WidgetStateProperty { + final Brightness _brightness; + + _BorderColor(this._brightness); + + @override + BoxBorder resolve(Set states) { + // TODO(damian-molinski): Those colors are not using properties. + // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 + // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 + return switch (_brightness) { + Brightness.dark => Border.all(color: const Color(0x1fbfc8d9)), + Brightness.light => Border.all(color: const Color(0x14212a3d)), + }; + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d3ad43f3ba5..d66d06f8d12 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -46,6 +46,7 @@ export 'indicators/voices_status_indicator.dart'; export 'list/bullet_list.dart'; export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; +export 'menu/voices_modal_menu.dart'; export 'menu/voices_node_menu.dart'; export 'menu/voices_wallet_tile.dart'; export 'modals/voices_alert_dialog.dart'; From 62540c3548e3d38bdfc9dcbf7a24296ca3a0eabc Mon Sep 17 00:00:00 2001 From: Ryszard Schossler <51096731+LynxLynxx@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:50:19 +0100 Subject: [PATCH 2/6] feat(cat-voices): No proposal state widget (#1236) * feat(voices): add NoProposals widget and corresponding tests, plus asset and localization updates * feat(voices): introduce VoicesImagesScheme widget and integrate it into NoProposals, add no proposal foreground asset * feat(voices): add NoProposals widget and corresponding tests, plus asset and localization updates * feat(voices): introduce VoicesImagesScheme widget and integrate it into NoProposals, add no proposal foreground asset * feat(voices): add missing background property to VoicesImagesScheme widget for improved layout flexibility --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../workspace/workspace_guidance_view.dart | 39 +++--- .../widgets/images/voices_image_scheme.dart | 23 ++++ .../lib/widgets/proposals/no_proposals.dart | 71 +++++++++++ .../widgets/proposals/no_proposals_test.dart | 116 ++++++++++++++++++ .../assets/images/no_proposal_foreground.svg | 41 +++++++ .../lib/l10n/intl_en.arb | 8 ++ 6 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart create mode 100644 catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart index fd580e3ac84..279aabae2b2 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; class GuidanceView extends StatefulWidget { final List guidances; + const GuidanceView(this.guidances, {super.key}); @override @@ -18,25 +19,6 @@ class _GuidanceViewState extends State { 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( @@ -74,6 +56,25 @@ class _GuidanceViewState extends State { ); } + @override + void didUpdateWidget(GuidanceView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.guidances != widget.guidances) { + filteredGuidances + ..clear() + ..addAll(widget.guidances); + _filterGuidances(selectedType); + } + } + + @override + void initState() { + super.initState(); + filteredGuidances + ..clear() + ..addAll(widget.guidances); + } + void _filterGuidances(GuidanceType? type) { selectedType = type; filteredGuidances diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart new file mode 100644 index 00000000000..04a77bb328f --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class VoicesImagesScheme extends StatelessWidget { + final Widget image; + final Widget background; + + const VoicesImagesScheme({ + super.key, + required this.image, + required this.background, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + background, + image, + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart b/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart new file mode 100644 index 00000000000..a3362192e5a --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart @@ -0,0 +1,71 @@ +import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class NoProposals extends StatelessWidget { + final String? title; + final String? description; + + const NoProposals({ + super.key, + this.title, + this.description, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + VoicesImagesScheme( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + background: Container( + height: 180, + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutral08, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: 430, + child: Column( + children: [ + Text( + _buildTitle(context), + style: textTheme.titleMedium + ?.copyWith(color: theme.colors.textOnPrimaryLevel1), + ), + const SizedBox(height: 8), + Text( + _buildDescription(context), + style: textTheme.bodyMedium + ?.copyWith(color: theme.colors.textOnPrimaryLevel1), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _buildTitle(BuildContext context) { + return title ?? context.l10n.noProposalStateTitle; + } + + String _buildDescription(BuildContext context) { + return description ?? context.l10n.noProposalStateDescription; + } +} diff --git a/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart b/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart new file mode 100644 index 00000000000..78e7f7550e0 --- /dev/null +++ b/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart @@ -0,0 +1,116 @@ +import 'package:catalyst_voices/widgets/proposals/no_proposals.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('NoProposals Widget Tests', () { + testWidgets('Renders correctly with default values', (tester) async { + await tester.pumpApp( + const NoProposals(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.byType(Text), findsNWidgets(2)); + expect(find.text('No draft proposals yet'), findsOneWidget); + expect( + find.text( + // ignore: lines_longer_than_80_chars + 'Discovery space will show draft proposals you can comment on, currently there are no draft proposals.', + ), + findsOneWidget, + ); + }); + + testWidgets('Renders correctly with custom values', (tester) async { + await tester.pumpApp( + const NoProposals( + title: 'Custom Title', + description: 'Custom Description', + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.text('Custom Title'), findsOneWidget); + expect(find.text('Custom Description'), findsOneWidget); + }); + + testWidgets('Uses correct custom color scheme', (tester) async { + const colors = + VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); + await tester.pumpApp( + voicesColors: colors, + const NoProposals( + title: 'Custom Title', + description: 'Custom Description', + ), + ); + await tester.pumpAndSettle(); + + final titleText = tester.widget( + find.byType(Text).first, + ); + + expect( + titleText.style?.color, + colors.textOnPrimaryLevel1, + ); + + final descriptionText = tester.widget( + find.byType(Text).last, + ); + + expect( + descriptionText.style?.color, + colors.textOnPrimaryLevel1, + ); + }); + + testWidgets( + 'Proposal image changes depending on theme brightness', + (tester) async { + // Given + const widget = NoProposals(); + + // When - Light theme + await tester.pumpApp( + widget, + theme: ThemeData(brightness: Brightness.light), + voicesColors: const VoicesColorScheme.optional(), + ); + await tester.pumpAndSettle(); + + // Then - Light theme + final lightThemeImage = tester.widget( + find.byType(CatalystSvgPicture), + ); + expect( + lightThemeImage, + isA(), + ); + + // When - Dark theme + await tester.pumpApp( + widget, + theme: ThemeData(brightness: Brightness.dark), + voicesColors: const VoicesColorScheme.optional(), + ); + await tester.pumpAndSettle(); + + // Then - Dark theme + final darkThemeImage = tester.widget( + find.byType(CatalystSvgPicture), + ); + expect( + darkThemeImage, + isA(), + ); + }, + ); + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg new file mode 100644 index 00000000000..df9c9749f62 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index de5f9c1f9a1..bcd2e5a2aa1 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 @@ -986,5 +986,13 @@ "noGuidanceForThisSection": "There is no guidance for this section", "@noGuidanceForThisSection": { "description": "Message when there is no guidance for this section" + }, + "noProposalStateDescription": "Discovery space will show draft proposals you can comment on, currently there are no draft proposals.", + "@noProposalStateDescription": { + "description": "Description shown when there are no proposals in the proposals tab" + }, + "noProposalStateTitle": "No draft proposals yet", + "@noProposalStateTitle": { + "description": "Title shown when there are no proposals in the proposals tab" } } \ No newline at end of file From 7d08e5dc79eb05b982580eca655fe513ea898d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:32:50 +0100 Subject: [PATCH 3/6] feat(cat-voices): campaign details and information (#1239) * feat: VoicesExpansionTile * feat: CampaignDetailsTile * fix: formatting --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../lib/common/formatters/date_formatter.dart | 29 ++- .../campaign/campaign_details_tile.dart | 219 ++++++++++++++++++ .../widgets/tiles/voices_expansion_tile.dart | 107 +++++++++ .../apps/voices/lib/widgets/widgets.dart | 1 + .../formatters/date_formatter_test.dart | 115 ++++++--- .../lib/l10n/intl_en.arb | 15 ++ 6 files changed, 446 insertions(+), 40 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart diff --git a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart index 0829da4ce11..5012fc0ca93 100644 --- a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart +++ b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart @@ -10,10 +10,14 @@ abstract class DateFormatter { /// - Yesterday /// - 2 days ago /// - Other cases: yMMMMd date format. - static String formatRecentDate(VoicesLocalizations l10n, DateTime dateTime) { - final now = DateTimeExt.now(); + static String formatRecentDate( + VoicesLocalizations l10n, + DateTime dateTime, { + DateTime? from, + }) { + from ??= DateTimeExt.now(); - final today = DateTime(now.year, now.month, now.day, 12); + final today = DateTime(from.year, from.month, from.day, 12); if (dateTime.isSameDateAs(today)) return l10n.today; final tomorrow = today.plusDays(1); @@ -27,4 +31,23 @@ abstract class DateFormatter { return DateFormat.yMMMMd().format(dateTime); } + + static String formatInDays( + VoicesLocalizations l10n, + DateTime dateTime, { + DateTime? from, + }) { + from ??= DateTimeExt.now(); + + final days = dateTime.isAfter(from) ? dateTime.difference(from).inDays : 0; + + return l10n.inXDays(days); + } + + static String formatShortMonth( + VoicesLocalizations l10n, + DateTime dateTime, + ) { + return DateFormat.MMM().format(dateTime); + } } diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart new file mode 100644 index 00000000000..f07a02a6e4e --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart @@ -0,0 +1,219 @@ +import 'package:catalyst_voices/common/formatters/date_formatter.dart'; +import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class CampaignDetailsTile extends StatelessWidget { + final String description; + final DateTime publishDate; + final DateTime startDate; + final DateTime endDate; + final int categoriesCount; + final int proposalsCount; + + const CampaignDetailsTile({ + super.key, + required this.description, + required this.publishDate, + required this.startDate, + required this.endDate, + required this.categoriesCount, + required this.proposalsCount, + }); + + @override + Widget build(BuildContext context) { + return VoicesExpansionTile( + initiallyExpanded: true, + title: Text(context.l10n.campaignDetails), + children: [ + _Body( + description: description, + ), + const SizedBox(height: 16 + 24), + _CampaignData( + publishDate: publishDate, + startDate: startDate, + endDate: endDate, + categoriesCount: categoriesCount, + proposalsCount: proposalsCount, + ), + ], + ); + } +} + +class _Body extends StatelessWidget { + final String description; + + const _Body({ + required this.description, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel1, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 14), + Text( + description, + style: textTheme.bodyLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + ], + ); + } +} + +class _CampaignData extends StatelessWidget { + final DateTime publishDate; + final DateTime startDate; + final DateTime endDate; + final int categoriesCount; + final int proposalsCount; + + const _CampaignData({ + required this.publishDate, + required this.startDate, + required this.endDate, + required this.categoriesCount, + required this.proposalsCount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colors; + final l10n = context.l10n; + + return Container( + decoration: BoxDecoration( + color: colors.onSurfacePrimary012, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + _CampaignDataTile( + key: const ValueKey('StartDateTileKey'), + title: l10n.startDate, + subtitle: DateFormatter.formatInDays(l10n, startDate), + value: startDate.day, + valueSuffix: DateFormatter.formatShortMonth(l10n, startDate), + ), + _CampaignDataTile( + key: const ValueKey('EndDateTileKey'), + title: l10n.endDate, + subtitle: DateFormatter.formatInDays(l10n, endDate), + value: endDate.day, + valueSuffix: DateFormatter.formatShortMonth(l10n, endDate), + ), + _CampaignDataTile( + key: const ValueKey('CategoriesTileKey'), + title: l10n.categories, + subtitle: DateFormatter.formatInDays( + l10n, + DateTime.now(), + from: publishDate, + ), + value: categoriesCount, + ), + _CampaignDataTile( + key: const ValueKey('ProposalsTileKey'), + title: l10n.proposals, + subtitle: l10n.totalSubmitted, + value: proposalsCount, + ), + ] + .map((e) => Expanded(child: e)) + .separatedBy(const SizedBox(width: 16)) + .toList(), + ), + ); + } +} + +class _CampaignDataTile extends StatelessWidget { + final String title; + final String subtitle; + final int value; + final String? valueSuffix; + + const _CampaignDataTile({ + super.key, + required this.title, + required this.subtitle, + required this.value, + this.valueSuffix, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + // TODO(damian-molinski): This color does not have property. + // Colors/sys color neutral md ref/N60 + color: const Color(0xFF7F90B3), + ), + ), + const SizedBox(height: 16), + Row( + textBaseline: TextBaseline.alphabetic, + crossAxisAlignment: valueSuffix != null + ? CrossAxisAlignment.baseline + : CrossAxisAlignment.end, + children: [ + Text( + '$value', + style: textTheme.headlineLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + if (valueSuffix != null) ...[ + const SizedBox(width: 4), + Text( + valueSuffix!, + style: textTheme.titleMedium?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + ], + ], + ), + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart new file mode 100644 index 00000000000..46aca16a6a8 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart @@ -0,0 +1,107 @@ +import 'package:catalyst_voices/widgets/buttons/voices_buttons.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesExpansionTile extends StatefulWidget { + final Widget title; + final List children; + final bool initiallyExpanded; + + const VoicesExpansionTile({ + super.key, + required this.title, + this.children = const [], + this.initiallyExpanded = false, + }); + + @override + State createState() => _VoicesExpansionTileState(); +} + +class _VoicesExpansionTileState extends State { + final _controller = ExpansionTileController(); + + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _isExpanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return _ThemeOverride( + child: Builder( + builder: (context) { + final theme = Theme.of(context); + + return ExpansionTile( + title: DefaultTextStyle( + style: theme.textTheme.titleLarge ?? const TextStyle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: widget.title, + ), + trailing: ChevronExpandButton( + isExpanded: _isExpanded, + onTap: _toggleExpand, + ), + controller: _controller, + initiallyExpanded: _isExpanded, + onExpansionChanged: _updateExpended, + children: widget.children, + ); + }, + ), + ); + } + + void _updateExpended(bool value) { + setState(() { + _isExpanded = value; + }); + } + + void _toggleExpand() { + if (_controller.isExpanded) { + _controller.collapse(); + } else { + _controller.expand(); + } + } +} + +class _ThemeOverride extends StatelessWidget { + final Widget child; + + const _ThemeOverride({ + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Theme( + data: theme.copyWith( + // listTileTheme is required here because ExpansionTile does not let + // us set shape or ripple used internally by ListTile. + listTileTheme: const ListTileThemeData(shape: RoundedRectangleBorder()), + expansionTileTheme: ExpansionTileThemeData( + backgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, + collapsedBackgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, + tilePadding: const EdgeInsets.fromLTRB(24, 8, 12, 8), + childrenPadding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + textColor: theme.colors.textOnPrimaryLevel1, + collapsedTextColor: theme.colors.textOnPrimaryLevel1, + iconColor: theme.colors.iconsForeground, + collapsedIconColor: theme.colors.iconsForeground, + shape: const RoundedRectangleBorder(), + collapsedShape: const RoundedRectangleBorder(), + ), + ), + child: child, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d66d06f8d12..a43e5437a5a 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -72,6 +72,7 @@ export 'text_field/voices_autocomplete.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; +export 'tiles/voices_expansion_tile.dart'; export 'tiles/voices_nav_tile.dart'; export 'toggles/voices_checkbox.dart'; export 'toggles/voices_checkbox_group.dart'; diff --git a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart index 0b163672d45..a07b0dee89f 100644 --- a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart +++ b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart @@ -1,53 +1,94 @@ import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations_en.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; -class _FakeVoicesLocalizations extends Fake implements VoicesLocalizations { - @override - String get today => 'Today'; - @override - String get tomorrow => 'Tomorrow'; - @override - String get yesterday => 'Yesterday'; - @override - String get twoDaysAgo => '2 days ago'; -} - void main() { group(DateFormatter, () { - final l10n = _FakeVoicesLocalizations(); + final l10n = VoicesLocalizationsEn(); - test('should return "Today" for today\'s date', () { - final today = DateTimeExt.now(); - final result = DateFormatter.formatRecentDate(l10n, today); - expect(result, l10n.today); - }); + group('formatRecentDate', () { + test('should return "Today" for today\'s date', () { + final today = DateTimeExt.now(); + final result = DateFormatter.formatRecentDate(l10n, today); + expect(result, l10n.today); + }); - test('should return "Tomorrow" for tomorrow\'s date', () { - final tomorrow = DateTimeExt.now().plusDays(1); - final result = DateFormatter.formatRecentDate(l10n, tomorrow); - expect(result, l10n.tomorrow); - }); + test('should return "Tomorrow" for tomorrow\'s date', () { + final tomorrow = DateTimeExt.now().plusDays(1); + final result = DateFormatter.formatRecentDate(l10n, tomorrow); + expect(result, l10n.tomorrow); + }); - test('should return "Yesterday" for yesterday\'s date', () { - final yesterday = DateTimeExt.now().minusDays(1); - final result = DateFormatter.formatRecentDate(l10n, yesterday); - expect(result, l10n.yesterday); - }); + test('should return "Yesterday" for yesterday\'s date', () { + final yesterday = DateTimeExt.now().minusDays(1); + final result = DateFormatter.formatRecentDate(l10n, yesterday); + expect(result, l10n.yesterday); + }); + + test('should return "2 days ago" for a date 2 days ago', () { + final twoDaysAgo = DateTimeExt.now().minusDays(2); + final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); + expect(result, l10n.twoDaysAgo); + }); - test('should return "2 days ago" for a date 2 days ago', () { - final twoDaysAgo = DateTimeExt.now().minusDays(2); - final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); - expect(result, l10n.twoDaysAgo); + test('should return formatted date for older dates', () { + final pastDate = DateTimeExt.now().minusDays(10); + final result = DateFormatter.formatRecentDate(l10n, pastDate); + final expectedFormat = DateFormat.yMMMMd().format(pastDate); + expect(result, expectedFormat); + }); }); - test('should return formatted date for older dates', () { - final pastDate = DateTimeExt.now().minusDays(10); - final result = DateFormatter.formatRecentDate(l10n, pastDate); - final expectedFormat = DateFormat.yMMMMd().format(pastDate); - expect(result, expectedFormat); + group('formatInDays', () { + test('returns 20 days when in comparing to 20 days in future', () { + // Given + final publishDate = DateTime(2024, 11, 20); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 20 days'); + }); + + test('returns 0 days when in comparing to past', () { + // Given + final publishDate = DateTime(2024, 2, 10); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 0 days'); + }); + + test('returns 1 day when in comparing to 1 day in future', () { + // Given + final publishDate = DateTime(2024, 11, 1); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 1 day'); + }); }); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index bcd2e5a2aa1..62c5d02f75c 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 @@ -994,5 +994,20 @@ "noProposalStateTitle": "No draft proposals yet", "@noProposalStateTitle": { "description": "Title shown when there are no proposals in the proposals tab" + }, + "campaignDetails": "Campaign Details", + "description": "Description", + "startDate": "Start Date", + "endDate": "End Date", + "categories": "Categories", + "proposals": "Proposals", + "totalSubmitted": "Total submitted", + "inXDays": "{x, plural, =1{In {x} day} other{In {x} days}}", + "@inXDays": { + "placeholders": { + "x": { + "type": "int" + } + } } } \ No newline at end of file From fab8ca0ba23e849d4175e337c8f26bde6ac56012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:58:50 +0100 Subject: [PATCH 4/6] feat(cat-voices): campaign categories (#1251) * chore: tile scaffolding * feat: finish component * fix: mutable classes should not be marked as @immutable * fix: sections list comparing with listEquals * fix: vault / keychain tests --- .../voices/lib/widgets/menu/voices_menu.dart | 34 ++-- .../lib/widgets/menu/voices_modal_menu.dart | 23 +-- .../campaign/campaign_categories_tile.dart | 165 ++++++++++++++++++ .../test/widgets/menu/voices_menu_test.dart | 16 +- .../lib/l10n/intl_en.arb | 4 +- .../lib/src/keychain/vault_keychain.dart | 3 - .../storage/vault/secure_storage_vault.dart | 14 +- .../lib/src/storage/vault/vault.dart | 4 +- .../vault_keychain_provider_test.dart | 5 +- .../src/keychain/vault_keychain_test.dart | 4 +- .../test/src/user/user_service_test.dart | 10 +- .../lib/src/campaign/campaign_category.dart | 14 ++ .../lib/src/campaign/campaign_section.dart | 31 ++++ .../lib/src/catalyst_voices_view_models.dart | 4 + .../lib/src/menu/menu_item.dart | 31 ++++ .../lib/src/menu/popup_menu_item.dart | 32 ++++ .../lib/examples/voices_menu_example.dart | 30 ++-- 17 files changed, 342 insertions(+), 82 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart index 0a98b997093..178e9bbccc8 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; /// A menu of the app that @@ -68,7 +69,7 @@ class _MenuButton extends StatelessWidget { final textStyle = textTheme.bodyMedium?.copyWith( color: - menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, + menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, ); final children = menuChildren; @@ -85,7 +86,7 @@ class _MenuButton extends StatelessWidget { child: IconTheme( data: IconThemeData( size: 24, - color: menuItem.enabled + color: menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, ), @@ -138,32 +139,29 @@ class _MenuButton extends StatelessWidget { } /// Model representing Menu Item -class MenuItem { - final int id; - final String label; - final Widget? icon; - final bool showDivider; - final bool enabled; - - MenuItem({ - required this.id, - required this.label, - this.icon, - this.showDivider = false, - this.enabled = true, +final class MenuItem extends BasicPopupMenuItem { + const MenuItem({ + required super.id, + required super.label, + super.isEnabled = true, + super.icon, + super.showDivider = false, }); } /// Model representing Submenu Item /// and extending from MenuItem -class SubMenuItem extends MenuItem { - List children; +final class SubMenuItem extends MenuItem { + final List children; - SubMenuItem({ + const SubMenuItem({ required super.id, required super.label, required this.children, super.icon, super.showDivider, }); + + @override + List get props => super.props + [children]; } diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart index 71d217670e5..61b24038779 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart @@ -1,30 +1,11 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:equatable/equatable.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; -class ModalMenuItem extends Equatable { - final String id; - final String label; - final bool isEnabled; - - const ModalMenuItem({ - required this.id, - required this.label, - this.isEnabled = true, - }); - - @override - List get props => [ - id, - label, - isEnabled, - ]; -} - class VoicesModalMenu extends StatelessWidget { final String? selectedId; - final List menuItems; + final List menuItems; final ValueChanged? onTap; const VoicesModalMenu({ diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart new file mode 100644 index 00000000000..644ffcb9438 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart @@ -0,0 +1,165 @@ +import 'package:catalyst_voices/widgets/menu/voices_modal_menu.dart'; +import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class CampaignCategoriesTile extends StatefulWidget { + final List sections; + + const CampaignCategoriesTile({ + super.key, + required this.sections, + }); + + @override + State createState() => _CampaignCategoriesTileState(); +} + +class _CampaignCategoriesTileState extends State { + String? _selectedSectionId; + + @override + void initState() { + super.initState(); + + _selectedSectionId = widget.sections.firstOrNull?.id; + } + + @override + void didUpdateWidget(covariant CampaignCategoriesTile oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!listEquals(widget.sections, oldWidget.sections)) { + if (!widget.sections.any((element) => element.id == _selectedSectionId)) { + _selectedSectionId = widget.sections.firstOrNull?.id; + } + } + } + + @override + Widget build(BuildContext context) { + final selectedSection = widget.sections + .singleWhereOrNull((element) => element.id == _selectedSectionId); + + return VoicesExpansionTile( + initiallyExpanded: true, + title: Text(context.l10n.campaignCategories), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Menu( + selectedId: _selectedSectionId, + menuItems: widget.sections, + onTap: _updateSelection, + ), + const SizedBox(width: 32), + Expanded( + child: selectedSection != null + ? _Details(section: selectedSection) + : const SizedBox(), + ), + ], + ), + ], + ); + } + + void _updateSelection(String id) { + setState(() { + _selectedSectionId = id; + }); + } +} + +class _Menu extends StatelessWidget { + final String? selectedId; + final List menuItems; + final ValueChanged onTap; + + const _Menu({ + this.selectedId, + required this.menuItems, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + context.l10n.cardanoUseCases, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 12), + VoicesModalMenu( + selectedId: selectedId, + menuItems: menuItems, + onTap: onTap, + ), + const SizedBox(height: 16), + ], + ); + } +} + +class _Details extends StatelessWidget { + final CampaignSection section; + + const _Details({ + required this.section, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + Text( + section.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.headlineMedium?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 24), + Text( + section.title, + style: textTheme.titleLarge?.copyWith( + color: colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 16), + Text( + section.body, + style: textTheme.bodyLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 32), + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart index 4ceb87006f3..2e09d8fb64a 100644 --- a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart @@ -9,34 +9,34 @@ import '../../helpers/helpers.dart'; void main() { final menu = [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 2, + id: '2', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ + children: const [ MenuItem( - id: 3, + id: '3', label: 'Team 1: The Vikings', ), MenuItem( - id: 4, + id: '4', label: 'Team 2: Pure Hearts', ), ], ), MenuItem( - id: 5, + id: '5', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - enabled: false, + isEnabled: false, ), MenuItem( - id: 6, + id: '6', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index 62c5d02f75c..f50bd18df7b 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 @@ -1009,5 +1009,7 @@ "type": "int" } } - } + }, + "campaignCategories": "Campaign Categories", + "cardanoUseCases": "Cardano Use Cases" } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart index 9a6ad8e7559..171fb17495f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart @@ -127,7 +127,4 @@ final class VaultKeychain extends SecureStorageVault implements Keychain { @override String toString() => 'VaultKeychain[$id]'; - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart index 00e91b49872..7bef14095d4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -6,7 +6,6 @@ import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -14,9 +13,8 @@ const _lockKey = 'LockKey'; /// Implementation of [Vault] that uses [FlutterSecureStorage] as /// facade for read/write operations. -base class SecureStorageVault - with StorageAsStringMixin, EquatableMixin - implements Vault { +base class SecureStorageVault with StorageAsStringMixin implements Vault { + @override final String id; @protected final FlutterSecureStorage secureStorage; @@ -171,6 +169,11 @@ base class SecureStorageVault } } + @override + String toString() { + return 'SecureStorageVault{id: $id}'; + } + /// Allows operation only when [isUnlocked] it true, otherwise returns null. /// /// Returns value assigned to [key]. May return null if not found for [key]. @@ -246,7 +249,4 @@ base class SecureStorageVault void _erase(Uint8List list) { list.fillRange(0, list.length, 0); } - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart index 8d03c3ee084..67bbbb620d1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -7,4 +7,6 @@ import 'package:catalyst_voices_services/src/storage/storage.dart'; /// /// In order to unlock [Vault] sufficient [LockFactor] have to be /// set via [unlock] that can unlock [LockFactor] from [setLock]. -abstract interface class Vault implements Storage, Lockable {} +abstract interface class Vault implements Storage, Lockable { + String get id; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart index 8d66f091238..f8418f71e06 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart @@ -24,7 +24,10 @@ void main() { // Then expect(await provider.exists(id), isTrue); - expect([keychain], await provider.getAll()); + expect( + [keychain.id], + await provider.getAll().then((value) => value.map((e) => e.id)), + ); }); test('calling create twice on keychain will empty previous data', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart index ccfd397b5a9..6d4d4d542a9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart @@ -68,7 +68,7 @@ void main() { ); }); - test('are equal when id is matching', () async { + test('are not equal when id is matching', () async { // Given final id = const Uuid().v4(); @@ -77,7 +77,7 @@ void main() { final vaultTwo = VaultKeychain(id: id); // Then - expect(vaultOne, equals(vaultTwo)); + expect(vaultOne, isNot(equals(vaultTwo))); }); test('metadata dates are in UTC', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart index 045abedef1e..c4aaecb3b41 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart @@ -29,7 +29,7 @@ void main() { // Then final currentKeychain = service.keychain; - expect(currentKeychain, keychain); + expect(currentKeychain?.id, keychain.id); }); test('using different keychain emits update in stream', () async { @@ -48,8 +48,8 @@ void main() { keychainStream, emitsInOrder([ isNull, - keychainOne, - keychainTwo, + predicate((e) => e.id == keychainOne.id), + predicate((e) => e.id == keychainTwo.id), isNull, ]), ); @@ -75,7 +75,7 @@ void main() { // Then final serviceKeychains = await service.keychains; - expect(serviceKeychains, keychains); + expect(serviceKeychains.map((e) => e.id), keychains.map((e) => e.id)); }); }); @@ -92,7 +92,7 @@ void main() { await service.useLastAccount(); // Then - expect(service.keychain, expectedKeychain); + expect(service.keychain?.id, expectedKeychain.id); }); test('use last account does nothing on clear instance', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart new file mode 100644 index 00000000000..fd2ca82d607 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +final class CampaignCategory extends Equatable { + final String id; + final String name; + + const CampaignCategory({ + required this.id, + required this.name, + }); + + @override + List get props => [id, name]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart new file mode 100644 index 00000000000..0e8a346e34f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignSection extends Equatable implements MenuItem { + @override + final String id; + final CampaignCategory category; + final String title; + final String body; + + const CampaignSection({ + required this.id, + required this.category, + required this.title, + required this.body, + }); + + @override + String get label => category.name; + + @override + bool get isEnabled => true; + + @override + List get props => [ + id, + category, + title, + body, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index f745b4c9141..a51dc69220c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1,6 +1,10 @@ export 'authentication/authentication.dart'; +export 'campaign/campaign_category.dart'; +export 'campaign/campaign_section.dart'; export 'exception/localized_exception.dart'; export 'exception/localized_unknown_exception.dart'; +export 'menu/menu_item.dart'; +export 'menu/popup_menu_item.dart'; export 'navigation/sections_list_view_item.dart'; export 'navigation/sections_navigation.dart'; export 'proposal/comment.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart new file mode 100644 index 00000000000..1481dba245e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +abstract interface class MenuItem { + String get id; + + String get label; + + bool get isEnabled; +} + +base class BasicMenuItem extends Equatable implements MenuItem { + @override + final String id; + @override + final String label; + @override + final bool isEnabled; + + const BasicMenuItem({ + required this.id, + required this.label, + this.isEnabled = true, + }); + + @override + List get props => [ + id, + label, + isEnabled, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart new file mode 100644 index 00000000000..9299e44e0f5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart @@ -0,0 +1,32 @@ +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/widgets.dart'; + +abstract interface class PopupMenuItem implements MenuItem { + Widget? get icon; + + bool get showDivider; +} + +base class BasicPopupMenuItem extends BasicMenuItem implements PopupMenuItem { + @override + final Widget? icon; + + @override + final bool showDivider; + + const BasicPopupMenuItem({ + required super.id, + required super.label, + super.isEnabled, + this.icon, + this.showDivider = false, + }); + + @override + List get props => + super.props + + [ + icon, + showDivider, + ]; +} diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart index 3dcfa8fb0fa..b85aa136885 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart @@ -71,28 +71,28 @@ class _MenuExample1 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 4, + id: '4', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ - MenuItem(id: 5, label: 'Team 1: The Vikings'), - MenuItem(id: 6, label: 'Team 2: Pure Hearts'), + children: const [ + MenuItem(id: '5', label: 'Team 1: The Vikings'), + MenuItem(id: '6', label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: 2, + id: '2', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - enabled: false, + isEnabled: false, ), MenuItem( - id: 3, + id: '3', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), @@ -118,27 +118,27 @@ class _MenuExample2 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 4, + id: '4', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ - MenuItem(id: 5, label: 'Team 1: The Vikings'), - MenuItem(id: 6, label: 'Team 2: Pure Hearts'), + children: const [ + MenuItem(id: '5', label: 'Team 1: The Vikings'), + MenuItem(id: '6', label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: 2, + id: '2', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, ), MenuItem( - id: 3, + id: '3', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), From 531349df794dd1251b7599fad1057ced7cdeb145 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:28:39 +0100 Subject: [PATCH 5/6] feat(cat-voices): autofocus password field to enable faster input (#1252) --- .../voices/lib/pages/account/unlock_keychain_dialog.dart | 1 + .../lib/widgets/text_field/voices_password_text_field.dart | 5 +++++ .../voices/lib/widgets/text_field/voices_text_field.dart | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart index 2c3b3385e45..deb0a32412e 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart @@ -155,6 +155,7 @@ class _UnlockPassword extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( controller: controller, + autofocus: true, decoration: VoicesTextFieldDecoration( labelText: context.l10n.unlockDialogHint, errorText: error?.message(context), diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart index e7c20a7810b..190bfaa79b4 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart @@ -19,6 +19,9 @@ final class VoicesPasswordTextField extends StatelessWidget { /// Optional decoration. See [VoicesTextField] for more details. final VoicesTextFieldDecoration? decoration; + /// [VoicesTextField.autofocus]. + final bool autofocus; + const VoicesPasswordTextField({ super.key, this.controller, @@ -26,6 +29,7 @@ final class VoicesPasswordTextField extends StatelessWidget { this.onChanged, this.onSubmitted, this.decoration, + this.autofocus = false, }); @override @@ -33,6 +37,7 @@ final class VoicesPasswordTextField extends StatelessWidget { return VoicesTextField( controller: controller, keyboardType: TextInputType.visiblePassword, + autofocus: autofocus, obscureText: true, textInputAction: textInputAction, onChanged: onChanged, diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index 31220324918..e63a838bf80 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -32,6 +32,9 @@ class VoicesTextField extends StatefulWidget { /// [TextField.style] final TextStyle? style; + /// [TextField.autofocus] + final bool autofocus; + /// [TextField.obscureText] final bool obscureText; @@ -77,6 +80,7 @@ class VoicesTextField extends StatefulWidget { this.textInputAction, this.textCapitalization = TextCapitalization.none, this.style, + this.autofocus = false, this.obscureText = false, this.maxLength, this.maxLines = 1, @@ -176,6 +180,7 @@ class _VoicesTextFieldState extends State { resizableVertically: resizable, child: TextFormField( textAlignVertical: TextAlignVertical.top, + autofocus: widget.autofocus, expands: resizable, controller: _obtainController(), focusNode: widget.focusNode, From 96daec376ba30037b6520418bca612f33faca03a Mon Sep 17 00:00:00 2001 From: Ryszard Schossler <51096731+LynxLynxx@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:08:36 +0100 Subject: [PATCH 6/6] refactor(cat-voices): Changing NoProposals widget to EmptyState (#1260) * making noProposals as empty state * test: adding test for custom image * Update catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../empty_state.dart} | 40 +++++++++++-------- .../widgets/images/voices_image_scheme.dart | 4 +- .../empty_state_test.dart} | 27 ++++++++++--- 3 files changed, 46 insertions(+), 25 deletions(-) rename catalyst_voices/apps/voices/lib/widgets/{proposals/no_proposals.dart => empty_state/empty_state.dart} (71%) rename catalyst_voices/apps/voices/test/widgets/{proposals/no_proposals_test.dart => empty_state/empty_state_test.dart} (81%) diff --git a/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart similarity index 71% rename from catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart rename to catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart index a3362192e5a..a2bb1e959b1 100644 --- a/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart +++ b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart @@ -4,14 +4,18 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; -class NoProposals extends StatelessWidget { +class EmptyState extends StatelessWidget { final String? title; final String? description; + final Widget? image; + final Widget? imageBackground; - const NoProposals({ + const EmptyState({ super.key, this.title, this.description, + this.image, + this.imageBackground, }); @override @@ -23,18 +27,20 @@ class NoProposals extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 64), child: Column( children: [ - VoicesImagesScheme( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - background: Container( - height: 180, - decoration: BoxDecoration( - color: theme.colors.onSurfaceNeutral08, - shape: BoxShape.circle, + image ?? + VoicesImagesScheme( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + background: imageBackground ?? + Container( + height: 180, + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutral08, + shape: BoxShape.circle, + ), + ), ), - ), - ), const SizedBox(height: 24), SizedBox( width: 430, @@ -61,11 +67,11 @@ class NoProposals extends StatelessWidget { ); } - String _buildTitle(BuildContext context) { - return title ?? context.l10n.noProposalStateTitle; - } - String _buildDescription(BuildContext context) { return description ?? context.l10n.noProposalStateDescription; } + + String _buildTitle(BuildContext context) { + return title ?? context.l10n.noProposalStateTitle; + } } diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart index 04a77bb328f..ddf0bf9bce3 100644 --- a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart +++ b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; class VoicesImagesScheme extends StatelessWidget { final Widget image; - final Widget background; + final Widget? background; const VoicesImagesScheme({ super.key, @@ -15,7 +15,7 @@ class VoicesImagesScheme extends StatelessWidget { return Stack( alignment: Alignment.center, children: [ - background, + if (background != null) background!, image, ], ); diff --git a/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart similarity index 81% rename from catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart rename to catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart index 78e7f7550e0..d19e3df0b8b 100644 --- a/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart @@ -1,4 +1,5 @@ -import 'package:catalyst_voices/widgets/proposals/no_proposals.dart'; +import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; +import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; @@ -7,10 +8,10 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/helpers.dart'; void main() { - group('NoProposals Widget Tests', () { + group('EmptyState Widget Tests', () { testWidgets('Renders correctly with default values', (tester) async { await tester.pumpApp( - const NoProposals(), + const EmptyState(), ); await tester.pumpAndSettle(); @@ -28,7 +29,7 @@ void main() { testWidgets('Renders correctly with custom values', (tester) async { await tester.pumpApp( - const NoProposals( + const EmptyState( title: 'Custom Title', description: 'Custom Description', ), @@ -45,7 +46,7 @@ void main() { VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); await tester.pumpApp( voicesColors: colors, - const NoProposals( + const EmptyState( title: 'Custom Title', description: 'Custom Description', ), @@ -75,7 +76,7 @@ void main() { 'Proposal image changes depending on theme brightness', (tester) async { // Given - const widget = NoProposals(); + const widget = EmptyState(); // When - Light theme await tester.pumpApp( @@ -112,5 +113,19 @@ void main() { ); }, ); + + testWidgets('Renders correctly with custom image', (tester) async { + await tester.pumpApp( + EmptyState( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.byType(VoicesImagesScheme), findsNothing); + }); }); }