From b76bd6ddf2245f0b89238981e5f66d253dbcaa72 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:37:57 +0200 Subject: [PATCH] feat(cat-voices): wallet selection logic (#893) * refactor: move link_wallet_dialog * refactor: align l10n keys * refactor: move link-wallet to another folder * feat: add content * feat: define the rest of stages for wallet link * feat: add missing panels * refactor: rename folder * chore: sort imports * chore: reformat * feat: add translations * chore: add wallet dependencies * feat: add generic error indicator * feat: add voices future builder * feat: update select wallet panel content * fix: error builder alignment * refactor: remove unused inheritance * feat: add empty state * fix: padding * fix: unwanted rebuilds * refactor: resign from future builder in favor of value listenable * feat: improve loading * refactor: improve error and loader builder, use minimum loading delay * refactor: result_type * refactor: cleanup --- .../select_wallet/select_wallet_panel.dart | 136 ++++++++++++-- .../infrastructure/voices_future_builder.dart | 168 ++++++++++++++++++ .../indicators/voices_error_indicator.dart | 59 ++++++ catalyst_voices/lib/widgets/widgets.dart | 1 + .../controllers/wallet_link_controller.dart | 33 +++- .../src/registration/registration_bloc.dart | 11 ++ .../catalyst_voices_blocs/pubspec.yaml | 5 + .../catalyst_voices_localizations.dart | 36 ++++ .../catalyst_voices_localizations_en.dart | 18 ++ .../catalyst_voices_localizations_es.dart | 18 ++ .../lib/l10n/intl_en.arb | 24 +++ .../lib/src/catalyst_voices_shared.dart | 1 + .../lib/src/utils/future_ext.dart | 16 ++ .../catalyst_voices_view_models/pubspec.yaml | 3 + catalyst_voices/pubspec.yaml | 1 + .../voices_future_builder_test.dart | 56 ++++++ .../voices_status_indicator_test.dart | 2 +- .../examples/voices_indicators_example.dart | 13 ++ 18 files changed, 588 insertions(+), 13 deletions(-) create mode 100644 catalyst_voices/lib/widgets/common/infrastructure/voices_future_builder.dart create mode 100644 catalyst_voices/lib/widgets/indicators/voices_error_indicator.dart create mode 100644 catalyst_voices/packages/catalyst_voices_shared/lib/src/utils/future_ext.dart create mode 100644 catalyst_voices/test/widgets/common/infrastructure/voices_future_builder_test.dart diff --git a/catalyst_voices/lib/pages/registration/wallet_link/select_wallet/select_wallet_panel.dart b/catalyst_voices/lib/pages/registration/wallet_link/select_wallet/select_wallet_panel.dart index 89e5dc1681..f240e8d37c 100644 --- a/catalyst_voices/lib/pages/registration/wallet_link/select_wallet/select_wallet_panel.dart +++ b/catalyst_voices/lib/pages/registration/wallet_link/select_wallet/select_wallet_panel.dart @@ -1,9 +1,13 @@ -import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart'; +import 'dart:async'; + +import 'package:catalyst_cardano/catalyst_cardano.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; +import 'package:result_type/result_type.dart'; -// TODO(dtscalac): define content class SelectWalletPanel extends StatelessWidget { const SelectWalletPanel({super.key}); @@ -12,23 +16,133 @@ class SelectWalletPanel extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Spacer(), - VoicesFilledButton( - leading: VoicesAssets.icons.wallet.buildIcon(), + const SizedBox(height: 24), + Text( + context.l10n.walletLinkSelectWalletTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 24), + Text( + context.l10n.walletLinkSelectWalletContent, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 40), + const Expanded(child: _Wallets()), + const SizedBox(height: 24), + VoicesBackButton( onTap: () { RegistrationBloc.of(context).add(const PreviousStepEvent()); }, - child: const Text('Previous'), ), - const SizedBox(height: 12), - VoicesFilledButton( - leading: VoicesAssets.icons.wallet.buildIcon(), + const SizedBox(height: 10), + VoicesTextButton( + trailing: VoicesAssets.icons.externalLink.buildIcon(), + onTap: () {}, + child: Text(context.l10n.seeAllSupportedWallets), + ), + ], + ); + } +} + +class _Wallets extends StatefulWidget { + const _Wallets(); + + @override + State<_Wallets> createState() => _WalletsState(); +} + +class _WalletsState extends State<_Wallets> { + @override + void initState() { + super.initState(); + + final bloc = RegistrationBloc.of(context); + if (bloc.cardanoWallets.value == null) { + unawaited(bloc.refreshCardanoWallets()); + } + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: RegistrationBloc.of(context).cardanoWallets, + builder: (context, result, _) { + return switch (result) { + Success(:final value) => value.isNotEmpty + ? _WalletsList(wallets: value) + : _WalletsEmpty(onRetry: _onRetry), + Failure() => _WalletsError(onRetry: _onRetry), + _ => const Center(child: VoicesCircularProgressIndicator()), + }; + }, + ); + } + + void _onRetry() { + unawaited(RegistrationBloc.of(context).refreshCardanoWallets()); + } +} + +class _WalletsList extends StatelessWidget { + final List wallets; + + const _WalletsList({required this.wallets}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: wallets.length, + itemBuilder: (context, index) { + final wallet = wallets[index]; + return VoicesWalletTile( + iconSrc: wallet.icon, + name: Text(wallet.name), onTap: () { RegistrationBloc.of(context).add(const NextStepEvent()); }, - child: const Text('Next'), + ); + }, + ); + } +} + +class _WalletsEmpty extends StatelessWidget { + final VoidCallback onRetry; + + const _WalletsEmpty({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: double.infinity, + child: VoicesErrorIndicator( + message: context.l10n.noWalletFound, + onRetry: onRetry, ), - ], + ), + ); + } +} + +class _WalletsError extends StatelessWidget { + final VoidCallback onRetry; + + const _WalletsError({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: double.infinity, + child: VoicesErrorIndicator( + message: context.l10n.somethingWentWrong, + onRetry: onRetry, + ), + ), ); } } diff --git a/catalyst_voices/lib/widgets/common/infrastructure/voices_future_builder.dart b/catalyst_voices/lib/widgets/common/infrastructure/voices_future_builder.dart new file mode 100644 index 0000000000..53698f8fb9 --- /dev/null +++ b/catalyst_voices/lib/widgets/common/infrastructure/voices_future_builder.dart @@ -0,0 +1,168 @@ +import 'package:catalyst_voices/widgets/indicators/voices_circular_progress_indicator.dart'; +import 'package:catalyst_voices/widgets/indicators/voices_error_indicator.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +/// A callback that generates a new [Future] of type [T]. +typedef VoicesFutureProvider = Future Function(); + +/// A callback that builds a widget from a [T] value. +/// +/// Call [onRetry] if your data state contains +/// the retry button, it will reload the widget. +typedef VoicesFutureDataBuilder = Widget Function( + BuildContext context, + T value, + VoidCallback onRetry, +); + +/// A callback that builds a widget in an error state. +/// +/// Call [onRetry] if your error state contains +/// the retry button, it will reload the widget. +typedef VoicesFutureErrorBuilder = Widget Function( + BuildContext context, + Object? error, + VoidCallback onRetry, +); + +/// A [FutureBuilder] which simplifies handling a [Future] gently. +class VoicesFutureBuilder extends StatefulWidget { + /// The future provider, make sure to return a fresh future + /// each time it is called, the widget takes care of caching + /// the future internally. + final VoicesFutureProvider future; + + /// The builder called to build a child + /// when the [future] finishes successfully. + final VoicesFutureDataBuilder dataBuilder; + + /// The builder called to build a child + /// when the [future] finishes with an error. + /// + /// If not provided then [VoicesErrorIndicator] is used instead. + final VoicesFutureErrorBuilder errorBuilder; + + /// The builder called to build a child + /// when the [future] hasn't finished yet. + /// + /// If not provided then a centered [VoicesCircularProgressIndicator] + /// is used instead. + final WidgetBuilder loaderBuilder; + + /// The minimum duration during which the loader state is shown. + /// + /// It is useful to delay a future which finishes in a split second + /// as this results in jumpy UI, not giving the user enough time to see + /// the loader state before data or error states are shown. + /// + /// Pass [Duration.zero] to disable it. + final Duration minimumDelay; + + const VoicesFutureBuilder({ + super.key, + required this.future, + required this.dataBuilder, + this.errorBuilder = _defaultErrorBuilder, + this.loaderBuilder = _defaultLoaderBuilder, + this.minimumDelay = const Duration(milliseconds: 300), + }); + + @override + State createState() => _VoicesFutureBuilderState(); +} + +class _VoicesFutureBuilderState + extends State> { + Future? _future; + + @override + void initState() { + super.initState(); + + // ignore: discarded_futures + _future = _makeDelayedFuture(); + } + + @override + void dispose() { + _future = null; + super.dispose(); + } + + @override + void didUpdateWidget(VoicesFutureBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.future != oldWidget.future) { + // ignore: discarded_futures + _future = _makeDelayedFuture(); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.errorBuilder(context, snapshot.error, _onRetry); + } + + final data = snapshot.data; + if (data == null) { + return widget.loaderBuilder(context); + } + + return widget.dataBuilder(context, data, _onRetry); + }, + ); + } + + void _onRetry() { + setState(() { + // ignore: discarded_futures + _future = _makeDelayedFuture(); + }); + } + + Future _makeDelayedFuture() async { + return widget.future().withMinimumDelay(widget.minimumDelay); + } +} + +Widget _defaultErrorBuilder( + BuildContext context, + Object? error, + VoidCallback onRetry, +) { + return _Error(onRetry: onRetry); +} + +Widget _defaultLoaderBuilder(BuildContext context) { + return const _Loader(); +} + +class _Error extends StatelessWidget { + final VoidCallback onRetry; + + const _Error({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return VoicesErrorIndicator( + message: context.l10n.somethingWentWrong, + onRetry: onRetry, + ); + } +} + +class _Loader extends StatelessWidget { + const _Loader(); + + @override + Widget build(BuildContext context) { + return const Center(child: VoicesCircularProgressIndicator()); + } +} diff --git a/catalyst_voices/lib/widgets/indicators/voices_error_indicator.dart b/catalyst_voices/lib/widgets/indicators/voices_error_indicator.dart new file mode 100644 index 0000000000..b34675fe93 --- /dev/null +++ b/catalyst_voices/lib/widgets/indicators/voices_error_indicator.dart @@ -0,0 +1,59 @@ +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'; + +/// A generic error state with optional retry button. +class VoicesErrorIndicator extends StatelessWidget { + /// The description of the error. + final String message; + + /// The callback called when refresh button is tapped. + /// + /// If null then retry button is hidden. + final VoidCallback? onRetry; + + const VoicesErrorIndicator({ + super.key, + required this.message, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colors.outlineBorderVariant!, + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + VoicesAssets.icons.exclamation.buildIcon( + color: Theme.of(context).colors.iconsError, + size: 20, + ), + const SizedBox(width: 10), + Text( + message, + style: Theme.of(context).textTheme.titleSmall?.copyWith(height: 1), + ), + if (onRetry != null) ...[ + const SizedBox(width: 10), + VoicesTextButton( + leading: VoicesAssets.icons.refresh.buildIcon(), + onTap: onRetry, + child: Text(context.l10n.retry), + ), + ], + ], + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index ff32170b6e..0f2fe87976 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -32,6 +32,7 @@ export 'headers/section_header.dart'; export 'headers/segment_header.dart'; export 'indicators/process_progress_indicator.dart'; export 'indicators/voices_circular_progress_indicator.dart'; +export 'indicators/voices_error_indicator.dart'; export 'indicators/voices_linear_progress_indicator.dart'; export 'indicators/voices_no_internet_connection_banner.dart'; export 'indicators/voices_password_strength_indicator.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart index 1ab1279649..8917312fb4 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart @@ -1,11 +1,25 @@ +import 'package:catalyst_cardano/catalyst_cardano.dart'; import 'package:catalyst_voices_blocs/src/registration/registration_navigator.dart'; import 'package:catalyst_voices_blocs/src/registration/registration_state.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; +import 'package:result_type/result_type.dart'; -abstract interface class WalletLinkController {} +// ignore: one_member_abstracts +abstract interface class WalletLinkController { + /// A value listenable with available cardano wallets. + ValueListenable, Exception>?> get cardanoWallets; + + /// Refreshes the [cardanoWallets]. + Future refreshCardanoWallets(); +} final class RegistrationWalletLinkController implements WalletLinkController, RegistrationNavigator { + final ValueNotifier, Exception>?> _wallets = + ValueNotifier(null); + WalletLinkStage _stage; RegistrationWalletLinkController({ @@ -57,4 +71,21 @@ final class RegistrationWalletLinkController return previousStep; } + + @override + ValueListenable, Exception>?> get cardanoWallets => + _wallets; + + @override + Future refreshCardanoWallets() async { + try { + _wallets.value = null; + + final wallets = + await CatalystCardano.instance.getWallets().withMinimumDelay(); + _wallets.value = Success(wallets); + } on Exception catch (error) { + _wallets.value = Failure(error); + } + } } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart index f1466c29ef..c03585012a 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart @@ -1,10 +1,13 @@ +import 'package:catalyst_cardano/catalyst_cardano.dart'; import 'package:catalyst_voices_blocs/src/registration/controllers/keychain_creation_controller.dart'; import 'package:catalyst_voices_blocs/src/registration/controllers/wallet_link_controller.dart'; import 'package:catalyst_voices_blocs/src/registration/registration_event.dart'; import 'package:catalyst_voices_blocs/src/registration/registration_state.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:result_type/result_type.dart'; /// Manages the registration state. final class RegistrationBloc extends Bloc @@ -101,4 +104,12 @@ final class RegistrationBloc extends Bloc WalletLink() => walletLinkPreviousStep(), }; } + + @override + ValueListenable, Exception>?> get cardanoWallets => + _walletLinkController.cardanoWallets; + + @override + Future refreshCardanoWallets() async => + _walletLinkController.refreshCardanoWallets(); } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml index e3f11a3fcc..c759c635c1 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_blocs/pubspec.yaml @@ -9,6 +9,9 @@ environment: dependencies: bloc_concurrency: ^0.2.2 + catalyst_cardano: ^0.3.0 + catalyst_cardano_serialization: ^0.4.0 + catalyst_cardano_web: ^0.3.0 catalyst_voices_brands: path: ../catalyst_voices_brands catalyst_voices_models: @@ -17,6 +20,8 @@ dependencies: path: ../catalyst_voices_repositories catalyst_voices_services: path: ../catalyst_voices_services + catalyst_voices_shared: + path: ../catalyst_voices_shared catalyst_voices_view_models: path: ../catalyst_voices_view_models collection: ^1.18.0 diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart index 39521ede2f..fed990d61d 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart @@ -712,6 +712,24 @@ abstract class VoicesLocalizations { /// **'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'** String get walletLinkIntroContent; + /// A title in link wallet flow on select wallet screen. + /// + /// In en, this message translates to: + /// **'Select the Cardano wallet to link\nto your Catalyst Keychain.'** + String get walletLinkSelectWalletTitle; + + /// A message (content) in link wallet flow on select wallet screen. + /// + /// In en, this message translates to: + /// **'To complete this action, you\'ll submit a signed transaction to Cardano. There will be an ADA transaction fee.'** + String get walletLinkSelectWalletContent; + + /// Message shown when redirecting to external content that describes which wallets are supported. + /// + /// In en, this message translates to: + /// **'See all supported wallets'** + String get seeAllSupportedWallets; + /// No description provided for @accountCreationCreate. /// /// In en, this message translates to: @@ -855,6 +873,24 @@ abstract class VoicesLocalizations { /// In en, this message translates to: /// **'Back'** String get back; + + /// Retry action when something goes wrong. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// Error description when something goes wrong. + /// + /// In en, this message translates to: + /// **'Something went wrong.'** + String get somethingWentWrong; + + /// A description when no wallet extension was found. + /// + /// In en, this message translates to: + /// **'No wallet found.'** + String get noWalletFound; } class _VoicesLocalizationsDelegate extends LocalizationsDelegate { diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart index 6de0f1cd16..850a07bbe0 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart @@ -370,6 +370,15 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get walletLinkIntroContent => 'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'; + @override + String get walletLinkSelectWalletTitle => 'Select the Cardano wallet to link\nto your Catalyst Keychain.'; + + @override + String get walletLinkSelectWalletContent => 'To complete this action, you\'ll submit a signed transaction to Cardano. There will be an ADA transaction fee.'; + + @override + String get seeAllSupportedWallets => 'See all supported wallets'; + @override String get accountCreationCreate => 'Create a new 
Catalyst Keychain'; @@ -441,4 +450,13 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get back => 'Back'; + + @override + String get retry => 'Retry'; + + @override + String get somethingWentWrong => 'Something went wrong.'; + + @override + String get noWalletFound => 'No wallet found.'; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart index ccef81b46c..795fd9e380 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart @@ -370,6 +370,15 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get walletLinkIntroContent => 'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'; + @override + String get walletLinkSelectWalletTitle => 'Select the Cardano wallet to link\nto your Catalyst Keychain.'; + + @override + String get walletLinkSelectWalletContent => 'To complete this action, you\'ll submit a signed transaction to Cardano. There will be an ADA transaction fee.'; + + @override + String get seeAllSupportedWallets => 'See all supported wallets'; + @override String get accountCreationCreate => 'Create a new 
Catalyst Keychain'; @@ -441,4 +450,13 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get back => 'Back'; + + @override + String get retry => 'Retry'; + + @override + String get somethingWentWrong => 'Something went wrong.'; + + @override + String get noWalletFound => 'No wallet found.'; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb index c9bad9cabd..760be780be 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -458,6 +458,18 @@ "@walletLinkIntroContent": { "description": "A message (content) in link wallet flow on intro screen." }, + "walletLinkSelectWalletTitle": "Select the Cardano wallet to link\nto your Catalyst Keychain.", + "@walletLinkSelectWalletTitle": { + "description": "A title in link wallet flow on select wallet screen." + }, + "walletLinkSelectWalletContent": "To complete this action, you'll submit a signed transaction to Cardano. There will be an ADA transaction fee.", + "@walletLinkSelectWalletContent": { + "description": "A message (content) in link wallet flow on select wallet screen." + }, + "seeAllSupportedWallets": "See all supported wallets", + "@seeAllSupportedWallets": { + "description": "Message shown when redirecting to external content that describes which wallets are supported." + }, "accountCreationCreate": "Create a new \u2028Catalyst Keychain", "accountCreationRecover": "Recover your\u2028Catalyst Keychain", "accountCreationOnThisDevice": "On this device", @@ -520,5 +532,17 @@ "back": "Back", "@back": { "description": "For example in button that goes to previous stage of registration" + }, + "retry": "Retry", + "@retry": { + "description": "Retry action when something goes wrong." + }, + "somethingWentWrong": "Something went wrong.", + "@somethingWentWrong": { + "description": "Error description when something goes wrong." + }, + "noWalletFound": "No wallet found.", + "@noWalletFound": { + "description": "A description when no wallet extension was found." } } \ No newline at end of file diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart index b0ad652197..f47e0ebb5a 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart @@ -9,5 +9,6 @@ export 'responsive/responsive_builder.dart'; export 'responsive/responsive_child.dart'; export 'responsive/responsive_padding.dart'; export 'utils/date_time_ext.dart'; +export 'utils/future_ext.dart'; export 'utils/iterable_ext.dart'; export 'utils/typedefs.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/utils/future_ext.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/utils/future_ext.dart new file mode 100644 index 0000000000..dd215d0545 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/utils/future_ext.dart @@ -0,0 +1,16 @@ +extension FutureExt on Future { + /// The minimum loading delay after which the state changes from loading + /// to success/failure will not be perceived too jumpy. + /// + /// Use it to avoid showing a loading state for split second. + static const Duration minimumDelay = Duration(milliseconds: 300); + + /// Returns the result of awaiting the [Future] + /// but applies [delay] to it or [minimumDelay] if [delay] is null. + Future withMinimumDelay([Duration delay = minimumDelay]) async { + final delayed = Future.delayed(delay); + final result = await this; + await delayed; + return result; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml index 5268e3c258..0880d5c290 100644 --- a/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml @@ -8,6 +8,9 @@ environment: flutter: ">=3.24.1" dependencies: + catalyst_cardano: ^0.3.0 + catalyst_cardano_serialization: ^0.4.0 + catalyst_cardano_web: ^0.3.0 equatable: ^2.0.5 flutter: sdk: flutter diff --git a/catalyst_voices/pubspec.yaml b/catalyst_voices/pubspec.yaml index e18d1bbbaf..f40af71fe3 100644 --- a/catalyst_voices/pubspec.yaml +++ b/catalyst_voices/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: go_router: ^14.0.2 google_fonts: ^6.2.1 intl: ^0.19.0 + result_type: ^0.2.0 sentry_flutter: ^8.8.0 url_launcher: ^6.2.2 url_strategy: ^0.3.0 diff --git a/catalyst_voices/test/widgets/common/infrastructure/voices_future_builder_test.dart b/catalyst_voices/test/widgets/common/infrastructure/voices_future_builder_test.dart new file mode 100644 index 0000000000..9a7ff45d03 --- /dev/null +++ b/catalyst_voices/test/widgets/common/infrastructure/voices_future_builder_test.dart @@ -0,0 +1,56 @@ +import 'package:catalyst_voices/widgets/common/infrastructure/voices_future_builder.dart'; +import 'package:catalyst_voices/widgets/indicators/voices_circular_progress_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + group(VoicesFutureBuilder, () { + testWidgets('Displays data when future completes successfully', + (tester) async { + await tester.pumpApp( + VoicesFutureBuilder( + future: () async => 'Test Data', + dataBuilder: (_, __, ___) => const Text('Success'), + ), + ); + + // Let the future start + await tester.pump(const Duration(milliseconds: 100)); + + // Shows loading indicator while waiting for data + expect(find.byType(VoicesCircularProgressIndicator), findsOneWidget); + + // Let the future finish + await tester.pump(const Duration(seconds: 1)); + + // Shows success state + expect(find.text('Success'), findsOneWidget); + }); + + testWidgets('Displays error when future completes with an error', + (tester) async { + // Act + await tester.pumpApp( + VoicesFutureBuilder( + future: () async => throw Exception('Error'), + dataBuilder: (_, __, ___) => const SizedBox.shrink(), + errorBuilder: (_, __, ___) => const Text('Error Occurred'), + ), + ); + + // Let the future start + await tester.pump(const Duration(milliseconds: 100)); + + // Shows loading indicator while waiting for data + expect(find.byType(VoicesCircularProgressIndicator), findsOneWidget); + + // Let the future finish + await tester.pump(const Duration(seconds: 1)); + + // Shows error state + expect(find.text('Error Occurred'), findsOneWidget); + }); + }); +} diff --git a/catalyst_voices/test/widgets/indicators/voices_status_indicator_test.dart b/catalyst_voices/test/widgets/indicators/voices_status_indicator_test.dart index bd7469e25f..a9df84e169 100644 --- a/catalyst_voices/test/widgets/indicators/voices_status_indicator_test.dart +++ b/catalyst_voices/test/widgets/indicators/voices_status_indicator_test.dart @@ -11,7 +11,7 @@ void main() { // Arrange const status = 'QR VERIFIED'; const title = 'Your QR code verified successfully'; - const body = 'You can now use your QR-code 
to login into Catalyst.'; + const body = 'You can now use your QR-code to login into Catalyst.'; const colors = VoicesColorScheme.optional( successContainer: Colors.green, diff --git a/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart index 60c4b6c294..684f897298 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart @@ -80,6 +80,19 @@ class VoicesIndicatorsExample extends StatelessWidget { VoicesCircularProgressIndicator(value: 0.75, showTrack: false), ], ), + const Text('Generic error indicator'), + Row( + children: [ + VoicesErrorIndicator( + message: 'Something went wrong', + onRetry: () {}, + ), + const SizedBox(width: 16), + const VoicesErrorIndicator( + message: 'Something went wrong', + ), + ], + ), const Text('No Internet Connection Banner'), const NoInternetConnectionBanner(), const Text('Password strength indicator'),