Skip to content

Commit

Permalink
feat: Add widget for delaying errors on text fields (#1043)
Browse files Browse the repository at this point in the history
Adds a delay to error messages while typing in a text field. Notably,
there is no delay when errors are removed.

---

UDENG-5681
  • Loading branch information
ashuntu authored Jan 10, 2025
2 parents fc6bd2b + 4964002 commit 07e2176
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 46 deletions.
29 changes: 12 additions & 17 deletions gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(),
),
Expand Down
121 changes: 121 additions & 0 deletions gui/packages/ubuntupro/lib/pages/widgets/delayed_text_field.dart
Original file line number Diff line number Diff line change
@@ -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<TextInputFormatter>? inputFormatters;
final Widget? label;
final void Function(String)? onChanged;
final void Function(String)? onSubmitted;

@override
State<DelayedTextField> createState() => _DelayedTextField();
}

class _DelayedTextField extends State<DelayedTextField>
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<ButtonStyleButton>(continueButton).enabled, isTrue);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ButtonStyleButton>(attach).enabled, isTrue);

Expand Down Expand Up @@ -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<ButtonStyleButton>(attach).enabled, isFalse);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<TextField>(inputField);
expect(input.decoration!.error, isNull);
});
Expand All @@ -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<TextField>(inputField);
expect(input.decoration!.error, isNull);
Expand All @@ -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<TextField>(inputField);
expect(input.decoration!.errorText, isNull);
Expand All @@ -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
Expand Down

0 comments on commit 07e2176

Please sign in to comment.