| 1 | import 'package:cake_wallet/entities/contact_base.dart'; |
| 2 | import 'package:cake_wallet/entities/qr_scanner.dart'; |
| 3 | import 'package:cake_wallet/generated/i18n.dart'; |
| 4 | import 'package:cake_wallet/new-ui/widgets/send_page/floating_icon_button.dart'; |
| 5 | import 'package:cake_wallet/routes.dart'; |
| 6 | import 'package:cake_wallet/utils/permission_handler.dart'; |
| 7 | import 'package:cw_core/currency.dart'; |
| 8 | import 'package:flutter/material.dart'; |
| 9 | import 'package:flutter/services.dart'; |
| 10 | import "package:permission_handler_platform_interface/permission_handler_platform_interface.dart"; |
| 11 | |
| 12 | class NewSendAddressInput extends StatefulWidget { |
| 13 | const NewSendAddressInput({ |
| 14 | super.key, |
| 15 | required this.addressController, |
| 16 | this.onURIScanned, |
| 17 | this.onPushPasteButton, |
| 18 | required this.selectedCurrency, |
| 19 | this.onSelectedContact, |
| 20 | this.onPushAddressBookButton, |
| 21 | required this.onEditingComplete, |
| 22 | this.bottomPadding = false, |
| 23 | this.validator, |
| 24 | this.focusNode, |
| 25 | this.displayName, |
| 26 | this.hintText, |
| 27 | }); |
| 28 | |
| 29 | final TextEditingController addressController; |
| 30 | final Function(Uri)? onURIScanned; |
| 31 | final Function(BuildContext)? onPushPasteButton; |
| 32 | final Function(BuildContext)? onPushAddressBookButton; |
| 33 | final Function(ContactBase)? onSelectedContact; |
| 34 | final String? displayName; |
| 35 | final Currency selectedCurrency; |
| 36 | final VoidCallback onEditingComplete; |
| 37 | final bool bottomPadding; |
| 38 | final FormFieldValidator<String>? validator; |
| 39 | final FocusNode? focusNode; |
| 40 | final String? hintText; |
| 41 | |
| 42 | @override |
| 43 | State<NewSendAddressInput> createState() => _NewSendAddressInputState(); |
| 44 | } |
| 45 | |
| 46 | class _NewSendAddressInputState extends State<NewSendAddressInput> { |
| 47 | FocusNode? node; |
| 48 | GlobalKey<FormFieldState<String>> formFieldKey = GlobalKey<FormFieldState<String>>(); |
| 49 | |
| 50 | @override |
| 51 | void initState() { |
| 52 | super.initState(); |
| 53 | node = widget.focusNode ?? FocusNode(); |
| 54 | node!.addListener(_onFocusChange); |
| 55 | widget.addressController |
| 56 | .addListener(() => formFieldKey.currentState?.didChange(widget.addressController.text)); |
| 57 | } |
| 58 | |
| 59 | void _onFocusChange() { |
| 60 | if (mounted) { |
| 61 | setState(() {}); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | @override |
| 66 | Widget build(BuildContext context) { |
| 67 | return Padding( |
| 68 | padding: widget.bottomPadding |
| 69 | ? EdgeInsets.only( |
| 70 | bottom: MediaQuery.of(context).viewInsets.bottom, |
| 71 | ) |
| 72 | : EdgeInsets.zero, |
| 73 | child: FormField<String>( |
| 74 | key: formFieldKey, |
| 75 | initialValue: widget.addressController.text, |
| 76 | validator: widget.validator, |
| 77 | builder: (state) => Column( |
| 78 | crossAxisAlignment: CrossAxisAlignment.start, |
| 79 | children: [ |
| 80 | Container( |
| 81 | decoration: BoxDecoration( |
| 82 | color: Theme.of(context).colorScheme.surfaceContainer, |
| 83 | borderRadius: BorderRadius.circular(18)), |
| 84 | child: Row( |
| 85 | children: [ |
| 86 | Expanded( |
| 87 | child: Stack( |
| 88 | children: [ |
| 89 | MergeSemantics( |
| 90 | child: Semantics( |
| 91 | label: _fieldSemanticsLabel(context), |
| 92 | child: TextField( |
| 93 | focusNode: widget.focusNode, |
| 94 | autocorrect: false, |
| 95 | enableSuggestions: false, |
| 96 | onSubmitted: (val) => FocusScope.of(context).unfocus(), |
| 97 | onChanged: state.didChange, |
| 98 | onEditingComplete: () { |
| 99 | widget.onEditingComplete(); |
| 100 | }, |
| 101 | onTapOutside: (_) { |
| 102 | widget.onEditingComplete(); |
| 103 | }, |
| 104 | controller: widget.addressController, |
| 105 | decoration: InputDecoration( |
| 106 | hintText: widget.hintText ?? S.of(context).search_or_enter, |
| 107 | errorMaxLines: 3, |
| 108 | ), |
| 109 | ), |
| 110 | ), |
| 111 | ), |
| 112 | Positioned.fill( |
| 113 | child: IgnorePointer( |
| 114 | child: AnimatedOpacity( |
| 115 | duration: Duration(milliseconds: 150), |
| 116 | opacity: (widget.focusNode == null || |
| 117 | widget.focusNode!.hasFocus || |
| 118 | widget.addressController.text.isEmpty) |
| 119 | ? 0 |
| 120 | : 1, |
| 121 | // Purely visual copy of the field content; announcing it again |
| 122 | // would read the address twice. |
| 123 | child: ExcludeSemantics( |
| 124 | child: SendAddressOverlay( |
| 125 | address: widget.addressController.text, |
| 126 | displayName: widget.displayName, |
| 127 | ), |
| 128 | )), |
| 129 | ), |
| 130 | ), |
| 131 | ], |
| 132 | ), |
| 133 | ), |
| 134 | Row( |
| 135 | spacing: 12, |
| 136 | children: [ |
| 137 | SizedBox.shrink(), |
| 138 | FloatingIconButton( |
| 139 | iconPath: "assets/new-ui/paste.svg", |
| 140 | semanticLabel: S.of(context).paste, |
| 141 | onPressed: () async { |
| 142 | _pasteAddress(context); |
| 143 | }), |
| 144 | FloatingIconButton( |
| 145 | iconPath: "assets/new-ui/scan.svg", |
| 146 | semanticLabel: S.of(context).scan_qr_code, |
| 147 | onPressed: () { |
| 148 | _presentQRScanner(context); |
| 149 | }), |
| 150 | FloatingIconButton( |
| 151 | iconPath: "assets/new-ui/contacts_outlined.svg", |
| 152 | semanticLabel: S.of(context).address_book, |
| 153 | onPressed: () { |
| 154 | _presetAddressBookPicker(context); |
| 155 | }), |
| 156 | SizedBox.shrink() |
| 157 | ], |
| 158 | ) |
| 159 | ], |
| 160 | ), |
| 161 | ), |
| 162 | if (state.hasError) |
| 163 | Padding( |
| 164 | padding: EdgeInsets.only(top: 6, left: 8), |
| 165 | child: Semantics( |
| 166 | container: true, |
| 167 | liveRegion: true, |
| 168 | label: "${S.of(context).address_or_alias}, ${state.errorText!}", |
| 169 | excludeSemantics: true, |
| 170 | child: Text( |
| 171 | state.errorText!, |
| 172 | style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error), |
| 173 | ), |
| 174 | ), |
| 175 | ) |
| 176 | ], |
| 177 | ), |
| 178 | ), |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | String _fieldSemanticsLabel(BuildContext context) { |
| 183 | final label = S.of(context).address_or_alias; |
| 184 | final displayName = widget.displayName; |
| 185 | |
| 186 | if (displayName == null || |
| 187 | displayName.isEmpty || |
| 188 | displayName == widget.addressController.text) { |
| 189 | return label; |
| 190 | } |
| 191 | |
| 192 | return "$label, $displayName"; |
| 193 | } |
| 194 | |
| 195 | Future<void> _presentQRScanner(BuildContext context) async { |
| 196 | bool isCameraPermissionGranted = |
| 197 | await PermissionHandler.checkPermission(Permission.camera, context); |
| 198 | if (!isCameraPermissionGranted) return; |
| 199 | final code = await presentQRScanner(context); |
| 200 | if (code == null) return; |
| 201 | if (code.isEmpty) return; |
| 202 | |
| 203 | try { |
| 204 | final uri = Uri.parse(code); |
| 205 | // probably should remove this and let the `onURIScanned` handle it, but for now, |
| 206 | // will fix that it takes the token contract address |
| 207 | if (!uri.path.contains("/transfer")) { |
| 208 | widget.addressController.text = uri.path; |
| 209 | } |
| 210 | widget.onURIScanned?.call(uri); |
| 211 | } catch (_) { |
| 212 | widget.addressController.text = code; |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | Future<void> _pasteAddress(BuildContext context) async { |
| 217 | final clipboard = await Clipboard.getData('text/plain'); |
| 218 | final address = clipboard?.text ?? ''; |
| 219 | |
| 220 | if (address.isNotEmpty) { |
| 221 | // if it has query parameters then it's a valid uri |
| 222 | // added because Uri.parse(address) can parse a normal address string and would still be valid |
| 223 | if (address.contains("=")) { |
| 224 | try { |
| 225 | final uri = Uri.parse(address); |
| 226 | widget.addressController.text = uri.path; |
| 227 | widget.onURIScanned?.call(uri); |
| 228 | return; |
| 229 | } catch (_) { |
| 230 | widget.addressController.text = address; |
| 231 | } |
| 232 | } else { |
| 233 | widget.addressController.text = address; |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | widget.onPushPasteButton?.call(context); |
| 238 | } |
| 239 | |
| 240 | Future<void> _presetAddressBookPicker(BuildContext context) async { |
| 241 | final contact = await Navigator.of(context) |
| 242 | .pushNamed(Routes.pickerAddressBook, arguments: [widget.selectedCurrency, false]); |
| 243 | |
| 244 | if (contact is ContactBase) { |
| 245 | widget.addressController.text = contact.address; |
| 246 | widget.onPushAddressBookButton?.call(context); |
| 247 | widget.onSelectedContact?.call(contact); |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | class SendAddressOverlay extends StatelessWidget { |
| 253 | const SendAddressOverlay({super.key, required this.address, this.displayName}); |
| 254 | |
| 255 | final String address; |
| 256 | final String? displayName; |
| 257 | |
| 258 | @override |
| 259 | Widget build(BuildContext context) { |
| 260 | final primaryTextStyle = TextStyle(fontSize: 16.5); |
| 261 | final secondaryTextStyle = |
| 262 | TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant); |
| 263 | |
| 264 | final showDisplayName = |
| 265 | displayName != null && displayName!.isNotEmpty && displayName != address; |
| 266 | |
| 267 | return Container( |
| 268 | decoration: BoxDecoration( |
| 269 | color: Theme.of(context).colorScheme.surfaceContainer, |
| 270 | borderRadius: BorderRadius.circular(16)), |
| 271 | child: Padding( |
| 272 | padding: const EdgeInsets.symmetric(horizontal: 12.0), |
| 273 | child: Column( |
| 274 | mainAxisAlignment: MainAxisAlignment.center, |
| 275 | crossAxisAlignment: CrossAxisAlignment.start, |
| 276 | children: [ |
| 277 | if (showDisplayName) |
| 278 | Text( |
| 279 | displayName!, |
| 280 | maxLines: 1, |
| 281 | overflow: TextOverflow.ellipsis, |
| 282 | style: primaryTextStyle, |
| 283 | ), |
| 284 | Text( |
| 285 | address, |
| 286 | maxLines: 1, |
| 287 | overflow: TextOverflow.ellipsis, |
| 288 | style: showDisplayName ? secondaryTextStyle : primaryTextStyle, |
| 289 | ) |
| 290 | ], |
| 291 | ), |
| 292 | ), |
| 293 | ); |
| 294 | } |
| 295 | } |