Cw 1539 lightning enhancements (#3400)

* feat: add Lightning transaction URL support in transaction details view model

* fix: update currency selection logic in WalletAddressListViewModel and ReceivePage

* feat: integrate computed transaction amount and fee getters in TransactionDetailsViewModel, refactor CopyWrapper logic in TransactionDetailsModal

* refactor: apply lint rules

* refactor: apply lint rules and improve LNURL handling with new methods for withdrawal requests and error checks

* auto-reformat

* fix: receive_page regression

* fix: Withdraw lightning to other btc wallet gives LN address

* chore: update .lock files [skip ci]

---------

Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com>
Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Konstantin Ullrich
2026-07-17 17:49:17 +03:00
committed by GitHub
co-authored by Robert Malikowski Omar Hatem
parent 28c2cc152e
commit 03edd658a5
18 changed files with 1193 additions and 1003 deletions
@@ -59,14 +59,13 @@ class _AssetsHistorySectionState extends State<AssetsHistorySection> {
AssetsSection(
dashboardViewModel: widget.dashboardViewModel,
),
hasAssetsButton ?
AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg",
() {
Navigator.of(context).pushNamed(
hasAssetsButton ? AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg",
() {Navigator.of(context).pushNamed(
Routes.homeSettings,
arguments: widget.dashboardViewModel.balanceViewModel,
);
}) : null),
}): null),
AssetsHistorySectionTab(
S.current.history,
HistorySection(
@@ -1,24 +1,28 @@
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart';
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:cake_wallet/new-ui/widgets/copy_wrapper.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart';
import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/view_model/transaction_details_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import "package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart";
import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart";
import "package:cake_wallet/new-ui/widgets/copy_wrapper.dart";
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:cake_wallet/routes.dart";
import "package:cake_wallet/src/screens/transaction_details/address_list_item.dart";
import "package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart";
import "package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart";
import "package:cake_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:cake_wallet/utils/address_formatter.dart";
import "package:cake_wallet/view_model/transaction_details_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class TransactionDetailsModal extends StatefulWidget {
const TransactionDetailsModal(
{super.key, required this.transactionDetailsViewModel, this.highlightNoteField = false});
{
required this.transactionDetailsViewModel,
this.highlightNoteField = false,
super.key,
});
final TransactionDetailsViewModel transactionDetailsViewModel;
final bool highlightNoteField;
@@ -48,177 +52,195 @@ class _TransactionDetailsModalState extends State<TransactionDetailsModal> {
}
@override
Widget build(BuildContext context) {
final transactionInfoAmount = widget.transactionDetailsViewModel.transactionInfo.amount;
return SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Container(
decoration: BoxDecoration(
Widget build(BuildContext context) => SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(25))),
child: Column(
children: [
ModalTopBar(
title: S.of(context).transaction,
leadingIcon: Icon(Icons.close),
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Column(
children: [
TokenImageWidget(
imageUrl:
widget.transactionDetailsViewModel.transactionAsset.iconPath ?? "",
size: 64,
),
SizedBox(height: 10),
Text(
widget.transactionDetailsViewModel.formattedTitle +
widget.transactionDetailsViewModel.formattedStatus,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
CopyWrapper(
requireLongPress: true,
data: ClipboardData(text: transactionInfoAmount.toString()),
builder: (context, copied) => AnimatedSwitcher(
duration: Duration(milliseconds: 300),
child: Text(
key: ValueKey(copied),
copied
? S.of(context).copied
: transactionInfoAmount.toStringWithSymbol(),
style: TextStyle(
fontSize: 28,
color: copied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface),
borderRadius: const BorderRadius.vertical(top: Radius.circular(25)),
),
child: Column(
children: [
ModalTopBar(
title: S.of(context).transaction,
leadingIcon: const Icon(Icons.close),
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Column(
children: [
TokenImageWidget(
imageUrl:
widget.transactionDetailsViewModel.transactionAsset.iconPath ?? "",
size: 64,
),
const SizedBox(height: 10),
Text(
widget.transactionDetailsViewModel.formattedTitle +
widget.transactionDetailsViewModel.formattedStatus,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Observer(
builder: (_) => CopyWrapper(
requireLongPress: true,
data: ClipboardData(
text: widget.transactionDetailsViewModel.transactionCopyAmount,
),
builder: (context, copied) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
key: ValueKey(copied),
copied
? S.of(context).copied
: widget.transactionDetailsViewModel.transactionAmount,
style: TextStyle(
fontSize: 28,
color: copied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
spacing: 12,
children: [
NewListSections(sections: {
"": widget.transactionDetailsViewModel.items
.map((item) {
if (item.value.isEmpty) return null;
Padding(
padding: const EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
NewListSections(
sections: {
"": widget.transactionDetailsViewModel.items
.map((item) {
if (item.value.isEmpty) {
return null;
}
final shouldBuildBottomWidget = item.value.length > 25;
final shouldBuildBottomWidget = item.value.length > 25;
return ListItemRegularRow(
copyableText: item.value,
showArrow: false,
keyValue: ((item.key as ValueKey?)?.value as String?) ??
item.title,
label: item.title,
trailingWidget: shouldBuildBottomWidget
? null
: _buildTrailingWIdget(item),
bottomWidget: shouldBuildBottomWidget
? _buildBottomWidget(item)
: null);
})
.whereType<ListItem>()
.toList(),
}),
Container(
decoration: BoxDecoration(
return ListItemRegularRow(
copyableText: item.value,
showArrow: false,
keyValue: ((item.key as ValueKey?)?.value as String?) ??
item.title,
label: item.title,
trailingWidget: shouldBuildBottomWidget
? null
: _buildTrailingWidget(item),
bottomWidget: shouldBuildBottomWidget
? _buildBottomWidget(item)
: null,
);
})
.whereType<ListItem>()
.toList(),
},
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).note),
TextField(
focusNode: noteFocusNode,
controller: noteController,
decoration: InputDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).note),
TextField(
focusNode: noteFocusNode,
controller: noteController,
decoration: InputDecoration(
hintText: S.of(context).add_a_note,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true),
)
],
isDense: true,
),
),
],
),
),
),
),
Observer(
builder: (_) => NewListSections(sections: {
"view tx": [
ListItemRegularRow(
keyValue: "view tx on",
label:
widget.transactionDetailsViewModel.explorerDescription,
onTap: widget.transactionDetailsViewModel.launchExplorer,
foregroundColor: Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8)
],
if (widget.transactionDetailsViewModel.canReplaceByFee)
"rbf": [
ListItemRegularRow(
keyValue: "replace by fee",
label: S.of(context).bump_fee,
onTap: () {
Navigator.of(context)
.pushNamed(Routes.bumpFeePage, arguments: [
widget.transactionDetailsViewModel.transactionInfo,
widget.transactionDetailsViewModel.rawTransaction
]);
})
]
}),
)
],
Observer(
builder: (_) => NewListSections(
sections: {
"view tx": [
ListItemRegularRow(
keyValue: "view tx on",
label: widget
.transactionDetailsViewModel.explorerDescription,
onTap: widget.transactionDetailsViewModel.launchExplorer,
foregroundColor: Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8,
),
],
if (widget.transactionDetailsViewModel.canReplaceByFee)
"rbf": [
ListItemRegularRow(
keyValue: "replace by fee",
label: S.of(context).bump_fee,
onTap: () {
Navigator.of(context).pushNamed(
Routes.bumpFeePage,
arguments: [
widget
.transactionDetailsViewModel.transactionInfo,
widget.transactionDetailsViewModel.rawTransaction,
],
);
},
),
],
},
),
),
],
),
),
),
SizedBox(height: MediaQuery.of(context).viewPadding.bottom)
],
SizedBox(height: MediaQuery.of(context).viewPadding.bottom),
],
),
),
),
)
],
],
),
),
),
),
),
);
}
);
Widget _buildTrailingWIdget(TransactionDetailsListItem item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: switch (item.runtimeType) {
ConfirmationsListItem => Row(
children: [
Text((item as ConfirmationsListItem).current.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.primary)),
if (item.needed > 0)
Text("/${item.needed}",
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant))
],
),
_ => Text(
item.value,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
},
);
}
Widget _buildTrailingWidget(TransactionDetailsListItem item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: switch (item.runtimeType) {
ConfirmationsListItem => Row(
children: [
Text(
(item as ConfirmationsListItem).current.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
if (item.needed > 0)
Text(
"/${item.needed}",
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
_ => Text(
item.value,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
},
);
Widget _buildBottomWidget(TransactionDetailsListItem item) {
return switch (item.runtimeType) {
@@ -1,41 +1,41 @@
import 'dart:async';
import "dart:async";
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/main.dart';
import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/new-ui/widgets/send_page/l2_send_external_modal.dart';
import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_simple_checkbox.dart';
import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
import 'package:cake_wallet/view_model/send/send_view_model.dart';
import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import "package:cake_wallet/bitcoin/bitcoin.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/main.dart";
import "package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart";
import "package:cake_wallet/new-ui/widgets/new_primary_button.dart";
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:cake_wallet/new-ui/widgets/send_page/l2_send_external_modal.dart";
import "package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cake_wallet/src/widgets/new_list_row/new_simple_checkbox.dart";
import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart";
import "package:cake_wallet/view_model/send/send_view_model.dart";
import "package:cake_wallet/view_model/wallet_switcher_view_model.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
enum l2actions { deposit, withdraw }
enum L2Actions { deposit, withdraw }
class L2ActionWalletSelector extends StatefulWidget {
const L2ActionWalletSelector({
super.key,
required this.showOtherWallets,
required this.sendViewModel,
required this.action,
required this.onSendInitiated,
required this.contactListViewModel,
required this.walletSwitcherViewModel,
super.key,
});
final bool showOtherWallets;
final SendViewModel sendViewModel;
final l2actions action;
final L2Actions action;
final VoidCallback onSendInitiated;
final ContactListViewModel contactListViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
@@ -56,12 +56,18 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
super.initState();
if (widget.showOtherWallets) {
() async {
items.addAll((await WalletInfo.getAll()).where((item) =>
item.type == widget.sendViewModel.walletType && item.hardwareWalletType == null));
items.addAll(
(await WalletInfo.getAll()).where(
(item) =>
item.type == widget.sendViewModel.walletType && item.hardwareWalletType == null,
),
);
items.sort((a, b) {
if (a.name == widget.sendViewModel.wallet.name)
if (a.name == widget.sendViewModel.wallet.name) {
return -1;
else if (b.name == widget.sendViewModel.wallet.name) return 1;
} else if (b.name == widget.sendViewModel.wallet.name) {
return 1;
}
return 0;
});
setState(() {});
@@ -80,16 +86,16 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
mainAxisSize: MainAxisSize.min,
children: [
ModalTopBar(
title: widget.action == l2actions.deposit
title: widget.action == L2Actions.deposit
? "${S.of(context).send_from}..."
: "${S.of(context).receive_to}...",
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingIcon: const Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
),
Flexible(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -123,29 +129,17 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
itemBuilder: (context, index) {
final item = items[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
padding: const EdgeInsets.symmetric(vertical: 4),
child: WalletRow(
currencyIconPath: getCryptoCurrencyIconForWalletListItem(item.type),
walletName: item.name,
isCurrent: item.name == widget.sendViewModel.wallet.name,
isSelected: _selectedWalletIndex == index && !textEntered,
onTap: () async {
onTap: () {
setState(() {
addressController.text = "";
_selectedWalletIndex = index;
});
// if (widget.action == l2actions.withdraw) {
// widget.sendViewModel.outputs.first.address = item.address;
// widget.onSendInitiated();
// } else if (widget.action == l2actions.deposit) {
// setState(() {
// loadingWalletName = item.name;
// });
// await _handleChangeWallet(item);
// widget.onSendInitiated();
// setState(() {
// loadingWalletName = null;
// });
},
),
);
@@ -198,7 +192,7 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
// ),
// ),
// ),
if (widget.action == l2actions.withdraw)
if (widget.action == L2Actions.withdraw)
Row(
children: [
Flexible(
@@ -215,12 +209,12 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
Column(
spacing: 8,
children: [
if (widget.action == l2actions.deposit && widget.showOtherWallets) ...[
if (widget.action == L2Actions.deposit && widget.showOtherWallets) ...[
Container(
height: 1,
color: Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(64),
),
SizedBox(),
const SizedBox(),
Container(
height: 52,
decoration: BoxDecoration(
@@ -232,7 +226,7 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () async {
onTap: () {
Navigator.of(context, rootNavigator: true).pop();
showCupertinoModalBottomSheet(
context: navigatorKey.currentContext ?? context,
@@ -246,7 +240,7 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
@@ -275,19 +269,22 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
onPressed: () async {
if (widget.sendViewModel.wallet.type == WalletType.bitcoin ||
widget.sendViewModel.wallet.type == WalletType.litecoin) {
if (widget.action == l2actions.withdraw) {
if (widget.action == L2Actions.withdraw) {
widget.sendViewModel.outputs.first.address =
bitcoin!.getUnusedSegwitAddress(widget.sendViewModel.wallet)!;
}
if (widget.showOtherWallets) {
if (widget.action == l2actions.deposit) {
if (widget.action == L2Actions.deposit) {
await _handleChangeWallet(items[_selectedWalletIndex]);
} else {
widget.sendViewModel.outputs.first.address =
addressController.text.isNotEmpty
? addressController.text
: items[_selectedWalletIndex].address;
if (items[_selectedWalletIndex].name !=
widget.sendViewModel.wallet.name) {
widget.sendViewModel.outputs.first.address =
addressController.text.isNotEmpty
? addressController.text
: items[_selectedWalletIndex].address;
}
}
}
widget.onSendInitiated();
@@ -298,7 +295,7 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: _isLoading,
),
SizedBox()
const SizedBox()
],
),
],
@@ -339,13 +336,13 @@ class _L2ActionWalletSelectorState extends State<L2ActionWalletSelector> {
class WalletRow extends StatelessWidget {
const WalletRow({
super.key,
required this.currencyIconPath,
required this.walletName,
required this.onTap,
this.isLoading = false,
this.isCurrent = false,
this.isSelected,
super.key,
});
final String currencyIconPath;
@@ -370,7 +367,7 @@ class WalletRow extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [