Punto de venta para Android – Open source

En este post te mostraré el código fuente (y también la ejecución) de una aplicación móvil de punto de venta para Android. Está escrita usando Dart y el framework Flutter así que teóricamente también puede compilar para iOS.

Esta app de sistema de ventas móvil se conecta a internet y consume una API de Laravel que a su vez también tiene versión web.

Debido a que puedes modificar el código a tus necesidades, puedes crear tu propia copia y montarla en una red local o en internet.

Inicio – Login

Login en punto de venta móvil

Al inicio nos encontramos con una pantalla de login.

Ese módulo realiza la autenticación con el servidor y en caso de que las credenciales sean correctas guarda el token usando las shared preferences del dispositivo; ya que no quise usar SQLite porque eran datos muy simples.

Antes de mostrar la pantalla de inicio de sesión verifica si ya existe un token y comprueba si es válido; en caso de que sí, redirecciona al escritorio. De este modo digamos que la sesión del usuario se queda guardada.

La petición está aquí:

  Future<bool> login(String usuario, String password) async {
    setState(() {
      cargandoBoton = true;
    });

    final http.Response response = await http.post(
      rutaLogin,
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
      },
      body: jsonEncode(<String, String>{
        'email': usuario,
        'password': password,
      }),
    );
    setState(() {
      this.cargandoBoton = false;
    });
    if (response.statusCode == 200) {
      Map<String, dynamic> respuesta = json.decode(response.body);
      var token = respuesta["access_token"];
      await _guardarToken(token);
      return true;
    }
    return false;
  }

Estamos usando async y await; esperamos a que se guarde el token en las SharedPreferences:

Future _guardarToken(String token) async {
  log("Estoy guardando el token...");
  final prefs = await SharedPreferences.getInstance();
  prefs.setString("token_api", token);
  log("Terminado de guardar token");
}

Y después regresamos la respuesta. Lo demás es puro diseño:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: cargandoGeneralmente
          ? Center(child: CircularProgressIndicator())
          : Form(
              key: _claveFormulario,
              child: ListView(
                shrinkWrap: true,
                children: <Widget>[
                  Padding(
                    padding: EdgeInsets.all(16.0),
                    child: TextFormField(
                      validator: (value) {
                        if (value.isEmpty) {
                          return 'Escribe el correo electrónico';
                        }
                        if (!value.contains("@")) {
                          return 'El correo electrónico debe llevar un @';
                        }
                        return null;
                      },
                      controller: _email,
                      decoration: InputDecoration(
                          hintText: 'correo@dominio',
                          labelText: "Correo electrónico"),
                      keyboardType: TextInputType.emailAddress,
                    ),
                  ),
                  Padding(
                    padding: EdgeInsets.all(16.0),
                    child: TextFormField(
                      controller: _password,
                      validator: (value) {
                        if (value.isEmpty) {
                          return 'Escribe la contraseña';
                        }
                        return null;
                      },
                      decoration: InputDecoration(
                          hintText: 'Contraseña', labelText: 'Contraseña'),
                      obscureText: true, /* <-- Aquí */
                    ),
                  ),
                  Builder(
                    builder: (context) => Padding(
                      padding: EdgeInsets.all(16.0),
                      child: RaisedButton(
                        color: Colors.blue,
                        textColor: Colors.white,
                        child: cargandoBoton
                            ? CircularProgressIndicator(
                                valueColor:
                                    AlwaysStoppedAnimation<Color>(Colors.white),
                              )
                            : Text('Iniciar sesión'),
                        onPressed: () async {
                          if (!_claveFormulario.currentState.validate()) {
                            return;
                          }
                          if (cargandoBoton) return;
                          bool respuesta =
                              await login(_email.text, _password.text);
                          if (respuesta) {
                            navegarAEscritorio();
                          } else {
                            Scaffold.of(context).showSnackBar(
                              SnackBar(
                                content:
                                    Text('Datos incorrectos. Intenta de nuevo'),
                                duration: Duration(seconds: 1),
                              ),
                            );
                          }
                        },
                      ),
                    ),
                  ),
                ],
              ),
            ),
    );
  }

Puedes ver que uso un input de tipo correo y un input de tipo contraseña. Además, el formulario está debidamente validado. Y finalmente utilizo algunas variables booleanas para mostrar que la app está cargando.

Constantes

Es buen momento para mostrar las constantes de la app escrita en Flutter. Son simples valores que utilizo en otros lugares y que dejo en un solo lugar para cambiarlos de manera fácil si es necesario:

const RUTA_API = "https://parzibyte.me/apps/sistema_ventas_laravel/public/api/auth";
var rutaLogin = "$RUTA_API/login";

Si montas la app web de Laravel en otro servidor entonces ahí sería en donde pondrías la IP o dominio del servidor.

Navegación y escritorio

Escritorio de sistema de ventas Android – Mostrar opciones

Aquí quiero mostrar dos cosas. El escritorio es puro diseño en un grid:

import 'package:flutter/material.dart';

import 'navigator.dart';

class Escritorio extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Escritorio"),
      ),
      body: GridView.count(
        crossAxisCount: 2,
        children: <Widget>[
          Card(
            elevation: 10,
            child: InkWell(
              splashColor: Colors.blue.withAlpha(30),
              onTap: () {
                navegarAProductos();
              },
              child: ListTile(
                title: Text(
                  'Productos',
                  textAlign: TextAlign.center,
                ),
                subtitle: Image(
                  image: AssetImage("assets/order.png"),
                ),
              ),
            ),
          ),
          Card(
            elevation: 10,
            child: InkWell(
              splashColor: Colors.blue.withAlpha(30),
              onTap: () {
                navegarAVentas();
              },
              child: ListTile(
                title: Text(
                  'Ventas',
                  textAlign: TextAlign.center,
                ),
                subtitle: Image(
                  image: AssetImage("assets/coupon.png"),
                ),
              ),
            ),
          ),
          Card(
            elevation: 10,
            child: InkWell(
              splashColor: Colors.blue.withAlpha(30),
              onTap: () {
                navegarAClientes();
              },
              child: ListTile(
                title: Text(
                  'Clientes',
                  textAlign: TextAlign.center,
                ),
                subtitle: Image(
                  image: AssetImage("assets/clientes.png"),
                ),
              ),
            ),
          ),
          Card(
            elevation: 10,
            child: InkWell(
              splashColor: Colors.blue.withAlpha(30),
              onTap: () {
                navegarAAcercaDe();
              },
              child: ListTile(
                title: Text(
                  'Acerca de',
                  textAlign: TextAlign.center,
                ),
                subtitle: Image(
                  image: AssetImage("assets/about.png"),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Fíjate que cada tarjeta invoca a un método de navegación. Los métodos para cambiar a distintos lugares dentro de la app están aquí:

import 'package:flutter/material.dart';

import 'acerca_de.dart';
import 'clientes.dart';
import 'escritorio.dart';
import 'productos.dart';
import 'ventas.dart';

final GlobalKey<NavigatorState> navigatorKey = new GlobalKey<NavigatorState>();

void navegarAEscritorio() {
  navigatorKey.currentState
      .push(MaterialPageRoute(builder: (context) => Escritorio()));
}

void navegarAProductos() {
  navigatorKey.currentState
      .push(MaterialPageRoute(builder: (context) => Productos()));
}

void navegarAVentas() {
  navigatorKey.currentState
      .push(MaterialPageRoute(builder: (context) => Ventas()));
}

void navegarAAcercaDe() {
  navigatorKey.currentState
      .push(MaterialPageRoute(builder: (context) => AcercaDe()));
}

void navegarAClientes() {
  navigatorKey.currentState
      .push(MaterialPageRoute(builder: (context) => Clientes()));
}

Así es como nos movemos entre distintas pantallas de la app.

Productos

Después de eso tenemos el CRUD de los productos. Las 4 operaciones funcionan de maravilla. Comencemos con el que se encarga de obtener los productos de la API de Laravel y mostrarlos en una lista con dos botones: uno para editar y otro para eliminar:

Listado de productos en app móvil

Para traer los productos desde la API de Laravel alojada en un servidor usamos lo siguiente:

  Future<String> obtenerProductos() async {
    setState(() {
      cargando = true;
    });
    log("Obteniendo prefs...");
    final prefs = await SharedPreferences.getInstance();
    String posibleToken = prefs.getString("token_api");
    log("Posible token: $posibleToken");
    if (posibleToken == null) {
      log("No hay token");
      return "No hay token";
    }
    log("Haciendo petición...");
    var response = await http.get(
      "$RUTA_API/productos",
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer $posibleToken',
      },
    );

    this.setState(() {
      productos = json.decode(response.body);
      this.cargando = false;
    });

    return "Success!";
  }

Aquí la respuesta no importa (de hecho se quedó una cadena de prueba) ya que estamos estableciendo la lista directamente, en lugar de regresarla. Lo que hacemos es asignar a “productos” el valor de decodificar la respuesta; todo esto en caso de que el código de estado sea 200.

Es buen momento para mostrar el código que elimina el producto usando una petición HTTP DELETE.

Future<bool> eliminarProducto(String id) async {
  log("Obteniendo prefs...");
  final prefs = await SharedPreferences.getInstance();
  String posibleToken = prefs.getString("token_api");
  log("Posible token: $posibleToken");
  if (posibleToken == null) {
    log("No hay token");
    return false;
  }
  log("Haciendo petición...");
  final http.Response response = await http.delete(
    "$RUTA_API/producto/$id",
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer $posibleToken',
    },
  );
  log("Response es 200?");
  log((response.statusCode == 200).toString());
  return response.statusCode == 200;
}

Recibe el id y hace la petición. Por cierto, nota que para todas las peticiones autenticadas incluyo el token que me dieron al iniciar sesión.

Finalmente veamos el diseño. Para dibujar la lista usamos un ListView.builder y nos ayudamos de un índice que nos pasa para dibujar correctamente cada elemento:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          /*
          * Esperamos a que vuelva de la ruta y refrescamos
          * los productos. No encontré otra manera de hacer que
          * se escuche cuando se regresa de la ruta
          * */
          await navigatorKey.currentState
              .push(MaterialPageRoute(builder: (context) => AgregarProducto()));
          this.obtenerProductos();
        },
        child: Icon(Icons.add),
      ),
      appBar: AppBar(
        title: Text("Productos"),
      ),
      body: (cargando)
          ? Center(
              child: CircularProgressIndicator(),
            )
          : ListView.builder(
              itemCount: productos == null ? 0 : productos.length,
              itemBuilder: (BuildContext context, int index) {
                return Column(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    ListTile(
                      title: Text(productos[index]["descripcion"]),
                      subtitle: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: <Widget>[
                          Row(
                            children: <Widget>[
                              Padding(
                                padding: EdgeInsets.only(
                                  left: 0,
                                  top: 0,
                                  right: 5,
                                  bottom: 0,
                                ),
                                child: Text(
                                  "Código",
                                  style: TextStyle(fontWeight: FontWeight.bold),
                                ),
                              ),
                              Text(productos[index]["codigo_barras"]),
                            ],
                          ),
                          Row(
                            children: <Widget>[
                              Padding(
                                padding: EdgeInsets.only(
                                  left: 0,
                                  top: 0,
                                  right: 5,
                                  bottom: 0,
                                ),
                                child: Text(
                                  "Compra",
                                  style: TextStyle(fontWeight: FontWeight.bold),
                                ),
                              ),
                              Text("\$" + productos[index]["precio_compra"]),
                            ],
                          ),
                          Row(
                            children: <Widget>[
                              Padding(
                                padding: EdgeInsets.only(
                                  left: 0,
                                  top: 0,
                                  right: 5,
                                  bottom: 0,
                                ),
                                child: Text(
                                  "Venta",
                                  style: TextStyle(fontWeight: FontWeight.bold),
                                ),
                              ),
                              Text("\$" + productos[index]["precio_venta"]),
                            ],
                          ),
                          Row(
                            children: <Widget>[
                              Padding(
                                padding: EdgeInsets.only(
                                  left: 0,
                                  top: 0,
                                  right: 5,
                                  bottom: 0,
                                ),
                                child: Text(
                                  "Existencia",
                                  style: TextStyle(fontWeight: FontWeight.bold),
                                ),
                              ),
                              Text(productos[index]["existencia"]),
                            ],
                          ),
                        ],
                      ),
//              subtitle: Text(productos[index]["codigo_barras"] + "\nxd"),
                    ),
                    ButtonBar(
                      children: <Widget>[
                        FlatButton(
                          child: Icon(
                            Icons.edit,
                            color: Colors.amber,
                          ),
                          onPressed: () async {
                            await navigatorKey.currentState.push(
                              MaterialPageRoute(
                                builder: (context) => EditarProducto(
                                  idProducto: this.productos[index]["id"],
                                ),
                              ),
                            );
                            this.obtenerProductos();
                          },
                        ),
                        Builder(
                          builder: (context) => FlatButton(
                            child: Icon(
                              Icons.delete,
                              color: Colors.red,
                            ),
                            onPressed: () {
                              showAlertDialog(
                                  context,
                                  FlatButton(
                                    child: Text("Cancelar"),
                                    onPressed: () {
                                      navigatorKey.currentState.pop();
                                    },
                                  ),
                                  FlatButton(
                                    child: Text("Sí, eliminar"),
                                    onPressed: () async {
                                      await eliminarProducto(this
                                          .productos[index]["id"]
                                          .toString());
                                      navigatorKey.currentState.pop();
                                      this.obtenerProductos();
                                      Scaffold.of(context).showSnackBar(
                                        SnackBar(
                                          content: Text('Producto eliminado'),
                                          duration: Duration(seconds: 1),
                                        ),
                                      );
                                    },
                                  ),
                                  "Eliminar producto",
                                  "¿Realmente deseas eliminar el producto ${this.productos[index]["descripcion"]}? esto no se puede deshacer");
                            },
                          ),
                        ),
                      ],
                    ),
                    Divider(),
                  ],
                );
              },
            ),
    );
  }

Observa dos cosas: existen dos botones. Uno para editar, que usa la navegación para ir al formulario de edición; y otro que muestra una alerta de pregunta; misma que veremos a continuación.

Diálogos o alertas de confirmación

Aquí está el código que permite reutilizar la alerta. Simplemente crea una alerta con dos botones que se le pasan al crearlo:

import 'package:flutter/material.dart';

showAlertDialog(BuildContext context, Widget cancelButton,
    Widget continueButton, String titulo, String contenido) {
  AlertDialog alert = AlertDialog(
    title: Text(titulo),
    content: Text(contenido),
    actions: [
      cancelButton,
      continueButton,
    ],
  );

  showDialog(
    context: context,
    builder: (BuildContext context) {
      return alert;
    },
  );
}

Debo admitir que el código es tomado de algún lado que no recuerdo; pero no es una copia exacta pues lo modifiqué para hacerlo reutilizable ya que el original no permitía especificar distintas acciones de acuerdo al botón.

De igual forma gracias a quien lo haya escrito en primer lugar. Aquí vemos un ejemplo para confirmar la eliminación de un producto desde el sistema de ventas para Android:

Alerta de confirmación para eliminar el producto

Agregar producto

Si se toca el floating action button en la pantalla principal de productos se navega al siguiente formulario:

Registrar nuevo producto

El formulario está validado igualmente. Su diseño es así:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Agregar producto"),
      ),
      body: Form(
        key: _claveFormulario,
        child: ListView(shrinkWrap: true, children: <Widget>[
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe el código de barras';
                }
                return null;
              },
              controller: _codigoBarras,
              decoration: InputDecoration(
                hintText: 'Escribe el código',
                labelText: "Código de barras",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe la descripción';
                }
                return null;
              },
              controller: _descripcion,
              decoration: InputDecoration(
                hintText: 'Escribe la descripción',
                labelText: "Descripción",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe el precio de compra';
                }
                return null;
              },
              controller: _precioCompra,
              decoration: InputDecoration(
                hintText: 'Escribe el precio de compra',
                labelText: "Precio de compra",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe el precio de venta';
                }
                return null;
              },
              controller: _precioVenta,
              decoration: InputDecoration(
                hintText: 'Escribe el precio de venta',
                labelText: "Precio de venta",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe la existencia';
                }
                return null;
              },
              controller: _existencia,
              decoration: InputDecoration(
                hintText: 'Escribe la existencia',
                labelText: "Existencia",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: Builder(
              builder: (context) => RaisedButton(
                color: Colors.blue,
                textColor: Colors.white,
                child: cargando
                    ? CircularProgressIndicator(
                        valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
                      )
                    : Text("Guardar"),
                onPressed: () async {
                  if (!_claveFormulario.currentState.validate()) {
                    return;
                  }
                  if (cargando) {
                    return;
                  }
                  Producto p = new Producto(
                      _codigoBarras.text,
                      _descripcion.text,
                      _precioCompra.text,
                      _precioVenta.text,
                      _existencia.text);
                  await agregarProducto(p);
                  Scaffold.of(context)
                      .showSnackBar(
                        SnackBar(
                          content: Text('Producto guardado'),
                          duration: Duration(seconds: 1),
                        ),
                      )
                      .closed
                      .then((razon) {
                    Navigator.of(context).pop();
                  });
                },
              ),
            ),
          ),
        ]),
      ),
    );
  }

Si la validación es exitosa entonces se hace una petición POST a la API para guardar el producto:

  Future<bool> agregarProducto(Producto producto) async {
    setState(() {
      cargando = true;
    });
    log("Obteniendo prefs...");
    final prefs = await SharedPreferences.getInstance();
    String posibleToken = prefs.getString("token_api");
    log("Posible token: $posibleToken");
    if (posibleToken == null) {
      log("No hay token");
      return false;
    }
    log("Haciendo petición...");
    final http.Response response = await http.post(
      "$RUTA_API/producto",
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer $posibleToken',
      },
      body: jsonEncode(<String, String>{
        'codigo_barras': producto.codigoBarras,
        'descripcion': producto.descripcion,
        'precio_compra': producto.precioCompra,
        'precio_venta': producto.precioVenta,
        'existencia': producto.existencia,
      }),
    );
    log("Response es 200?");
    log((response.statusCode == 200).toString());
    setState(() {
      cargando = false;
    });
    return response.statusCode == 200;
  }

Editar productos

Editar producto (existencia, precios, código de barras) en app móvil

Veamos el de edición de un producto ya que en primer lugar se obtiene el producto para rellenar los campos:

  Future<bool> obtenerProducto(int idProducto) async {
    setState(() {
      cargando = true;
    });
    log("Obteniendo prefs...");
    final prefs = await SharedPreferences.getInstance();
    String posibleToken = prefs.getString("token_api");
    log("Posible token: $posibleToken");
    if (posibleToken == null) {
      log("No hay token");
      return false;
    }
    log("Haciendo petición...");
    final http.Response response = await http.get(
      "$RUTA_API/producto/$idProducto",
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer $posibleToken',
      },
    );
    log("Response es 200?");
    log((response.statusCode == 200).toString());
    Map<String, dynamic> productoRaw = json.decode(response.body);

    setState(() {
      cargando = false;
      _codigoBarras.text = productoRaw["codigo_barras"];
      _descripcion.text = productoRaw["descripcion"];
      _precioCompra.text = productoRaw["precio_compra"];
      _precioVenta.text = productoRaw["precio_venta"];
      _existencia.text = productoRaw["existencia"];
    });
    return response.statusCode == 200;
  }

Después vemos el diseño del formulario que es idéntico al de agregar productos:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Editar producto #$idProducto"),
      ),
      body: Form(
        key: _claveFormulario,
        child: ListView(shrinkWrap: true, children: <Widget>[
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe el código de barras';
                }
                return null;
              },
              controller: _codigoBarras,
              decoration: InputDecoration(
                hintText: 'Escribe el código',
                labelText: "Código de barras",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe la descripción';
                }
                return null;
              },
              controller: _descripcion,
              decoration: InputDecoration(
                hintText: 'Escribe la descripción',
                labelText: "Descripción",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe el precio de compra';
                }
                return null;
              },
              controller: _precioCompra,
              decoration: InputDecoration(
                hintText: 'Escribe el precio de compra',
                labelText: "Precio de compra",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe el precio de venta';
                }
                return null;
              },
              controller: _precioVenta,
              decoration: InputDecoration(
                hintText: 'Escribe el precio de venta',
                labelText: "Precio de venta",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: TextFormField(
              keyboardType: TextInputType.number,
              validator: (value) {
                if (value.isEmpty) {
                  return 'Escribe la existencia';
                }
                return null;
              },
              controller: _existencia,
              decoration: InputDecoration(
                hintText: 'Escribe la existencia',
                labelText: "Existencia",
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16.0),
            child: Builder(
              builder: (context) => RaisedButton(
                color: Colors.blue,
                textColor: Colors.white,
                child: cargando
                    ? CircularProgressIndicator(
                        valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
                      )
                    : Text("Guardar"),
                onPressed: () async {
                  if (!_claveFormulario.currentState.validate()) {
                    return;
                  }
                  if (cargando) {
                    return;
                  }
                  Producto p = new Producto(
                      _codigoBarras.text,
                      _descripcion.text,
                      _precioCompra.text,
                      _precioVenta.text,
                      _existencia.text);
                  await actualizarProducto(p);

                  Scaffold.of(context)
                      .showSnackBar(
                        SnackBar(
                          content: Text('Producto actualizado'),
                          duration: Duration(seconds: 1),
                        ),
                      )
                      .closed
                      .then((razon) {
                    Navigator.of(context).pop();
                  });
                },
              ),
            ),
          ),
        ]),
      ),
    );
  }

Y finalmente cuando se guarda el producto se hace una petición PUT para actualizarlo en la API:

  Future<bool> actualizarProducto(Producto producto) async {
    setState(() {
      cargando = true;
    });
    log("Obteniendo prefs...");
    final prefs = await SharedPreferences.getInstance();
    String posibleToken = prefs.getString("token_api");
    log("Posible token: $posibleToken");
    if (posibleToken == null) {
      log("No hay token");
      return false;
    }
    log("Haciendo petición...");
    final http.Response response = await http.put(
      "$RUTA_API/producto",
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer $posibleToken',
      },
      body: jsonEncode(<String, String>{
        "id": this.idProducto.toString(),
        'codigo_barras': producto.codigoBarras,
        'descripcion': producto.descripcion,
        'precio_compra': producto.precioCompra,
        'precio_venta': producto.precioVenta,
        'existencia': producto.existencia,
      }),
    );
    log("Response es 200?");
    log((response.statusCode == 200).toString());
    setState(() {
      cargando = false;
    });
    return response.statusCode == 200;
  }

He mostrado todo esto porque para los clientes es algo parecido; es decir, es igualmente un CRUD, así que no mostraré código.

Clientes

Dejaré la captura de pantalla de cómo se ven los clientes dentro de la aplicación:

Clientes para asignar ventas en Aplicación móvil

Ver ventas

Mostrar ventas en pos escrito en Flutter

Si desde el escritorio navegamos a ventas podemos ver la lista de las mismas. Comenzamos viendo la petición HTTP GET que las trae:

  Future<String> obtenerVentas() async {
    setState(() {
      this.cargando = true;
    });
    log("Obteniendo prefs...");
    final prefs = await SharedPreferences.getInstance();
    String posibleToken = prefs.getString("token_api");
    log("Posible token: $posibleToken");
    if (posibleToken == null) {
      log("No hay token");
      return "No hay token";
    }
    log("Haciendo petición...");
    var response = await http.get(
      "$RUTA_API/ventas",
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer $posibleToken',
      },
    );

    this.setState(() {
      cargando = false;
      ventas = json.decode(response.body);
    });

    return "Success!";
  }

Hacemos lo mismo que con los productos: mostramos las ventas en una lista. Lo que cambia aquí es que fue un poco complejo calcular el total debido a que el total no se guarda en la base de datos, sino que se calcula de acuerdo a los productos vendidos:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          /*
          * Esperamos a que vuelva de la ruta y refrescamos
          * los clientes. No encontré otra manera de hacer que
          * se escuche cuando se regresa de la ruta
          * */
//          await navigatorKey.currentState
//              .push(MaterialPageRoute(builder: (context) => AgregarVenta()));
//          this.obtenerVentas();
        },
        child: Icon(Icons.add),
      ),
      appBar: AppBar(
        title: Text("Ventas"),
      ),
      body: (cargando)
          ? Center(
              child: CircularProgressIndicator(),
            )
          : ListView.builder(
              itemCount: ventas == null ? 0 : ventas.length,
              itemBuilder: (BuildContext context, int index) {
                return Column(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    ListTile(
                      title: Builder(
                        builder: (context) {
                          double total = 0;
                          for (var i = 0;
                              i < ventas[index]["productos"].length;
                              i++) {
                            double cantidad = double.parse(
                                ventas[index]["productos"][i]["cantidad"]);
                            double precio = double.parse(
                                ventas[index]["productos"][i]["precio"]);
                            total += (cantidad * precio);
                          }
                          return Text("\$" + formateador.format(total));
                        },
                      ),
                      subtitle: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: <Widget>[
                          Row(
                            children: <Widget>[
                              Padding(
                                padding: EdgeInsets.only(
                                  left: 0,
                                  top: 0,
                                  right: 5,
                                  bottom: 0,
                                ),
                                child: Text(
                                  "Fecha",
                                  style: TextStyle(fontWeight: FontWeight.bold),
                                ),
                              ),
                              Text(ventas[index]["created_at"]
                                  .toString()
                                  .substring(
                                      0,
                                      ventas[index]["created_at"]
                                          .toString()
                                          .indexOf(".0000"))
                                  .replaceFirst("T", " ")),
                            ],
                          ),
                          Row(
                            children: <Widget>[
                              Padding(
                                padding: EdgeInsets.only(
                                  left: 0,
                                  top: 0,
                                  right: 5,
                                  bottom: 0,
                                ),
                                child: Text(
                                  "Cliente",
                                  style: TextStyle(fontWeight: FontWeight.bold),
                                ),
                              ),
                              Text(ventas[index]["cliente"]["nombre"]),
                            ],
                          ),
                        ],
                      ),
                    ),
                    ButtonBar(
                      children: <Widget>[
                        FlatButton(
                          child: Icon(
                            Icons.zoom_in,
                            color: Colors.blue,
                          ),
                          onPressed: () {
                            navigatorKey.currentState.push(
                              MaterialPageRoute(
                                builder: (context) => DetalleDeVenta(
                                  idVenta: this.ventas[index]["id"],
                                ),
                              ),
                            );
                          },
                        ),
                        Builder(
                          builder: (context) => FlatButton(
                            child: Icon(
                              Icons.delete,
                              color: Colors.red,
                            ),
                            onPressed: () {
                              showAlertDialog(
                                  context,
                                  FlatButton(
                                    child: Text("Cancelar"),
                                    onPressed: () {
                                      navigatorKey.currentState.pop();
                                    },
                                  ),
                                  FlatButton(
                                    child: Text("Sí, eliminar"),
                                    onPressed: () async {
                                      await eliminarVenta(
                                          this.ventas[index]["id"].toString());
                                      navigatorKey.currentState.pop();
                                      this.obtenerVentas();
                                      Scaffold.of(context).showSnackBar(
                                        SnackBar(
                                          content: Text('Venta eliminada'),
                                          duration: Duration(seconds: 1),
                                        ),
                                      );
                                    },
                                  ),
                                  "Eliminar venta",
                                  "¿Realmente deseas eliminar la venta? esto no se puede deshacer");
                            },
                          ),
                        ),
                      ],
                    ),
                    Divider(),
                  ],
                );
              },
            ),
    );
  }

Fíjate en la línea 33 a la 43; ahí se calcula el total de acuerdo a los productos. También se usa un formateador de dinero que no hace otra cosa más que lo que su nombre indica.

Otra cosa interesante es para la fecha; debido a que se guardan los milisegundos o la zona horaria o algo, lo remuevo usando varias funciones de cadena de Dart.

Detalle de venta

Se puede mostrar el detalle de una venta. Esa pantalla se compone de varios elementos. Lo que más me gustó fue que logré que la lista de productos no ocupara todo el alto de la pantalla y que se hiciera scroll.

Veamos primero la petición GET para obtener la venta:

  Future<bool> obtenerVenta(int idVenta) async {
    setState(() {
      cargando = true;
    });
    log("Obteniendo prefs...");
    final prefs = await SharedPreferences.getInstance();
    String posibleToken = prefs.getString("token_api");
    log("Posible token: $posibleToken");
    if (posibleToken == null) {
      log("No hay token");
      return false;
    }
    log("Haciendo petición...");
    final http.Response response = await http.get(
      "$RUTA_API/venta/$idVenta",
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer $posibleToken',
      },
    );
    log("Response es 200?");
    log((response.statusCode == 200).toString());
    detalle = json.decode(response.body);
    double t = 0;
    if (detalle != null) {
      for (var x = 0; x < detalle["productos"].length; x++) {
        t += (double.parse(detalle["productos"][x]["cantidad"]) *
            double.parse(detalle["productos"][x]["precio"]));
      }
    }

    setState(() {
      cargando = false;
      total = t;
    });
    return response.statusCode == 200;
  }

Ahora el diseño del cual me siento más orgulloso:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Detalle de venta #$idVenta"),
      ),
      /*
      * es
      * detalle["productos"][index]["descripcion"]
      * */
      body: detalle == null
          ? Center(child: CircularProgressIndicator())
          : Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Padding(
                  padding: EdgeInsets.only(
                    left: 5,
                    bottom: 5,
                  ),
                  child: Text(
                    "Venta #$idVenta",
                    style: TextStyle(fontSize: 20),
                  ),
                ),
                Padding(
                  padding: EdgeInsets.only(
                    left: 5,
                    bottom: 5,
                  ),
                  child: Row(
                    children: <Widget>[
                      Icon(
                        Icons.person,
                        color: Colors.green,
                      ),
                      Text(
                        detalle["cliente"]["nombre"],
                        style: TextStyle(fontSize: 20),
                      ),
                    ],
                  ),
                ),
                Padding(
                  padding: EdgeInsets.only(
                    left: 5,
                    bottom: 5,
                  ),
                  child: Row(
                    children: <Widget>[
                      Icon(
                        Icons.format_list_bulleted,
                        color: Colors.amber,
                      ),
                      Text(
                        "Productos",
                        style: TextStyle(fontSize: 20),
                      ),
                    ],
                  ),
                ),
                Divider(),
                Expanded(
                  child: ListView.builder(
                    itemCount:
                        detalle == null ? 0 : detalle["productos"].length,
                    itemBuilder: (BuildContext context, int index) {
                      return ListTile(
                        title: Text(detalle["productos"][index]["descripcion"]),
                        subtitle: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: <Widget>[
                            Row(
                              children: <Widget>[
                                Padding(
                                  padding: EdgeInsets.only(
                                    left: 0,
                                    top: 0,
                                    right: 5,
                                    bottom: 0,
                                  ),
                                  child: Text(
                                    "Código",
                                    style:
                                        TextStyle(fontWeight: FontWeight.bold),
                                  ),
                                ),
                                Text(detalle["productos"][index]
                                    ["codigo_barras"]),
                              ],
                            ),
                            Row(
                              children: <Widget>[
                                Padding(
                                  padding: EdgeInsets.only(
                                    left: 0,
                                    top: 0,
                                    right: 5,
                                    bottom: 0,
                                  ),
                                  child: Text("Precio",
                                      style: TextStyle(
                                          fontWeight: FontWeight.bold)),
                                ),
                                Text(
                                  "\$" +
                                      formateador.format(double.parse(
                                          detalle["productos"][index]
                                              ["precio"])),
                                ),
                              ],
                            ),
                            Row(
                              children: <Widget>[
                                Padding(
                                  padding: EdgeInsets.only(
                                    left: 0,
                                    top: 0,
                                    right: 5,
                                    bottom: 0,
                                  ),
                                  child: Text(
                                    "Cantidad",
                                    style:
                                        TextStyle(fontWeight: FontWeight.bold),
                                  ),
                                ),
                                Text(
                                  "\$" +
                                      formateador.format(double.parse(
                                          detalle["productos"][index]
                                              ["cantidad"])),
                                ),
                              ],
                            ),
                            Row(
                              children: <Widget>[
                                Padding(
                                  padding: EdgeInsets.only(
                                    left: 0,
                                    top: 0,
                                    right: 5,
                                    bottom: 0,
                                  ),
                                  child: Text("Subtotal",
                                      style: TextStyle(
                                          fontWeight: FontWeight.bold)),
                                ),
                                Text(
                                  "\$" +
                                      formateador.format((double.parse(
                                              detalle["productos"][index]
                                                  ["precio"]) *
                                          double.parse(detalle["productos"]
                                              [index]["cantidad"]))),
                                ),
                              ],
                            ),
                            Divider(),
                          ],
                        ),
                      );
                    },
                  ),
                ),
                Padding(
                  padding: EdgeInsets.only(
                    left: 5,
                    bottom: 5,
                  ),
                  child: Text(
                    "Total: \$" + formateador.format(total),
                    style: TextStyle(fontSize: 20),
                  ),
                ),
              ],
            ),
    );
  }

Y la operación de eliminar es parecida a la de los productos. De igual modo si tienes dudas voy a dejar todo el código fuente completo al final del post.

Créditos

Ahora veamos los créditos de las imágenes y algunos botones que dirigen a mis redes:

Créditos de sistema de ventas para Android escrito en Flutter

Por el momento no cuenta con un botón del repositorio a GitHub de la app porque cuando programé esa parte no pensaba liberar el código fuente.

Descargar aplicación

Si quieres descargar la aplicación puedes ir al repositorio del sistema de ventas en Laravel; lo he subido ahí pues GitHub no me dejó subirla a la página de releases.

Descargar código fuente

Esta app es open source así que le puedes hacer las mejoras que quieras. El código fuente está en mi GitHub.

Demostración de la aplicación

También he grabado un vídeo que muestra cómo funciona la app:

Enlaces de interés

Recuerda que esta app consume una API de Laravel y que originalmente este sistema está creado con Laravel, pero adaptado para móvil.

Te invito a ver otros proyectos que he realizado.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

8 comentarios en “Punto de venta para Android – Open source”

  1. Estoy probando el con visual code y un simulador, pero me sale un error;

    Exception has ocurred.
    SocketException (SocketException; Failed host lookup: “parzybite.me” (OS Error: No address associated with hostname, errno=7))

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *