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();