CRUD con Flutter y SQlite.

Noé Montes AlvarezFlutter

Todas (o casi todas) las aplicaciones que utilizamos consultan y almacenan información. Muchas de ellas realizando peticiones contra un servidor y otras persistiendo datos localmente en el propio dispositivo. Las aplicaciones desarrolladas en flutter hacen uso de las bases de datos SQLite a través del complemento sqlflite. En este pequeño ejemplo vamos a crear un CRUD (Create, Read, Update y Delete) para presentar las operaciones básicas de persistencia de base de datos. Empezamos!

Aplicación de ejemplo.

La aplicación que vamos a hacer va a ser un pequeño listado de usuarios y un formulario para agregar y editar usuarios. Muy sencillo y espero que también muy util 😉

Dependencias.

En primer lugar vamos a agregar las dependencias necesarias en nuestro fichero pubspec.yaml.

sqflite: ^2.0.2+1
provider: ^6.0.3
path: ^1.8.1
path_provider: ^2.0.11

Entrando un poco mas en detalles de las dependencias que hemos agregado, nos encontramos:

  • SQFlite. Plugin de flutter para gestionar bases de datos sqlite.
  • provider. Manejador de estado que nos permite comunicar widgets entre si que no son visibles directamente. Tienes un artículo de su uso completo aquí
  • path y path_provider. Estas dos dependencias nos permiten gestionar ficheros dentro de nuestro dispositivo. En este caso nos apoyaremos en estas librerías para crear, abrir y gestionar la base de datos dentro del almacenamiento de nuestro dispositivo.

Estructura de base de datos.

Vamos a definir y crear la estructura de nuestra base de datos. Para ello vamos a apoyarnos en el uso del patron Singleton que nos permite manejar una única instancia de base datos.

class DBHelper {
  static const _databaseName = 'Users.db';
  static const _databaseVersion = 1;

  DBHelper._();

  static final DBHelper instance = DBHelper._();
  static Database? _database;

  // Getter database
  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();

    return _database!;
  }

  _initDatabase() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _databaseName);

    return await openDatabase(path, 
      version: _databaseVersion,
      onCreate: _onCreateDatabase
    );
  }

  Future _onCreateDatabase(Database db, int version) async{
    await db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY
    AUTOINCREMENT, nombre TEXT NOT NULL, apellidos TEXT, email TEXT NOT NULL)');
  }
}

Esta clase crea una única instancia de DBHelper que será accedida desde nuestra clases de acceso a datos. En la primera llamada, cuando la base de datos no existe, se llama al método _initDatabase. En primer lugar accede al directorio de documentos de nuestro dispositivo donde vamos a crear nuestra base de datos mediante Directory documentsDirectory = await getApplicationDocumentsDirectory(); Si en el momento de realizar el openDatabase la base de datos no existe, se ejecuta el método onCreate que en nuestro ejemplo crea la tabla users.

Clase modelo.

Ahora que conocemos la estructura de nuestra base de datos, podemos definir la clase modelo para trabajar con la información desde nuestra aplicación.

User userFromJson(String str) => User.fromJson(json.decode(str));

String userToJson(User data) => json.encode(data.toJson());

class User {
  int? id;
  String nombre;
  String? apellidos;
  String email;

  User({
    this.id,
    required this.nombre,
    this.apellidos,
    required this.email,
  });

  factory User.fromJson(Map<String, dynamic> json) => User(
    id: json["id"],
    nombre: json["nombre"],
    apellidos: json["apellidos"],
    email: json["email"],
  );

  Map<String, dynamic> toJson() => {
    "id": id,
    "nombre": nombre,
    "apellidos": apellidos,
    "email": email,
  };
}

Los métodos que incorpora la librería sqflite para comunicarse con la base de datos, manejan mapas para trabajar con los datos con lo que creamos en nuestro modelo dos métodos de utilidad User userFromJson(String str) y String userToJson(User data), que nos permiten convertir nuestro objeto User a json y crear un nuevo User desde un json.

CRUD.

Llegados a este punto vamos a definir los métodos de escritura y lectura de la base de datos. CRUD es el acrónimo de Create Read Update Delete, las 4 operaciones básicas de gestión de bases de datos. En una aplicación grande que necesite varias tablas personalmente crearía clases independientes para cada uno de los modelos, pero en este caso que es una aplicación pequeña con una única tabla, voy a crear los métodos de acceso a datos en la propia clase DBHelper.

  // Método que crea un usuario en base de datos
  Future<int> createUser(User user) async{
    final Database db = await database;

    int inserted = await db.insert('users', user.toJson());

    return inserted;
  }

  // Método que lista todos los usuarios de base de datos
  Future<List<User>> listUsers() async{
    final Database db = await database;
    
    final List<Map<String, Object?>> respDb = await db.query('users');

    return respDb.map((e) => User.fromJson(e)).toList();
  }

  // Método que elimina un usuario de base de datos
  Future<void> deleteUser(int id) async{
    final Database db = await database;

    db.delete('users', where: 'id = ?', whereArgs: [id]);
  }

  // Método que actualiza un usuario existente
  Future<void> updateUser(User user) async{
    final Database db = await database;

    db.update('users', user.toJson(), where: 'id = ?', whereArgs: [user.id]);
  }

Provider. Manejador de estado.

Ahora que tenemos nuestra capa de persistencia definida, necesitamos centrarnos en nuestra lógica de negocio y en la comunicación entre la vista y la base de datos. Para esta tarea nos vamos a apoyar en provider, que nos permitirá comunicar los cambios de los widgets entre sí y con la base de datos. Si quieres ver más en detalle este patrón puedes verlo aquí. Primero vamos a ver el código completo de la clase y luego comentamos puntos importantes.

class UserProvider extends ChangeNotifier{

  static List<User> _userList = [];

  List<User> get userList => _userList;

  
  Future<void> addUser(String nombre, String apellidos, String email) async{   
    User user = User(nombre: nombre, apellidos: apellidos, email: email);
    await DBHelper.instance.createUser(user);

    listUsers();
  }

  Future<void> updateUser(User user) async{
    await DBHelper.instance.updateUser(user);
    
    listUsers();
  } 

  Future<void> deleteUser(int id) async{
    await DBHelper.instance.deleteUser(id);

    listUsers();
  }

  Future<void> listUsers() async{
    _userList = await DBHelper.instance.listUsers();

    notifyListeners();
  }
}

Como toda clase provider, tiene que extender de ChangeNotifier, que notifica los cambios en el modelo a todos los widgets que estén escuchando. Esta notificación se realiza mediante la llama al método notifyListeners();

Crear nuevo usuario.

Ahora que tenemos definida la lógica de negocio, podemos centrarnos en el último paso de nuestra aplicación que es la creación de nuestra interfaz de usuario. Primero vamos a centrarnos en el alta y edición de usuarios.

Vamos a trabajar con formularios así que es necesario que definamos la clase como un StatefulWidget. Para reutilizar código vamos a usar el mismo formulario para crear y para modificar un usuario, para ello vamos a definir una variable User, que será opcional y se recibe como parámetro.

final User? editUser;
const UserFormPage({Key? key, this.editUser}) : super(key: key);

Si esta variable está informada se trata de una edición, sino se trata de un alta. El siguiente paso será definir los controladores de nuestros campos de texto y la clave única de formulario que nos ayuda a identificarlo de cara a validaciones.

final _formKey = GlobalKey<FormState>();
final _inputNameController = TextEditingController();
final _inputLastNameController = TextEditingController();
final _inputEmailController = TextEditingController();

Sobreescribimos los métodos initState() y dispose(). El primero nos permite realizar acciones en la carga de la pantalla, el segundo se ejecuta justo antes de destruir la vista. En el initState vamos a inicializar los valores de los campos de texto para el caso de que estemos editando un usuario. En el dispose vamos a cerrar los controladores antes de destruir la vista, es una buena práctica para no dejar controladores abiertos sin uso.

@override
  void initState() {
    if(widget.editUser != null){
      _inputNameController.text = widget.editUser!.nombre;
      _inputLastNameController.text = widget.editUser!.apellidos != null ? widget.editUser!.apellidos! : '';
      _inputEmailController.text = widget.editUser!.email;
    }
    super.initState();
  }

  @override
  void dispose() {
    _inputNameController.dispose();
    _inputLastNameController.dispose();
    _inputEmailController.dispose();

    super.dispose();
  }

Ahora definimos nuestro formulario con los campos y las validaciones necesarias.

Form(
   key: _formKey,
   child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextFormField(
           controller: _inputNameController,
           decoration: const InputDecoration(
              hintText: 'Introduce nombre',
              labelText: 'Nombre'
           ),
           validator: (value){
             if(value!.isEmpty){
                return 'El nombre es obligatorio';
             }
      
             return null;
           },
        ),
        TextFormField(
           controller: _inputLastNameController,
           decoration: const InputDecoration(
              hintText: 'Introduce apellidos',
              labelText: 'Apellidos'
           ),
        ),
        TextFormField(
           controller: _inputEmailController,
           decoration: const InputDecoration(
             hintText: 'Introduce email',
             labelText: 'Email'
           ),
           validator: (value){
              if(value!.isEmpty){
                return 'El email es obligatorio';
              }
      
              bool emailValid = RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+").hasMatch(value);
              if(!emailValid){
                 return 'El formato del email no es válido';
              }
                    
              return null;
            },
         )
       ]
    )
)

Por último el botón para dar de alta o confirmar los cambios en una edición. Una vez mas nos vamos a apoyar en provider para realizar la comunicación.

ElevatedButton(
  style: ElevatedButton.styleFrom(
           minimumSize: const Size.fromHeight(40),
         )
  onPressed: (){
    if(_formKey.currentState!.validate()){
      final userService = Provider.of<UserProvider>(context, listen: false);

      if(widget.editUser != null){ // Es una edicion de usuario
         widget.editUser!.nombre = _inputNameController.text;
         widget.editUser!.apellidos = _inputLastNameController.text;
         widget.editUser!.email = _inputEmailController.text;

         userService.updateUser(widget.editUser!);
      }else{ //Es un alta
        userService.addUser(_inputNameController.text, _inputLastNameController.text, _inputEmailController.text);
      }
      Navigator.of(context).pop();
    }
  },
  child: const Text('Agregar',
           style: TextStyle(fontSize: 20.0),
        )
)

Declaramos el provider con el parámetro listen: false, muy importante este punto ya que cuando estamos grabando los datos no es necesario repintar esta vista, con lo cual no hace falta escuchar los cambios en el modelo.

Listar usuarios.

Por último vamos a crear el listado de usuarios. También se trata de un StatefulWidget, en cuyo body vamos a definir un consumer. Un consumer como su propio nombre indica es un consumidor, que escucha los cambios en los modelos para repintar los componentes necesarios cuando el modelo cambia y notifica los cambios.

 Consumer<UserProvider>(
   builder: (context, data, child){
    var userList = data.userList;
    .......
   }
)

The end.

Bueno hasta aquí este ejemplo. No sé si me expliqué todo lo bien que me gustaría, a veces escribiendo no se quedan las cosas 100% claras. Podéis ver, descargar y usar el código completo desde mi repositorio en github.

Nos vemos pronto!! Bye!!