diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart index f7177646adda..7ce4c174f745 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/proposal/proposal_brief_card.dart @@ -31,11 +31,13 @@ class ProposalBriefCard extends StatefulWidget { State createState() => _ProposalBriefCardState(); } -class _Author extends StatelessWidget { - final String? author; +class _AuthorAndCollaborators extends StatelessWidget { + final CatalystId? author; + final List? collaborators; - const _Author({ - required this.author, + const _AuthorAndCollaborators({ + this.author, + this.collaborators, }); @override @@ -48,13 +50,16 @@ class _Author extends StatelessWidget { children: [ ProfileAvatar( size: 32, - username: author, + username: author?.username, ), - UsernameText( - key: const Key('Author'), - author, - style: context.textTheme.titleSmall?.copyWith( - color: context.colors.textOnPrimaryLevel1, + Flexible( + child: AccountsText( + ids: [?author, ...?collaborators], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.textOnPrimaryLevel1, + ), ), ), ], @@ -222,7 +227,10 @@ class _ProposalBriefCardState extends State { ), const SizedBox(height: 4), _Title(text: proposal.title), - _Author(author: proposal.author), + _AuthorAndCollaborators( + author: proposal.author, + collaborators: proposal.acceptedCollaboratorsIds, + ), _FundsAndDuration( funds: proposal.formattedFunds, duration: proposal.duration, diff --git a/catalyst_voices/apps/voices/lib/widgets/user/accounts_text.dart b/catalyst_voices/apps/voices/lib/widgets/user/accounts_text.dart new file mode 100644 index 000000000000..36d468e3ad9a --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/user/accounts_text.dart @@ -0,0 +1,114 @@ +import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; +import 'package:catalyst_voices/widgets/user/catalyst_id_text.dart'; +import 'package:catalyst_voices/widgets/user/username_text.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class AccountsText extends StatelessWidget { + final List ids; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + + const AccountsText({ + super.key, + required this.ids, + this.style, + this.maxLines, + this.overflow, + }); + + @override + Widget build(BuildContext context) { + final firstId = ids.firstOrNull; + final firstUsername = firstId?.username; + final isAnonymous = firstId != null && (firstUsername == null || firstUsername.isBlank); + + final effectiveFirstUsername = isAnonymous ? context.l10n.anonymousUsername : firstUsername; + + return Text.rich( + TextSpan( + text: effectiveFirstUsername, + style: TextStyle(fontStyle: isAnonymous ? FontStyle.italic : FontStyle.normal), + children: [ + if (ids.length > 1) ...[ + TextSpan(text: ' ${context.l10n.andSign} '), + WidgetSpan( + child: Tooltip( + richMessage: WidgetSpan(child: _OtherTooltipOverlay(ids.sublist(1))), + padding: EdgeInsets.zero, + decoration: const BoxDecoration(), + constraints: const BoxConstraints(maxWidth: 268), + enableTapToDismiss: false, + child: Text( + context.l10n.others(ids.length - 1), + style: (style ?? const TextStyle()).copyWith( + color: Theme.of(context).linksPrimary, + ), + ), + ), + ), + ], + ], + ), + style: style, + maxLines: maxLines, + overflow: overflow, + ); + } +} + +class _OtherTooltipOverlay extends StatelessWidget { + final List ids; + + const _OtherTooltipOverlay(this.ids); + + @override + Widget build(BuildContext context) { + return Material( + color: context.colors.elevationsOnSurfaceNeutralLv1White, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: ids.map(_OtherTooltipOverlayTile.new).toList(), + ), + ); + } +} + +class _OtherTooltipOverlayTile extends StatelessWidget { + final CatalystId id; + + const _OtherTooltipOverlayTile(this.id); + + @override + Widget build(BuildContext context) { + final idStyle = context.textTheme.labelSmall; + final usernameStyle = (context.textTheme.bodyLarge ?? const TextStyle()).copyWith( + color: context.colors.textOnPrimaryLevel0, + ); + + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ).add(const EdgeInsets.only(right: 8)), + child: AffixDecorator( + suffix: CatalystIdText( + id, + isCompact: true, + showCopy: false, + copyEnabled: false, + tooltipEnabled: false, + style: idStyle, + ), + child: UsernameText(id.username, style: usernameStyle, maxLines: 1), + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/user/catalyst_id_text.dart b/catalyst_voices/apps/voices/lib/widgets/user/catalyst_id_text.dart index 74b99b071cd2..4db7a7d96976 100644 --- a/catalyst_voices/apps/voices/lib/widgets/user/catalyst_id_text.dart +++ b/catalyst_voices/apps/voices/lib/widgets/user/catalyst_id_text.dart @@ -19,6 +19,8 @@ class CatalystIdText extends StatefulWidget { final bool showLabel; final bool showUsername; final bool includeUsername; + final bool copyEnabled; + final bool tooltipEnabled; final TextStyle? style; final TextStyle? labelStyle; final double labelGap; @@ -32,6 +34,8 @@ class CatalystIdText extends StatefulWidget { this.showLabel = false, this.showUsername = false, this.includeUsername = true, + this.copyEnabled = true, + this.tooltipEnabled = true, this.style, this.labelStyle, this.labelGap = 6, @@ -58,8 +62,8 @@ class _CatalystIdTextState extends State { child: Offstage( offstage: _effectiveData.isEmpty, child: _TapDetector( - onTap: _copyDataToClipboard, - onHoverExit: _handleHoverExit, + onTap: widget.copyEnabled ? _copyDataToClipboard : null, + onHoverExit: widget.copyEnabled ? _handleHoverExit : null, child: TooltipVisibility( visible: _tooltipVisible, child: Row( @@ -72,7 +76,7 @@ class _CatalystIdTextState extends State { constraints: const BoxConstraints(), child: _Chip( _effectiveData, - onTap: _copyDataToClipboard, + onTap: widget.copyEnabled ? _copyDataToClipboard : null, style: widget.style, backgroundColor: widget.backgroundColor, ), @@ -96,7 +100,9 @@ class _CatalystIdTextState extends State { void didUpdateWidget(CatalystIdText oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.data != oldWidget.data || widget.isCompact != oldWidget.isCompact) { + if (widget.data != oldWidget.data || + widget.isCompact != oldWidget.isCompact || + widget.tooltipEnabled != oldWidget.tooltipEnabled) { _fullDataAsString = _buildFullData(); _effectiveData = _buildTextData(); _tooltipVisible = _isTooltipVisible(); @@ -165,7 +171,11 @@ class _CatalystIdTextState extends State { } bool _isTooltipVisible() { - return widget.isCompact && _effectiveData.length < _fullDataAsString.length; + if (!widget.tooltipEnabled || !widget.isCompact) { + return false; + } + + return _effectiveData.length < _fullDataAsString.length; } void _removeHighlight() { @@ -187,7 +197,7 @@ class _CatalystIdTextState extends State { class _Chip extends StatelessWidget { final String data; - final VoidCallback onTap; + final VoidCallback? onTap; final Color? backgroundColor; final TextStyle? style; @@ -276,8 +286,8 @@ class _LabelText extends StatelessWidget { } class _TapDetector extends StatelessWidget { - final VoidCallback onTap; - final VoidCallback onHoverExit; + final VoidCallback? onTap; + final VoidCallback? onHoverExit; final Widget child; const _TapDetector({ @@ -292,7 +302,7 @@ class _TapDetector extends StatelessWidget { onTap: onTap, // there is a gap between text and copy behavior: HitTestBehavior.translucent, - mouseRegionOnExit: (event) => onHoverExit(), + mouseRegionOnExit: onHoverExit != null ? (event) => onHoverExit!.call() : null, child: child, ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/user/username_text.dart b/catalyst_voices/apps/voices/lib/widgets/user/username_text.dart index c81baa493ada..998d819c2126 100644 --- a/catalyst_voices/apps/voices/lib/widgets/user/username_text.dart +++ b/catalyst_voices/apps/voices/lib/widgets/user/username_text.dart @@ -18,17 +18,19 @@ class UsernameText extends StatelessWidget { @override Widget build(BuildContext context) { - final isAnonymous = data == null || (data?.isBlank ?? false); + final username = data; + final isAnonymous = username == null || username.isBlank; + final effectiveUsername = isAnonymous ? context.l10n.anonymousUsername : username; final effectiveStyle = (style ?? const TextStyle()).copyWith( fontStyle: isAnonymous ? FontStyle.italic : FontStyle.normal, ); - return DefaultTextStyle.merge( + return Text( + effectiveUsername, style: effectiveStyle, maxLines: maxLines, overflow: overflow, - child: Text(isAnonymous ? context.l10n.anonymousUsername : data!), ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index 44ebb47ab179..bb61ab36bea5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -121,6 +121,7 @@ export 'toggles/voices_radio.dart'; export 'toggles/voices_switch.dart'; export 'tooltips/voices_plain_tooltip.dart'; export 'tooltips/voices_rich_tooltip.dart'; +export 'user/accounts_text.dart'; export 'user/catalyst_id_text.dart'; export 'user/profile_avatar.dart'; export 'user/profile_container.dart'; 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 bba969ffa7ca..7b149981c8e7 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 @@ -133,6 +133,10 @@ "@all": { "description": "Primary used to select all object. To display object without any filter" }, + "andSign": "&", + "@andSign": { + "description": "Generic and sign" + }, "anonymousUsername": "Anonymous", "@anonymousUsername": { "description": "When account does not have specified username this text is shown" @@ -2236,6 +2240,15 @@ "@orderProposalsUpdateDateDesc": { "description": "Order proposals by update date option" }, + "others": "{count} {count, plural, =1{Other} other{Others}}", + "@others": { + "description": "In context of proposal author and collaborators. Eg. Author & 5 Others", + "placeholders": { + "count": { + "type": "int" + } + } + }, "paginationProposalsCounter": "{from}-{to} of {max} {max, plural, =0{proposals} =1{proposal} other{proposals}}", "@paginationProposalsCounter": { "description": "Below pagination list, next to page switch controls", diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart index c1e20e822592..ceb985827614 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -106,6 +106,7 @@ export 'proposal/proposal_or_document.dart'; export 'proposal/proposal_version.dart'; export 'proposal/proposal_votes.dart'; export 'proposal/proposal_with_context.dart'; +export 'proposal/proposals_collaboration_status.dart'; export 'proposals/proposals_filters_v2.dart'; export 'proposals/proposals_order.dart'; export 'proposals/proposals_total_ask_filters.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart index 5ec436087ad7..032bbfed7f45 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/data/proposal_brief_data.dart @@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart'; final class ProposalBriefData extends Equatable { final DocumentRef id; - final String authorName; + final CatalystId? author; final String title; final String description; final String categoryName; @@ -18,7 +18,7 @@ final class ProposalBriefData extends Equatable { const ProposalBriefData({ required this.id, - required this.authorName, + required this.author, required this.title, required this.description, required this.categoryName, @@ -35,7 +35,7 @@ final class ProposalBriefData extends Equatable { @override List get props => [ id, - authorName, + author, title, description, categoryName, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart index 3d123854f5d2..c0ffd78154a7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposal_or_document.dart @@ -8,7 +8,7 @@ import 'package:equatable/equatable.dart'; /// specific template (`DocumentData`). /// /// This class provides a unified interface to access common properties -/// like [title], [authorName], [description], etc., regardless of the +/// like [title], [author], [description], etc., regardless of the /// underlying data type. /// /// It's useful when dealing with list of proposals and some of them may not have templates @@ -22,8 +22,8 @@ sealed class ProposalOrDocument extends Equatable { /// Creates a [ProposalOrDocument] from a structured [ProposalDocument]. const factory ProposalOrDocument.proposal(ProposalDocument data) = _Proposal; - /// The name of the proposal's author. - String? get authorName; + /// The id of the proposal's author. + CatalystId? get author; // TODO(damian-molinski): Category name should come from query but atm those are not documents. /// The name of the proposal's category. @@ -64,7 +64,7 @@ final class _Document extends ProposalOrDocument { const _Document(this.data); @override - String? get authorName => data.metadata.authors?.firstOrNull?.username; + CatalystId? get author => data.metadata.authors?.firstOrNull; @override String? get description => ProposalDocument.titleNodeId.from(data.content.data); @@ -78,10 +78,10 @@ final class _Document extends ProposalOrDocument { Money? get fundsRequested => null; @override - List get props => [data]; + DocumentRef get id => data.metadata.id; @override - DocumentRef get id => data.metadata.id; + List get props => [data]; @override String? get title => ProposalDocument.titleNodeId.from(data.content.data); @@ -99,7 +99,7 @@ final class _Proposal extends ProposalOrDocument { const _Proposal(this.data); @override - String? get authorName => data.authorName; + CatalystId? get author => data.authorId; @override String? get description => data.description; @@ -111,10 +111,10 @@ final class _Proposal extends ProposalOrDocument { Money? get fundsRequested => data.fundsRequested; @override - List get props => [data]; + DocumentRef get id => data.metadata.id; @override - DocumentRef get id => data.metadata.id; + List get props => [data]; @override String? get title => data.title; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposals_collaboration_status.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposals_collaboration_status.dart new file mode 100644 index 000000000000..b85706943d6f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposal/proposals_collaboration_status.dart @@ -0,0 +1,9 @@ +enum ProposalsCollaborationStatus { + accepted, + rejected, + pending; + + const ProposalsCollaborationStatus(); + + bool get isAccepted => this == ProposalsCollaborationStatus.accepted; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart index 899d6b0a4f52..602eb4e680e1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart @@ -650,7 +650,7 @@ final class ProposalServiceImpl implements ProposalService { return ProposalBriefData( id: proposal.id, - authorName: proposal.authorName ?? '', + author: proposal.author, title: proposal.title ?? '', description: proposal.description ?? '', categoryName: proposal.categoryName ?? '', diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart index 406952c23f54..104ebf26d688 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposal/proposal_brief.dart @@ -2,12 +2,13 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; class ProposalBrief extends Equatable { final DocumentRef id; final String title; final String categoryName; - final String? author; + final CatalystId? author; final Money fundsRequested; final int duration; final ProposalPublish publish; @@ -17,6 +18,7 @@ class ProposalBrief extends Equatable { final int? commentsCount; final bool isFavorite; final VoteButtonData? voteData; + final List? collaborators; const ProposalBrief({ required this.id, @@ -32,6 +34,7 @@ class ProposalBrief extends Equatable { this.commentsCount, this.isFavorite = false, this.voteData, + this.collaborators, }); factory ProposalBrief.fromData(ProposalBriefData data) { @@ -39,7 +42,7 @@ class ProposalBrief extends Equatable { id: data.id, title: data.title, categoryName: data.categoryName, - author: data.authorName, + author: data.author, fundsRequested: data.fundsRequested, duration: data.durationInMonths, publish: data.isFinal ? ProposalPublish.submittedProposal : ProposalPublish.publishedDraft, @@ -49,6 +52,9 @@ class ProposalBrief extends Equatable { commentsCount: data.commentsCount, isFavorite: data.isFavorite, voteData: data.votes.toViewModel(), + // TODO(damian-molinski): Integration to be done + // ignore: avoid_redundant_argument_values + collaborators: null, ); } @@ -57,7 +63,11 @@ class ProposalBrief extends Equatable { id: SignedDocumentRef.generateFirstRef(), title: 'Proposal Title', categoryName: 'Category Name', - author: 'Author Name', + author: CatalystId( + host: CatalystIdHost.cardano.host, + role0Key: Uint8List.fromList(List.filled(32, 0)), + username: 'Author Name', + ), fundsRequested: Money.zero(currency: Currencies.ada), duration: 0, publish: ProposalPublish.publishedDraft, @@ -68,6 +78,11 @@ class ProposalBrief extends Equatable { ); } + List? get acceptedCollaboratorsIds => collaborators + ?.where((collaborator) => collaborator.status.isAccepted) + .map((collaborator) => collaborator.id) + .toList(); + String get formattedFunds { return MoneyFormatter.formatCompactRounded(fundsRequested); } @@ -87,13 +102,14 @@ class ProposalBrief extends Equatable { commentsCount, isFavorite, voteData, + collaborators, ]; ProposalBrief copyWith({ DocumentRef? id, String? title, String? categoryName, - Optional? author, + Optional? author, Money? fundsRequested, int? duration, ProposalPublish? publish, @@ -103,6 +119,7 @@ class ProposalBrief extends Equatable { Optional? commentsCount, bool? isFavorite, Optional? voteData, + Optional>? collaborators, }) { return ProposalBrief( id: id ?? this.id, @@ -118,10 +135,24 @@ class ProposalBrief extends Equatable { commentsCount: commentsCount.dataOr(this.commentsCount), isFavorite: isFavorite ?? this.isFavorite, voteData: voteData.dataOr(this.voteData), + collaborators: collaborators.dataOr(this.collaborators), ); } } +final class ProposalBriefDataCollaborator extends Equatable { + final CatalystId id; + final ProposalsCollaborationStatus status; + + const ProposalBriefDataCollaborator({ + required this.id, + required this.status, + }); + + @override + List get props => [id, status]; +} + extension on ProposalBriefDataVotes? { VoteButtonData? toViewModel() { final instance = this;