Cw 921 create new bottom sheet sending confirmation UI (#2099)

* init commit

* add slide button

* address book popup

* fix colors for themes

* update exchange page

* update cake pay purchase card page

* Update rbf_details_page.dart

* refactored code

* add generating tx bottom sheet

* add show scrollbar

* minor fixes
This commit is contained in:
Serhii
2025-03-21 05:24:22 +02:00
committed by GitHub
parent 00642e6027
commit 626e532fce
12 changed files with 1192 additions and 271 deletions
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
abstract class BaseBottomSheet extends StatelessWidget {
final String titleText;
final String? titleIconPath;
const BaseBottomSheet({required this.titleText, this.titleIconPath});
Widget headerWidget(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
const Spacer(flex: 4),
Expanded(
flex: 2,
child: Container(
height: 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
),
),
),
const Spacer(flex: 4),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (titleIconPath != null)
Image.asset(titleIconPath!, height: 24, width: 24)
else
Container(),
const SizedBox(width: 6),
Text(
titleText,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
),
),
],
),
const SizedBox(height: 13),
],
);
}
Widget contentWidget(BuildContext context);
Widget footerWidget(BuildContext context);
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: 600),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(30.0), topRight: Radius.circular(30.0)),
child: Container(
color: Theme.of(context).dialogBackgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
headerWidget(context),
contentWidget(context),
footerWidget(context),
],
),
),
),
);
}
}
@@ -0,0 +1,450 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart';
import 'package:cake_wallet/themes/extensions/balance_page_theme.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/filter_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:cake_wallet/view_model/send/output.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'base_bottom_sheet_widget.dart';
class ConfirmSendingBottomSheet extends BaseBottomSheet {
final CryptoCurrency currency;
final ThemeBase currentTheme;
final String? paymentId;
final String? paymentIdValue;
final String? expirationTime;
final String amount;
final String amountValue;
final String fiatAmountValue;
final String fee;
final String feeValue;
final String feeFiatAmount;
final List<Output> outputs;
final VoidCallback onSlideComplete;
final PendingChange? change;
ConfirmSendingBottomSheet({
required String titleText,
String? titleIconPath,
required this.currency,
required this.currentTheme,
this.paymentId,
this.paymentIdValue,
this.expirationTime,
required this.amount,
required this.amountValue,
required this.fiatAmountValue,
required this.fee,
required this.feeValue,
required this.feeFiatAmount,
required this.outputs,
required this.onSlideComplete,
this.change,
Key? key,
}) : showScrollbar = outputs.length > 3,
super(titleText: titleText, titleIconPath: titleIconPath);
final bool showScrollbar;
final ScrollController scrollController = ScrollController();
@override
Widget contentWidget(BuildContext context) {
final itemTitleTextStyle = TextStyle(
fontSize: 16,
fontFamily: 'Lato',
fontWeight: FontWeight.w500,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
);
final itemSubTitleTextStyle = TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: currentTheme.type == ThemeType.bright
? Theme.of(context).extension<CakeTextTheme>()!.titleColor
: Theme.of(context).extension<BalancePageTheme>()!.labelTextColor,
decoration: TextDecoration.none,
);
Widget content = Padding(
padding: EdgeInsets.fromLTRB(8, 0, showScrollbar ? 16 : 8, 8),
child: Column(
children: [
if (paymentId != null && paymentIdValue != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AddressTile(
itemTitle: paymentId!,
currentTheme: currentTheme,
itemTitleTextStyle: itemTitleTextStyle,
isBatchSending: false,
amount: '',
address: paymentIdValue!,
itemSubTitleTextStyle: itemSubTitleTextStyle,
),
),
StandardTile(
itemTitle: amount,
itemValue: amountValue + ' ${currency.title}',
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitle: fiatAmountValue,
itemSubTitleTextStyle: itemSubTitleTextStyle,
),
const SizedBox(height: 8),
StandardTile(
itemTitle: fee,
itemValue: feeValue,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitle: feeFiatAmount,
itemSubTitleTextStyle: itemSubTitleTextStyle,
),
const SizedBox(height: 8),
Column(
children: [
ListView.separated(
padding: const EdgeInsets.only(top: 0),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: outputs.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final bool isBatchSending = outputs.length > 1;
final item = outputs[index];
final contactName = item.parsedAddress.name;
final batchContactTitle =
'${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}';
final _address = item.isParsedAddress ? item.extractedAddress : item.address;
final _amount = item.cryptoAmount.replaceAll(',', '.') + ' ${currency.title}';
return isBatchSending || contactName.isNotEmpty
? AddressExpansionTile(
contactType: 'Contact',
currentTheme: currentTheme,
name: isBatchSending ? batchContactTitle : contactName,
address: _address,
amount: _amount,
isBatchSending: isBatchSending,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
)
: AddressTile(
itemTitle: 'Address',
currentTheme: currentTheme,
itemTitleTextStyle: itemTitleTextStyle,
isBatchSending: isBatchSending,
amount: _amount,
address: _address,
itemSubTitleTextStyle: itemSubTitleTextStyle,
);
},
),
if (change != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: AddressExpansionTile(
contactType: 'Change',
currentTheme: currentTheme,
name: S.of(context).send_change_to_you,
address: change!.address,
amount: change!.amount + ' ${currency.title}',
isBatchSending: true,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
),
),
],
),
const SizedBox(height: 8),
],
),
);
if (showScrollbar) {
return SizedBox(
height: 380,
child: Scrollbar(
controller: scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(),
child: content,
),
),
);
} else {
return content;
}
}
@override
Widget footerWidget(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(40, 12, 40, 34),
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
boxShadow: [
if (showScrollbar)
BoxShadow(
color: Colors.black.withOpacity(0.5),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 0),
),
],
),
child: StandardSlideButton(
onSlideComplete: onSlideComplete,
buttonText: 'Swipe to send',
currentTheme: currentTheme,
),
);
}
}
class StandardTile extends StatelessWidget {
const StandardTile({
super.key,
required this.itemTitle,
required this.itemValue,
required this.itemTitleTextStyle,
this.itemSubTitle,
required this.itemSubTitleTextStyle,
});
final String itemTitle;
final String itemValue;
final TextStyle itemTitleTextStyle;
final String? itemSubTitle;
final TextStyle itemSubTitleTextStyle;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Theme.of(context).extension<FilterTheme>()!.buttonColor),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(itemTitle, style: itemTitleTextStyle),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(itemValue, style: itemTitleTextStyle),
itemSubTitle == null
? Container()
: Text(itemSubTitle!, style: itemSubTitleTextStyle),
],
),
],
),
);
}
}
class AddressTile extends StatelessWidget {
const AddressTile({
super.key,
required this.itemTitle,
required this.currentTheme,
required this.itemTitleTextStyle,
required this.isBatchSending,
required this.amount,
required this.address,
required this.itemSubTitleTextStyle,
});
final String itemTitle;
final ThemeBase currentTheme;
final TextStyle itemTitleTextStyle;
final bool isBatchSending;
final String amount;
final String address;
final TextStyle itemSubTitleTextStyle;
@override
Widget build(BuildContext context) {
final addressTextStyle = TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: currentTheme.type == ThemeType.bright
? Theme.of(context).extension<BalancePageTheme>()!.labelTextColor
: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Theme.of(context).extension<FilterTheme>()!.buttonColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(itemTitle, style: itemTitleTextStyle),
if (isBatchSending) Text(amount, style: itemTitleTextStyle),
],
),
buildSegmentedAddress(
address: address,
evenTextStyle: currentTheme.type == ThemeType.bright
? itemSubTitleTextStyle
: addressTextStyle,
oddTextStyle: itemSubTitleTextStyle,
),
],
),
);
}
Widget buildSegmentedAddress({
required String address,
int chunkSize = 6,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
}) {
final spans = <TextSpan>[];
int index = 0;
for (int i = 0; i < address.length; i += chunkSize) {
final chunk = address.substring(i, math.min(i + chunkSize, address.length));
final style = (index % 2 == 0) ? evenTextStyle : oddTextStyle;
spans.add(
TextSpan(text: '$chunk ', style: style),
);
index++;
}
return RichText(
text: TextSpan(children: spans, style: evenTextStyle),
overflow: TextOverflow.visible,
);
}
}
class AddressExpansionTile extends StatelessWidget {
const AddressExpansionTile({
super.key,
required this.contactType,
required this.currentTheme,
required this.name,
required this.address,
required this.amount,
required this.isBatchSending,
required this.itemTitleTextStyle,
required this.itemSubTitleTextStyle,
});
final String contactType;
final ThemeBase currentTheme;
final String name;
final String address;
final String amount;
final bool isBatchSending;
final TextStyle itemTitleTextStyle;
final TextStyle itemSubTitleTextStyle;
@override
Widget build(BuildContext context) {
final addressTextStyle = TextStyle(
fontSize: 12,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: currentTheme.type == ThemeType.bright
? Theme.of(context).extension<BalancePageTheme>()!.labelTextColor
: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10)),
color: Theme.of(context).extension<FilterTheme>()!.buttonColor,
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: isBatchSending ? 0 : 8),
child: ExpansionTile(
childrenPadding: EdgeInsets.zero,
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(isBatchSending ? name : contactType, style: itemTitleTextStyle),
Text(isBatchSending ? amount : name,
style: TextStyle(
fontSize: 14,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
)),
],
),
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildSegmentedAddress(
address: address,
evenTextStyle: currentTheme.type == ThemeType.bright
? itemSubTitleTextStyle
: addressTextStyle,
oddTextStyle: itemSubTitleTextStyle,
),
],
),
),
],
),
],
),
),
),
);
}
Widget buildSegmentedAddress({
required String address,
int chunkSize = 6,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
}) {
final spans = <TextSpan>[];
int index = 0;
for (int i = 0; i < address.length; i += chunkSize) {
final chunk = address.substring(i, math.min(i + chunkSize, address.length));
final style = (index % 2 == 0) ? evenTextStyle : oddTextStyle;
spans.add(
TextSpan(text: '$chunk ', style: style),
);
index++;
}
return RichText(
text: TextSpan(children: spans, style: evenTextStyle),
overflow: TextOverflow.visible,
);
}
}
@@ -0,0 +1,231 @@
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart';
import 'package:cake_wallet/themes/extensions/menu_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'base_bottom_sheet_widget.dart';
class LoadingBottomSheet extends BaseBottomSheet {
LoadingBottomSheet({required String titleText, String? titleIconPath})
: super(titleText: titleText, titleIconPath: titleIconPath);
@override
Widget contentWidget(BuildContext context) {
return SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
@override
Widget footerWidget(BuildContext context) => const SizedBox(height: 94);
}
class InfoBottomSheet extends BaseBottomSheet {
final ThemeBase currentTheme;
final String? contentImage;
final Color? contentImageColor;
final String? content;
final bool isTwoAction;
final bool showDontAskMeCheckbox;
final Function(bool)? onCheckboxChanged;
final String? actionButtonText;
final VoidCallback? actionButton;
final Key? actionButtonKey;
final String? leftButtonText;
final String? rightButtonText;
final VoidCallback? actionLeftButton;
final VoidCallback? actionRightButton;
final Key? rightActionButtonKey;
final Key? leftActionButtonKey;
InfoBottomSheet({
required String titleText,
String? titleIconPath,
required this.currentTheme,
this.contentImage,
this.contentImageColor,
this.content,
this.isTwoAction = false,
this.showDontAskMeCheckbox = false,
this.onCheckboxChanged,
this.actionButtonText,
this.actionButton,
this.actionButtonKey,
this.leftButtonText,
this.rightButtonText,
this.actionLeftButton,
this.actionRightButton,
this.rightActionButtonKey,
this.leftActionButtonKey,
}) : super(titleText: titleText, titleIconPath: titleIconPath);
@override
Widget contentWidget(BuildContext context) {
return SizedBox(
height: 200,
child: Column(
children: [
if (contentImage != null)
Expanded(
flex: 4,
child: getImage(contentImage!, imageColor: contentImageColor),
)
else
Container(),
if (content != null)
Expanded(
flex: 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(flex: 2),
Expanded(
flex: 6,
child: Text(
content!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontFamily: 'Lato',
fontWeight: FontWeight.w500,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
),
),
),
const Spacer(flex: 2),
],
),
),
if (showDontAskMeCheckbox)
Padding(
padding: const EdgeInsets.only(left: 34.0),
child: Row(
children: [
SimpleCheckbox(onChanged: onCheckboxChanged),
const SizedBox(width: 8),
Text(
'Don’t ask me next time',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontFamily: 'Lato',
fontWeight: FontWeight.w500,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
decoration: TextDecoration.none,
),
),
],
),
),
],
),
);
}
@override
Widget footerWidget(BuildContext context) {
if (isTwoAction) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 34),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.only(right: 8.0, top: 8.0),
child: PrimaryButton(
key: leftActionButtonKey,
onPressed: actionLeftButton,
text: leftButtonText ?? '',
color: currentTheme.type == ThemeType.dark
? Theme.of(context).extension<CakeMenuTheme>()!.backgroundColor
: Theme.of(context).cardColor,
textColor: currentTheme.type == ThemeType.dark
? Theme.of(context).extension<DashboardPageTheme>()!.textColor
: Theme.of(context).extension<CakeTextTheme>()!.buttonTextColor,
),
),
),
Flexible(
child: Container(
padding: const EdgeInsets.only(left: 8.0, top: 8.0),
child: PrimaryButton(
key: rightActionButtonKey,
onPressed: actionRightButton,
text: rightButtonText ?? '',
color: Theme.of(context).primaryColor,
textColor: currentTheme.type == ThemeType.dark
? Theme.of(context).extension<DashboardPageTheme>()!.textColor
: Theme.of(context).extension<CakeTextTheme>()!.buttonTextColor,
),
),
),
],
),
);
} else {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 34),
child: LoadingPrimaryButton(
key: actionButtonKey,
onPressed: actionButton ?? () {},
text: actionButtonText ?? '',
color: Theme.of(context).primaryColor,
textColor: Colors.white,
isLoading: false,
isDisabled: false,
),
);
}
}
Widget getImage(String imagePath, {Color? imageColor}) {
final bool isSvg = imagePath.endsWith('.svg');
if (isSvg) {
return SvgPicture.asset(
imagePath,
colorFilter: imageColor != null ? ColorFilter.mode(imageColor, BlendMode.srcIn) : null,
);
} else {
return Image.asset(imagePath);
}
}
}
class SimpleCheckbox extends StatefulWidget {
SimpleCheckbox({this.onChanged});
final Function(bool)? onChanged;
@override
State<SimpleCheckbox> createState() => _SimpleCheckboxState();
}
class _SimpleCheckboxState extends State<SimpleCheckbox> {
bool initialValue = false;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 24.0,
width: 24.0,
child: Checkbox(
value: initialValue,
onChanged: (value) => setState(() {
initialValue = value!;
widget.onChanged?.call(value);
}),
checkColor: Theme.of(context).extension<CakeTextTheme>()!.titleColor,
activeColor: Colors.transparent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: WidgetStateBorderSide.resolveWith((states) => BorderSide(
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor, width: 1.0)),
),
);
}
}
@@ -0,0 +1,90 @@
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/themes/extensions/filter_theme.dart';
import 'package:cake_wallet/themes/extensions/menu_theme.dart';
import 'package:cake_wallet/themes/theme_base.dart';
import 'package:flutter/material.dart';
class StandardSlideButton extends StatefulWidget {
const StandardSlideButton({
Key? key,
required this.onSlideComplete,
this.buttonText = '',
this.height = 48.0,
required this.currentTheme,
}) : super(key: key);
final VoidCallback onSlideComplete;
final String buttonText;
final double height;
final ThemeBase currentTheme;
@override
_StandardSlideButtonState createState() => _StandardSlideButtonState();
}
class _StandardSlideButtonState extends State<StandardSlideButton> {
double _dragPosition = 0.0;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final double maxWidth = constraints.maxWidth;
const double sideMargin = 4.0;
final double effectiveMaxWidth = maxWidth - 2 * sideMargin;
const double sliderWidth = 42.0;
return Container(
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: widget.currentTheme.type == ThemeType.light || widget.currentTheme.type == ThemeType.bright
? Theme.of(context).disabledColor
: widget.currentTheme.type == ThemeType.oled ? Colors.black : Theme.of(context).extension<CakeMenuTheme>()!.backgroundColor),
child: Stack(
alignment: Alignment.centerLeft,
children: [
Center(
child: Text(widget.buttonText,
style: TextStyle(
fontSize: 16,
fontFamily: 'Lato',
fontWeight: FontWeight.w600,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor))),
Positioned(
left: sideMargin + _dragPosition,
child: GestureDetector(
onHorizontalDragUpdate: (details) {
setState(() {
_dragPosition += details.delta.dx;
if (_dragPosition < 0) _dragPosition = 0;
if (_dragPosition > effectiveMaxWidth - sliderWidth) {
_dragPosition = effectiveMaxWidth - sliderWidth;
}
});
},
onHorizontalDragEnd: (details) {
if (_dragPosition >= effectiveMaxWidth - sliderWidth - 10) {
widget.onSlideComplete();
} else {
setState(() => _dragPosition = 0);
}
},
child: Container(
width: sliderWidth,
height: widget.height - 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: widget.currentTheme.type == ThemeType.bright ? Theme.of(context).extension<CakeMenuTheme>()!.backgroundColor : Theme.of(context).extension<FilterTheme>()!.buttonColor,
),
alignment: Alignment.center,
child: Icon(Icons.arrow_forward,
color: Theme.of(context).extension<CakeTextTheme>()!.titleColor),
),
),
)
],
),
);
});
}
}