diff --git a/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart b/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart index 287f65e23..bd767d276 100644 --- a/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart +++ b/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart @@ -14,8 +14,9 @@ import 'package:yaru/yaru.dart'; import '/constants.dart'; import '/core/agent_api_client.dart'; +import '/pages/widgets/delayed_text_field.dart'; +import '/pages/widgets/navigation_row.dart'; import '/pages/widgets/page_widgets.dart'; -import '../widgets/navigation_row.dart'; import 'landscape_model.dart'; /// Defines the overall structure of the Landscape configuration page and seggregates @@ -161,20 +162,16 @@ class _ManualForm extends StatelessWidget { return Column( children: [ - TextField( - decoration: InputDecoration( - label: Text(lang.landscapeFQDNLabel), - errorText: model.manual.fqdnError.localize(lang), - ), + DelayedTextField( + label: Text(lang.landscapeFQDNLabel), + errorText: model.manual.fqdnError.localize(lang), onChanged: model.setFqdn, enabled: model.configType == LandscapeConfigType.manual, ), const SizedBox(height: 8), - TextField( - decoration: InputDecoration( - label: Text(lang.landscapeKeyLabel), - hintText: '163456', - ), + DelayedTextField( + label: Text(lang.landscapeKeyLabel), + hintText: '163456', onChanged: model.setManualRegistrationKey, enabled: model.configType == LandscapeConfigType.manual, ), @@ -257,12 +254,10 @@ class _FilePickerFieldState extends State<_FilePickerField> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: TextField( - decoration: InputDecoration( - label: Text(widget.inputlabel), - hintText: widget.hint, - errorText: widget.errorText, - ), + child: DelayedTextField( + label: Text(widget.inputlabel), + hintText: widget.hint, + errorText: widget.errorText, controller: txt, onChanged: widget.onChanged, enabled: widget.enabled, diff --git a/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_widgets.dart b/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_widgets.dart index 7dbc315c1..9280b2066 100644 --- a/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_widgets.dart +++ b/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_widgets.dart @@ -5,7 +5,9 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:yaru/yaru.dart'; -import '../../core/pro_token.dart'; + +import '/core/pro_token.dart'; +import '/pages/widgets/delayed_text_field.dart'; import 'subscribe_now_model.dart'; /// A validated text field with a submit button that calls the supplied [onApply] @@ -51,27 +53,25 @@ class ProTokenInputField extends StatelessWidget { styleSheet: linkStyle, ), const SizedBox(height: 8), - TextField( + DelayedTextField( inputFormatters: [ // This ignores all sorts of (Unicode) whitespaces (not only at the ends). FilteringTextInputFormatter.deny(RegExp(r'\s')), ], autofocus: false, controller: controller, - decoration: InputDecoration( - label: Text(lang.tokenInputHint), - error: model.tokenError?.localize(lang) != null - ? Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - model.tokenError!.localize(lang)!, - style: theme.textTheme.bodySmall!.copyWith( - color: YaruColors.of(context).error, - ), + label: Text(lang.tokenInputHint), + error: model.tokenError?.localize(lang) != null + ? Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + model.tokenError!.localize(lang)!, + style: theme.textTheme.bodySmall!.copyWith( + color: YaruColors.of(context).error, ), - ) - : null, - ), + ), + ) + : null, onChanged: model.updateToken, onSubmitted: (_) => onSubmit?.call(), ), diff --git a/gui/packages/ubuntupro/lib/pages/widgets/delayed_text_field.dart b/gui/packages/ubuntupro/lib/pages/widgets/delayed_text_field.dart new file mode 100644 index 000000000..48e6c1aa4 --- /dev/null +++ b/gui/packages/ubuntupro/lib/pages/widgets/delayed_text_field.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +/// A [TextField] that displays error messages on a delay instead of +/// immediately. +class DelayedTextField extends StatefulWidget { + const DelayedTextField({ + this.autofocus = false, + this.enabled = true, + this.controller, + this.error, + this.errorText, + this.hintText, + this.inputFormatters, + this.label, + this.onChanged, + this.onSubmitted, + super.key, + }); + + final bool autofocus; + final TextEditingController? controller; + final bool enabled; + final Widget? error; + final String? errorText; + final String? hintText; + final List? inputFormatters; + final Widget? label; + final void Function(String)? onChanged; + final void Function(String)? onSubmitted; + + @override + State createState() => _DelayedTextField(); +} + +class _DelayedTextField extends State + with SingleTickerProviderStateMixin { + late TimerNotifier debouncer; + + bool showError = false; + + @override + void initState() { + super.initState(); + debouncer = TimerNotifier(vsync: this, duration: Durations.medium4); + debouncer.addListener(() { + showError = mounted && widget.error != null || widget.errorText != null; + }); + } + + @override + void dispose() { + debouncer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: debouncer, + builder: (context, _) { + return TextField( + controller: widget.controller, + autofocus: widget.autofocus, + inputFormatters: widget.inputFormatters, + onChanged: (value) { + widget.onChanged?.call(value); + debouncer.stop(); + debouncer.resume(); + }, + onSubmitted: widget.onSubmitted, + decoration: InputDecoration( + error: showError ? widget.error : null, + errorText: showError ? widget.errorText : null, + label: widget.label, + ), + ); + }, + ); + } +} + +/// A [ChangeNotifier] that notifies when the provided duration elapses. +class TimerNotifier extends ChangeNotifier { + TimerNotifier({required this.duration, required this.vsync}) + : assert(duration > Duration.zero, 'Duration must be greater than zero') { + _ticker = vsync.createTicker(_onTick); + } + + final TickerProvider vsync; + final Duration duration; + + Ticker? _ticker; + + @override + void dispose() { + _ticker?.dispose(); + super.dispose(); + } + + /// Stops the timer. + void stop() { + _ticker?.stop(); + } + + /// Resumes the timer from the last elapsed time. + void resume() { + _ticker?.start(); + } + + /// Callback executed on each tick. + void _onTick(Duration elapsed) { + if (elapsed >= duration) { + _ticker?.stop(); + scheduleMicrotask(notifyListeners); + } + } +} diff --git a/gui/packages/ubuntupro/test/pages/landscape/landscape_page_test.dart b/gui/packages/ubuntupro/test/pages/landscape/landscape_page_test.dart index b89de49dc..16d67df2b 100644 --- a/gui/packages/ubuntupro/test/pages/landscape/landscape_page_test.dart +++ b/gui/packages/ubuntupro/test/pages/landscape/landscape_page_test.dart @@ -116,12 +116,12 @@ void main() { final continueButton = find.button(lang.landscapeRegister); await tester.enterText(fqdnInput, '::'); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(continueButton); expect(applied, isFalse); await tester.enterText(fqdnInput, kExampleLandscapeFQDN); - await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(continueButton); await tester.pump(); expect(applied, isTrue); @@ -154,7 +154,7 @@ void main() { await tester.pump(); await tester.enterText(fileInput, customConf); - await tester.pump(); + await tester.pumpAndSettle(); final continueButton = find.button(lang.landscapeRegister); expect(tester.widget(continueButton).enabled, isTrue); @@ -185,7 +185,7 @@ void main() { expect(fqdnInput, findsOne); await tester.enterText(fqdnInput, '::'); - await tester.pump(); + await tester.pumpAndSettle(); final errorText = find.text(lang.landscapeFQDNError); expect(errorText, findsOne); @@ -210,7 +210,7 @@ void main() { await tester.pump(); await tester.enterText(fileInput, notFoundPath); - await tester.pump(); + await tester.pumpAndSettle(); final errorText = find.text(lang.landscapeFileNotFound); expect(errorText, findsOne); @@ -238,7 +238,7 @@ void main() { await tester.tap(fqdnInput); await tester.pump(); await tester.enterText(fqdnInput, kExampleLandscapeFQDN); - await tester.pump(); + await tester.pumpAndSettle(); final next = find.button(lang.landscapeRegister); await tester.tap(next); diff --git a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart index e8b0f1f17..e088e0fc4 100644 --- a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart +++ b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart @@ -152,7 +152,7 @@ void main() { final input = find.textField(lang.tokenInputHint); await tester.enterText(input, good); - await tester.pump(); + await tester.pumpAndSettle(); expect(tester.firstWidget(attach).enabled, isTrue); @@ -186,7 +186,7 @@ void main() { final input = find.textField(lang.tokenInputHint); await tester.enterText(input, invalidTokens[0]); - await tester.pump(); + await tester.pumpAndSettle(); expect(tester.firstWidget(attach).enabled, isFalse); diff --git a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_widgets_test.dart b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_widgets_test.dart index 68550ea2a..10e555a78 100644 --- a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_widgets_test.dart +++ b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_widgets_test.dart @@ -48,7 +48,7 @@ void main() { for (final token in tks.invalidTokens) { await tester.enterText(inputField, token); - await tester.pump(); + await tester.pumpAndSettle(); final errorText = find.descendant( of: inputField, @@ -66,7 +66,7 @@ void main() { final lang = AppLocalizations.of(context); await tester.enterText(inputField, tks.invalidTokens[0]); - await tester.pump(); + await tester.pumpAndSettle(); final errorText = find.descendant( of: inputField, @@ -76,7 +76,7 @@ void main() { // ...except when we delete the content we should have no more errors await tester.enterText(inputField, ''); - await tester.pump(); + await tester.pumpAndSettle(); final input = tester.firstWidget(inputField); expect(input.decoration!.error, isNull); }); @@ -88,7 +88,7 @@ void main() { final lang = AppLocalizations.of(context); await tester.enterText(inputField, tks.good); - await tester.pump(); + await tester.pumpAndSettle(); final input = tester.firstWidget(inputField); expect(input.decoration!.error, isNull); @@ -110,7 +110,7 @@ void main() { // good token plus a bunch of types of white spaces. ' ${tks.good} \u{00A0}\u{2000}\u{2002}\u{202F}\u{205F}\u{3000} ', ); - await tester.pump(); + await tester.pumpAndSettle(); final input = tester.firstWidget(inputField); expect(input.decoration!.errorText, isNull); @@ -131,7 +131,7 @@ void main() { final inputField = find.byType(TextField); await tester.enterText(inputField, tks.good); - await tester.pump(); + await tester.pumpAndSettle(); expect(called, isFalse); // simulate an enter key/submission of the text field