Flutter - Persistência

De Aulas

Afluentes: Dispositivos Móveis; Usabilidade, desenvolvimento web, mobile e jogos

Persistência com Flutter

Antes de vermos persistência, é importante ver (ou rever) um pouco sobre Data Access Object (DAO):

Para trabalhar com persistência no Flutter, vamos utilizar aqui o sqflite. É tipo o sqlite, mas para usar com o flutter.

Trabalhar com sqlite ou, no caso sqflite, é como se estivéssemos usando banco de dados, mas é em um arquivo, ou seja, não utilizamos um SGBD (Sistema Gerenciador de Banco de Dados) pois não seria conveniente para se trabalhar com um aplicativo instalado em um dispositivo móvel.

Outra forma de trabalhar com dados, é consumindo API (Serviço Web). A diferença é que nesse exemplo, os dados estão no dispositivo e não precisamos de conexão com a Internet para usá-lo. Quando consumimos uma API e os dados estão em um servidor, precisamos da internet para usar o aplicativo.

Vamos adicionar o pacote sqflite no projeto pra poder trabalhar com dados persistentes no dispositivo móvel:

$ flutter pub add sqflite

Também vamos adicionar o pacote path

$ flutter pub add path

Esses comandos adiciona o sqflite e o path como dependência no pubspec.yaml

dependencies:
 ...
 sqflite: ...
 path: ...

Caso queira atualizar um pacote, por exemplo o sqflite, digite o comando:

$ flutter pub upgrade sqflite


Tplnote Bulbgraph.png

A aplicação com sqflite só vai funcionar no dispositivo Android. Para Linux, windows e web dá pra usar o sqflite_common_ffi

contact.dart

/// # Classe Contact
/// Colocamos o nome da tabela e das colunas da tabela
/// como constantes, facilita na manutenção, pois caso
/// em algum momento a gente decida mudar o nome, muda
/// só aqui.
/// Também tempos um constructor e um get, que retorna
/// um Map com as informações do objeto.

class Contact {
  static const String tableName = 'contact';
  static const String columnNameName = 'name';
  static const String columnNamePhone = 'phone';

  final String name;
  final String phone;

  Contact({required this.name, required this.phone});

  Map<String, dynamic> toMap() {
    return {'name': name, 'phone': phone};
  }
}

contact_dao.dart

/// # sqflite
/// Como incluímos o sqflite, precisamos instalar ele no nosso
/// projeto. Para isso, temos que digitar dentro da pasta raíz
/// do projeto o seguinte comando:
/// $ flutter pub add sqflite
/// Caso queira atualizar a versão, então usamos o comando:
/// $ flutter pub upgrade sqflite
/// E isso serve para qualquer pacote que for necessário adicionar
/// ao projeto.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'dart:async';
import 'contact.dart';

/// # Classe ContactDao
/// Gerencia o CRUD da tabela de contatos. O objeto da
/// base de dados é um *Future*.
///
/// Um **Future** é usado para representar um valor potencial,
/// ou erro, que estará disponível em algum momento no futuro.
/// Usando um **Future**, podemos tratar os erros mais ou menos
/// no mesmo esquema que *Try* e *Catch*.
class ContactDao {
  static const String databaseName = 'phonebook.db';
  late Future<Database> database;

  /// **Método connect**: É *async*, ou seja, é executado de forma
  /// assíncrona. Isso porque temos um **await** que vai conectar
  /// à base de dados e, caso ela não exista, cria a tabela. No caso
  /// aqui, só usamos o *onCreate*. Caso alguém tenha uma versão mais
  /// antiga da base instalada e vai fazer uma atualização do aplicativo,
  /// a versão em *verrsion* vai ser diferente, então usamos um método
  /// *onUpdate* para fazer as operações de migração da antiga base de
  ///  dados para a nova.
  Future connect() async {
    var databasesPath = await getDatabasesPath();
    String path = p.join(databasesPath, databaseName);
    database = openDatabase(
      path,
      version: 1,
      onCreate: (Database db, int version) {
        return db.execute("CREATE TABLE IF NOT EXISTS ${Contact.tableName} ( "
            "${Contact.columnNameName} TEXT PRIMARY KEY, "
            "${Contact.columnNamePhone} TEXT)");
      },
    );
  }

  /// **Método list**: recupera todos os registros da base de dados.
  /// Para isso, usamos o método *query*. Não colocamos todos os
  /// parâmetros, só passamos a tabela, então retorna tudo. Depois
  /// retornamos em uma lista (Map) de contatos.
  Future<List<Contact>> list() async {
    final Database db = await database;
    final List<Map<String, dynamic>> maps = await db.query(Contact.tableName);
    return List.generate(maps.length, (i) {
      return Contact(
        name: maps[i][Contact.columnNameName],
        phone: maps[i][Contact.columnNamePhone],
      );
    });
  }

  /// **Método insert**: insere um objeto contato como registro na nossa
  /// base de dados. Quando usamos no *conflictAlgorithm* o valor
  /// *ConflictAlgorithm.replace*, significa que não vai ter erro caso
  /// encontre um objeto com a mesma chave, mas apenas substitui o
  /// objeto existente pelo novo. É quase um update.
  Future<void> insert(Contact contact) async {
    final Database db = await database;
    await db.insert(
      Contact.tableName,
      contact.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  /// **Método update**: faz o update na base de dados. Não uso nesse
  /// exemplo, mas deixo aqui como forma instrutiva.
  Future<void> update(Contact contact) async {
    final db = await database;
    await db.update(
      Contact.tableName,
      contact.toMap(),
      where: "${Contact.columnNameName} = ?",
      whereArgs: [contact.name],
    );
  }

  /// **Método delete**: exclui um registro da base de dados conforme a
  /// String *name* passada como parâmetro. Lembrando que *name* na nossa
  /// tabela de contatos é chave primária.
  /// Veja que em *where* colocamos a condição indicando quando que a
  /// exclusão vai ser feita e em *whereArgs* é o valor que será substituído
  /// pelo curinga (?) do *where*. Dá para ter vários curingas e colocamos
  /// todos eles no vetor onde está o *name*. Nesse caso, foi só um parâmetro.
  Future<void> delete(String name) async {
    final db = await database;
    await db.delete(
      Contact.tableName,
      where: "${Contact.columnNameName} = ?",
      whereArgs: [name],
    );
  }
}

main.dart

import 'package:flutter/material.dart';
import 'phonebook.dart';

void main() => runApp(const App());

class App extends StatelessWidget {
  const App({super.key});

  @override
  build(context) {
    return const MaterialApp(
      title: 'Contatos',
      home: PhoneBook(),
    );
  }
}

phonebook.dart

import 'package:flutter/material.dart';
import 'contact_dao.dart';
import 'contact.dart';

/// # Classe PhoneBook
/// Como vamos precisar de uma página que altera o *state*, Então
/// não dá para ser *StatelessWidget*. Daí usamos um *StatefulWidget*
/// que chama uma classe que herda de State do tipo *PhoneBook*.
class PhoneBook extends StatefulWidget {
  const PhoneBook({super.key});

  @override
  State<PhoneBook> createState() => _PhoneBookState();
}

class _PhoneBookState extends State<PhoneBook> {
  /// Instancia o *Data Access Object* (DAO) dos contatos
  final ContactDao dao = ContactDao();

  /// lista de contatos
  List<Contact> contacts = [];

  /// Controladores dos TextFields
  TextEditingController name = TextEditingController();
  TextEditingController phone = TextEditingController();

  /// **Constructor** - conecta o banco de dados. É uma função assincrona,
  /// então segue o programa. Só quando o banco for conectado que
  /// entra e executa o método load()
  _PhoneBookState() {
    dao.connect().then((value) {
      load();
    });
  }

  /// **Método load**: chama o método list do DAO. Quando terminada a
  /// operação, atualiza o *state* da lista de contatos. Quando um *state*
  /// é atualizado, é feita uma nova renderização da tela. No caso, a
  /// nossa ListView será atualizada com os elementos lidos da base de
  /// dados. Aqui, já aproveito para apagar os campos de texto.
  load() {
    dao.list().then((value) {
      setState(() {
        contacts = value;
        name.text = "";
        phone.text = "";
      });
    });
  }

  /// **Método build**: gera a nossa tela. Ela contém dois campos texto,
  /// três botões (gravar, excluir e limpar campos) e uma ListView.
  /// Por questão de conveniência e para o código ficar mais limpo,
  /// coloquei os códigos de cada um dos elementos dentro de métodos, mas
  /// não é obrigatório e vai da metodologia de desenvolvimento que você
  /// melhor se adequar.
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Agenda"),
      ),
      body: Column(
        children: [
          fieldName(),
          fieldPhone(),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              buttonSave(),
              buttonDel(),
              buttonClear(),
            ],
          ),
          listView(),
        ],
      ),
    );
  }

  /// **Método fieldName**: Campo de texto do nome. Veja que usamos o
  /// atributo da classe chamado **name** no *controller*.
  fieldName() {
    return TextField(
      decoration: const InputDecoration(
        labelText: 'Nome',
      ),
      controller: name,
    );
  }

  /// **Método fieldPhone**: Campo de texto para o telefone
  fieldPhone() {
    return TextField(
      decoration: const InputDecoration(
        labelText: 'Telefone',
      ),
      controller: phone,
    );
  }

  /// **Método buttonSave**: quando clicado, verifica se a entrada não
  /// está vazia. Pega o conteúdo dos campos de texto, criando um objeto
  /// do tipo *Contact* e pede para o DAO inserir no banco de dados.
  /// Feito isso, faz um reload para atualizar a ListView.
  buttonSave() {
    return ElevatedButton(
      child: const Text('Gravar'),
      onPressed: () {
        if (name.text.trim() != '') {
          var c = Contact(
            name: name.text,
            phone: phone.text,
          );
          dao.insert(c).then((value) {
            load();
          });
        }
      },
    );
  }

  /// **Método buttonDel**: Veja que colocamos um estilo nele para
  /// diferenciar do botão Gravar. Colocamos ele com cor de fundo
  /// vermelha e texto em branco. Quando clicado *onPressed* é
  /// chamado e pede para o dao excluir o registro que possui o
  /// nome que está no campo texto do nome.
  buttonDel() {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        foregroundColor: Colors.white,
        backgroundColor: Colors.red, // foreground
      ),
      child: const Text('Excluir'),
      onPressed: () {
        dao.delete(name.text.trim()).then((value) {
          load();
        });
      },
    );
  }

  /// **Método buttonClear**: Também mudamos a cor, verde de fundo e o
  /// texto em branco. Quando clicado, apenas apaga os campos de texto.
  /// Veja que usamos o setState pra fazer um novo render da página.
  buttonClear() {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        foregroundColor: Colors.green, // background
        backgroundColor: Colors.white, // foreground
      ),
      child: const Text('Limpar'),
      onPressed: () {
        setState(() {
          name.text = "";
          phone.text = "";
        });
      },
    );
  }

  /// **Método listView**: aqui retornamos a listView que mostra o nome e o
  /// telefone dos registros armazenados na base de dados. Colocamos ela
  /// dentroo de um Expanded para poder rolar na tela. O Título contém o
  /// nome e coloquei um estilo, e o subtítulo é o telefone. Quando clicado
  /// um elemento, carrego os dados daquele elemento nos campos texto para
  /// poder alterá-los ou apagá-lo.
  listView() {
    return Expanded(
      child: ListView.builder(
        shrinkWrap: true,
        itemCount: contacts.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(
              contacts[index].name,
              style: const TextStyle(
                fontSize: 20.0,
                color: Colors.black,
              ),
            ),
            subtitle: Text(contacts[index].phone),
            onTap: () {
              setState(() {
                name.text = contacts[index].name;
                phone.text = contacts[index].phone;
              });
            },
          );
        },
      ),
    );
  }
}

Atividades

Desafio 1

Implemente no seu computador o exemplo passado na aula e faça-o funcionar. Faça alterações que achar conveniente (individual).

Arrumar a aplicação para poder alterar o nome também.

Desafio 2

Implemente um aplicativo para dispositivo móvel com as seguintes características (individual ou em grupo de até 4 integrantes):

  • Persistência de dados (CRUD)
  • Pelo menos duas janelas
  • Uso de Listview