diff --git a/assets/i18n/en.i18n.yaml b/assets/i18n/en.i18n.yaml index 291d078a1..4e4a75fee 100644 --- a/assets/i18n/en.i18n.yaml +++ b/assets/i18n/en.i18n.yaml @@ -168,6 +168,39 @@ donation: ln_address_pow: "powbitcoiner@speed.app" ln_invoice_api_error: "Failed to generate Lightning invoice." +transaction_draft: + title: "Temporary Transaction" + empty_message: "No temporary transaction saved" + deleted_wallet: "Deleted Wallet" + save: "Temporary Save" + load: "Load Transaction" + recipient_batch_address: "$address and $count other addresses" + fee_rate: "Fee Rate" + sats_per_vbyte: "sat/vB" + dialog: + transaction_draft_saved_send_screen: "Transaction Draft Saved" + transaction_draft_saved_send_screen_description: "The transaction information has been temporarily saved. Would you like to move to the list now?" + transaction_draft_saved_broadcast_screen: "Transaction Draft Saved" + transaction_draft_saved_description_broadcast_screen: "The transaction has been temporarily saved. Would you like to move to the list now?" + transaction_draft_save_failed: "Transaction Draft Save Failed" + transaction_draft_already_exists: "Transaction Draft Already Exists" + transaction_draft_empty_required_fields: "Please enter the address and amount to send." + transaction_unavailable_to_sign: "Transaction Unavailable to Sign" + transaction_already_used_utxo_included: "Transaction Already Used UTXO Included. Would you like to delete the saved transaction?" + transaction_has_been_locked_utxo_included: "Locked UTXO Included. Would you like to delete the saved transaction?" + transaction_draft_delete: "Delete Temporary Transaction" + transaction_draft_delete_description: "Would you like to delete the temporary transaction? Once deleted, it cannot be restored." + transaction_draft_delete_failed: "Transaction Draft Delete Failed" + transaction_draft_delete_completed: "Transaction Draft Delete Completed" + transaction_draft_delete_completed_description: "The temporary transaction has been deleted." + cancel: "Cancel" + remove: "Remove" + move: "Move" + confirm: "Confirm" + signed: "Signed" + unsigned: "Unsigned" + max: "Max" + # lib/enums transaction_enums: high_priority: "Fast" diff --git a/assets/i18n/jp.i18n.yaml b/assets/i18n/jp.i18n.yaml index c65d99c31..e1e3e9867 100644 --- a/assets/i18n/jp.i18n.yaml +++ b/assets/i18n/jp.i18n.yaml @@ -168,6 +168,39 @@ donation: ln_address_pow: "powbitcoiner@speed.app" ln_invoice_api_error: "ライトニングインボイスの生成に失敗しました。" +transaction_draft: + title: "一時保存トランザクション" + empty_message: "一時保存されたトランザクションがありません" + deleted_wallet: "削除されたウォレット" + save: "一時保存" + load: "読み込み" + recipient_batch_address: "$address および $count 個の他のアドレス" + fee_rate: "手数料率" + sats_per_vbyte: "sat/vB" + dialog: + transaction_draft_saved_send_screen: "送信情報保存完了" + transaction_draft_saved_send_screen_description: "後で署名できるように送信情報を一時保存しました。\n今すぐリストに移動しますか?" + transaction_draft_saved_broadcast_screen: "トランザクション保存完了" + transaction_draft_saved_description_broadcast_screen: "後で送信できるようにトランザクションを一時保存しました。\n今すぐリストに移動しますか?" + transaction_draft_save_failed: "トランザクション保存失敗" + transaction_draft_already_exists: "すでに保存されたトランザクションがあります。" + transaction_draft_empty_required_fields: "送信先と送信金額を両方入力してください。不足している項目を確認してください。" + transaction_unavailable_to_sign: "トランザクション署名不可" + transaction_already_used_utxo_included: "すでに使用されたUTXOが含まれています。保存されたトランザクションを削除しますか?" + transaction_has_been_locked_utxo_included: "ロックされたUTXOが含まれています。保存されたトランザクションを削除しますか?" + transaction_draft_delete: "一時保存トランザクション削除" + transaction_draft_delete_description: "一時保存されたトランザクションを削除しますか?一度削除すると元に戻すことはできません。" + transaction_draft_delete_failed: "一時保存トランザクション削除失敗" + transaction_draft_delete_completed: "一時保存トランザクション削除完了" + transaction_draft_delete_completed_description: "一時保存されたトランザクションを削除しました。" + cancel: "キャンセル" + remove: "削除" + move: "移動" + confirm: "確認" + signed: "署名完了" + unsigned: "署名未完了" + max: "最大" + # lib/enums transaction_enums: high_priority: "高速" diff --git a/assets/i18n/kr.i18n.yaml b/assets/i18n/kr.i18n.yaml index dd433d98d..fafabcde2 100644 --- a/assets/i18n/kr.i18n.yaml +++ b/assets/i18n/kr.i18n.yaml @@ -168,6 +168,39 @@ donation: ln_address_pow: "powbitcoiner@speed.app" ln_invoice_api_error: "라이트닝 인보이스 생성에 실패했어요" +transaction_draft: + title: "임시 저장 트랜잭션" + empty_message: "임시 저장된 트랜잭션이 없어요" + deleted_wallet: "삭제된 지갑" + save: "임시 저장" + load: "불러오기" + recipient_batch_address: "$address 외 $count개 주소" + fee_rate: "수수료율" + sats_per_vbyte: "sat/vB" + dialog: + transaction_draft_saved_send_screen: "보내기 정보 저장 완료" + transaction_draft_saved_send_screen_description: "나중에 서명할 수 있도록 보내기 정보를 임시 저장했어요.\n지금 목록으로 이동할까요?" + transaction_draft_saved_broadcast_screen: "트랜잭션 저장 완료" + transaction_draft_saved_description_broadcast_screen: "나중에 전송할 수 있도록 트랜잭션을 임시 저장했어요.\n지금 목록으로 이동할까요?" + transaction_draft_save_failed: "트랜잭션 저장 실패" + transaction_draft_already_exists: "이미 저장된 트랜잭션이에요." + transaction_draft_empty_required_fields: "주소와 보낼 금액을 모두 입력해야 해요. 빠진 항목을 확인해 주세요." + transaction_unavailable_to_sign: "트랜잭션 서명 불가" + transaction_already_used_utxo_included: "이미 사용된 UTXO가 포함되어 있어요. 저장된 트랜잭션을 지울까요?" + transaction_has_been_locked_utxo_included: "잠금 설정이 된 UTXO가 포함되어 있어요. 저장된 트랜잭션을 지울까요?" + transaction_draft_delete: "임시 저장 트랜잭션 삭제" + transaction_draft_delete_description: "임시 저장된 트랜잭션을 삭제할까요? 한번 삭제하면 되돌릴 수 없어요." + transaction_draft_delete_failed: "임시 저장 트랜잭션 삭제 실패" + transaction_draft_delete_completed: "삭제 완료" + transaction_draft_delete_completed_description: "임시 저장 트랜잭션을 삭제했어요." + cancel: "취소" + remove: "지우기" + move: "이동하기" + confirm: "확인" + signed: "서명 완료" + unsigned: "서명 전" + max: "최대" + # lib/enums transaction_enums: high_priority: "빠른 전송" diff --git a/lib/app.dart b/lib/app.dart index 7c516803a..06ed398c1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,6 +13,8 @@ import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/repository/realm/address_repository.dart'; import 'package:coconut_wallet/repository/realm/realm_manager.dart'; import 'package:coconut_wallet/repository/realm/subscription_repository.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/transaction_draft_repository.dart'; import 'package:coconut_wallet/repository/realm/transaction_repository.dart'; import 'package:coconut_wallet/repository/realm/utxo_repository.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; @@ -31,6 +33,7 @@ import 'package:coconut_wallet/screens/settings/block_explorer_screen.dart'; import 'package:coconut_wallet/screens/settings/coconut_crew_screen.dart'; import 'package:coconut_wallet/screens/settings/electrum_server_screen.dart'; import 'package:coconut_wallet/screens/settings/log_viewer_screen.dart'; +import 'package:coconut_wallet/screens/transaction_draft/transaction_draft_screen.dart'; import 'package:coconut_wallet/screens/wallet_detail/address_list_screen.dart'; import 'package:coconut_wallet/screens/review/negative_feedback_screen.dart'; import 'package:coconut_wallet/screens/review/positive_feedback_screen.dart'; @@ -114,6 +117,9 @@ class _CoconutWalletAppState extends State { Provider(create: (context) => AddressRepository(context.read())), Provider(create: (context) => TransactionRepository(context.read())), Provider(create: (context) => UtxoRepository(context.read())), + Provider( + create: (context) => TransactionDraftRepository(context.read(), context.read()), + ), Provider(create: (context) => SubscriptionRepository(context.read())), Provider( create: (context) => WalletPreferencesRepository(context.read()), @@ -237,7 +243,16 @@ class _CoconutWalletAppState extends State { // 로딩이 필요한 화면들 '/wallet-add-input': (context) => const CustomLoadingOverlay(child: WalletAddInputScreen()), - '/broadcasting': (context) => const CustomLoadingOverlay(child: BroadcastingScreen()), + '/broadcasting': + (context) => buildLoadingScreenWithArgs( + context, + (args) => BroadcastingScreen( + transactionDraft: + args.containsKey('transactionDraft') + ? args['transactionDraft'] as RealmTransactionDraft? + : null, + ), + ), // 인자가 있는 기본 화면들 (Privacy Screen 사용 ❌ - 각 화면 내부에서 설정/해제 합니다) // 1. 주소 보기 @@ -285,7 +300,11 @@ class _CoconutWalletAppState extends State { '/send': (context) => buildScreenWithArgs( context, - (args) => SendScreen(walletId: args['walletId'], sendEntryPoint: args['sendEntryPoint']), + (args) => SendScreen( + walletId: args['walletId'], + sendEntryPoint: args['sendEntryPoint'], + transactionDraft: args['transactionDraft'], + ), ), '/utxo-tag': (context) => buildScreenWithArgs(context, (args) => UtxoTagCrudScreen(id: args['id'])), '/select-donation-amount': @@ -350,6 +369,7 @@ class _CoconutWalletAppState extends State { context, (args) => UtxoDetailScreen(utxo: args['utxo'], id: args['id']), ), + '/transaction-draft': (context) => const TransactionDraftScreen(), }, ); @@ -363,13 +383,13 @@ class _CoconutWalletAppState extends State { /// 화면 생성 헬퍼 메서드 /// 1. 인자가 있는 화면 Widget buildScreenWithArgs(BuildContext context, Widget Function(Map) builder) { - final Map args = ModalRoute.of(context)?.settings.arguments as Map; + final Map args = ModalRoute.of(context)?.settings.arguments as Map? ?? {}; return builder(args); } /// 2. CustomLoadingOverlay + 인자가 있는 화면 Widget buildLoadingScreenWithArgs(BuildContext context, Widget Function(Map) builder) { - final Map args = ModalRoute.of(context)?.settings.arguments as Map; + final Map args = ModalRoute.of(context)?.settings.arguments as Map? ?? {}; return CustomLoadingOverlay(child: builder(args)); } } diff --git a/lib/constants/realm_constants.dart b/lib/constants/realm_constants.dart index 1723feae1..96a948a43 100644 --- a/lib/constants/realm_constants.dart +++ b/lib/constants/realm_constants.dart @@ -1 +1 @@ -const int kRealmVersion = 5; +const int kRealmVersion = 6; diff --git a/lib/constants/secure_keys.dart b/lib/constants/secure_keys.dart index 48dc4d0b7..70f6fffee 100644 --- a/lib/constants/secure_keys.dart +++ b/lib/constants/secure_keys.dart @@ -1 +1,2 @@ const kSecureStoragePinKey = 'pin'; +const kSecureStorageSignedTransactionDraftPrefix = 'signed_transaction_draft_'; diff --git a/lib/model/error/app_error.dart b/lib/model/error/app_error.dart index 9fd51c38f..3cffb21f6 100644 --- a/lib/model/error/app_error.dart +++ b/lib/model/error/app_error.dart @@ -33,4 +33,8 @@ class ErrorCodes { static AppError nodeIsolateError = AppError('1301', t.errors.node_unknown); static AppError broadcastError = AppError('1302', t.errors.broadcast_error); static AppError broadcastErrorWithMessage(String message) => ErrorCodes.withMessage(broadcastError, message); + static AppError transactionDraftAlreadyExists = AppError( + '1400', + t.transaction_draft.dialog.transaction_draft_already_exists, + ); } diff --git a/lib/providers/send_info_provider.dart b/lib/providers/send_info_provider.dart index 838a96921..c38740bf1 100644 --- a/lib/providers/send_info_provider.dart +++ b/lib/providers/send_info_provider.dart @@ -25,6 +25,8 @@ class SendInfoProvider { // null인 경우 RBF 또는 CPFP가 아닙니다. FeeBumpingType? _feeBumpingType; WalletImportSource? _walletImportSource; + int? _transactionDraftId; + double? _feeRate; int? get walletId => _walletId; String? get recipientAddress => _recipientAddress; @@ -42,6 +44,16 @@ class SendInfoProvider { _recipientsForBatch == null ? null : UnmodifiableMapView(_recipientsForBatch!); FeeBumpingType? get feeBumpingType => _feeBumpingType; WalletImportSource? get walletImportSource => _walletImportSource; + int? get transactionDraftId => _transactionDraftId; + double? get feeRate => _feeRate; + + void setFeeRate(double feeRate) { + _feeRate = feeRate; + } + + void setTransactionDraftId(int? id) { + _transactionDraftId = id; + } void setWalletId(int id) { _walletId = id; @@ -116,7 +128,9 @@ class SendInfoProvider { _rawSignedTransaction = _isDonation = _sendEntryPoint = - _recipientsForBatch = _feeBumpingType = _walletImportSource = null; + _recipientsForBatch = + _feeBumpingType = + _walletImportSource = _transactionDraftId = null; } Map? getRecipientMap() { diff --git a/lib/providers/view_model/send/broadcasting_view_model.dart b/lib/providers/view_model/send/broadcasting_view_model.dart index 4d5df4628..bf5d92208 100644 --- a/lib/providers/view_model/send/broadcasting_view_model.dart +++ b/lib/providers/view_model/send/broadcasting_view_model.dart @@ -2,7 +2,6 @@ import 'dart:collection'; import 'dart:convert'; import 'package:coconut_lib/coconut_lib.dart'; -import 'package:coconut_wallet/enums/network_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; @@ -53,6 +52,15 @@ class BroadcastingViewModel extends ChangeNotifier { _walletBase = _walletProvider.getWalletById(_sendInfoProvider.walletId!).walletBase; _walletId = _sendInfoProvider.walletId!; _isSendingDonation = _sendInfoProvider.isDonation ?? false; + + debugPrint('sendInfoProvider.transactionDraftId: ${_sendInfoProvider.transactionDraftId}'); + debugPrint('sendInfoProvider.signedPsbt: ${_sendInfoProvider.signedPsbt}'); + debugPrint('sendInfoProvider.txWaitingForSign: ${_sendInfoProvider.txWaitingForSign}'); + debugPrint('sendInfoProvider.transaction: ${_sendInfoProvider.transaction}'); + debugPrint('sendInfoProvider.walletId: ${_sendInfoProvider.walletId}'); + debugPrint('sendInfoProvider.walletBase: $_walletBase'); + debugPrint('sendInfoProvider.walletAddressType: ${_walletBase.addressType}'); + debugPrint('sendInfoProvider.walletId: $_walletId'); } List get recipientAddresses => UnmodifiableListView(_recipientAddresses); @@ -74,6 +82,15 @@ class BroadcastingViewModel extends ChangeNotifier { FeeBumpingType? get feeBumpingType => _sendInfoProvider.feeBumpingType; + int? get transactionDraftId => _sendInfoProvider.transactionDraftId; + double? get feeRate => _sendInfoProvider.feeRate; + bool? get isMaxMode => _sendInfoProvider.isMaxMode; + bool? get isMultisig => _sendInfoProvider.isMultisig; + Transaction? get transaction => _sendInfoProvider.transaction; + String? get txWaitingForSign => _sendInfoProvider.txWaitingForSign; + String? get signedPsbt => _sendInfoProvider.signedPsbt; + String? get signedPsbtBase64Encoded => _sendInfoProvider.signedPsbt; + Future> broadcast(Transaction signedTx) async { Logger.log('BroadcastingViewModel: signedTx = ${signedTx.serialize()}'); final isConnected = await isElectrumServerConnected(); diff --git a/lib/providers/view_model/send/refactor/send_view_model.dart b/lib/providers/view_model/send/refactor/send_view_model.dart index 30664d71f..c83325b69 100644 --- a/lib/providers/view_model/send/refactor/send_view_model.dart +++ b/lib/providers/view_model/send/refactor/send_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:coconut_lib/coconut_lib.dart'; import 'package:coconut_wallet/constants/bitcoin_network_rules.dart'; import 'package:coconut_wallet/core/transaction/transaction_builder.dart'; @@ -14,6 +16,7 @@ import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; import 'package:coconut_wallet/providers/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; import 'package:coconut_wallet/screens/send/refactor/send_screen.dart'; import 'package:coconut_wallet/services/fee_service.dart'; import 'package:coconut_wallet/utils/address_util.dart'; @@ -199,6 +202,7 @@ class SendViewModel extends ChangeNotifier { int? get unintendedDustFee => _unintendedDustFee; TransactionBuildResult? _txBuildResult; + final RealmTransactionDraft? _transactionDraft; List get validRecipientList { return _recipientList @@ -274,6 +278,7 @@ class SendViewModel extends ChangeNotifier { this._onRecipientPageDeleted, int? walletId, SendEntryPoint sendEntryPoint, + this._transactionDraft, ) { _sendInfoProvider.clear(); _sendInfoProvider.setSendEntryPoint(sendEntryPoint); @@ -285,6 +290,11 @@ class SendViewModel extends ChangeNotifier { } _recipientList = [RecipientInfo()]; + + if (_transactionDraft != null) { + loadTransactionDraft(_transactionDraft); + } + _initBalances(); _setRecommendedFees().whenComplete(() { notifyListeners(); @@ -383,6 +393,148 @@ class SendViewModel extends ChangeNotifier { notifyListeners(); } + /// TransactionDraft를 로드하여 SendViewModel 상태에 반영 + void loadTransactionDraft(RealmTransactionDraft? draft) { + if (draft == null) return; + debugPrint('draft id: ${draft.id}'); + // draft id 설정 + _sendInfoProvider.setTransactionDraftId(draft.id); + + // 지갑 선택 + final walletIndex = _walletProvider.walletItemList.indexWhere((e) => e.id == draft.walletId); + if (walletIndex != -1) { + _initializeWithSelectedWallet(walletIndex); + } + + // recipientList 설정 + final recipientListJson = draft.recipientListJson.toList(); + final recipientList = + recipientListJson.map((jsonString) { + final json = jsonDecode(jsonString) as Map; + debugPrint('json: $json'); + debugPrint('address: ${json['address']}'); + debugPrint('amount: ${json['amount']}'); + debugPrint('addressError: ${json['addressError']}'); + debugPrint('minimumAmountError: ${json['minimumAmountError']}'); + return RecipientInfo( + address: json['address'] as String? ?? '', + amount: json['amount'] as String? ?? '', + addressError: AddressError.values.firstWhere( + (e) => e.name == (json['addressError'] as String? ?? 'none'), + orElse: () => AddressError.none, + ), + minimumAmountError: AmountError.values.firstWhere( + (e) => e.name == (json['minimumAmountError'] as String? ?? 'none'), + orElse: () => AmountError.none, + ), + ); + }).toList(); + _recipientList = recipientList; + + // address 설정 + if (_recipientList.isNotEmpty) { + for (int i = 0; i < _recipientList.length; i++) { + setAddressText(_recipientList[i].address, i); + debugPrint('address: ${_recipientList[i].address}'); + } + } + + // feeRate 설정 + if (draft.feeRate != null) { + setFeeRateText(draft.feeRate.toString()); + } + + // maxMode 설정 (amount 설정 전에 먼저 설정, skipAmountReset으로 amount 초기화 방지) + if (draft.isMaxMode == true) { + setMaxMode(true); + } else { + setMaxMode(false, skipAmountReset: true); + } + + // amount 설정 (maxMode 설정 후에 설정하여 덮어쓰기 방지) + if (_recipientList.isNotEmpty) { + for (int i = 0; i < _recipientList.length; i++) { + if (draft.currentUnit == t.btc) { + if (_recipientList[i].amount.isNotEmpty) { + setAmountText(UnitUtil.convertBitcoinToSatoshi(double.parse(_recipientList[i].amount)), i); + } + } else { + if (_recipientList[i].amount.isNotEmpty) { + setAmountText(int.parse(_recipientList[i].amount), i); + } + } + } + } + + // isFeeSubtractedFromSendAmount 설정 + if (draft.isFeeSubtractedFromSendAmount == true) { + _isFeeSubtractedFromSendAmount = true; + } + + // selectedUtxoList 설정 + if (draft.selectedUtxoListJson.isNotEmpty) { + // 수동 선택 모드 + _isUtxoSelectionAuto = false; + final selectedUtxoListJson = draft.selectedUtxoListJson.toList(); + final allUtxoList = _walletProvider.getUtxoList(draft.walletId); + _selectedUtxoList = + selectedUtxoListJson.map((jsonString) { + final json = jsonDecode(jsonString) as Map; + final utxoId = '${json['transactionHash'] as String}${json['index'] as int}'; + return allUtxoList.firstWhere( + (utxo) => utxo.utxoId == utxoId, + orElse: + () => UtxoState( + transactionHash: json['transactionHash'] as String, + index: json['index'] as int, + amount: json['amount'] as int, + derivationPath: json['derivationPath'] as String, + blockHeight: json['blockHeight'] as int, + to: json['to'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + ), + ); + }).toList(); + } else { + // 자동 선택 모드 + _isUtxoSelectionAuto = true; + _selectedUtxoList = _walletProvider.getUtxoList(draft.walletId); + } + selectedUtxoAmountSum = _selectedUtxoList.fold(0, (totalAmount, utxo) => totalAmount + utxo.amount); + + // 현재 단위 확인 및 업데이트 + if (draft.currentUnit != null) { + final draftUnit = BitcoinUnit.values.firstWhere( + (unit) => unit.symbol == draft.currentUnit, + orElse: () => _currentUnit, + ); + if (draftUnit != _currentUnit) { + toggleUnit(); + } + } + + // 페이지 인덱스 초기화 + _currentIndex = 0; + _onRecipientPageDeleted(0); // 페이지 초기화를 위해 삭제 콜백 호출 + _initBalances(); + + for (int i = 0; i < _recipientList.length; i++) { + _onAmountTextUpdate(_recipientList[i].amount); + } + _onFeeRateTextUpdate(_feeRateText); + + // 트랜잭션 빌드 및 잔액 검증 상태 업데이트 + _buildTransaction(); + _updateAmountValidationState(); + + setShowAddressBoard(false); + if (recipientList.length > 1 || recipientList.any((r) => r.amount.isNotEmpty && r.address.isNotEmpty)) { + _showFeeBoard = true; + } + _updateFeeBoardVisibility(); + notifyListeners(); + } + void _setEstimatedFee(int? estimatedFee) { if (_estimatedFee == estimatedFee) return; @@ -522,7 +674,7 @@ class SendViewModel extends ChangeNotifier { _updateAmountValidationState(recipientIndex: recipientIndex); } - void setMaxMode(bool isEnabled) { + void setMaxMode(bool isEnabled, {bool skipAmountReset = false}) { if (_isMaxMode == isEnabled) return; _isMaxMode = isEnabled; @@ -532,10 +684,14 @@ class SendViewModel extends ChangeNotifier { _previousIsFeeSubtractedFromSendAmount = _isFeeSubtractedFromSendAmount; _isFeeSubtractedFromSendAmount = true; } else { - /// maxMode 꺼지면 마지막 수신자 금액 초기화 - _recipientList[lastIndex].amount = ""; - if (_currentIndex == lastIndex) { - _onAmountTextUpdate(_recipientList[lastIndex].amount); + /// maxMode 꺼지면 마지막 수신자 금액 초기화 (skipAmountReset이 true면 스킵) + if (!skipAmountReset) { + if (_recipientList.isNotEmpty && lastIndex >= 0) { + _recipientList[lastIndex].amount = ""; + if (_currentIndex == lastIndex) { + _onAmountTextUpdate(_recipientList[lastIndex].amount); + } + } } _isFeeSubtractedFromSendAmount = _previousIsFeeSubtractedFromSendAmount; } @@ -627,7 +783,19 @@ class SendViewModel extends ChangeNotifier { } } + /// recipientIndex 유효성 검사 + bool _isValidRecipientIndex(int recipientIndex, String methodName) { + if (recipientIndex < 0 || recipientIndex >= _recipientList.length) { + debugPrint( + '$methodName: Invalid recipientIndex $recipientIndex, _recipientList.length: ${_recipientList.length}', + ); + return false; + } + return true; + } + void setAddressText(String text, int recipientIndex) { + if (!_isValidRecipientIndex(recipientIndex, 'setAddressText')) return; if (_recipientList[recipientIndex].address == text) return; _recipientList[recipientIndex].address = text; if (text.isEmpty) { @@ -638,6 +806,7 @@ class SendViewModel extends ChangeNotifier { /// bip21 url에서 amount값 파싱 성공했을 때 사용 void setAmountText(int satoshi, int recipientIndex) { + if (!_isValidRecipientIndex(recipientIndex, 'setAmountText')) return; if (currentUnit == BitcoinUnit.sats) { _recipientList[recipientIndex].amount = satoshi.toString(); } else { @@ -804,9 +973,16 @@ class SendViewModel extends ChangeNotifier { } void _updateIsAmountSumExceedsBalance(double amountSum) { + // 수수료가 아직 계산되지 않았으면 잔액 검증을 하지 않음 + if (_estimatedFee == null && !_isFeeSubtractedFromSendAmount) { + _isAmountSumExceedsBalance = AmountError.none; + return; + } + double total = _isFeeSubtractedFromSendAmount ? amountSum : amountSum + _estimatedFeeByUnit; + double balanceInUnit = balance / _dustLimitDenominator; _isAmountSumExceedsBalance = - total > 0 && total > balance / _dustLimitDenominator ? AmountError.insufficientBalance : AmountError.none; + total > 0 && total > balanceInUnit ? AmountError.insufficientBalance : AmountError.none; } void _validateOneAmount(int recipientIndex) { @@ -856,6 +1032,8 @@ class SendViewModel extends ChangeNotifier { } void _setAddressError(AddressError error, int index) { + debugPrint('setAddressError: $error, $index'); + if (!_isValidRecipientIndex(index, '_setAddressError')) return; if (_recipientList[index].addressError != error) { _recipientList[index].addressError = error; _updateFinalErrorMessage(); @@ -868,6 +1046,8 @@ class SendViewModel extends ChangeNotifier { } bool validateAddress(String address, int recipientIndex) { + if (!_isValidRecipientIndex(recipientIndex, 'validateAddress')) return false; + AddressValidationError? error = AddressValidator.validateAddress(address, NetworkType.currentNetworkType); switch (error) { @@ -932,6 +1112,7 @@ class SendViewModel extends ChangeNotifier { _sendInfoProvider.setTransaction(_txBuildResult!.transaction!); _sendInfoProvider.setIsMultisig(_selectedWalletItem!.walletType == WalletType.multiSignature); _sendInfoProvider.setWalletImportSource(_selectedWalletItem!.walletImportSource); + _sendInfoProvider.setFeeRate(double.parse(_feeRateText)); } } diff --git a/lib/providers/view_model/transaction_draft/transaction_draft_view_model.dart b/lib/providers/view_model/transaction_draft/transaction_draft_view_model.dart new file mode 100644 index 000000000..21fc1c86a --- /dev/null +++ b/lib/providers/view_model/transaction_draft/transaction_draft_view_model.dart @@ -0,0 +1,29 @@ +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/transaction_draft_repository.dart'; +import 'package:flutter/material.dart'; + +class TransactionDraftViewModel extends ChangeNotifier { + final TransactionDraftRepository _transactionDraftRepository; + + /// Wallet variables --------------------------------------------------------- + List _unsignedTransactionDraftList = []; + List _signedTransactionDraftList = []; + bool _isInitialized = false; + + TransactionDraftViewModel(this._transactionDraftRepository); + + List get signedTransactionDraftList => _signedTransactionDraftList; + List get unsignedTransactionDraftList => _unsignedTransactionDraftList; + bool get isInitialized => _isInitialized; + + Future initializeDraftList() async { + final allDrafts = await _transactionDraftRepository.getAllTransactionDrafts(); + + // createdAt 기준 최신순으로 정렬 (이미 getAllTransactionDrafts에서 정렬됨) + _unsignedTransactionDraftList = allDrafts.where((draft) => draft.signedPsbtBase64Encoded == null).toList(); + _signedTransactionDraftList = allDrafts.where((draft) => draft.signedPsbtBase64Encoded != null).toList(); + _isInitialized = true; + + notifyListeners(); + } +} diff --git a/lib/repository/realm/migration/migration.dart b/lib/repository/realm/migration/migration.dart index 2f5e5b8af..0e989769c 100644 --- a/lib/repository/realm/migration/migration.dart +++ b/lib/repository/realm/migration/migration.dart @@ -32,6 +32,9 @@ import 'package:realm/realm.dart'; /// 4. RealmWalletAddress 의 id 를 재생성 /// 5. TempBroadcastTimeRecord 삭제 /// +/// [addRealmTransactionDraft] (5 -> 6) +/// 1. RealmTransactionDraft 추가 +/// void defaultMigration(Migration migration, int oldVersion) { if (oldVersion == kRealmVersion) { Logger.log('oldVersion: $oldVersion is same as kRealmVersion: $kRealmVersion'); diff --git a/lib/repository/realm/model/coconut_wallet_model.dart b/lib/repository/realm/model/coconut_wallet_model.dart index 999af3c5e..bc00f8300 100644 --- a/lib/repository/realm/model/coconut_wallet_model.dart +++ b/lib/repository/realm/model/coconut_wallet_model.dart @@ -26,6 +26,7 @@ final realmAllSchemas = [ RealmCpfpHistory.schema, RealmTransactionMemo.schema, RealmWalletPreferences.schema, + RealmTransactionDraft.schema, ]; @RealmModel() @@ -226,3 +227,29 @@ class _RealmWalletPreferences { // 총 잔액에서 제외되는 지갑 ID 목록. late List excludedFromTotalBalanceWalletIds; } + +@RealmModel() +class _RealmTransactionDraft { + @PrimaryKey() + late int id; + late int walletId; + late double? feeRate; + late bool? isMaxMode; + late bool? isMultisig; + late bool? isFeeSubtractedFromSendAmount; + // Transaction 객체를 hex 문자열로 직렬화하여 저장 + late String? transactionHex; + late String? txWaitingForSign; + late String? signedPsbtBase64Encoded; + // 각 문자열은 {"address": "...", "amount": "...", "addressError": "...", "minimumAmountError": "..."} 형태 + late List recipientListJson; + // 저장 시간 + late DateTime? createdAt; + // 저장 시 사용한 단위 + late String? currentUnit; + // 선택된 UTXO 리스트 (자동 선택 모드면 null) + // 각 문자열은 UtxoState의 JSON 직렬화된 형태 + late List selectedUtxoListJson; + // totalAmount는 서명된 트랜잭션 임시저장에 사용됨 + late int? totalAmount; +} diff --git a/lib/repository/realm/realm_manager.dart b/lib/repository/realm/realm_manager.dart index de2702016..fbba1f1a0 100644 --- a/lib/repository/realm/realm_manager.dart +++ b/lib/repository/realm/realm_manager.dart @@ -40,6 +40,7 @@ class RealmManager { realm.deleteAll(); realm.deleteAll(); realm.deleteAll(); + realm.deleteAll(); }); } diff --git a/lib/repository/realm/transaction_draft_repository.dart b/lib/repository/realm/transaction_draft_repository.dart new file mode 100644 index 000000000..9f2b51416 --- /dev/null +++ b/lib/repository/realm/transaction_draft_repository.dart @@ -0,0 +1,511 @@ +import 'dart:convert'; + +import 'package:coconut_lib/coconut_lib.dart' as lib; +import 'package:coconut_wallet/constants/secure_keys.dart'; +import 'package:coconut_wallet/model/error/app_error.dart'; +import 'package:coconut_wallet/model/utxo/utxo_state.dart'; +import 'package:coconut_wallet/model/utxo/utxo_tag.dart'; +import 'package:coconut_wallet/providers/view_model/send/refactor/send_view_model.dart'; +import 'package:coconut_wallet/repository/realm/base_repository.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/service/realm_id_service.dart'; +import 'package:coconut_wallet/repository/realm/utxo_repository.dart'; +import 'package:coconut_wallet/repository/secure_storage/secure_storage_repository.dart'; +import 'package:coconut_wallet/utils/result.dart'; + +/// 선택된 UTXO의 상태를 나타내는 enum +enum SelectedUtxoStatus { + unused, // 모든 UTXO가 사용 가능 + used, // 일부 UTXO가 이미 사용됨 + locked, // 일부 UTXO가 잠금됨 +} + +/// RecipientInfo 리스트를 JSON 문자열 리스트로 변환 +List _recipientListToJson(List recipients) { + return recipients.map((recipient) { + return jsonEncode({ + 'address': recipient.address, + 'amount': recipient.amount, + 'addressError': recipient.addressError.name, + 'minimumAmountError': recipient.minimumAmountError.name, + }); + }).toList(); +} + +/// JSON 문자열 리스트를 RecipientInfo 리스트로 변환 +List _jsonToRecipientList(List recipientListJson) { + return recipientListJson.map((jsonString) { + final json = jsonDecode(jsonString) as Map; + return RecipientInfo( + address: json['address'] as String? ?? '', + amount: json['amount'] as String? ?? '', + addressError: _parseAddressError(json['addressError'] as String?), + minimumAmountError: _parseAmountError(json['minimumAmountError'] as String?), + ); + }).toList(); +} + +AddressError _parseAddressError(String? value) { + if (value == null) return AddressError.none; + return AddressError.values.firstWhere((e) => e.name == value, orElse: () => AddressError.none); +} + +AmountError _parseAmountError(String? value) { + if (value == null) return AmountError.none; + return AmountError.values.firstWhere((e) => e.name == value, orElse: () => AmountError.none); +} + +/// UtxoState 리스트를 JSON 문자열 리스트로 변환 +List _utxoListToJson(List utxoList) { + return utxoList.map((utxo) { + return jsonEncode({ + 'transactionHash': utxo.transactionHash, + 'index': utxo.index, + 'amount': utxo.amount, + 'derivationPath': utxo.derivationPath, + 'blockHeight': utxo.blockHeight, + 'to': utxo.to, + 'timestamp': utxo.timestamp.toIso8601String(), + 'tags': + utxo.tags + ?.map( + (tag) => { + 'id': tag.id, + 'walletId': tag.walletId, + 'name': tag.name, + 'colorIndex': tag.colorIndex, + 'utxoIdList': tag.utxoIdList, + }, + ) + .toList(), + 'status': utxo.status.name, + 'spentByTransactionHash': utxo.spentByTransactionHash, + }); + }).toList(); +} + +/// JSON 문자열 리스트를 UtxoState 리스트로 변환 +List _jsonToUtxoList(List utxoListJson) { + return utxoListJson.map((jsonString) { + final json = jsonDecode(jsonString) as Map; + final tagsJson = json['tags'] as List?; + final tags = + tagsJson?.map((tagJson) { + return UtxoTag( + id: tagJson['id'] as String, + walletId: tagJson['walletId'] as int, + name: tagJson['name'] as String, + colorIndex: tagJson['colorIndex'] as int, + utxoIdList: (tagJson['utxoIdList'] as List?)?.map((e) => e as String).toList(), + ); + }).toList(); + + final statusString = json['status'] as String? ?? 'unspent'; + final status = UtxoStatus.values.firstWhere((s) => s.name == statusString, orElse: () => UtxoStatus.unspent); + + return UtxoState( + transactionHash: json['transactionHash'] as String, + index: json['index'] as int, + amount: json['amount'] as int, + derivationPath: json['derivationPath'] as String, + blockHeight: json['blockHeight'] as int, + to: json['to'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + tags: tags, + status: status, + spentByTransactionHash: json['spentByTransactionHash'] as String?, + ); + }).toList(); +} + +/// SendViewModel 데이터를 RealmTransactionDraft로 변환 +RealmTransactionDraft _mapToRealmTransactionDraft({ + required int id, + required int walletId, + required List recipientList, + required String? feeRateText, + required bool isMaxMode, + required bool isMultisig, + required bool isFeeSubtractedFromSendAmount, + lib.Transaction? transaction, + String? txWaitingForSign, + String? signedPsbtBase64Encoded, + required String currentUnit, + List? selectedUtxoList, + int? totalAmount, +}) { + final feeRate = feeRateText != null && feeRateText.isNotEmpty ? double.tryParse(feeRateText) : null; + final transactionHex = transaction?.serialize(); + + return RealmTransactionDraft( + id, + walletId, + feeRate: feeRate, + isMaxMode: isMaxMode, + isMultisig: isMultisig, + isFeeSubtractedFromSendAmount: isFeeSubtractedFromSendAmount, + transactionHex: transactionHex, + txWaitingForSign: txWaitingForSign, + signedPsbtBase64Encoded: signedPsbtBase64Encoded, + recipientListJson: _recipientListToJson(recipientList), + createdAt: DateTime.now(), + currentUnit: currentUnit, + selectedUtxoListJson: selectedUtxoList != null ? _utxoListToJson(selectedUtxoList) : const [], + totalAmount: totalAmount, + ); +} + +class TransactionDraftRepository extends BaseRepository { + final UtxoRepository _utxoRepository; + final SecureStorageRepository _secureStorage = SecureStorageRepository(); + + TransactionDraftRepository(super._realmManager, this._utxoRepository); + + /// Unsigned TransactionDraft 저장 + Future> saveUnsignedTransactionDraft({ + required int walletId, + List? recipientList, + required String? feeRateText, + required bool isMaxMode, + required bool isMultisig, + bool? isFeeSubtractedFromSendAmount, + lib.Transaction? transaction, + String? txWaitingForSign, + String? signedPsbtBase64Encoded, + required String currentUnit, + List? selectedUtxoList, + int? totalAmount, + }) async { + // SignedTransactionDraft인 경우 SecureStorage에만 저장 + if (signedPsbtBase64Encoded != null && signedPsbtBase64Encoded.isNotEmpty) { + return await _saveSignedTransactionDraftToSecureStorage( + walletId: walletId, + recipientList: recipientList ?? [], + feeRateText: feeRateText, + isMaxMode: isMaxMode, + isMultisig: isMultisig, + isFeeSubtractedFromSendAmount: isFeeSubtractedFromSendAmount ?? false, + transaction: transaction, + txWaitingForSign: txWaitingForSign, + signedPsbtBase64Encoded: signedPsbtBase64Encoded, + currentUnit: currentUnit, + selectedUtxoList: selectedUtxoList, + totalAmount: totalAmount, + ); + } + + // UnsignedTransactionDraft는 Realm에 저장 + return handleAsyncRealm(() async { + final lastId = getLastId(realm, (RealmTransactionDraft).toString()); + final newId = lastId + 1; + + final draft = _mapToRealmTransactionDraft( + id: newId, + walletId: walletId, + recipientList: recipientList ?? [], + feeRateText: feeRateText, + isMaxMode: isMaxMode, + isMultisig: isMultisig, + isFeeSubtractedFromSendAmount: isFeeSubtractedFromSendAmount ?? false, + transaction: transaction, + txWaitingForSign: txWaitingForSign, + signedPsbtBase64Encoded: null, // Unsigned는 null + currentUnit: currentUnit, + selectedUtxoList: selectedUtxoList, + totalAmount: totalAmount, + ); + + await realm.writeAsync(() { + realm.add(draft); + }); + + saveLastId(realm, (RealmTransactionDraft).toString(), newId); + + return draft; + }); + } + + /// TransactionDraft 업데이트 + Future> updateUnsignedTransactionDraft({ + required int id, + int? walletId, + List? recipientList, + String? feeRateText, + bool? isMaxMode, + bool? isMultisig, + bool? isFeeSubtractedFromSendAmount, + lib.Transaction? transaction, + String? txWaitingForSign, + String? signedPsbtBase64Encoded, + String? currentUnit, + List? selectedUtxoList, + }) async { + return handleAsyncRealm(() async { + final draft = realm.find(id); + if (draft == null) { + throw StateError('[updateTransactionDraft] Draft not found: $id'); + } + + await realm.writeAsync(() { + if (walletId != null) draft.walletId = walletId; + if (feeRateText != null) { + draft.feeRate = feeRateText.isNotEmpty ? double.tryParse(feeRateText) : null; + } + if (isMaxMode != null) draft.isMaxMode = isMaxMode; + if (isMultisig != null) draft.isMultisig = isMultisig; + if (isFeeSubtractedFromSendAmount != null) draft.isFeeSubtractedFromSendAmount = isFeeSubtractedFromSendAmount; + if (transaction != null) draft.transactionHex = transaction.serialize(); + if (txWaitingForSign != null) draft.txWaitingForSign = txWaitingForSign; + if (signedPsbtBase64Encoded != null) draft.signedPsbtBase64Encoded = signedPsbtBase64Encoded; + if (recipientList != null) { + draft.recipientListJson.clear(); + draft.recipientListJson.addAll(_recipientListToJson(recipientList)); + } + if (currentUnit != null) draft.currentUnit = currentUnit; + if (selectedUtxoList != null) { + draft.selectedUtxoListJson.clear(); + draft.selectedUtxoListJson.addAll(_utxoListToJson(selectedUtxoList)); + } + }); + + return draft; + }); + } + + /// Unsigned TransactionDraft 조회 (ID로) + RealmTransactionDraft? getUnsignedTransactionDraft(int id) { + return realm.find(id); + } + + /// 모든 Unsigned TransactionDraft 조회 + List getAllUnsignedTransactionDrafts() { + return realm.all().toList(); + } + + /// walletId로 Unsigned TransactionDraft 조회 + List getUnsignedTransactionDraftsByWalletId(int walletId) { + return realm.query('walletId == $walletId').toList(); + } + + /// RealmTransactionDraft를 RecipientInfo 리스트와 함께 반환하는 헬퍼 클래스 + TransactionDraftData? getUnsignedTransactionDraftData(int id) { + final draft = getUnsignedTransactionDraft(id); + if (draft == null) return null; + + return TransactionDraftData( + draft: draft, + recipientList: _jsonToRecipientList(draft.recipientListJson.toList()), + transaction: draft.transactionHex != null ? lib.Transaction.parse(draft.transactionHex!) : null, + ); + } + + /// Signed TransactionDraft 삭제 + Future> deleteTransactionDraft(int id) async { + return await deleteSignedTransactionDraft(id); + } + + /// Unsigned TransactionDraft 삭제 + Future> deleteUnsignedTransactionDraft(int id) async { + return handleAsyncRealm(() async { + final draft = realm.find(id); + if (draft == null) { + throw StateError('[deleteTransactionDraft] Draft not found: $id'); + } + + await realm.writeAsync(() { + realm.delete(draft); + }); + }); + } + + /// 모든 TransactionDraft 삭제 + Future> deleteAllUnsignedTransactionDrafts() async { + return handleAsyncRealm(() async { + await realm.writeAsync(() { + realm.deleteAll(); + }); + }); + } + + /// 선택된 UTXO의 상태 확인 (unused, used, locked) + SelectedUtxoStatus getSelectedUtxoStatus(int walletId, List selectedUtxoListJson) { + if (selectedUtxoListJson.isEmpty) return SelectedUtxoStatus.unused; + + final utxoList = _utxoRepository.getUtxoStateList(walletId); + final selectedUtxoIds = _jsonToUtxoList(selectedUtxoListJson).map((utxo) => utxo.utxoId).toList(); + + bool hasUsed = false; + bool hasLocked = false; + + for (final utxo in utxoList) { + if (selectedUtxoIds.contains(utxo.utxoId)) { + if (utxo.status == UtxoStatus.locked) { + hasLocked = true; + } else if (utxo.status != UtxoStatus.unspent) { + hasUsed = true; + } + } + } + + if (hasUsed) return SelectedUtxoStatus.used; + if (hasLocked) return SelectedUtxoStatus.locked; + return SelectedUtxoStatus.unused; + } + + /// SignedTransactionDraft를 SecureStorage에 저장 + Future> _saveSignedTransactionDraftToSecureStorage({ + required int walletId, + required List recipientList, + required String? feeRateText, + required bool isMaxMode, + required bool isMultisig, + required bool isFeeSubtractedFromSendAmount, + lib.Transaction? transaction, + String? txWaitingForSign, + required String signedPsbtBase64Encoded, + required String currentUnit, + List? selectedUtxoList, + int? totalAmount, + }) async { + try { + // ID 생성 (Realm의 lastId를 사용하되, Signed는 별도로 관리) + final lastId = getLastId(realm, (RealmTransactionDraft).toString()); + final newId = lastId + 1; + + final draft = _mapToRealmTransactionDraft( + id: newId, + walletId: walletId, + recipientList: recipientList, + feeRateText: feeRateText, + isMaxMode: isMaxMode, + isMultisig: isMultisig, + isFeeSubtractedFromSendAmount: isFeeSubtractedFromSendAmount, + transaction: transaction, + txWaitingForSign: txWaitingForSign, + signedPsbtBase64Encoded: signedPsbtBase64Encoded, + currentUnit: currentUnit, + selectedUtxoList: selectedUtxoList, + totalAmount: totalAmount, + ); + + final key = '$kSecureStorageSignedTransactionDraftPrefix$newId'; + final jsonData = jsonEncode({ + 'id': draft.id, + 'walletId': draft.walletId, + 'feeRate': draft.feeRate, + 'isMaxMode': draft.isMaxMode, + 'isMultisig': draft.isMultisig, + 'isFeeSubtractedFromSendAmount': draft.isFeeSubtractedFromSendAmount, + 'transactionHex': draft.transactionHex, + 'txWaitingForSign': draft.txWaitingForSign, + 'signedPsbtBase64Encoded': draft.signedPsbtBase64Encoded, + 'recipientListJson': draft.recipientListJson.toList(), + 'createdAt': draft.createdAt?.toIso8601String(), + 'currentUnit': draft.currentUnit, + 'selectedUtxoListJson': draft.selectedUtxoListJson.toList(), + 'totalAmount': draft.totalAmount, + }); + await _secureStorage.write(key: key, value: jsonData); + + // ID를 업데이트하여 다음 SignedTransactionDraft가 다른 ID를 사용하도록 함 + saveLastId(realm, (RealmTransactionDraft).toString(), newId); + + return Result.success(draft); + } catch (e) { + return Result.failure( + AppError('SAVE_SIGNED_DRAFT_ERROR', 'Failed to save signed transaction draft: $e'), + ); + } + } + + /// SecureStorage에서 SignedTransactionDraft 조회 + Future _getSignedTransactionDraftFromSecureStorage(int id) async { + final key = '$kSecureStorageSignedTransactionDraftPrefix$id'; + try { + final jsonString = await _secureStorage.read(key: key); + if (jsonString == null) return null; + + final json = jsonDecode(jsonString) as Map; + return RealmTransactionDraft( + json['id'] as int, + json['walletId'] as int, + feeRate: json['feeRate'] as double?, + isMaxMode: json['isMaxMode'] as bool?, + isMultisig: json['isMultisig'] as bool?, + isFeeSubtractedFromSendAmount: json['isFeeSubtractedFromSendAmount'] as bool?, + transactionHex: json['transactionHex'] as String?, + txWaitingForSign: json['txWaitingForSign'] as String?, + signedPsbtBase64Encoded: json['signedPsbtBase64Encoded'] as String?, + recipientListJson: (json['recipientListJson'] as List).map((e) => e as String).toList(), + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, + currentUnit: json['currentUnit'] as String?, + selectedUtxoListJson: (json['selectedUtxoListJson'] as List).map((e) => e as String).toList(), + totalAmount: json['totalAmount'] as int?, + ); + } catch (e) { + return null; + } + } + + /// 모든 SignedTransactionDraft 조회 + Future> getAllSignedTransactionDrafts() async { + final allKeys = await _secureStorage.getAllKeys(); + final signedDraftKeys = allKeys.where((key) => key.startsWith(kSecureStorageSignedTransactionDraftPrefix)).toList(); + + final drafts = []; + for (final key in signedDraftKeys) { + final idString = key.replaceFirst(kSecureStorageSignedTransactionDraftPrefix, ''); + final id = int.tryParse(idString); + if (id != null) { + final draft = await _getSignedTransactionDraftFromSecureStorage(id); + if (draft != null) { + drafts.add(draft); + } + } + } + + return drafts; + } + + /// SignedTransactionDraft 삭제 + Future> deleteSignedTransactionDraft(int id) async { + try { + final key = '$kSecureStorageSignedTransactionDraftPrefix$id'; + await _secureStorage.delete(key: key); + return Result.success(null); + } catch (e) { + return Result.failure( + AppError('DELETE_SIGNED_DRAFT_ERROR', 'Failed to delete signed transaction draft: $e'), + ); + } + } + + /// 모든 TransactionDraft 조회 (Unsigned + Signed) + Future> getAllTransactionDrafts() async { + final unsignedDrafts = getAllUnsignedTransactionDrafts(); + final signedDrafts = await getAllSignedTransactionDrafts(); + + final allDrafts = [...unsignedDrafts, ...signedDrafts]; + + // createdAt 기준 최신순으로 정렬 + allDrafts.sort((a, b) { + final aCreatedAt = a.createdAt; + final bCreatedAt = b.createdAt; + if (aCreatedAt == null && bCreatedAt == null) return 0; + if (aCreatedAt == null) return 1; + if (bCreatedAt == null) return -1; + return bCreatedAt.compareTo(aCreatedAt); + }); + + return allDrafts; + } +} + +/// TransactionDraft와 변환된 데이터를 함께 담는 클래스 +class TransactionDraftData { + final RealmTransactionDraft draft; + final List recipientList; + final lib.Transaction? transaction; + + TransactionDraftData({required this.draft, required this.recipientList, required this.transaction}); +} diff --git a/lib/repository/realm/wallet_repository.dart b/lib/repository/realm/wallet_repository.dart index 7dc58835c..e2458e47f 100644 --- a/lib/repository/realm/wallet_repository.dart +++ b/lib/repository/realm/wallet_repository.dart @@ -147,6 +147,7 @@ class WalletRepository extends BaseRepository { } final transactions = realm.query('walletId == $walletId'); + final transactionDrafts = realm.query('walletId == $walletId'); final walletBalance = realm.query('walletId == $walletId'); final walletAddress = realm.query('walletId == $walletId'); final utxos = realm.query('walletId == $walletId'); @@ -170,6 +171,9 @@ class WalletRepository extends BaseRepository { if (transactions.isNotEmpty) { realm.deleteMany(transactions); } + if (transactionDrafts.isNotEmpty) { + realm.deleteMany(transactionDrafts); + } if (realmMultisigWallet != null) { realm.delete(realmMultisigWallet); } diff --git a/lib/screens/home/wallet_home_screen.dart b/lib/screens/home/wallet_home_screen.dart index ec8ac04b2..1d8e85087 100644 --- a/lib/screens/home/wallet_home_screen.dart +++ b/lib/screens/home/wallet_home_screen.dart @@ -216,6 +216,7 @@ class _WalletHomeScreenState extends State with TickerProvider _scrollController = ScrollController(); _dropdownActions = [ + () => Navigator.pushNamed(context, '/transaction-draft'), () => CommonBottomSheets.showCustomHeightBottomSheet( context: context, child: const GlossaryBottomSheet(), @@ -1145,6 +1146,7 @@ class _WalletHomeScreenState extends State with TickerProvider CoconutPulldownMenuGroup( groupTitle: t.tool, items: [ + CoconutPulldownMenuItem(title: t.transaction_draft.title), if (showGlossary) CoconutPulldownMenuItem(title: t.glossary), CoconutPulldownMenuItem(title: t.mnemonic_wordlist), if (NetworkType.currentNetworkType.isTestnet) CoconutPulldownMenuItem(title: t.tutorial), @@ -1184,6 +1186,12 @@ class _WalletHomeScreenState extends State with TickerProvider void _handleDropdownSelection(int index, bool showGlossary) { int adjustedIndex = index; + if (adjustedIndex == 0) { + // 임시저장 트랜잭션 + _dropdownActions[adjustedIndex].call(); + return; + } + // 용어집이 없는 경우 인덱스 조정 if (!showGlossary) { adjustedIndex++; diff --git a/lib/screens/send/broadcasting_screen.dart b/lib/screens/send/broadcasting_screen.dart index 680fa5c35..5d0af5e58 100644 --- a/lib/screens/send/broadcasting_screen.dart +++ b/lib/screens/send/broadcasting_screen.dart @@ -11,7 +11,10 @@ import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; import 'package:coconut_wallet/providers/view_model/send/broadcasting_view_model.dart'; +import 'package:coconut_wallet/providers/view_model/send/refactor/send_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/transaction_draft_repository.dart'; import 'package:coconut_wallet/styles.dart'; import 'package:coconut_wallet/utils/alert_util.dart'; import 'package:coconut_wallet/utils/logger.dart'; @@ -19,6 +22,7 @@ import 'package:coconut_wallet/utils/result.dart'; import 'package:coconut_wallet/utils/transaction_util.dart'; import 'package:coconut_wallet/utils/vibration_util.dart'; import 'package:coconut_wallet/widgets/button/fixed_bottom_button.dart'; +import 'package:coconut_wallet/widgets/button/fixed_bottom_tween_button.dart'; import 'package:coconut_wallet/widgets/card/information_item_card.dart'; import 'package:coconut_wallet/widgets/contents/fiat_price.dart'; import 'package:coconut_wallet/widgets/floating_widget.dart'; @@ -30,7 +34,8 @@ import 'package:lottie/lottie.dart'; import 'package:provider/provider.dart'; class BroadcastingScreen extends StatefulWidget { - const BroadcastingScreen({super.key}); + final RealmTransactionDraft? transactionDraft; + const BroadcastingScreen({super.key, this.transactionDraft}); @override State createState() => _BroadcastingScreenState(); @@ -101,6 +106,17 @@ class _BroadcastingScreenState extends State { vibrateLight(); await _viewModel.updateTagsOfUsedUtxos(signedTx.transactionHash); + // transactionDraft가 있으면 삭제 + // 서명된 임시저장 트랜잭션인 경우 widget.transactionDraft가 있음 + // 서명되지 않은 임시저장 트랜잭션인 경우 widget.transactionDraft가 없고, sendInfoProvider.transactionDraftId가 있음 + if (widget.transactionDraft != null) { + final transactionDraftRepository = Provider.of(context, listen: false); + await transactionDraftRepository.deleteSignedTransactionDraft(widget.transactionDraft!.id); + } else if (_viewModel.transactionDraftId != null) { + final transactionDraftRepository = Provider.of(context, listen: false); + await transactionDraftRepository.deleteUnsignedTransactionDraft(_viewModel.transactionDraftId!); + } + if (!mounted) return; Navigator.pushNamedAndRemoveUntil( @@ -166,24 +182,54 @@ class _BroadcastingScreenState extends State { viewModel.isNetworkOn, ), if (!viewModel.isSendingDonation) - FixedBottomButton( - showGradient: false, - isActive: viewModel.isNetworkOn && viewModel.isInitDone, - onButtonClicked: () async { - if (viewModel.isNetworkOn == false) { - CoconutToast.showWarningToast(context: context, text: ErrorCodes.networkError.message); - return; - } - if (viewModel.feeBumpingType != null && viewModel.hasTransactionConfirmed()) { - await TransactionUtil.showTransactionConfirmedDialog(context); - return; - } - if (viewModel.isInitDone) { - broadcast(); - } - }, - text: t.broadcasting_screen.btn_submit, - ), + if (viewModel.transactionDraftId == null && viewModel.feeBumpingType == null) ...{ + FixedBottomTweenButton( + leftButtonRatio: 0.35, + leftButtonClicked: () async { + final transactionDraftRepository = Provider.of( + context, + listen: false, + ); + final result = await transactionDraftRepository.saveUnsignedTransactionDraft( + walletId: viewModel.walletId, + feeRateText: viewModel.feeRate?.toString() ?? '', + isMaxMode: viewModel.isMaxMode ?? false, + isMultisig: viewModel.isMultisig ?? false, + transaction: viewModel.transaction, + recipientList: + viewModel.recipientAddresses + .map((address) => RecipientInfo(address: address)) + .toList(), + txWaitingForSign: viewModel.txWaitingForSign, + signedPsbtBase64Encoded: viewModel.signedPsbtBase64Encoded, + currentUnit: context.read().currentUnit.symbol, + totalAmount: (viewModel.totalAmount ?? 0) - viewModel.fee!, + ); + + if (result.isSuccess) { + _showTransactionDraftSavedDialog(); + } else { + // 저장 실패 + debugPrint('저장 실패 ${result.error.message}'); + _showTransactionDraftSaveFailedDialog(result.error.message); + } + }, + rightButtonClicked: () async { + _onBroadcastButtonClicked(viewModel); + }, + leftText: t.transaction_draft.save, + rightText: t.broadcasting_screen.btn_submit, + ), + } else ...{ + FixedBottomButton( + showGradient: false, + isActive: viewModel.isNetworkOn && viewModel.isInitDone, + onButtonClicked: () async { + _onBroadcastButtonClicked(viewModel); + }, + text: t.broadcasting_screen.btn_submit, + ), + }, ], ), ), @@ -192,10 +238,101 @@ class _BroadcastingScreenState extends State { ); } + void _showTransactionDraftSavedDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_saved_broadcast_screen, + description: t.transaction_draft.dialog.transaction_draft_saved_description_broadcast_screen, + leftButtonText: t.transaction_draft.dialog.cancel, + rightButtonText: t.transaction_draft.dialog.move, + onTapRight: () { + Navigator.pushNamedAndRemoveUntil(context, '/transaction-draft', ModalRoute.withName("/")); + }, + onTapLeft: () { + Navigator.pop(context); + }, + ); + }, + ); + } + + void _showTransactionDraftSaveFailedDialog(String errorMessage) { + showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_save_failed, + description: errorMessage, + rightButtonText: t.transaction_draft.dialog.confirm, + onTapRight: () { + Navigator.pop(context); + }, + ); + }, + ); + } + + void _onBroadcastButtonClicked(BroadcastingViewModel viewModel) async { + if (viewModel.isNetworkOn == false) { + CoconutToast.showWarningToast(context: context, text: ErrorCodes.networkError.message); + return; + } + if (viewModel.feeBumpingType != null && viewModel.hasTransactionConfirmed()) { + await TransactionUtil.showTransactionConfirmedDialog(context); + return; + } + if (viewModel.isInitDone) { + broadcast(); + } + } + @override void initState() { super.initState(); _currentUnit = context.read().currentUnit; + + // transactionDraft가 전달된 경우 SendInfoProvider에 데이터 설정 + if (widget.transactionDraft != null) { + final sendInfoProvider = Provider.of(context, listen: false); + final draft = widget.transactionDraft!; + + sendInfoProvider.setTransactionDraftId(draft.id); + sendInfoProvider.setWalletId(draft.walletId); + + if (draft.feeRate != null) { + sendInfoProvider.setFeeRate(draft.feeRate!.toDouble()); + } + + if (draft.isMaxMode != null) { + sendInfoProvider.setIsMaxMode(draft.isMaxMode!); + } + + if (draft.isMultisig != null) { + sendInfoProvider.setIsMultisig(draft.isMultisig!); + } + + if (draft.txWaitingForSign != null && draft.txWaitingForSign!.isNotEmpty) { + sendInfoProvider.setTxWaitingForSign(draft.txWaitingForSign!); + } + + if (draft.signedPsbtBase64Encoded != null && draft.signedPsbtBase64Encoded!.isNotEmpty) { + sendInfoProvider.setSignedPsbtBase64Encoded(draft.signedPsbtBase64Encoded!); + } + + if (draft.transactionHex != null && draft.transactionHex!.isNotEmpty) { + try { + final transaction = Transaction.parse(draft.transactionHex!); + sendInfoProvider.setTransaction(transaction); + } catch (e) { + Logger.log('Failed to parse transactionHex from draft: $e'); + } + } + } + _viewModel = BroadcastingViewModel( Provider.of(context, listen: false), Provider.of(context, listen: false), diff --git a/lib/screens/send/refactor/select_wallet_bottom_sheet.dart b/lib/screens/send/refactor/select_wallet_bottom_sheet.dart index b1aecca27..5b285e082 100644 --- a/lib/screens/send/refactor/select_wallet_bottom_sheet.dart +++ b/lib/screens/send/refactor/select_wallet_bottom_sheet.dart @@ -176,25 +176,39 @@ class _SelectWalletBottomSheetState extends State { } return Row( children: [ - SizedBox( - width: Sizes.size32, - height: Sizes.size32, - child: WalletIconSmall( - walletImportSource: walletBase.walletImportSource, - iconIndex: walletBase.iconIndex, - colorIndex: walletBase.colorIndex, - gradientColors: signer != null ? ColorUtil.getGradientColors(signer) : null, + Expanded( + child: Row( + children: [ + SizedBox( + width: Sizes.size32, + height: Sizes.size32, + child: WalletIconSmall( + walletImportSource: walletBase.walletImportSource, + iconIndex: walletBase.iconIndex, + colorIndex: walletBase.colorIndex, + gradientColors: signer != null ? ColorUtil.getGradientColors(signer) : null, + ), + ), + CoconutLayout.spacing_300w, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(amountText, style: CoconutTypography.body2_14_Number), + Text( + walletBase.name, + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], ), ), - CoconutLayout.spacing_300w, - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(amountText, style: CoconutTypography.body2_14_Number), - Text(walletBase.name, style: CoconutTypography.body3_12.setColor(CoconutColors.gray400)), - ], - ), - if (isChecked) ...{const Spacer(), SvgPicture.asset('assets/svg/check.svg')}, + + if (isChecked) ...{SvgPicture.asset('assets/svg/check.svg')}, ], ); } diff --git a/lib/screens/send/refactor/select_wallet_with_options_bottom_sheet.dart b/lib/screens/send/refactor/select_wallet_with_options_bottom_sheet.dart index d72580664..2aaad458a 100644 --- a/lib/screens/send/refactor/select_wallet_with_options_bottom_sheet.dart +++ b/lib/screens/send/refactor/select_wallet_with_options_bottom_sheet.dart @@ -285,17 +285,37 @@ class _SelectWalletWithOptionsBottomSheetState extends State 10 + ? '${_selectedWalletItem!.name.substring(0, 10)}...' + : _selectedWalletItem!.name) + : t.send_screen.select_wallet, + style: CoconutTypography.body2_14_Bold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + CoconutLayout.spacing_50w, + const Icon(Icons.keyboard_arrow_down_sharp, color: CoconutColors.white, size: Sizes.size20), + ], + ), + ), ), - CoconutLayout.spacing_50w, - const Icon(Icons.keyboard_arrow_down_sharp, color: CoconutColors.white, size: Sizes.size20), ], ), - FittedBox(child: Text(amountText, style: CoconutTypography.body2_14_Number)), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(amountText, style: CoconutTypography.body2_14_Number), + ), ], ), ); diff --git a/lib/screens/send/refactor/send_screen.dart b/lib/screens/send/refactor/send_screen.dart index 8fc4ca7d3..7442dc42a 100644 --- a/lib/screens/send/refactor/send_screen.dart +++ b/lib/screens/send/refactor/send_screen.dart @@ -1,5 +1,6 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/enums/fiat_enums.dart'; +import 'package:coconut_wallet/enums/wallet_enums.dart'; import 'package:coconut_wallet/extensions/string_extensions.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; @@ -8,6 +9,9 @@ import 'package:coconut_wallet/providers/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/view_model/send/refactor/send_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/transaction_draft_repository.dart' + show TransactionDraftRepository, SelectedUtxoStatus; import 'package:coconut_wallet/screens/send/refactor/select_wallet_bottom_sheet.dart'; import 'package:coconut_wallet/screens/send/refactor/select_wallet_with_options_bottom_sheet.dart'; import 'package:coconut_wallet/screens/wallet_detail/address_list_screen.dart'; @@ -16,15 +20,18 @@ import 'package:coconut_wallet/utils/address_util.dart'; import 'package:coconut_wallet/utils/balance_format_util.dart'; import 'package:coconut_wallet/utils/dashed_border_painter.dart'; import 'package:coconut_wallet/utils/text_field_filter_util.dart'; +import 'package:coconut_wallet/utils/transaction_draft_list_util.dart'; import 'package:coconut_wallet/utils/vibration_util.dart'; import 'package:coconut_wallet/utils/wallet_util.dart'; import 'package:coconut_wallet/widgets/body/address_qr_scanner_body.dart'; import 'package:coconut_wallet/widgets/button/fixed_bottom_button.dart'; import 'package:coconut_wallet/widgets/button/shrink_animation_button.dart'; +import 'package:coconut_wallet/widgets/card/transaction_draft_card.dart'; import 'package:coconut_wallet/widgets/overlays/common_bottom_sheets.dart'; import 'package:coconut_wallet/widgets/ripple_effect.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -37,8 +44,9 @@ import 'package:tuple/tuple.dart'; class SendScreen extends StatefulWidget { final int? walletId; final SendEntryPoint sendEntryPoint; + final RealmTransactionDraft? transactionDraft; - const SendScreen({super.key, this.walletId, required this.sendEntryPoint}); + const SendScreen({super.key, this.walletId, required this.sendEntryPoint, this.transactionDraft}); @override State createState() => _SendScreenState(); @@ -49,6 +57,8 @@ class _SendScreenState extends State with SingleTickerProviderStateM final GlobalKey _addressInputFieldKey = GlobalKey(); double _addressInputFieldBottomDy = 0; // 주소 입력창의 하단 Position.dy + bool _isDropdownMenuVisible = false; + final Color keyboardToolbarGray = const Color(0xFF2E2E2E); final Color feeRateFieldGray = const Color(0xFF2B2B2B); // 스크롤 범위 연산에 사용하는 값들 @@ -89,6 +99,9 @@ class _SendScreenState extends State with SingleTickerProviderStateM bool _isQrDataHandling = false; String _previousAmountText = ""; + // Transaction draft 선택 상태 + int? _selectedDraftId; + bool get _hasKeyboard => _amountFocusNode.hasFocus || _feeRateFocusNode.hasFocus || _isAddressFocused; bool get _isAddressFocused => _addressFocusNodeList.any((e) => e.hasFocus); @@ -114,13 +127,20 @@ class _SendScreenState extends State with SingleTickerProviderStateM _onRecipientPageDeleted, widget.walletId, widget.sendEntryPoint, + widget.transactionDraft, ); + if (widget.transactionDraft != null) { + _syncAddressControllersWithRecipientList(); + } _amountFocusNode.addListener( () => setState(() { if (!_amountFocusNode.hasFocus) { _viewModel.validateAllFieldsOnFocusLost(); } + if (_isDropdownMenuVisible) { + _setDropdownMenuVisiblility(false); + } }), ); _feeRateFocusNode.addListener( @@ -227,6 +247,38 @@ class _SendScreenState extends State with SingleTickerProviderStateM setState(() {}); } + void _setDropdownMenuVisiblility(bool isVisible) { + if (isVisible) { + _feeRateController.text = _removeTrailingDot(_feeRateController.text); + _amountController.text = _removeTrailingDot(_amountController.text); + FocusManager.instance.primaryFocus?.unfocus(); + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isDropdownMenuVisible = true; + }); + } + }); + } else { + if (_isDropdownMenuVisible != isVisible) { + setState(() { + _isDropdownMenuVisible = false; + }); + } + } + } + + bool _validateEnteredAddresses() { + // 임시저장 가능한지 확인하기 위한 함수 + for (var address in _addressControllerList) { + if (address.text.isEmpty || !_viewModel.validateAddress(address.text, _addressControllerList.indexOf(address))) { + return false; + } + } + return true; + } + @override Widget build(BuildContext context) { // usableHeight: height - safeArea - toolbar @@ -246,13 +298,14 @@ class _SendScreenState extends State with SingleTickerProviderStateM } return previous ?? _viewModel; }, - child: GestureDetector( - onTap: _clearFocus, - child: Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Colors.black, - appBar: _buildAppBar(context), - body: SizedBox( + child: Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Colors.black, + appBar: _buildAppBar(context), + body: GestureDetector( + onTap: _clearFocus, + behavior: HitTestBehavior.translucent, + child: SizedBox( height: usableHeight, child: Stack( children: [ @@ -275,6 +328,7 @@ class _SendScreenState extends State with SingleTickerProviderStateM ), ), _buildFinalButton(context), + _buildDropdownMenu(), ], ), ), @@ -283,6 +337,207 @@ class _SendScreenState extends State with SingleTickerProviderStateM ); } + Widget _buildDropdownMenu() { + // unsigned draft가 있는지 확인 + final transactionDraftRepository = Provider.of(context, listen: false); + final sortedDrafts = getSortedUnsignedTransactionDrafts(transactionDraftRepository); + final hasUnsignedDrafts = sortedDrafts.isNotEmpty; + + return Positioned( + top: 0, + right: 20, + child: Visibility( + visible: _isDropdownMenuVisible, + child: CoconutPulldownMenu( + shadowColor: CoconutColors.gray800, + dividerColor: CoconutColors.gray800, + entries: [ + CoconutPulldownMenuItem(title: t.transaction_draft.save, isDisabled: !_validateEnteredAddresses()), + if (hasUnsignedDrafts) CoconutPulldownMenuItem(title: t.transaction_draft.load), + ], + onSelected: ((index, selectedText) async { + _setDropdownMenuVisiblility(false); + if (index == 0) { + final transactionDraftRepository = Provider.of(context, listen: false); + final result = await transactionDraftRepository.saveUnsignedTransactionDraft( + walletId: _viewModel.selectedWalletItem?.id ?? 0, + recipientList: _viewModel.recipientList, + feeRateText: _feeRateController.text, + isMaxMode: _viewModel.isMaxMode, + isMultisig: _viewModel.selectedWalletItem?.walletType == WalletType.multiSignature, + isFeeSubtractedFromSendAmount: _viewModel.isFeeSubtractedFromSendAmount, + transaction: null, // 서명된 트랜잭션이 있는 경우 + txWaitingForSign: null, + signedPsbtBase64Encoded: null, + currentUnit: _viewModel.currentUnit.symbol, + selectedUtxoList: _viewModel.isUtxoSelectionAuto ? null : _viewModel.selectedUtxoList, + ); + + if (result.isSuccess) { + _showTransactionDraftSavedDialog(); + } else { + // 저장 실패 + debugPrint('저장 실패 ${result.error.message}'); + _showTransactionDraftSaveFailedDialog(result.error.message); + } + } else { + _selectedDraftId = null; + CommonBottomSheets.showDraggableBottomSheet( + context: context, + childBuilder: (scrollController) { + final transactionDraftRepository = Provider.of(context, listen: false); + return _TransactionDraftBottomSheet( + scrollController: scrollController, + transactionDraftRepository: transactionDraftRepository, + selectedDraftId: _selectedDraftId, + onSelectedDraftIdChanged: (id) { + _selectedDraftId = id; + }, + onDraftSelected: (context, setSheetState, removeItem) async { + await _onDraftSelected(context, setSheetState, removeItem); + }, + ); + }, + ); + } + }), + ), + ), + ); + } + + Future _onDraftSelected(BuildContext context, StateSetter setSheetState, Function(int, int) removeItem) async { + if (_selectedDraftId == null) return; + final transactionDraftRepository = Provider.of(context, listen: false); + final draft = transactionDraftRepository.getUnsignedTransactionDraft(_selectedDraftId!); + debugPrint('draft: ${draft?.recipientListJson[0].toString()}'); + + // selectedUtxo 상태 확인 + final selectedUtxoStatus = transactionDraftRepository.getSelectedUtxoStatus( + _viewModel.selectedWalletItem?.id ?? 0, + draft?.selectedUtxoListJson ?? [], + ); + + if (selectedUtxoStatus == SelectedUtxoStatus.locked || selectedUtxoStatus == SelectedUtxoStatus.used) { + // 이미 사용되거나 [UTXO 잠금] 설정된 경우 + final description = + selectedUtxoStatus == SelectedUtxoStatus.locked + ? t.transaction_draft.dialog.transaction_has_been_locked_utxo_included + : t.transaction_draft.dialog.transaction_already_used_utxo_included; + + final shouldDelete = await showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_unavailable_to_sign, + description: description, + rightButtonText: t.confirm, + onTapLeft: () { + Navigator.pop(context, false); + }, + onTapRight: () async { + final deletedDraftId = _selectedDraftId!; + final result = await transactionDraftRepository.deleteUnsignedTransactionDraft(deletedDraftId); + if (result.isSuccess) { + await _showDeleteCompletedDialog(); + final sortedDrafts = getSortedUnsignedTransactionDrafts(transactionDraftRepository); + final index = sortedDrafts.indexWhere((d) { + try { + return d.id == deletedDraftId; + } catch (e) { + return false; + } + }); + + if (index != -1) { + removeItem(index, deletedDraftId); + } + + setSheetState(() { + _selectedDraftId = null; + }); + } + Navigator.pop(context, true); + }, + ); + }, + ); + + if (shouldDelete == true) { + return; + } + return; + } + + _viewModel.loadTransactionDraft(draft); + + // recipientList와 _addressControllerList 동기화 + // WidgetsBinding.instance.addPostFrameCallback을 사용하여 다음 프레임에 실행 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _syncAddressControllersWithRecipientList(); + } + }); + + Navigator.pop(context); + } + + void _showTransactionDraftSavedDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_saved_send_screen, + description: t.transaction_draft.dialog.transaction_draft_saved_send_screen_description, + leftButtonText: t.transaction_draft.dialog.cancel, + rightButtonText: t.transaction_draft.dialog.move, + onTapRight: () { + Navigator.pushNamedAndRemoveUntil(context, '/transaction-draft', ModalRoute.withName("/")); + }, + onTapLeft: () { + Navigator.pop(context); + }, + ); + }, + ); + } + + void _showTransactionDraftSaveFailedDialog(String errorMessage) { + showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_save_failed, + description: errorMessage, + rightButtonText: t.transaction_draft.dialog.confirm, + onTapRight: () { + Navigator.pop(context); + }, + ); + }, + ); + } + + Future _showDeleteCompletedDialog() async { + await showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_delete_completed, + description: t.transaction_draft.dialog.transaction_draft_delete_completed_description, + rightButtonText: t.transaction_draft.dialog.confirm, + onTapRight: () { + Navigator.pop(context); + }, + ); + }, + ); + } + PreferredSizeWidget _buildAppBar(BuildContext context) { return CoconutAppBar.build( height: kCoconutAppbarHeight, @@ -326,12 +581,25 @@ class _SendScreenState extends State with SingleTickerProviderStateM Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - isWalletWithoutMfp(_viewModel.selectedWalletItem) ? '-' : selectedWalletItem!.name, - style: CoconutTypography.body1_16.setColor(CoconutColors.white), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + isWalletWithoutMfp(_viewModel.selectedWalletItem) + ? '-' + : (selectedWalletItem!.name.length > 10 + ? '${selectedWalletItem.name.substring(0, 10)}...' + : selectedWalletItem.name), + style: CoconutTypography.body1_16.setColor(CoconutColors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + CoconutLayout.spacing_50w, + const Icon(Icons.keyboard_arrow_down_sharp, color: CoconutColors.white, size: 16), + ], + ), ), - CoconutLayout.spacing_50w, - const Icon(Icons.keyboard_arrow_down_sharp, color: CoconutColors.white, size: 16), ], ), if (!isWalletWithoutMfp(_viewModel.selectedWalletItem) && !isUtxoSelectionAuto) @@ -348,7 +616,23 @@ class _SendScreenState extends State with SingleTickerProviderStateM }, context: context, isBottom: true, - actionButtonList: [const SizedBox(width: 24, height: 24)], + actionButtonList: [ + SizedBox( + height: 40, + width: 40, + child: IconButton( + icon: SvgPicture.asset('assets/svg/kebab.svg'), + onPressed: () { + if (_isDropdownMenuVisible) { + _setDropdownMenuVisiblility(false); + } else { + _setDropdownMenuVisiblility(true); + } + }, + color: CoconutColors.white, + ), + ), + ], onBackPressed: () { Navigator.of(context).pop(); }, @@ -925,6 +1209,7 @@ class _SendScreenState extends State with SingleTickerProviderStateM _viewModel.addRecipient(); _amountController.text = ''; _addAddressField(); + _setDropdownMenuVisiblility(false); }, child: CustomPaint( painter: DashedBorderPainter(dashSpace: 4.0, dashWidth: 4.0, color: CoconutColors.gray600), @@ -1094,6 +1379,7 @@ class _SendScreenState extends State with SingleTickerProviderStateM iconSize: 14, padding: EdgeInsets.zero, onPressed: () async { + _setDropdownMenuVisiblility(false); if (controller.text.isEmpty) { await _showAddressScanner(index); } else { @@ -1135,6 +1421,7 @@ class _SendScreenState extends State with SingleTickerProviderStateM onTap: () { _deleteAddressField(_viewModel.currentIndex); _viewModel.deleteRecipient(); + _setDropdownMenuVisiblility(false); }, textStyle: CoconutTypography.body3_12.setColor(CoconutColors.gray400), padding: EdgeInsets.zero, @@ -1183,10 +1470,29 @@ class _SendScreenState extends State with SingleTickerProviderStateM Widget _buildAddressRow(int index, String address, String walletName, String derivationPath) { return ShrinkAnimationButton( onPressed: () { - _addressControllerList[_viewModel.currentIndex].text = address; + final currentIndex = _viewModel.currentIndex; + if (currentIndex < _addressControllerList.length) { + // 리스너를 제거하여 notifyListeners 호출 방지 + _addressControllerList[currentIndex].removeListener(_addressTextListenerList[currentIndex]); + _addressControllerList[currentIndex].text = address; + // 리스너는 빌드 완료 후 다시 추가 + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted && currentIndex < _addressControllerList.length) { + _addressControllerList[currentIndex].addListener(_addressTextListenerList[currentIndex]); + // ViewModel에 직접 설정 (이미 텍스트가 같으면 notifyListeners 호출 안 함) + _viewModel.setAddressText(address, currentIndex); + } + }); + } _viewModel.markWalletAddressForUpdate(index); - _clearFocus(); vibrateLight(); + + // _clearFocus는 한 프레임 더 지연 + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _clearFocus(); + } + }); }, defaultColor: Colors.transparent, pressedColor: CoconutColors.gray800, @@ -1590,6 +1896,8 @@ class _SendScreenState extends State with SingleTickerProviderStateM } void _onRecipientPageDeleted(int page) { + // PageController가 아직 attach되지 않았으면 (PageView가 빌드되지 않았으면) 실행하지 않음 + if (!_recipientPageController.hasClients) return; if (_recipientPageController.page == page) return; _recipientPageController.animateToPage(page, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } @@ -1649,6 +1957,9 @@ class _SendScreenState extends State with SingleTickerProviderStateM if (!focusNode.hasFocus) { _viewModel.validateAllFieldsOnFocusLost(); } + if (_isDropdownMenuVisible) { + _setDropdownMenuVisiblility(false); + } Future.delayed(const Duration(milliseconds: 1000), () { final viewMoreButtonRect = _viewMoreButtonKey.currentContext?.findRenderObject() as RenderBox; final viewMoreButtonPosition = viewMoreButtonRect.localToGlobal(Offset.zero); @@ -1693,11 +2004,18 @@ class _SendScreenState extends State with SingleTickerProviderStateM _feeRateController.text = _removeTrailingDot(_feeRateController.text); _amountController.text = _removeTrailingDot(_amountController.text); FocusManager.instance.primaryFocus?.unfocus(); - if (_isLeftDragGuideViewVisible) { - setState(() { - _isLeftDragGuideViewVisible = false; - }); - } + + // setState 변경은 빌드 완료 후 실행 + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + if (_isLeftDragGuideViewVisible || _isDropdownMenuVisible) { + setState(() { + _isLeftDragGuideViewVisible = false; + _isDropdownMenuVisible = false; + }); + } + }); } /// 텍스트 끝의 소수점을 제거하는 함수 @@ -1707,6 +2025,64 @@ class _SendScreenState extends State with SingleTickerProviderStateM } return text; } + + /// recipientList와 _addressControllerList 동기화 + void _syncAddressControllersWithRecipientList() { + final recipientListLength = _viewModel.recipientList.length; + final currentControllerLength = _addressControllerList.length; + + // recipientList의 길이에 맞춰 _addressControllerList 조정 + if (recipientListLength > currentControllerLength) { + // 부족한 만큼 추가 + for (int i = currentControllerLength; i < recipientListLength; i++) { + _addAddressField(); + } + } else if (recipientListLength < currentControllerLength) { + // 초과하는 만큼 삭제 + for (int i = currentControllerLength - 1; i >= recipientListLength; i--) { + _deleteAddressField(i); + } + } + + // 각 컨트롤러의 텍스트를 recipientList의 address로 업데이트 + // 리스너를 모두 제거한 후 텍스트를 설정하고, 나중에 다시 추가 + for (int i = 0; i < recipientListLength; i++) { + if (i < _addressControllerList.length) { + _addressControllerList[i].removeListener(_addressTextListenerList[i]); + } + } + + for (int i = 0; i < recipientListLength; i++) { + if (i < _addressControllerList.length) { + final address = _viewModel.recipientList[i].address; + if (_addressControllerList[i].text != address) { + _addressControllerList[i].text = address; + } + } + } + + // 모든 리스너를 다시 추가 (빌드 완료 후) + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + for (int i = 0; i < recipientListLength && i < _addressControllerList.length; i++) { + _addressControllerList[i].addListener(_addressTextListenerList[i]); + } + }); + + // amount 컨트롤러도 업데이트 + if (recipientListLength > 0) { + final currentIndex = _viewModel.currentIndex; + if (currentIndex < recipientListLength) { + final amount = _viewModel.recipientList[currentIndex].amount; + if (_amountController.text != amount) { + _amountController.removeListener(_amountTextListener); + _amountController.text = amount; + _previousAmountText = amount; + _amountController.addListener(_amountTextListener); + } + } + } + } } class SingleDotInputFormatter extends TextInputFormatter { @@ -1728,3 +2104,177 @@ class FinalButtonMessage { } enum RecommendedFeeFetchStatus { fetching, succeed, failed } + +class _TransactionDraftBottomSheet extends StatefulWidget { + final ScrollController scrollController; + final TransactionDraftRepository transactionDraftRepository; + final int? selectedDraftId; + final ValueChanged onSelectedDraftIdChanged; + final Future Function(BuildContext, StateSetter, Function(int, int)) onDraftSelected; + + const _TransactionDraftBottomSheet({ + required this.scrollController, + required this.transactionDraftRepository, + required this.selectedDraftId, + required this.onSelectedDraftIdChanged, + required this.onDraftSelected, + }); + + @override + State<_TransactionDraftBottomSheet> createState() => _TransactionDraftBottomSheetState(); +} + +class _TransactionDraftBottomSheetState extends State<_TransactionDraftBottomSheet> { + final GlobalKey _animatedListKey = GlobalKey(); + List _displayedDraftList = []; + int? _selectedDraftId; + static const Duration _duration = Duration(milliseconds: 300); + + @override + void initState() { + super.initState(); + _selectedDraftId = widget.selectedDraftId; + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateDisplayedList(); + }); + } + + @override + void didUpdateWidget(_TransactionDraftBottomSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedDraftId != widget.selectedDraftId) { + setState(() { + _selectedDraftId = widget.selectedDraftId; + }); + } + } + + void _updateDisplayedList() { + final sortedDrafts = getSortedUnsignedTransactionDrafts(widget.transactionDraftRepository); + if (mounted) { + setState(() { + _displayedDraftList = List.from(sortedDrafts); + }); + } + } + + @override + Widget build(BuildContext context) { + final sortedDrafts = getSortedUnsignedTransactionDrafts(widget.transactionDraftRepository); + + if (_displayedDraftList.length != sortedDrafts.length || + !_displayedDraftList.every((draft) => sortedDrafts.any((s) => s.id == draft.id))) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _displayedDraftList = List.from(sortedDrafts); + }); + } + }); + } + + return Scaffold( + appBar: CoconutAppBar.build(title: t.transaction_draft.title, context: context, isBottom: true), + backgroundColor: CoconutColors.gray900, + body: SafeArea( + child: Stack( + children: [ + ClipRect( + clipBehavior: Clip.none, + child: AnimatedList( + key: ValueKey('${_displayedDraftList.length}'), + controller: widget.scrollController, + initialItemCount: _displayedDraftList.length, + itemBuilder: (context, index, animation) { + if (index >= _displayedDraftList.length) { + return const SizedBox.shrink(); + } + + final transactionDraft = _displayedDraftList[index]; + try { + transactionDraft.id; + } catch (e) { + return const SizedBox.shrink(); + } + + final draft = transactionDraft; + + return Column( + children: [ + if (index == 0) CoconutLayout.spacing_300h, + TransactionDraftCard( + transactionDraft: draft, + isSelectable: true, + isSelected: _selectedDraftId == draft.id, + onTap: () { + setState(() { + if (_selectedDraftId == draft.id) { + _selectedDraftId = null; + widget.onSelectedDraftIdChanged(null); + } else { + _selectedDraftId = draft.id; + widget.onSelectedDraftIdChanged(draft.id); + } + }); + }, + ), + CoconutLayout.spacing_300h, + if (index == _displayedDraftList.length - 1) CoconutLayout.spacing_2500h, + ], + ); + }, + ), + ), + FixedBottomButton( + onButtonClicked: () async { + await widget.onDraftSelected(context, setState, removeItem); + }, + isActive: _selectedDraftId != null, + text: t.select, + backgroundColor: CoconutColors.white, + ), + ], + ), + ), + ); + } + + Widget _buildRemoveCardPlaceholder(Animation animation) { + return SizeTransition( + sizeFactor: animation, + child: FadeTransition( + opacity: animation, + child: Column( + children: [ + if (_displayedDraftList.isNotEmpty) CoconutLayout.spacing_300h, + Container( + decoration: BoxDecoration(color: CoconutColors.gray800, borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: Sizes.size24, vertical: Sizes.size16), + height: 120, + ), + ], + ), + ), + ); + } + + void removeItem(int index, int draftId) { + setState(() { + _displayedDraftList.removeWhere((draft) { + try { + return draft.id == draftId; + } catch (e) { + return false; + } + }); + }); + + if (_animatedListKey.currentState != null && index >= 0 && index < _displayedDraftList.length + 1) { + _animatedListKey.currentState?.removeItem( + index, + (context, animation) => _buildRemoveCardPlaceholder(animation), + duration: _duration, + ); + } + } +} diff --git a/lib/screens/send/refactor/utxo_selection_screen.dart b/lib/screens/send/refactor/utxo_selection_screen.dart index 4e962d39a..8b4f94124 100644 --- a/lib/screens/send/refactor/utxo_selection_screen.dart +++ b/lib/screens/send/refactor/utxo_selection_screen.dart @@ -83,65 +83,67 @@ class _UtxoSelectionScreenState extends State { context: context, onBackPressed: () => Navigator.pop(context), ), - body: Stack( - children: [ - Column( - children: [ - Visibility( - visible: !viewModel.isNetworkOn, - maintainSize: false, - maintainAnimation: false, - maintainState: false, - child: NetworkErrorTooltip(isNetworkOn: viewModel.isNetworkOn), - ), - SelectedUtxoAmountHeader( - orderDropdownButtonKey: _orderDropdownButtonKey, - orderText: _viewModel.utxoOrder.text, - selectedUtxoCount: viewModel.selectedUtxoList.length, - selectedUtxoAmountSum: viewModel.selectedUtxoAmountSum, - currentUnit: widget.currentUnit, - onSelectAll: _selectAll, - onUnselectAll: _deselectAll, - onToggleOrderDropdown: () { - setState(() { - _isOrderDropdownVisible = !_isOrderDropdownVisible; - }); - }, - ), - _buildUtxoTagList(viewModel), - Expanded( - child: Stack( - children: [ - SingleChildScrollView( - controller: _scrollController, - child: Column( - children: [ - _buildUtxoList(viewModel), - CoconutLayout.spacing_400h, - const SizedBox(height: 50), - ], + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + Visibility( + visible: !viewModel.isNetworkOn, + maintainSize: false, + maintainAnimation: false, + maintainState: false, + child: NetworkErrorTooltip(isNetworkOn: viewModel.isNetworkOn), + ), + SelectedUtxoAmountHeader( + orderDropdownButtonKey: _orderDropdownButtonKey, + orderText: _viewModel.utxoOrder.text, + selectedUtxoCount: viewModel.selectedUtxoList.length, + selectedUtxoAmountSum: viewModel.selectedUtxoAmountSum, + currentUnit: widget.currentUnit, + onSelectAll: _selectAll, + onUnselectAll: _deselectAll, + onToggleOrderDropdown: () { + setState(() { + _isOrderDropdownVisible = !_isOrderDropdownVisible; + }); + }, + ), + _buildUtxoTagList(viewModel), + Expanded( + child: Stack( + children: [ + SingleChildScrollView( + controller: _scrollController, + child: Column( + children: [ + _buildUtxoList(viewModel), + CoconutLayout.spacing_400h, + const SizedBox(height: 50), + ], + ), + ), + FixedBottomButton( + buttonHeight: 50, + onButtonClicked: () { + vibrateLight(); + Navigator.pop(context, _viewModel.selectedUtxoList); + }, + text: t.complete, + isActive: _viewModel.hasSelectionChanged, + showGradient: true, + gradientPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 40, top: 110), + horizontalPadding: 16, + backgroundColor: CoconutColors.white, ), - ), - FixedBottomButton( - buttonHeight: 50, - onButtonClicked: () { - vibrateLight(); - Navigator.pop(context, _viewModel.selectedUtxoList); - }, - text: t.complete, - isActive: _viewModel.hasSelectionChanged, - showGradient: true, - gradientPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 40, top: 110), - horizontalPadding: 16, - backgroundColor: CoconutColors.white, - ), - ], + ], + ), ), - ), - ], - ), - _buildUtxoOrderDropdown(viewModel), - ], + ], + ), + _buildUtxoOrderDropdown(viewModel), + ], + ), ), ), ), diff --git a/lib/screens/transaction_draft/transaction_draft_screen.dart b/lib/screens/transaction_draft/transaction_draft_screen.dart new file mode 100644 index 000000000..d4d93b5ad --- /dev/null +++ b/lib/screens/transaction_draft/transaction_draft_screen.dart @@ -0,0 +1,437 @@ +import 'dart:async'; + +import 'package:coconut_design_system/coconut_design_system.dart'; +import 'package:coconut_wallet/localization/strings.g.dart'; +import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/send_info_provider.dart'; +import 'package:coconut_wallet/providers/view_model/transaction_draft/transaction_draft_view_model.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/transaction_draft_repository.dart'; +import 'package:coconut_wallet/utils/vibration_util.dart'; +import 'package:coconut_wallet/widgets/card/transaction_draft_card.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TransactionDraftScreen extends StatefulWidget { + const TransactionDraftScreen({super.key}); + + @override + State createState() => _TransactionDraftScreenState(); +} + +class _TransactionDraftScreenState extends State { + final bool _isInitializing = false; + bool? _isSignedTransactionSelected; + + /// 스크롤 + final bool _isScrollOverTitleHeight = false; + late ScrollController _controller; + + /// 현재 열린 카드 ID (스와이프된 카드) + int? _swipedCardId; + + /// AnimatedList를 위한 키 + final GlobalKey _animatedListKey = GlobalKey(); + + /// 현재 표시 중인 리스트 (AnimatedList용) + List _displayedDraftList = []; + + /// 애니메이션 duration + static const Duration _duration = Duration(milliseconds: 300); + + /// 초기 선택 상태가 설정되었는지 여부 + bool _initialSelectionSet = false; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: + (_) => + TransactionDraftViewModel(Provider.of(context, listen: false)) + ..initializeDraftList(), + child: Consumer( + builder: (context, viewModel, child) { + // 초기 선택 상태 설정 (한 번만 실행, initializeDraftList 완료 후) + if (!_initialSelectionSet && viewModel.isInitialized) { + final signedList = viewModel.signedTransactionDraftList; + final unsignedList = viewModel.unsignedTransactionDraftList; + if (signedList.isNotEmpty) { + // 서명 완료 탭에 데이터가 있으면 서명 완료 탭 선택 + _isSignedTransactionSelected = true; + } else if (unsignedList.isNotEmpty) { + // 서명 완료 탭이 비어있고 서명 전 탭이 비어있지 않으면 서명 전 탭 선택 + _isSignedTransactionSelected = false; + } else { + // 둘 다 비어있으면 서명 완료 탭 선택 (기본값) + _isSignedTransactionSelected = true; + } + _initialSelectionSet = true; + } + + // ViewModel 리스트와 _displayedDraftList 동기화 + final currentList = + (_isSignedTransactionSelected ?? true) + ? viewModel.signedTransactionDraftList + : viewModel.unsignedTransactionDraftList; + + // 초기 로드 시 또는 세그먼트 전환 시에만 동기화 + if (_displayedDraftList.isEmpty && currentList.isNotEmpty) { + // setState를 사용하여 상태 업데이트 (다음 프레임에 AnimatedList가 올바른 initialItemCount로 생성됨) + Future.microtask(() { + if (mounted) { + setState(() { + _displayedDraftList = List.from(currentList); + }); + } + }); + } + + return Scaffold( + backgroundColor: CoconutColors.black, + appBar: _buildAppBar(context), + body: Column( + children: [ + _buildSegmentedControl(context, viewModel), + Expanded(child: _buildTransactionDraftList(currentList, viewModel)), + ], + ), + ); + }, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + } + + Future scrollToTop() async { + if (_controller.hasClients) { + await _controller.animateTo(0, duration: const Duration(milliseconds: 500), curve: Curves.decelerate); + } + } + + Future _showDeleteCompletedDialog() async { + await showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_delete_completed, + description: t.transaction_draft.dialog.transaction_draft_delete_completed_description, + rightButtonText: t.transaction_draft.dialog.confirm, + onTapRight: () { + Navigator.pop(context); + }, + ); + }, + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return CoconutAppBar.build( + context: context, + backgroundColor: _isScrollOverTitleHeight ? CoconutColors.black.withOpacity(0.5) : CoconutColors.black, + title: t.transaction_draft.title, + isBottom: true, + ); + } + + Widget _buildSegmentedControl(BuildContext context, TransactionDraftViewModel viewModel) { + return Padding( + padding: const EdgeInsets.only(top: 14, bottom: 14, left: 16, right: 16), + child: CoconutSegmentedControl( + labels: [t.transaction_draft.signed, t.transaction_draft.unsigned], + isSelected: [_isSignedTransactionSelected ?? true, !(_isSignedTransactionSelected ?? true)], + onPressed: (index) async { + final wasSignedSelected = _isSignedTransactionSelected ?? true; + if (index == 0) { + if (!(_isSignedTransactionSelected ?? true)) { + setState(() { + _isSignedTransactionSelected = true; + }); + } + } else { + if (_isSignedTransactionSelected ?? true) { + setState(() { + _isSignedTransactionSelected = false; + }); + } + } + + // 세그먼트 전환 시 _displayedDraftList 초기화 + if (wasSignedSelected != (_isSignedTransactionSelected ?? true)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final newList = + (_isSignedTransactionSelected ?? true) + ? viewModel.signedTransactionDraftList + : viewModel.unsignedTransactionDraftList; + setState(() { + _displayedDraftList = List.from(newList); + _swipedCardId = null; + }); + } + }); + } + + await scrollToTop(); + }, + ), + ); + } + + Widget _buildTransactionDraftList( + List transactionDraftList, + TransactionDraftViewModel viewModel, + ) { + if (transactionDraftList.isEmpty) { + // 리스트가 비어있으면 _displayedDraftList도 비우기 + if (_displayedDraftList.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _displayedDraftList = []; + }); + } + }); + } + return Column(children: [CoconutLayout.spacing_2500h, Text(t.transaction_draft.empty_message)]); + } + + // 초기 로드가 완료되지 않았으면 로딩 표시 + if (_displayedDraftList.isEmpty && transactionDraftList.isNotEmpty) { + return Container(); + } + + return _isInitializing + ? const Center(child: CircularProgressIndicator(color: CoconutColors.white)) + : GestureDetector( + onTap: () { + // 화면 탭 시 열린 카드 닫기 + if (_swipedCardId != null) { + setState(() { + _swipedCardId = null; + }); + } + }, + child: AnimatedList( + key: ValueKey('${_displayedDraftList.length}_$_isSignedTransactionSelected'), + initialItemCount: _displayedDraftList.length, + itemBuilder: (context, index, animation) { + if (index >= _displayedDraftList.length) { + return const SizedBox.shrink(); + } + + // Realm 객체가 유효한지 확인 + final transactionDraft = _displayedDraftList[index]; + int? cardId; + try { + cardId = transactionDraft.id; + } catch (e) { + // Realm 객체가 이미 invalidated된 경우 + return const SizedBox.shrink(); + } + + return Column( + children: [ + if (index > 0) CoconutLayout.spacing_300h, + _buildTransactionDraftCard(transactionDraft, index, animation, cardId, viewModel), + ], + ); + }, + ), + ); + } + + Widget _buildTransactionDraftCard( + RealmTransactionDraft transactionDraft, + int index, + Animation animation, + int cardId, + TransactionDraftViewModel viewModel, + ) { + return GestureDetector( + onHorizontalDragStart: (details) { + // 다른 카드가 열려있으면 먼저 닫기 + if (_swipedCardId != null && _swipedCardId != cardId) { + setState(() { + _swipedCardId = null; + }); + } + }, + child: TransactionDraftCard( + transactionDraft: transactionDraft, + isSwiped: _swipedCardId == cardId, + onSwipeChanged: (isSwiped) { + setState(() { + _swipedCardId = isSwiped ? cardId : null; + }); + }, + onTap: () { + _handleTransactionDraftCardTap(transactionDraft, viewModel); + }, + onDelete: () async { + final transactionDraftRepository = Provider.of(context, listen: false); + try { + showDialog( + context: context, + builder: (context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_delete, + description: t.transaction_draft.dialog.transaction_draft_delete_description, + leftButtonText: t.cancel, + rightButtonText: t.confirm, + rightButtonColor: CoconutColors.white, + onTapRight: () async { + Navigator.pop(context); + + // 삭제될 아이템 인덱스와 ID 저장 + final deletedIndex = index; + final deletedCardId = cardId; + + // Realm 객체 삭제 먼저 수행 + final result = await transactionDraftRepository.deleteUnsignedTransactionDraft(cardId); + + if (result.isSuccess) { + // _displayedDraftList에서 해당 ID를 가진 항목만 제거 (인덱스로 접근하면 안됨) + setState(() { + _displayedDraftList.removeWhere((draft) { + try { + return draft.id == deletedCardId; + } catch (e) { + // invalidated 객체는 제거 + return false; + } + }); + _swipedCardId = null; + }); + + // 애니메이션과 함께 삭제 + // 삭제된 후에는 _displayedDraftList의 길이가 줄어들지만, + // AnimatedList는 removeItem이 호출될 때까지 원래 길이를 유지 + if (_animatedListKey.currentState != null && deletedIndex >= 0) { + _animatedListKey.currentState?.removeItem( + deletedIndex, + (context, animation) => _buildRemoveCardPlaceholder(animation), + duration: _duration, + ); + vibrateLight(); + } + + await _showDeleteCompletedDialog(); + } else { + vibrateLightDouble(); + if (mounted) { + showDialog( + context: context, + builder: (context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_delete_failed, + description: result.error.message, + rightButtonText: t.confirm, + rightButtonColor: CoconutColors.white, + onTapRight: () { + Navigator.pop(context); + }, + ); + }, + ); + } + } + }, + onTapLeft: () { + Navigator.pop(context); + }, + ); + }, + ); + } catch (e) { + vibrateLightDouble(); + showDialog( + context: context, + builder: (context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.transaction_draft.dialog.transaction_draft_delete_failed, + description: e.toString(), + rightButtonText: t.confirm, + rightButtonColor: CoconutColors.white, + onTapRight: () { + Navigator.pop(context); + }, + ); + }, + ); + } + }, + ), + ); + } + + Future _handleTransactionDraftCardTap( + RealmTransactionDraft transactionDraft, + TransactionDraftViewModel viewModel, + ) async { + if (transactionDraft.signedPsbtBase64Encoded != null) { + await Navigator.pushNamed(context, '/broadcasting', arguments: {'transactionDraft': transactionDraft}); + } else { + await Navigator.pushNamed( + context, + '/send', + arguments: {'walletId': null, 'sendEntryPoint': SendEntryPoint.home, 'transactionDraft': transactionDraft}, + ); + } + if (!mounted) return; + await _refreshTransactionDraftList(viewModel); + } + + Future _refreshTransactionDraftList(TransactionDraftViewModel viewModel) async { + await viewModel.initializeDraftList(); + + if (!mounted) { + return; + } + + final updatedList = + (_isSignedTransactionSelected ?? true) + ? viewModel.signedTransactionDraftList + : viewModel.unsignedTransactionDraftList; + + setState(() { + _displayedDraftList = List.from(updatedList); + _swipedCardId = null; + }); + } + + Widget _buildRemoveCardPlaceholder(Animation animation) { + return SizeTransition( + sizeFactor: animation, + child: FadeTransition( + opacity: animation, + child: Column( + children: [ + if (_displayedDraftList.isNotEmpty) CoconutLayout.spacing_300h, + // 삭제되는 카드와 동일한 높이의 플레이스홀더 + Container( + decoration: BoxDecoration(color: CoconutColors.gray800, borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: Sizes.size24, vertical: Sizes.size16), + // 대략적인 카드 높이 (타임스탬프, 지갑 정보, 주소, 수수료 등) + height: 120, + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/transaction_draft_list_util.dart b/lib/utils/transaction_draft_list_util.dart new file mode 100644 index 000000000..d22f692ad --- /dev/null +++ b/lib/utils/transaction_draft_list_util.dart @@ -0,0 +1,10 @@ +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/repository/realm/transaction_draft_repository.dart'; + +/// Transaction draft 리스트를 가져와서 최신순으로 정렬하여 반환 +List getSortedUnsignedTransactionDrafts(TransactionDraftRepository repository) { + final allDrafts = repository.getAllUnsignedTransactionDrafts(); + final unsignedDrafts = allDrafts.where((draft) => draft.signedPsbtBase64Encoded == null).toList(); + final sortedDrafts = unsignedDrafts.toList()..sort((a, b) => b.createdAt!.compareTo(a.createdAt!)); + return sortedDrafts; +} diff --git a/lib/widgets/button/fixed_bottom_tween_button.dart b/lib/widgets/button/fixed_bottom_tween_button.dart index f453633c3..5180059f9 100644 --- a/lib/widgets/button/fixed_bottom_tween_button.dart +++ b/lib/widgets/button/fixed_bottom_tween_button.dart @@ -6,8 +6,8 @@ import 'package:coconut_wallet/widgets/button/fixed_bottom_button.dart'; import 'package:flutter/material.dart'; class FixedBottomTweenButton extends StatefulWidget { - static const fixedBottomButtonDefaultHeight = 40.0; - static const fixedBottomButtonDefaultBottomPadding = 16.0; + static const fixedBottomButtonDefaultHeight = 50.0; + static const fixedBottomButtonDefaultBottomPadding = 30.0; const FixedBottomTweenButton({ super.key, diff --git a/lib/widgets/card/transaction_draft_card.dart b/lib/widgets/card/transaction_draft_card.dart new file mode 100644 index 000000000..bdc644cfc --- /dev/null +++ b/lib/widgets/card/transaction_draft_card.dart @@ -0,0 +1,434 @@ +import 'dart:convert'; + +import 'package:coconut_design_system/coconut_design_system.dart'; +import 'package:coconut_wallet/enums/fiat_enums.dart'; +import 'package:coconut_wallet/enums/wallet_enums.dart'; +import 'package:coconut_wallet/localization/strings.g.dart'; +import 'package:coconut_wallet/model/utxo/utxo_state.dart'; +import 'package:coconut_wallet/model/wallet/multisig_signer.dart'; +import 'package:coconut_wallet/model/wallet/multisig_wallet_list_item.dart'; +import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/send_info_provider.dart'; +import 'package:coconut_wallet/providers/wallet_provider.dart'; +import 'package:coconut_wallet/repository/realm/model/coconut_wallet_model.dart'; +import 'package:coconut_wallet/utils/balance_format_util.dart'; +import 'package:coconut_wallet/utils/colors_util.dart'; +import 'package:coconut_wallet/utils/datetime_util.dart'; +import 'package:coconut_wallet/widgets/button/shrink_animation_button.dart'; +import 'package:coconut_wallet/widgets/icon/wallet_icon_small.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; +import 'package:realm/realm.dart'; + +class TransactionDraftCard extends StatefulWidget { + final RealmTransactionDraft transactionDraft; + final bool? isSwiped; + final void Function(bool isSwiped)? onSwipeChanged; + final VoidCallback? onDelete; + final bool isSelectable; + final bool isSelected; + final VoidCallback? onTap; + + const TransactionDraftCard({ + super.key, + required this.transactionDraft, + this.isSwiped, + this.onSwipeChanged, + this.onDelete, + this.isSelectable = false, + this.isSelected = false, + this.onTap, + }); + + @override + State createState() => _TransactionDraftCardState(); +} + +class _TransactionDraftCardState extends State with SingleTickerProviderStateMixin { + late double _dragOffset; + final double _swipeThreshold = 0.2; // 20% 스와이프 + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _dragOffset = 0; + _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200)); + _animation = Tween( + begin: 0, + end: 0, + ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(TransactionDraftCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isSwiped != widget.isSwiped) { + if (widget.isSwiped != null && widget.isSwiped!) { + _animateToSwipedPosition(); + } else { + _animateToOriginalPosition(); + } + } + } + + void _animateToSwipedPosition() { + final screenWidth = MediaQuery.of(context).size.width; + final targetOffset = -screenWidth * _swipeThreshold; + + // 기존 리스너 제거 + _animation.removeListener(_animationListener); + _animationController.stop(); + _animationController.reset(); + + _animation = Tween( + begin: _dragOffset, + end: targetOffset, + ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); + + _animation.addListener(_animationListener); + _animationController.forward(); + } + + void _animateToOriginalPosition() { + // 기존 리스너 제거 + _animation.removeListener(_animationListener); + _animationController.stop(); + _animationController.reset(); + + _animation = Tween( + begin: _dragOffset, + end: 0, + ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); + + _animation.addListener(_animationListener); + _animation.addStatusListener((status) { + if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { + _animation.removeListener(_animationListener); + } + }); + _animationController.forward(); + } + + void _animationListener() { + if (mounted) { + setState(() { + _dragOffset = _animation.value; + }); + } + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final walletId = widget.transactionDraft.walletId; + // recipientListJson에서 amount 합산 (BTC 단위) + final totalAmount = widget.transactionDraft.recipientListJson.fold(0.0, (sum, jsonString) { + final json = jsonDecode(jsonString) as Map; + final amountStr = json['amount'] as String? ?? '0'; + final amount = double.tryParse(amountStr) ?? 0.0; + return sum + amount; + }); + final currentUnit = widget.transactionDraft.currentUnit; + // BTC를 사토시로 변환 + final totalAmountSats = currentUnit == t.btc ? UnitUtil.convertBitcoinToSatoshi(totalAmount) : totalAmount.toInt(); + final totalAmountForSignedTransaction = widget.transactionDraft.totalAmount; + final feeRate = widget.transactionDraft.feeRate; + final isMaxMode = widget.transactionDraft.isMaxMode; + final isMultisig = widget.transactionDraft.isMultisig; + final isFeeSubtractedFromSendAmount = widget.transactionDraft.isFeeSubtractedFromSendAmount; + final transactionHex = widget.transactionDraft.transactionHex; + final txWaitingForSign = widget.transactionDraft.txWaitingForSign; + final signedPsbtBase64Encoded = widget.transactionDraft.signedPsbtBase64Encoded; + final recipientListJson = widget.transactionDraft.recipientListJson; + final createdAt = widget.transactionDraft.createdAt; + final formattedCreatedAt = createdAt != null ? DateTimeUtil.formatTimestamp(createdAt.toLocal()) : []; + + final selectedUtxoListJson = widget.transactionDraft.selectedUtxoListJson; + final formattedSelectedUtxoList = + selectedUtxoListJson.map((jsonString) { + final json = jsonDecode(jsonString) as Map; + return UtxoState( + transactionHash: json['transactionHash'] as String, + index: json['index'] as int, + amount: json['amount'] as int, + derivationPath: json['derivationPath'] as String, + blockHeight: json['blockHeight'] as int, + to: json['to'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + }).toList(); + + String walletName; + int iconIndex; + int colorIndex; + WalletImportSource walletImportSource; + List? signers; + try { + final wallet = context.read().getWalletById(walletId); + walletName = wallet.name; + iconIndex = wallet.iconIndex; + colorIndex = wallet.colorIndex; + walletImportSource = wallet.walletImportSource; + if (wallet.walletType == WalletType.multiSignature) { + signers = (wallet as MultisigWalletListItem).signers; + } + } catch (e) { + // 삭제된 지갑인 경우 + walletName = t.transaction_draft.deleted_wallet; + iconIndex = 0; + colorIndex = 0; + walletImportSource = WalletImportSource.coconutVault; + signers = null; + } + + return SizedBox( + width: screenWidth - 32, + child: ShrinkAnimationButton( + onPressed: () async { + if (_dragOffset != 0) { + // 스와이프된 상태면 닫기 + widget.onSwipeChanged?.call(false); + } else { + if (widget.onTap != null) { + widget.onTap!.call(); + } else { + // 임시 저장 트랜잭션 화면에서 카드를 눌렀을 때 -> 화면 이동 + await Navigator.pushNamed( + context, + '/send', + arguments: { + 'walletId': null, + 'sendEntryPoint': SendEntryPoint.home, + 'transactionDraft': widget.transactionDraft, + }, + ); + } + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + // 삭제 버튼 + if (widget.onDelete != null) + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + color: CoconutColors.gray800, + borderRadius: BorderRadius.only(topRight: Radius.circular(12), bottomRight: Radius.circular(12)), + ), + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: screenWidth * _swipeThreshold + 20, + height: double.infinity, + child: GestureDetector( + onTap: widget.onDelete, + child: Container( + decoration: const BoxDecoration(color: CoconutColors.hotPink), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CoconutLayout.spacing_500w, + SvgPicture.asset( + 'assets/svg/trash.svg', + width: 24, + colorFilter: const ColorFilter.mode(CoconutColors.white, BlendMode.srcIn), + ), + ], + ), + ), + ), + ), + ), + ), + ), + GestureDetector( + onHorizontalDragUpdate: (details) { + if (widget.onSwipeChanged == null) return; + if (details.delta.dx < 0) { + // 왼쪽으로 드래그 + setState(() { + _dragOffset = (_dragOffset + details.delta.dx).clamp(-screenWidth * _swipeThreshold, 0); + }); + } else if (details.delta.dx > 0 && _dragOffset < 0) { + // 오른쪽으로 드래그 (복원) + setState(() { + _dragOffset = (_dragOffset + details.delta.dx).clamp(-screenWidth * _swipeThreshold, 0); + }); + } + }, + onHorizontalDragEnd: (details) { + if (widget.onSwipeChanged == null) return; + final threshold = screenWidth * _swipeThreshold; + if (_dragOffset.abs() >= threshold * 0.5) { + // 50% 이상 스와이프되면 완전히 열기 + widget.onSwipeChanged?.call(true); + _animateToSwipedPosition(); + } else { + // 그렇지 않으면 닫기 + widget.onSwipeChanged?.call(false); + _animateToOriginalPosition(); + } + }, + child: Transform.translate( + offset: Offset(_dragOffset, 0), + child: Container( + decoration: BoxDecoration(color: CoconutColors.gray800, borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: Sizes.size24, vertical: Sizes.size16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTimestamp(formattedCreatedAt), + CoconutLayout.spacing_200h, + _buildWalletNameAmount( + walletImportSource, + walletName, + totalAmountForSignedTransaction, + totalAmountSats, + iconIndex, + colorIndex, + signers, + context.read().currentUnit, + isMaxMode ?? false, + ), + CoconutLayout.spacing_200h, + _buildRecipientAddress(recipientListJson), + CoconutLayout.spacing_200h, + _buildFeeRate(feeRate ?? 0), + ], + ), + ), + ), + ), + if (widget.isSelectable && widget.isSelected) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CoconutColors.gray350, width: 1), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTimestamp(List transactionTimeStamp) { + if (transactionTimeStamp.isEmpty) { + return const SizedBox.shrink(); + } + final textStyle = CoconutTypography.body3_12_Number.setColor(CoconutColors.gray400); + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(transactionTimeStamp[0], style: textStyle), + CoconutLayout.spacing_200w, + Container(width: 1, height: 10, color: CoconutColors.gray600), + CoconutLayout.spacing_200w, + Text(transactionTimeStamp[1], style: textStyle), + ], + ); + } + + Widget _buildWalletNameAmount( + WalletImportSource walletImportSource, + String walletName, + int? totalAmountForSignedTransaction, + int amountSats, + int iconIndex, + int colorIndex, + List? signers, + BitcoinUnit currentUnit, + bool isMaxMode, + ) { + String amountString = currentUnit.displayBitcoinAmount( + totalAmountForSignedTransaction ?? amountSats, + withUnit: true, + ); + if (amountString != '0 BTC') { + amountString = '- $amountString'; + } else { + amountString = ''; + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + WalletIconSmall( + walletImportSource: walletImportSource, + iconIndex: iconIndex, + colorIndex: colorIndex, + gradientColors: signers != null ? ColorUtil.getGradientColors(signers) : null, + ), + CoconutLayout.spacing_150w, + Expanded( + child: Text( + walletName, + style: CoconutTypography.body2_14.setColor(CoconutColors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + CoconutLayout.spacing_200w, + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + isMaxMode ? t.transaction_draft.max : amountString, + style: CoconutTypography.body1_16_Number.setColor(CoconutColors.white), + ), + ), + ), + ], + ); + } + + Widget _buildRecipientAddress(RealmList recipientListJson) { + if (recipientListJson.isEmpty) { + return const SizedBox.shrink(); + } + + bool isBatchTransaction = recipientListJson.length > 1; + final firstRecipientAddress = jsonDecode(recipientListJson[0])['address'] as String; + return isBatchTransaction + ? Text( + t.transaction_draft.recipient_batch_address( + address: firstRecipientAddress, + count: recipientListJson.length - 1, + ), + style: CoconutTypography.body3_12.setColor(CoconutColors.white), + ) + : Text(firstRecipientAddress, style: CoconutTypography.body3_12.setColor(CoconutColors.white)); + } + + Widget _buildFeeRate(double feeRate) { + return Row( + children: [ + Text(t.transaction_draft.fee_rate, style: CoconutTypography.body3_12.setColor(CoconutColors.gray400)), + CoconutLayout.spacing_100w, + Text(feeRate.toString(), style: CoconutTypography.body3_12.setColor(CoconutColors.white)), + CoconutLayout.spacing_100w, + Text(t.transaction_draft.sats_per_vbyte, style: CoconutTypography.body3_12.setColor(CoconutColors.white)), + ], + ); + } +}