From 14fe9a7931539509035fc6f1d92ce81f7e401d42 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Thu, 7 Mar 2024 13:36:51 +1000 Subject: [PATCH 1/6] fix(web): fetch and sync wallet in one go Previously we relied on the regular wallet refresh to refresh the frontend. With this fix, we sync the wallet and refresh right afterwards. Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/wallet/history_screen.dart | 1 + webapp/frontend/lib/wallet/wallet_change_notifier.dart | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/frontend/lib/wallet/history_screen.dart b/webapp/frontend/lib/wallet/history_screen.dart index b817ebdd3..5af986c94 100644 --- a/webapp/frontend/lib/wallet/history_screen.dart +++ b/webapp/frontend/lib/wallet/history_screen.dart @@ -37,6 +37,7 @@ class _HistoryScreenState extends State { refreshing = true; }); await service.sync(); + await walletChangeNotifier.refresh(); setState(() { refreshing = false; }); diff --git a/webapp/frontend/lib/wallet/wallet_change_notifier.dart b/webapp/frontend/lib/wallet/wallet_change_notifier.dart index 6477035bd..1a9e804a9 100644 --- a/webapp/frontend/lib/wallet/wallet_change_notifier.dart +++ b/webapp/frontend/lib/wallet/wallet_change_notifier.dart @@ -14,13 +14,13 @@ class WalletChangeNotifier extends ChangeNotifier { List? _history; WalletChangeNotifier(this.service) { - _refresh(); + refresh(); Timer.periodic(const Duration(seconds: 30), (timer) async { - _refresh(); + await refresh(); }); } - void _refresh() async { + Future refresh() async { try { final data = await Future.wait([service.getBalance(), service.getOnChainPaymentHistory()]); From 0f64207542d0be2c56d5d66112c8cebec067cde0 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Thu, 7 Mar 2024 14:55:20 +1000 Subject: [PATCH 2/6] chore(web): redesign wallet interface putting the wallet history next to send/receive gives a better overview as the history is always shown. Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/common/truncate_text.dart | 5 + .../frontend/lib/settings/channel_screen.dart | 7 +- .../frontend/lib/wallet/history_screen.dart | 80 ++++++------- .../wallet/onchain_payment_history_item.dart | 1 + .../frontend/lib/wallet/receive_screen.dart | 7 +- webapp/frontend/lib/wallet/wallet_screen.dart | 112 ++++++++++-------- 6 files changed, 109 insertions(+), 103 deletions(-) create mode 100644 webapp/frontend/lib/common/truncate_text.dart diff --git a/webapp/frontend/lib/common/truncate_text.dart b/webapp/frontend/lib/common/truncate_text.dart new file mode 100644 index 000000000..0f0c0ee72 --- /dev/null +++ b/webapp/frontend/lib/common/truncate_text.dart @@ -0,0 +1,5 @@ +String truncateWithEllipsis(int cutoff, String text) { + return (text.length <= cutoff) + ? text + : '${text.substring(0, (cutoff / 2).floor())}...${text.substring(text.length - (cutoff / 2).ceil(), text.length)}'; +} diff --git a/webapp/frontend/lib/settings/channel_screen.dart b/webapp/frontend/lib/settings/channel_screen.dart index 5ecfe3dc7..3c9f2c3e7 100644 --- a/webapp/frontend/lib/settings/channel_screen.dart +++ b/webapp/frontend/lib/settings/channel_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get_10101/common/channel_state_label.dart'; import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/common/truncate_text.dart'; import 'package:get_10101/settings/channel_change_notifier.dart'; import 'package:get_10101/settings/channel_service.dart'; import 'package:get_10101/settings/dlc_channel.dart'; @@ -286,12 +287,6 @@ class _ChannelDetailWidgetState extends State { } } -String truncateWithEllipsis(int cutoff, String text) { - return (text.length <= cutoff) - ? text - : '${text.substring(0, (cutoff / 2).floor())}...${text.substring(text.length - (cutoff / 2).ceil(), text.length)}'; -} - Uri buildUri(String txId) { // TODO: support different networks return Uri( diff --git a/webapp/frontend/lib/wallet/history_screen.dart b/webapp/frontend/lib/wallet/history_screen.dart index 5af986c94..9a9e11c59 100644 --- a/webapp/frontend/lib/wallet/history_screen.dart +++ b/webapp/frontend/lib/wallet/history_screen.dart @@ -21,51 +21,41 @@ class _HistoryScreenState extends State { final history = walletChangeNotifier.getHistory(); - return Container( - padding: const EdgeInsets.only(top: 25), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: ElevatedButton( - onPressed: refreshing - ? null - : () async { - setState(() { - refreshing = true; - }); - await service.sync(); - await walletChangeNotifier.refresh(); - setState(() { - refreshing = false; - }); - }, - child: - refreshing ? const CircularProgressIndicator() : const Text("Refresh")), - ), - ], + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: history == null + ? [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(), + ) + ] + : history.map((item) => OnChainPaymentHistoryItem(data: item)).toList(), ), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: history == null - ? [ - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(), - ) - ] - : history.map((item) => OnChainPaymentHistoryItem(data: item)).toList(), - ), - ), - ], - ), - ], - )); + ), + ), + Padding( + padding: const EdgeInsets.all(15.0), + child: ElevatedButton( + onPressed: refreshing + ? null + : () async { + setState(() { + refreshing = true; + }); + await service.sync(); + await walletChangeNotifier.refresh(); + setState(() { + refreshing = false; + }); + }, + child: refreshing ? const CircularProgressIndicator() : const Text("Refresh")), + ), + ], + ); } } diff --git a/webapp/frontend/lib/wallet/onchain_payment_history_item.dart b/webapp/frontend/lib/wallet/onchain_payment_history_item.dart index 4b6e20cfc..059530c74 100644 --- a/webapp/frontend/lib/wallet/onchain_payment_history_item.dart +++ b/webapp/frontend/lib/wallet/onchain_payment_history_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/payment.dart'; +import 'package:get_10101/wallet/walle_history_detail_dialog.dart'; import 'package:intl/intl.dart'; import 'package:timeago/timeago.dart' as timeago; diff --git a/webapp/frontend/lib/wallet/receive_screen.dart b/webapp/frontend/lib/wallet/receive_screen.dart index 822823c8c..9380290ed 100644 --- a/webapp/frontend/lib/wallet/receive_screen.dart +++ b/webapp/frontend/lib/wallet/receive_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/common/truncate_text.dart'; import 'package:get_10101/wallet/wallet_change_notifier.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -41,9 +42,9 @@ class _ReceiveScreenState extends State { children: [ address == null ? const SizedBox.square( - dimension: 350, child: Center(child: CircularProgressIndicator())) + dimension: 200, child: Center(child: CircularProgressIndicator())) : SizedBox.square( - dimension: 350, + dimension: 200, child: QrImageView( data: address!, eyeStyle: const QrEyeStyle( @@ -71,7 +72,7 @@ class _ReceiveScreenState extends State { child: address == null ? const Center(child: CircularProgressIndicator()) : Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(address!), + Text(truncateWithEllipsis(30, address!)), GestureDetector( child: const Icon(Icons.copy, size: 20), onTap: () async { diff --git a/webapp/frontend/lib/wallet/wallet_screen.dart b/webapp/frontend/lib/wallet/wallet_screen.dart index 3d2d2517a..0984724f8 100644 --- a/webapp/frontend/lib/wallet/wallet_screen.dart +++ b/webapp/frontend/lib/wallet/wallet_screen.dart @@ -15,61 +15,75 @@ class WalletScreen extends StatefulWidget { } class _WalletScreenState extends State with SingleTickerProviderStateMixin { - late final _tabController = TabController(length: 3, vsync: this); + late final _tabController = TabController(length: 2, vsync: this); @override Widget build(BuildContext context) { - return SizedBox( - width: 500, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - TabBar( - unselectedLabelColor: Colors.black, - labelColor: tenTenOnePurple, - tabs: const [ - Tab( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(FontAwesomeIcons.clockRotateLeft, size: 20), - SizedBox(width: 10), - Text("History") - ], - )), - Tab( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(FontAwesomeIcons.arrowDown, size: 20), - SizedBox(width: 10), - Text("Receive") + return Row(children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TabBar( + unselectedLabelColor: Colors.black, + labelColor: tenTenOnePurple, + tabs: const [ + Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FontAwesomeIcons.arrowDown, size: 20), + SizedBox(width: 10), + Text("Receive") + ], + ), + ), + Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FontAwesomeIcons.arrowUp, size: 20), + SizedBox(width: 10), + Text("Withdraw") + ], + ), + ) ], + controller: _tabController, + indicatorSize: TabBarIndicatorSize.tab, ), - ), - Tab( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(FontAwesomeIcons.arrowUp, size: 20), - SizedBox(width: 10), - Text("Withdraw") - ], + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ReceiveScreen(), SendScreen()], + ), ), - ) - ], - controller: _tabController, - indicatorSize: TabBarIndicatorSize.tab, - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: const [HistoryScreen(), ReceiveScreen(), SendScreen()], - ), - ), - ], + ], + )) + ], + ), + )), + const SizedBox( + width: 5, ), - ); + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: const Column( + children: [Expanded(child: HistoryScreen())], + ))) + ]); } } From 3783c74ae49a442ab50b4d8565423744769b8e97 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Thu, 7 Mar 2024 14:56:23 +1000 Subject: [PATCH 3/6] chore(web): rename withdraw to send Withdraw reminds me of _withdrawing_ from an exchange while this is its own wallet from which you can _send_. Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/wallet/wallet_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/frontend/lib/wallet/wallet_screen.dart b/webapp/frontend/lib/wallet/wallet_screen.dart index 0984724f8..acb869791 100644 --- a/webapp/frontend/lib/wallet/wallet_screen.dart +++ b/webapp/frontend/lib/wallet/wallet_screen.dart @@ -53,7 +53,7 @@ class _WalletScreenState extends State with SingleTickerProviderSt children: [ Icon(FontAwesomeIcons.arrowUp, size: 20), SizedBox(width: 10), - Text("Withdraw") + Text("Send") ], ), ) From c9dba752658b9f24a94c95673c86d9e37a05e290 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Thu, 7 Mar 2024 14:59:17 +1000 Subject: [PATCH 4/6] feat(web): introduce tx detail dialog Signed-off-by: Philipp Hoenisch --- webapp/frontend/devtools_options.yaml | 1 + .../wallet/onchain_payment_history_item.dart | 6 +- .../wallet/walle_history_detail_dialog.dart | 144 ++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 webapp/frontend/devtools_options.yaml create mode 100644 webapp/frontend/lib/wallet/walle_history_detail_dialog.dart diff --git a/webapp/frontend/devtools_options.yaml b/webapp/frontend/devtools_options.yaml new file mode 100644 index 000000000..7e7e7f67d --- /dev/null +++ b/webapp/frontend/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/webapp/frontend/lib/wallet/onchain_payment_history_item.dart b/webapp/frontend/lib/wallet/onchain_payment_history_item.dart index 059530c74..1986343ad 100644 --- a/webapp/frontend/lib/wallet/onchain_payment_history_item.dart +++ b/webapp/frontend/lib/wallet/onchain_payment_history_item.dart @@ -30,7 +30,11 @@ class OnChainPaymentHistoryItem extends StatelessWidget { elevation: 0, child: ListTile( onTap: () async { - // todo + showDialog( + context: context, + builder: (context) { + return WalletHistoryDetailDialog(data: data); + }); }, leading: Stack(children: [ Container( diff --git a/webapp/frontend/lib/wallet/walle_history_detail_dialog.dart b/webapp/frontend/lib/wallet/walle_history_detail_dialog.dart new file mode 100644 index 000000000..1c20f4166 --- /dev/null +++ b/webapp/frontend/lib/wallet/walle_history_detail_dialog.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:get_10101/common/amount_text.dart'; +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/common/payment.dart'; +import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/common/truncate_text.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class WalletHistoryDetailDialog extends StatelessWidget { + final OnChainPayment data; + + const WalletHistoryDetailDialog({ + Key? key, + required this.data, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final formattedDate = DateFormat.yMd().add_jm().format(data.timestamp); + final (directionMultiplier, verb) = switch ((data.flow, data.confirmations)) { + (PaymentFlow.inbound, 0) => (1, "are receiving"), + (PaymentFlow.inbound, _) => (1, "received"), + (PaymentFlow.outbound, 0) => (-1, "are sending"), + (PaymentFlow.outbound, _) => (-1, "sent"), + }; + + return AlertDialog( + content: SizedBox( + width: 440, + child: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + SizedBox( + width: 50, height: 50, child: SvgPicture.asset("assets/Bitcoin_logo.svg")), + Text("You $verb"), + AmountText( + amount: Amount(data.amount.sats * directionMultiplier), + textStyle: const TextStyle(fontSize: 25, fontWeight: FontWeight.bold)), + ], + ), + HistoryDetail( + label: "When", + value: formattedDate, + truncate: false, + ), + HistoryDetail( + label: "Transaction Id", + value: data.txid, + displayWidget: TransactionIdText(data.txid)), + HistoryDetail(label: "Confirmations", value: data.confirmations.toString()), + HistoryDetail( + label: "Fee", + value: data.fee.toString(), + truncate: false, + ), + ], + ), + ), + ), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Close'), + ), + ], + ); + } +} + +class HistoryDetail extends StatelessWidget { + final String label; + final String value; + final Widget? displayWidget; + final bool truncate; + + static const TextStyle defaultValueStyle = TextStyle(fontSize: 16); + + const HistoryDetail( + {super.key, + required this.label, + required this.value, + this.displayWidget, + this.truncate = true}); + + @override + Widget build(BuildContext context) { + return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text(label, style: defaultValueStyle.copyWith(fontWeight: FontWeight.bold)), + Expanded( + child: Row(children: [ + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: displayWidget ?? + Text(truncate ? truncateWithEllipsis(10, value) : value, + style: defaultValueStyle))), + IconButton( + padding: EdgeInsets.zero, + onPressed: () { + Clipboard.setData(ClipboardData(text: value)).then((_) { + showSnackBar(ScaffoldMessenger.of(context), '$label copied to clipboard'); + }); + }, + icon: const Icon(Icons.copy, size: 18)) + ]), + ) + ]); + } +} + +class TransactionIdText extends StatelessWidget { + final String txId; + + const TransactionIdText(this.txId, {super.key}); + + @override + Widget build(BuildContext context) { + Uri uri = Uri( + scheme: 'https', + host: 'mempool.space', + pathSegments: ['tx', txId], + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text(truncateWithEllipsis(10, txId)), + IconButton( + padding: EdgeInsets.zero, + onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), + icon: const Icon(Icons.open_in_new, size: 18)) + ], + ); + } +} From 4c3a5e8b8c844d1da71d6cf4a61c8413d803760f Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Thu, 7 Mar 2024 15:13:01 +1000 Subject: [PATCH 5/6] chore(web): add system local support this will influence date and number formatting (unless overwritten). Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/main.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/webapp/frontend/lib/main.dart b/webapp/frontend/lib/main.dart index 6ca7c8f2c..fa5a5149d 100644 --- a/webapp/frontend/lib/main.dart +++ b/webapp/frontend/lib/main.dart @@ -15,15 +15,24 @@ import 'package:get_10101/trade/quote_service.dart'; import 'package:get_10101/settings/settings_service.dart'; import 'package:get_10101/wallet/wallet_change_notifier.dart'; import 'package:get_10101/wallet/wallet_service.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl_browser.dart'; import 'package:provider/provider.dart'; import 'common/color.dart'; import 'common/theme.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); buildLogger(false); logger.i("Logger initialized"); + + // Get the system's default locale + String defaultLocale = await findSystemLocale(); + + // Initialize the date format data for the system's default locale + await initializeDateFormatting(defaultLocale, null); + const walletService = WalletService(); const channelService = ChannelService(); @@ -63,6 +72,12 @@ class _TenTenOneAppState extends State { return MaterialApp.router( title: "10101", scaffoldMessengerKey: scaffoldMessengerKey, + supportedLocales: const [ + Locale('en', 'US'), + Locale('es', 'ES'), + Locale('fr', 'FR'), + Locale('de', 'DE'), + ], theme: ThemeData( primarySwatch: swatch, bottomNavigationBarTheme: const BottomNavigationBarThemeData( From 93f19b4457248f1e4f0a0e0d072729ef1696a742 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Thu, 7 Mar 2024 15:14:08 +1000 Subject: [PATCH 6/6] chore(web): format tx history date differently If the tx is older than 30 minutes, it will show the date, otherwise it will show something like 'moments ago', '15 minutes ago' Signed-off-by: Philipp Hoenisch --- .../lib/wallet/onchain_payment_history_item.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/webapp/frontend/lib/wallet/onchain_payment_history_item.dart b/webapp/frontend/lib/wallet/onchain_payment_history_item.dart index 1986343ad..720931a6e 100644 --- a/webapp/frontend/lib/wallet/onchain_payment_history_item.dart +++ b/webapp/frontend/lib/wallet/onchain_payment_history_item.dart @@ -11,6 +11,8 @@ class OnChainPaymentHistoryItem extends StatelessWidget { @override Widget build(BuildContext context) { + final formattedDate = DateFormat.yMd().add_jm().format(data.timestamp); + final statusIcon = switch (data.confirmations) { >= 3 => const Icon(Icons.check_circle, color: Colors.green, size: 18), _ => const Icon(Icons.pending, size: 18) @@ -58,7 +60,9 @@ class OnChainPaymentHistoryItem extends StatelessWidget { textWidthBasis: TextWidthBasis.longestLine, text: TextSpan(style: DefaultTextStyle.of(context).style, children: [ TextSpan( - text: timeago.format(data.timestamp), + text: wasMoreThanHalfAnHourAgo(data.timestamp) + ? formattedDate + : timeago.format(data.timestamp), style: const TextStyle(color: Colors.grey)), ])), trailing: Padding( @@ -95,3 +99,10 @@ class OnChainPaymentHistoryItem extends StatelessWidget { ); } } + +bool wasMoreThanHalfAnHourAgo(DateTime timestamp) { + DateTime now = DateTime.now(); + DateTime oneHourAgo = now.subtract(const Duration(minutes: 30)); + + return timestamp.isBefore(oneHourAgo); +}