diff --git a/.github/workflows/docker-android-build.yml b/.github/workflows/docker-android-build.yml index 3a8fbbe5f5..4fb20061c0 100644 --- a/.github/workflows/docker-android-build.yml +++ b/.github/workflows/docker-android-build.yml @@ -46,6 +46,13 @@ jobs: - name: Build Android image env: GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional feedback provider secrets to embed dart-defines + TRELLO_API_KEY: ${{ secrets.TRELLO_API_KEY }} + TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }} + TRELLO_BOARD_ID: ${{ secrets.TRELLO_BOARD_ID }} + TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} + FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} + FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} run: | chmod +x .docker/build.sh sh .docker/build.sh apk release diff --git a/.github/workflows/mobile-builds.yml b/.github/workflows/mobile-builds.yml index fc43673a1f..15b47dac6a 100644 --- a/.github/workflows/mobile-builds.yml +++ b/.github/workflows/mobile-builds.yml @@ -58,6 +58,15 @@ jobs: store-password: ${{ secrets.ANDROID_STORE_PASSWORD }} key-password: ${{ secrets.ANDROID_KEY_PASSWORD }} + # Flutter build with `--no-pub` flag fails on Android due to a + # known regression with the build system in 3.32.5. + # https://github.com/flutter/flutter/issues/169336 + # Run config-only before the full double-build to ensure success. + - name: Temporary workaround for Android build issue + if: ${{ matrix.platform == 'Android' }} + run: | + flutter build apk --config-only + - name: Fetch packages and generate assets uses: ./.github/actions/generate-assets with: @@ -68,19 +77,7 @@ jobs: TRELLO_LIST_ID: ${{ secrets.TRELLO_LIST_ID }} FEEDBACK_API_KEY: ${{ secrets.FEEDBACK_API_KEY }} FEEDBACK_PRODUCTION_URL: ${{ secrets.FEEDBACK_PRODUCTION_URL }} - - # Flutter build with `--no-pub` flag fails on Android due to a - # known regression with the build system in 3.32.5. - # https://github.com/flutter/flutter/issues/169336 - - name: Temporary workaround for Android build issue - if: ${{ matrix.platform == 'Android' }} - run: | - flutter build apk --config-only - - - name: Build for ${{ matrix.platform }} - env: - GITHUB_API_PUBLIC_READONLY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ${{ matrix.build_command }} + BUILD_COMMAND: ${{ matrix.build_command }} - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/analysis_options.yaml b/analysis_options.yaml index d20d1b59cd..f42ea70c44 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,6 +11,11 @@ include: - package:bloc_lint/recommended.yaml - package:flutter_lints/flutter.yaml +analyzer: + exclude: + - "**/*.freezed.dart" + - "**/*.g.dart" + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/assets/translations/en.json b/assets/translations/en.json index 9fa91a467e..02f1986560 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -303,9 +303,28 @@ "feedbackFormDescription": "Our mission to improve the wallet never stops, and your feedback is highly appreciated!", "feedbackFormThanksTitle": "Thank you for your feedback", "feedbackFormThanksDescription": "We will send a response to your email address as soon as possible", + "feedbackFormKindQuestion": "What kind of feedback do you want to give?", + "feedbackFormDescribeTitle": "Your feedback", + "feedbackFormContactRequired": "How can we contact you?", + "feedbackFormContactOptional": "How can we contact you? (Optional)", + "feedbackFormMessageHint": "Enter your feedback here...", + "feedbackFormBugReport": "Bug Report", + "feedbackFormFeatureRequest": "Feature Request", + "feedbackFormSupportRequest": "Support Request", + "feedbackFormOther": "Other", + "feedbackFormDiscord": "Discord", + "feedbackFormMatrix": "Matrix", + "feedbackFormTelegram": "Telegram", + "feedbackFormSelectContactMethod": "Select contact method", + "feedbackFormDiscordHint": "Discord username (e.g., username123)", + "feedbackFormMatrixHint": "Matrix ID (e.g., @user:matrix.org)", + "feedbackFormTelegramHint": "Telegram username (e.g., @username)", + "feedbackFormEmailHint": "Your email address", + "feedbackFormContactHint": "Enter your contact details", + "feedbackFormContactOptOut": "I don't want to share contact info", "email": "Email", "emailValidatorError": "Please enter a valid email address", - "contactRequiredError": "Contact details are required for support requests", + "contactRequiredError": "Please provide both contact method and details", "contactDetailsMaxLengthError": "Contact details must be {} characters or less", "discordUsernameValidatorError": "Please enter a valid Discord username (2-32 characters, letters, numbers, dots, underscores)", "telegramUsernameValidatorError": "Please enter a valid Telegram username (5-32 characters, letters, numbers, underscores)", @@ -491,7 +510,6 @@ "feedback": "Feedback", "feedbackViewTitle": "Send us your feedback", "feedbackPageDescription": "Help us improve by sharing your suggestions, reporting bugs, or giving general feedback.", - "sendFeedbackButton": "Share your feedback", "feedbackThankyou": "Thank you for your feedback!", "feedbackError": "Failed to submit feedback", "selectAToken": "Select a token", diff --git a/lib/bloc/feedback_form/feedback_form_bloc.dart b/lib/bloc/feedback_form/feedback_form_bloc.dart index 57f34f572c..01fdbd360b 100644 --- a/lib/bloc/feedback_form/feedback_form_bloc.dart +++ b/lib/bloc/feedback_form/feedback_form_bloc.dart @@ -15,6 +15,7 @@ class FeedbackFormBloc extends Bloc { on(_onMessageChanged); on(_onContactMethodChanged); on(_onContactDetailsChanged); + on(_onContactOptOutChanged); on(_onSubmitted); } @@ -29,10 +30,12 @@ class FeedbackFormBloc extends Bloc { event.type, state.contactMethod, ); - emit(state.copyWith( - feedbackType: event.type, - contactDetailsError: contactError, - )); + emit( + state.copyWith( + feedbackType: event.type, + contactDetailsError: contactError, + ), + ); } void _onMessageChanged( @@ -40,10 +43,12 @@ class FeedbackFormBloc extends Bloc { Emitter emit, ) { final text = _sanitizeInput(event.message); - emit(state.copyWith( - feedbackText: text, - feedbackTextError: _validateFeedbackText(text), - )); + emit( + state.copyWith( + feedbackText: text, + feedbackTextError: _validateFeedbackText(text), + ), + ); } void _onContactMethodChanged( @@ -55,8 +60,9 @@ class FeedbackFormBloc extends Bloc { state.feedbackType, event.method, ); - emit(state.copyWith( - contactMethod: event.method, contactDetailsError: error)); + emit( + state.copyWith(contactMethod: event.method, contactDetailsError: error), + ); } void _onContactDetailsChanged( @@ -72,6 +78,33 @@ class FeedbackFormBloc extends Bloc { emit(state.copyWith(contactDetails: details, contactDetailsError: error)); } + void _onContactOptOutChanged( + FeedbackFormContactOptOutChanged event, + Emitter emit, + ) { + if (event.optOut && !state.isSupportType) { + emit( + state.copyWith( + contactOptOut: true, + contactMethod: null, + contactDetails: '', + contactDetailsError: null, + ), + ); + return; + } + + final error = _validateContactDetails( + state.contactDetails, + state.feedbackType, + state.contactMethod, + contactOptOut: event.optOut, + ); + emit( + state.copyWith(contactOptOut: event.optOut, contactDetailsError: error), + ); + } + Future _onSubmitted( FeedbackFormSubmitted event, Emitter emit, @@ -86,10 +119,12 @@ class FeedbackFormBloc extends Bloc { if (state.feedbackType == null || feedbackErr != null || contactErr != null) { - emit(state.copyWith( - feedbackTextError: feedbackErr, - contactDetailsError: contactErr, - )); + emit( + state.copyWith( + feedbackTextError: feedbackErr, + contactDetailsError: contactErr, + ), + ); return; } @@ -99,17 +134,16 @@ class FeedbackFormBloc extends Bloc { feedbackType: state.feedbackType, feedbackText: state.feedbackText, contactMethod: state.contactMethod, - contactDetails: - state.contactDetails.isNotEmpty ? state.contactDetails : null, - ); - await _onSubmit( - data.toFormattedDescription(), - extras: data.toMap(), + contactDetails: state.contactDetails.isNotEmpty + ? state.contactDetails + : null, ); + await _onSubmit(data.toFormattedDescription(), extras: data.toMap()); emit(state.copyWith(status: FeedbackFormStatus.success)); } catch (e) { - emit(state.copyWith( - status: FeedbackFormStatus.failure, errorMessage: '$e')); + emit( + state.copyWith(status: FeedbackFormStatus.failure, errorMessage: '$e'), + ); } } @@ -129,19 +163,27 @@ class FeedbackFormBloc extends Bloc { String? _validateContactDetails( String value, FeedbackType? type, - ContactMethod? method, - ) { + ContactMethod? method, { + bool? contactOptOut, + }) { final trimmed = value.trim(); final hasMethod = method != null; final hasDetails = trimmed.isNotEmpty; + final optedOut = contactOptOut ?? state.contactOptOut; if (type == FeedbackType.support || type == FeedbackType.missingCoins) { if (!hasMethod || !hasDetails) { return LocaleKeys.contactRequiredError.tr(); } } else { - if ((hasMethod && !hasDetails) || (!hasMethod && hasDetails)) { - return LocaleKeys.contactRequiredError.tr(); + if (!optedOut) { + if (!hasMethod || !hasDetails) { + return LocaleKeys.contactRequiredError.tr(); + } + } else { + if ((hasMethod && !hasDetails) || (!hasMethod && hasDetails)) { + return LocaleKeys.contactRequiredError.tr(); + } } } @@ -187,9 +229,12 @@ class FeedbackFormBloc extends Bloc { .trim() .replaceAll(RegExp(r'<[^>]*>'), '') .replaceAll( - RegExp(r')<[^<]*)*<\/script>', - caseSensitive: false), - '') + RegExp( + r')<[^<]*)*<\/script>', + caseSensitive: false, + ), + '', + ) .replaceAll(RegExp(r'javascript:', caseSensitive: false), '') .replaceAll(RegExp(r'data:[^,]*script[^,]*,', caseSensitive: false), '') .replaceAll(RegExp(r'\n{3,}'), '\n\n'); diff --git a/lib/bloc/feedback_form/feedback_form_event.dart b/lib/bloc/feedback_form/feedback_form_event.dart index 1cb1ec640a..e8d5bd6186 100644 --- a/lib/bloc/feedback_form/feedback_form_event.dart +++ b/lib/bloc/feedback_form/feedback_form_event.dart @@ -43,6 +43,15 @@ class FeedbackFormContactDetailsChanged extends FeedbackFormEvent { List get props => [details]; } +class FeedbackFormContactOptOutChanged extends FeedbackFormEvent { + const FeedbackFormContactOptOutChanged(this.optOut); + + final bool optOut; + + @override + List get props => [optOut]; +} + class FeedbackFormSubmitted extends FeedbackFormEvent { const FeedbackFormSubmitted(); } diff --git a/lib/bloc/feedback_form/feedback_form_state.dart b/lib/bloc/feedback_form/feedback_form_state.dart index b7a63d3c2c..0b0b65817b 100644 --- a/lib/bloc/feedback_form/feedback_form_state.dart +++ b/lib/bloc/feedback_form/feedback_form_state.dart @@ -10,6 +10,7 @@ class FeedbackFormState extends Equatable { this.contactMethod, this.contactDetails = '', this.contactDetailsError, + this.contactOptOut = false, this.status = FeedbackFormStatus.initial, this.errorMessage, }); @@ -20,6 +21,7 @@ class FeedbackFormState extends Equatable { final ContactMethod? contactMethod; final String contactDetails; final String? contactDetailsError; + final bool contactOptOut; final FeedbackFormStatus status; final String? errorMessage; @@ -29,6 +31,19 @@ class FeedbackFormState extends Equatable { contactDetailsError == null && feedbackText.trim().isNotEmpty; + bool get isSubmitting => status == FeedbackFormStatus.submitting; + + bool get isSupportType => + feedbackType == FeedbackType.support || + feedbackType == FeedbackType.missingCoins; + + bool get isContactOptOutVisible => !isSupportType; + + bool get isContactRequired => isSupportType || !contactOptOut; + + bool get isContactRowDisabled => + isSubmitting || (isContactOptOutVisible && contactOptOut); + FeedbackFormState copyWith({ FeedbackType? feedbackType, String? feedbackText, @@ -36,6 +51,7 @@ class FeedbackFormState extends Equatable { ContactMethod? contactMethod, String? contactDetails, String? contactDetailsError, + bool? contactOptOut, FeedbackFormStatus? status, String? errorMessage, }) { @@ -46,6 +62,7 @@ class FeedbackFormState extends Equatable { contactMethod: contactMethod ?? this.contactMethod, contactDetails: contactDetails ?? this.contactDetails, contactDetailsError: contactDetailsError, + contactOptOut: contactOptOut ?? this.contactOptOut, status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, ); @@ -53,13 +70,14 @@ class FeedbackFormState extends Equatable { @override List get props => [ - feedbackType, - feedbackText, - feedbackTextError, - contactMethod, - contactDetails, - contactDetailsError, - status, - errorMessage, - ]; + feedbackType, + feedbackText, + feedbackTextError, + contactMethod, + contactDetails, + contactDetailsError, + contactOptOut, + status, + errorMessage, + ]; } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index a18ede914c..5dc16c40b1 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -2,7 +2,7 @@ // ignore_for_file: constant_identifier_names -abstract class LocaleKeys { +abstract class LocaleKeys { static const plsActivateKmd = 'plsActivateKmd'; static const rewardClaiming = 'rewardClaiming'; static const noKmdAddress = 'noKmdAddress'; @@ -106,27 +106,34 @@ abstract class LocaleKeys { static const seedPhrase = 'seedPhrase'; static const assetNumber = 'assetNumber'; static const clipBoard = 'clipBoard'; - static const walletsManagerCreateWalletButton = 'walletsManagerCreateWalletButton'; - static const walletsManagerImportWalletButton = 'walletsManagerImportWalletButton'; - static const walletsManagerStepBuilderCreationWalletError = 'walletsManagerStepBuilderCreationWalletError'; + static const walletsManagerCreateWalletButton = + 'walletsManagerCreateWalletButton'; + static const walletsManagerImportWalletButton = + 'walletsManagerImportWalletButton'; + static const walletsManagerStepBuilderCreationWalletError = + 'walletsManagerStepBuilderCreationWalletError'; static const walletCreationTitle = 'walletCreationTitle'; static const walletImportTitle = 'walletImportTitle'; static const walletImportByFileTitle = 'walletImportByFileTitle'; static const invalidWalletNameError = 'invalidWalletNameError'; static const invalidWalletFileNameError = 'invalidWalletFileNameError'; - static const walletImportCreatePasswordTitle = 'walletImportCreatePasswordTitle'; + static const walletImportCreatePasswordTitle = + 'walletImportCreatePasswordTitle'; static const walletImportByFileDescription = 'walletImportByFileDescription'; static const walletLogInTitle = 'walletLogInTitle'; static const walletCreationNameHint = 'walletCreationNameHint'; static const walletCreationPasswordHint = 'walletCreationPasswordHint'; - static const walletCreationConfirmPasswordHint = 'walletCreationConfirmPasswordHint'; + static const walletCreationConfirmPasswordHint = + 'walletCreationConfirmPasswordHint'; static const walletCreationConfirmPassword = 'walletCreationConfirmPassword'; static const walletCreationUploadFile = 'walletCreationUploadFile'; static const walletCreationEmptySeedError = 'walletCreationEmptySeedError'; static const walletCreationExistNameError = 'walletCreationExistNameError'; static const walletCreationNameLengthError = 'walletCreationNameLengthError'; - static const walletCreationFormatPasswordError = 'walletCreationFormatPasswordError'; - static const walletCreationConfirmPasswordError = 'walletCreationConfirmPasswordError'; + static const walletCreationFormatPasswordError = + 'walletCreationFormatPasswordError'; + static const walletCreationConfirmPasswordError = + 'walletCreationConfirmPasswordError'; static const incorrectPassword = 'incorrectPassword'; static const oneClickLogin = 'oneClickLogin'; static const quickLoginTooltip = 'quickLoginTooltip'; @@ -135,15 +142,19 @@ abstract class LocaleKeys { static const passphraseCheckingTitle = 'passphraseCheckingTitle'; static const passphraseCheckingDescription = 'passphraseCheckingDescription'; static const passphraseCheckingEnterWord = 'passphraseCheckingEnterWord'; - static const passphraseCheckingEnterWordHint = 'passphraseCheckingEnterWordHint'; + static const passphraseCheckingEnterWordHint = + 'passphraseCheckingEnterWordHint'; static const back = 'back'; static const settingsMenuGeneral = 'settingsMenuGeneral'; static const settingsMenuLanguage = 'settingsMenuLanguage'; static const settingsMenuSecurity = 'settingsMenuSecurity'; static const settingsMenuAbout = 'settingsMenuAbout'; - static const seedPhraseSettingControlsViewSeed = 'seedPhraseSettingControlsViewSeed'; - static const seedPhraseSettingControlsDownloadSeed = 'seedPhraseSettingControlsDownloadSeed'; - static const debugSettingsResetActivatedCoins = 'debugSettingsResetActivatedCoins'; + static const seedPhraseSettingControlsViewSeed = + 'seedPhraseSettingControlsViewSeed'; + static const seedPhraseSettingControlsDownloadSeed = + 'seedPhraseSettingControlsDownloadSeed'; + static const debugSettingsResetActivatedCoins = + 'debugSettingsResetActivatedCoins'; static const debugSettingsDownloadButton = 'debugSettingsDownloadButton'; static const or = 'or'; static const passwordTitle = 'passwordTitle'; @@ -153,16 +164,19 @@ abstract class LocaleKeys { static const changePasswordSpan1 = 'changePasswordSpan1'; static const updatePassword = 'updatePassword'; static const passwordHasChanged = 'passwordHasChanged'; - static const confirmationForShowingSeedPhraseTitle = 'confirmationForShowingSeedPhraseTitle'; + static const confirmationForShowingSeedPhraseTitle = + 'confirmationForShowingSeedPhraseTitle'; static const saveAndRemember = 'saveAndRemember'; static const seedPhraseShowingTitle = 'seedPhraseShowingTitle'; static const seedPhraseShowingWarning = 'seedPhraseShowingWarning'; static const seedPhraseShowingShowPhrase = 'seedPhraseShowingShowPhrase'; static const seedPhraseShowingCopySeed = 'seedPhraseShowingCopySeed'; - static const seedPhraseShowingSavedPhraseButton = 'seedPhraseShowingSavedPhraseButton'; + static const seedPhraseShowingSavedPhraseButton = + 'seedPhraseShowingSavedPhraseButton'; static const seedAccessSpan1 = 'seedAccessSpan1'; static const backupSeedNotificationTitle = 'backupSeedNotificationTitle'; - static const backupSeedNotificationDescription = 'backupSeedNotificationDescription'; + static const backupSeedNotificationDescription = + 'backupSeedNotificationDescription'; static const backupSeedNotificationButton = 'backupSeedNotificationButton'; static const swapConfirmationTitle = 'swapConfirmationTitle'; static const swapConfirmationYouReceive = 'swapConfirmationYouReceive'; @@ -170,41 +184,54 @@ abstract class LocaleKeys { static const tradingDetailsTitleFailed = 'tradingDetailsTitleFailed'; static const tradingDetailsTitleCompleted = 'tradingDetailsTitleCompleted'; static const tradingDetailsTitleInProgress = 'tradingDetailsTitleInProgress'; - static const tradingDetailsTitleOrderMatching = 'tradingDetailsTitleOrderMatching'; + static const tradingDetailsTitleOrderMatching = + 'tradingDetailsTitleOrderMatching'; static const tradingDetailsTotalSpentTime = 'tradingDetailsTotalSpentTime'; - static const tradingDetailsTotalSpentTimeWithHours = 'tradingDetailsTotalSpentTimeWithHours'; + static const tradingDetailsTotalSpentTimeWithHours = + 'tradingDetailsTotalSpentTimeWithHours'; static const swapRecoverButtonTitle = 'swapRecoverButtonTitle'; static const swapRecoverButtonText = 'swapRecoverButtonText'; static const swapRecoverButtonErrorMessage = 'swapRecoverButtonErrorMessage'; - static const swapRecoverButtonSuccessMessage = 'swapRecoverButtonSuccessMessage'; + static const swapRecoverButtonSuccessMessage = + 'swapRecoverButtonSuccessMessage'; static const swapProgressStatusFailed = 'swapProgressStatusFailed'; static const swapDetailsStepStatusFailed = 'swapDetailsStepStatusFailed'; static const disclaimerAcceptEulaCheckbox = 'disclaimerAcceptEulaCheckbox'; - static const disclaimerAcceptTermsAndConditionsCheckbox = 'disclaimerAcceptTermsAndConditionsCheckbox'; + static const disclaimerAcceptTermsAndConditionsCheckbox = + 'disclaimerAcceptTermsAndConditionsCheckbox'; static const disclaimerAcceptDescription = 'disclaimerAcceptDescription'; - static const swapDetailsStepStatusInProcess = 'swapDetailsStepStatusInProcess'; - static const swapDetailsStepStatusTimeSpent = 'swapDetailsStepStatusTimeSpent'; + static const swapDetailsStepStatusInProcess = + 'swapDetailsStepStatusInProcess'; + static const swapDetailsStepStatusTimeSpent = + 'swapDetailsStepStatusTimeSpent'; static const milliseconds = 'milliseconds'; static const seconds = 'seconds'; static const minutes = 'minutes'; static const hours = 'hours'; - static const coinAddressDetailsNotificationTitle = 'coinAddressDetailsNotificationTitle'; - static const coinAddressDetailsNotificationDescription = 'coinAddressDetailsNotificationDescription'; + static const coinAddressDetailsNotificationTitle = + 'coinAddressDetailsNotificationTitle'; + static const coinAddressDetailsNotificationDescription = + 'coinAddressDetailsNotificationDescription'; static const swapFeeDetailsPaidFromBalance = 'swapFeeDetailsPaidFromBalance'; static const swapFeeDetailsSendCoinTxFee = 'swapFeeDetailsSendCoinTxFee'; - static const swapFeeDetailsReceiveCoinTxFee = 'swapFeeDetailsReceiveCoinTxFee'; + static const swapFeeDetailsReceiveCoinTxFee = + 'swapFeeDetailsReceiveCoinTxFee'; static const swapFeeDetailsTradingFee = 'swapFeeDetailsTradingFee'; - static const swapFeeDetailsSendTradingFeeTxFee = 'swapFeeDetailsSendTradingFeeTxFee'; + static const swapFeeDetailsSendTradingFeeTxFee = + 'swapFeeDetailsSendTradingFeeTxFee'; static const swapFeeDetailsNone = 'swapFeeDetailsNone'; - static const swapFeeDetailsPaidFromReceivedVolume = 'swapFeeDetailsPaidFromReceivedVolume'; + static const swapFeeDetailsPaidFromReceivedVolume = + 'swapFeeDetailsPaidFromReceivedVolume'; static const logoutPopupTitle = 'logoutPopupTitle'; - static const logoutPopupDescriptionWalletOnly = 'logoutPopupDescriptionWalletOnly'; + static const logoutPopupDescriptionWalletOnly = + 'logoutPopupDescriptionWalletOnly'; static const logoutPopupDescription = 'logoutPopupDescription'; static const transactionDetailsTitle = 'transactionDetailsTitle'; static const customSeedWarningText = 'customSeedWarningText'; static const customSeedIUnderstand = 'customSeedIUnderstand'; static const walletCreationBip39SeedError = 'walletCreationBip39SeedError'; - static const walletCreationHdBip39SeedError = 'walletCreationHdBip39SeedError'; + static const walletCreationHdBip39SeedError = + 'walletCreationHdBip39SeedError'; static const walletPageNoSuchAsset = 'walletPageNoSuchAsset'; static const swap = 'swap'; static const dexAddress = 'dexAddress'; @@ -280,7 +307,8 @@ abstract class LocaleKeys { static const sellCryptoDescription = 'sellCryptoDescription'; static const buy = 'buy'; static const changingWalletPassword = 'changingWalletPassword'; - static const changingWalletPasswordDescription = 'changingWalletPasswordDescription'; + static const changingWalletPasswordDescription = + 'changingWalletPasswordDescription'; static const dark = 'dark'; static const darkMode = 'darkMode'; static const light = 'light'; @@ -300,12 +328,33 @@ abstract class LocaleKeys { static const feedbackFormDescription = 'feedbackFormDescription'; static const feedbackFormThanksTitle = 'feedbackFormThanksTitle'; static const feedbackFormThanksDescription = 'feedbackFormThanksDescription'; + static const feedbackFormKindQuestion = 'feedbackFormKindQuestion'; + static const feedbackFormDescribeTitle = 'feedbackFormDescribeTitle'; + static const feedbackFormContactRequired = 'feedbackFormContactRequired'; + static const feedbackFormContactOptional = 'feedbackFormContactOptional'; + static const feedbackFormMessageHint = 'feedbackFormMessageHint'; + static const feedbackFormBugReport = 'feedbackFormBugReport'; + static const feedbackFormFeatureRequest = 'feedbackFormFeatureRequest'; + static const feedbackFormSupportRequest = 'feedbackFormSupportRequest'; + static const feedbackFormOther = 'feedbackFormOther'; + static const feedbackFormDiscord = 'feedbackFormDiscord'; + static const feedbackFormMatrix = 'feedbackFormMatrix'; + static const feedbackFormTelegram = 'feedbackFormTelegram'; + static const feedbackFormSelectContactMethod = + 'feedbackFormSelectContactMethod'; + static const feedbackFormDiscordHint = 'feedbackFormDiscordHint'; + static const feedbackFormMatrixHint = 'feedbackFormMatrixHint'; + static const feedbackFormTelegramHint = 'feedbackFormTelegramHint'; + static const feedbackFormEmailHint = 'feedbackFormEmailHint'; + static const feedbackFormContactHint = 'feedbackFormContactHint'; + static const feedbackFormContactOptOut = 'feedbackFormContactOptOut'; static const email = 'email'; static const emailValidatorError = 'emailValidatorError'; static const contactRequiredError = 'contactRequiredError'; static const contactDetailsMaxLengthError = 'contactDetailsMaxLengthError'; static const discordUsernameValidatorError = 'discordUsernameValidatorError'; - static const telegramUsernameValidatorError = 'telegramUsernameValidatorError'; + static const telegramUsernameValidatorError = + 'telegramUsernameValidatorError'; static const matrixIdValidatorError = 'matrixIdValidatorError'; static const myCoinsMissing = 'myCoinsMissing'; static const myCoinsMissingReassurance = 'myCoinsMissingReassurance'; @@ -315,7 +364,8 @@ abstract class LocaleKeys { static const myCoinsMissingHelp = 'myCoinsMissingHelp'; static const myCoinsMissingSignIn = 'myCoinsMissingSignIn'; static const feedbackValidatorEmptyError = 'feedbackValidatorEmptyError'; - static const feedbackValidatorMaxLengthError = 'feedbackValidatorMaxLengthError'; + static const feedbackValidatorMaxLengthError = + 'feedbackValidatorMaxLengthError'; static const yourFeedback = 'yourFeedback'; static const sendFeedback = 'sendFeedback'; static const sendFeedbackError = 'sendFeedbackError'; @@ -355,7 +405,8 @@ abstract class LocaleKeys { static const trezorSelectTitle = 'trezorSelectTitle'; static const trezorSelectSubTitle = 'trezorSelectSubTitle'; static const trezorBrowserUnsupported = 'trezorBrowserUnsupported'; - static const trezorTransactionInProgressMessage = 'trezorTransactionInProgressMessage'; + static const trezorTransactionInProgressMessage = + 'trezorTransactionInProgressMessage'; static const mixedCaseError = 'mixedCaseError'; static const addressConvertedToMixedCase = 'addressConvertedToMixedCase'; static const invalidAddressChecksum = 'invalidAddressChecksum'; @@ -365,7 +416,8 @@ abstract class LocaleKeys { static const noSenderAddress = 'noSenderAddress'; static const confirmOnTrezor = 'confirmOnTrezor'; static const alphaVersionWarningTitle = 'alphaVersionWarningTitle'; - static const alphaVersionWarningDescription = 'alphaVersionWarningDescription'; + static const alphaVersionWarningDescription = + 'alphaVersionWarningDescription'; static const sendToAnalytics = 'sendToAnalytics'; static const backToWallet = 'backToWallet'; static const backToDex = 'backToDex'; @@ -390,12 +442,14 @@ abstract class LocaleKeys { static const currentPassword = 'currentPassword'; static const walletNotFound = 'walletNotFound'; static const passwordIsEmpty = 'passwordIsEmpty'; - static const passwordContainsTheWordPassword = 'passwordContainsTheWordPassword'; + static const passwordContainsTheWordPassword = + 'passwordContainsTheWordPassword'; static const passwordTooShort = 'passwordTooShort'; static const passwordMissingDigit = 'passwordMissingDigit'; static const passwordMissingLowercase = 'passwordMissingLowercase'; static const passwordMissingUppercase = 'passwordMissingUppercase'; - static const passwordMissingSpecialCharacter = 'passwordMissingSpecialCharacter'; + static const passwordMissingSpecialCharacter = + 'passwordMissingSpecialCharacter'; static const passwordConsecutiveCharacters = 'passwordConsecutiveCharacters'; static const passwordSecurity = 'passwordSecurity'; static const allowWeakPassword = 'allowWeakPassword'; @@ -424,13 +478,16 @@ abstract class LocaleKeys { static const bridgeMaxSendAmountError = 'bridgeMaxSendAmountError'; static const bridgeMinOrderAmountError = 'bridgeMinOrderAmountError'; static const bridgeMaxOrderAmountError = 'bridgeMaxOrderAmountError'; - static const bridgeInsufficientBalanceError = 'bridgeInsufficientBalanceError'; + static const bridgeInsufficientBalanceError = + 'bridgeInsufficientBalanceError'; static const lowTradeVolumeError = 'lowTradeVolumeError'; static const bridgeSelectReceiveCoinError = 'bridgeSelectReceiveCoinError'; static const withdrawNoParentCoinError = 'withdrawNoParentCoinError'; static const withdrawTopUpBalanceError = 'withdrawTopUpBalanceError'; - static const withdrawNotEnoughBalanceForGasError = 'withdrawNotEnoughBalanceForGasError'; - static const withdrawNotSufficientBalanceError = 'withdrawNotSufficientBalanceError'; + static const withdrawNotEnoughBalanceForGasError = + 'withdrawNotEnoughBalanceForGasError'; + static const withdrawNotSufficientBalanceError = + 'withdrawNotSufficientBalanceError'; static const withdrawZeroBalanceError = 'withdrawZeroBalanceError'; static const withdrawAmountTooLowError = 'withdrawAmountTooLowError'; static const withdrawNoSuchCoinError = 'withdrawNoSuchCoinError'; @@ -488,7 +545,6 @@ abstract class LocaleKeys { static const feedback = 'feedback'; static const feedbackViewTitle = 'feedbackViewTitle'; static const feedbackPageDescription = 'feedbackPageDescription'; - static const sendFeedbackButton = 'sendFeedbackButton'; static const feedbackThankyou = 'feedbackThankyou'; static const feedbackError = 'feedbackError'; static const selectAToken = 'selectAToken'; @@ -503,8 +559,10 @@ abstract class LocaleKeys { static const availableForSwaps = 'availableForSwaps'; static const swapNow = 'swapNow'; static const passphrase = 'passphrase'; - static const enterPassphraseHiddenWalletTitle = 'enterPassphraseHiddenWalletTitle'; - static const enterPassphraseHiddenWalletDescription = 'enterPassphraseHiddenWalletDescription'; + static const enterPassphraseHiddenWalletTitle = + 'enterPassphraseHiddenWalletTitle'; + static const enterPassphraseHiddenWalletDescription = + 'enterPassphraseHiddenWalletDescription'; static const skip = 'skip'; static const activateToSeeFunds = 'activateToSeeFunds'; static const useCustomSeedOrWif = 'useCustomSeedOrWif'; @@ -531,13 +589,15 @@ abstract class LocaleKeys { static const downloadAllKeys = 'downloadAllKeys'; static const shareAllKeys = 'shareAllKeys'; static const confirmPrivateKeyBackup = 'confirmPrivateKeyBackup'; - static const confirmPrivateKeyBackupDescription = 'confirmPrivateKeyBackupDescription'; + static const confirmPrivateKeyBackupDescription = + 'confirmPrivateKeyBackupDescription'; static const importantSecurityNotice = 'importantSecurityNotice'; static const privateKeySecurityWarning = 'privateKeySecurityWarning'; static const privateKeyBackupConfirmation = 'privateKeyBackupConfirmation'; static const confirmBackupComplete = 'confirmBackupComplete'; static const privateKeyExportSuccessTitle = 'privateKeyExportSuccessTitle'; - static const privateKeyExportSuccessDescription = 'privateKeyExportSuccessDescription'; + static const privateKeyExportSuccessDescription = + 'privateKeyExportSuccessDescription'; static const iHaveSavedMyPrivateKeys = 'iHaveSavedMyPrivateKeys'; static const copyWarning = 'copyWarning'; static const seedConfirmTitle = 'seedConfirmTitle'; @@ -585,8 +645,10 @@ abstract class LocaleKeys { static const collectibles = 'collectibles'; static const sendingProcess = 'sendingProcess'; static const ercStandardDisclaimer = 'ercStandardDisclaimer'; - static const nftReceiveNonSwapAddressWarning = 'nftReceiveNonSwapAddressWarning'; - static const nftReceiveNonSwapWalletDetails = 'nftReceiveNonSwapWalletDetails'; + static const nftReceiveNonSwapAddressWarning = + 'nftReceiveNonSwapAddressWarning'; + static const nftReceiveNonSwapWalletDetails = + 'nftReceiveNonSwapWalletDetails'; static const nftMainLoggedOut = 'nftMainLoggedOut'; static const confirmLogoutOnAnotherTab = 'confirmLogoutOnAnotherTab'; static const refreshList = 'refreshList'; @@ -600,8 +662,10 @@ abstract class LocaleKeys { static const noWalletsAvailable = 'noWalletsAvailable'; static const selectWalletToReset = 'selectWalletToReset'; static const qrScannerTitle = 'qrScannerTitle'; - static const qrScannerErrorControllerUninitialized = 'qrScannerErrorControllerUninitialized'; - static const qrScannerErrorPermissionDenied = 'qrScannerErrorPermissionDenied'; + static const qrScannerErrorControllerUninitialized = + 'qrScannerErrorControllerUninitialized'; + static const qrScannerErrorPermissionDenied = + 'qrScannerErrorPermissionDenied'; static const qrScannerErrorGenericError = 'qrScannerErrorGenericError'; static const qrScannerErrorTitle = 'qrScannerErrorTitle'; static const spend = 'spend'; @@ -646,7 +710,8 @@ abstract class LocaleKeys { static const fiatPaymentInProgressMessage = 'fiatPaymentInProgressMessage'; static const pleaseWait = 'pleaseWait'; static const bitrefillPaymentSuccessfull = 'bitrefillPaymentSuccessfull'; - static const bitrefillPaymentSuccessfullInstruction = 'bitrefillPaymentSuccessfullInstruction'; + static const bitrefillPaymentSuccessfullInstruction = + 'bitrefillPaymentSuccessfullInstruction'; static const tradingBot = 'tradingBot'; static const margin = 'margin'; static const updateInterval = 'updateInterval'; @@ -734,5 +799,4 @@ abstract class LocaleKeys { static const fetchingPrivateKeysMessage = 'fetchingPrivateKeysMessage'; static const pubkeyType = 'pubkeyType'; static const securitySettings = 'securitySettings'; - } diff --git a/lib/main.dart b/lib/main.dart index df6533b8d5..4f7902a1f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; -import 'package:feedback/feedback.dart'; import 'package:flutter/foundation.dart' show kIsWasm, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,8 +28,9 @@ import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/model/stored_settings.dart'; import 'package:web_dex/performance_analytics/performance_analytics.dart'; import 'package:web_dex/sdk/widgets/window_close_handler.dart'; -import 'package:web_dex/services/feedback/custom_feedback_form.dart'; +import 'package:web_dex/services/feedback/app_feedback_wrapper.dart'; import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/services/storage/get_storage.dart'; import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; @@ -143,8 +143,7 @@ class MyApp extends StatelessWidget { final komodoDefiSdk = RepositoryProvider.of(context); final walletsRepository = RepositoryProvider.of(context); - final theme = Theme.of(context); - + final sensitivityController = ScreenshotSensitivityController(); return MultiBlocProvider( providers: [ BlocProvider( @@ -159,16 +158,15 @@ class MyApp extends StatelessWidget { }, ), ], - child: BetterFeedback( - feedbackBuilder: CustomFeedbackForm.feedbackBuilder, - themeMode: ThemeMode.light, - darkTheme: _feedbackThemeData(theme), - theme: _feedbackThemeData(theme), + child: AppFeedbackWrapper( child: AnalyticsLifecycleHandler( child: WindowCloseHandler( - child: app_bloc_root.AppBlocRoot( - storedPrefs: _storedSettings!, - komodoDefiSdk: komodoDefiSdk, + child: ScreenshotSensitivity( + controller: sensitivityController, + child: app_bloc_root.AppBlocRoot( + storedPrefs: _storedSettings!, + komodoDefiSdk: komodoDefiSdk, + ), ), ), ), @@ -176,15 +174,3 @@ class MyApp extends StatelessWidget { ); } } - -FeedbackThemeData _feedbackThemeData(ThemeData appTheme) { - return FeedbackThemeData( - bottomSheetTextInputStyle: appTheme.textTheme.bodyMedium!, - bottomSheetDescriptionStyle: appTheme.textTheme.bodyMedium!, - dragHandleColor: appTheme.colorScheme.primary, - colorScheme: appTheme.colorScheme, - sheetIsDraggable: true, - feedbackSheetHeight: 0.3, - drawColors: [Colors.red, Colors.white, Colors.green], - ); -} diff --git a/lib/services/feedback/app_feedback_wrapper.dart b/lib/services/feedback/app_feedback_wrapper.dart new file mode 100644 index 0000000000..0c61aa7cb6 --- /dev/null +++ b/lib/services/feedback/app_feedback_wrapper.dart @@ -0,0 +1,40 @@ +import 'package:feedback/feedback.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/services/feedback/custom_feedback_form.dart'; + +/// Wraps the app with BetterFeedback and provides consistent theming. +class AppFeedbackWrapper extends StatelessWidget { + const AppFeedbackWrapper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + return BetterFeedback( + feedbackBuilder: CustomFeedbackForm.feedbackBuilder, + themeMode: ThemeMode.system, + theme: _buildFeedbackTheme(brightness), + darkTheme: _buildFeedbackTheme(Brightness.dark), + child: child, + ); + } + + FeedbackThemeData _buildFeedbackTheme(Brightness brightness) { + final base = brightness == Brightness.light + ? FeedbackThemeData.light() + : FeedbackThemeData.dark(); + return FeedbackThemeData( + background: base.background, + feedbackSheetColor: base.feedbackSheetColor, + activeFeedbackModeColor: base.activeFeedbackModeColor, + drawColors: base.drawColors, + bottomSheetDescriptionStyle: base.bottomSheetDescriptionStyle, + bottomSheetTextInputStyle: base.bottomSheetTextInputStyle, + dragHandleColor: base.dragHandleColor, + colorScheme: base.colorScheme, + sheetIsDraggable: true, + feedbackSheetHeight: 0.35, + ); + } +} diff --git a/lib/services/feedback/custom_feedback_form.dart b/lib/services/feedback/custom_feedback_form.dart index 700e2c3338..537260d9a9 100644 --- a/lib/services/feedback/custom_feedback_form.dart +++ b/lib/services/feedback/custom_feedback_form.dart @@ -1,35 +1,36 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:feedback/feedback.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/feedback_form/feedback_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/services/feedback/feedback_models.dart'; import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/views/support/missing_coins_dialog.dart'; /// A form that prompts the user for feedback using BLoC for state management. class CustomFeedbackForm extends StatelessWidget { - const CustomFeedbackForm({ - super.key, - required this.scrollController, - }); + const CustomFeedbackForm({super.key, required this.scrollController}); final ScrollController? scrollController; static FeedbackBuilder get feedbackBuilder => (context, onSubmit, scrollController) => BlocProvider( - create: (_) => FeedbackFormBloc(onSubmit), - child: CustomFeedbackForm(scrollController: scrollController), - ); + create: (_) => FeedbackFormBloc(onSubmit), + child: CustomFeedbackForm(scrollController: scrollController), + ); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final theme = Theme.of(context); + // final theme = Theme.of(context); // Unused here; section widgets read theme directly final isLoading = state.status == FeedbackFormStatus.submitting; final formValid = state.isValid && !isLoading; + final submitLabel = LocaleKeys.send.tr(); + return Form( autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( @@ -39,129 +40,56 @@ class CustomFeedbackForm extends StatelessWidget { children: [ if (scrollController != null) const FeedbackSheetDragHandle(), - ListView( - controller: scrollController, - padding: EdgeInsets.fromLTRB( - 16, - scrollController != null ? 20 : 16, - 16, - 0, - ), + _ScrollableFormContent( + scrollController: scrollController, + topPadding: scrollController != null ? 20.0 : 0.0, children: [ - Text( - 'What kind of feedback do you want to give?', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - DropdownButtonFormField( - isExpanded: true, - value: state.feedbackType, - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null) { - return 'Please select a feedback type'; - } - return null; - }, - items: FeedbackType.values - .map( - (type) => DropdownMenuItem( - value: type, - child: Text(type.description), - ), - ) - .toList(), - onChanged: isLoading - ? null - : (feedbackType) { - if (feedbackType == - FeedbackType.missingCoins) { - showMissingCoinsDialog(context); - } - context.read().add( - FeedbackFormTypeChanged(feedbackType)); - }, + _SectionTitle( + title: LocaleKeys.feedbackFormKindQuestion.tr(), ), - const SizedBox(height: 16), - Text( - 'Please describe your feedback:', - style: theme.textTheme.titleMedium, + const SizedBox(height: 4), + _FeedbackTypeDropdown( + isLoading: isLoading, + selected: state.feedbackType, ), + const SizedBox(height: 8), - UiTextFormField( - maxLength: feedbackMaxLength, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - enabled: !isLoading, - autofocus: true, - hintText: 'Enter your feedback here...', + _MessageField( + isLoading: isLoading, errorText: state.feedbackTextError, - validationMode: InputValidationMode.eager, - onChanged: (value) => context - .read() - .add(FeedbackFormMessageChanged(value ?? '')), - ), - const SizedBox(height: 16), - Text( - state.feedbackType == FeedbackType.support || - state.feedbackType == - FeedbackType.missingCoins - ? 'How can we contact you?' - : 'How can we contact you? (Optional)', - style: theme.textTheme.titleMedium, ), + const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 130, - child: DropdownButtonFormField( - isExpanded: true, - value: state.contactMethod, - hint: const Text('Select'), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - items: ContactMethod.values - .map( - (method) => - DropdownMenuItem( - value: method, - child: Text(method.label), - ), - ) - .toList(), - onChanged: isLoading - ? null - : (method) => context - .read() - .add(FeedbackFormContactMethodChanged( - method)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: UiTextFormField( - enabled: !isLoading, - maxLength: contactDetailsMaxLength, - maxLengthEnforcement: - MaxLengthEnforcement.enforced, - hintText: _getContactHint(state.contactMethod), - errorText: state.contactDetailsError, - validationMode: InputValidationMode.eager, - onChanged: (value) => context - .read() - .add(FeedbackFormContactDetailsChanged( - value ?? '')), + _SectionTitle( + title: state.isContactRequired + ? LocaleKeys.feedbackFormContactRequired.tr() + : LocaleKeys.feedbackFormContactOptional.tr(), + ), + const SizedBox(height: 4), + if (state.isContactOptOutVisible) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: state.contactOptOut, + onChanged: isLoading + ? null + : (checked) => + context.read().add( + FeedbackFormContactOptOutChanged( + checked ?? false, + ), + ), + title: Text( + LocaleKeys.feedbackFormContactOptOut.tr(), ), ), - ], + ), + _ContactRow( + isLoading: state.isContactRowDisabled, + selectedMethod: state.contactMethod, + contactError: state.contactDetailsError, ), ], ), @@ -169,28 +97,14 @@ class CustomFeedbackForm extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isLoading) - const Padding( - padding: EdgeInsets.only(right: 16.0), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2.0), - ), - ), - TextButton( - onPressed: formValid - ? () => context - .read() - .add(const FeedbackFormSubmitted()) - : null, - child: const Text('SUBMIT'), - ), - ], + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: _ActionsRow( + isLoading: isLoading, + isFormValid: formValid, + submitLabel: submitLabel, ), ), ], @@ -201,17 +115,231 @@ class CustomFeedbackForm extends StatelessWidget { } } +class _ScrollableFormContent extends StatelessWidget { + const _ScrollableFormContent({ + required this.scrollController, + required this.topPadding, + required this.children, + }); + + final ScrollController? scrollController; + final double topPadding; + final List children; + + @override + Widget build(BuildContext context) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB(16, topPadding, 16, 0), + children: children, + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text(title, style: theme.textTheme.titleMedium); + } +} + +class _FeedbackTypeDropdown extends StatelessWidget { + const _FeedbackTypeDropdown({ + required this.isLoading, + required this.selected, + }); + + final bool isLoading; + final FeedbackType? selected; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + autofocus: true, + isExpanded: true, + initialValue: selected, + decoration: InputDecoration( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + validator: (value) => + value == null ? 'Please select a feedback type' : null, + items: FeedbackType.values + .map( + (type) => DropdownMenuItem( + value: type, + child: Text(type.description), + ), + ) + .toList(), + onChanged: isLoading + ? null + : (feedbackType) { + if (feedbackType == FeedbackType.missingCoins) { + showMissingCoinsDialog(context); + } + context.read().add( + FeedbackFormTypeChanged(feedbackType), + ); + }, + ); + } +} + +class _MessageField extends StatelessWidget { + const _MessageField({required this.isLoading, required this.errorText}); + + final bool isLoading; + final String? errorText; + + @override + Widget build(BuildContext context) { + return UiTextFormField( + maxLines: null, + maxLength: feedbackMaxLength, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + enabled: !isLoading, + labelText: LocaleKeys.feedbackFormDescribeTitle.tr(), + hintText: LocaleKeys.feedbackFormMessageHint.tr(), + errorText: errorText, + validationMode: InputValidationMode.eager, + onChanged: (value) => context.read().add( + FeedbackFormMessageChanged(value ?? ''), + ), + ); + } +} + +class _ContactRow extends StatelessWidget { + const _ContactRow({ + required this.isLoading, + required this.selectedMethod, + required this.contactError, + }); + + final bool isLoading; + final ContactMethod? selectedMethod; + final String? contactError; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: DropdownButtonFormField( + isExpanded: true, + initialValue: selectedMethod, + hint: Text(LocaleKeys.feedbackFormSelectContactMethod.tr()), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + items: ContactMethod.values + .map( + (method) => DropdownMenuItem( + value: method, + child: Text(method.label), + ), + ) + .toList(), + onChanged: isLoading + ? null + : (method) => context.read().add( + FeedbackFormContactMethodChanged(method), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: UiTextFormField( + enabled: !isLoading, + maxLength: contactDetailsMaxLength, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + hintText: _getContactHint(selectedMethod).tr(), + errorText: contactError, + validationMode: InputValidationMode.eager, + onChanged: (value) => context.read().add( + FeedbackFormContactDetailsChanged(value ?? ''), + ), + ), + ), + ], + ); + } +} + +class _ActionsRow extends StatelessWidget { + const _ActionsRow({ + required this.isLoading, + required this.isFormValid, + required this.submitLabel, + }); + + final bool isLoading; + final bool isFormValid; + final String submitLabel; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isLoading) + const Padding( + padding: EdgeInsets.only(right: 16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + ), + TextButton( + onPressed: isLoading ? null : () => BetterFeedback.of(context).hide(), + child: Text(LocaleKeys.cancel.tr()), + ), + const SizedBox(width: 16), + FilledButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + ), + onPressed: isFormValid + ? () => context.read().add( + const FeedbackFormSubmitted(), + ) + : null, + label: Text(submitLabel), + icon: const Icon(Icons.send), + ), + ], + ); + } +} + String _getContactHint(ContactMethod? method) { switch (method) { case ContactMethod.discord: - return 'Discord username (e.g., username123)'; + return LocaleKeys.feedbackFormDiscordHint; case ContactMethod.matrix: - return 'Matrix ID (e.g., @user:matrix.org)'; + return LocaleKeys.feedbackFormMatrixHint; case ContactMethod.telegram: - return 'Telegram username (e.g., @username)'; + return LocaleKeys.feedbackFormTelegramHint; case ContactMethod.email: - return 'Your email address'; + return LocaleKeys.feedbackFormEmailHint; default: - return 'Enter your contact details'; + return LocaleKeys.feedbackFormContactHint; } } diff --git a/lib/services/feedback/feedback_formatter.dart b/lib/services/feedback/feedback_formatter.dart new file mode 100644 index 0000000000..eeaaded385 --- /dev/null +++ b/lib/services/feedback/feedback_formatter.dart @@ -0,0 +1,112 @@ +import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; + +/// Utility class for formatting feedback descriptions in an agent-friendly way +class FeedbackFormatter { + /// Creates a properly formatted description for agent review + static String createAgentFriendlyDescription( + String description, + String type, + Map metadata, + ) { + final buffer = StringBuffer(); + + // Add the pre-formatted description from the form + buffer.writeln(description); + buffer.writeln(); + + // Technical information section + buffer.writeln('🔧 TECHNICAL INFORMATION:'); + buffer.writeln('─' * 40); + + // Group related metadata for better readability + final appInfo = {}; + final deviceInfo = {}; + final buildInfo = {}; + final walletInfo = {}; + + for (final entry in metadata.entries) { + switch (entry.key) { + case 'contactMethod': + case 'contactDetails': + // These are already handled in the form-level formatting + break; + case 'appName': + case 'packageName': + case 'version': + case 'buildNumber': + appInfo[entry.key] = entry.value; + break; + case 'platform': + case 'targetPlatform': + case 'baseUrl': + deviceInfo[entry.key] = entry.value; + break; + case 'mode': + case 'commitHash': + case 'timestamp': + buildInfo[entry.key] = entry.value; + break; + case 'coinsCurrentCommit': + case 'coinsLatestCommit': + buildInfo[entry.key] = entry.value; + break; + case 'wallet': + walletInfo[entry.key] = entry.value; + break; + default: + deviceInfo[entry.key] = entry.value; + } + } + + if (appInfo.isNotEmpty) { + buffer.writeln(' 📱 App Information:'); + appInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + if (deviceInfo.isNotEmpty) { + buffer.writeln(' 💻 Device Information:'); + deviceInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + if (buildInfo.isNotEmpty) { + buffer.writeln(' 🔨 Build Information:'); + buildInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + if (walletInfo.isNotEmpty) { + buffer.writeln(' 👛 Wallet Information:'); + walletInfo.forEach( + (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), + ); + buffer.writeln(); + } + + buffer.writeln('═══════════════════════════════════════'); + + return buffer.toString(); + } + + // Convert camel case to separate words + static String _formatKey(String key) { + // Special-case certain keys for clearer labeling in reports + if (key == 'commitHash') { + return 'KDF commit hash'; + } + return key + .replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (Match m) => '${m[1]} ${m[2]}', + ) + .replaceAll('_', ' ') + .toCapitalize(); + } +} diff --git a/lib/services/feedback/feedback_models.dart b/lib/services/feedback/feedback_models.dart index 6d8079a1a6..431e4cfbdc 100644 --- a/lib/services/feedback/feedback_models.dart +++ b/lib/services/feedback/feedback_models.dart @@ -31,8 +31,9 @@ class CustomFeedback { String toFormattedDescription() { final buffer = StringBuffer(); buffer.writeln('═══════════════════════════════════════'); - buffer - .writeln('📋 ${feedbackType?.description ?? 'Unknown'}'.toUpperCase()); + buffer.writeln( + '📋 ${feedbackType?.description ?? 'Unknown'}'.toUpperCase(), + ); buffer.writeln('═══════════════════════════════════════'); buffer.writeln(); buffer.writeln('💬 USER FEEDBACK:'); @@ -63,7 +64,8 @@ class CustomFeedback { break; case ContactMethod.telegram: buffer.writeln( - ' 📱 Telegram: ${contact.startsWith('@') ? contact : '@$contact'}'); + ' 📱 Telegram: ${contact.startsWith('@') ? contact : '@$contact'}', + ); break; case ContactMethod.matrix: buffer.writeln(' 🔗 Matrix: $contact'); @@ -72,61 +74,52 @@ class CustomFeedback { if (feedbackType == FeedbackType.support || feedbackType == FeedbackType.missingCoins) { buffer.writeln( - ' ⚠️ PRIORITY: Contact details provided for support request'); + ' ⚠️ PRIORITY: Contact details provided for support request', + ); } } else { buffer.writeln(' ❌ No contact information provided'); if (feedbackType == FeedbackType.support || feedbackType == FeedbackType.missingCoins) { buffer.writeln( - ' ⚠️ WARNING: Support request without contact details!'); + ' ⚠️ WARNING: Support request without contact details!', + ); } } return buffer.toString(); } } -enum FeedbackType { - missingCoins, - bugReport, - featureRequest, - support, - other; -} +enum FeedbackType { missingCoins, bugReport, featureRequest, support, other } extension FeedbackTypeDescription on FeedbackType { String get description { switch (this) { case FeedbackType.bugReport: - return 'Bug Report'; + return LocaleKeys.feedbackFormBugReport.tr(); case FeedbackType.featureRequest: - return 'Feature Request'; + return LocaleKeys.feedbackFormFeatureRequest.tr(); case FeedbackType.support: - return 'Support Request'; + return LocaleKeys.feedbackFormSupportRequest.tr(); case FeedbackType.missingCoins: return LocaleKeys.myCoinsMissing.tr(); case FeedbackType.other: - return 'Other'; + return LocaleKeys.feedbackFormOther.tr(); } } } -enum ContactMethod { - discord, - matrix, - telegram, - email; -} +enum ContactMethod { discord, matrix, telegram, email } extension ContactMethodLabel on ContactMethod { String get label { switch (this) { case ContactMethod.discord: - return 'Discord'; + return LocaleKeys.feedbackFormDiscord.tr(); case ContactMethod.matrix: - return 'Matrix'; + return LocaleKeys.feedbackFormMatrix.tr(); case ContactMethod.telegram: - return 'Telegram'; + return LocaleKeys.feedbackFormTelegram.tr(); case ContactMethod.email: return LocaleKeys.email.tr(); } diff --git a/lib/services/feedback/feedback_provider.dart b/lib/services/feedback/feedback_provider.dart new file mode 100644 index 0000000000..c0d542ed09 --- /dev/null +++ b/lib/services/feedback/feedback_provider.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +/// Abstract interface for feedback providers +abstract class FeedbackProvider { + /// Submits feedback to the provider + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }); + + /// Returns true if this provider is configured and available for use + bool get isAvailable; +} diff --git a/lib/services/feedback/feedback_service.dart b/lib/services/feedback/feedback_service.dart index c49dc3b7ce..8d2a7f1461 100644 --- a/lib/services/feedback/feedback_service.dart +++ b/lib/services/feedback/feedback_service.dart @@ -1,20 +1,16 @@ -import 'dart:convert'; -import 'package:easy_localization/easy_localization.dart'; import 'package:feedback/feedback.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:http/http.dart' as http; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; -import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:http_parser/http_parser.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:web_dex/app_config/app_config.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; +import 'package:web_dex/services/feedback/providers/cloudflare_feedback_provider.dart'; +import 'package:web_dex/services/feedback/providers/debug_console_feedback_provider.dart'; +import 'package:web_dex/services/feedback/providers/trello_feedback_provider.dart'; + +export 'feedback_ui_extension.dart'; /// Service that handles user feedback submission class FeedbackService { @@ -42,7 +38,7 @@ class FeedbackService { /// /// Returns true if feedback was submitted successfully, false otherwise Future handleFeedback(UserFeedback feedback) async { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); final buildMode = kReleaseMode ? 'release' @@ -57,15 +53,16 @@ class FeedbackService { if (feedback.extra != null && feedback.extra is JsonMap) { final extras = feedback.extra!; - contactMethod = extras['contact_method'] as String?; - contactDetails = extras['contact_details'] as String?; - feedbackType = extras['feedback_type'] as String?; + contactMethod = extras.valueOrNull('contact_method'); + contactDetails = extras.valueOrNull('contact_details'); + feedbackType = extras.valueOrNull('feedback_type'); } + final sdk = GetIt.I(); + final Map metadata = { if (contactMethod != null) 'contactMethod': contactMethod, if (contactDetails != null) 'contactDetails': contactDetails, - 'platform': kIsWeb ? 'web' : 'native', 'commitHash': const String.fromEnvironment( 'COMMIT_HASH', @@ -78,9 +75,11 @@ class FeedbackService { ...packageInfo.data, 'mode': buildMode, 'timestamp': DateTime.now().toIso8601String(), - 'wallet': (await GetIt.I().auth.currentUser)?.toJson() ?? 'None', + + 'coinsCurrentCommit': await sdk.assets.currentCoinsCommit, + 'coinsLatestCommit': await sdk.assets.latestCoinsCommit, }; try { @@ -104,585 +103,3 @@ class FeedbackService { } } } - -/// Abstract interface for feedback providers -abstract class FeedbackProvider { - /// Submits feedback to the provider - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }); - - /// Returns true if this provider is configured and available for use - bool get isAvailable; -} - -/// Utility class for formatting feedback descriptions in an agent-friendly way -class FeedbackFormatter { - /// Creates a properly formatted description for agent review - static String createAgentFriendlyDescription( - String description, - String type, - Map metadata, - ) { - final buffer = StringBuffer(); - - // Add the pre-formatted description from the form - buffer.writeln(description); - buffer.writeln(); - - // Technical information section - buffer.writeln('🔧 TECHNICAL INFORMATION:'); - buffer.writeln('─' * 40); - - // Group related metadata for better readability - final appInfo = {}; - final deviceInfo = {}; - final buildInfo = {}; - final walletInfo = {}; - - for (final entry in metadata.entries) { - switch (entry.key) { - case 'contactMethod': - case 'contactDetails': - // These are already handled in the form-level formatting - break; - case 'appName': - case 'packageName': - case 'version': - case 'buildNumber': - appInfo[entry.key] = entry.value; - break; - case 'platform': - case 'targetPlatform': - case 'baseUrl': - deviceInfo[entry.key] = entry.value; - break; - case 'mode': - case 'commitHash': - case 'timestamp': - buildInfo[entry.key] = entry.value; - break; - case 'wallet': - walletInfo[entry.key] = entry.value; - break; - default: - deviceInfo[entry.key] = entry.value; - } - } - - if (appInfo.isNotEmpty) { - buffer.writeln(' 📱 App Information:'); - appInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), - ); - buffer.writeln(); - } - - if (deviceInfo.isNotEmpty) { - buffer.writeln(' 💻 Device Information:'); - deviceInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), - ); - buffer.writeln(); - } - - if (buildInfo.isNotEmpty) { - buffer.writeln(' 🔨 Build Information:'); - buildInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), - ); - buffer.writeln(); - } - - if (walletInfo.isNotEmpty) { - buffer.writeln(' 👛 Wallet Information:'); - walletInfo.forEach( - (key, value) => buffer.writeln(' • ${_formatKey(key)}: $value'), - ); - buffer.writeln(); - } - - buffer.writeln('═══════════════════════════════════════'); - - return buffer.toString(); - } - - // Convert camel case to separate words - static String _formatKey(String key) { - return key - .replaceAllMapped( - RegExp(r'([a-z])([A-Z])'), - (Match m) => '${m[1]} ${m[2]}', - ) - .replaceAll('_', ' ') - .toCapitalize(); - } - - // ...existing code... -} - -/// Implementation of FeedbackProvider that submits feedback to Trello -/// -/// The following environment variables must be set using dart-define: -/// TRELLO_API_KEY: Your Trello API key -/// TRELLO_TOKEN: Your Trello API token -/// TRELLO_BOARD_ID: The ID of the Trello board where feedback will be sent -/// TRELLO_LIST_ID: The ID of the Trello list where feedback will be sent - -/// The Trello API key can be obtained by going to the Power-Ups console: -/// https://trello.com/power-ups/admin - -/// For Komodo Wallet, the Trello API token can be re-generated by going to: -/// https://trello.com/1/authorize?expiration=never&name=Komodo%20Wallet%20Feedback&scope=read,write&response_type=token&key=YOUR_API_KEY -/// -/// If you have trouble generating that or if you are setting it up for a fork, -/// there is an option in the power-up console to generate the link. -/// -/// The Trello board ID and list ID can be obtained by going to the Trello board -/// and adding `.json` to the end of the URL, doing a search for the board/list -/// name and then copying the `id`. -/// E.g. https://trello.com/c/AbcdXYZ/63-feedback-user-feedback -> -/// https://trello.com/c/AbcdXYZ/63-feedback-user-feedback.json -/// -/// The environment variables can be set for the build using the following -/// command for example: -/// flutter build web --dart-define TRELLO_API_KEY=YOUR_KEY_HERE --dart-define TRELLO_TOKEN=YOUR_TOKEN_HERE --dart-define TRELLO_BOARD_ID=YOUR_BOARD_ID_HERE --dart-define TRELLO_LIST_ID=YOUR_LIST_ID_HERE -class TrelloFeedbackProvider implements FeedbackProvider { - final String apiKey; - final String token; - final String boardId; - final String listId; - - const TrelloFeedbackProvider({ - required this.apiKey, - required this.token, - required this.boardId, - required this.listId, - }); - - static bool hasEnvironmentVariables() { - final requiredVars = { - 'TRELLO_API_KEY': const String.fromEnvironment('TRELLO_API_KEY'), - 'TRELLO_TOKEN': const String.fromEnvironment('TRELLO_TOKEN'), - 'TRELLO_BOARD_ID': const String.fromEnvironment('TRELLO_BOARD_ID'), - 'TRELLO_LIST_ID': const String.fromEnvironment('TRELLO_LIST_ID'), - }; - - final missingVars = requiredVars.entries - .where((e) => e.value.isEmpty) - .toList(); - - if (missingVars.isNotEmpty) { - final altAvailable = - CloudflareFeedbackProvider.fromEnvironment().isAvailable; - if (kDebugMode && !altAvailable) { - debugPrint( - 'Missing required environment variables for Trello feedback provider: ' + - missingVars.join(', '), - ); - } - return false; - } - - return true; - } - - /// Creates a TrelloFeedbackProvider instance if all required environment variables are set. - /// Returns null if any environment variable is missing or empty. - static TrelloFeedbackProvider? fromEnvironment() { - if (!hasEnvironmentVariables()) { - return null; - } - - return TrelloFeedbackProvider( - apiKey: const String.fromEnvironment('TRELLO_API_KEY'), - token: const String.fromEnvironment('TRELLO_TOKEN'), - boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), - listId: const String.fromEnvironment('TRELLO_LIST_ID'), - ); - } - - @override - bool get isAvailable => hasEnvironmentVariables(); - - @override - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }) async { - try { - // Create comprehensive formatted description for agents - final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( - description, - type, - metadata, - ); - - // 1. Create the card - final cardResponse = await http.post( - Uri.parse('https://api.trello.com/1/cards'), - headers: {'Content-Type': 'application/json; charset=utf-8'}, - body: jsonEncode({ - 'idList': listId, - 'key': apiKey, - 'token': token, - 'name': 'Feedback: $type', - 'desc': formattedDesc, - }), - ); - - if (cardResponse.statusCode != 200) { - throw Exception( - 'Failed to create Trello card (${cardResponse.statusCode}): ${cardResponse.body}', - ); - } - - final cardId = jsonDecode(cardResponse.body)['id']; - - // 2. Attach the screenshot to the card - final attachmentRequest = http.MultipartRequest( - 'POST', - Uri.parse('https://api.trello.com/1/cards/$cardId/attachments'), - ); - - attachmentRequest.fields.addAll({'key': apiKey, 'token': token}); - - attachmentRequest.files.add( - http.MultipartFile.fromBytes( - 'file', - screenshot, - filename: 'screenshot.png', - contentType: MediaType('image', 'png'), - ), - ); - - final attachmentResponse = await attachmentRequest.send(); - final streamedResponse = await http.Response.fromStream( - attachmentResponse, - ); - - if (streamedResponse.statusCode != 200) { - throw Exception( - 'Failed to attach screenshot (${streamedResponse.statusCode}): ${streamedResponse.body}', - ); - } - } catch (e) { - final altAvailable = - CloudflareFeedbackProvider.fromEnvironment().isAvailable; - if (kDebugMode && !altAvailable) { - debugPrint('Error in Trello submitFeedback: $e'); - } - rethrow; - } - } -} - -/// Implementation of FeedbackProvider that submits feedback to Komodo's -/// internal API. -/// -/// The following environment variables must be set using dart-define: -/// FEEDBACK_API_KEY: The API key for the feedback service -/// FEEDBACK_PRODUCTION_URL: The production URL for the feedback API -/// TRELLO_LIST_ID: The ID of the Trello list where feedback will be sent (shared with TrelloFeedbackProvider) -/// TRELLO_BOARD_ID: The ID of the Trello board (shared with TrelloFeedbackProvider) -/// -/// This provider is used for submitting feedback to the Cloudflare Worker. -/// You can set up your own feedback backend by using the repository available at: -/// https://github.com/KomodoPlatform/komodo-wallet-feedback-cf-worker -/// -/// Example build command: -/// ``` -/// flutter build web --dart-define=FEEDBACK_PRODUCTION_URL=https://your-api-url.com --dart-define=FEEDBACK_API_KEY=your_api_key --dart-define=TRELLO_LIST_ID=your_list_id --dart-define=TRELLO_BOARD_ID=your_board_id -/// ``` -/// -/// Example run command (debugging): -/// ``` -/// flutter run --dart-define=FEEDBACK_API_KEY=your_api_key --dart-define=TRELLO_LIST_ID=your_list_id --dart-define=TRELLO_BOARD_ID=your_board_id -/// ``` -/// -class CloudflareFeedbackProvider implements FeedbackProvider { - final String apiKey; - final String prodEndpoint; - final String listId; - final String boardId; - - const CloudflareFeedbackProvider({ - required this.apiKey, - required this.prodEndpoint, - required this.listId, - required this.boardId, - }); - - /// Creates a CloudflareFeedbackProvider instance from environment variables. - /// - /// Uses the following environment variables: - /// - FEEDBACK_API_KEY: The API key for the feedback service - /// - FEEDBACK_PRODUCTION_URL: The production URL for the feedback API - /// - TRELLO_LIST_ID: The ID of the Trello list where feedback will be sent (shared with TrelloFeedbackProvider) - /// - TRELLO_BOARD_ID: The ID of the Trello board where feedback will be sent (shared with TrelloFeedbackProvider) - static CloudflareFeedbackProvider fromEnvironment() { - return CloudflareFeedbackProvider( - apiKey: const String.fromEnvironment('FEEDBACK_API_KEY'), - prodEndpoint: const String.fromEnvironment('FEEDBACK_PRODUCTION_URL'), - listId: const String.fromEnvironment('TRELLO_LIST_ID'), - boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), - ); - } - - String get _endpoint => prodEndpoint; - - @override - bool get isAvailable => - apiKey.isNotEmpty && - prodEndpoint.isNotEmpty && - listId.isNotEmpty && - boardId.isNotEmpty; - - @override - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }) async { - try { - // Create comprehensive formatted description for agents - final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( - description, - type, - metadata, - ); - - final request = http.MultipartRequest('POST', Uri.parse(_endpoint)); - - // Set headers including charset - request.headers.addAll({'X-KW-KEY': apiKey, 'Accept-Charset': 'utf-8'}); - - // Properly encode all string fields to ensure UTF-8 encoding - request.fields.addAll({ - 'idBoard': boardId, - 'idList': listId, - 'name': 'Feedback: $type', - 'desc': formattedDesc, - }); - - request.files.add( - http.MultipartFile.fromBytes( - 'img', - screenshot, - filename: 'screenshot.png', - contentType: MediaType('image', 'png'), - ), - ); - - // Encode metadata as JSON with proper UTF-8 handling - final metadataJson = metadata.toJsonString(); - request.fields['metadata'] = metadataJson; - - final streamedResponse = await request.send(); - final response = await http.Response.fromStream(streamedResponse); - - if (response.statusCode != 200) { - throw Exception( - 'Failed to submit feedback (${response.statusCode}): ${response.body}', - ); - } - } catch (e) { - final altAvailable = TrelloFeedbackProvider.hasEnvironmentVariables(); - if (kDebugMode && !altAvailable) { - debugPrint('Error in Cloudflare submitFeedback: $e'); - } - rethrow; - } - } -} - -/// Debug implementation of FeedbackProvider that prints feedback to console -class DebugConsoleFeedbackProvider implements FeedbackProvider { - @override - bool get isAvailable => true; - - @override - Future submitFeedback({ - required String description, - required Uint8List screenshot, - required String type, - required Map metadata, - }) async { - debugPrint('---------------- DEBUG FEEDBACK ----------------'); - debugPrint('Type: $type'); - debugPrint('Description:'); - debugPrint(description); - debugPrint('\nMetadata:'); - metadata.forEach((key, value) => debugPrint('$key: $value')); - debugPrint('Screenshot size: ${screenshot.length} bytes'); - debugPrint('---------------------------------------------'); - } -} - -extension BuildContextShowFeedback on BuildContext { - /// Shows the feedback dialog if the feedback service is available. - /// Does nothing if the feedback service is not configured. - void showFeedback() { - final feedbackService = FeedbackService.create(); - if (feedbackService == null) { - debugPrint( - 'Feedback dialog not shown: feedback service is not configured', - ); - return; - } - - BetterFeedback.of(this).show((feedback) async { - // Workaround for known BetterFeedback issue: - // https://github.com/ueman/feedback/issues/322#issuecomment-2384060812 - await Future.delayed(Duration(milliseconds: 500)); - try { - final success = await feedbackService.handleFeedback(feedback); - - if (success) { - // Close the feedback dialog - BetterFeedback.of(this).hide(); - - // Check if Discord was selected as contact method - String? contactMethod; - if (feedback.extra != null && feedback.extra is JsonMap) { - contactMethod = feedback.extra!['contact_method'] as String?; - } - - // Show Discord info dialog if Discord was selected - if (contactMethod == 'discord') { - // Use a short delay to ensure the feedback form is fully closed - await Future.delayed(Duration(milliseconds: 300)); - await _showDiscordInfoDialog(this); - } - - // Show success message - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text( - 'Thank you! ${LocaleKeys.feedbackFormDescription.tr()}', - ), - action: SnackBarAction( - label: LocaleKeys.addMoreFeedback.tr(), - onPressed: () => showFeedback(), - ), - duration: const Duration(seconds: 5), - ), - ); - } else { - // Keep the feedback dialog open but show error message - final theme = Theme.of(this); - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.feedbackError.tr(), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } - } catch (e) { - debugPrint('Error submitting feedback: $e'); - - // Show error message but keep dialog open - final theme = Theme.of(this); - ScaffoldMessenger.of(this).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.feedbackError.tr(), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } - }); - } - - /// Returns true if feedback functionality is available - bool get isFeedbackAvailable => - FeedbackService.create()?.isAvailable ?? false; -} - -/// Shows a dialog with information about Discord contact -Future _showDiscordInfoDialog(BuildContext context) { - final theme = Theme.of(context); - - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Let\'s Connect on Discord!'), - contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 24), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'To ensure we can reach you:', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '• Make sure you\'re a member of the Komodo Discord server', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - '• Watch for our team in the support channel', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - '• Feel free to reach out to us anytime in the server', - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ], - ), - actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text('Close'), - ), - SizedBox( - width: 230, - child: UiPrimaryButton( - onPressed: () async { - Navigator.of(context).pop(); - await _openDiscordSupport(); - }, - child: Text('Join Komodo Discord'), - ), - ), - ], - ), - ); -} - -Future _openDiscordSupport() async { - try { - await launchUrl(discordInviteUrl, mode: LaunchMode.externalApplication); - } catch (e) { - debugPrint('Error opening Discord link: $e'); - } -} diff --git a/lib/services/feedback/feedback_ui_extension.dart b/lib/services/feedback/feedback_ui_extension.dart new file mode 100644 index 0000000000..c7875a7db5 --- /dev/null +++ b/lib/services/feedback/feedback_ui_extension.dart @@ -0,0 +1,176 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:feedback/feedback.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/feedback/feedback_service.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; +import 'dart:typed_data'; + +extension BuildContextShowFeedback on BuildContext { + /// Shows the feedback dialog if the feedback service is available. + /// Does nothing if the feedback service is not configured. + void showFeedback() { + final feedbackService = FeedbackService.create(); + if (feedbackService == null) { + debugPrint( + 'Feedback dialog not shown: feedback service is not configured', + ); + return; + } + + BetterFeedback.of(this).show((feedback) async { + await Future.delayed(Duration(milliseconds: 500)); + try { + // If current UI is marked screenshot-sensitive, replace screenshot with a + // minimal transparent PNG to avoid leaking secrets. + final bool isSensitive = isScreenshotSensitive; + final UserFeedback sanitized = isSensitive + ? UserFeedback( + text: feedback.text, + extra: feedback.extra, + // 1x1 transparent PNG + screenshot: Uint8List.fromList(const [ + 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,10,73,68,65,84,120,156,99,96,0,0,0,2,0,1,226,33,185,120,0,0,0,0,73,69,78,68,174,66,96,130 + ]), + ) + : feedback; + + final success = await feedbackService.handleFeedback(sanitized); + + if (success) { + BetterFeedback.of(this).hide(); + + String? contactMethod; + if (feedback.extra != null && feedback.extra is JsonMap) { + final extras = feedback.extra!; + contactMethod = extras.valueOrNull('contact_method'); + } + + if (contactMethod == 'discord') { + await Future.delayed(Duration(milliseconds: 300)); + await _showDiscordInfoDialog(this); + } + + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text( + 'Thank you! ${LocaleKeys.feedbackFormDescription.tr()}', + ), + action: SnackBarAction( + label: LocaleKeys.addMoreFeedback.tr(), + onPressed: () => showFeedback(), + ), + duration: const Duration(seconds: 5), + ), + ); + } else { + final theme = Theme.of(this); + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.feedbackError.tr(), + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + } catch (e) { + debugPrint('Error submitting feedback: $e'); + final theme = Theme.of(this); + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.feedbackError.tr(), + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + }); + } + + bool get isFeedbackAvailable => + FeedbackService.create()?.isAvailable ?? false; +} + +Future _showDiscordInfoDialog(BuildContext context) { + final theme = Theme.of(context); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Let\'s Connect on Discord!'), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'To ensure we can reach you:', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• Make sure you\'re a member of the Komodo Discord server', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '• Watch for our team in the support channel', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '• Feel free to reach out to us anytime in the server', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Close'), + ), + SizedBox( + width: 230, + child: UiPrimaryButton( + onPressed: () async { + Navigator.of(context).pop(); + await _openDiscordSupport(); + }, + child: Text('Join Komodo Discord'), + ), + ), + ], + ), + ); +} + +Future _openDiscordSupport() async { + try { + await launchUrl(discordInviteUrl, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('Error opening Discord link: $e'); + } +} diff --git a/lib/services/feedback/providers/cloudflare_feedback_provider.dart b/lib/services/feedback/providers/cloudflare_feedback_provider.dart new file mode 100644 index 0000000000..6a6b709812 --- /dev/null +++ b/lib/services/feedback/providers/cloudflare_feedback_provider.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:web_dex/services/feedback/feedback_formatter.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; +import 'package:web_dex/services/logger/get_logger.dart' as app_logger; + +class CloudflareFeedbackProvider implements FeedbackProvider { + final String apiKey; + final String prodEndpoint; + final String listId; + final String boardId; + + const CloudflareFeedbackProvider({ + required this.apiKey, + required this.prodEndpoint, + required this.listId, + required this.boardId, + }); + + static CloudflareFeedbackProvider fromEnvironment() { + return CloudflareFeedbackProvider( + apiKey: const String.fromEnvironment('FEEDBACK_API_KEY'), + prodEndpoint: const String.fromEnvironment('FEEDBACK_PRODUCTION_URL'), + listId: const String.fromEnvironment('TRELLO_LIST_ID'), + boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), + ); + } + + String get _endpoint => prodEndpoint; + + @override + bool get isAvailable => + apiKey.isNotEmpty && + prodEndpoint.isNotEmpty && + listId.isNotEmpty && + boardId.isNotEmpty; + + @override + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }) async { + try { + final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( + description, + type, + metadata, + ); + + final request = http.MultipartRequest('POST', Uri.parse(_endpoint)); + request.headers.addAll({'X-KW-KEY': apiKey, 'Accept-Charset': 'utf-8'}); + request.fields.addAll({ + 'idBoard': boardId, + 'idList': listId, + 'name': 'Feedback: $type', + 'desc': formattedDesc, + }); + + request.files.add( + http.MultipartFile.fromBytes( + 'img', + screenshot, + filename: 'screenshot.png', + contentType: MediaType('image', 'png'), + ), + ); + + try { + final Uint8List logsBytes = await app_logger.logger + .exportRecentLogsBytes(maxBytes: 9 * 1024 * 1024); + if (logsBytes.isNotEmpty) { + request.files.add( + http.MultipartFile.fromBytes( + 'logs', + logsBytes, + filename: 'logs.txt', + contentType: MediaType('text', 'plain'), + ), + ); + } + } catch (e) { + if (kDebugMode) { + debugPrint('Skipping logs attachment: $e'); + } + } + + final streamedResponse = await request.send(); + final response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode != 200) { + throw Exception( + 'Failed to submit feedback (${response.statusCode}): ${response.body}', + ); + } + } catch (e) { + rethrow; + } + } +} diff --git a/lib/services/feedback/providers/debug_console_feedback_provider.dart b/lib/services/feedback/providers/debug_console_feedback_provider.dart new file mode 100644 index 0000000000..f04af8e95e --- /dev/null +++ b/lib/services/feedback/providers/debug_console_feedback_provider.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; + +class DebugConsoleFeedbackProvider implements FeedbackProvider { + @override + bool get isAvailable => true; + + @override + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }) async { + debugPrint('---------------- DEBUG FEEDBACK ----------------'); + debugPrint('Type: $type'); + debugPrint('Description:'); + debugPrint(description); + debugPrint('\nMetadata:'); + metadata.forEach((key, value) => debugPrint('$key: $value')); + debugPrint('Screenshot size: ${screenshot.length} bytes'); + debugPrint('---------------------------------------------'); + } +} diff --git a/lib/services/feedback/providers/trello_feedback_provider.dart b/lib/services/feedback/providers/trello_feedback_provider.dart new file mode 100644 index 0000000000..1a0c1f768d --- /dev/null +++ b/lib/services/feedback/providers/trello_feedback_provider.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:web_dex/services/feedback/feedback_formatter.dart'; +import 'package:web_dex/services/feedback/feedback_provider.dart'; +import 'package:web_dex/services/logger/get_logger.dart' as app_logger; + +class TrelloFeedbackProvider implements FeedbackProvider { + final String apiKey; + final String token; + final String boardId; + final String listId; + + const TrelloFeedbackProvider({ + required this.apiKey, + required this.token, + required this.boardId, + required this.listId, + }); + + static bool hasEnvironmentVariables() { + final requiredVars = { + 'TRELLO_API_KEY': const String.fromEnvironment('TRELLO_API_KEY'), + 'TRELLO_TOKEN': const String.fromEnvironment('TRELLO_TOKEN'), + 'TRELLO_BOARD_ID': const String.fromEnvironment('TRELLO_BOARD_ID'), + 'TRELLO_LIST_ID': const String.fromEnvironment('TRELLO_LIST_ID'), + }; + + final missing = requiredVars.entries.where((e) => e.value.isEmpty).toList(); + return missing.isEmpty; + } + + static TrelloFeedbackProvider? fromEnvironment() { + if (!hasEnvironmentVariables()) return null; + return TrelloFeedbackProvider( + apiKey: const String.fromEnvironment('TRELLO_API_KEY'), + token: const String.fromEnvironment('TRELLO_TOKEN'), + boardId: const String.fromEnvironment('TRELLO_BOARD_ID'), + listId: const String.fromEnvironment('TRELLO_LIST_ID'), + ); + } + + @override + bool get isAvailable => hasEnvironmentVariables(); + + @override + Future submitFeedback({ + required String description, + required Uint8List screenshot, + required String type, + required Map metadata, + }) async { + // 1) Create card with formatted description + final formattedDesc = FeedbackFormatter.createAgentFriendlyDescription( + description, + type, + metadata, + ); + + final cardResponse = await http.post( + Uri.parse('https://api.trello.com/1/cards'), + headers: {'Content-Type': 'application/json; charset=utf-8'}, + body: jsonEncode({ + 'idList': listId, + 'key': apiKey, + 'token': token, + 'name': 'Feedback: $type', + 'desc': formattedDesc, + }), + ); + + if (cardResponse.statusCode != 200) { + throw Exception( + 'Failed to create Trello card (${cardResponse.statusCode}): ${cardResponse.body}', + ); + } + + final cardId = jsonDecode(cardResponse.body)['id']; + + // 2) Attach screenshot + final imgReq = http.MultipartRequest( + 'POST', + Uri.parse('https://api.trello.com/1/cards/$cardId/attachments'), + ); + imgReq.fields.addAll({'key': apiKey, 'token': token}); + imgReq.files.add( + http.MultipartFile.fromBytes( + 'file', + screenshot, + filename: 'screenshot.png', + contentType: MediaType('image', 'png'), + ), + ); + final imgResp = await http.Response.fromStream(await imgReq.send()); + if (imgResp.statusCode != 200) { + throw Exception( + 'Failed to attach screenshot (${imgResp.statusCode}): ${imgResp.body}', + ); + } + + // 3) Attach logs (<= 9MB) - optional + try { + final bytes = await app_logger.logger.exportRecentLogsBytes( + maxBytes: 9 * 1024 * 1024, + ); + if (bytes.isEmpty) return; + + final logsReq = http.MultipartRequest( + 'POST', + Uri.parse('https://api.trello.com/1/cards/$cardId/attachments'), + ); + logsReq.fields.addAll({'key': apiKey, 'token': token}); + logsReq.files.add( + http.MultipartFile.fromBytes( + 'file', + bytes, + filename: 'logs.txt', + contentType: MediaType('text', 'plain'), + ), + ); + final logsResp = await http.Response.fromStream(await logsReq.send()); + if (logsResp.statusCode != 200) { + throw Exception( + 'Failed to attach logs (${logsResp.statusCode}): ${logsResp.body}', + ); + } + } catch (e) { + if (kDebugMode) { + debugPrint('Skipping logs attachment (Trello): $e'); + } + } + } +} diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 8f143ee590..3ddc91922c 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data'; + abstract class LoggerInterface { Future init(); Future write(String logMessage, [String? path]); Future getLogFile(); + Future exportRecentLogsBytes({int maxBytes}); } diff --git a/lib/services/logger/mock_logger.dart b/lib/services/logger/mock_logger.dart index 3e04a62877..62f4deb7ea 100644 --- a/lib/services/logger/mock_logger.dart +++ b/lib/services/logger/mock_logger.dart @@ -1,5 +1,6 @@ // ignore_for_file: avoid_print +import 'dart:typed_data'; import 'package:web_dex/services/logger/logger.dart'; class MockLogger implements LoggerInterface { @@ -19,4 +20,12 @@ class MockLogger implements LoggerInterface { Future init() async { print('initialized'); } + + @override + Future exportRecentLogsBytes({ + int maxBytes = 9 * 1024 * 1024, + }) async { + final String mock = 'Mock logs: logger not available in this environment.'; + return Uint8List.fromList(mock.codeUnits); + } } diff --git a/lib/services/logger/universal_logger.dart b/lib/services/logger/universal_logger.dart index 6549508c42..df16a29603 100644 --- a/lib/services/logger/universal_logger.dart +++ b/lib/services/logger/universal_logger.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; import 'package:dragon_logs/dragon_logs.dart'; import 'package:intl/intl.dart'; @@ -27,8 +29,9 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { try { await DragonLogs.init(); - initialised_logger - .log('Logger initialized in ${timer.elapsedMilliseconds}ms'); + initialised_logger.log( + 'Logger initialized in ${timer.elapsedMilliseconds}ms', + ); _isInitialized = true; } catch (e) { @@ -86,8 +89,9 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { return; } - final String date = - DateFormat('dd.MM.yyyy_HH-mm-ss').format(DateTime.now()); + final String date = DateFormat( + 'dd.MM.yyyy_HH-mm-ss', + ).format(DateTime.now()); final String filename = 'komodo_wallet_log_$date'; await FileLoader.fromPlatform().save( @@ -96,4 +100,29 @@ class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { type: LoadFileType.compressed, ); } + + @override + Future exportRecentLogsBytes({ + int maxBytes = 9 * 1024 * 1024, + }) async { + final List recentChunks = []; + int totalBytes = 0; + + await for (final String chunk in DragonLogs.exportLogsStream()) { + final Uint8List bytes = Uint8List.fromList(utf8.encode(chunk)); + recentChunks.add(bytes); + totalBytes += bytes.length; + + while (totalBytes > maxBytes && recentChunks.isNotEmpty) { + totalBytes -= recentChunks.first.length; + recentChunks.removeAt(0); + } + } + + final BytesBuilder builder = BytesBuilder(copy: false); + for (final Uint8List part in recentChunks) { + builder.add(part); + } + return builder.toBytes(); + } } diff --git a/lib/shared/screenshot/screenshot_sensitivity.dart b/lib/shared/screenshot/screenshot_sensitivity.dart new file mode 100644 index 0000000000..d4b7fb9f0f --- /dev/null +++ b/lib/shared/screenshot/screenshot_sensitivity.dart @@ -0,0 +1,80 @@ +import 'package:flutter/widgets.dart'; + +/// Controller that tracks whether the current UI subtree is considered +/// screenshot-sensitive. +class ScreenshotSensitivityController extends ChangeNotifier { + int _depth = 0; + + bool get isSensitive => _depth > 0; + + void enter() { + _depth += 1; + notifyListeners(); + } + + void exit() { + if (_depth > 0) { + _depth -= 1; + notifyListeners(); + } + } +} + +/// Inherited notifier providing access to the ScreenshotSensitivityController. +class ScreenshotSensitivity extends InheritedNotifier { + const ScreenshotSensitivity({ + super.key, + required ScreenshotSensitivityController controller, + required Widget child, + }) : super(notifier: controller, child: child); + + static ScreenshotSensitivityController? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.notifier; + } + + static ScreenshotSensitivityController of(BuildContext context) { + final controller = maybeOf(context); + assert(controller != null, 'ScreenshotSensitivity not found in widget tree'); + return controller!; + } +} + +/// Widget that marks its subtree as screenshot-sensitive while mounted. +class ScreenshotSensitive extends StatefulWidget { + const ScreenshotSensitive({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _ScreenshotSensitiveState(); +} + +class _ScreenshotSensitiveState extends State { + ScreenshotSensitivityController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final controller = ScreenshotSensitivity.maybeOf(context); + if (!identical(controller, _controller)) { + _controller?.exit(); + _controller = controller; + _controller?.enter(); + } + } + + @override + void dispose() { + _controller?.exit(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +extension ScreenshotSensitivityContextExt on BuildContext { + bool get isScreenshotSensitive => + ScreenshotSensitivity.maybeOf(this)?.isSensitive ?? false; +} + diff --git a/lib/views/bitrefill/bitrefill_inappwebview_button.dart b/lib/views/bitrefill/bitrefill_inappwebview_button.dart index d24596bab7..fd42db695f 100644 --- a/lib/views/bitrefill/bitrefill_inappwebview_button.dart +++ b/lib/views/bitrefill/bitrefill_inappwebview_button.dart @@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; import 'package:web_dex/views/bitrefill/bitrefill_button_view.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; /// A button that opens the provided url in an embedded InAppWebview widget. /// This widget uses the flutter_inappwebview package to open the url using @@ -110,12 +111,14 @@ class BitrefillInAppWebviewButtonState content: SizedBox( width: width, height: height, - child: InAppWebView( - key: const Key('bitrefill-inappwebview'), - initialUrlRequest: _createUrlRequest(), - initialSettings: settings, - onWebViewCreated: _onCreated, - onConsoleMessage: _onConsoleMessage, + child: ScreenshotSensitive( + child: InAppWebView( + key: const Key('bitrefill-inappwebview'), + initialUrlRequest: _createUrlRequest(), + initialSettings: settings, + onWebViewCreated: _onCreated, + onConsoleMessage: _onConsoleMessage, + ), ), ), actions: [ @@ -143,12 +146,14 @@ class BitrefillInAppWebviewButtonState elevation: 0, ), body: SafeArea( - child: InAppWebView( - key: const Key('bitrefill-inappwebview'), - initialUrlRequest: _createUrlRequest(), - initialSettings: settings, - onWebViewCreated: _onCreated, - onConsoleMessage: _onConsoleMessage, + child: ScreenshotSensitive( + child: InAppWebView( + key: const Key('bitrefill-inappwebview'), + initialUrlRequest: _createUrlRequest(), + initialSettings: settings, + onWebViewCreated: _onCreated, + onConsoleMessage: _onConsoleMessage, + ), ), ), ); diff --git a/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart b/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart index 50d3508173..adb1a8e894 100644 --- a/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart +++ b/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart @@ -5,6 +5,7 @@ import 'package:web_dex/common/screen.dart'; import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/model/hw_wallet/trezor_task.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'trezor_steps/trezor_dialog_select_wallet.dart'; @@ -23,12 +24,14 @@ Future showTrezorPassphraseDialog(TrezorTask task) async { context: context, width: trezorDialogWidth, onDismiss: close, - popupContent: TrezorDialogSelectWallet( - onComplete: (String passphrase) async { - final authBloc = context.read(); - authBloc.add(AuthTrezorPassphraseProvided(passphrase)); - close(); - }, + popupContent: ScreenshotSensitive( + child: TrezorDialogSelectWallet( + onComplete: (String passphrase) async { + final authBloc = context.read(); + authBloc.add(AuthTrezorPassphraseProvided(passphrase)); + close(); + }, + ), ), ); diff --git a/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart b/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart index 58899febe7..0d970e871b 100644 --- a/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart +++ b/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart @@ -6,6 +6,7 @@ import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/model/hw_wallet/trezor_task.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; Future showTrezorPinDialog(TrezorTask task) async { late PopupDispatcher popupManager; @@ -22,16 +23,18 @@ Future showTrezorPinDialog(TrezorTask task) async { context: context, width: trezorDialogWidth, onDismiss: close, - popupContent: TrezorDialogPinPad( - onComplete: (String pin) async { - final authBloc = context.read(); - authBloc.add(AuthTrezorPinProvided(pin)); - close(); - }, - onClose: () { - context.read().add(AuthTrezorCancelled()); - close(); - }, + popupContent: ScreenshotSensitive( + child: TrezorDialogPinPad( + onComplete: (String pin) async { + final authBloc = context.read(); + authBloc.add(AuthTrezorPinProvided(pin)); + close(); + }, + onClose: () { + context.read().add(AuthTrezorCancelled()); + close(); + }, + ), ), ); diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart index 8173cfbe9e..41fb70ae31 100644 --- a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart @@ -6,6 +6,7 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/shared/ui/ui_light_button.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; const List> _keys = [ [7, 8, 9], @@ -44,7 +45,7 @@ class _TrezorDialogPinPadState extends State { @override Widget build(BuildContext context) { - return KeyboardListener( + return ScreenshotSensitive(child: KeyboardListener( autofocus: true, onKeyEvent: _onKeyEvent, focusNode: _focus, @@ -70,7 +71,7 @@ class _TrezorDialogPinPadState extends State { _buildButtons(), ], ), - ); + )); } Widget _buildObscuredPin() { diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart index bb9dabc4c9..dd7a57e3dc 100644 --- a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class TrezorDialogSelectWallet extends StatelessWidget { const TrezorDialogSelectWallet({Key? key, required this.onComplete}) @@ -12,7 +13,7 @@ class TrezorDialogSelectWallet extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + return ScreenshotSensitive(child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( @@ -31,7 +32,7 @@ class TrezorDialogSelectWallet extends StatelessWidget { onSubmit: (String passphrase) => onComplete(passphrase), ), ], - ); + )); } } diff --git a/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart b/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart index c4f99b5a59..5185c3f1ac 100644 --- a/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart +++ b/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart @@ -4,6 +4,7 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/common/wallet_password_dialog/password_dialog_content.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; // Shows wallet password dialog and // returns password value or null (if wrong or cancelled) @@ -24,14 +25,15 @@ Future walletPasswordDialog( popupManager = PopupDispatcher( context: context, - popupContent: PasswordDialogContent( + popupContent: ScreenshotSensitive( + child: PasswordDialogContent( wallet: wallet, onSuccess: (String pass) { password = pass; close(); }, onCancel: close, - ), + )), ); isOpen = true; @@ -72,7 +74,8 @@ Future walletPasswordDialogWithLoading( popupManager = PopupDispatcher( context: context, - popupContent: PasswordDialogContentWithLoading( + popupContent: ScreenshotSensitive( + child: PasswordDialogContentWithLoading( wallet: wallet, onPasswordValidated: onPasswordValidated, onComplete: (bool success) { @@ -84,7 +87,7 @@ Future walletPasswordDialogWithLoading( loadingMessage: loadingMessage, operationFailedMessage: operationFailedMessage, passwordFieldKey: passwordFieldKey, - ), + )), ); isOpen = true; diff --git a/lib/views/fiat/webview_dialog.dart b/lib/views/fiat/webview_dialog.dart index edb1734791..d586ffee6d 100644 --- a/lib/views/fiat/webview_dialog.dart +++ b/lib/views/fiat/webview_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/utils/window/window.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; /// The display mode for the webview dialog. enum WebViewDialogMode { @@ -155,12 +156,14 @@ class InAppWebviewDialog extends StatelessWidget { bottomLeft: Radius.circular(12.0), bottomRight: Radius.circular(12.0), ), - child: MessageInAppWebView( - key: const Key('dialog-inappwebview'), - settings: webviewSettings, - url: url, - onConsoleMessage: onConsoleMessage, - onCloseWindow: onCloseWindow, + child: ScreenshotSensitive( + child: MessageInAppWebView( + key: const Key('dialog-inappwebview'), + settings: webviewSettings, + url: url, + onConsoleMessage: onConsoleMessage, + onCloseWindow: onCloseWindow, + ), ), ), ), @@ -203,12 +206,14 @@ class FullscreenInAppWebview extends StatelessWidget { ), ), body: SafeArea( - child: MessageInAppWebView( - key: const Key('fullscreen-inapp-webview'), - settings: webviewSettings, - url: url, - onConsoleMessage: onConsoleMessage, - onCloseWindow: onCloseWindow, + child: ScreenshotSensitive( + child: MessageInAppWebView( + key: const Key('fullscreen-inapp-webview'), + settings: webviewSettings, + url: url, + onConsoleMessage: onConsoleMessage, + onCloseWindow: onCloseWindow, + ), ), ), ); diff --git a/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart b/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart index afc8cf4ce7..e20df95aa6 100644 --- a/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart +++ b/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart @@ -16,6 +16,7 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart'; import 'package:web_dex/views/wallet/wallet_page/common/expandable_private_key_list.dart'; import 'package:web_dex/views/settings/widgets/security_settings/private_key_settings/private_key_actions_widget.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; /// Widget for displaying private keys in a secure manner. /// @@ -50,7 +51,7 @@ class PrivateKeyShow extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + return ScreenshotSensitive(child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -121,7 +122,7 @@ class PrivateKeyShow extends StatelessWidget { ], ), ], - ); + )); } } diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart index 0eda5a39b4..e3c0c6548f 100644 --- a/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart @@ -13,6 +13,7 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_word_button.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class SeedConfirmation extends StatefulWidget { const SeedConfirmation({required this.seedPhrase}); @@ -39,7 +40,7 @@ class _SeedConfirmationState extends State { @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DexScrollbar( + return ScreenshotSensitive(child: DexScrollbar( isMobile: isMobile, scrollController: scrollController, child: SingleChildScrollView( @@ -112,7 +113,7 @@ class _SeedConfirmationState extends State { ], ), ), - ); + )); } void _onWordPressed(_SeedWord word) { diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart index 2eea29d1c0..74f70b5363 100644 --- a/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart @@ -20,6 +20,7 @@ import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; import 'package:web_dex/shared/widgets/dry_intrinsic.dart'; import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart'; import 'package:web_dex/views/wallet/coin_details/receive/qr_code_address.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class SeedShow extends StatelessWidget { const SeedShow({ @@ -32,7 +33,7 @@ class SeedShow extends StatelessWidget { @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DexScrollbar( + return ScreenshotSensitive(child: DexScrollbar( scrollController: scrollController, child: SingleChildScrollView( controller: scrollController, @@ -89,7 +90,7 @@ class SeedShow extends StatelessWidget { ], ), ), - ); + )); } } diff --git a/lib/views/wallets_manager/widgets/wallet_creation.dart b/lib/views/wallets_manager/widgets/wallet_creation.dart index 11bcb32a93..c34da52e86 100644 --- a/lib/views/wallets_manager/widgets/wallet_creation.dart +++ b/lib/views/wallets_manager/widgets/wallet_creation.dart @@ -13,6 +13,7 @@ import 'package:web_dex/shared/widgets/disclaimer/eula_tos_checkboxes.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletCreation extends StatefulWidget { const WalletCreation({ @@ -91,7 +92,7 @@ class _WalletCreationState extends State { } }, child: AutofillGroup( - child: Form( + child: ScreenshotSensitive(child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, @@ -136,7 +137,7 @@ class _WalletCreationState extends State { ), ], ), - ), + )), ), ); } diff --git a/lib/views/wallets_manager/widgets/wallet_deleting.dart b/lib/views/wallets_manager/widgets/wallet_deleting.dart index fb54d88573..a731f90828 100644 --- a/lib/views/wallets_manager/widgets/wallet_deleting.dart +++ b/lib/views/wallets_manager/widgets/wallet_deleting.dart @@ -9,6 +9,7 @@ import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletDeleting extends StatefulWidget { const WalletDeleting({ @@ -37,7 +38,7 @@ class _WalletDeletingState extends State { @override Widget build(BuildContext context) { - return Form( + return ScreenshotSensitive(child: Form( key: _formKey, child: Column( children: [ @@ -83,7 +84,7 @@ class _WalletDeletingState extends State { ), ], ), - ); + )); } Widget _buildHeader() { diff --git a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart index 30b8a9c902..3ce3077eb3 100644 --- a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart +++ b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart @@ -16,6 +16,7 @@ import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/custom_seed_checkbox.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletFileData { const WalletFileData({required this.content, required this.name}); @@ -93,7 +94,7 @@ class _WalletImportByFileState extends State { @override Widget build(BuildContext context) { - return Column( + return ScreenshotSensitive(child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( @@ -210,7 +211,7 @@ class _WalletImportByFileState extends State { ), ), ], - ); + )); } @override diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart index 9fe8c9488f..79f27f5bf6 100644 --- a/lib/views/wallets_manager/widgets/wallet_login.dart +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -15,6 +15,7 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletLogIn extends StatefulWidget { const WalletLogIn({ @@ -102,7 +103,7 @@ class _WalletLogInState extends State { : state.authError?.message; return AutofillGroup( - child: Column( + child: ScreenshotSensitive(child: Column( mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, children: [ Text( @@ -165,7 +166,7 @@ class _WalletLogInState extends State { text: LocaleKeys.cancel.tr(), ), ], - ), + )), ); }, ); diff --git a/lib/views/wallets_manager/widgets/wallet_simple_import.dart b/lib/views/wallets_manager/widgets/wallet_simple_import.dart index f6542c99ce..79e9388b8b 100644 --- a/lib/views/wallets_manager/widgets/wallet_simple_import.dart +++ b/lib/views/wallets_manager/widgets/wallet_simple_import.dart @@ -19,6 +19,7 @@ import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.dart'; import 'package:web_dex/views/wallets_manager/widgets/custom_seed_checkbox.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletSimpleImport extends StatefulWidget { const WalletSimpleImport({ @@ -95,7 +96,7 @@ class _WalletImportWrapperState extends State { } }, child: AutofillGroup( - child: Column( + child: ScreenshotSensitive(child: Column( mainAxisSize: MainAxisSize.min, children: [ SelectableText( @@ -140,7 +141,7 @@ class _WalletImportWrapperState extends State { ), ), ], - ), + )), ), ); }