diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index 342dd0eed4b7..be7f506d9e7a 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -211,6 +211,11 @@ final class Dependencies extends DependencyProvider { get(), get(), ); + }) + ..registerFactory(() { + return AddCollaboratorCubit( + get(), + ); }); } diff --git a/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_button.dart b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_button.dart new file mode 100644 index 000000000000..fa655a09da8c --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_button.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart'; +import 'package:catalyst_voices/widgets/indicators/voices_circular_progress_indicator.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class AddCollaboratorButton extends StatelessWidget { + const AddCollaboratorButton({super.key}); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.collaboratorIdState; + }, + builder: (context, collaboratorIdState) { + return _AddCollaboratorButton(collaboratorIdState); + }, + ); + } +} + +class _AddCollaboratorButton extends StatelessWidget { + final CollaboratorIdState collaboratorIdState; + + const _AddCollaboratorButton(this.collaboratorIdState); + + @override + Widget build(BuildContext context) { + return VoicesFilledButton( + onTap: collaboratorIdState.isValid ? () => _validateCollaboratorId(context) : null, + trailing: collaboratorIdState.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: VoicesCircularProgressIndicator(), + ) + : null, + child: Text(context.l10n.addCollaborator), + ); + } + + void _validateCollaboratorId(BuildContext context) { + if (collaboratorIdState.isLoading) return; + + unawaited(context.read().validateCollaboratorId()); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_dialog.dart b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_dialog.dart new file mode 100644 index 000000000000..3a90144d6f62 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_dialog.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:catalyst_voices/dependency/dependencies.dart'; +import 'package:catalyst_voices/pages/co_proposers/widgets/add_collaborator/add_collaborator_view.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class AddCollaboratorDialog extends StatefulWidget { + final CatalystId authorId; + final Collaborators collaborators; + + const AddCollaboratorDialog({super.key, required this.authorId, required this.collaborators}); + + @override + State createState() => _AddCollaboratorDialogState(); + + static Future show( + BuildContext context, { + required CatalystId authorId, + Collaborators? collaborators, + }) async { + return VoicesDialog.show( + context: context, + builder: (context) => AddCollaboratorDialog( + authorId: authorId, + collaborators: collaborators ?? const Collaborators(), + ), + routeSettings: const RouteSettings(name: '/add-collaborator-dialog'), + ); + } +} + +class _AddCollaboratorDialogState extends State { + late final AddCollaboratorCubit _cubit; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: ScaffoldMessenger( + child: Scaffold( + backgroundColor: Colors.transparent, + body: VoicesPanelDialog( + constraints: const Responsive.single(BoxConstraints(maxWidth: 602, maxHeight: 396)), + child: const AddCollaboratorView(), + ), + ), + ), + ); + } + + @override + void dispose() { + unawaited(_cubit.close()); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _cubit = Dependencies.instance.get(); + + _cubit.init(collaborators: widget.collaborators, authorCatalystId: widget.authorId); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_text_field.dart b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_text_field.dart new file mode 100644 index 000000000000..4f0c2d2c8d55 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_text_field.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_text_field.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class AddCollaboratorTextField extends StatelessWidget { + const AddCollaboratorTextField({super.key}); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.collaboratorIdState.collaboratorId; + }, + builder: (context, collaboratorId) { + return _AddCollaboratorTextField(collaboratorId); + }, + ); + } +} + +class __AddCollaboratorTextFieldState extends State<_AddCollaboratorTextField> { + late final FocusNode _focusNode; + + String? get errorMessage { + final error = widget.collaboratorId.displayError; + if (error is InvalidCatalystIdFormatValidationException) { + return error.message(context); + } + return null; + } + + @override + Widget build(BuildContext context) { + return VoicesTextField( + focusNode: _focusNode, + initialText: widget.collaboratorId.value, + onChanged: _onTextFieldChange, + onFieldSubmitted: _onTextFieldSubmitted, + decoration: VoicesTextFieldDecoration( + labelText: context.l10n.catalystId, + labelStyle: context.textTheme.labelLarge, + errorText: errorMessage, + ), + ); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _focusNode = FocusNode()..requestFocus(); + } + + void _onTextFieldChange(String? value) { + context.read().updateCollaboratorId(value ?? ''); + } + + void _onTextFieldSubmitted(String? value) { + unawaited(context.read().validateCollaboratorId()); + } +} + +class _AddCollaboratorTextField extends StatefulWidget { + final CollaboratorCatalystId collaboratorId; + + const _AddCollaboratorTextField(this.collaboratorId); + + @override + State<_AddCollaboratorTextField> createState() => __AddCollaboratorTextFieldState(); +} diff --git a/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_view.dart b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_view.dart new file mode 100644 index 000000000000..8191427e1d03 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/co_proposers/widgets/add_collaborator/add_collaborator_view.dart @@ -0,0 +1,88 @@ +import 'package:catalyst_voices/common/error_handler.dart'; +import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/common/signal_handler.dart'; +import 'package:catalyst_voices/pages/co_proposers/widgets/add_collaborator/add_collaborator_button.dart'; +import 'package:catalyst_voices/pages/co_proposers/widgets/add_collaborator/add_collaborator_text_field.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +class AddCollaboratorView extends StatefulWidget { + const AddCollaboratorView({super.key}); + + @override + State createState() => _AddCollaboratorViewState(); +} + +class _AddCollaboratorViewState extends State + with + ErrorHandlerStateMixin, + SignalHandlerStateMixin { + @override + Widget build(BuildContext context) { + return const SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB(40, 42, 40, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _HeaderIcon(), + _HeaderText(), + SizedBox(height: 24), + _Description(), + SizedBox(height: 28), + AddCollaboratorTextField(), + SizedBox(height: 24), + AddCollaboratorButton(), + ], + ), + ), + ); + } + + @override + void handleSignal(AddCollaboratorSignal signal) { + return switch (signal) { + ValidCollaboratorIdSignal(:final catalystId) => _popWithResult(catalystId), + }; + } + + void _popWithResult(CatalystId catalystId) { + Navigator.pop(context, catalystId); + } +} + +class _Description extends StatelessWidget { + const _Description(); + + @override + Widget build(BuildContext context) { + return Text(context.l10n.howToAddCollaboratorDescription); + } +} + +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon(); + + @override + Widget build(BuildContext context) { + return VoicesAssets.icons.userGroup.buildIcon( + size: 76, + color: context.colors.iconsPrimary, + ); + } +} + +class _HeaderText extends StatelessWidget { + const _HeaderText(); + + @override + Widget build(BuildContext context) { + return Text( + context.l10n.addCollaborator, + style: context.textTheme.titleLarge, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/error_page/error_page.dart b/catalyst_voices/apps/voices/lib/pages/error_page/error_page.dart new file mode 100644 index 000000000000..867375716f21 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/pages/error_page/error_page.dart @@ -0,0 +1,128 @@ +import 'dart:math'; + +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +/// A fullscreen error page. Meant to be used as a template for other error pages. +class ErrorPage extends StatelessWidget { + final AssetGenImage image; + final double maxImageWidth; + final String title; + final String message; + final Widget button; + + const ErrorPage({ + super.key, + required this.image, + this.maxImageWidth = 500, + required this.title, + required this.message, + required this.button, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + const Positioned.fill( + child: _Background(), + ), + Positioned.fill( + left: 24, + right: 24, + child: Column( + children: [ + const Spacer(flex: 3), + Center( + child: _Image( + image: image, + maxImageWidth: maxImageWidth, + ), + ), + const SizedBox(height: 56), + _Title(text: title), + const SizedBox(height: 12), + _Message(text: message), + const SizedBox(height: 32), + button, + const Spacer(flex: 4), + ], + ), + ), + ], + ); + } +} + +class _Background extends StatelessWidget { + const _Background(); + + @override + Widget build(BuildContext context) { + return CatalystImage.asset( + VoicesAssets.images.bgBubbles.path, + fit: BoxFit.fill, + ); + } +} + +class _Image extends StatelessWidget { + final AssetGenImage image; + final double maxImageWidth; + + const _Image({ + required this.image, + required this.maxImageWidth, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.sizeOf(context).width; + final minImageWidth = min(300, screenWidth * 0.9); + final preferredImageWidth = screenWidth * 0.6; + + final imageWidth = preferredImageWidth.clamp( + minImageWidth, + maxImageWidth, + ); + + return CatalystImage.asset( + image.path, + width: imageWidth.toDouble(), + fit: BoxFit.cover, + ); + } +} + +class _Message extends StatelessWidget { + final String text; + + const _Message({required this.text}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith(color: theme.colors.textOnPrimaryLevel1), + ); + } +} + +class _Title extends StatelessWidget { + final String text; + + const _Title({required this.text}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text, + textAlign: TextAlign.center, + style: theme.textTheme.headlineLarge?.copyWith(color: theme.colorScheme.primary), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/pages/not_found/not_found_page.dart b/catalyst_voices/apps/voices/lib/pages/not_found/not_found_page.dart index 1a0681c7297a..527b73548ced 100644 --- a/catalyst_voices/apps/voices/lib/pages/not_found/not_found_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/not_found/not_found_page.dart @@ -1,9 +1,7 @@ -import 'dart:math'; - +import 'package:catalyst_voices/pages/error_page/error_page.dart'; import 'package:catalyst_voices/routes/routing/root_route.dart'; import 'package:catalyst_voices/widgets/widgets.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'; @@ -13,107 +11,15 @@ class NotFoundPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Stack( - children: [ - Positioned.fill( - child: _Background(), - ), - Positioned.fill( - left: 24, - right: 24, - child: Column( - children: [ - Spacer(flex: 3), - _Image(), - SizedBox(height: 56), - _Title(), - SizedBox(height: 12), - _Message(), - SizedBox(height: 32), - _Button(), - Spacer(flex: 4), - ], - ), - ), - ], - ); - } -} - -class _Background extends StatelessWidget { - const _Background(); - - @override - Widget build(BuildContext context) { - return CatalystImage.asset( - VoicesAssets.images.bgBubbles.path, - fit: BoxFit.fill, - ); - } -} - -class _Button extends StatelessWidget { - const _Button(); - - @override - Widget build(BuildContext context) { - return VoicesTextButton( - leading: VoicesAssets.icons.arrowNarrowRight.buildIcon(), - child: Text(context.l10n.notFoundButton), - onTap: () => const RootRoute().go(context), - ); - } -} - -class _Image extends StatelessWidget { - const _Image(); - - @override - Widget build(BuildContext context) { - final screenWidth = MediaQuery.sizeOf(context).width; - final minImageWidth = min(300, screenWidth * 0.9); - const maxImageWidth = 500; - final preferredImageWidth = screenWidth * 0.6; - - final imageWidth = preferredImageWidth.clamp( - minImageWidth, - maxImageWidth, - ); - - return Center( - child: CatalystImage.asset( - VoicesAssets.images.notFound404.path, - width: imageWidth.toDouble(), - fit: BoxFit.cover, + return ErrorPage( + image: VoicesAssets.images.notFound404, + title: context.l10n.notFoundTitle, + message: context.l10n.notFoundMessage, + button: VoicesTextButton( + leading: VoicesAssets.icons.arrowNarrowRight.buildIcon(), + child: Text(context.l10n.notFoundButton), + onTap: () => const RootRoute().go(context), ), ); } } - -class _Message extends StatelessWidget { - const _Message(); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Text( - context.l10n.notFoundMessage, - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith(color: theme.colors.textOnPrimaryLevel1), - ); - } -} - -class _Title extends StatelessWidget { - const _Title(); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Text( - context.l10n.notFoundTitle, - textAlign: TextAlign.center, - style: theme.textTheme.headlineLarge?.copyWith(color: theme.colorScheme.primary), - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/pages/proposal/proposal_error.dart b/catalyst_voices/apps/voices/lib/pages/proposal/proposal_error.dart index bc705bc5acdc..f9542340e392 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposal/proposal_error.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposal/proposal_error.dart @@ -1,9 +1,11 @@ -import 'dart:async'; - import 'package:catalyst_voices/common/typedefs.dart'; -import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices/pages/error_page/error_page.dart'; +import 'package:catalyst_voices/routes/routing/root_route.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; class ProposalError extends StatelessWidget { @@ -14,34 +16,67 @@ class ProposalError extends StatelessWidget { return BlocSelector( selector: (state) => (show: state.showError, data: state.error), builder: (context, state) { - final errorMessage = state.data?.message(context); - return Offstage( offstage: !state.show, - child: _ProposalError( - message: errorMessage ?? context.l10n.somethingWentWrong, - ), + child: _ProposalError(exception: state.data), ); }, ); } } +class _NotFoundError extends StatelessWidget { + final String? message; + + const _NotFoundError({this.message}); + + @override + Widget build(BuildContext context) { + return ErrorPage( + image: VoicesAssets.images.notFound404, + title: context.l10n.proposalViewNotFoundTitle, + message: message ?? context.l10n.proposalViewNotFoundMessage, + button: VoicesTextButton( + leading: VoicesAssets.icons.arrowNarrowRight.buildIcon(), + child: Text(context.l10n.proposalViewNotFoundButton), + onTap: () => const RootRoute().go(context), + ), + ); + } +} + class _ProposalError extends StatelessWidget { - final String message; + final LocalizedException? exception; + + const _ProposalError({required this.exception}); + + @override + Widget build(BuildContext context) { + return switch (exception) { + LocalizedNotFoundException() => const _NotFoundError(), + LocalizedDocumentReferenceException() => _NotFoundError(message: exception?.message(context)), + LocalizedDocumentHiddenException() => _NotFoundError(message: exception?.message(context)), + _ => _RecoverableError(title: exception?.message(context)), + }; + } +} + +class _RecoverableError extends StatelessWidget { + final String? title; - const _ProposalError({ - required this.message, - }); + const _RecoverableError({this.title}); @override Widget build(BuildContext context) { - return Center( - child: VoicesErrorIndicator( - message: message, - onRetry: () { - unawaited(context.read().retryLastRef()); - }, + return ErrorPage( + image: VoicesAssets.images.magGlass, + maxImageWidth: 300, + title: title ?? context.l10n.somethingWentWrong, + message: context.l10n.proposalViewLoadErrorMessage, + button: VoicesTextButton( + leading: VoicesAssets.icons.refresh.buildIcon(), + child: Text(context.l10n.refresh), + onTap: () => context.read().retryLastRef(), ), ); } diff --git a/catalyst_voices/apps/voices/lib/pages/proposal/proposal_page.dart b/catalyst_voices/apps/voices/lib/pages/proposal/proposal_page.dart index 66f13e9bce54..4fcf5235693f 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposal/proposal_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposal/proposal_page.dart @@ -84,30 +84,30 @@ class _ProposalPageState extends State Widget build(BuildContext context) { return SegmentsControllerScope( controller: _segmentsController, - child: Scaffold( - appBar: const _AppBar(), - endDrawer: const OpportunitiesDrawer(), - floatingActionButton: _ScrollToTopButton( - segmentsScrollController: _segmentsScrollController, - ), - body: Stack( - children: [ - ProposalHeaderWrapper( - child: ProposalSidebars( - navPanel: const ProposalNavigationPanel(), - body: Stack( - children: [ - ProposalContent( + child: Stack( + children: [ + Scaffold( + appBar: const _AppBar(), + endDrawer: const OpportunitiesDrawer(), + floatingActionButton: _ScrollToTopButton( + segmentsScrollController: _segmentsScrollController, + ), + body: Stack( + children: [ + ProposalHeaderWrapper( + child: ProposalSidebars( + navPanel: const ProposalNavigationPanel(), + body: ProposalContent( scrollController: _segmentsScrollController, ), - const ProposalError(), - ], + ), ), - ), + const ProposalLoading(), + ], ), - const ProposalLoading(), - ], - ), + ), + const ProposalError(), + ], ), ); } 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 2424ab00a892..74b99b071cd2 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 @@ -17,6 +17,8 @@ class CatalystIdText extends StatefulWidget { final bool isCompact; final bool showCopy; final bool showLabel; + final bool showUsername; + final bool includeUsername; final TextStyle? style; final TextStyle? labelStyle; final double labelGap; @@ -28,6 +30,8 @@ class CatalystIdText extends StatefulWidget { required this.isCompact, this.showCopy = true, this.showLabel = false, + this.showUsername = false, + this.includeUsername = true, this.style, this.labelStyle, this.labelGap = 6, @@ -93,8 +97,7 @@ class _CatalystIdTextState extends State { super.didUpdateWidget(oldWidget); if (widget.data != oldWidget.data || widget.isCompact != oldWidget.isCompact) { - final id = widget.data; - _fullDataAsString = id.withoutUsername().toUri().toString(); + _fullDataAsString = _buildFullData(); _effectiveData = _buildTextData(); _tooltipVisible = _isTooltipVisible(); } @@ -112,12 +115,17 @@ class _CatalystIdTextState extends State { void initState() { super.initState(); - final id = widget.data; - _fullDataAsString = id.withoutUsername().toString(); + _fullDataAsString = _buildFullData(); _effectiveData = _buildTextData(); _tooltipVisible = _isTooltipVisible(); } + String _buildFullData() { + final catalystId = widget.showUsername ? widget.data : widget.data.withoutUsername(); + + return catalystId.toUri().toString(); + } + String _buildTextData() { final data = _fullDataAsString; final isCompact = widget.isCompact; @@ -132,7 +140,10 @@ class _CatalystIdTextState extends State { } Future _copyDataToClipboard() async { - final data = ClipboardData(text: _fullDataAsString); + final catalystId = widget.includeUsername + ? widget.data.toUri().toString() + : widget.data.withoutUsername().toUri().toString(); + final data = ClipboardData(text: catalystId); await Clipboard.setData(data); if (mounted) { diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/proposal_background_1.webp b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/proposal_background_1.webp deleted file mode 100644 index 7136a7e91514..000000000000 Binary files a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/proposal_background_1.webp and /dev/null differ diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/proposal_background_2.webp b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/proposal_background_2.webp deleted file mode 100644 index f93fc1fdc438..000000000000 Binary files a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/proposal_background_2.webp and /dev/null differ diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart index e55c53428c58..f5ba2544aa1a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart @@ -4,6 +4,7 @@ export 'brand/brand.dart'; export 'campaign/campaign_builder/campaign_builder.dart'; export 'campaign/campaign_phase/campaign_phase_aware.dart'; export 'category/category_detail.dart'; +export 'collaborators/collaborators.dart'; export 'common/bloc_error_emitter_mixin.dart'; export 'common/bloc_event_transformers.dart'; export 'common/bloc_extensions.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_cubit.dart new file mode 100644 index 000000000000..81454976f7b8 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_cubit.dart @@ -0,0 +1,61 @@ +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; + +final class AddCollaboratorCubit extends Cubit + with + BlocErrorEmitterMixin, + BlocSignalEmitterMixin { + final ProposalService _proposalService; + + AddCollaboratorCubit( + this._proposalService, + ) : super( + const AddCollaboratorState(), + ); + + void init({required Collaborators collaborators, required CatalystId authorCatalystId}) { + emit( + AddCollaboratorState( + collaborators: collaborators, + authorCatalystId: authorCatalystId, + ), + ); + } + + void updateCollaboratorId(String value) { + final result = CollaboratorCatalystId.dirty( + value: value, + collaborators: state.collaborators.collaborators, + authorCatalystId: state.authorCatalystId, + ); + final error = result.error; + + if (error != null && error is! InvalidCatalystIdFormatValidationException) { + emitError(error); + } + final newCollaboratorIdState = state.collaboratorIdState.copyWith(collaboratorId: result); + emit(state.copyWith(collaboratorIdState: newCollaboratorIdState)); + } + + Future validateCollaboratorId() async { + if (state.collaboratorIdState.isLoading) return; + + final id = state.collaboratorIdState.collaboratorId.value; + final catalystId = CatalystId.tryParse(id); + + if (catalystId == null) return; + final newCollaboratorIdState = state.collaboratorIdState; + emit(state.copyWith(collaboratorIdState: newCollaboratorIdState.copyWith(isLoading: true))); + final result = await _proposalService.validateForCollaborator(catalystId); + + emit(state.copyWith(collaboratorIdState: newCollaboratorIdState.copyWith(isLoading: false))); + + if (result) { + emitSignal(ValidCollaboratorIdSignal(catalystId)); + } else { + emitError(const LocalizedCollaboratorIsNotAProposerException()); + } + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_signal.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_signal.dart new file mode 100644 index 000000000000..db4a33b352c4 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_signal.dart @@ -0,0 +1,17 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +sealed class AddCollaboratorSignal extends Equatable { + const AddCollaboratorSignal(); + + @override + List get props => []; +} + +final class ValidCollaboratorIdSignal extends AddCollaboratorSignal { + final CatalystId catalystId; + const ValidCollaboratorIdSignal(this.catalystId); + + @override + List get props => [...super.props, catalystId]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_state.dart new file mode 100644 index 000000000000..f247fbe943f3 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/add_collaborator_state.dart @@ -0,0 +1,57 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:equatable/equatable.dart'; + +class AddCollaboratorState extends Equatable { + final CatalystId? authorCatalystId; + final Collaborators collaborators; + final CollaboratorIdState collaboratorIdState; + + const AddCollaboratorState({ + this.authorCatalystId, + this.collaborators = const Collaborators(), + this.collaboratorIdState = const CollaboratorIdState(), + }); + + @override + List get props => [ + authorCatalystId, + collaborators, + collaboratorIdState, + ]; + + AddCollaboratorState copyWith({ + CollaboratorIdState? collaboratorIdState, + }) { + return AddCollaboratorState( + authorCatalystId: authorCatalystId, + collaborators: collaborators, + collaboratorIdState: collaboratorIdState ?? this.collaboratorIdState, + ); + } +} + +class CollaboratorIdState extends Equatable { + final bool isLoading; + final CollaboratorCatalystId collaboratorId; + + const CollaboratorIdState({ + this.isLoading = false, + this.collaboratorId = const CollaboratorCatalystId.pure(), + }); + + bool get isValid => collaboratorId.isValid && !collaboratorId.isPure; + + @override + List get props => [isLoading, collaboratorId]; + + CollaboratorIdState copyWith({ + bool? isLoading, + CollaboratorCatalystId? collaboratorId, + }) { + return CollaboratorIdState( + isLoading: isLoading ?? this.isLoading, + collaboratorId: collaboratorId ?? this.collaboratorId, + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/collaborators.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/collaborators.dart new file mode 100644 index 000000000000..9c515d573dd5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/collaborators/collaborators.dart @@ -0,0 +1,3 @@ +export 'add_collaborator_cubit.dart'; +export 'add_collaborator_signal.dart'; +export 'add_collaborator_state.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart index c6916004fa72..a9e9282a86c3 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal/proposal_cubit.dart @@ -74,22 +74,28 @@ final class ProposalCubit extends Cubit Future load({required DocumentRef ref}) async { try { - final isReadOnlyMode = await _isReadOnlyMode(); - final campaign = await _campaignService.getActiveCampaign(); - final isVotingStage = _isVotingStage(campaign); - final showComments = campaign?.supportsComments ?? false; _logger.info('Loading $ref'); - _cache = _cache.copyWith(ref: Optional.of(ref)); + if (!ref.isValid) { + emit(state.copyWith(error: const Optional(LocalizedDocumentReferenceException()))); + return; + } emit(state.copyWith(isLoading: true)); + _cache = _cache.copyWith(ref: Optional.of(ref)); final proposal = await _proposalService.getProposalDetail(ref: ref); - final category = await _campaignService.getCategory(proposal.document.metadata.categoryId); - final commentTemplate = await _commentService.getCommentTemplateFor( - category: proposal.document.metadata.categoryId, - ); - final isFavorite = await _proposalService.watchIsFavoritesProposal(ref: ref).first; + + final (isReadOnlyMode, campaign, category, commentTemplate, isFavorite) = await ( + _isReadOnlyMode(), + _campaignService.getActiveCampaign(), + _campaignService.getCategory(proposal.document.metadata.categoryId), + _commentService.getCommentTemplateFor(category: proposal.document.metadata.categoryId), + _proposalService.watchIsFavoritesProposal(ref: ref).first, + ).wait; + + final isVotingStage = _isVotingStage(campaign); + final showComments = campaign?.supportsComments ?? false; _cache = _cache.copyWith( proposal: Optional(proposal), 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 f96be6d5dd1e..a30f88ba7168 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 @@ -97,6 +97,10 @@ "@actor": { "description": "Refers to user that created keychain and is unlocked." }, + "addCollaborator": "Add Co-proposer", + "@addCollaborator": { + "description": "Informs user that he is adding collaborator to his proposal" + }, "addCommentSection": "Add comment", "@addCommentSection": { "description": "Viewing proposal navigation section" @@ -388,6 +392,18 @@ "@catalystId": { "description": "Label of chip with account catalyst ID clearly describing content" }, + "catalystIdAlreadyAddedAsCollaboratorMessage": "This Catalyst ID is already added as Co-Proposer", + "@catalystIdAlreadyAddedAsCollaboratorMessage": { + "description": "Error message shown to user when he tries to add Catalyst ID which already exists as collaborator in proposal" + }, + "catalystIdBelongsToMainProposer": "This Catalyst ID belongs to the Author and can't be added as Co-Proposer", + "@catalystIdBelongsToMainProposer": { + "description": "Error message shown to user when he tries to add Catalyst ID which belongs to himself" + }, + "catalystIdFormatInvalidMessage": "This Catalyst ID has invalid format", + "@catalystIdFormatInvalidMessage": { + "description": "Error message shown to user when Catalyst ID has not valid format" + }, "catalystKeychain": "Catalyst Keychain", "@catalystKeychain": { "description": "Name for Catalyst Keychain" @@ -465,6 +481,10 @@ "@close": { "description": "CTA To close something" }, + "collaboratorCatalystIdIsMissingProposerRole": "This Catalyst ID is missing Proposer role. Only Proposers can be added as Co-Proposers", + "@collaboratorCatalystIdIsMissingProposerRole": { + "description": "Error message shown to user when he tries to add Catalyst ID of a user that don't have Proposer role" + }, "comingSoon": "Coming Soon", "@comingSoon": { "description": "Coming soon message" @@ -933,10 +953,18 @@ "@doWord": { "description": "Label for a list of things to do" }, + "documentHiddenException": "The proposal is no longer available.", + "@documentHiddenException": { + "description": "Message when the document (proposal) is no longer available due to being hidden." + }, "documentImportInvalidDataError": "The imported document does not have valid format or is corrupted.", "@documentImportInvalidDataError": { "description": "Error message when user tries to import a document that is not valid and cannot be parsed" }, + "documentReferenceError": "Invalid proposal reference.", + "@documentReferenceError": { + "description": "Message when the resource reference such as proposal is invalid." + }, "dontShowAgain": "Don't show again", "@dontShowAgain": { "description": "Action to not show again" @@ -1561,6 +1589,10 @@ "@howItWorksVoteDescription": { "description": "Description of voting process and rewards" }, + "howToAddCollaboratorDescription": "Enter the full catalyst ID of the co-proposer you wish to add to your proposal:", + "@howToAddCollaboratorDescription": { + "description": "Description on how to add collaborator" + }, "ideaJourney": "Idea Journey", "@ideaJourney": { "description": "Header for idea journey section" @@ -2403,6 +2435,10 @@ "@proposalViewFundingRequested": { "description": "When viewing proposal" }, + "proposalViewLoadErrorMessage": "We weren't able to load that proposal.", + "@proposalViewLoadErrorMessage": { + "description": "Error message shown when proposal cannot be loaded (i.e. due to connection error)." + }, "proposalViewMetadataOverviewSegment": "Overview", "@proposalViewMetadataOverviewSegment": { "description": "Viewing proposal navigation segment" @@ -2411,6 +2447,18 @@ "@proposalViewMetadataSection": { "description": "Viewing proposal navigation section" }, + "proposalViewNotFoundButton": "Go to Catalyst Home.", + "@proposalViewNotFoundButton": { + "description": "Error button shown when proposal cannot be found." + }, + "proposalViewNotFoundMessage": "It looks like we can't find the proposal you're looking for.\nMaybe a good time for a coffee break?", + "@proposalViewNotFoundMessage": { + "description": "Error message shown when proposal cannot be found." + }, + "proposalViewNotFoundTitle": "We can't find that proposal.", + "@proposalViewNotFoundTitle": { + "description": "Error title shown when proposal cannot be found." + }, "proposalViewProjectDelivery": "Project Delivery", "@proposalViewProjectDelivery": { "description": "When viewing proposal" diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 8ba456721b5e..a7815969f92a 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 @@ -55,6 +55,7 @@ export 'document/document_ref.dart'; export 'document/enums/document_content_media_type.dart'; export 'document/enums/document_property_format.dart'; export 'document/enums/document_property_type.dart'; +export 'document/exception/document_hidden_exception.dart'; export 'document/exception/document_import_invalid_data_exception.dart'; export 'document/schema/document_schema.dart'; export 'document/schema/property/document_property_schema.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart index fffb38acfda4..5bddf177e30b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_ref.dart @@ -42,6 +42,13 @@ sealed class DocumentRef extends Equatable implements Comparable { /// Whether the ref specifies the document [version]. bool get isExact => version != null; + bool get isValid { + final isIdValid = Uuid.isValidUUID(fromString: id); + final isVersionValid = version == null || Uuid.isValidUUID(fromString: version!); + + return isIdValid && isVersionValid; + } + @override List get props => [id, version]; @@ -182,13 +189,6 @@ final class SignedDocumentRef extends DocumentRef { const SignedDocumentRef.loose({required super.id}); - bool get isValid { - final isIdValid = Uuid.isValidUUID(fromString: id); - final isVersionValid = version == null || Uuid.isValidUUID(fromString: version!); - - return isIdValid && isVersionValid; - } - @override SignedDocumentRef copyWith({ String? id, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/exception/document_hidden_exception.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/exception/document_hidden_exception.dart new file mode 100644 index 000000000000..cdc6a50b992f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/exception/document_hidden_exception.dart @@ -0,0 +1,15 @@ +import 'package:catalyst_voices_models/src/document/document_ref.dart'; +import 'package:equatable/equatable.dart'; + +/// A document is hidden and should not be accessed. +final class DocumentHiddenException with EquatableMixin implements Exception { + final DocumentRef ref; + + const DocumentHiddenException({required this.ref}); + + @override + List get props => [ref]; + + @override + String toString() => 'DocumentHiddenException(ref: $ref)'; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart index f9282a75f2c2..e47f91ad62ea 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/catalyst_id.dart @@ -157,6 +157,18 @@ final class CatalystId extends Equatable { return userInfo.isNotEmpty ? userInfo : null; } + /// Tries to parse a [CatalystId] from a [String]. + /// + /// Returns `null` if the [value] cannot be parsed as a valid [CatalystId]. + static CatalystId? tryParse(String value) { + try { + final uri = Uri.parse(value); + return CatalystId.fromUri(uri); + } catch (_) { + return null; + } + } + /// Parses the data from [Uri.path]. /// /// Format: role0Key[/roleNumber][/rotation] diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/user/catalyst_id_test.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/user/catalyst_id_test.dart index 99af97578ced..13b900ab9071 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/test/user/catalyst_id_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/user/catalyst_id_test.dart @@ -211,6 +211,28 @@ void main() { // Then expect(userInfo, contains(encodedUsername)); }); + + test('should fail at parsing', () { + const validUri = 'id.catalyst://cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE='; + + const invalidUri = 'id.catalyst://cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDs='; + const invalidUri2 = ''; + + final validCatalystId = CatalystId.tryParse(validUri); + expect(validCatalystId?.host, equals(CatalystIdHost.cardano.host)); + expect(validCatalystId?.username, isNull); + expect(validCatalystId?.nonce, isNull); + expect(validCatalystId?.role0Key, isNotNull); + expect(validCatalystId?.role?.number, isNull); + expect(validCatalystId?.rotation, isNull); + expect(validCatalystId?.encrypt, isFalse); + + final invalidCatalystId = CatalystId.tryParse(invalidUri); + final invalidCatalystId2 = CatalystId.tryParse(invalidUri2); + + expect(invalidCatalystId, isNull); + expect(invalidCatalystId2, isNull); + }); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart index 19697fea1fbb..d528055b7e46 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart @@ -140,7 +140,7 @@ final class ProposalRepositoryImpl implements ProposalRepository { ); final proposalPublish = await getProposalPublishForRef(ref: ref); if (proposalPublish == null) { - throw const NotFoundException(message: 'Proposal is hidden'); + throw DocumentHiddenException(ref: ref); } final templateRef = documentData.metadata.template!; final documentTemplate = await _documentRepository.getDocumentData(ref: templateRef); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart index 9ece65eec2f7..10dfd3ec6cf6 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; @@ -119,6 +121,8 @@ abstract interface class ProposalService { required SignedDocumentRef categoryId, }); + Future validateForCollaborator(CatalystId id); + /// Fetches favorites proposals ids of the user Stream> watchFavoritesProposalsIds(); @@ -490,6 +494,14 @@ final class ProposalServiceImpl implements ProposalService { ); } + @override + Future validateForCollaborator(CatalystId id) async { + // TODO(LynxLynxx): Add implementation + return Future.delayed(const Duration(seconds: 1), () { + return Random().nextBool(); + }); + } + @override Stream> watchFavoritesProposalsIds() { return _documentRepository.watchAllDocumentsFavoriteIds( diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/voting/voting_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/voting/voting_service.dart index 052c59d36d0c..62b5aa028139 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/voting/voting_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/voting/voting_service.dart @@ -31,7 +31,7 @@ final class VotingMockService implements VotingService { final category = _cacheCampaign!.categories.firstWhere( (category) => category.selfRef == proposal.categoryRef, - orElse: () => throw StateError('Category not found'), + orElse: () => throw const NotFoundException(message: 'Category not found'), ); return VoteProposal.fromData( 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 8c3e5eb58bb0..6b9a7052c255 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 @@ -15,6 +15,9 @@ export 'campaign/current_campaign_info_view_model.dart'; export 'campaign/exception/active_campaign_not_found_exception.dart'; export 'category/category_description_view_model.dart'; export 'category/dropdown_menu_view_model.dart'; +export 'collaborators/collaborator_catalyst_id.dart'; +export 'collaborators/collaborators.dart'; +export 'collaborators/exception/localized_collaborator_is_not_a_proposer_exception.dart'; export 'common/formatters/date_formatter.dart'; export 'common/formatters/duration_formatter.dart'; export 'common/formatters/input_formatters.dart'; @@ -25,7 +28,9 @@ export 'document/document_segment.dart'; export 'document/document_version.dart'; export 'document/uuid.dart'; export 'document/validation/localized_document_validation_result.dart'; +export 'exception/localized_document_hidden_exception.dart'; export 'exception/localized_document_import_invalid_data_exception.dart'; +export 'exception/localized_document_reference_exception.dart'; export 'exception/localized_exception.dart'; export 'exception/localized_not_found_exception.dart'; export 'exception/localized_permission_need_explanation_exception.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator_catalyst_id.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator_catalyst_id.dart new file mode 100644 index 000000000000..6f6ca8c5be02 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborator_catalyst_id.dart @@ -0,0 +1,70 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; +import 'package:formz/formz.dart'; + +final class CatalystIdAlreadyAddedValidationException + extends CollaboratorCatalystIdValidationException { + const CatalystIdAlreadyAddedValidationException(); + + @override + String message(BuildContext context) { + return context.l10n.catalystIdAlreadyAddedAsCollaboratorMessage; + } +} + +final class CatalystIdBelongsToMainProposerValidationException + extends CollaboratorCatalystIdValidationException { + const CatalystIdBelongsToMainProposerValidationException(); + + @override + String message(BuildContext context) { + return context.l10n.catalystIdBelongsToMainProposer; + } +} + +final class CollaboratorCatalystId + extends FormzInput { + final CatalystId? authorCatalystId; + final List collaborators; + + const CollaboratorCatalystId.dirty({ + required String value, + required this.collaborators, + required this.authorCatalystId, + }) : super.dirty(value); + + const CollaboratorCatalystId.pure([ + super.value = '', + this.collaborators = const [], + this.authorCatalystId, + ]) : super.pure(); + + @override + CollaboratorCatalystIdValidationException? validator(String value) { + final catalystId = CatalystId.tryParse(value); + if (catalystId == null) { + return const InvalidCatalystIdFormatValidationException(); + } else if (catalystId == authorCatalystId) { + return const CatalystIdBelongsToMainProposerValidationException(); + } else if (collaborators.contains(catalystId)) { + return const CatalystIdAlreadyAddedValidationException(); + } + return null; + } +} + +sealed class CollaboratorCatalystIdValidationException extends LocalizedException { + const CollaboratorCatalystIdValidationException(); +} + +final class InvalidCatalystIdFormatValidationException + extends CollaboratorCatalystIdValidationException { + const InvalidCatalystIdFormatValidationException(); + + @override + String message(BuildContext context) { + return context.l10n.catalystIdFormatInvalidMessage; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborators.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborators.dart new file mode 100644 index 000000000000..b014757cb9d3 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/collaborators.dart @@ -0,0 +1,11 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +class Collaborators extends Equatable { + final List collaborators; + + const Collaborators({this.collaborators = const []}); + + @override + List get props => [collaborators]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/exception/localized_collaborator_is_not_a_proposer_exception.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/exception/localized_collaborator_is_not_a_proposer_exception.dart new file mode 100644 index 000000000000..d6aeee8dd488 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/collaborators/exception/localized_collaborator_is_not_a_proposer_exception.dart @@ -0,0 +1,12 @@ +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'; + +final class LocalizedCollaboratorIsNotAProposerException extends LocalizedException { + const LocalizedCollaboratorIsNotAProposerException(); + + @override + String message(BuildContext context) { + return context.l10n.collaboratorCatalystIdIsMissingProposerRole; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_document_hidden_exception.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_document_hidden_exception.dart new file mode 100644 index 000000000000..adb7b5fbf8f4 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_document_hidden_exception.dart @@ -0,0 +1,11 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/src/exception/localized_exception.dart'; +import 'package:flutter/material.dart'; + +/// Exception thrown when a document is hidden and should not be accessed. +final class LocalizedDocumentHiddenException extends LocalizedException { + const LocalizedDocumentHiddenException(); + + @override + String message(BuildContext context) => context.l10n.documentHiddenException; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_document_reference_exception.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_document_reference_exception.dart new file mode 100644 index 000000000000..f76c79e6fc8b --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_document_reference_exception.dart @@ -0,0 +1,11 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/src/exception/localized_exception.dart'; +import 'package:flutter/material.dart'; + +/// Exception thrown when a reference is invalid. +final class LocalizedDocumentReferenceException extends LocalizedException { + const LocalizedDocumentReferenceException(); + + @override + String message(BuildContext context) => context.l10n.documentReferenceError; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_exception.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_exception.dart index c0b435ac9847..1a61cb7e2b23 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_exception.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/exception/localized_exception.dart @@ -16,6 +16,7 @@ abstract base class LocalizedException with EquatableMixin implements Exception if (error is LocalizedException) return error; if (error is ApiException) return LocalizedApiException.from(error); if (error is NotFoundException) return const LocalizedNotFoundException(); + if (error is DocumentHiddenException) return const LocalizedDocumentHiddenException(); if (error is ResourceConflictException) { return LocalizedResourceConflictException(error.message); }