flutterdartdio

Selected items are no longer marked as selected after searching for a specific item


The app loads recipes from an API and outputs them to a GridView.builder.

The user must select at least 3 recipes in order to be able to continue processing the next step.

Everything works up to this point. However, if the user searches for a specific recipe, the list will be reloaded but the recipes that have already been selected will no longer be marked as selected.

After clicking "Continue", all selected recipes are displayed in a modal bottom sheet as expected.

The only problem is that the visual marker (Check icon) is lost as soon as the user uses the search.

The following code visualizes whether a recipe has been selected:

if (_selected.contains(recipe))
  Positioned(
    top: 8,
    right: 8,
    child: Container(
      padding: const EdgeInsets.all(4),
      decoration: BoxDecoration(
        color: Colors.deepPurple,
        shape: BoxShape.circle,
        border: Border.all(
          width: 1,
          color: Colors.white,
        ),
      ),
      child: const Icon(
        Icons.check,
        color: Colors.white,
        size: 18,
      ),
    ),
  ),

It seems as if after the search the condition if(_selected.contains(recipe)) no longer works.

Here is the complete code:

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final TextEditingController _search = TextEditingController();
  List<dynamic> _recipes = [];
  List<dynamic> _selected = [];
  bool isLoading = false;

  Dio dio = Dio(
    BaseOptions(
      baseUrl: 'https://dummyjson.com',
      headers: {'Content-Type': 'application/json'},
    ),
  );

  Future<void> getRecipes() async {
    Response response;

    setState(() {
      isLoading = true;
    });

    try {
      if (_search.text.isEmpty) {
        response = await dio.get('/recipes');
      } else {
        response = await dio.get('/recipes/search', queryParameters: {
          'q': _search.text,
        });
      }

      setState(() {
        _recipes = response.data['recipes'];
        isLoading = false;
      });
    } on DioException catch (e) {
      isLoading = false;
      throw Exception(e.toString());
    }
  }

  void showSelected() {
    showModalBottomSheet(
      context: context,
      builder: (BuildContext context) {
        return DefaultTextStyle(
          style: const TextStyle(color: Colors.white),
          child: Container(
            height: double.infinity,
            color: Colors.deepPurple,
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                mainAxisSize: MainAxisSize.min,
                children: <Widget>[
                  Expanded(
                      child: ListView.separated(
                    physics: const BouncingScrollPhysics(),
                    separatorBuilder: (context, index) =>
                        const SizedBox(height: 16),
                    padding: const EdgeInsets.symmetric(vertical: 16),
                    shrinkWrap: true,
                    itemCount: _selected.length,
                    itemBuilder: (context, index) {
                      Map recipe = _selected[index];

                      return ListTile(
                        leading: AspectRatio(
                          aspectRatio: 1,
                          child: Image.network(recipe['image']),
                        ),
                        title: Text(
                          recipe['name'],
                          style: const TextStyle(color: Colors.white),
                          overflow: TextOverflow.ellipsis,
                        ),
                      );
                    },
                  )),
                  GestureDetector(
                    onTap: () => Navigator.pop(context),
                    child: Container(
                      padding: const EdgeInsets.all(16),
                      color: Colors.black12,
                      width: double.infinity,
                      child: const Icon(Icons.close, color: Colors.white),
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }

  @override
  void initState() {
    super.initState();
    getRecipes();
  }

  @override
  void dispose() {
    super.dispose();
    _search.dispose();
  }

  @override
  Widget build(BuildContext context) {
    Widget searchIcon = GestureDetector(
      onTap: () => setState(() {
        _search.clear();
        getRecipes();
      }),
      child: Icon(_search.text.isEmpty ? Icons.search : Icons.close),
    );

    if (isLoading) {
      searchIcon = const UnconstrainedBox(
        child: SizedBox(
          width: 20,
          height: 20,
          child: CircularProgressIndicator(strokeWidth: 3),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Choose at least 3 recipes'),
        centerTitle: true,
      ),
      bottomNavigationBar: Container(
        padding: const EdgeInsets.all(8),
        child: ElevatedButton(
          onPressed: _selected.length < 3 ? null : showSelected,
          child: Text('Continue (${_selected.length.toString()})'),
        ),
      ),
      body: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              controller: _search,
              keyboardType: TextInputType.text,
              decoration: InputDecoration(
                hintText: 'Search',
                border: const OutlineInputBorder(),
                contentPadding: const EdgeInsets.all(16),
                suffixIcon: searchIcon,
              ),
              onTapOutside: (PointerDownEvent event) {
                FocusManager.instance.primaryFocus?.unfocus();
              },
              onChanged: (String value) async {
                getRecipes();
              },
            ),
          ),
          Expanded(
            child: GridView.builder(
              shrinkWrap: true,
              padding: const EdgeInsets.only(
                left: 16,
                right: 16,
                bottom: 16,
              ),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 3,
                crossAxisSpacing: 16,
                mainAxisSpacing: 16,
                childAspectRatio: 0.65,
              ),
              itemCount: _recipes.length,
              itemBuilder: (context, index) {
                Map recipe = _recipes[index];

                return GestureDetector(
                  child: Stack(
                    children: [
                      Column(
                        children: [
                          Container(
                            padding: EdgeInsets.all(
                                _selected.contains(recipe) ? 5 : 0),
                            color: Colors.deepPurple,
                            child: AspectRatio(
                              aspectRatio: 1,
                              child: Image.network(recipe['image']),
                            ),
                          ),
                          Padding(
                            padding: const EdgeInsets.all(5),
                            child: Text(
                              recipe['name'],
                              textAlign: TextAlign.center,
                              overflow: TextOverflow.ellipsis,
                              style: TextStyle(
                                  color: _selected.contains(recipe)
                                      ? Colors.deepPurple
                                      : null),
                              maxLines: 2,
                            ),
                          ),
                        ],
                      ),
                      if (_selected.contains(recipe))
                        Positioned(
                          top: 8,
                          right: 8,
                          child: Container(
                            padding: const EdgeInsets.all(4),
                            decoration: BoxDecoration(
                              color: Colors.deepPurple,
                              shape: BoxShape.circle,
                              border: Border.all(
                                width: 1,
                                color: Colors.white,
                              ),
                            ),
                            child: const Icon(
                              Icons.check,
                              color: Colors.white,
                              size: 18,
                            ),
                          ),
                        )
                    ],
                  ),
                  onTap: () {
                    if (_selected.contains(recipe)) {
                      setState(() => _selected.remove(recipe));
                    } else {
                      setState(() => _selected.add(recipe));
                    }
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Update

I have uploaded the complete source code here => https://filebin.net/qrh5xajhhuldv4z7


Solution

  • The current recipe object is a different instance to the existing recipe element inside the _selected list, even if the content is the same. The contains method relies on operator== to check for equality, so it won't work as expected.

    You can verify by printing their hash codes:

    if (_selected.length == 1) {
      print(recipe.hashCode);
      print(_selected.first.hashCode);
    }
    

    The hash codes will be different.


    One way to make the check success is to change _selected.contains(recipe) to

    _selected.any((item) => item['id'] == recipe['id'])
    

    You also need to change how you remove items from the list by replacing _selected.remove(recipe) to

    _selected.removeWhere((item) => item['id'] == recipe['id'])
    

    Recommendation:

    In Dart, it is common to convert json result to a model rather than using it directly as a map. You can see this guide from the official Flutter documentation: Serializing JSON inside model classes.

    Other option is to use packages such as json_serializable or freezed.