diff --git a/android/fastlane/metadata/android/en-US/changelogs/84.txt b/android/fastlane/metadata/android/en-US/changelogs/84.txt new file mode 100644 index 0000000000..dec1065845 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/84.txt @@ -0,0 +1,3 @@ +- Adds browser limit info to Top Up modal +- Removes unneeded upload warning +- Enables sending and redeeming gifted Credits \ No newline at end of file diff --git a/lib/app_shell.dart b/lib/app_shell.dart index 6791185633..aa5ac144f4 100644 --- a/lib/app_shell.dart +++ b/lib/app_shell.dart @@ -1,5 +1,6 @@ import 'package:ardrive/components/profile_card.dart'; import 'package:ardrive/components/side_bar.dart'; +import 'package:ardrive/gift/reedem_button.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; import 'package:ardrive/utils/logger/logger.dart'; import 'package:ardrive/utils/size_constants.dart'; @@ -179,6 +180,7 @@ class AppShellState extends State { setState(() => _showProfileOverlay = !_showProfileOverlay); } +// TODO: add the gift icon class MobileAppBar extends StatelessWidget implements PreferredSizeWidget { const MobileAppBar({ super.key, @@ -223,6 +225,12 @@ class MobileAppBar extends StatelessWidget implements PreferredSizeWidget { const SizedBox( width: 24, ), + if (AppPlatform.isMobileWeb()) ...[ + const RedeemButton(), + const SizedBox( + width: 24, + ), + ], const Padding( padding: EdgeInsets.only(right: 12.0), child: ProfileCard(), diff --git a/lib/blocs/pin_file/pin_file_bloc.dart b/lib/blocs/pin_file/pin_file_bloc.dart index 32dff49854..86243f8e39 100644 --- a/lib/blocs/pin_file/pin_file_bloc.dart +++ b/lib/blocs/pin_file/pin_file_bloc.dart @@ -460,8 +460,10 @@ class PinFileBloc extends Bloc { } IdValidationResult validateId(String value) { + // TODO: Replace this with isValidUuidFormat from `ardrive_utils` const kFileIdRegex = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; + // TODO: Implement this method on `ardrive_utils` const kTransactionIdRegex = r'^[\w-+]{43}$'; final fileIdRegex = RegExp(kFileIdRegex); diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 8ad8b4bfe9..8e25b5bbd5 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -574,7 +574,6 @@ class UploadCubit extends Cubit { progress: progress, controller: uploadController, uploadMethod: _uploadMethod!, - containsLargeTurboUpload: _containsLargeTurboUpload!, ), ); }, @@ -674,7 +673,6 @@ class UploadCubit extends Cubit { controller: uploadController, equatableBust: UniqueKey(), uploadMethod: _uploadMethod!, - containsLargeTurboUpload: _containsLargeTurboUpload!, ), ); }, @@ -882,7 +880,6 @@ class UploadCubit extends Cubit { totalProgress: state.totalProgress, isCanceling: true, uploadMethod: _uploadMethod!, - containsLargeTurboUpload: state.containsLargeTurboUpload, ), ); @@ -896,7 +893,6 @@ class UploadCubit extends Cubit { totalProgress: state.totalProgress, isCanceling: false, uploadMethod: _uploadMethod!, - containsLargeTurboUpload: state.containsLargeTurboUpload, ), ); diff --git a/lib/blocs/upload/upload_state.dart b/lib/blocs/upload/upload_state.dart index 0c79922ecc..3d8f94e76c 100644 --- a/lib/blocs/upload/upload_state.dart +++ b/lib/blocs/upload/upload_state.dart @@ -214,7 +214,6 @@ class UploadInProgressUsingNewUploader extends UploadState { final bool isCanceling; final Key? equatableBust; final UploadMethod uploadMethod; - final bool containsLargeTurboUpload; UploadInProgressUsingNewUploader({ required this.progress, @@ -223,7 +222,6 @@ class UploadInProgressUsingNewUploader extends UploadState { this.equatableBust, this.isCanceling = false, required this.uploadMethod, - required this.containsLargeTurboUpload, }); @override diff --git a/lib/components/app_top_bar.dart b/lib/components/app_top_bar.dart index a278d867a4..556286016f 100644 --- a/lib/components/app_top_bar.dart +++ b/lib/components/app_top_bar.dart @@ -1,6 +1,7 @@ // implement a widget that has 145 of height and maximum widget, and has a row as child import 'package:ardrive/blocs/sync/sync_cubit.dart'; import 'package:ardrive/components/profile_card.dart'; +import 'package:ardrive/gift/reedem_button.dart'; import 'package:ardrive/pages/drive_detail/components/dropdown_item.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; @@ -23,6 +24,8 @@ class AppTopBar extends StatelessWidget { children: [ SyncButton(), SizedBox(width: 24), + RedeemButton(), + SizedBox(width: 24), ProfileCard(), ], ), diff --git a/lib/components/top_up_dialog.dart b/lib/components/top_up_dialog.dart index 475df96ed1..2d1421f91a 100644 --- a/lib/components/top_up_dialog.dart +++ b/lib/components/top_up_dialog.dart @@ -10,6 +10,7 @@ import 'package:ardrive/turbo/utils/utils.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/utils/file_size_units.dart'; import 'package:ardrive/utils/open_url.dart'; +import 'package:ardrive/utils/split_localizations.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; import 'package:arweave/arweave.dart'; import 'package:flutter/gestures.dart'; @@ -386,6 +387,31 @@ class _PresetAmountSelectorState extends State { ), ), ); + final textStyle = ArDriveTypography.body.buttonNormalRegular( + color: ArDriveTheme.of(context).themeData.colors.themeFgDefault, + ); + final appLimitsWarning = splitTranslationsWithMultipleStyles( + originalText: appLocalizationsOf(context).turboModalBrowserLimit, + defaultMapper: (text) => TextSpan( + text: text, + style: ArDriveTypography.body.buttonNormalBold( + color: ArDriveTheme.of(context).themeData.colors.themeFgMuted, + ), + ), + parts: { + appLocalizationsOf(context).turboModalBrowserLimit_link: (text) => + TextSpan( + text: text, + style: textStyle.copyWith( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => openUrl( + url: Resources.ardriveAppLimits, + ), + ) + }, + ); return ArDriveForm( key: _formKey, @@ -405,6 +431,10 @@ class _PresetAmountSelectorState extends State { color: ArDriveTheme.of(context).themeData.colors.themeFgMuted, ), ), + const SizedBox(height: 8), + RichText( + text: TextSpan(children: appLimitsWarning), + ), const SizedBox(height: 32), Text( appLocalizationsOf(context).amount, diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index af60589344..035261b5b7 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -1136,35 +1136,6 @@ class _UploadFormState extends State { color: ArDriveTheme.of(context).themeData.colors.themeFgDefault), ), - - if (state.containsLargeTurboUpload) ...[ - const SizedBox( - height: 8, - ), - Align( - alignment: Alignment.center, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Warning!', - style: ArDriveTypography.body - .buttonLargeBold( - color: ArDriveTheme.of(context) - .themeData - .colors - .themeErrorMuted, - ) - .copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text('Leaving this page may result in a failed upload', - style: ArDriveTypography.body.buttonLargeBold()) - ], - ), - ), - ], ], ), ); diff --git a/lib/gift/bloc/redeem_gift_bloc.dart b/lib/gift/bloc/redeem_gift_bloc.dart new file mode 100644 index 0000000000..a43a467f07 --- /dev/null +++ b/lib/gift/bloc/redeem_gift_bloc.dart @@ -0,0 +1,53 @@ +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/turbo/services/payment_service.dart'; +import 'package:ardrive/utils/logger/logger.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'redeem_gift_event.dart'; +part 'redeem_gift_state.dart'; + +class RedeemGiftBloc extends Bloc { + final PaymentService _paymentService; + final ArDriveAuth _auth; + + RedeemGiftBloc({ + required PaymentService paymentService, + required ArDriveAuth auth, + }) : _auth = auth, + _paymentService = paymentService, + super(RedeemGiftInitial()) { + on((event, emit) async { + if (event is RedeemGiftLoad) { + await _handleRedeemGiftLoad(emit, event); + } + }); + } + + Future _handleRedeemGiftLoad( + Emitter emit, RedeemGiftLoad event) async { + try { + logger.d('RedeemGiftLoad'); + + emit(RedeemGiftLoading()); + + await _paymentService.redeemGift( + email: event.email, + giftCode: event.giftCode, + destinationAddress: _auth.currentUser.walletAddress, + ); + + logger.d('RedeemGiftSuccess'); + + emit(RedeemGiftSuccess()); + } catch (e) { + if (e is GiftAlreadyRedeemed) { + logger.e('RedeemGiftAlreadyRedeemed', e); + emit(RedeemGiftAlreadyRedeemed()); + return; + } + logger.e('RedeemGiftFailure', e); + emit(RedeemGiftFailure()); + } + } +} diff --git a/lib/gift/bloc/redeem_gift_event.dart b/lib/gift/bloc/redeem_gift_event.dart new file mode 100644 index 0000000000..5f346bc672 --- /dev/null +++ b/lib/gift/bloc/redeem_gift_event.dart @@ -0,0 +1,21 @@ +part of 'redeem_gift_bloc.dart'; + +sealed class RedeemGiftEvent extends Equatable { + const RedeemGiftEvent(); + + @override + List get props => []; +} + +class RedeemGiftLoad extends RedeemGiftEvent { + const RedeemGiftLoad({ + required this.giftCode, + required this.email, + }); + + final String giftCode; + final String email; + + @override + List get props => [giftCode]; +} diff --git a/lib/gift/bloc/redeem_gift_state.dart b/lib/gift/bloc/redeem_gift_state.dart new file mode 100644 index 0000000000..ea8068e798 --- /dev/null +++ b/lib/gift/bloc/redeem_gift_state.dart @@ -0,0 +1,18 @@ +part of 'redeem_gift_bloc.dart'; + +sealed class RedeemGiftState extends Equatable { + const RedeemGiftState(); + + @override + List get props => []; +} + +final class RedeemGiftInitial extends RedeemGiftState {} + +final class RedeemGiftLoading extends RedeemGiftState {} + +final class RedeemGiftSuccess extends RedeemGiftState {} + +final class RedeemGiftFailure extends RedeemGiftState {} + +final class RedeemGiftAlreadyRedeemed extends RedeemGiftState {} diff --git a/lib/gift/redeem_gift_modal.dart b/lib/gift/redeem_gift_modal.dart new file mode 100644 index 0000000000..e49bafcde2 --- /dev/null +++ b/lib/gift/redeem_gift_modal.dart @@ -0,0 +1,341 @@ +import 'package:ardrive/blocs/profile/profile_cubit.dart'; +import 'package:ardrive/gift/bloc/redeem_gift_bloc.dart'; +import 'package:ardrive/theme/theme.dart'; +import 'package:ardrive/turbo/topup/views/topup_review_view.dart'; +import 'package:ardrive/turbo/topup/views/topup_success_view.dart'; +import 'package:ardrive/turbo/topup/views/turbo_error_view.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:confetti/confetti.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:responsive_builder/responsive_builder.dart'; + +class RedeemGiftModal extends StatefulWidget { + const RedeemGiftModal({super.key}); + + @override + State createState() => _RedeemGiftModalState(); +} + +class _RedeemGiftModalState extends State + with TickerProviderStateMixin { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _giftCodeController = TextEditingController(); + final ConfettiController _confettiController = ConfettiController( + duration: const Duration(seconds: 5), + ); + + late final AnimationController _opacityController; + + bool _isEmailValid = false; + bool _isGiftCodeValid = false; + + @override + initState() { + super.initState(); + _opacityController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 300)).then((_) { + if (mounted) _opacityController.forward(); + }); + }); + } + + @override + dispose() { + _opacityController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ArDriveTheme.of(context).themeData; + + // custom theme for the text fields on the top-up form + final textTheme = theme.copyWith( + textFieldTheme: theme.textFieldTheme.copyWith( + inputBackgroundColor: theme.colors.themeBgCanvas, + labelColor: theme.colors.themeFgDefault, + requiredLabelColor: theme.colors.themeFgDefault, + inputTextStyle: theme.textFieldTheme.inputTextStyle.copyWith( + color: theme.colors.themeFgMuted, + fontWeight: FontWeight.w600, + height: 1.5, + fontSize: 16, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 13, + vertical: 8, + ), + labelStyle: TextStyle( + color: theme.colors.themeFgDefault, + fontWeight: FontWeight.w600, + height: 1.5, + fontSize: 16, + ), + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ArDriveStandardModal( + hasCloseButton: true, + width: kLargeDialogWidth + 200, + title: appLocalizationsOf(context).redeemYourGift, + actions: [ + ModalAction( + action: () { + Navigator.of(context).pop(); + }, + title: appLocalizationsOf(context).cancel, + ), + ModalAction( + isEnable: _isEmailValid && _isGiftCodeValid, + action: () { + context.read().add(RedeemGiftLoad( + giftCode: _giftCodeController.text, + email: _emailController.text)); + }, + title: appLocalizationsOf(context).confirm, + ) + ], + content: BlocConsumer( + listener: (context, state) { + if (state is RedeemGiftLoading) { + _opacityController.reset(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 300)).then((_) { + if (mounted) _opacityController.forward(); + }); + }); + } + if (state is RedeemGiftSuccess) { + _confettiController.play(); + context.read().refreshBalance(); + Navigator.of(context).pop(); + + showArDriveDialog( + context, + content: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ArDriveStandardModal( + width: 575, + content: SuccessView( + showConfetti: true, + successMessage: appLocalizationsOf(context).giftRedeemed, + detailMessage: appLocalizationsOf(context) + .redemptionSuccessDescription, + closeButtonLabel: appLocalizationsOf(context).close, + ), + ), + ), + barrierDismissible: false, + barrierColor: ArDriveTheme.of(context) + .themeData + .colors + .shadow + .withOpacity(0.9), + ); + } else if (state is RedeemGiftFailure) { + showArDriveDialog( + context, + content: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ArDriveStandardModal( + width: 575, + content: ErrorView( + errorTitle: appLocalizationsOf(context).invalidCode, + errorMessage: + appLocalizationsOf(context).redemptionErrorDescription, + onDismiss: () { + Navigator.of(context).pop(); + }, + onTryAgain: () { + Navigator.of(context).pop(); + }, + ), + ), + ), + barrierDismissible: false, + barrierColor: ArDriveTheme.of(context) + .themeData + .colors + .shadow + .withOpacity(0.9), + ); + } else if (state is RedeemGiftAlreadyRedeemed) { + showArDriveDialog( + context, + content: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ArDriveStandardModal( + width: 575, + content: ErrorView( + errorTitle: appLocalizationsOf(context).codeAlreadyUsed, + errorMessage: + appLocalizationsOf(context).codeAlreadyUsedDescription, + onDismiss: () { + Navigator.of(context).pop(); + }, + onTryAgain: () { + Navigator.of(context).pop(); + }, + ), + ), + ), + barrierDismissible: false, + barrierColor: ArDriveTheme.of(context) + .themeData + .colors + .shadow + .withOpacity(0.9), + ); + } + }, builder: (context, state) { + Widget child; + if (state is RedeemGiftLoading) { + child = const SizedBox( + height: 350, child: Center(child: CircularProgressIndicator())); + } else { + child = Column( + children: [ + Text( + appLocalizationsOf(context) + .confirmTheEmailAddressTheGiftWasSentTo, + style: ArDriveTypography.body + .buttonNormalBold( + color: ArDriveTheme.of(context) + .themeData + .colors + .themeAccentDisabled, + ) + .copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox( + height: 64, + ), + ArDriveTheme( + themeData: textTheme, + child: ScreenTypeLayout.builder( + mobile: (context) { + return Column( + children: [ + SizedBox(height: 100, child: _emailField()), + const SizedBox( + height: 16, + ), + SizedBox(height: 100, child: _giftCodeField()), + ], + ); + }, + desktop: (context) { + return SizedBox( + height: 100, + child: Row( + children: [ + Flexible( + flex: 1, + child: _emailField(), + ), + const SizedBox( + width: 16, + ), + Flexible( + flex: 1, + child: _giftCodeField(), + ), + ], + ), + ); + }, + ), + ), + const SizedBox( + height: 64, + ), + Divider( + color: ArDriveTheme.of(context) + .themeData + .colors + .themeBorderDefault, + ), + ], + ); + } + return AnimatedSize( + duration: const Duration(milliseconds: 300), + child: FadeTransition( + opacity: _opacityController, + child: child, + ), + ); + }), + ), + ); + } + + Widget _emailField() { + return ArDriveTextField( + label: appLocalizationsOf(context).email, + validator: (s) { + if (s == null || s.isEmpty || isEmailValid(s)) { + setState(() { + _isEmailValid = true; + }); + return null; + } + + setState(() { + _isEmailValid = false; + }); + + return appLocalizationsOf(context).pleaseEnterAValidEmail; + }, + onFieldSubmitted: (s) { + if (_isEmailValid && _isGiftCodeValid) { + context.read().add(RedeemGiftLoad( + giftCode: _giftCodeController.text, + email: _emailController.text)); + } + }, + controller: _emailController, + onChanged: (s) {}, + ); + } + + Widget _giftCodeField() { + return ArDriveTextField( + controller: _giftCodeController, + onFieldSubmitted: (s) { + if (_isEmailValid && _isGiftCodeValid) { + context.read().add(RedeemGiftLoad( + giftCode: _giftCodeController.text, + email: _emailController.text)); + } + }, + validator: (s) { + if (s == null || !isValidUuidV4(s)) { + setState(() { + _isGiftCodeValid = false; + }); + return appLocalizationsOf(context).theGiftCodeProvidedIsInvalid; + } + + setState(() { + _isGiftCodeValid = true; + }); + + return null; + }, + label: appLocalizationsOf(context).giftCode, + ); + } +} diff --git a/lib/gift/reedem_button.dart b/lib/gift/reedem_button.dart new file mode 100644 index 0000000000..4d7e518cbc --- /dev/null +++ b/lib/gift/reedem_button.dart @@ -0,0 +1,60 @@ +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/gift/bloc/redeem_gift_bloc.dart'; +import 'package:ardrive/gift/redeem_gift_modal.dart'; +import 'package:ardrive/misc/misc.dart'; +import 'package:ardrive/pages/drive_detail/components/dropdown_item.dart'; +import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; +import 'package:ardrive/turbo/services/payment_service.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; +import 'package:ardrive/utils/open_url.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class RedeemButton extends StatelessWidget { + const RedeemButton({super.key}); + + @override + Widget build(BuildContext context) { + return ArDriveDropdown( + anchor: const Aligned( + follower: Alignment.topRight, + target: Alignment.bottomRight, + ), + items: [ + ArDriveDropdownItem( + onClick: () { + openUrl(url: Resources.sendGiftLink); + }, + content: ArDriveDropdownItemTile( + name: appLocalizationsOf(context).sendGift, + ), + ), + ArDriveDropdownItem( + onClick: () { + showArDriveDialog( + context, + content: BlocProvider( + create: (context) => RedeemGiftBloc( + paymentService: context.read(), + auth: context.read()), + child: const RedeemGiftModal(), + ), + ); + }, + content: ArDriveDropdownItemTile( + name: appLocalizationsOf(context).redeemGift, + ), + ), + ], + child: ArDriveIconButton( + icon: ArDriveIcons.gift( + size: 20, + color: ArDriveTheme.of(context).themeData.colors.themeFgDefault, + ), + tooltip: appLocalizationsOf(context).giftCredits, + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 49226c24bd..d24ce66e34 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -198,6 +198,10 @@ "@closeEmphasized": { "description": "Close modal. Emphasized with upper case" }, + "codeAlreadyUsed": "Code already used", + "@codeAlreadyUsed": {}, + "codeAlreadyUsedDescription": "The code provided has already been redeemed.", + "@codeAlreadyUsedDescription": {}, "collapse": "Collapse", "@collapse": { "description": "E.g.: Collapse the image" @@ -210,6 +214,8 @@ "@computingSnapshotData": { "description": "Computing Snapshot Data" }, + "confirm": "Confirm", + "@confirm": {}, "confirmed": "Confirmed", "@confirmed": { "description": "Status tooltip for transactions that are confirmed by the network" @@ -222,6 +228,8 @@ "@confirmPassword": { "description": "Label for repeating the password again" }, + "confirmTheEmailAddressTheGiftWasSentTo": "Please confirm the email address the gift was sent to and enter the gift code to redeem your gift!", + "@confirmTheEmailAddressTheGiftWasSentTo": {}, "conflictingFiles": "Conflicting files:", "@conflictingFiles": { "description": "Files conflict with already existing items" @@ -360,9 +368,9 @@ "@createNewSnapshot": { "description": "The action of snapshotting a drive" }, - "createSnapshot": "New Snapshot", + "createSnapshot": "Create snapshot", "@createSnapshot": { - "description": "The action of snapshotting a drive" + "description": "The action of creating a snapshot" }, "createSnapshotExplanation": "Snapshots reduce the time it takes for the drive to load. Do you want to create a snapshot of your drive {driveName}?", "@createSnapshotExplanation": { @@ -475,6 +483,10 @@ "@doneEmphasized": { "description": "Done, no more to do here. Emphasized with upper case" }, + "dontAskMeAgain": "Don’t ask me again.", + "@dontAskMeAgain": { + "description": "Don't ask me for it again" + }, "dontHaveAWallet1Part": "Don't have a wallet? Get one ", "@dontHaveAWallet1Part": { "description": "text to open the url to get a wallet" @@ -605,6 +617,8 @@ } } }, + "email": "Email", + "@email": {}, "enableBiometricLogin": "Enable Biometric Login", "@enableBiometricLogin": { "description": "Enable Biometric Login" @@ -985,6 +999,12 @@ "@getAWallet": { "description": "Text for the link that redirects the user to the 'Claim a token' page of ArWeave" }, + "giftCode": "Gift Code", + "@giftCode": {}, + "giftCredits": "Gift Credits", + "@giftCredits": {}, + "giftRedeemed": "Gift Redeemed!", + "@giftRedeemed": {}, "gotIt": "Got it!", "@gotIt": { "description": "Example similar phrases: I've got it, I understood, OK" @@ -1077,6 +1097,8 @@ "@insufficientFundsForUploadFolders": { "description": "Show that needs funds to upload folders" }, + "invalidCode": "Invalid code", + "@invalidCode": {}, "invalidKeyFile": "Invalid Keyfile", "@invalidKeyFile": { "description": "Invalid Keyfile" @@ -1282,6 +1304,10 @@ "@newFolder": { "description": "Create a new folder entity" }, + "newSnapshot": "New Snapshot", + "@newSnapshot": { + "description": "The action of snapshotting a drive" + }, "newString": "New", "@newString": { "description": "New entity (folder, file, drive, manifest, attached drive)" @@ -1508,6 +1534,16 @@ "@recreatingFolderEmphasized": { "description": "Folder is being recreated. Emphasized with upper case" }, + "redeemGift": "Redeem gift", + "@redeemGift": {}, + "redeemYourGift": "Redeem Your Gift", + "@redeemYourGift": {}, + "redemptionErrorDescription": "Redemption was unsuccessful", + "@redemptionErrorDescription": {}, + "redemptionErrorTitle": "No soup for you!", + "@redemptionErrorTitle": {}, + "redemptionSuccessDescription": "Your credits will be added to your account soon. You can close this window.", + "@redemptionSuccessDescription": {}, "refresh": "Refresh", "@refresh": {}, "refreshTurboBalanceTooltip": "Refresh balance", @@ -1584,6 +1620,8 @@ "@selectWalletEmphasized": { "description": "Users can log in with a crypto wallet, so this prompts them to choose one for their login. Emphasized with upper case" }, + "sendGift": "Send gift", + "@sendGift": {}, "share": "Share", "@share": {}, "sharedDrives": "Shared Drives", @@ -1684,6 +1722,18 @@ } } }, + "snapshotRecommended": "Snapshot Recommended", + "@snapshotRecommended": { + "description": "Indicates that making a drive snapshot is recommended " + }, + "snapshotRecommendedBody": "Snapshots help speed up the time it takes to sync your large drives. Would you like to try?", + "@snapshotRecommendedBody": { + "description": "Explains how the snapshots improves the syncing time of a drive" + }, + "snapshotRecommendedDontAskAgain": "You can always create a snapshot under the Advanced section of the New menu in the future.", + "@snapshotRecommendedDontAskAgain": { + "description": "Explains where you can make a snapshot." + }, "snapshotSize": "Snapshot Size: {size}", "@snapshotSize": { "description": "Snapshot Size", @@ -1744,6 +1794,8 @@ "@termsAndConditions": { "description": "terms and conditions" }, + "theGiftCodeProvidedIsInvalid": "The gift code provided is invalid.", + "@theGiftCodeProvidedIsInvalid": {}, "theIdProvidedDoesntExist": "The ID provided does not exist", "@theIdProvidedDoesntExist": { "description": "Indicates that the valid ID you provided doesn't exist" @@ -1822,6 +1874,14 @@ "@turboErrorMessageSessionExpired": {}, "turboErrorMessageUnknown": "The payment was not successful. Please check your card information and try again.", "@turboErrorMessageUnknown": {}, + "turboModalBrowserLimit": "Upload sizes are limited by browsers and other factors. Learn more.", + "@turboModalBrowserLimit": { + "description": "Explains there's platform-specific limitations for uploading" + }, + "turboModalBrowserLimit_link": "Learn more.", + "@turboModalBrowserLimit_link": { + "description": "Should perfectly match the segment of text from turboModalBrowserLimit." + }, "turboPercentageDiscountApplied": "({percentage}% discount applied)", "@turboPercentageDiscountApplied": { "description": "E.g. \"50% discount applied\"", @@ -1847,7 +1907,7 @@ }, "turboUsdDiscountApplied": "(${amount} discount applied)", "@turboUsdDiscountApplied": { - "description": "E.g. \"10$ discount applied\"", + "description": "E.g. \"$10 discount applied\"", "placeholders": { "amount": { "type": "String" @@ -2112,4 +2172,4 @@ "@zippingYourFiles": { "description": "Download failure message when a file is too big" } -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 1ee1028e0e..257528bfd1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -118,12 +118,10 @@ Future _initialize() async { ) : DontUseUploadService(); - _turboPayment = config.useTurboPayment - ? PaymentService( - turboPaymentUri: Uri.parse(config.defaultTurboPaymentUrl!), - httpClient: ArDriveHTTP(), - ) - : DontUsePaymentService(); + _turboPayment = PaymentService( + turboPaymentUri: Uri.parse(config.defaultTurboPaymentUrl!), + httpClient: ArDriveHTTP(), + ); if (kIsWeb) { refreshHTMLPageAtInterval(const Duration(hours: 12)); diff --git a/lib/misc/resources.dart b/lib/misc/resources.dart index ef95559bda..77aad3f274 100644 --- a/lib/misc/resources.dart +++ b/lib/misc/resources.dart @@ -11,6 +11,7 @@ class Resources { static const ardrivePublicSiteLink = 'https://ardrive.io/'; static const agreementLink = 'https://ardrive.io/tos-and-privacy/'; static const getWalletLink = 'https://www.arconnect.io/'; + static const sendGiftLink = 'http://gift.ardrive.io/'; static const howDoesKeyFileLoginWork = 'https://help.ardrive.io/hc/en-us/articles/15412384724251-How-Do-Keyfile-and-Seed-Phrase-Login-Work-'; @@ -21,6 +22,9 @@ class Resources { static const helpCenterLink = 'https://help.ardrive.io/hc/en-us/articles/9350732157723-Contact-Us'; static const discordLink = 'https://discord.gg/KkTqDe4GAF'; + + static const ardriveAppLimits = + 'https://help.ardrive.io/hc/en-us/articles/5300389777179-ArDrive-App-'; } class Images { diff --git a/lib/turbo/services/payment_service.dart b/lib/turbo/services/payment_service.dart index 2d9667cfe7..dfa854ca06 100644 --- a/lib/turbo/services/payment_service.dart +++ b/lib/turbo/services/payment_service.dart @@ -5,6 +5,7 @@ import 'package:ardrive/utils/logger/logger.dart'; import 'package:ardrive/utils/turbo_utils.dart'; import 'package:ardrive_http/ardrive_http.dart'; import 'package:arweave/arweave.dart'; +import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; import 'package:uuid/uuid.dart'; @@ -128,6 +129,40 @@ class PaymentService { return List.from(jsonDecode(result.data)); } + + Future redeemGift({ + required String email, + required String giftCode, + required String destinationAddress, + }) async { + try { + final result = await httpClient.get( + url: + '$turboPaymentUri/v1/redeem?id=$giftCode&email=$email&destinationAddress=$destinationAddress', + responseType: ResponseType.json, + ); + + logger.d('Gift redeem result: ${result.data}'); + + if (result.statusCode != 200) { + throw Exception( + 'Gift redeem failed with status code ${result.statusCode}'); + } + + final newBalance = result.data['userBalance'] as String; + + return int.parse(newBalance); + } on ArDriveHTTPException catch (e) { + if (e.data == 'Gift has already been redeemed!') { + logger.e('Gift has already been redeemed!'); + throw GiftAlreadyRedeemed(); + } + rethrow; + } catch (e) { + logger.d(e.toString()); + rethrow; + } + } } PriceForFiat _parseHttpResponseForPriceForFiat( @@ -217,53 +252,14 @@ String _urlParamsForGetPriceForFiat({ return urlParams; } -class DontUsePaymentService implements PaymentService { - @override - late ArDriveHTTP httpClient; - - @override - Future getPriceForBytes({required int byteSize}) => - throw UnimplementedError(); - - @override - Future getBalance({required Wallet wallet}) => - throw UnimplementedError(); - - @override - Future getPaymentIntent({ - required Wallet wallet, - required double amount, - String currency = 'usd', - String? promoCode, - }) async { - throw UnimplementedError(); - } - - @override - Uri get turboPaymentUri => throw UnimplementedError(); - - @override - bool get useTurboPayment => false; - - @override - Future getPriceForFiat({ - required wallet, - required double amount, - required String currency, - String? promoCode, - }) => - throw UnimplementedError(); - - @override - Future> getSupportedCountries() { - throw UnimplementedError(); - } -} - class TurboUserNotFound implements Exception { TurboUserNotFound(); } +class GiftAlreadyRedeemed implements Exception { + GiftAlreadyRedeemed(); +} + class PaymentServiceException implements Exception, Equatable { final String message; diff --git a/lib/turbo/topup/views/topup_success_view.dart b/lib/turbo/topup/views/topup_success_view.dart index bd074e88e9..201355b56c 100644 --- a/lib/turbo/topup/views/topup_success_view.dart +++ b/lib/turbo/topup/views/topup_success_view.dart @@ -1,10 +1,60 @@ +import 'dart:math'; + import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:confetti/confetti.dart'; import 'package:flutter/material.dart'; class TurboSuccessView extends StatelessWidget { const TurboSuccessView({super.key}); + @override + Widget build(BuildContext context) { + return SuccessView( + successMessage: appLocalizationsOf(context).paymentSuccessful, + detailMessage: + appLocalizationsOf(context).yourCreditsWillBeAddedToYourAccount, + closeButtonLabel: appLocalizationsOf(context).close, + ); + } +} + +class SuccessView extends StatefulWidget { + final String successMessage; + final String detailMessage; + final String closeButtonLabel; + final bool showConfetti; + + const SuccessView({ + super.key, + required this.successMessage, + required this.detailMessage, + required this.closeButtonLabel, + this.showConfetti = false, + }); + + @override + State createState() => _SuccessViewState(); +} + +class _SuccessViewState extends State { + final ConfettiController? confettiController1 = ConfettiController( + duration: const Duration(seconds: 5), + ); + + final ConfettiController? confettiController2 = ConfettiController( + duration: const Duration(seconds: 5), + ); + + @override + void initState() { + super.initState(); + if (widget.showConfetti) { + confettiController1!.play(); + confettiController2!.play(); + } + } + @override Widget build(BuildContext context) { return ArDriveCard( @@ -12,6 +62,35 @@ class TurboSuccessView extends StatelessWidget { contentPadding: EdgeInsets.zero, content: Column( children: [ + if (widget.showConfetti) + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ConfettiWidget( + numberOfParticles: 10, + blastDirection: -pi / 2, + blastDirectionality: BlastDirectionality.explosive, + confettiController: confettiController1!, + maxBlastForce: 40, + child: const SizedBox( + height: 0, + width: 0, + ), + ), + ConfettiWidget( + numberOfParticles: 10, + blastDirection: pi / 2, + blastDirectionality: BlastDirectionality.explosive, + confettiController: confettiController2!, + maxBlastForce: 40, + child: const SizedBox( + height: 0, + width: 0, + ), + ), + ], + ), Flexible( flex: 1, child: Align( @@ -37,18 +116,19 @@ class TurboSuccessView extends StatelessWidget { .colors .themeSuccessDefault, ), - Text(appLocalizationsOf(context).paymentSuccessful, + Text(widget.successMessage, style: ArDriveTypography.body.leadBold()), const SizedBox(height: 16), Text( - appLocalizationsOf(context) - .yourCreditsWillBeAddedToYourAccount, - style: ArDriveTypography.body.buttonNormalRegular( - color: ArDriveTheme.of(context) - .themeData - .colors - .themeFgDefault, - ), + widget.detailMessage, + style: ArDriveTypography.body + .buttonNormalRegular( + color: ArDriveTheme.of(context) + .themeData + .colors + .themeFgDefault, + ) + .copyWith(fontWeight: FontWeight.w700), ), ], ), @@ -60,7 +140,7 @@ class TurboSuccessView extends StatelessWidget { child: ArDriveButton( maxHeight: 44, maxWidth: 143, - text: appLocalizationsOf(context).close, + text: widget.closeButtonLabel, fontStyle: ArDriveTypography.body.buttonLargeBold( color: Colors.white, ), diff --git a/lib/turbo/topup/views/turbo_error_view.dart b/lib/turbo/topup/views/turbo_error_view.dart index ac1f9ae59c..15c0f99ffa 100644 --- a/lib/turbo/topup/views/turbo_error_view.dart +++ b/lib/turbo/topup/views/turbo_error_view.dart @@ -36,6 +36,31 @@ class TurboErrorView extends StatelessWidget { } } + @override + Widget build(BuildContext context) { + return ErrorView( + errorMessage: _getErrorMessageForErrorType(context), + errorTitle: appLocalizationsOf(context).theresBeenAProblem, + onDismiss: () => onDismiss(), + onTryAgain: () => onTryAgain(), + ); + } +} + +class ErrorView extends StatelessWidget { + const ErrorView({ + super.key, + required this.errorMessage, + required this.errorTitle, + this.onTryAgain, + required this.onDismiss, + }); + + final String errorMessage; + final String errorTitle; + final VoidCallback? onTryAgain; + final VoidCallback onDismiss; + @override Widget build(BuildContext context) { return ArDriveCard( @@ -44,25 +69,25 @@ class TurboErrorView extends StatelessWidget { content: Column( children: [ Flexible( - flex: 1, child: Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.only(top: 26, right: 26), child: ArDriveClickArea( child: GestureDetector( - onTap: () { - onDismiss(); - Navigator.pop(context); - }, - child: ArDriveIcons.x()), + onTap: () { + onDismiss(); + Navigator.pop(context); + }, + child: ArDriveIcons.x(), + ), ), ), ), ), Flexible( - flex: 1, child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ ArDriveIcons.triangle( size: 50, @@ -71,12 +96,12 @@ class TurboErrorView extends StatelessWidget { .colors .themeErrorDefault, ), - Text(appLocalizationsOf(context).theresBeenAProblem, - style: ArDriveTypography.body.leadBold()), + Text(errorTitle, style: ArDriveTypography.body.leadBold()), const SizedBox(height: 16), Text( - _getErrorMessageForErrorType(context), - style: ArDriveTypography.body.buttonNormalRegular( + errorMessage, + textAlign: TextAlign.center, + style: ArDriveTypography.body.buttonNormalBold( color: ArDriveTheme.of(context) .themeData .colors @@ -87,7 +112,6 @@ class TurboErrorView extends StatelessWidget { ), ), Flexible( - flex: 1, child: Align( alignment: Alignment.center, child: ArDriveButton( @@ -97,9 +121,7 @@ class TurboErrorView extends StatelessWidget { fontStyle: ArDriveTypography.body.buttonLargeBold( color: Colors.white, ), - onPressed: () { - onTryAgain(); - }, + onPressed: onTryAgain, ), ), ), diff --git a/packages/ardrive_uploader/pubspec.yaml b/packages/ardrive_uploader/pubspec.yaml index 423def4d64..3b516db24d 100644 --- a/packages/ardrive_uploader/pubspec.yaml +++ b/packages/ardrive_uploader/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: ardrive_http: git: url: https://github.com/ardriveapp/ardrive_http.git - ref: v1.3.1 + ref: v1.3.2 ardrive_io: git: url: https://github.com/ardriveapp/ardrive_io.git diff --git a/packages/ardrive_utils/lib/ardrive_utils.dart b/packages/ardrive_utils/lib/ardrive_utils.dart index 281594bd78..2bd0c5b1d5 100644 --- a/packages/ardrive_utils/lib/ardrive_utils.dart +++ b/packages/ardrive_utils/lib/ardrive_utils.dart @@ -13,3 +13,4 @@ export 'src/types/arweave_address.dart'; export 'src/types/string_types.dart'; export 'src/types/transaction_id.dart'; export 'src/types/winston.dart'; +export 'src/validations.dart'; diff --git a/packages/ardrive_utils/lib/src/validations.dart b/packages/ardrive_utils/lib/src/validations.dart new file mode 100644 index 0000000000..7b3acab1b9 --- /dev/null +++ b/packages/ardrive_utils/lib/src/validations.dart @@ -0,0 +1,13 @@ +bool isValidUuidV4(String uuid) { + final RegExp uuidV4Pattern = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'); + + return uuidV4Pattern.hasMatch(uuid.toLowerCase()); +} + +bool isValidUuidFormat(String uuid) { + final RegExp uuidPattern = + RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); + + return uuidPattern.hasMatch(uuid.toLowerCase()); +} diff --git a/packages/ardrive_utils/pubspec.yaml b/packages/ardrive_utils/pubspec.yaml index 77663c1d53..ba456c4576 100644 --- a/packages/ardrive_utils/pubspec.yaml +++ b/packages/ardrive_utils/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: ardrive_http: git: url: https://github.com/ardriveapp/ardrive_http.git - ref: v1.3.1 + ref: v1.3.2 arweave: git: url: https://github.com/ardriveapp/arweave-dart.git diff --git a/packages/ardrive_utils/test/src/validations_test.dart b/packages/ardrive_utils/test/src/validations_test.dart new file mode 100644 index 0000000000..8a9d807a93 --- /dev/null +++ b/packages/ardrive_utils/test/src/validations_test.dart @@ -0,0 +1,90 @@ +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isValidUuidV4 Tests', () { + test('should return true for a valid UUID v4', () { + expect(isValidUuidV4('d96fed74-7908-4810-9e0a-fc2754e4c810'), isTrue); + expect(isValidUuidV4('2da64b48-bc4b-41e4-9b23-f937a2bb0397'), isTrue); + expect(isValidUuidV4('f47ac10b-58cc-4372-a567-0e02b2c3d479'), isTrue); + }); + + test('should return false for an invalid UUID format', () { + expect(isValidUuidV4('invalid-uuid'), isFalse); + + /// without the dashes + expect(isValidUuidV4('123e4567e89b-12d3-a456-426614174000'), isFalse); + expect(isValidUuidV4('123e4567e89b12d3-a456-426614174000'), isFalse); + expect(isValidUuidV4('123e4567e89b12d3a456-426614174000'), isFalse); + expect(isValidUuidV4('123e4567e89b12d3a456426614174000'), isFalse); + expect( + isValidUuidV4('12345678-1234-1234-1234-1234567890ab-1234'), isFalse); + expect(isValidUuidV4('123456781234123412341234567890ab'), isFalse); + }); + + test('should return false for an empty string', () { + expect(isValidUuidV4(''), isFalse); + }); + + test('should handle case sensitivity', () { + expect(isValidUuidV4('123E4567-E89B-42D3-A456-426614174000'), isTrue); + expect(isValidUuidV4('123e4567-e89b-42d3-a456-426614174000'), isTrue); + }); + + // Boundary Values Tests + test('Boundary Value: Minimum', () { + expect(isValidUuidV4('00000000-0000-4000-8000-000000000000'), isTrue); + }); + + test('Boundary Value: Maximum', () { + expect(isValidUuidV4('ffffffff-ffff-4fff-bfff-ffffffffffff'), isTrue); + }); + + // wrong version + test('Boundary Value: Minimum', () { + expect(isValidUuidV4('00000000-0000-5000-8000-000000000000'), isFalse); + expect(isValidUuidV4('00000000-0000-3000-8000-000000000000'), isFalse); + expect(isValidUuidV4('00000000-0000-2000-8000-000000000000'), isFalse); + expect(isValidUuidV4('00000000-0000-1000-8000-000000000000'), isFalse); + }); + test('Boundary Value: Maximum', () { + expect(isValidUuidV4('ffffffff-ffff-5fff-bfff-ffffffffffff'), isFalse); + expect(isValidUuidV4('ffffffff-ffff-3fff-bfff-ffffffffffff'), isFalse); + expect(isValidUuidV4('ffffffff-ffff-2fff-bfff-ffffffffffff'), isFalse); + expect(isValidUuidV4('ffffffff-ffff-1fff-bfff-ffffffffffff'), isFalse); + }); + }); + + group('isValidUuidFormat Tests', () { + test('Valid UUID format', () { + expect(isValidUuidFormat('123e4567-e89b-12d3-a456-426614174000'), isTrue); + }); + + test('Invalid UUID format - missing parts', () { + expect(isValidUuidFormat('123e4567-e89b-12d3-a456'), isFalse); + }); + + test('Invalid UUID format - extra parts', () { + expect(isValidUuidFormat('123e4567-e89b-12d3-a456-426614174000-1234'), + isFalse); + }); + + test('Invalid UUID format - wrong separator', () { + expect( + isValidUuidFormat('123e4567:e89b:12d3:a456:426614174000'), isFalse); + }); + + test('Invalid UUID format - invalid characters', () { + expect( + isValidUuidFormat('123e4567-e89b-12d3-a456-42661417g000'), isFalse); + }); + + test('Invalid UUID format - wrong length', () { + expect(isValidUuidFormat('123e4567-e89b-12d3-a456-42661417400'), isFalse); + }); + + test('Invalid UUID format - empty string', () { + expect(isValidUuidFormat(''), isFalse); + }); + }); +} diff --git a/packages/pst/pubspec.lock b/packages/pst/pubspec.lock index c272381c0d..95d86aaaa7 100644 --- a/packages/pst/pubspec.lock +++ b/packages/pst/pubspec.lock @@ -21,11 +21,11 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.3.1" - resolved-ref: "355a6113774a926e4d5df8ee9e63d8434623c7e9" + ref: "v1.3.2" + resolved-ref: "0bc4711331004c99d41a9de361467a23beb9216c" url: "https://github.com/ardriveapp/ardrive_http.git" source: git - version: "1.3.0" + version: "1.3.2" ardrive_utils: dependency: "direct main" description: diff --git a/packages/pst/pubspec.yaml b/packages/pst/pubspec.yaml index 2d8118dd27..b629ebfaad 100644 --- a/packages/pst/pubspec.yaml +++ b/packages/pst/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: ardrive_http: git: url: https://github.com/ardriveapp/ardrive_http.git - ref: v1.3.1 + ref: v1.3.2 universal_html: ^2.2.4 ardrive_utils: path: ../ardrive_utils diff --git a/pubspec.lock b/pubspec.lock index 1cb7cd8d56..70ed0571b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -75,11 +75,11 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.3.1" - resolved-ref: "355a6113774a926e4d5df8ee9e63d8434623c7e9" + ref: "v1.3.2" + resolved-ref: "0bc4711331004c99d41a9de361467a23beb9216c" url: "https://github.com/ardriveapp/ardrive_http.git" source: git - version: "1.3.0" + version: "1.3.2" ardrive_io: dependency: "direct main" description: @@ -93,11 +93,11 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.16.0" - resolved-ref: bd66276b7875a8c47f7f630b2f045f99694fed70 + ref: "v1.17.0" + resolved-ref: "4c6dcf1c22fb888a41a16ebe5f7f4c39d51b2dd3" url: "https://github.com/ardriveapp/ardrive_ui.git" source: git - version: "1.16.0" + version: "1.17.0" ardrive_uploader: dependency: "direct main" description: @@ -368,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.2" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "979aafde2428c53947892c95eb244466c109c129b7eee9011f0a66caaca52267" + url: "https://pub.dev" + source: hosted + version: "0.7.0" connectivity_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 914a1e2d17..56a8d5cc6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Secure, permanent storage publish_to: 'none' -version: 2.27.0 +version: 2.28.0 environment: sdk: '>=3.0.2 <4.0.0' @@ -31,7 +31,7 @@ dependencies: ardrive_http: git: url: https://github.com/ardriveapp/ardrive_http.git - ref: v1.3.1 + ref: v1.3.2 ardrive_io: git: url: https://github.com/ardriveapp/ardrive_io.git @@ -39,7 +39,7 @@ dependencies: ardrive_ui: git: url: https://github.com/ardriveapp/ardrive_ui.git - ref: v1.16.0 + ref: v1.17.0 ardrive_utils: path: ./packages/ardrive_utils ardrive_uploader: @@ -129,6 +129,7 @@ dependencies: just_audio: ^0.9.34 loading_animation_widget: ^1.2.0+4 synchronized: ^3.1.0 + confetti: ^0.7.0 dependency_overrides: stripe_js: diff --git a/test/gift/blocs/redeem_gift_bloc_test.dart b/test/gift/blocs/redeem_gift_bloc_test.dart new file mode 100644 index 0000000000..8566af0e68 --- /dev/null +++ b/test/gift/blocs/redeem_gift_bloc_test.dart @@ -0,0 +1,85 @@ +import 'package:ardrive/gift/bloc/redeem_gift_bloc.dart'; +import 'package:ardrive/turbo/services/payment_service.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../blocs/turbo_balance_cubit_test.dart'; +import '../../test_utils/fake_user.dart'; +import '../../test_utils/utils.dart'; + +void main() { + late MockPaymentService mockPaymentService; + late MockArDriveAuth mockArDriveAuth; + late RedeemGiftBloc redeemGiftBloc; + + setUp(() { + mockPaymentService = MockPaymentService(); + mockArDriveAuth = MockArDriveAuth(); + redeemGiftBloc = RedeemGiftBloc( + paymentService: mockPaymentService, + auth: mockArDriveAuth, + ); + }); + + tearDown(() { + redeemGiftBloc.close(); + }); + + test('initial state is RedeemGiftInitial', () { + expect(redeemGiftBloc.state, RedeemGiftInitial()); + }); + + blocTest( + 'emits [RedeemGiftLoading, RedeemGiftSuccess] when RedeemGiftLoad succeeds', + build: () => redeemGiftBloc, + act: (bloc) { + when(() => mockArDriveAuth.currentUser).thenReturn(fakeUserJson); + when(() => mockPaymentService.redeemGift( + email: 'test@example.com', + giftCode: '123456', + destinationAddress: fakeUserJson.walletAddress, + )).thenAnswer((_) async { + return 100; + }); + + bloc.add( + const RedeemGiftLoad(email: 'test@example.com', giftCode: '123456')); + }, + expect: () => [RedeemGiftLoading(), RedeemGiftSuccess()], + ); + + blocTest( + 'emits [RedeemGiftLoading, RedeemGiftFailure] when RedeemGiftLoad fails', + build: () => redeemGiftBloc, + act: (bloc) { + when(() => mockArDriveAuth.currentUser).thenReturn(fakeUserJson); + when(() => mockPaymentService.redeemGift( + email: 'test@example.com', + giftCode: '123456', + destinationAddress: fakeUserJson.walletAddress, + )).thenThrow(Exception('Failed')); + + bloc.add( + const RedeemGiftLoad(email: 'test@example.com', giftCode: '123456')); + }, + expect: () => [RedeemGiftLoading(), RedeemGiftFailure()], + ); + + blocTest( + 'emits [RedeemGiftLoading, RedeemGiftAlreadyRedeemed] when RedeemGiftLoad fails with exception GiftAlreadyRedeemed', + build: () => redeemGiftBloc, + act: (bloc) { + when(() => mockArDriveAuth.currentUser).thenReturn(fakeUserJson); + when(() => mockPaymentService.redeemGift( + email: 'test@example.com', + giftCode: '123456', + destinationAddress: fakeUserJson.walletAddress, + )).thenThrow(GiftAlreadyRedeemed()); + + bloc.add( + const RedeemGiftLoad(email: 'test@example.com', giftCode: '123456')); + }, + expect: () => [RedeemGiftLoading(), RedeemGiftAlreadyRedeemed()], + ); +} diff --git a/test/services/payment_service_test.dart b/test/services/payment_service_test.dart index ba0c2d8738..6cc10c0e29 100644 --- a/test/services/payment_service_test.dart +++ b/test/services/payment_service_test.dart @@ -1,11 +1,10 @@ -// unit tests for PaymentService - import 'dart:io'; import 'package:ardrive/turbo/services/payment_service.dart'; import 'package:ardrive/turbo/topup/models/payment_model.dart'; import 'package:ardrive_http/ardrive_http.dart'; import 'package:arweave/arweave.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -19,22 +18,22 @@ const int byteSize = 1000; class ArDriveHTTPMock extends Mock implements ArDriveHTTP {} -void main() { +void main() async { late PaymentService paymentService; late ArDriveHTTPMock httpClient; late Wallet wallet; late String walletAddress; + httpClient = ArDriveHTTPMock(); + wallet = getTestWallet(); + walletAddress = await wallet.getAddress(); + group('PaymentService class', () { - setUp(() async { - httpClient = ArDriveHTTPMock(); + setUp(() { paymentService = PaymentService( turboPaymentUri: Uri.parse(fakeUrl), httpClient: httpClient, ); - wallet = getTestWallet(); - walletAddress = await wallet.getAddress(); - when(() => httpClient.get(url: '$fakeUrl/v1/price/bytes/$byteSize')) .thenAnswer( (_) async => ArDriveHTTPResponse( @@ -281,67 +280,151 @@ void main() { ); }); }); - }); - group('getPaymentIntent method', () { - test('should return a PaymentModel object', () async { - final result = await paymentService.getPaymentIntent( - wallet: wallet, - amount: amount, - currency: currency, - ); - expect(result, isA()); - }); + group('redeemGift method', () { + const fakeGiftCode = 'fakeGiftCode'; + const email = 'test@test.com'; - test('should throw an exception if the request fails', () async { when( () => httpClient.get( url: - '$fakeUrl/v1/top-up/payment-intent/$walletAddress/$currency/$amount?promoCode=$fakePromoCode', - headers: any(named: 'headers'), - ), - ).thenThrow( - ArDriveHTTPException( - statusCode: 404, - retryAttempts: 0, - exception: Exception('404'), + '$fakeUrl/v1/redeem?id=$fakeGiftCode&email=$email&destinationAddress=$walletAddress', + responseType: ResponseType.json, ), - ); - expect( - () async => await paymentService.getPaymentIntent( + ).thenAnswer((invocation) => Future.value( + ArDriveHTTPResponse( + statusCode: HttpStatus.ok, + data: { + 'message': 'Payment receipt redeemed for 1000 winc!', + 'userBalance': '1000', + 'userAddress': 'abcdefghijklmnopqrxtuvwxyz123456789ABCDEFGH', + 'userCreationDate': '2023-05-17T21:46:38.404Z' + }, + retryAttempts: 0, + ), + )); + + test('should return a new updated balance', () async { + final result = await paymentService.redeemGift( + email: email, + giftCode: fakeGiftCode, + destinationAddress: walletAddress, + ); + expect(result, 1000); + }); + + test('should throw an exception if the request fails', () async { + when( + () => httpClient.get( + url: + '$fakeUrl/v1/redeem?id=$fakeGiftCode&email=$email&destinationAddress=$walletAddress', + responseType: ResponseType.json, + ), + ).thenThrow( + ArDriveHTTPException( + statusCode: 404, + retryAttempts: 0, + exception: Exception('404'), + ), + ); + expect( + () async => await paymentService.redeemGift( + email: email, + giftCode: fakeGiftCode, + destinationAddress: walletAddress, + ), + throwsException, + ); + }); + + test( + 'should throw GiftAlreadyRedeemed if the response contains the already redeemed message', + () { + when( + () => httpClient.get( + url: + '$fakeUrl/v1/redeem?id=$fakeGiftCode&email=$email&destinationAddress=$walletAddress', + responseType: ResponseType.json, + ), + ).thenThrow( + ArDriveHTTPException( + statusCode: 400, + retryAttempts: 0, + exception: Exception('Gift has already been redeemed!'), + data: 'Gift has already been redeemed!', + ), + ); + expect( + () async => await paymentService.redeemGift( + email: email, + giftCode: fakeGiftCode, + destinationAddress: walletAddress, + ), + throwsA(isA()), + ); + }); + }); + + group('getPaymentIntent method', () { + test('should return a PaymentModel object', () async { + final result = await paymentService.getPaymentIntent( wallet: wallet, amount: amount, currency: currency, - promoCode: fakePromoCode, - ), - throwsException, - ); - }); - }); + ); + expect(result, isA()); + }); - group('getSupportedCountries method', () { - test('should return a list of countries', () async { - final result = await paymentService.getSupportedCountries(); - expect(result, isA>()); + test('should throw an exception if the request fails', () async { + when( + () => httpClient.get( + url: + '$fakeUrl/v1/top-up/payment-intent/$walletAddress/$currency/$amount?promoCode=$fakePromoCode', + headers: any(named: 'headers'), + ), + ).thenThrow( + ArDriveHTTPException( + statusCode: 404, + retryAttempts: 0, + exception: Exception('404'), + ), + ); + expect( + () async => await paymentService.getPaymentIntent( + wallet: wallet, + amount: amount, + currency: currency, + promoCode: fakePromoCode, + ), + throwsException, + ); + }); }); - test('should throw an exception if the request fails', () async { - when( - () => httpClient.get( - url: '$fakeUrl/v1/countries', - headers: any(named: 'headers'), - ), - ).thenThrow( - ArDriveHTTPException( - statusCode: 404, - retryAttempts: 0, - exception: Exception('404'), - ), - ); - expect( - () async => await paymentService.getSupportedCountries(), - throwsException, - ); + group('getSupportedCountries method', () { + test('should return a list of countries', () async { + final result = await paymentService.getSupportedCountries(); + expect(result, isA>()); + }); + + test('should throw an exception if the request fails', () async { + when( + () => httpClient.get( + url: '$fakeUrl/v1/countries', + headers: any(named: 'headers'), + ), + ).thenThrow( + ArDriveHTTPException( + statusCode: 404, + retryAttempts: 0, + exception: Exception('404'), + ), + ); + expect( + () async => await paymentService.getSupportedCountries(), + throwsException, + ); + }); }); }); } diff --git a/test/test_utils/fake_user.dart b/test/test_utils/fake_user.dart new file mode 100644 index 0000000000..a6f5c837fe --- /dev/null +++ b/test/test_utils/fake_user.dart @@ -0,0 +1,23 @@ +import 'package:ardrive/entities/profile_types.dart'; +import 'package:ardrive/user/user.dart'; +import 'package:cryptography/cryptography.dart'; + +import 'utils.dart'; + +final fakeUserJson = User( + password: 'password', + wallet: getTestWallet(), + walletAddress: 'address', + walletBalance: BigInt.zero, + cipherKey: SecretKey([1, 2, 3]), + profileType: ProfileType.json, +); + +final fakeUserArConnect = User( + password: 'password', + wallet: getTestWallet(), + walletAddress: 'address', + walletBalance: BigInt.zero, + cipherKey: SecretKey([1, 2, 3]), + profileType: ProfileType.arConnect, +); diff --git a/test/user/download_wallet/bloc/download_wallet_bloc_test.dart b/test/user/download_wallet/bloc/download_wallet_bloc_test.dart index b1d7af7600..39b9679845 100644 --- a/test/user/download_wallet/bloc/download_wallet_bloc_test.dart +++ b/test/user/download_wallet/bloc/download_wallet_bloc_test.dart @@ -1,13 +1,11 @@ import 'package:ardrive/authentication/ardrive_auth.dart'; -import 'package:ardrive/entities/profile_types.dart'; import 'package:ardrive/user/download_wallet/bloc/download_wallet_bloc.dart'; -import 'package:ardrive/user/user.dart'; import 'package:ardrive/utils/io_utils.dart'; import 'package:bloc_test/bloc_test.dart'; -import 'package:cryptography/cryptography.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../test_utils/fake_user.dart'; import '../../../test_utils/utils.dart'; class MockArDriveAuth extends Mock implements ArDriveAuth {} @@ -30,23 +28,14 @@ void main() { registerFallbackValue(getTestWallet()); }); - final userJson = User( - password: 'password', - wallet: getTestWallet(), - walletAddress: 'address', - walletBalance: BigInt.zero, - cipherKey: SecretKey([1, 2, 3]), - profileType: ProfileType.json, - ); - blocTest( 'emits [DownloadWalletLoading, DownloadWalletSuccess] when wallet download is successful', build: () { - final mockUser = userJson; + final userJson = fakeUserJson; when(() => mockArDriveAuth.unlockUser(password: any(named: 'password'))) .thenAnswer((_) async => userJson); - when(() => mockArDriveAuth.currentUser).thenReturn(mockUser); + when(() => mockArDriveAuth.currentUser).thenReturn(userJson); when(() => mockArDriveIOUtils.downloadWalletAsJsonFile( wallet: any(named: 'wallet'))).thenAnswer((_) async => {}); diff --git a/web/ardrive-http.js b/web/ardrive-http.js index aa56f14590..5069f24cf5 100644 --- a/web/ardrive-http.js +++ b/web/ardrive-http.js @@ -1,7 +1,7 @@ -"use strict";var retryStatusCodes=[408,429,440,460,499,500,502,503,504,520,521,522,523,524,525,527,598,599],isStatusCodeError=e=>e>=400&&e<=599,retryDelay=(e,r)=>r*Math.pow(1.5,e),logMessage=(e,r,t,s)=>`uri: ${e} - response: Http status error [${r}]: ${t} - retryAttempts: ${s}`,logger={retry:(e,r,t,s)=>{const a=logMessage(e,r,t,s);console.warn(`Network Request Retry -${a}`)},error:(e,r,t,s)=>{const a=logMessage(e,r,t,s);console.error(`Network Request Error -${a}`)}},requestType={json:{contentType:"application/json; charset=utf-8",getResponse:async e=>await e.json()},bytes:{contentType:"application/octet-stream",getResponse:async e=>await e.arrayBuffer()},text:{contentType:"plain/text; charset=utf-8",getResponse:async e=>await e.text()}},get=async([e,r,t,s,a,g=!1,i=0])=>{try{const u=new AbortController,n=await fetch(e,{method:"GET",headers:{...JSON.parse(r)},redirect:"follow",signal:u.signal}),o=n.status,c=n.statusText;if(s>0&&retryStatusCodes.includes(o))return g||logger.retry(e,o,c,i),await get([e,r,t,s-1,a,g,i+1]);if(isStatusCodeError(o))return{error:`Network Request Error -${logMessage(e,o,c,i)}`,retryAttempts:i,statusCode:o,statusMessage:c};const p=await requestType[`${t}`].getResponse(n);return{statusCode:o,statusMessage:c,data:p,retryAttempts:i}}catch(u){return console.error(u),console.error(u.stack),{error:`${u}`,retryAttempts:i}}},post=async([e,r,t,s,a,g,i,u=!1,n=0])=>{try{const o=new AbortController,c=await fetch(e,{method:"POST",headers:{...s!==requestType.text.contentType?{"Content-Type":s}:{},...JSON.parse(r)},redirect:"follow",body:t,signal:o.signal}),p=c.status,l=c.statusText;if(g>0&&retryStatusCodes.includes(p))return u||logger.retry(e,p,l,n),await post([e,r,t,s,a,g-1,i,u,n+1]);if(isStatusCodeError(p))return{error:`Network Request Error -${logMessage(e,p,l,n)}`,retryAttempts:n,statusCode:p,statusMessage:l};const y=await requestType[a].getResponse(c);return{statusCode:p,statusMessage:l,data:y,retryAttempts:n}}catch(o){return{error:`${o}`,retryAttempts:n}}};self.get=get,self.post=post; +"use strict";var retryStatusCodes=[408,429,440,460,499,500,502,503,504,520,521,522,523,524,525,527,598,599],isStatusCodeError=e=>e>=400&&e<=599,retryDelay=(e,r)=>r*Math.pow(1.5,e),logMessage=(e,r,s,t)=>`uri: ${e} + response: Http status error [${r}]: ${s} + retryAttempts: ${t}`,logger={retry:(e,r,s,t)=>{const a=logMessage(e,r,s,t);console.warn(`Network Request Retry +${a}`)},error:(e,r,s,t)=>{const a=logMessage(e,r,s,t);console.error(`Network Request Error +${a}`)}},requestType={json:{contentType:"application/json; charset=utf-8",getResponse:async e=>await e.json()},bytes:{contentType:"application/octet-stream",getResponse:async e=>await e.arrayBuffer()},text:{contentType:"plain/text; charset=utf-8",getResponse:async e=>await e.text()}},get=async([e,r,s,t,a,y=!1,i=0])=>{try{const n=new AbortController,u=setTimeout(()=>n.abort(),8e3),o=await fetch(e,{method:"GET",headers:{...JSON.parse(r)},redirect:"follow",signal:n.signal});clearTimeout(u);const c=o.status,p=o.statusText;if(t>0&&retryStatusCodes.includes(c))return y||logger.retry(e,c,p,i),await get([e,r,s,t-1,a,y,i+1]);if(isStatusCodeError(c)){const l=logMessage(e,c,p,i),b=await requestType.text.getResponse(o);return{error:`Network Request Error +${l}`,retryAttempts:i,statusCode:c,statusMessage:p,data:b}}const g=await requestType[`${s}`].getResponse(o);return{statusCode:c,statusMessage:p,data:g,retryAttempts:i}}catch(n){return console.error(n),console.error(n.stack),{error:`${n}`,retryAttempts:i}}},post=async([e,r,s,t,a,y,i,n=!1,u=0])=>{try{const o=new AbortController,c=setTimeout(()=>o.abort(),8e3),p=await fetch(e,{method:"POST",headers:{...t!==requestType.text.contentType?{"Content-Type":t}:{},...JSON.parse(r)},redirect:"follow",body:s,signal:o.signal});clearTimeout(c);const g=p.status,l=p.statusText;if(y>0&&retryStatusCodes.includes(g))return n||logger.retry(e,g,l,u),await post([e,r,s,t,a,y-1,i,n,u+1]);if(isStatusCodeError(g))return{error:`Network Request Error +${logMessage(e,g,l,u)}`,retryAttempts:u,statusCode:g,statusMessage:l};const b=await requestType[a].getResponse(p);return{statusCode:g,statusMessage:l,data:b,retryAttempts:u}}catch(o){return{error:`${o}`,retryAttempts:u}}};self.get=get,self.post=post;