flutterdartsqflitedart-async

FutureBuilder doesn't build widget as expected


I've been trying to solve this problem for a couple of days now, but to no avail. I have a simple app with three tabs:

  1. Deliveries Page: Has a DropdownMenuFormField (basically just a DropdownMenu with a validator) where the user can select a location (previously registered using the next page) and a TextFormField where they can specify the amount of volumes (boxes) to be delivered to said place. Pressing a "+" button, the data is then saved to a JSON file and displayed using a ReorderableListView along with several options (checkbox, edit, delete, drag).

  2. Locations Page: Has a button where you can add a location to a local SQLite database (using sqflite) and a button where you can wipe the locations table clean (for debugging purposes). The database schema is as follows:

    Database Schema

  3. Not relevant and not even started yet.

The problem is: in the dropdown menu in Deliveries Page, I use a FutureBuilder to build the DropdownMenuFormField when the DropdownMenuEntry list is actually completed (the entries are retrieved from the 'locais' table in the database), but the dropdown menu never actually has any entries in the first place. I have to switch into the second tab and then come back again for it to actually load a menu with the locations I have registered into the database. I've tried several different methods, like using a hardcoded delay in the prepareDropdown method, using a ListenableBuilder instead of FutureBuilder with a completely different approach, but nothing works. This project seemed like something really simple, but ended up being very difficult to debug, and I am honestly lost; any help would be appreciated. Code below.


//this is main.dart
import 'package:flutter/material.dart';
import 'LocationsPage.dart';
import 'DeliveriesPage.dart';

void main() {
  runApp(MaterialApp(
    home: Tabs(),
    debugShowCheckedModeBanner: false,
  ));
}

class Tabs extends StatelessWidget {
  DeliveriesPage deliveriesPage = DeliveriesPage();
  LocationsPage locationsPage = LocationsPage();

  Tabs({super.key});

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: Text('Rotas AMX'),
          centerTitle: true,
          backgroundColor: Colors.green,
          foregroundColor: Colors.white,
          bottom: const TabBar(
            labelStyle: TextStyle(color: Colors.white),
            indicatorColor: Colors.white,
            tabs: <Widget>[
              Tab(child: Text("Rota")),
              Tab(child: Text("Locais")),
              Tab(child: Text("Registros"))
            ]
          ),
          actions: [
            IconButton(
              icon: Icon(Icons.refresh),
              onPressed: () => {}
            )
          ],
        ),

        body: TabBarView(
          children: [
            deliveriesPage,
            locationsPage,
            Text("W.I.P")
          ],
        )
      )
    );
  }
}
//this is DeliveriesPage.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'DatabaseWizard.dart';
import 'JsonStorage.dart';
import 'DropdownMenuFormField.dart' as ddmff;
import 'BasicFormValidators.dart';

class DeliveriesPage extends StatefulWidget{
  @override
  _DeliveriesPageState createState() => _DeliveriesPageState();
}

class _DeliveriesPageState extends State<DeliveriesPage>{

  TextEditingController cntrlLocation = TextEditingController();
  TextEditingController cntrlVolumeCount = TextEditingController();
  DatabaseWizard dbWizard = DatabaseWizard();
  JsonStorage jsonFile = JsonStorage("delivery_data");
  final formKey = GlobalKey<FormState>();
  String dropdownElementId = '';
  Key refreshFutureBuilderKey = UniqueKey();

  bool loading = true;
  late Future<List<DropdownMenuEntry>> dropdownEntries;
  Map<String, dynamic> destinations = {};

  @override
  void initState() {
    super.initState();
    dbWizard.openDb();
    dropdownEntries = prepareDropdown();
    loadData();
  }

  Future<List<DropdownMenuEntry>> prepareDropdown() async{
    final List<Map<String, dynamic>>? locations = await dbWizard.getAllEntries('locais');
    List<DropdownMenuEntry> tempList = [];

    locations?.forEach((m) {
      tempList.add(
          DropdownMenuEntry(
              value: m['id_local'],
              label: m['nome']!
          )
      );
    });

    return tempList;
  }

  FutureBuilder buildDropdownMenu(BuildContext context) {
    return FutureBuilder(
      key: refreshFutureBuilderKey,
      future: dropdownEntries,
      builder:(context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done){
          return SizedBox(
            child: Center(child: CircularProgressIndicator())
          );
        }
        else if (snapshot.hasData && snapshot.connectionState == ConnectionState.done){
          List<DropdownMenuEntry> entries = snapshot.data;

          return ddmff.DropdownMenuFormField(
            dropdownMenuEntries: entries,
            controller: cntrlLocation,
            enableFilter: false,
            onSelected: (value) {
              dropdownElementId = value;
            },
            validator: dropdownValidator
          );
        }
        else {
          return SizedBox();
        }
      }
    );
  }

  Future<void> loadData() async {
    jsonFile.readFile().then((data) {
      final Map<String, dynamic> parsedJsonData = (data == "" ? {} : jsonDecode(data!));

      setState(() {
        destinations = parsedJsonData;
        loading = false;
      });
    });
  }

  void addDelivery() async{
    setState(() => loading = true);
    final int idEntrega = await dbWizard.getNextId('entregas');

    final deliveryInfo = {
      'id_local': dropdownElementId,
      'id_entrega': idEntrega.toString(),
      'nome': cntrlLocation.text,
      'volumes': cntrlVolumeCount.text,
      'entregue_com_sucesso': false
    };
    final keyNewElement = (destinations.keys.length).toString();

    destinations[keyNewElement] = deliveryInfo;
    jsonFile.saveFile(destinations);
    loadData();
  }

  void changeCheckbox(key, value) async{
    destinations[key]['entregue_com_sucesso'] = value;
    jsonFile.saveFile(destinations);
    loadData();
  }

  Widget editAndDeleteButtons(String key, BuildContext context, int index) {
    TextEditingController cntrlNome = TextEditingController();
    TextEditingController cntrlVolume = TextEditingController();
    final formKey = GlobalKey<FormState>();

    return Row(
      mainAxisAlignment: MainAxisAlignment.end,
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        SizedBox(
            width: 30,
            child: IconButton(
                onPressed: () async {
                  await showDialog(
                      context: context,
                      builder: (BuildContext context) {
                        return AlertDialog(
                          title: Text("Editar Entrega"),
                          content: Form(
                            key: formKey,
                            child: Column(
                              mainAxisSize: MainAxisSize.min,
                              spacing: 20,
                              children: <Widget>[
                                FractionallySizedBox(
                                  widthFactor: 1,
                                  child: buildDropdownMenu(context)
                                ),

                                FractionallySizedBox(
                                  widthFactor: 1,
                                  child: TextFormField(
                                    controller: cntrlVolume,
                                    validator: textfieldValidator,
                                    initialValue: destinations[key]['volume'],
                                    decoration: const InputDecoration(
                                      labelText: "Volume",
                                      border: OutlineInputBorder(),
                                      fillColor: Colors.green,
                                      hintText: "1"
                                    ),
                                  ),
                                ),

                                Row(
                                  spacing: 10,
                                  children: <Widget>[
                                    ElevatedButton(
                                      onPressed: Navigator.of(context).pop,
                                      style: ElevatedButton.styleFrom(
                                        backgroundColor: Colors.white,
                                        foregroundColor: Colors.green,
                                      ),
                                      child: Text("Cancelar"),
                                    ),
                                    ElevatedButton(
                                      onPressed: () {
                                        if (formKey.currentState!.validate()) {
                                          destinations[key]['id_local'] =
                                              dropdownElementId;
                                          destinations[key]['nome'] =
                                              cntrlNome.text;
                                          destinations[key]['volumes'] =
                                              cntrlVolume.text;
                                          jsonFile.saveFile(destinations);
                                          loadData();
                                          Navigator.of(context).pop();
                                        }
                                      },
                                      style: ElevatedButton.styleFrom(
                                        backgroundColor: Colors.green,
                                        foregroundColor: Colors.white,
                                      ),
                                      child: Text("OK")
                                    )
                                  ],
                                ),
                              ],
                            ),

                          ),
                        );
                      }
                  );
                },
                icon: Icon(Icons.edit)
            )
        ),

        SizedBox(
            width: 30,
            child: IconButton(
                onPressed: () {
                  destinations.remove(key);
                  jsonFile.saveFile(destinations);
                  loadData();
                },
                icon: Icon(Icons.delete)
            )
        ),

        SizedBox(
          width: 30,
          child: ReorderableDragStartListener(
            index: index,
            child: Icon(Icons.drag_handle)
          )
        )
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
          children: <Widget>[
            Padding(
              padding: EdgeInsets.fromLTRB(10, 10, 10, 0),
              child: Form(
                key: formKey,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Expanded(
                      flex: 13,
                      child: buildDropdownMenu(context),
                    ),
                    Expanded(
                      flex: 1,
                      child: SizedBox()
                    ),
                    Expanded(
                      flex: 5,
                      child: TextFormField(
                        controller: cntrlVolumeCount,
                        decoration: const InputDecoration(
                            labelText: "Volume",
                            border: OutlineInputBorder(),
                            fillColor: Colors.green,
                            hintText: "1"
                        ),
                        validator: textfieldValidator
                      ),
                    ),

                    Expanded(
                      flex: 1,
                      child: SizedBox()
                    ),
                    ElevatedButton(
                      onPressed: () {
                        if (formKey.currentState!.validate()) {
                          addDelivery();
                        }
                      },
                      style: ElevatedButton.styleFrom(
                          backgroundColor: Colors.green,
                          foregroundColor: Colors.white
                      ),
                      child: Icon(Icons.add)
                    )
                  ],
                ),
              )
            ),

            Expanded(
                child: loading ? const Center(child: CircularProgressIndicator())
                    : ReorderableListView.builder(
                    padding: EdgeInsets.only(top: 10.0),
                    itemCount: destinations.length,
                    onReorder: (int oldIdx, int newIdx) {
                      final keyList = destinations.keys.toList();
                      final element = keyList[oldIdx];
                      keyList.insert(newIdx, element);

                      if (oldIdx < newIdx) {
                        keyList.removeAt(oldIdx);
                      }
                      else {
                        keyList.removeAt(oldIdx+1);
                      }

                      final tempCopy = Map.from(destinations);

                      int i = 0;
                      destinations.forEach((key, value) {
                        destinations[key] = tempCopy[keyList[i]];
                        i++;
                      });

                      jsonFile.saveFile(destinations);
                    },
                    itemBuilder: (context, index) {
                      final key = destinations.keys.elementAt(index);
                      final destination = destinations[key];

                      return Dismissible(
                        key: ValueKey(key),
                        direction: DismissDirection.endToStart,
                        background: Container(
                          color: Colors.redAccent,
                          alignment: Alignment.centerRight,
                          padding: const EdgeInsets.symmetric(horizontal: 16.0),
                          child: const Icon(Icons.delete, color: Colors.white),
                        ),
                        onDismissed: (direction) {
                          destinations.remove(key);
                          jsonFile.saveFile(destinations);
                        },
                        child: Row(
                          children: <Widget>[
                            Expanded(
                              flex: 3,
                              child: ListTile(
                                title: Text(
                                  destination['nome'],
                                  style: TextStyle(
                                    decoration: destination['entregue_com_sucesso'] as bool
                                      ? TextDecoration.lineThrough
                                      : null
                                  )
                                ),
                                leading: Checkbox(
                                  value: destination['entregue_com_sucesso'] as bool,
                                  onChanged: (v) => changeCheckbox(key, v)
                                ),
                              )
                            ),

                            Expanded(
                              flex: 2,
                              child: ListTile(
                                title: Text(
                                  destination['volumes'],
                                  textAlign: TextAlign.center,
                                  style: TextStyle(
                                      decoration: destination['entregue_com_sucesso'] as bool
                                          ? TextDecoration.lineThrough
                                          : null
                                  )
                                ),
                                trailing: editAndDeleteButtons(key, super.context, index)
                              )
                            )
                          ]
                        )
                      );
                    }
                )
            ),
          ],
        ),
    );
  }
}
//this is DatabaseWizard.dart
import 'package:sqflite/sqflite.dart' as sqfl;
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;

class DatabaseWizard {
  static final DatabaseWizard _instance = DatabaseWizard._internal();
  factory DatabaseWizard() => _instance;
  DatabaseWizard._internal();

  static const dbName = "db_entregas.db";
  static const dbVersion = 1;

  sqfl.Database? db;

  static Future _onConfigure(sqfl.Database db) async {
    await db.execute('PRAGMA foreign_keys = ON');
  }

  void openDb() async {
    final appDir = await getApplicationDocumentsDirectory();
    final dbPath = path.join(appDir.path, dbName);

    db = await sqfl.openDatabase(dbPath,
      version: dbVersion,
      onConfigure: _onConfigure,
      onCreate: (sqfl.Database db, int version) async {
        await db.execute('''
          CREATE TABLE locais(
            id_local INTEGER PRIMARY KEY AUTOINCREMENT,
            nome VARCHAR(255) NOT NULL,
            endereco TEXT,
            observacao TEXT
          )''');
          
        await db.execute('''CREATE TABLE entregas(
            id_entrega INTEGER PRIMARY KEY AUTOINCREMENT,
            nome VARCHAR(255) NOT NULL,
            data DATE
          )''');

        _onConfigure(db);

        await db.execute('''CREATE TABLE entregas_locais(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            id_local INTEGER NOT NULL,
            id_entrega INTEGER NOT NULL,
            volumes INTEGER NOT NULL,
            observacao TEXT,
            entregue_com_sucesso BOOL NOT NULL,
            
            FOREIGN KEY (id_local) REFERENCES locais(id_local),
            FOREIGN KEY (id_entrega) REFERENCES entregas(id_entrega)
          )''');
      }
    );
  }

  Future<List<Map<String, dynamic>>?> getAllEntries(String table) async{
    List<Map<String, dynamic>>? result = await db?.query(table);

    return result;
  }

  Future<int> getNextId(String table) async {
   final currentId = (await db?.query("sqlite_sequence",
       columns: ['seq'],
       where: 'name = ?',
       whereArgs: [table]
   )).toString();

   try {
     final nextId = int.parse(currentId);

     return nextId + 1;
   }
   catch (e) {
     return 1;
   }
  }
}
//this is LocationsPage.dart
import 'package:flutter/material.dart';
import 'DatabaseWizard.dart';
import 'BasicFormValidators.dart';

class LocationsPage extends StatefulWidget{
  @override
  _LocationsPageState createState() => _LocationsPageState();
}

class _LocationsPageState extends State<LocationsPage>{

  TextEditingController cntrlNome = TextEditingController();
  TextEditingController cntrlEndereco = TextEditingController();
  TextEditingController cntrlObservacao = TextEditingController();
  final formKey = GlobalKey<FormState>();
  bool loading = true;
  DatabaseWizard dbWizard = DatabaseWizard();

  List<Map<String, dynamic>> locations = [];


  @override
  void initState() {
    super.initState();
    dbWizard.openDb();
    loadData();
  }

  Future<void> loadData() async {
    locations = (await dbWizard.getAllEntries('locais'))!;
    setState(() => loading = false);
  }

  void addLocal(){
    setState(() => loading = true);
    dbWizard.db?.insert(
      'locais',
      {
        'nome': cntrlNome.text,
        'endereco': cntrlEndereco.text ?? '',
        'observacao': cntrlObservacao.text ?? '',
      }
    );
  }

  Widget addLocationDialog(BuildContext context) {
    return SizedBox(
      height: MediaQuery.sizeOf(context).height,
      width: MediaQuery.sizeOf(context).width,
      child: AlertDialog(
        title: Text("Adicionar Local"),
        content: Form(
          key: formKey,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            spacing: 20,
            children: <Widget>[
              FractionallySizedBox(
                widthFactor: 1,
                child: TextFormField(
                  controller: cntrlNome,
                  validator: dropdownValidator,
                  decoration: const InputDecoration(
                    labelText: "Nome",
                    border: OutlineInputBorder(),
                    fillColor: Colors.green
                  ),
                )
              ),

              FractionallySizedBox(
                widthFactor: 1,
                child: TextFormField(
                  controller: cntrlEndereco,
                  validator: dropdownValidator,
                  decoration: const InputDecoration(
                    labelText: "Endereço (opcional)",
                    border: OutlineInputBorder(),
                    fillColor: Colors.green
                  ),
                )
              ),

              FractionallySizedBox(
                widthFactor: 1,
                child: TextFormField(
                  controller: cntrlObservacao,
                  validator: dropdownValidator,
                  decoration: const InputDecoration(
                    labelText: "Observação (opcional)",
                    border: OutlineInputBorder(),
                    fillColor: Colors.green
                  ),
                )
              ),

              Row(
                spacing: 10,
                children: <Widget>[
                  ElevatedButton(
                    onPressed: Navigator.of(context).pop,
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.white,
                      foregroundColor: Colors.green,
                    ),
                    child: Text("Cancelar"),
                  ),
                  ElevatedButton(
                      onPressed: () {
                        if (formKey.currentState!.validate()) {
                          addLocal();
                          loadData();
                          Navigator.of(context).pop();
                        }
                      },
                      style: ElevatedButton.styleFrom(
                        backgroundColor: Colors.green,
                        foregroundColor: Colors.white,
                      ),
                      child: Text("OK")
                  )
                ],
              ),
            ],
          ),
        ),
      )
    );
  }

  @override
  Widget build(BuildContext context){
    return Scaffold(
      body: Container(
        height: MediaQuery.sizeOf(context).height,
        width: MediaQuery.sizeOf(context).width,
        padding: EdgeInsets.all(10),
        child: Column(
          children: <Widget>[
            Expanded(
              flex: 1,
              child:ElevatedButton(
                style: ElevatedButton.styleFrom(
                  foregroundColor: Colors.white,
                  backgroundColor: Colors.green,
                  alignment: Alignment.center
                ),
                onPressed: () async => await showDialog(
                  context: context,
                  builder: (BuildContext context) => addLocationDialog(context)
                ),
                child: Text("Adicionar Local")
              ),
            ),

            Expanded(
              flex: 1,
              child: ElevatedButton(
                style: ElevatedButton.styleFrom(
                    foregroundColor: Colors.white,
                    backgroundColor: Colors.green,
                    alignment: Alignment.center
                ),
                onPressed: () {
                  dbWizard.db?.execute('DELETE FROM locais');
                  loadData();
                },
                child: Icon(Icons.delete)
              )
            ),

            Expanded(
              flex: 18,
              child: ListView.builder(
                itemCount: locations.length,
                itemBuilder: (context, index) {
                  return ListTile(

                    title: Text(locations[index]['nome']),
                  );
                }
              )
            ),
          ],
        ),
      )
    );
  }
}
//this is JsonStorage.dart
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:path_provider/path_provider.dart';

class JsonStorage {

  late String file_name; //WITHOUT the file extension!

  JsonStorage(f_name): file_name = f_name;

  Future<File> getFile() async{
    final directory = await getApplicationDocumentsDirectory();
    final file = File("${directory.path}/data_tarefas.json");
    if (!(await file.exists())){
      await file.create();
    }
    return file;
  }

  Future<void> saveFile(info) async{
    String data = json.encode(info);

    final file = await getFile();
    file.writeAsString(data);
  }

  Future<String?> readFile() async{
    try{
      final file = await getFile();
      return file.readAsString();
    }
    catch (e){
      return null;
    }
  }
}

Solution

  • You have a race condition between dbWizard.openDb and dbWizard.getAllEntries. You call the former in initState, but because it isn't awaited, the call to the latter in prepareDropdown might complete before the database is properly initialized. If that happens, dbWizard.getAllEntries will return null the first time it is called, but then possibly succeed on later attempts (such as after you reload the page).

    You need to wait until the database is initialized before you try to fetch any data from it. There are multiple ways to do this, but perhaps the simplest is to move dbWizard.openDb to inside prepareDropdown.

      @override
      void initState() {
        super.initState();
        // dbWizard.openDb(); // Remove the db initialization code from here
        dropdownEntries = prepareDropdown();
        loadData();
      }
    
      Future<List<DropdownMenuEntry>> prepareDropdown() async{
        await dbWizard.openDb(); // Move the db initialization code to here and await it
    
        final List<Map<String, dynamic>>? locations = await dbWizard.getAllEntries('locais');
        List<DropdownMenuEntry> tempList = [];
    
        locations?.forEach((m) {
          tempList.add(
              DropdownMenuEntry(
                  value: m['id_local'],
                  label: m['nome']!
              )
          );
        });
    
        return tempList;
      }