<|start_filename|>lib/src/searchfield.dart<|end_filename|>
import 'dart:async';
import 'package:flutter/material.dart';

enum SuggestionState {
  /// always show suggestions
  enabled,

  /// hide the suggestions on initial focus
  hidden,

  /// show suggestions only on tap
  onTap,
}

// enum to define the Focus of the searchfield when a suggestion is tapped
enum SuggestionAction {
  /// shift to next focus
  next,

  /// close keyboard and unfocus
  unfocus,
}

class SearchField extends StatefulWidget {
  /// Data source to perform search.
  final List<String> suggestions;

  /// Callback to return the selected suggestion.
  final Function(String?)? onTap;

  /// Hint for the [SearchField].
  final String? hint;

  /// Define a [TextInputAction] that is called when the field is submitted
  final TextInputAction? textInputAction;

  /// The initial value to be selected for [SearchField]. The value
  /// must be present in [suggestions].
  ///
  /// When not specified, [hint] is shown instead of `initialValue`.
  final String? initialValue;

  /// Specifies [TextStyle] for search input.
  final TextStyle? searchStyle;

  /// Specifies [TextStyle] for suggestions.
  final TextStyle? suggestionStyle;

  /// Specifies [InputDecoration] for search input [TextField].
  ///
  /// When not specified, the default value is [InputDecoration] initialized
  /// with [hint].
  final InputDecoration? searchInputDecoration;

  /// defaults to SuggestionState.hidden
  final SuggestionState suggestionState;

  /// Specifies the [SuggestionAction] called on suggestion tap.
  final SuggestionAction? suggestionAction;

  /// Specifies [BoxDecoration] for suggestion list. The property can be used to add [BoxShadow],
  /// and much more. For more information, checkout [BoxDecoration].
  ///
  /// Default value,
  ///
  /// ```dart
  /// BoxDecoration(
  ///   color: Theme.of(context).colorScheme.surface,
  ///   boxShadow: [
  ///     BoxShadow(
  ///       color: onSurfaceColor.withOpacity(0.1),
  ///       blurRadius: 8.0, // soften the shadow
  ///       spreadRadius: 2.0, //extend the shadow
  ///       offset: Offset(
  ///         2.0,
  ///         5.0,
  ///       ),
  ///     ),
  ///   ],
  /// )
  /// ```
  final BoxDecoration? suggestionsDecoration;

  /// Specifies [BoxDecoration] for items in suggestion list. The property can be used to add [BoxShadow],
  /// and much more. For more information, checkout [BoxDecoration].
  ///
  /// Default value,
  ///
  /// ```dart
  /// BoxDecoration(
  ///   border: Border(
  ///     bottom: BorderSide(
  ///       color: widget.marginColor ??
  ///         onSurfaceColor.withOpacity(0.1),
  ///     ),
  ///   ),
  /// )
  final BoxDecoration? suggestionItemDecoration;

  /// Specifies height for item suggestion.
  ///
  /// When not specified, the default value is `35.0`.
  final double itemHeight;

  /// Specifies the color of margin between items in suggestions list.
  ///
  /// When not specified, the default value is `Theme.of(context).colorScheme.onSurface.withOpacity(0.1)`.
  final Color? marginColor;

  /// Specifies the number of suggestions that can be shown in viewport.
  ///
  /// When not specified, the default value is `5`.
  /// if the number of suggestions is less than 5, then [maxSuggestionsInViewPort]
  /// will be the length of [suggestions]
  final int maxSuggestionsInViewPort;

  /// Specifies the `TextEditingController` for [SearchField].
  final TextEditingController? controller;

  /// `validator` for the [SearchField]
  /// to make use of this validator, The
  /// SearchField widget needs to be wrapped in a Form
  /// and pass it a Global key
  /// and write your validation logic in the validator
  /// you can define a global key
  ///
  ///  ```
  ///  Form(
  ///   key: _formKey,
  ///   child: SearchField(
  ///     suggestions: _statesOfIndia,
  ///     validator: (state) {
  ///       if (!_statesOfIndia.contains(state) || state.isEmpty) {
  ///         return 'Please Enter a valid State';
  ///       }
  ///       return null;
  ///     },
  ///   )
  /// ```
  /// You can then validate the form by calling
  /// the validate function of the form
  ///
  /// `_formKey.currentState.validate();`
  ///
  ///
  ///
  final String? Function(String?)? validator;

  /// if false the suggestions will be shown below
  /// the searchfield along the Y-axis.
  /// if true the suggestions will be shown floating like the
  /// along the Z-axis
  /// defaults to ```true```
  final bool hasOverlay;

  SearchField({
    Key? key,
    required this.suggestions,
    this.initialValue,
    this.hint,
    this.hasOverlay = true,
    this.searchStyle,
    this.marginColor,
    this.controller,
    this.validator,
    this.suggestionState = SuggestionState.hidden,
    this.itemHeight = 35.0,
    this.suggestionsDecoration,
    this.suggestionStyle,
    this.searchInputDecoration,
    this.suggestionItemDecoration,
    this.maxSuggestionsInViewPort = 5,
    this.onTap,
    this.textInputAction,
    this.suggestionAction,
  })  : assert(
            (initialValue != null && suggestions.contains(initialValue)) ||
                initialValue == null,
            'Initial value should either be null or should be present in suggestions list.'),
        super(key: key);

  @override
  _SearchFieldState createState() => _SearchFieldState();
}

class _SearchFieldState extends State<SearchField> {
  final StreamController<List<String?>?> sourceStream =
      StreamController<List<String>?>.broadcast();
  final FocusNode _focus = FocusNode();
  bool sourceFocused = false;
  TextEditingController? sourceController;

  @override
  void dispose() {
    _focus.dispose();
    sourceStream.close();
    super.dispose();
  }

  void initialize() {
    _focus.addListener(() {
      setState(() {
        sourceFocused = _focus.hasFocus;
      });
      if (widget.hasOverlay) {
        if (sourceFocused) {
          if (widget.initialValue == null) {
            if (widget.suggestionState == SuggestionState.enabled) {
              Future.delayed(Duration(milliseconds: 100), () {
                sourceStream.sink.add(widget.suggestions);
              });
            }
          }
          _overlayEntry = _createOverlay();
          Overlay.of(context)!.insert(_overlayEntry);
        } else {
          _overlayEntry.remove();
        }
      } else if (sourceFocused) {
        if (widget.initialValue == null) {
          if (widget.suggestionState == SuggestionState.enabled) {
            Future.delayed(Duration(milliseconds: 100), () {
              sourceStream.sink.add(widget.suggestions);
            });
          }
        }
      }
    });
  }

  late OverlayEntry _overlayEntry;
  @override
  void initState() {
    super.initState();
    sourceController = widget.controller ?? TextEditingController();
    initialize();
    WidgetsBinding.instance!.addPostFrameCallback((_) {
      if (widget.initialValue == null || widget.initialValue!.isEmpty) {
        sourceStream.sink.add(null);
      } else {
        sourceController!.text = widget.initialValue!;
        sourceStream.sink.add([widget.initialValue]);
      }
    });
  }

  @override
  void didUpdateWidget(covariant SearchField oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.controller != widget.controller) {
      sourceController = widget.controller ?? TextEditingController();
    }
    if (oldWidget.hasOverlay != widget.hasOverlay) {
      if (widget.hasOverlay) {
        initialize();
      } else {
        if (_overlayEntry.mounted) {
          _overlayEntry.remove();
        }
      }
      setState(() {});
    }
  }

  Widget _suggestionsBuilder() {
    final onSurfaceColor = Theme.of(context).colorScheme.onSurface;
    return StreamBuilder<List<String?>?>(
      stream: sourceStream.stream,
      builder: (BuildContext context, AsyncSnapshot<List<String?>?> snapshot) {
        if (snapshot.data == null || snapshot.data!.isEmpty || !sourceFocused) {
          return Container();
        } else {
          if (snapshot.data!.length > widget.maxSuggestionsInViewPort) {
            height = widget.itemHeight * widget.maxSuggestionsInViewPort;
          } else if (snapshot.data!.length == 1) {
            height = widget.itemHeight;
          } else {
            height = snapshot.data!.length * widget.itemHeight;
          }
          return AnimatedContainer(
            duration: isUp ? Duration.zero : Duration(milliseconds: 300),
            height: height,
            alignment: Alignment.centerLeft,
            decoration: widget.suggestionsDecoration ??
                BoxDecoration(
                  color: Theme.of(context).colorScheme.surface,
                  boxShadow: [
                    BoxShadow(
                      color: onSurfaceColor.withOpacity(0.1),
                      blurRadius: 8.0, // soften the shadow
                      spreadRadius: 2.0, // extend the shadow
                      offset: widget.hasOverlay
                          ? Offset(
                              2.0,
                              5.0,
                            )
                          : Offset(1.0, 0.5),
                    ),
                  ],
                ),
            child: ListView.builder(
              reverse: isUp,
              padding: EdgeInsets.zero,
              itemCount: snapshot.data!.length,
              physics: snapshot.data!.length == 1
                  ? NeverScrollableScrollPhysics()
                  : ScrollPhysics(),
              itemBuilder: (context, index) => GestureDetector(
                onTap: () {
                  sourceController!.text = snapshot.data![index]!;
                  sourceController!.selection = TextSelection.fromPosition(
                    TextPosition(
                      offset: sourceController!.text.length,
                    ),
                  );

                  // suggestion action
                  if (widget.suggestionAction != null) {
                    if (widget.suggestionAction == SuggestionAction.next) {
                      _focus.nextFocus();
                    } else if (widget.suggestionAction ==
                        SuggestionAction.unfocus) {
                      _focus.unfocus();
                    }
                  }

                  // hide the suggestions
                  sourceStream.sink.add(null);
                  if (widget.onTap != null) {
                    widget.onTap!(snapshot.data![index]);
                  }
                },
                child: Container(
                  height: widget.itemHeight,
                  padding: EdgeInsets.symmetric(horizontal: 5) +
                      EdgeInsets.only(left: 8),
                  width: double.infinity,
                  alignment: Alignment.centerLeft,
                  decoration: widget.suggestionItemDecoration?.copyWith(
                        border: Border(
                          bottom: BorderSide(
                            color: widget.marginColor ??
                                onSurfaceColor.withOpacity(0.1),
                          ),
                        ),
                      ) ??
                      BoxDecoration(
                        border: index == snapshot.data!.length - 1
                            ? null
                            : Border(
                                bottom: BorderSide(
                                  color: widget.marginColor ??
                                      onSurfaceColor.withOpacity(0.1),
                                ),
                              ),
                      ),
                  child: Text(
                    snapshot.data![index]!,
                    style: widget.suggestionStyle,
                  ),
                ),
              ),
            ),
          );
        }
      },
    );
  }

  Offset getYOffset(Offset widgetOffset, int resultCount) {
    final size = MediaQuery.of(context).size;
    final position = widgetOffset.dy;
    if ((position + height) < (size.height - widget.itemHeight * 2)) {
      return Offset(0, widget.itemHeight + 10.0);
    } else {
      if (resultCount > widget.maxSuggestionsInViewPort) {
        isUp = false;
        return Offset(
            0, -(widget.itemHeight * widget.maxSuggestionsInViewPort));
      } else {
        isUp = true;
        return Offset(0, -(widget.itemHeight * resultCount));
      }
    }
  }

  OverlayEntry _createOverlay() {
    final renderBox = context.findRenderObject() as RenderBox;
    final size = renderBox.size;
    final offset = renderBox.localToGlobal(Offset.zero);
    return OverlayEntry(
        builder: (context) => StreamBuilder<List<String?>?>(
            stream: sourceStream.stream,
            builder:
                (BuildContext context, AsyncSnapshot<List<String?>?> snapshot) {
              late var count = widget.maxSuggestionsInViewPort;
              if (snapshot.data != null) {
                count = snapshot.data!.length;
              }
              return Positioned(
                left: offset.dx,
                width: size.width,
                child: CompositedTransformFollower(
                    offset: getYOffset(offset, count),
                    link: _layerLink,
                    child: Material(child: _suggestionsBuilder())),
              );
            }));
  }

  final LayerLink _layerLink = LayerLink();
  late double height;
  bool isUp = false;
  @override
  Widget build(BuildContext context) {
    if (widget.suggestions.length > widget.maxSuggestionsInViewPort) {
      height = widget.itemHeight * widget.maxSuggestionsInViewPort;
    } else {
      height = widget.suggestions.length * widget.itemHeight;
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        CompositedTransformTarget(
          link: _layerLink,
          child: TextFormField(
            onTap: () {
              /// only call that if [SuggestionState.onTap] is selected
              if (!sourceFocused &&
                  widget.suggestionState == SuggestionState.onTap) {
                setState(() {
                  sourceFocused = true;
                });
                Future.delayed(Duration(milliseconds: 100), () {
                  sourceStream.sink.add(widget.suggestions);
                });
              }
            },
            controller: widget.controller ?? sourceController,
            focusNode: _focus,
            validator: widget.validator,
            style: widget.searchStyle,
            textInputAction: widget.textInputAction,
            decoration:
                widget.searchInputDecoration?.copyWith(hintText: widget.hint) ??
                    InputDecoration(hintText: widget.hint),
            onChanged: (item) {
              final searchResult = <String>[];
              if (item.isEmpty) {
                sourceStream.sink.add(widget.suggestions);
                return;
              }
              for (final suggestion in widget.suggestions) {
                if (suggestion.toLowerCase().contains(item.toLowerCase())) {
                  searchResult.add(suggestion);
                }
              }
              sourceStream.sink.add(searchResult);
            },
          ),
        ),
        if (!widget.hasOverlay)
          SizedBox(
            height: 2,
          ),
        if (!widget.hasOverlay) _suggestionsBuilder()
      ],
    );
  }
}


<|start_filename|>example/lib/main.dart<|end_filename|>
import 'package:example/country_model.dart';
import 'package:flutter/material.dart';
import 'package:searchfield/searchfield.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(
        title: "Demo Home Page",
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }

  final _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    countries = data.map((e) => Country.fromMap(e)).toList();
  }

  List<Country> countries = [];
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: () => FocusScope.of(context).unfocus(),
        child: Scaffold(
            appBar: AppBar(
              title: Text(widget.title),
            ),
            body: Column(
              children: [
                Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: SearchField(
                    suggestions: countries.map((e) => e.name).toList(),
                    suggestionState: SuggestionState.enabled,
                    controller: _searchController,
                    hint: 'Search by country name',
                    maxSuggestionsInViewPort: 4,
                    itemHeight: 45,
                    onTap: (x) {
                      final country = countries.firstWhere((e) => e.name == x);
                      Navigator.of(context)
                          .push(MaterialPageRoute<CountryDetail>(
                              builder: (_) => CountryDetail(
                                    country: country,
                                  )));
                    },
                  ),
                ),
                Expanded(
                  child: ListView.separated(
                    itemCount: countries.length,
                    separatorBuilder: (_, index) => Divider(),
                    itemBuilder: (_, index) => ListTile(
                      title: Text(countries[index].name),
                      trailing: IconButton(
                          onPressed: () {
                            Navigator.of(context)
                                .push(MaterialPageRoute<CountryDetail>(
                                    builder: (_) => CountryDetail(
                                          country: countries[index],
                                        )));
                          },
                          icon: Icon(Icons.arrow_forward_ios, size: 16)),
                    ),
                  ),
                )
              ],
            )));
  }
}

class CountryDetail extends StatefulWidget {
  final Country country;

  const CountryDetail({Key key, this.country}) : super(key: key);
  @override
  _CountryDetailState createState() => _CountryDetailState();
}

class _CountryDetailState extends State<CountryDetail> {
  Widget dataWidget(String key, int value) {
    return Container(
      alignment: Alignment.center,
      padding: EdgeInsets.symmetric(
          horizontal: MediaQuery.of(context).size.width * 0.15),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Text("$key:"),
          SizedBox(
            width: 16,
          ),
          Flexible(
            child: Text(
              '$value',
              style: TextStyle(fontSize: 30),
            ),
          )
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Details'),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Container(
            padding: EdgeInsets.symmetric(horizontal: 24),
            alignment: Alignment.center,
            child: Text(
              widget.country.name,
              style: TextStyle(fontSize: 40),
            ),
          ),
          SizedBox(
            height: 20,
          ),
          dataWidget('Population:', widget.country.population),
          dataWidget('Density', widget.country.density),
          dataWidget('Land Area (in Km\'s)', widget.country.landArea)
        ],
      ),
    );
  }
}


<|start_filename|>test/searchfield_test.dart<|end_filename|>
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:searchfield/searchfield.dart';

void main() {
  Widget _boilerplate({required Widget child}) {
    return MaterialApp(
      home: Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(size: Size(800.0, 600.0)),
          child: Center(
            key: const Key('centerKey'),
            child: Material(
              child: child,
            ),
          ),
        ),
      ),
    );
  }

  group('Searchfield integration tests', () {
    testWidgets(
        'Test assert: Initial value should either be null or should be present in suggestions list.',
        (WidgetTester tester) async {
      await tester.pump();

      expect(
          () => _boilerplate(
              child: SearchField(
                  suggestions: ['ABC', 'DEF'], initialValue: 'GHI')),
          throwsAssertionError);

      await tester.pumpWidget(_boilerplate(
          child: SearchField(
        key: const Key('searchfield'),
        suggestions: ['ABC', 'DEF'],
      )));

      final finder = find.byKey(const Key('searchfield'));
      expect(finder, findsOneWidget);
    });
  });
}


<|start_filename|>example/lib/example2.dart<|end_filename|>
import 'package:flutter/material.dart';
import 'package:searchfield/searchfield.dart';

class ExampleDemo extends StatefulWidget {
  @override
  _ExampleDemoState createState() => _ExampleDemoState();
}

class _ExampleDemoState extends State<ExampleDemo> {
  final List<String> _suggestions = [
    'United States',
    'Germany',
    'Washington',
    'Paris',
    'Jakarta',
    'Australia',
    'India',
    'Czech Republic',
    'Lorem Ipsum',
  ];
  final List<String> _statesOfIndia = [
    'Andhra Pradesh',
    'Assam',
    'Arunachal Pradesh',
    'Bihar',
    'Goa',
    'Gujarat',
    'Jammu and Kashmir',
    'Jharkhand',
    'West Bengal',
    'Karnataka',
    'Kerala',
    'Madhya Pradesh',
    'Maharashtra',
    'Manipur',
    'Meghalaya',
    'Mizoram',
    'Nagaland',
    'Orissa',
    'Punjab',
    'Rajasthan',
    'Sikkim',
    'Tamil Nadu',
    'Tripura',
    'Uttaranchal',
    'Uttar Pradesh',
    'Haryana',
    'Himachal Pradesh',
    'Chhattisgarh'
  ];
  final _formKey = GlobalKey<FormState>();

  final _searchController = TextEditingController();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: <Widget>[
          SizedBox(
            height: 20,
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: SearchField(
              suggestionState: SuggestionState.enabled,
              suggestionAction: SuggestionAction.next,
              suggestions: _suggestions,
              textInputAction: TextInputAction.next,
              controller: _searchController,
              hint: 'SearchField Sample 1',
              initialValue: _suggestions[2],
              maxSuggestionsInViewPort: 3,
              itemHeight: 45,
              onTap: (x) {
                print('selected =$x ${_searchController.text}');
              },
            ),
          ),
          SizedBox(
            height: 20,
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Form(
              key: _formKey,
              child: SearchField(
                suggestions: _statesOfIndia,
                suggestionState: SuggestionState.enabled,
                textInputAction: TextInputAction.next,
                hint: 'SearchField Sample 2',
                searchStyle: TextStyle(
                  fontSize: 18,
                  color: Colors.black.withOpacity(0.8),
                ),
                validator: (x) {
                  if (!_statesOfIndia.contains(x) || x.isEmpty) {
                    return 'Please Enter a valid State';
                  }
                  return null;
                },
                searchInputDecoration: InputDecoration(
                  focusedBorder: OutlineInputBorder(
                    borderSide: BorderSide(
                      color: Colors.black.withOpacity(0.8),
                    ),
                  ),
                  border: OutlineInputBorder(
                    borderSide: BorderSide(color: Colors.red),
                  ),
                ),
                maxSuggestionsInViewPort: 6,
                itemHeight: 50,
                onTap: (x) {
                  print(x);
                },
              ),
            ),
          ),
          SizedBox(
            height: 20,
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: SearchField(
              maxSuggestionsInViewPort: 5,
              itemHeight: 40,
              hint: 'SearchField Sample 3',
              suggestionsDecoration: BoxDecoration(
                borderRadius: BorderRadius.only(
                  bottomLeft: Radius.circular(8.0),
                  bottomRight: Radius.circular(8),
                ),
                border: Border.all(
                  color: Colors.grey.withOpacity(0.5),
                ),
              ),
              suggestionItemDecoration: BoxDecoration(
                gradient: LinearGradient(
                  colors: [
                    Color(0xff9D50BB).withOpacity(0.5),
                    Color(0xff6E48AA).withOpacity(0.8),
                  ],
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                ),
              ),
              searchInputDecoration: InputDecoration(
                filled: true,
                fillColor: Colors.grey.withOpacity(0.2),
                focusedBorder: OutlineInputBorder(
                  borderSide: const BorderSide(
                    color: Colors.white,
                    width: 2.0,
                  ),
                  borderRadius: BorderRadius.circular(8.0),
                ),
                border: OutlineInputBorder(),
              ),
              marginColor: Colors.white,
              suggestions: _suggestions,
            ),
          ),
          SizedBox(
            height: 20,
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: SearchField(
              suggestionStyle: TextStyle(color: Colors.green),
              suggestionItemDecoration: BoxDecoration(
                color: Colors.grey.withOpacity(0.2),
                gradient: LinearGradient(colors: [
                  Color(0xff70e1f5),
                  Color(0xffffd194),
                ], begin: Alignment.topLeft, end: Alignment.bottomRight),
              ),
              suggestions: _suggestions,
              suggestionState: SuggestionState.hidden,
              searchInputDecoration: InputDecoration(
                floatingLabelBehavior: FloatingLabelBehavior.auto,
                labelText: 'SearchField',
                border: OutlineInputBorder(),
              ),
              // hasOverlay: false,
              hint: 'SearchField Sample 4',
              maxSuggestionsInViewPort: 4,
              itemHeight: 45,
              onTap: (x) {
                print(x);
              },
            ),
          ),
          Container(
            margin: EdgeInsets.symmetric(
                horizontal: MediaQuery.of(context).size.width * 0.3,
                vertical: 10),
            child: ElevatedButton(
                onPressed: () {
                  _formKey.currentState.validate();
                },
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Text('Validate Field 2'),
                )),
          )
        ],
      ),
    );
  }
}
