diff --git a/assets/translations/en.json b/assets/translations/en.json index d73ae0e965..d83fa5e865 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -767,13 +767,13 @@ "fetchingPrivateKeysMessage": "Please wait while we securely fetch your private keys...", "pubkeyType": "Type", "securitySettings": "Security Settings", - "zhtlcConfigureTitle": "Configure {}", + "zhtlcConfigureTitle": "Configure {} chain sync", "zhtlcZcashParamsPathLabel": "Zcash parameters path", "zhtlcPathAutomaticallyDetected": "Path automatically detected", "zhtlcSaplingParamsFolder": "Folder containing sapling params", "zhtlcBlocksPerIterationLabel": "Blocks per iteration", "zhtlcScanIntervalLabel": "Scan interval (ms)", - "zhtlcStartSyncFromLabel": "Start sync from:", + "zhtlcStartSyncFromLabel": "Start chain sync from:", "zhtlcEarliestSaplingOption": "Earliest (sapling)", "zhtlcBlockHeightOption": "Block height", "zhtlcShieldedAddress": "Shielded", @@ -790,5 +790,8 @@ "one": "Activating ZHTLC coin. Please do not close the app or tab until complete.", "other": "Activating ZHTLC coins. Please do not close the app or tab until complete." }, - "zhtlcActivationWarning": "This may take from a few minutes to a few hours, depending on your sync params and how long since your last sync." -} \ No newline at end of file + "zhtlcActivationWarning": "This may take from a few minutes to a few hours, depending on your sync params and how long since your last sync.", + "zhtlcAdvancedConfiguration": "Advanced configuration", + "zhtlcAdvancedConfigurationHint": "Faster intervals (lower milliseconds) and higher blocks per iteration result in higher memory and CPU usage.", + "zhtlcConfigButton": "Config" +} diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index d43411083a..cf5fb37f94 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -4,7 +4,6 @@ import 'dart:math' show min; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart' show NetworkImage; - import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -756,15 +755,62 @@ class CoinsRepo { bool notifyListeners = true, bool addToWalletMetadata = true, }) async { - final inactiveAssets = await assets.removeActiveAssets(_kdfSdk); - for (final asset in inactiveAssets) { + final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); + + for (final asset in assets) { final coin = coins.firstWhere((coin) => coin.id == asset.id); - await _activateZhtlcAsset( - asset, - coin, - notifyListeners: notifyListeners, - addToWalletMetadata: addToWalletMetadata, - ); + + // Check if asset is already activated + final isAlreadyActivated = activatedAssets.any((a) => a.id == asset.id); + + if (isAlreadyActivated) { + _log.info( + 'ZHTLC coin ${coin.id} is already activated. Broadcasting active state.', + ); + + // Add to wallet metadata if requested + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + // Broadcast active state for already activated assets + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.active)); + if (coin.id.parentId != null) { + final parentCoin = _assetToCoinWithoutAddress( + _kdfSdk.assets.available[coin.id.parentId]!, + ); + _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); + } + } + + // Subscribe to balance updates for already activated assets + _subscribeToBalanceUpdates(asset); + if (coin.id.parentId != null) { + final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; + if (parentAsset == null) { + _log.warning('Parent asset not found: ${coin.id.parentId}'); + } else { + _subscribeToBalanceUpdates(parentAsset); + } + } + + // Register custom icon if available + if (coin.logoImageUrl?.isNotEmpty ?? false) { + AssetIcon.registerCustomIcon( + coin.id, + NetworkImage(coin.logoImageUrl!), + ); + } + } else { + // Asset needs activation + await _activateZhtlcAsset( + asset, + coin, + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } } } @@ -832,11 +878,17 @@ class CoinsRepo { 'ZHTLC asset activation failed: ${asset.id.id} - $message', ); - if (notifyListeners) { + // Only broadcast suspended state if it's not a user cancellation + // User cancellations have the message "Configuration cancelled by user or timed out" + final isUserCancellation = message.contains('cancelled by user'); + + if (notifyListeners && !isUserCancellation) { _broadcastAsset(coin.copyWith(state: CoinState.suspended)); } - throw Exception('ZHTLC activation failed: $message'); + if (!isUserCancellation) { + throw Exception("zcoin activaiton failed: $message"); + } }, needsConfiguration: (coinId, requiredSettings) { _log.severe( diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index a34c75bce0..dd0c6dfc6b 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -778,5 +778,8 @@ abstract class LocaleKeys { static const zhtlcDateSyncHint = 'zhtlcDateSyncHint'; static const zhtlcActivating = 'zhtlcActivating'; static const zhtlcActivationWarning = 'zhtlcActivationWarning'; + static const zhtlcAdvancedConfiguration = 'zhtlcAdvancedConfiguration'; + static const zhtlcAdvancedConfigurationHint = 'zhtlcAdvancedConfigurationHint'; + static const zhtlcConfigButton = 'zhtlcConfigButton'; } diff --git a/lib/main.dart b/lib/main.dart index 392f3e3ae1..894449396c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,7 +74,7 @@ Future main() async { final tradingStatusRepository = TradingStatusRepository(komodoDefiSdk); final tradingStatusService = TradingStatusService(tradingStatusRepository); await tradingStatusService.initialize(); - final arrrActivationService = ArrrActivationService(komodoDefiSdk); + final arrrActivationService = ArrrActivationService(komodoDefiSdk, mm2); final coinsRepo = CoinsRepo( kdfSdk: komodoDefiSdk, diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index a843b14011..80be34869e 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -6,18 +6,21 @@ import 'package:komodo_defi_types/komodo_defi_type_utils.dart' import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:mutex/mutex.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; import 'arrr_config.dart'; /// Service layer - business logic coordination for ARRR activation class ArrrActivationService { - ArrrActivationService(this._sdk) + ArrrActivationService(this._sdk, this._mm2) : _configService = _sdk.activationConfigService { _startListeningToAuthChanges(); } final ActivationConfigService _configService; final KomodoDefiSdk _sdk; + final MM2 _mm2; final Logger _log = Logger('ArrrActivationService'); /// Stream controller for configuration requests @@ -448,6 +451,67 @@ class ArrrActivationService { ); } + /// Updates the configuration for an already activated ZHTLC coin + /// This will: + /// 1. Cancel any ongoing activation tasks for the asset + /// 2. Disable the coin if it's currently active + /// 3. Store the new configuration + Future updateZhtlcConfig( + Asset asset, + ZhtlcUserConfig newConfig, + ) async { + if (_isDisposing || _configRequestController.isClosed) { + throw StateError('ArrrActivationService has been disposed'); + } + + _log.info('Updating ZHTLC configuration for ${asset.id.id}'); + + try { + // Cancel any pending configuration requests + final completer = _configCompleters[asset.id]; + if (completer != null && !completer.isCompleted) { + _log.info( + 'Cancelling pending configuration request for ${asset.id.id}', + ); + completer.complete(null); + _configCompleters.remove(asset.id); + } + + // 2. Disable the coin if it's currently active + await _disableCoin(asset.id.id); + + // 3. Store the new configuration + _log.info('Saving new configuration for ${asset.id.id}'); + await _configService.saveZhtlcConfig(asset.id, newConfig); + } catch (e, stackTrace) { + _log.severe( + 'Failed to update ZHTLC configuration for ${asset.id.id}', + e, + stackTrace, + ); + await _cacheActivationError(asset.id, e.toString()); + } + } + + /// Disable a coin by calling the MM2 disable_coin RPC + /// Copied from CoinsRepo._disableCoin for consistency + Future _disableCoin(String coinId) async { + try { + final activatedAssets = await _sdk.assets.getEnabledCoins(); + final isCurrentlyActive = activatedAssets.any( + (configId) => configId == coinId, + ); + if (isCurrentlyActive) { + _log.info('Disabling currently active ZHTLC coin $coinId'); + await _mm2.call(DisableCoinReq(coin: coinId)); + _log.info('Successfully disabled coin $coinId'); + } + } catch (e, s) { + _log.shout('Error disabling $coinId', e, s); + // Don't rethrow - we want to continue with the configuration update + } + } + /// Dispose resources void dispose() { // Mark as disposing to prevent new operations 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 4b5f2d3954..1df71d84c1 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -5,18 +7,23 @@ import 'package:flutter_svg/svg.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/app_config/app_config.dart'; -import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/main_menu_value.dart'; import 'package:web_dex/model/wallet.dart'; -import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/bitrefill/bitrefill_button.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_addresses.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/contract_address_button.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart'; class CoinDetailsCommonButtons extends StatelessWidget { const CoinDetailsCommonButtons({ @@ -127,6 +134,10 @@ class CoinDetailsCommonButtonsMobileLayout extends StatelessWidget { ), ], ), + if (coin.id.subClass == CoinSubClass.zhtlc) ...[ + const SizedBox(height: 12), + ZhtlcConfigButton(coin: coin, isMobile: isMobile), + ], ], ); } @@ -199,6 +210,12 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { tooltip: _getBitrefillTooltip(coin), ), ), + if (coin.id.subClass == CoinSubClass.zhtlc) + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: ZhtlcConfigButton(coin: coin, isMobile: isMobile), + ), Flexible( flex: 2, child: Align( @@ -430,3 +447,79 @@ String? _getBitrefillTooltip(Coin coin) { // Check if coin has zero balance (this could be enhanced with actual balance check) return null; // Let BitrefillButton handle the zero balance tooltip } + +class ZhtlcConfigButton extends StatelessWidget { + const ZhtlcConfigButton({ + required this.coin, + required this.isMobile, + super.key, + }); + + final Coin coin; + final bool isMobile; + + Future _handleConfigUpdate(BuildContext context) async { + final sdk = RepositoryProvider.of(context); + final arrrService = RepositoryProvider.of(context); + final coinsBloc = context.read(); + + // Get the asset from the SDK + final asset = sdk.assets.available[coin.id]; + if (asset == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Asset ${coin.id.id} not found'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + ZhtlcUserConfig? newConfig; + try { + newConfig = await confirmZhtlcConfiguration(context, asset: asset); + if (newConfig != null && context.mounted) { + coinsBloc.add(CoinsDeactivated({coin.id.id})); + await arrrService.updateZhtlcConfig(asset, newConfig); + + // Forcefully navigate back to wallet page so that the zhtlc status bar + // is visible, rather than allowing periodic balance, pubkey, and tx + // history requests to continue running and failing during activation + routingState.selectedMenu = MainMenuValue.wallet; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error updating configuration: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (newConfig != null) { + coinsBloc.add(CoinsActivated([asset.id.id])); + } + } + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return UiPrimaryButton( + key: const Key('coin-details-zhtlc-config-button'), + height: isMobile ? 52 : 40, + prefix: Container( + padding: const EdgeInsets.only(right: 14), + child: const Icon(Icons.settings, size: 18), + ), + textStyle: themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: coin.isActive ? () => _handleConfigUpdate(context) : null, + text: LocaleKeys.zhtlcConfigButton.tr(), + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index 605bab4f7b..a88e70d342 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show ActivationStep, AssetId; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; @@ -91,7 +92,8 @@ class _ZhtlcActivationStatusBarState extends State { @override Widget build(BuildContext context) { - // Filter out completed or error statuses older than 5 seconds + // Filter out completed statuses older than 5 seconds + // Keep error statuses for 60 seconds (these are final errors after all retries) final activeStatuses = _cachedStatuses.entries.where((entry) { final status = entry.value; return status.when( @@ -106,7 +108,7 @@ class _ZhtlcActivationStatusBarState extends State { completed: (coinId, completionTime) => DateTime.now().difference(completionTime).inSeconds < 5, error: (coinId, errorMessage, errorTime) => - DateTime.now().difference(errorTime).inSeconds < 5, + DateTime.now().difference(errorTime).inSeconds < 60, ); }).toList(); @@ -175,24 +177,16 @@ class _ZhtlcActivationStatusBarState extends State { padding: const EdgeInsets.only(top: 4.0), child: status.when( completed: (_, __) => const SizedBox.shrink(), - error: (assetId, errorMessage, errorTime) => Row( - children: [ - Icon( - Icons.error_outline, - size: 14, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(width: 12), - Expanded( - child: AutoScrollText( - text: errorMessage, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], + error: (assetId, errorMessage, errorTime) => Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ErrorDisplay( + message: + '${assetId.id}: ${LocaleKeys.activationFailedMessage.tr()}', + detailedMessage: errorMessage, + showDetails: false, + showIcon: true, + narrowBreakpoint: 400, + ), ), inProgress: ( diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart index 881c3e0f4f..c0a8dc207c 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -16,8 +15,8 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' DownloadResultSuccess; import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; import 'package:komodo_defi_types/komodo_defi_types.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; enum ZhtlcSyncType { earliest, height, date } @@ -95,6 +94,7 @@ class _ZhtlcConfigurationDialogState extends State { late final TextEditingController intervalMsController; StreamSubscription? _authSubscription; bool _dismissedDueToAuthChange = false; + bool _showAdvancedConfig = false; final GlobalKey<_SyncFormState> _syncFormKey = GlobalKey<_SyncFormState>(); @@ -156,42 +156,40 @@ class _ZhtlcConfigurationDialogState extends State { title: Text( LocaleKeys.zhtlcConfigureTitle.tr(args: [widget.asset.id.id]), ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!kIsWeb) ...[ - TextField( - controller: zcashPathController, - readOnly: widget.prefilledZcashPath != null, - decoration: InputDecoration( - labelText: LocaleKeys.zhtlcZcashParamsPathLabel.tr(), - helperText: widget.prefilledZcashPath != null - ? LocaleKeys.zhtlcPathAutomaticallyDetected.tr() - : LocaleKeys.zhtlcSaplingParamsFolder.tr(), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700, minWidth: 300), + child: IntrinsicWidth( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!kIsWeb) ...[ + TextField( + controller: zcashPathController, + readOnly: widget.prefilledZcashPath != null, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcZcashParamsPathLabel.tr(), + helperText: widget.prefilledZcashPath != null + ? LocaleKeys.zhtlcPathAutomaticallyDetected.tr() + : LocaleKeys.zhtlcSaplingParamsFolder.tr(), + ), + ), + const SizedBox(height: 12), + ], + _SyncForm(key: _syncFormKey), + const SizedBox(height: 24), + _AdvancedConfigurationSection( + showAdvancedConfig: _showAdvancedConfig, + onToggle: () => setState( + () => _showAdvancedConfig = !_showAdvancedConfig, + ), + blocksPerIterController: blocksPerIterController, + intervalMsController: intervalMsController, ), - ), - const SizedBox(height: 12), - ], - TextField( - controller: blocksPerIterController, - decoration: InputDecoration( - labelText: LocaleKeys.zhtlcBlocksPerIterationLabel.tr(), - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 12), - TextField( - controller: intervalMsController, - decoration: InputDecoration( - labelText: LocaleKeys.zhtlcScanIntervalLabel.tr(), - ), - keyboardType: TextInputType.number, + ], ), - const SizedBox(height: 12), - _SyncForm(key: _syncFormKey), - ], + ), ), ), actions: [ @@ -222,6 +220,98 @@ class _ZhtlcConfigurationDialogState extends State { } } +class _AdvancedConfigurationSection extends StatelessWidget { + const _AdvancedConfigurationSection({ + required this.showAdvancedConfig, + required this.onToggle, + required this.blocksPerIterController, + required this.intervalMsController, + }); + + final bool showAdvancedConfig; + final VoidCallback onToggle; + final TextEditingController blocksPerIterController; + final TextEditingController intervalMsController; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onToggle, + child: Row( + children: [ + Icon(showAdvancedConfig ? Icons.expand_less : Icons.expand_more), + const SizedBox(width: 8), + Text( + LocaleKeys.zhtlcAdvancedConfiguration.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + if (showAdvancedConfig) ...[ + const SizedBox(height: 12), + const _AdvancedConfigurationWarning(), + const SizedBox(height: 12), + TextField( + controller: blocksPerIterController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcBlocksPerIterationLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: intervalMsController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcScanIntervalLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + ], + ], + ); + } +} + +class _AdvancedConfigurationWarning extends StatelessWidget { + const _AdvancedConfigurationWarning(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.1), + border: Border.all(color: foregroundColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.zhtlcAdvancedConfigurationHint.tr(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: foregroundColor), + softWrap: true, + ), + ), + ], + ), + ); + } +} + class _SyncForm extends StatefulWidget { const _SyncForm({super.key});