diff --git a/assets/translations/en.json b/assets/translations/en.json index 80c2e8a79e..ca44d18b41 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -764,5 +764,36 @@ "fetchingPrivateKeysTitle": "Fetching Private Keys...", "fetchingPrivateKeysMessage": "Please wait while we securely fetch your private keys...", "pubkeyType": "Type", - "securitySettings": "Security Settings" -} \ No newline at end of file + "securitySettings": "Security Settings", + "sendToAddress": "Only send {} to this address", + "message": "Message", + "signMessage": "Sign Message", + "selectedAddress": "Selected Address", + "selectAddress": "Select Address", + "messageToSign": "Message to Sign", + "enterMessage": "Enter message", + "signMessageButton": "Sign Message", + "signedMessage": "Signed Message", + "pleaseSelectAddress": "Please select an address first", + "pleaseEnterMessage": "Please enter a message to sign", + "failedToSignMessage": "Failed to sign message: {}", + "swapCoin": "Swap", + "komodoWalletSeed": "Komodo Wallet seed", + "failedToLoadAddresses": "Failed to load addresses: {}", + "sendFeedbackButton": "Share your feedback", + "allowCustomFee": "Allow custom seed", + "copyToClipboard": "Copy to clipboard", + "copyAllDetails": "Copy all details", + "zeroBalanceTooltip": "Insufficient balance to use Bitrefill", + "swapAddress": "Swap Address", + "userNotFoundError": "User not found", + "loginFailedError": "Login failed", + "previewWithdrawal": "Preview Withdrawal", + "chart": "Chart", + "confirmMessageSigning": "Confirm Message Signing", + "messageSigningWarning": "Only sign messages from trusted sources.", + "messageSigningCheckboxText": "I understand that signing proves ownership of this address.", + "messageSigned": "Message signed", + "addressLabel": "Address - ", + "signingAddress": "Signing address" +} diff --git a/docs/BLOC_NAMING_CONVENTIONS.md b/docs/BLOC_NAMING_CONVENTIONS.md new file mode 100644 index 0000000000..e6443a9c42 --- /dev/null +++ b/docs/BLOC_NAMING_CONVENTIONS.md @@ -0,0 +1,82 @@ +--- +title: Naming Conventions +description: Overview of the recommended naming conventions when using bloc. +--- + +import EventExamplesGood1 from '~/components/naming-conventions/EventExamplesGood1Snippet.astro'; +import EventExamplesBad1 from '~/components/naming-conventions/EventExamplesBad1Snippet.astro'; +import StateExamplesGood1Snippet from '~/components/naming-conventions/StateExamplesGood1Snippet.astro'; +import SingleStateExamplesGood1Snippet from '~/components/naming-conventions/SingleStateExamplesGood1Snippet.astro'; +import StateExamplesBad1Snippet from '~/components/naming-conventions/StateExamplesBad1Snippet.astro'; + +The following naming conventions are simply recommendations and are completely optional. Feel free to use whatever naming conventions you prefer. You may find some of the examples/documentation do not follow the naming conventions mainly for simplicity/conciseness. These conventions are strongly recommended for large projects with multiple developers. + +## Event Conventions + +Events should be named in the **past tense** because events are things that have already occurred from the bloc's perspective. + +### Anatomy + +`BlocSubject` + `Noun (optional)` + `Verb (event)` + +Initial load events should follow the convention: `BlocSubject` + `Started` + +:::note +The base event class should be name: `BlocSubject` + `Event`. +::: + +### Examples + +✅ **Good** + + + +❌ **Bad** + + + +## State Conventions + +States should be nouns because a state is just a snapshot at a particular point in time. There are two common ways to represent state: using subclasses or using a single class. + +### Anatomy + +#### Subclasses + +`BlocSubject` + `Verb (action)` + `State` + +When representing the state as multiple subclasses `State` should be one of the following: + +`Initial` | `Success` | `Failure` | `InProgress` + +:::note +Initial states should follow the convention: `BlocSubject` + `Initial`. +::: + +#### Single Class + +`BlocSubject` + `State` + +When representing the state as a single base class an enum named `BlocSubject` + `Status` should be used to represent the status of the state: + +`initial` | `success` | `failure` | `loading`. + +:::note +The base state class should always be named: `BlocSubject` + `State`. +::: + +### Examples + +✅ **Good** + +##### Subclasses + + + +##### Single Class + + + +❌ **Bad** + + diff --git a/lib/bloc/message_signing/message_signing_bloc.dart b/lib/bloc/message_signing/message_signing_bloc.dart new file mode 100644 index 0000000000..306dba711c --- /dev/null +++ b/lib/bloc/message_signing/message_signing_bloc.dart @@ -0,0 +1,101 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_event.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_state.dart'; + +class MessageSigningBloc + extends Bloc { + final KomodoDefiSdk sdk; + + MessageSigningBloc(this.sdk) : super(MessageSigningState.initial()) { + on(_onLoadAddresses); + on(_onSelectAddress); + on(_onSubmitMessage); + on(_onRequestConfirmation); + on(_onCancelConfirmation); + } + + Future _onLoadAddresses( + MessageSigningAddressesRequested event, + Emitter emit, + ) async { + emit(state.copyWith( + status: MessageSigningStatus.loading, errorMessage: null)); + + try { + final result = await sdk.pubkeys.getPubkeys(event.asset); + final keys = result.keys; + + emit(state.copyWith( + addresses: keys, + selected: keys.isNotEmpty ? keys.first : null, + status: MessageSigningStatus.ready, + )); + } catch (e) { + emit(state.copyWith( + status: MessageSigningStatus.failure, + errorMessage: e.toString(), + )); + } + } + + void _onSelectAddress( + MessageSigningAddressSelected event, + Emitter emit, + ) { + emit(state.copyWith(selected: event.address)); + } + + void _onRequestConfirmation( + MessageSigningInputConfirmed event, + Emitter emit, + ) { + emit(state.copyWith(status: MessageSigningStatus.confirming)); + } + + void _onCancelConfirmation( + MessageSigningConfirmationCancelled event, + Emitter emit, + ) { + emit(state.copyWith(status: MessageSigningStatus.ready)); + } + + Future _onSubmitMessage( + MessageSigningFormSubmitted event, + Emitter emit, + ) async { + final address = state.selected; + if (address == null) { + emit(state.copyWith( + errorMessage: LocaleKeys.pleaseSelectAddress.tr(), + status: MessageSigningStatus.failure, + )); + return; + } + + emit(state.copyWith( + status: MessageSigningStatus.submitting, + errorMessage: null, + )); + + try { + final signed = await sdk.messageSigning.signMessage( + coin: event.coinAbbr, + address: address.address, + message: event.message, + ); + + emit(state.copyWith( + signedMessage: signed, + status: MessageSigningStatus.success, + )); + } catch (e) { + emit(state.copyWith( + errorMessage: LocaleKeys.failedToSignMessage.tr(args: [e.toString()]), + status: MessageSigningStatus.failure, + )); + } + } +} diff --git a/lib/bloc/message_signing/message_signing_event.dart b/lib/bloc/message_signing/message_signing_event.dart new file mode 100644 index 0000000000..475f07cfb4 --- /dev/null +++ b/lib/bloc/message_signing/message_signing_event.dart @@ -0,0 +1,28 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +sealed class MessageSigningEvent {} + +class MessageSigningAddressesRequested extends MessageSigningEvent { + final Asset asset; + + MessageSigningAddressesRequested(this.asset); +} + +class MessageSigningAddressSelected extends MessageSigningEvent { + final PubkeyInfo address; + + MessageSigningAddressSelected(this.address); +} + +class MessageSigningFormSubmitted extends MessageSigningEvent { + final String message; + final String coinAbbr; + + MessageSigningFormSubmitted({ + required this.message, + required this.coinAbbr, + }); +} + +class MessageSigningInputConfirmed extends MessageSigningEvent {} +class MessageSigningConfirmationCancelled extends MessageSigningEvent {} \ No newline at end of file diff --git a/lib/bloc/message_signing/message_signing_state.dart b/lib/bloc/message_signing/message_signing_state.dart new file mode 100644 index 0000000000..3ef8c68cca --- /dev/null +++ b/lib/bloc/message_signing/message_signing_state.dart @@ -0,0 +1,61 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +enum MessageSigningStatus { + initial, + loading, + ready, + confirming, + submitting, + success, + failure, +} + +class MessageSigningState extends Equatable { + final List addresses; + final PubkeyInfo? selected; + final String? signedMessage; + final String? errorMessage; + final MessageSigningStatus status; + + const MessageSigningState({ + required this.addresses, + required this.selected, + required this.signedMessage, + required this.errorMessage, + required this.status, + }); + + factory MessageSigningState.initial() => const MessageSigningState( + addresses: [], + selected: null, + signedMessage: null, + errorMessage: null, + status: MessageSigningStatus.initial, + ); + + MessageSigningState copyWith({ + List? addresses, + PubkeyInfo? selected, + String? signedMessage, + String? errorMessage, + MessageSigningStatus? status, + }) { + return MessageSigningState( + addresses: addresses ?? this.addresses, + selected: selected ?? this.selected, + signedMessage: signedMessage ?? this.signedMessage, + errorMessage: errorMessage ?? this.errorMessage, + status: status ?? this.status, + ); + } + + @override + List get props => [ + addresses, + selected, + signedMessage, + errorMessage, + status, + ]; +} \ No newline at end of file diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 08380eabd1..7ce9bfd2bf 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -1,5 +1,4 @@ // DO NOT EDIT. This is code generated via package:easy_localization/generate.dart - // ignore_for_file: constant_identifier_names abstract class LocaleKeys { @@ -106,25 +105,20 @@ 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'; @@ -143,19 +137,15 @@ 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'; @@ -165,19 +155,16 @@ 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'; @@ -185,54 +172,41 @@ 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'; @@ -308,8 +282,7 @@ 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'; @@ -341,8 +314,7 @@ abstract class LocaleKeys { static const feedbackFormDiscord = 'feedbackFormDiscord'; static const feedbackFormMatrix = 'feedbackFormMatrix'; static const feedbackFormTelegram = 'feedbackFormTelegram'; - static const feedbackFormSelectContactMethod = - 'feedbackFormSelectContactMethod'; + static const feedbackFormSelectContactMethod = 'feedbackFormSelectContactMethod'; static const feedbackFormDiscordHint = 'feedbackFormDiscordHint'; static const feedbackFormMatrixHint = 'feedbackFormMatrixHint'; static const feedbackFormTelegramHint = 'feedbackFormTelegramHint'; @@ -354,8 +326,7 @@ abstract class LocaleKeys { 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'; @@ -365,8 +336,7 @@ 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'; @@ -406,8 +376,7 @@ 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'; @@ -417,8 +386,7 @@ 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'; @@ -443,14 +411,12 @@ 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'; @@ -479,16 +445,13 @@ 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'; @@ -560,10 +523,8 @@ 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'; @@ -590,15 +551,13 @@ 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'; @@ -646,10 +605,8 @@ 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'; @@ -663,10 +620,8 @@ 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'; @@ -711,8 +666,7 @@ 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'; @@ -800,4 +754,35 @@ abstract class LocaleKeys { static const fetchingPrivateKeysMessage = 'fetchingPrivateKeysMessage'; static const pubkeyType = 'pubkeyType'; static const securitySettings = 'securitySettings'; + static const sendToAddress = 'sendToAddress'; + static const message = 'message'; + static const signMessage = 'signMessage'; + static const selectedAddress = 'selectedAddress'; + static const selectAddress = 'selectAddress'; + static const messageToSign = 'messageToSign'; + static const enterMessage = 'enterMessage'; + static const signMessageButton = 'signMessageButton'; + static const signedMessage = 'signedMessage'; + static const pleaseSelectAddress = 'pleaseSelectAddress'; + static const pleaseEnterMessage = 'pleaseEnterMessage'; + static const failedToSignMessage = 'failedToSignMessage'; + static const swapCoin = 'swapCoin'; + static const komodoWalletSeed = 'komodoWalletSeed'; + static const failedToLoadAddresses = 'failedToLoadAddresses'; + static const sendFeedbackButton = 'sendFeedbackButton'; + static const allowCustomFee = 'allowCustomFee'; + static const copyToClipboard = 'copyToClipboard'; + static const copyAllDetails = 'copyAllDetails'; + static const zeroBalanceTooltip = 'zeroBalanceTooltip'; + static const swapAddress = 'swapAddress'; + static const userNotFoundError = 'userNotFoundError'; + static const loginFailedError = 'loginFailedError'; + static const previewWithdrawal = 'previewWithdrawal'; + static const chart = 'chart'; + static const confirmMessageSigning = 'confirmMessageSigning'; + static const messageSigningWarning = 'messageSigningWarning'; + static const messageSigningCheckboxText = 'messageSigningCheckboxText'; + static const messageSigned = 'messageSigned'; + static const addressLabel = 'addressLabel'; + static const signingAddress = 'signingAddress'; } diff --git a/lib/shared/utils/coin_filter_optimizer.dart b/lib/shared/utils/coin_filter_optimizer.dart new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index c25a668ff0..1393a37cec 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -356,12 +356,19 @@ String abbr2Ticker(String abbr) { 'IBC_NUCLEUSTEST', ]; - // Join the suffixes with '|' to form the regex pattern - final String regexPattern = '(${filteredSuffixes.join('|')})'; - - final String ticker = abbr - .replaceAll(RegExp('-$regexPattern'), '') - .replaceAll(RegExp('_$regexPattern'), ''); + const List filteredPrefixes = ['NFT']; + + // Create regex patterns for both suffixes and prefixes + String suffixPattern = '(${filteredSuffixes.join('|')})'; + String prefixPattern = '(${filteredPrefixes.join('|')})'; + + String ticker = abbr + // Remove suffixes + .replaceAll(RegExp('-$suffixPattern'), '') + .replaceAll(RegExp('_$suffixPattern'), '') + // Remove prefixes + .replaceAll(RegExp('^$prefixPattern-'), '') + .replaceAll(RegExp('^${prefixPattern}_'), ''); _abbr2TickerCache[abbr] = ticker; return ticker; diff --git a/lib/views/bitrefill/bitrefill_button.dart b/lib/views/bitrefill/bitrefill_button.dart index 36a631f481..e83f7c8e78 100644 --- a/lib/views/bitrefill/bitrefill_button.dart +++ b/lib/views/bitrefill/bitrefill_button.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -13,6 +14,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/bitrefill/bitrefill_inappwebview_button.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:get_it/get_it.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; /// A button that opens the Bitrefill widget in a new window or tab. /// The Bitrefill widget is a web page that allows the user to purchase gift @@ -50,9 +52,9 @@ class _BitrefillButtonState extends State { @override void initState() { - context - .read() - .add(BitrefillLoadRequested(coin: widget.coin)); + context.read().add( + BitrefillLoadRequested(coin: widget.coin), + ); super.initState(); } @@ -92,11 +94,17 @@ class _BitrefillButtonState extends State { return Column( children: [ BitrefillInAppWebviewButton( + key: Key( + 'coin-details-bitrefill-button-${widget.coin.abbr.toLowerCase()}', + ), windowTitle: widget.windowTitle, url: url, enabled: isEnabled, tooltip: _getTooltipMessage( - hasNonZeroBalance, isEnabled, isCoinSupported), + hasNonZeroBalance, + isEnabled, + isCoinSupported, + ), onMessage: handleMessage, onPressed: () async => _handleButtonPress(context, hasNonZeroBalance), @@ -109,7 +117,10 @@ class _BitrefillButtonState extends State { /// Gets the appropriate tooltip message based on balance and coin status String? _getTooltipMessage( - bool hasNonZeroBalance, bool isEnabled, bool isCoinSupported) { + bool hasNonZeroBalance, + bool isEnabled, + bool isCoinSupported, + ) { if (widget.tooltip != null) { return widget.tooltip; } @@ -125,7 +136,7 @@ class _BitrefillButtonState extends State { } if (!hasNonZeroBalance) { - return 'No ${widget.coin.abbr} balance available for spending'; + return LocaleKeys.zeroBalanceTooltip.tr(); } } @@ -160,11 +171,11 @@ class _BitrefillButtonState extends State { // Reload Bitrefill with new address context.read().add( - BitrefillLoadRequested( - coin: widget.coin, - refundAddress: _selectedRefundAddress, - ), - ); + BitrefillLoadRequested( + coin: widget.coin, + refundAddress: _selectedRefundAddress, + ), + ); } } // If single address or no address selection needed, the button will work with existing URL @@ -182,9 +193,9 @@ class _BitrefillButtonState extends State { final BitrefillWidgetEvent bitrefillEvent = BitrefillEventFactory.createEvent(decodedEvent); if (bitrefillEvent is BitrefillPaymentIntentEvent) { - context - .read() - .add(BitrefillPaymentIntentReceived(bitrefillEvent)); + context.read().add( + BitrefillPaymentIntentReceived(bitrefillEvent), + ); } } } diff --git a/lib/views/common/page_header/page_header.dart b/lib/views/common/page_header/page_header.dart index 8b41f3c5f5..4565a48384 100644 --- a/lib/views/common/page_header/page_header.dart +++ b/lib/views/common/page_header/page_header.dart @@ -84,7 +84,7 @@ class _MobileHeader extends StatelessWidget { actions: [ if (actions != null) ...actions!, if (context.watch().state.mode != AuthorizeMode.logIn) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 20.0), child: ConnectWalletButton( eventType: WalletsManagerEventType.header, diff --git a/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart b/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart index 1958deb110..adec86757a 100644 --- a/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart +++ b/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart @@ -1,12 +1,15 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart' as mm2; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table_item.dart'; @@ -100,11 +103,21 @@ class GroupedListView extends StatelessWidget { Coin _createHeaderCoinData(BuildContext context, List list) { final firstCoin = getCoin(context, list.first); + final sdk = GetIt.I(); + + final totalBalance = list.fold(BalanceInfo.zero(), ( + sum, + item, + ) { + final coin = getCoin(context, item); + final coinBalance = sdk.balances.lastKnown(coin.id) ?? BalanceInfo.zero(); + return sum + coinBalance; + }); final coin = firstCoin.dummyCopyWithoutProtocolData(); // Since we can't use 'balance' property directly anymore, we need to // construct the coin without using the balance property - return coin; + return coin.copyWith(sendableBalance: totalBalance.spendable.toDouble()); } Map> _groupList(BuildContext context, List list) { @@ -123,7 +136,7 @@ class GroupedListView extends StatelessWidget { } else if (item is SelectItem) { return (coinsState.walletCoins[item.id] ?? coinsState.coins[item.id])!; } else { - final String coinId = (item as BestOrder).coin; + final String coinId = (item as mm2.BestOrder).coin; return (coinsState.walletCoins[coinId] ?? coinsState.coins[coinId])!; } } diff --git a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart index a1d321403f..7fb2c20a7d 100644 --- a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart +++ b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart @@ -55,6 +55,7 @@ class OrdersTableContent extends StatelessWidget { if (orders.isEmpty) return const NothingFound(); return GroupedListView( + key: const Key('orders_table'), items: orders, onSelect: onSelect, maxHeight: maxHeight, diff --git a/lib/views/nfts/common/widgets/nft_connect_wallet.dart b/lib/views/nfts/common/widgets/nft_connect_wallet.dart index ca447e4d10..4854a60dac 100644 --- a/lib/views/nfts/common/widgets/nft_connect_wallet.dart +++ b/lib/views/nfts/common/widgets/nft_connect_wallet.dart @@ -18,7 +18,7 @@ class NftConnectWallet extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 210), child: NftNoLogin(text: LocaleKeys.nftMainLoggedOut.tr())), if (isMobile) - const Padding( + Padding( padding: EdgeInsets.only(top: 16), child: ConnectWalletButton( eventType: WalletsManagerEventType.nft, diff --git a/lib/views/wallet/coin_details/coin_details.dart b/lib/views/wallet/coin_details/coin_details.dart index 54193eb8f2..20a8393e63 100644 --- a/lib/views/wallet/coin_details/coin_details.dart +++ b/lib/views/wallet/coin_details/coin_details.dart @@ -11,6 +11,7 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; +import 'package:web_dex/views/wallet/coin_details/message_signing/message_signing_screen.dart'; import 'package:web_dex/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart'; import 'package:web_dex/views/wallet/coin_details/rewards/kmd_rewards_info.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/withdraw_form.dart'; @@ -103,6 +104,12 @@ class _CoinDetailsState extends State { formattedUsd: _formattedUsdPrice, onBackButtonPressed: _openInfo, ); + + case CoinPageType.signMessage: + return MessageSigningScreen( + coin: widget.coin, + onBackButtonPressed: _openInfo, + ); } } diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart index afc00c0a3a..a1229a32d5 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart @@ -116,7 +116,16 @@ class CoinDetailsCommonButtonsMobileLayout extends StatelessWidget { ), const SizedBox(width: 12), ], - if (!coin.walletOnly) + Flexible( + child: CoinDetailsMessageSigningButton( + isMobile: isMobile, + coin: coin, + selectWidget: selectWidget, + context: context, + ), + ), + if (!coin.walletOnly) ...[ + const SizedBox(width: 12), Flexible( child: CoinDetailsSwapButton( isMobile: isMobile, @@ -125,6 +134,7 @@ class CoinDetailsCommonButtonsMobileLayout extends StatelessWidget { context: context, ), ), + ], ], ), ], @@ -171,6 +181,16 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { context: context, ), ), + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: CoinDetailsMessageSigningButton( + isMobile: isMobile, + coin: coin, + selectWidget: selectWidget, + context: context, + ), + ), if (!coin.walletOnly && context.watch().state is TradingEnabled) Container( @@ -418,6 +438,53 @@ class CoinDetailsSwapButton extends StatelessWidget { } } +class CoinDetailsMessageSigningButton extends StatelessWidget { + const CoinDetailsMessageSigningButton({ + required this.isMobile, + required this.coin, + required this.selectWidget, + required this.context, + super.key, + }); + + final bool isMobile; + final Coin coin; + final void Function(CoinPageType) selectWidget; + final BuildContext context; + + @override + Widget build(BuildContext context) { + final hasAddresses = context + .watch() + .state + .addresses + .isNotEmpty; + final ThemeData themeData = Theme.of(context); + + return UiPrimaryButton( + key: const Key('coin-details-sign-message-button'), + height: isMobile ? 52 : 40, + prefix: Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon( + Icons.fingerprint, + size: 18, + color: themeData.colorScheme.onTertiary, + ), + ), + textStyle: themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: coin.isSuspended || !hasAddresses + ? null + : () => selectWidget(CoinPageType.signMessage), + text: LocaleKeys.signMessage.tr(), + ); + } +} + /// Gets the appropriate tooltip message for the Bitrefill button String? _getBitrefillTooltip(Coin coin) { if (!coin.isActive) { diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart index 58fd533149..f5f07e0cdf 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart @@ -258,8 +258,9 @@ class _DesktopCoinDetails extends StatelessWidget { CoinDetailsInfoFiat(coin: coin, isMobile: false), ], ), - Padding( + Container( padding: const EdgeInsets.fromLTRB(2, 28.0, 0, 0), + width: double.infinity, child: CoinDetailsCommonButtons( isMobile: false, selectWidget: setPageType, diff --git a/lib/views/wallet/coin_details/coin_page_type.dart b/lib/views/wallet/coin_details/coin_page_type.dart index 3dde59b6b5..f26db5078c 100644 --- a/lib/views/wallet/coin_details/coin_page_type.dart +++ b/lib/views/wallet/coin_details/coin_page_type.dart @@ -1 +1 @@ -enum CoinPageType { send, claim, info, claimSuccess } +enum CoinPageType { send, claim, info, claimSuccess, signMessage } diff --git a/lib/views/wallet/coin_details/message_signing/Widgets/message_signed_result.dart b/lib/views/wallet/coin_details/message_signing/Widgets/message_signed_result.dart new file mode 100644 index 0000000000..2e820d2f70 --- /dev/null +++ b/lib/views/wallet/coin_details/message_signing/Widgets/message_signed_result.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class MessageSignedResult extends StatelessWidget { + final ThemeData theme; + final PubkeyInfo selected; + final String message; + final String signedMessage; + + const MessageSignedResult({ + super.key, + required this.theme, + required this.selected, + required this.message, + required this.signedMessage, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: theme.colorScheme.primary.withOpacity(0.2), + ), + ), + color: theme.colorScheme.surface.withOpacity(0.95), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.messageSigned.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Container( + margin: const EdgeInsets.symmetric(vertical: 15.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + border: Border.all(color: theme.colorScheme.primary, width: 4), + ), + child: Icon( + Icons.check_rounded, + size: 66, + color: theme.colorScheme.primary, + ), + ), + Center( + child: Text( + LocaleKeys.addressLabel.tr(args: [selected.address]), + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: theme.textTheme.bodyMedium?.color?.withOpacity(0.6), + ), + ), + ), + const SizedBox(height: 24), + _buildStyledSection(context, + content: message, + icon: Icons.chat_bubble_outline, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(12)), + showTopBorder: true), + _buildStyledSection( + context, + content: signedMessage, + icon: Icons.vpn_key_outlined, + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(12)), + showTopBorder: false, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: UiSecondaryButton( + text: 'Share', + onPressed: () { + Clipboard.setData(ClipboardData(text: signedMessage)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: UiPrimaryButton( + text: 'Copy', + onPressed: () { + // TODO: Add share logic + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStyledSection( + BuildContext context, { + required String content, + required IconData icon, + required BorderRadius borderRadius, + bool showTopBorder = true, + }) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: theme.colorScheme.surface.withOpacity(0.7), + border: Border( + top: showTopBorder + ? BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)) + : BorderSide.none, + bottom: BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)), + left: BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)), + right: BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)), + ), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.shadow.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + content, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_confirmation.dart b/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_confirmation.dart new file mode 100644 index 0000000000..9713ac3525 --- /dev/null +++ b/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_confirmation.dart @@ -0,0 +1,161 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_bloc.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_event.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class MessageSigningConfirmationCard extends StatelessWidget { + final ThemeData theme; + final String message; + final String coinAbbr; + final bool understood; + final VoidCallback onCancel; + final ValueChanged onUnderstoodChanged; + + const MessageSigningConfirmationCard({ + super.key, + required this.theme, + required this.message, + required this.coinAbbr, + required this.understood, + required this.onCancel, + required this.onUnderstoodChanged, + }); + + @override + Widget build(BuildContext context) { + final selected = context.read().state.selected; + + return Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: theme.colorScheme.primary.withOpacity(0.2), + ), + ), + color: theme.colorScheme.surface.withOpacity(0.95), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.confirmMessageSigning.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + _buildStyledSection(context, + content: selected?.address ?? '', + icon: Icons.account_balance_wallet, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(12)), + showTopBorder: true), + _buildStyledSection(context, + content: message, + icon: Icons.chat_bubble_outline, + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(12)), + showTopBorder: false), + const SizedBox(height: 24), + Text( + LocaleKeys.messageSigningWarning.tr(), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + UiCheckbox( + value: understood, + onChanged: (val) => onUnderstoodChanged(val), + text: LocaleKeys.messageSigningCheckboxText.tr(), + textColor: theme.textTheme.bodyMedium?.color, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: UiSecondaryButton( + text: LocaleKeys.cancel.tr(), + onPressed: onCancel, + ), + ), + const SizedBox(width: 12), + Expanded( + child: UiPrimaryButton( + text: LocaleKeys.confirm.tr(), + onPressed: understood + ? () { + context.read().add( + MessageSigningFormSubmitted( + message: message, + coinAbbr: coinAbbr, + ), + ); + onCancel(); + } + : null, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStyledSection( + BuildContext context, { + required String content, + required IconData icon, + required BorderRadius borderRadius, + bool showTopBorder = true, + }) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: theme.colorScheme.surface.withOpacity(0.7), + border: Border( + top: showTopBorder + ? BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)) + : BorderSide.none, + bottom: BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)), + left: BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)), + right: BorderSide(color: theme.colorScheme.outline.withOpacity(0.2)), + ), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.shadow.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: SelectableText( + content, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_form.dart b/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_form.dart new file mode 100644 index 0000000000..21bdf12aff --- /dev/null +++ b/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_form.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui/komodo_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_bloc.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_event.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_state.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class MessageSigningForm extends StatelessWidget { + final MessageSigningState state; + final ThemeData theme; + final Coin coin; + final Asset asset; + final TextEditingController messageController; + final VoidCallback onSignPressed; + + const MessageSigningForm({ + super.key, + required this.state, + required this.theme, + required this.coin, + required this.asset, + required this.messageController, + required this.onSignPressed, + }); + + @override + Widget build(BuildContext context) { + final isSelectEnabled = state.addresses.length > 1; + final isSubmitting = state.status == MessageSigningStatus.submitting; + final hasError = state.status == MessageSigningStatus.failure && + state.errorMessage != null; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: theme.colorScheme.primary.withOpacity(0.2), + ), + ), + color: theme.colorScheme.surface.withOpacity(0.95), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.signingAddress.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + if (hasError) + ErrorMessageWidget(errorMessage: state.errorMessage!) + else if (state.addresses.isEmpty) + const SizedBox() + else + Builder( + builder: (context) { + final selected = state.selected ?? state.addresses.first; + return AddressSelectInput( + addresses: state.addresses, + selectedAddress: selected, + onAddressSelected: isSelectEnabled + ? (address) { + if (address != null) { + context.read().add( + MessageSigningAddressSelected(address), + ); + } + } + : null, + assetName: asset.id.name, + ); + }, + ), + const SizedBox(height: 20), + Text( + LocaleKeys.messageToSign.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: EnhancedMessageInput( + controller: messageController, + hintText: LocaleKeys.enterMessage.tr(), + showCopyButton: true, + onCopyPressed: () async { + final data = await Clipboard.getData('text/plain'); + if (data?.text != null) { + messageController.text = data!.text!; + } + }, + trailingIcon: UiPrimaryButton.flexible( + onPressed: isSubmitting + ? null + : () async { + final result = + await QrCodeReaderOverlay.show(context); + if (result != null) { + messageController.text = result; + } + }, + child: const Icon(Icons.qr_code_scanner, size: 16), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: UiPrimaryButton.flexible( + text: LocaleKeys.signMessageButton.tr(), + onPressed: isSubmitting ? null : onSignPressed, + padding: + const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + ), + ), + ], + ), + ), + ); + } +} + +class ErrorMessageWidget extends StatelessWidget { + final String errorMessage; + + const ErrorMessageWidget({ + super.key, + required this.errorMessage, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + errorMessage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ); + } +} + +class EnhancedMessageInput extends StatefulWidget { + final TextEditingController controller; + final String hintText; + final bool showCopyButton; + final VoidCallback? onCopyPressed; + final Widget? trailingIcon; + + const EnhancedMessageInput({ + required this.controller, + required this.hintText, + this.showCopyButton = false, + this.onCopyPressed, + this.trailingIcon, + super.key, + }); + + @override + State createState() => _EnhancedMessageInputState(); +} + +class _EnhancedMessageInputState extends State { + late int charCount = widget.controller.text.length; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_updateCharCount); + } + + void _updateCharCount() => + setState(() => charCount = widget.controller.text.length); + + @override + void dispose() { + widget.controller.removeListener(_updateCharCount); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.shadow.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: TextField( + controller: widget.controller, + decoration: InputDecoration( + hintText: widget.hintText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + contentPadding: const EdgeInsets.all(16), + fillColor: theme.colorScheme.surfaceContainerHighest + .withOpacity(0.3), + filled: true, + ), + style: theme.textTheme.bodyMedium?.copyWith( + letterSpacing: 0.5, + height: 1.5, + ), + maxLines: 4, + cursorColor: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (widget.trailingIcon != null) widget.trailingIcon!, + if (widget.showCopyButton) + UiPrimaryButton.flexible( + padding: const EdgeInsets.all(8), + onPressed: widget.onCopyPressed, + child: const Icon(Icons.content_paste, size: 16), + ), + ], + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8, right: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '$charCount characters', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_header.dart b/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_header.dart new file mode 100644 index 0000000000..3406c8e17b --- /dev/null +++ b/lib/views/wallet/coin_details/message_signing/Widgets/message_signing_header.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; + +class MessageSigningHeader extends StatelessWidget { + final VoidCallback? onBackButtonPressed; + final String title; + + const MessageSigningHeader({ + super.key, + required this.title, + this.onBackButtonPressed, + }); + + @override + Widget build(BuildContext context) { + return PageHeader( + title: title, + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: onBackButtonPressed, + ); + } +} diff --git a/lib/views/wallet/coin_details/message_signing/message_signing_screen.dart b/lib/views/wallet/coin_details/message_signing/message_signing_screen.dart new file mode 100644 index 0000000000..5c9d873683 --- /dev/null +++ b/lib/views/wallet/coin_details/message_signing/message_signing_screen.dart @@ -0,0 +1,192 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_event.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_state.dart'; +import 'package:web_dex/bloc/message_signing/message_signing_bloc.dart'; +import 'package:web_dex/views/wallet/coin_details/message_signing/Widgets/message_signing_confirmation.dart'; +import 'package:web_dex/views/wallet/coin_details/message_signing/Widgets/message_signing_header.dart'; +import 'package:web_dex/views/wallet/coin_details/message_signing/Widgets/message_signing_form.dart'; +import 'package:web_dex/views/wallet/coin_details/message_signing/Widgets/message_signed_result.dart'; + +class MessageSigningScreen extends StatefulWidget { + final Coin coin; + final VoidCallback? onBackButtonPressed; + + const MessageSigningScreen({ + super.key, + required this.coin, + this.onBackButtonPressed, + }); + + @override + State createState() => _MessageSigningScreenState(); +} + +class _MessageSigningScreenState extends State { + late final Asset asset; + + @override + void initState() { + super.initState(); + final sdk = context.read(); + asset = widget.coin.toSdkAsset(sdk); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MessageSigningBloc(context.read()) + ..add(MessageSigningAddressesRequested(asset)), + child: _MessageSigningScreenContent( + coin: widget.coin, + asset: asset, + onBackButtonPressed: widget.onBackButtonPressed, + ), + ); + } +} + +class _MessageSigningScreenContent extends StatefulWidget { + final Coin coin; + final Asset asset; + final VoidCallback? onBackButtonPressed; + + const _MessageSigningScreenContent({ + required this.coin, + required this.asset, + this.onBackButtonPressed, + }); + + @override + State<_MessageSigningScreenContent> createState() => + _MessageSigningScreenContentState(); +} + +class _MessageSigningScreenContentState + extends State<_MessageSigningScreenContent> { + final TextEditingController messageController = TextEditingController(); + bool showConfirmation = false; + bool understood = false; + + @override + void initState() { + super.initState(); + } + + void _handleSignMessage(BuildContext context) { + final message = messageController.text.trim(); + final selected = context.read().state.selected; + + if (selected == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.pleaseSelectAddress.tr())), + ); + return; + } + + if (message.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.pleaseEnterMessage.tr())), + ); + return; + } + + setState(() { + showConfirmation = true; + understood = false; + }); + } + + @override + void dispose() { + messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + children: [ + MessageSigningHeader( + title: LocaleKeys.signMessage.tr(), + onBackButtonPressed: widget.onBackButtonPressed, + ), + Expanded( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.scaffoldBackgroundColor, + theme.scaffoldBackgroundColor.withOpacity(0.95), + ], + ), + ), + child: BlocBuilder( + builder: (context, state) { + final theme = Theme.of(context); + Widget content; + + if (state.signedMessage != null) { + final selected = state.selected ?? + (state.addresses.isNotEmpty ? state.addresses.first : null); + + content = selected != null + ? MessageSignedResult( + theme: theme, + selected: selected, + message: messageController.text, + signedMessage: state.signedMessage!, + ) + : const SizedBox(); + } else if (showConfirmation) { + content = MessageSigningConfirmationCard( + theme: theme, + message: messageController.text.trim(), + coinAbbr: widget.coin.abbr, + understood: understood, + onCancel: () { + setState(() { + showConfirmation = false; + understood = false; + }); + }, + onUnderstoodChanged: (val) { + setState(() => understood = val); + }, + ); + } else { + content = MessageSigningForm( + state: state, + theme: theme, + coin: widget.coin, + asset: widget.asset, + messageController: messageController, + onSignPressed: () => _handleSignMessage(context), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + content, + ], + ), + ); + }), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/views/wallet/coins_manager/coins_manager_switch_button.dart b/lib/views/wallet/coins_manager/coins_manager_switch_button.dart index 7fecd3e406..b56090f2ce 100644 --- a/lib/views/wallet/coins_manager/coins_manager_switch_button.dart +++ b/lib/views/wallet/coins_manager/coins_manager_switch_button.dart @@ -15,7 +15,7 @@ class CoinsManagerSwitchButton extends StatelessWidget { final state = context.watch().state; return UiPrimaryButton( - buttonKey: const Key('coins-manager-switch-button'), + key: const Key('coins-manager-switch-button'), prefix: state.isSwitching ? Padding( padding: const EdgeInsets.only(right: 8), diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart index 6e7b621a5c..a563ccb35b 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart @@ -60,7 +60,7 @@ class WalletManageSection extends StatelessWidget { ), const SizedBox(width: 16), UiPrimaryButton( - buttonKey: const Key('add-assets-button'), + key: const Key('add-assets-button'), onPressed: () => _onAddAssetsPress(context), text: LocaleKeys.addAssets.tr(), height: 40, diff --git a/lib/views/wallets_manager/widgets/wallets_type_list.dart b/lib/views/wallets_manager/widgets/wallets_type_list.dart index 445c0aadcd..b7ac2191ad 100644 --- a/lib/views/wallets_manager/widgets/wallets_type_list.dart +++ b/lib/views/wallets_manager/widgets/wallets_type_list.dart @@ -3,15 +3,19 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_type_list_item.dart'; class WalletsTypeList extends StatelessWidget { - const WalletsTypeList({Key? key, required this.onWalletTypeClick}) - : super(key: key); + const WalletsTypeList({super.key, required this.onWalletTypeClick}); final void Function(WalletType) onWalletTypeClick; + static const _excludedWalletTypes = [ + WalletType.hdwallet, + WalletType.metamask, + WalletType.keplr, + ]; @override Widget build(BuildContext context) { return Column( children: WalletType.values - .where((type) => type != WalletType.hdwallet) + .where((type) => !_excludedWalletTypes.contains(type)) .map((type) => Padding( padding: const EdgeInsets.only(bottom: 12.0), child: WalletTypeListItem( diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart index c0a9261bed..a4e616518d 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart @@ -1,35 +1,78 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; -class UIBaseButton extends StatelessWidget { - const UIBaseButton({ - required this.isEnabled, - required this.child, - required this.width, - required this.height, - required this.border, - super.key, - }); - final bool isEnabled; - final double width; - final double height; - final BoxBorder? border; - final Widget child; +/// Button type enum to differentiate between different Material Design button types +enum ButtonType { + /// Text button with minimum width of 64dp, height of 36dp + text, - @override - Widget build(BuildContext context) { - return IgnorePointer( - ignoring: !isEnabled, - child: Opacity( - opacity: isEnabled ? 1 : 0.4, - child: Container( - constraints: BoxConstraints.tightFor(width: width, height: height), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(18)), - border: border, - ), - child: child, - ), - ), - ); + /// Contained or outlined button with minimum width of 88dp, height of 36dp + containedOrOutlined, + + /// Icon button with touch target of 48x48dp + icon +} + +/// Utility functions for buttons +class ButtonUtils { + /// Get the constraints for a button based on its type and configuration + static BoxConstraints getButtonConstraints({ + double? width, + double? height, + ButtonType buttonType = ButtonType.containedOrOutlined, + bool shouldEnforceMinimumSize = false, + bool expandToFillParent = false, + }) { + // For backward compatibility, if explicit dimensions are provided or + // we're not enforcing minimum size, use the provided dimensions directly + if (!shouldEnforceMinimumSize || (width != null && height != null)) { + if (width != null && height != null) { + return BoxConstraints.tightFor(width: width, height: height); + } else if (width != null) { + return BoxConstraints(minWidth: width); + } else if (height != null) { + return BoxConstraints(minHeight: height); + } else if (expandToFillParent) { + // If we're expanding to fill parent and no other constraints are set, + // make sure we at least apply minimum height + return const BoxConstraints(minHeight: 36); + } else { + return const BoxConstraints(); + } + } + + // Only apply Material Design minimum dimensions for flexible constructors + // when shouldEnforceMinimumSize is true + double minWidth; + double minHeight; + + // Determine minimum dimensions based on button type + switch (buttonType) { + case ButtonType.text: + minWidth = 64; + minHeight = 36; + break; + case ButtonType.containedOrOutlined: + minWidth = 88; + minHeight = 36; + break; + case ButtonType.icon: + minWidth = 48; + minHeight = 48; + break; + } + + // For flexible constructors, use constraints that allow the button to grow + // beyond the minimum dimensions while still respecting the minimums + if (expandToFillParent) { + return BoxConstraints( + minWidth: double.infinity, + minHeight: minHeight, + ); + } else { + return BoxConstraints( + minWidth: minWidth, + minHeight: minHeight, + ); + } } } diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart index baf85dc34e..7e16cca186 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart @@ -18,11 +18,36 @@ class UiBorderButton extends StatelessWidget { this.fontWeight = FontWeight.w700, this.fontSize = 14, this.textColor, + this.padding, }); - final String text; - final double width; - final double height; + /// Constructor for a border button which inherits its size from the parent + /// widget. See [UiPrimaryButton.flexible] for more details. + /// + /// The padding defaults to 16dp horizontal and 8dp vertical, following Material Design + /// specifications. The button maintains the minimum dimensions of an outlined button + /// (88dp width, 36dp height) unless explicitly overridden. + const UiBorderButton.flexible({ + required this.text, + required this.onPressed, + super.key, + this.borderColor, + this.borderWidth = 3, + this.backgroundColor, + this.prefix, + this.suffix, + this.icon, + this.allowMultiline = false, + this.fontWeight = FontWeight.w700, + this.fontSize = 14, + this.textColor, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + }) : width = null, + height = null; + + final String text; + final double? width; + final double? height; final Widget? prefix; final Widget? suffix; final Color? borderColor; @@ -34,6 +59,7 @@ class UiBorderButton extends StatelessWidget { final FontWeight fontWeight; final double fontSize; final Color? textColor; + final EdgeInsets? padding; @override Widget build(BuildContext context) { @@ -42,10 +68,7 @@ class UiBorderButton extends StatelessWidget { return Opacity( opacity: onPressed == null ? 0.4 : 1, child: Container( - constraints: BoxConstraints.tightFor( - width: width, - height: allowMultiline ? null : height, - ), + constraints: _buildConstraints(), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(18)), color: borderColor ?? theme.custom.defaultBorderButtonBorder, @@ -68,7 +91,7 @@ class UiBorderButton extends StatelessWidget { focusColor: secondaryColor.withValues(alpha: 0.2), splashColor: secondaryColor.withValues(alpha: 0.4), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 6, 12, 6), + padding: padding ?? const EdgeInsets.fromLTRB(12, 6, 12, 6), child: Builder( builder: (context) { if (icon == null) { @@ -108,6 +131,38 @@ class UiBorderButton extends StatelessWidget { ), ); } + + BoxConstraints _buildConstraints() { + // Material Design minimum dimensions for outlined buttons + const double materialMinWidth = 88; + const double materialMinHeight = 36; + + // For fixed sizes (backward compatibility) + if (width != null || height != null) { + if (width != null && height != null) { + if (allowMultiline) { + return BoxConstraints(minWidth: width!); + } + return BoxConstraints(minWidth: width!, minHeight: height!); + } else if (width != null) { + return BoxConstraints(minWidth: width!); + } else if (height != null && !allowMultiline) { + return BoxConstraints(minHeight: height!); + } + } + + // For flexible constructor - apply Material Design minimums + // but allow growing to fit content + if (width == null && height == null) { + return BoxConstraints( + minWidth: materialMinWidth, + minHeight: materialMinHeight, + ); + } + + // Fallback + return const BoxConstraints(); + } } class _ButtonText extends StatelessWidget { diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart index 47997162d6..0079153bf7 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart @@ -1,88 +1,189 @@ +import 'dart:async'; + import 'package:app_theme/app_theme.dart'; import 'package:flutter/material.dart'; import 'package:komodo_ui_kit/src/buttons/ui_base_button.dart'; class UiPrimaryButton extends StatefulWidget { + /// Creates a primary button with the given properties. + /// + /// NB! Prefer using the [UiPrimaryButton.flexible] constructor. The [width] + /// and [height] parameters will be deprecated in the future and the button + /// will have the same behavior as the [UiPrimaryButton.flexible] constructor. + @Deprecated('Use UiPrimaryButton.flexible instead.') const UiPrimaryButton({ - required this.onPressed, - this.buttonKey, + this.onPressed, this.text = '', this.width = double.infinity, this.height = 48.0, this.backgroundColor, this.textStyle, this.prefix, + this.prefixPadding, this.border, this.focusNode, this.shadowColor, this.child, this.padding, this.borderRadius, + this.optimisticEnabledDuration, + this.onOptimisticEnabledTimeout, super.key, }); + /// Constructor for a primary button which inherits its size from the parent + /// widget. + /// + /// By default, the button will take up the minimum width required to fit its + /// content and use the minimum height needed. If you want it to take up the + /// full width of its parent, wrap it in a [SizedBox] or a [Container] with + /// `width: double.infinity`. + /// + /// The padding defaults to 16dp horizontal and 8dp vertical, following Material Design + /// specifications. The button maintains the minimum dimensions of a contained button + /// (88dp width, 36dp height) unless explicitly overridden. + /// + /// For displaying text, use the [child] parameter with a [Text] widget. For example: + /// ```dart + /// UiPrimaryButton.flexible( + /// onPressed: () {}, + /// child: Text('Button Text'), + /// ) + /// ``` + const UiPrimaryButton.flexible({ + this.onPressed, + super.key, + // TODO: Remove this in the future in favor of using the `child` parameter + // to better follow the Flutter conventions. + this.text = '', + this.backgroundColor, + this.textStyle, + this.prefix, + this.prefixPadding = const EdgeInsets.only(right: 12), + this.border, + this.focusNode, + this.shadowColor, + this.child, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + this.borderRadius, + this.optimisticEnabledDuration, + this.onOptimisticEnabledTimeout, + }) : width = null, + height = null; + + /// The text to display on the button final String text; + + /// The style to apply to the button's text final TextStyle? textStyle; - final double width; - final double height; + + /// The width of the button. If null, the button will size itself to its content + final double? width; + + /// The height of the button. If null, the button will size itself to its content + final double? height; + + /// The background color of the button final Color? backgroundColor; + + /// A widget to display before the button's text final Widget? prefix; - final Key? buttonKey; + + /// The padding to apply to the prefix widget + final EdgeInsets? prefixPadding; + + /// The border to apply to the button final BoxBorder? border; + + /// Called when the button is tapped final void Function()? onPressed; + + /// The focus node to use for the button final FocusNode? focusNode; + + /// The color of the button's shadow when focused final Color? shadowColor; + + /// A custom child widget to display instead of text final Widget? child; + + /// The padding to apply to the button's content final EdgeInsets? padding; + + /// The border radius of the button final double? borderRadius; + /// Duration for which a disabled button should appear enabled and show a loading + /// state if tapped. If [onPressed] becomes non-null during this period, it will + /// be called immediately. + /// + /// This creates an "optimistic UI" where buttons appear ready for interaction even + /// if they are technically disabled, improving perceived performance when the app + /// is waiting for some condition that will enable the button. + final Duration? optimisticEnabledDuration; + + /// Called when the [optimisticEnabledDuration] expires after the user taps + /// the button and if the button is still not enabled ([onPressed] is still null). + final VoidCallback? onOptimisticEnabledTimeout; + @override State createState() => _UiPrimaryButtonState(); } class _UiPrimaryButtonState extends State { bool _hasFocus = false; + bool _isLoading = false; + Timer? _loadingTimer; + @override - Widget build(BuildContext context) { - return UIBaseButton( - isEnabled: widget.onPressed != null, - width: widget.width, - height: widget.height, - border: widget.border, - child: ElevatedButton( - focusNode: widget.focusNode, - onFocusChange: (value) { - setState(() { - _hasFocus = value; - }); - }, - onPressed: widget.onPressed ?? () {}, - key: widget.buttonKey, - style: ElevatedButton.styleFrom( - shape: _shape, - shadowColor: _shadowColor, - elevation: 1, - backgroundColor: _backgroundColor, - foregroundColor: _foregroundColor, - padding: widget.padding, - ), - child: DefaultTextStyle( - style: _defaultTextStyle(context) ?? - widget.textStyle ?? - const TextStyle(), - child: widget.child ?? - _ButtonContent( - text: widget.text, - textStyle: widget.textStyle, - prefix: widget.prefix, - ), - ), - ), - ); + void didUpdateWidget(UiPrimaryButton oldWidget) { + super.didUpdateWidget(oldWidget); + // If the button becomes enabled while in loading state, immediately execute onPressed + if (widget.onPressed != null && oldWidget.onPressed == null && _isLoading) { + _loadingTimer?.cancel(); + setState(() => _isLoading = false); + widget.onPressed!(); + } + } + + @override + void dispose() { + _loadingTimer?.cancel(); + super.dispose(); + } + + void _handlePress() { + // If onPressed is available, execute it and clear any loading state + if (widget.onPressed != null) { + _loadingTimer?.cancel(); + if (_isLoading) { + setState(() => _isLoading = false); + } + widget.onPressed!(); + return; + } + + // Only show loading state if optimisticEnabledDuration is specified and not already loading + if (!_isLoading && widget.optimisticEnabledDuration != null) { + setState(() => _isLoading = true); + _loadingTimer?.cancel(); + _loadingTimer = Timer(widget.optimisticEnabledDuration!, () { + if (mounted) { + setState(() => _isLoading = false); + } + widget.onOptimisticEnabledTimeout?.call(); + }); + } + } + + /// Determines if the button should appear enabled, even if it's technically disabled + bool get _shouldAppearEnabled { + return widget.onPressed != null || + (widget.optimisticEnabledDuration != null) || + _isLoading; } Color get _backgroundColor { - // Always use the theme's primary color for both background and text return widget.backgroundColor ?? Theme.of(context).colorScheme.primary; } @@ -103,14 +204,85 @@ class _UiPrimaryButtonState extends State { borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius ?? 18)), ); -} -TextStyle? _defaultTextStyle(BuildContext context) { - return Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.bold, - fontSize: 14, - color: theme.custom.defaultGradientButtonTextColor, + @override + Widget build(BuildContext context) { + final shouldEnforceMinimumSize = + widget.width == null && widget.height == null; + final constraints = ButtonUtils.getButtonConstraints( + width: widget.width, + height: widget.height, + shouldEnforceMinimumSize: shouldEnforceMinimumSize, + expandToFillParent: widget.width == double.infinity, + ); + + // This is the key change - we determine if the button should appear enabled + // based on our new logic that includes optimisticEnabledDuration + final shouldAppearEnabled = _shouldAppearEnabled; + + // Create the base button widget + final button = ElevatedButton( + focusNode: widget.focusNode, + onFocusChange: (value) { + setState(() => _hasFocus = value); + }, + // Always allow the button to be pressed if it should appear enabled + onPressed: shouldAppearEnabled ? _handlePress : null, + style: ElevatedButton.styleFrom( + shape: _shape, + shadowColor: _shadowColor, + elevation: 1, + backgroundColor: _backgroundColor, + foregroundColor: _foregroundColor, + padding: widget.padding, + minimumSize: shouldEnforceMinimumSize + ? null + : Size( + constraints.minWidth > 0 ? constraints.minWidth : 0, + constraints.minHeight > 0 ? constraints.minHeight : 0, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: widget.border != null + ? BorderSide( + width: 1, + color: widget.border is Border + ? (widget.border as Border).top.color + : Theme.of(context).colorScheme.primary, + ) + : null, + ), + child: _isLoading + ? _buildLoadingIndicator() + : widget.child ?? + _ButtonContent( + text: widget.text, + textStyle: widget.textStyle, + prefix: widget.prefix, + prefixPadding: widget.prefixPadding, + ), + ); + + if (widget.width != null || widget.height != null) { + return SizedBox( + width: widget.width, + height: widget.height, + child: button, ); + } + + return button; + } + + Widget _buildLoadingIndicator() { + return SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(_foregroundColor), + ), + ); + } } class _ButtonContent extends StatelessWidget { @@ -118,20 +290,34 @@ class _ButtonContent extends StatelessWidget { required this.text, required this.textStyle, required this.prefix, + this.prefixPadding, }); final String text; final TextStyle? textStyle; final Widget? prefix; + final EdgeInsets? prefixPadding; @override Widget build(BuildContext context) { return Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - if (prefix != null) prefix!, + if (prefix != null) + Container( + padding: prefixPadding, + child: prefix!, + ), Text(text, style: textStyle ?? _defaultTextStyle(context)), ], ); } + + TextStyle? _defaultTextStyle(BuildContext context) { + return Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 14, + color: theme.custom.defaultGradientButtonTextColor, + ); + } } diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart index 422109051c..b739b1c292 100644 --- a/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart @@ -15,13 +15,38 @@ class UiSecondaryButton extends StatefulWidget { this.focusNode, this.shadowColor, this.child, + this.padding, + this.borderRadius, super.key, }); + /// Constructor for a secondary button which inherits its size from the parent + /// widget. See [UiPrimaryButton.flexible] for more details. + /// + /// The padding defaults to 16dp horizontal and 8dp vertical, following Material Design + /// specifications. The button maintains the minimum dimensions of an outlined button + /// (88dp width, 36dp height) unless explicitly overridden. + const UiSecondaryButton.flexible({ + required this.onPressed, + this.buttonKey, + this.text = '', + this.borderColor, + this.textStyle, + this.prefix, + this.border, + this.focusNode, + this.shadowColor, + this.child, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + this.borderRadius, + super.key, + }) : width = null, + height = null; + final String text; final TextStyle? textStyle; - final double width; - final double height; + final double? width; + final double? height; final Color? borderColor; final Widget? prefix; final Key? buttonKey; @@ -30,6 +55,8 @@ class UiSecondaryButton extends StatefulWidget { final FocusNode? focusNode; final Color? shadowColor; final Widget? child; + final EdgeInsets? padding; + final double? borderRadius; @override State createState() => _UiSecondaryButtonState(); @@ -37,42 +64,62 @@ class UiSecondaryButton extends StatefulWidget { class _UiSecondaryButtonState extends State { bool _hasFocus = false; + @override Widget build(BuildContext context) { - return UIBaseButton( - isEnabled: widget.onPressed != null, + final shouldEnforceMinimumSize = + widget.width == null && widget.height == null; + final constraints = ButtonUtils.getButtonConstraints( width: widget.width, height: widget.height, - border: widget.border, - child: ElevatedButton( - focusNode: widget.focusNode, - onFocusChange: (value) { - setState(() { - _hasFocus = value; - }); - }, - onPressed: widget.onPressed ?? () {}, - key: widget.buttonKey, - style: ElevatedButton.styleFrom( - shape: _shape, - side: BorderSide( - color: _borderColor, - width: 1, - ), - shadowColor: _shadowColor, - elevation: 1, - backgroundColor: Colors.transparent, - foregroundColor: _borderColor, - padding: EdgeInsets.zero, + shouldEnforceMinimumSize: shouldEnforceMinimumSize, + expandToFillParent: widget.width == double.infinity, + ); + + final buttonWidget = ElevatedButton( + focusNode: widget.focusNode, + onFocusChange: (value) { + setState(() => _hasFocus = value); + }, + onPressed: widget.onPressed, + key: widget.buttonKey, + style: ElevatedButton.styleFrom( + shape: _shape, + side: BorderSide( + color: _borderColor, + width: 1, ), - child: widget.child ?? - _ButtonContent( - text: widget.text, - textStyle: widget.textStyle, - prefix: widget.prefix, - ), + shadowColor: _shadowColor, + elevation: 1, + backgroundColor: Colors.transparent, + foregroundColor: _borderColor, + padding: widget.padding, + minimumSize: shouldEnforceMinimumSize + ? null + : Size( + constraints.minWidth > 0 ? constraints.minWidth : 0, + constraints.minHeight > 0 ? constraints.minHeight : 0, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), + child: widget.child ?? + _ButtonContent( + text: widget.text, + textStyle: widget.textStyle, + prefix: widget.prefix, + ), ); + + // Only apply size wrapper if needed + if (widget.width != null || widget.height != null) { + return SizedBox( + width: widget.width, + height: widget.height, + child: buttonWidget, + ); + } + + return buttonWidget; } Color get _borderColor { @@ -85,8 +132,9 @@ class _UiSecondaryButtonState extends State { : Colors.transparent; } - OutlinedBorder get _shape => const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(18)), + OutlinedBorder get _shape => RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(widget.borderRadius ?? 18)), ); } @@ -104,7 +152,7 @@ class _ButtonContent extends StatelessWidget { @override Widget build(BuildContext context) { return Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ if (prefix != null) prefix!, Text(text, style: textStyle ?? _defaultTextStyle(context)),