fix: keyboard dismissal on ios send page (#2747)

* fix: keyboard dismissal on ios send page * further keyboard action fixes on send page

malik1004x committed Dec 23, 2025 at 02:30 UTC ab0e3b65edb2ff85b94cb84616ba1324d6dbb1b5
1 file changed +315 -301
lib/src/screens/send/send_page.dart
+315 -301
@@ -61,6 +61,11 @@ class SendPage extends BasePage {
61 final GlobalKey<FormState> _formKey;
62 final controller = PageController(initialPage: 0);
63 final PaymentRequest? initialPaymentRequest;
64 + final FocusNode _cryptoAmountFocus = FocusNode();
65 + final FocusNode _fiatAmountFocus = FocusNode();
66 +
67 + final currentPage = ValueNotifier<int>(0);
68 +
69
70 bool _effectsInstalled = false;
71 ContactRecord? newContactAddress;
@@ -172,319 +177,323 @@ class SendPage extends BasePage {
177 Widget body(BuildContext context) {
178 _setEffects(context);
179
175 - return Observer(builder: (_) {
176 - List<Widget> sendCards = [];
177 - List<KeyboardActionsItem> keyboardActions = [];
178 - for (var output in sendViewModel.outputs) {
179 - var cryptoAmountFocus = FocusNode();
180 - var fiatAmountFocus = FocusNode();
181 - sendCards.add(
182 - SendCard(
183 - currentTheme: currentTheme,
184 - key: output.key,
185 - output: output,
186 - sendViewModel: sendViewModel,
187 - paymentViewModel: paymentViewModel,
188 - walletSwitcherViewModel: walletSwitcherViewModel,
189 - initialPaymentRequest: initialPaymentRequest,
190 - cryptoAmountFocus: cryptoAmountFocus,
191 - fiatAmountFocus: fiatAmountFocus,
192 - ),
193 - );
194 - keyboardActions.add(
195 - KeyboardActionsItem(
196 - focusNode: cryptoAmountFocus,
197 - toolbarButtons: [(_) => KeyboardDoneButton()],
198 - ),
199 - );
200 - keyboardActions.add(
201 - KeyboardActionsItem(
202 - focusNode: fiatAmountFocus,
203 - toolbarButtons: [(_) => KeyboardDoneButton()],
204 - ),
205 - );
206 - }
207 - return Stack(
208 - children: [
209 - KeyboardActions(
210 - config: KeyboardActionsConfig(
211 - keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
212 - keyboardBarColor: Theme.of(context).colorScheme.surface,
213 - nextFocus: false,
214 - actions: keyboardActions,
215 - ),
216 - child: Container(
217 - height: 0,
218 - color: Colors.transparent,
219 - ),
220 - ),
221 - GestureDetector(
222 - onLongPress: () => sendViewModel.balanceViewModel.isReversing =
223 - !sendViewModel.balanceViewModel.isReversing,
224 - onLongPressUp: () => sendViewModel.balanceViewModel.isReversing =
225 - !sendViewModel.balanceViewModel.isReversing,
226 - child: Form(
227 - key: _formKey,
228 - child: ScrollableWithBottomSection(
229 - contentPadding: EdgeInsets.only(bottom: 24),
230 - content: FocusTraversalGroup(
231 - policy: OrderedTraversalPolicy(),
232 - child: Column(
233 - children: <Widget>[
234 - PageViewHeightAdaptable(
235 - controller: controller,
236 - children: sendCards,
237 - ),
238 - SizedBox(height: 10),
239 - Padding(
240 - padding: EdgeInsets.only(left: 24, right: 24, bottom: 10),
241 - child: Container(
242 - height: 10,
243 - child: Observer(
244 - builder: (_) {
245 - final count = sendViewModel.outputs.length;
246 -
247 - return count > 1
248 - ? Semantics(
249 - label: 'Page Indicator',
250 - hint: 'Swipe to change receiver',
251 - excludeSemantics: true,
252 - child: SmoothPageIndicator(
253 - controller: controller,
254 - count: count,
255 - effect: ScrollingDotsEffect(
256 - spacing: 6.0,
257 - radius: 6.0,
258 - dotWidth: 6.0,
259 - dotHeight: 6.0,
260 - dotColor: Theme.of(context)
261 - .colorScheme
262 - .primary
263 - .withOpacity(0.4),
264 - activeDotColor: Theme.of(context).colorScheme.primary,
265 - ),
266 - ),
267 - )
268 - : Offstage();
269 - },
180 + return ValueListenableBuilder(
181 + valueListenable: currentPage,
182 + builder: (context, value, child) {
183 + return Observer(builder: (_) {
184 + List<Widget> sendCards = [];
185 + List<KeyboardActionsItem> keyboardActions = [];
186 + for (var output in sendViewModel.outputs) {
187 + final isCurrent = sendViewModel.outputs.indexOf(output) == value;
188 + sendCards.add(
189 + SendCard(
190 + currentTheme: currentTheme,
191 + key: output.key,
192 + output: output,
193 + sendViewModel: sendViewModel,
194 + paymentViewModel: paymentViewModel,
195 + walletSwitcherViewModel: walletSwitcherViewModel,
196 + initialPaymentRequest: initialPaymentRequest,
197 + cryptoAmountFocus: isCurrent ? _cryptoAmountFocus : null,
198 + fiatAmountFocus: isCurrent ? _fiatAmountFocus : null,
199 + ),
200 + );
201 + keyboardActions.add(
202 + KeyboardActionsItem(
203 + focusNode: _cryptoAmountFocus,
204 + toolbarButtons: [(_) => KeyboardDoneButton()],
205 + ),
206 + );
207 + keyboardActions.add(
208 + KeyboardActionsItem(
209 + focusNode: _fiatAmountFocus,
210 + toolbarButtons: [(_) => KeyboardDoneButton()],
211 + ),
212 + );
213 + }
214 + return Stack(
215 + children: [
216 + KeyboardActions(
217 + config: KeyboardActionsConfig(
218 + keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
219 + keyboardBarColor: Theme.of(context).colorScheme.surface,
220 + nextFocus: false,
221 + actions: keyboardActions,
222 + ),
223 + child: Container(
224 + height: 0,
225 + color: Colors.transparent,
226 + ),
227 + ),
228 + GestureDetector(
229 + onLongPress: () => sendViewModel.balanceViewModel.isReversing =
230 + !sendViewModel.balanceViewModel.isReversing,
231 + onLongPressUp: () => sendViewModel.balanceViewModel.isReversing =
232 + !sendViewModel.balanceViewModel.isReversing,
233 + child: Form(
234 + key: _formKey,
235 + child: ScrollableWithBottomSection(
236 + contentPadding: EdgeInsets.only(bottom: 24),
237 + content: FocusTraversalGroup(
238 + policy: OrderedTraversalPolicy(),
239 + child: Column(
240 + children: <Widget>[
241 + PageViewHeightAdaptable(
242 + controller: controller,
243 + children: sendCards,
244 ),
271 - ),
272 - ),
273 - Container(
274 - height: 40,
275 - width: double.infinity,
276 - padding: EdgeInsets.only(left: 24),
277 - child: SingleChildScrollView(
278 - scrollDirection: Axis.horizontal,
279 - child: Observer(
280 - builder: (_) {
281 - final templates = sendViewModel.templates;
282 - final itemCount = templates.length;
283 -
284 - return Row(
285 - children: <Widget>[
286 - AddTemplateButton(
287 - key: ValueKey('send_page_add_template_button_key'),
288 - onTap: () =>
289 - Navigator.of(context).pushNamed(Routes.sendTemplate),
290 - currentTemplatesLength: templates.length,
291 - ),
292 - ListView.builder(
293 - scrollDirection: Axis.horizontal,
294 - shrinkWrap: true,
295 - physics: NeverScrollableScrollPhysics(),
296 - itemCount: itemCount,
297 - itemBuilder: (context, index) {
298 - final template = templates[index];
299 - return TemplateTile(
300 - key: UniqueKey(),
301 - to: template.name,
302 - hasMultipleRecipients:
303 - template.additionalRecipients != null &&
304 - template.additionalRecipients!.length > 1,
305 - amount: template.isCurrencySelected
306 - ? template.amount
307 - : template.amountFiat,
308 - from: template.isCurrencySelected
309 - ? template.cryptoCurrency
310 - : template.fiatCurrency,
311 - onTap: () async {
312 - sendViewModel.state = LoadingTemplateExecutingState();
313 - if (template.additionalRecipients?.isNotEmpty ??
314 - false) {
315 - sendViewModel.clearOutputs();
316 -
317 - for (int i = 0;
318 - i < template.additionalRecipients!.length;
319 - i++) {
320 - Output output;
321 - try {
322 - output = sendViewModel.outputs[i];
323 - } catch (e) {
324 - sendViewModel.addOutput();
325 - output = sendViewModel.outputs[i];
245 + SizedBox(height: 10),
246 + Padding(
247 + padding: EdgeInsets.only(left: 24, right: 24, bottom: 10),
248 + child: Container(
249 + height: 10,
250 + child: Observer(
251 + builder: (_) {
252 + final count = sendViewModel.outputs.length;
253 +
254 + return count > 1
255 + ? Semantics(
256 + label: 'Page Indicator',
257 + hint: 'Swipe to change receiver',
258 + excludeSemantics: true,
259 + child: SmoothPageIndicator(
260 + controller: controller,
261 + count: count,
262 + effect: ScrollingDotsEffect(
263 + spacing: 6.0,
264 + radius: 6.0,
265 + dotWidth: 6.0,
266 + dotHeight: 6.0,
267 + dotColor: Theme.of(context)
268 + .colorScheme
269 + .primary
270 + .withOpacity(0.4),
271 + activeDotColor: Theme.of(context).colorScheme.primary,
272 + ),
273 + ),
274 + )
275 + : Offstage();
276 + },
277 + ),
278 + ),
279 + ),
280 + Container(
281 + height: 40,
282 + width: double.infinity,
283 + padding: EdgeInsets.only(left: 24),
284 + child: SingleChildScrollView(
285 + scrollDirection: Axis.horizontal,
286 + child: Observer(
287 + builder: (_) {
288 + final templates = sendViewModel.templates;
289 + final itemCount = templates.length;
290 +
291 + return Row(
292 + children: <Widget>[
293 + AddTemplateButton(
294 + key: ValueKey('send_page_add_template_button_key'),
295 + onTap: () =>
296 + Navigator.of(context).pushNamed(Routes.sendTemplate),
297 + currentTemplatesLength: templates.length,
298 + ),
299 + ListView.builder(
300 + scrollDirection: Axis.horizontal,
301 + shrinkWrap: true,
302 + physics: NeverScrollableScrollPhysics(),
303 + itemCount: itemCount,
304 + itemBuilder: (context, index) {
305 + final template = templates[index];
306 + return TemplateTile(
307 + key: UniqueKey(),
308 + to: template.name,
309 + hasMultipleRecipients:
310 + template.additionalRecipients != null &&
311 + template.additionalRecipients!.length > 1,
312 + amount: template.isCurrencySelected
313 + ? template.amount
314 + : template.amountFiat,
315 + from: template.isCurrencySelected
316 + ? template.cryptoCurrency
317 + : template.fiatCurrency,
318 + onTap: () async {
319 + sendViewModel.state = LoadingTemplateExecutingState();
320 + if (template.additionalRecipients?.isNotEmpty ??
321 + false) {
322 + sendViewModel.clearOutputs();
323 +
324 + for (int i = 0;
325 + i < template.additionalRecipients!.length;
326 + i++) {
327 + Output output;
328 + try {
329 + output = sendViewModel.outputs[i];
330 + } catch (e) {
331 + sendViewModel.addOutput();
332 + output = sendViewModel.outputs[i];
333 + }
334 +
335 + await _setInputsFromTemplate(
336 + context,
337 + output: output,
338 + template: template.additionalRecipients![i],
339 + );
340 + }
341 + } else {
342 + final output = _defineCurrentOutput();
343 + await _setInputsFromTemplate(
344 + context,
345 + output: output,
346 + template: template,
347 + );
348 }
327 -
328 - await _setInputsFromTemplate(
329 - context,
330 - output: output,
331 - template: template.additionalRecipients![i],
349 + sendViewModel.state = InitialExecutionState();
350 + },
351 + onRemove: () {
352 + showPopUp<void>(
353 + context: context,
354 + builder: (dialogContext) {
355 + return AlertWithTwoActions(
356 + alertTitle: S.of(context).template,
357 + alertContent:
358 + S.of(context).confirm_delete_template,
359 + rightButtonText: S.of(context).delete,
360 + leftButtonText: S.of(context).cancel,
361 + actionRightButton: () {
362 + Navigator.of(dialogContext).pop();
363 + sendViewModel.sendTemplateViewModel
364 + .removeTemplate(template: template);
365 + },
366 + actionLeftButton: () =>
367 + Navigator.of(dialogContext).pop());
368 + },
369 );
333 - }
334 - } else {
335 - final output = _defineCurrentOutput();
336 - await _setInputsFromTemplate(
337 - context,
338 - output: output,
339 - template: template,
340 - );
341 - }
342 - sendViewModel.state = InitialExecutionState();
343 - },
344 - onRemove: () {
345 - showPopUp<void>(
346 - context: context,
347 - builder: (dialogContext) {
348 - return AlertWithTwoActions(
349 - alertTitle: S.of(context).template,
350 - alertContent:
351 - S.of(context).confirm_delete_template,
352 - rightButtonText: S.of(context).delete,
353 - leftButtonText: S.of(context).cancel,
354 - actionRightButton: () {
355 - Navigator.of(dialogContext).pop();
356 - sendViewModel.sendTemplateViewModel
357 - .removeTemplate(template: template);
358 - },
359 - actionLeftButton: () =>
360 - Navigator.of(dialogContext).pop());
370 },
371 );
372 },
364 - );
365 - },
366 - ),
367 - ],
368 - );
369 - },
373 + ),
374 + ],
375 + );
376 + },
377 + ),
378 + ),
379 ),
371 - ),
380 + ],
381 ),
373 - ],
374 - ),
375 - ),
376 - bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24),
377 - bottomSection: Column(
378 - children: [
379 - if (sendViewModel.sendTemplateViewModel.hasMultiRecipient)
380 - Padding(
381 - padding: EdgeInsets.only(bottom: 12),
382 - child: PrimaryButton(
383 - key: ValueKey('send_page_add_receiver_button_key'),
384 - onPressed: () {
385 - sendViewModel.addOutput();
386 - Future.delayed(const Duration(milliseconds: 250), () {
387 - controller.jumpToPage(sendViewModel.outputs.length - 1);
388 - });
389 - },
390 - text: S.of(context).add_receiver,
391 - color: Colors.transparent,
392 - textColor: Theme.of(context).colorScheme.onSurfaceVariant,
393 - isDottedBorder: true,
394 - borderColor: Theme.of(context).colorScheme.outline,
395 - )),
396 - Observer(
397 - builder: (_) {
398 - return LoadingPrimaryButton(
399 - key: ValueKey('send_page_send_button_key'),
400 - onPressed: () async {
401 - //Request dummy node to get the focus out of the text fields
402 - FocusScope.of(context).requestFocus(FocusNode());
403 -
404 - if (sendViewModel.state is IsExecutingState) return;
405 - if (_formKey.currentState != null &&
406 - !_formKey.currentState!.validate()) {
407 - if (sendViewModel.outputs.length > 1) {
408 - showErrorValidationAlert(context);
409 - }
410 -
411 - return;
412 - }
413 -
414 - final notValidItems = sendViewModel.outputs
415 - .where(
416 - (item) => item.address.isEmpty || item.cryptoAmount.isEmpty)
417 - .toList();
418 -
419 - if (notValidItems.isNotEmpty) {
420 - showErrorValidationAlert(context);
421 - return;
422 - }
423 -
424 - if (sendViewModel.wallet.isHardwareWallet) {
425 - if (!sendViewModel.hardwareWalletViewModel!.isConnected) {
426 - await Navigator.of(context).pushNamed(Routes.connectDevices,
427 - arguments: ConnectDevicePageParams(
428 - walletType: sendViewModel.walletType,
429 - hardwareWalletType:
430 - sendViewModel.wallet.walletInfo.hardwareWalletType!,
431 - onConnectDevice: (BuildContext context, _) {
432 - sendViewModel.hardwareWalletViewModel!
433 - .initWallet(sendViewModel.wallet);
434 - Navigator.of(context).pop();
435 - },
436 - ));
437 - } else {
438 - sendViewModel.hardwareWalletViewModel!
439 - .initWallet(sendViewModel.wallet);
440 - }
441 - }
442 -
443 - if (sendViewModel.wallet.type == WalletType.monero) {
444 - int amount = 0;
445 - for (var item in sendViewModel.outputs) {
446 - amount += item.formattedCryptoAmount;
447 - }
448 - if (monero!.needExportOutputs(sendViewModel.wallet, amount)) {
449 - await Navigator.of(context).pushNamed(Routes.urqrAnimatedPage,
450 - arguments: monero!.exportOutputsUR(sendViewModel.wallet));
451 - await Future.delayed(
452 - Duration(seconds: 1)); // wait for monero to refresh the state
453 - }
454 - if (monero!.needExportOutputs(sendViewModel.wallet, amount)) {
455 - return;
456 - }
457 - }
458 -
459 - final check = sendViewModel.shouldDisplayTotp();
460 - authService.authenticateAction(
461 - context,
462 - conditionToDetermineIfToUse2FA: check,
463 - onAuthSuccess: (value) async {
464 - if (value) {
465 - await sendViewModel.createTransaction();
382 + ),
383 + bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24),
384 + bottomSection: Column(
385 + children: [
386 + if (sendViewModel.sendTemplateViewModel.hasMultiRecipient)
387 + Padding(
388 + padding: EdgeInsets.only(bottom: 12),
389 + child: PrimaryButton(
390 + key: ValueKey('send_page_add_receiver_button_key'),
391 + onPressed: () {
392 + sendViewModel.addOutput();
393 + Future.delayed(const Duration(milliseconds: 250), () {
394 + controller.jumpToPage(sendViewModel.outputs.length - 1);
395 + });
396 + },
397 + text: S.of(context).add_receiver,
398 + color: Colors.transparent,
399 + textColor: Theme.of(context).colorScheme.onSurfaceVariant,
400 + isDottedBorder: true,
401 + borderColor: Theme.of(context).colorScheme.outline,
402 + )),
403 + Observer(
404 + builder: (_) {
405 + return LoadingPrimaryButton(
406 + key: ValueKey('send_page_send_button_key'),
407 + onPressed: () async {
408 + //Request dummy node to get the focus out of the text fields
409 + FocusScope.of(context).requestFocus(FocusNode());
410 +
411 + if (sendViewModel.state is IsExecutingState) return;
412 + if (_formKey.currentState != null &&
413 + !_formKey.currentState!.validate()) {
414 + if (sendViewModel.outputs.length > 1) {
415 + showErrorValidationAlert(context);
416 + }
417 +
418 + return;
419 + }
420 +
421 + final notValidItems = sendViewModel.outputs
422 + .where(
423 + (item) => item.address.isEmpty || item.cryptoAmount.isEmpty)
424 + .toList();
425 +
426 + if (notValidItems.isNotEmpty) {
427 + showErrorValidationAlert(context);
428 + return;
429 + }
430 +
431 + if (sendViewModel.wallet.isHardwareWallet) {
432 + if (!sendViewModel.hardwareWalletViewModel!.isConnected) {
433 + await Navigator.of(context).pushNamed(Routes.connectDevices,
434 + arguments: ConnectDevicePageParams(
435 + walletType: sendViewModel.walletType,
436 + hardwareWalletType:
437 + sendViewModel.wallet.walletInfo.hardwareWalletType!,
438 + onConnectDevice: (BuildContext context, _) {
439 + sendViewModel.hardwareWalletViewModel!
440 + .initWallet(sendViewModel.wallet);
441 + Navigator.of(context).pop();
442 + },
443 + ));
444 + } else {
445 + sendViewModel.hardwareWalletViewModel!
446 + .initWallet(sendViewModel.wallet);
447 + }
448 + }
449 +
450 + if (sendViewModel.wallet.type == WalletType.monero) {
451 + int amount = 0;
452 + for (var item in sendViewModel.outputs) {
453 + amount += item.formattedCryptoAmount;
454 + }
455 + if (monero!.needExportOutputs(sendViewModel.wallet, amount)) {
456 + await Navigator.of(context).pushNamed(Routes.urqrAnimatedPage,
457 + arguments: monero!.exportOutputsUR(sendViewModel.wallet));
458 + await Future.delayed(
459 + Duration(seconds: 1)); // wait for monero to refresh the state
460 + }
461 + if (monero!.needExportOutputs(sendViewModel.wallet, amount)) {
462 + return;
463 + }
464 }
465 +
466 + final check = sendViewModel.shouldDisplayTotp();
467 + authService.authenticateAction(
468 + context,
469 + conditionToDetermineIfToUse2FA: check,
470 + onAuthSuccess: (value) async {
471 + if (value) {
472 + await sendViewModel.createTransaction();
473 + }
474 + },
475 + );
476 },
477 + text: _sendButtonText(context),
478 + color: Theme.of(context).colorScheme.primary,
479 + textColor: Theme.of(context).colorScheme.onPrimary,
480 + isLoading: sendViewModel.state is IsExecutingState ||
481 + sendViewModel.state is TransactionCommitting ||
482 + sendViewModel.state is IsAwaitingDeviceResponseState ||
483 + sendViewModel.state is LoadingTemplateExecutingState,
484 + isDisabled: !sendViewModel.isReadyForSend || sendViewModel.state is ExecutedSuccessfullyState,
485 );
486 },
470 - text: _sendButtonText(context),
471 - color: Theme.of(context).colorScheme.primary,
472 - textColor: Theme.of(context).colorScheme.onPrimary,
473 - isLoading: sendViewModel.state is IsExecutingState ||
474 - sendViewModel.state is TransactionCommitting ||
475 - sendViewModel.state is IsAwaitingDeviceResponseState ||
476 - sendViewModel.state is LoadingTemplateExecutingState,
477 - isDisabled: !sendViewModel.isReadyForSend || sendViewModel.state is ExecutedSuccessfullyState,
478 - );
479 - },
480 - )
481 - ],
482 - )),
483 - ),
484 - ),
485 - ],
486 - );
487 - });
487 + )
488 + ],
489 + )),
490 + ),
491 + ),
492 + ],
493 + );
494 + });
495 + }
496 + );
497 }
498
499 BuildContext? dialogContext;
@@ -499,6 +508,11 @@ class SendPage extends BasePage {
508 bitcoin!.updateFeeRates(sendViewModel.wallet);
509 }
510
511 + controller.addListener((){
512 + if (!controller.hasClients) return;
513 + currentPage.value = controller.page!.round();
514 + });
515 +
516 reaction((_) => sendViewModel.state, (ExecutionState state) async {
517 if (dialogContext != null && dialogContext?.mounted == true) {
518 Navigator.of(dialogContext!).pop();