diff --git a/emma/lib/main.dart b/emma/lib/main.dart index 7dd9edd..d48529a 100644 --- a/emma/lib/main.dart +++ b/emma/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; - +import 'dart:async'; void main() => runApp(const MyApp()); +const Duration fakeAPIDuration = Duration(milliseconds: 50); +const Duration debounceDuration = Duration(milliseconds: 50); class MyApp extends StatelessWidget { const MyApp({super.key}); @@ -90,7 +92,7 @@ class _MyFormPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - TextFormField( + /*TextFormField( keyboardType: TextInputType.text, autocorrect: false, decoration: const InputDecoration( @@ -103,8 +105,9 @@ class _MyFormPageState extends State { } return null; }, - ), + ),*/ const SizedBox(height: 20), + const _AsyncAutocomplete(), TextFormField( keyboardType: TextInputType.emailAddress, decoration: const InputDecoration( @@ -455,3 +458,203 @@ class CategoriePage extends StatelessWidget { ); } } +class _AsyncAutocomplete extends StatefulWidget { + const _AsyncAutocomplete(); + + @override + State<_AsyncAutocomplete> createState() => _AsyncAutocompleteState(); +} + +class _AsyncAutocompleteState extends State<_AsyncAutocomplete> { + // The query currently being searched for. If null, there is no pending + // request. + String? _currentQuery; + + // The most recent options received from the API. + late Iterable _lastOptions = []; + + late final _Debounceable?, String> _debouncedSearch; + + // Whether to consider the fake network to be offline. + bool _networkEnabled = true; + + // A network error was recieved on the most recent query. + bool _networkError = false; + + // Calls the "remote" API to search with the given query. Returns null when + // the call has been made obsolete. + Future?> _search(String query) async { + _currentQuery = query; + + late final Iterable options; + try { + options = await _FakeAPI.search(_currentQuery!, _networkEnabled); + } catch (error) { + if (error is _NetworkException) { + setState(() { + _networkError = true; + }); + return []; + } + rethrow; + } + + // If another search happened after this one, throw away these options. + if (_currentQuery != query) { + return null; + } + _currentQuery = null; + + return options; + } + + @override + void initState() { + super.initState(); + _debouncedSearch = _debounce?, String>(_search); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _networkEnabled + ? 'Network is on, toggle to induce network errors.' + : 'Network is off, toggle to allow requests to go through.', + ), + Switch( + value: _networkEnabled, + onChanged: (bool? value) { + setState(() { + _networkEnabled = !_networkEnabled; + }); + }, + ), + const SizedBox( + height: 32.0, + ), + Autocomplete( + fieldViewBuilder: (BuildContext context, + TextEditingController controller, + FocusNode focusNode, + VoidCallback onFieldSubmitted) { + return TextFormField( + decoration: InputDecoration( + labelText: 'Wohin solls gehen?', + border: OutlineInputBorder(), + errorText: + _networkError ? 'Network error, please try again.' : null, + ), + controller: controller, + focusNode: focusNode, + onFieldSubmitted: (String value) { + onFieldSubmitted(); + }, + ); + }, + optionsBuilder: (TextEditingValue textEditingValue) async { + setState(() { + _networkError = false; + }); + final Iterable? options = + await _debouncedSearch(textEditingValue.text); + if (options == null) { + return _lastOptions; + } + _lastOptions = options; + return options; + }, + onSelected: (String selection) { + debugPrint('You just selected $selection'); + }, + ), + const SizedBox(height: 20), + ], + ); + } +} + +// Mimics a remote API. +class _FakeAPI { + static const List _kOptions = [ + 'ingolstadt', + 'jesuitenstraße', + 'eiskeller', + ]; + + // Searches the options, but injects a fake "network" delay. + static Future> search( + String query, bool networkEnabled) async { + await Future.delayed(fakeAPIDuration); // Fake 1 second delay. + if (!networkEnabled) { + throw const _NetworkException(); + } + if (query == '') { + return const Iterable.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} + +typedef _Debounceable = Future Function(T parameter); + +/// Returns a new function that is a debounced version of the given function. +/// +/// This means that the original function will be called only after no calls +/// have been made for the given Duration. +_Debounceable _debounce(_Debounceable function) { + _DebounceTimer? debounceTimer; + + return (T parameter) async { + if (debounceTimer != null && !debounceTimer!.isCompleted) { + debounceTimer!.cancel(); + } + debounceTimer = _DebounceTimer(); + try { + await debounceTimer!.future; + } catch (error) { + if (error is _CancelException) { + return null; + } + rethrow; + } + return function(parameter); + }; +} + +// A wrapper around Timer used for debouncing. +class _DebounceTimer { + _DebounceTimer() { + _timer = Timer(debounceDuration, _onComplete); + } + + late final Timer _timer; + final Completer _completer = Completer(); + + void _onComplete() { + _completer.complete(); + } + + Future get future => _completer.future; + + bool get isCompleted => _completer.isCompleted; + + void cancel() { + _timer.cancel(); + _completer.completeError(const _CancelException()); + } +} + +// An exception indicating that the timer was canceled. +class _CancelException implements Exception { + const _CancelException(); +} + +// An exception indicating that a network request has failed. +class _NetworkException implements Exception { + const _NetworkException(); +}