GetX en Flutter: ejercicio resuelto de gestión de estado

GetX en Flutter: ejercicio resuelto de gestión de estado

GetX es una solución todo-en-uno: gestión de estado reactiva, inyección de dependencias y navegación sin BuildContext. Su ventaja principal es la velocidad de desarrollo — un controlador observable reconstruye solo los widgets suscritos, sin Provider, sin StreamBuilder, sin context.watch.

Enunciado

Implementa una app de carrito de compras con GetX donde:

  • El catálogo de productos carga desde un repositorio falso (simula llamada API).
  • El carrito usa un GetxController con lista reactiva.
  • Un badge en el AppBar muestra el número de ítems en tiempo real.
  • La pantalla de carrito lista los productos con cantidad y total.

Dependencias

1
2
3
4
5
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  get: ^4.6.6

Solución completa

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';

void main() {
  Get.put(CartController());           // inyección global al arrancar
  runApp(const GetMaterialApp(home: CatalogPage()));
}

// ── Modelo ─────────────────────────────────────────────────────────────────────
class Product {
  final int id;
  final String name;
  final double price;

  const Product({required this.id, required this.name, required this.price});
}

// ── Repositorio (simula API) ───────────────────────────────────────────────────
class ProductRepository {
  Future<List<Product>> fetchAll() async {
    await Future.delayed(const Duration(milliseconds: 600));
    return [
      const Product(id: 1, name: 'Teclado mecánico', price: 89.99),
      const Product(id: 2, name: 'Ratón ergonómico', price: 45.00),
      const Product(id: 3, name: 'Monitor 27"', price: 299.00),
      const Product(id: 4, name: 'Auriculares USB', price: 65.50),
      const Product(id: 5, name: 'Hub USB-C 7 puertos', price: 38.00),
    ];
  }
}

// ── Carrito controller ─────────────────────────────────────────────────────────
class CartController extends GetxController {
  // RxList: cualquier widget que use .obs se reconstruye al cambiar
  final items = <Product>[].obs;

  int get totalItems => items.length;

  double get totalPrice => items.fold(0, (sum, p) => sum + p.price);

  void add(Product product) => items.add(product);

  void remove(Product product) => items.remove(product);

  bool contains(Product product) => items.any((p) => p.id == product.id);
}

// ── Catálogo controller ────────────────────────────────────────────────────────
class CatalogController extends GetxController {
  final _repo = ProductRepository();

  final products = <Product>[].obs;
  final isLoading = true.obs;
  final errorMessage = ''.obs;

  @override
  void onInit() {
    super.onInit();
    _loadProducts();
  }

  Future<void> _loadProducts() async {
    try {
      isLoading(true);
      errorMessage('');
      products.assignAll(await _repo.fetchAll());
    } catch (e) {
      errorMessage('Error al cargar productos: $e');
    } finally {
      isLoading(false);
    }
  }
}

// ── Pantalla catálogo ──────────────────────────────────────────────────────────
class CatalogPage extends StatelessWidget {
  const CatalogPage({super.key});

  @override
  Widget build(BuildContext context) {
    // Get.put dentro de la pantalla: se destruye al salir (fenix = false por defecto)
    final catalog = Get.put(CatalogController());
    final cart = Get.find<CartController>();

    return Scaffold(
      appBar: AppBar(
        title: const Text('Catálogo'),
        actions: [
          // Obx reconstruye solo este IconButton cuando totalItems cambia
          Obx(() => Badge(
            isLabelVisible: cart.totalItems > 0,
            label: Text('${cart.totalItems}'),
            child: IconButton(
              icon: const Icon(Icons.shopping_cart),
              onPressed: () => Get.to(() => const CartPage()),
            ),
          )),
          const SizedBox(width: 8),
        ],
      ),
      body: Obx(() {
        if (catalog.isLoading.value) {
          return const Center(child: CircularProgressIndicator());
        }
        if (catalog.errorMessage.isNotEmpty) {
          return Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(catalog.errorMessage.value),
                TextButton(onPressed: catalog._loadProducts, child: const Text('Reintentar')),
              ],
            ),
          );
        }
        return ListView.builder(
          padding: const EdgeInsets.all(12),
          itemCount: catalog.products.length,
          itemBuilder: (_, i) => _ProductTile(product: catalog.products[i]),
        );
      }),
    );
  }
}

class _ProductTile extends StatelessWidget {
  final Product product;
  const _ProductTile({required this.product});

  @override
  Widget build(BuildContext context) {
    final cart = Get.find<CartController>();
    return Card(
      child: ListTile(
        title: Text(product.name),
        subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
        trailing: Obx(() => cart.contains(product)
            ? FilledButton.icon(
                onPressed: () => cart.remove(product),
                icon: const Icon(Icons.remove_shopping_cart),
                label: const Text('Quitar'),
                style: FilledButton.styleFrom(backgroundColor: Colors.red),
              )
            : FilledButton.icon(
                onPressed: () => cart.add(product),
                icon: const Icon(Icons.add_shopping_cart),
                label: const Text('Añadir'),
              )),
      ),
    );
  }
}

// ── Pantalla carrito ───────────────────────────────────────────────────────────
class CartPage extends StatelessWidget {
  const CartPage({super.key});

  @override
  Widget build(BuildContext context) {
    final cart = Get.find<CartController>();
    return Scaffold(
      appBar: AppBar(title: const Text('Carrito')),
      body: Obx(() {
        if (cart.items.isEmpty) {
          return const Center(child: Text('El carrito está vacío'));
        }
        return Column(
          children: [
            Expanded(
              child: ListView.builder(
                itemCount: cart.items.length,
                itemBuilder: (_, i) {
                  final p = cart.items[i];
                  return ListTile(
                    title: Text(p.name),
                    subtitle: Text('\$${p.price.toStringAsFixed(2)}'),
                    trailing: IconButton(
                      icon: const Icon(Icons.delete_outline),
                      onPressed: () => cart.remove(p),
                    ),
                  );
                },
              ),
            ),
            const Divider(),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('Total (${cart.totalItems} ítems)',
                      style: const TextStyle(fontSize: 16)),
                  Obx(() => Text(
                    '\$${cart.totalPrice.toStringAsFixed(2)}',
                    style: const TextStyle(
                        fontSize: 20, fontWeight: FontWeight.bold),
                  )),
                ],
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
              child: FilledButton(
                onPressed: () {
                  cart.items.clear();
                  Get.back();
                  Get.snackbar('Pedido realizado', '¡Gracias por tu compra!',
                      snackPosition: SnackPosition.BOTTOM);
                },
                style: FilledButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
                child: const Text('Confirmar pedido'),
              ),
            ),
          ],
        );
      }),
    );
  }
}

Cómo funciona internamente

ConceptoDescripción
GetxControllerClase base para controladores con ciclo de vida (onInit, onClose)
.obsConvierte cualquier variable en observable (RxList, RxBool, RxString…)
Obx(() => ...)Se suscribe automáticamente a los observables usados dentro del builder
Get.put(T)Inyecta y registra una instancia en el contenedor de GetX
Get.find<T>()Recupera la instancia registrada desde cualquier parte del código
GetMaterialAppReemplaza MaterialApp para activar navegación y snackbars sin contexto

Bindings (inicialización diferida)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class HomeBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => CatalogController());
    Get.lazyPut(() => CartController());
  }
}

// En las rutas:
GetPage(name: '/home', page: () => const CatalogPage(), binding: HomeBinding()),

Los Binding agrupan las dependencias de cada ruta y las destruyen al salir.

Errores frecuentes

  • Get.find<T>() sin registrar: lanza "CatalogController" not found. Usa Get.put o Get.lazyPut antes de llamar a Get.find.
  • Obx sin observables dentro: GetX avisa en consola que el widget no se suscribió a ningún observable. Comprueba que accedes a .value o usas propiedades .obs.
  • GetMaterialApp olvidado: sin él, Get.snackbar y la navegación sin contexto no funcionan.

Aplicación práctica

GetX es popular en apps donde la velocidad de desarrollo importa más que la arquitectura estricta: prototipos, apps medianas, equipos pequeños. Para proyectos grandes con test unitario extenso, BLoC o Riverpod ofrecen mejor separación de responsabilidades.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿GetX reemplaza completamente a Provider o Riverpod?

Sí, en funcionalidad lo cubre todo: estado, DI y navegación. Pero sacrifica testabilidad: los controladores con Get.find global son difíciles de aislar en tests unitarios. Provider y Riverpod son más seguros para proyectos grandes.

¿Qué diferencia hay entre Obx y GetX?

Obx(() => ...) es el builder reactivo simple. GetX<Controller>(builder: ...) accede al controlador directamente desde el builder, útil si necesitas llamar métodos del controlador dentro del widget sin Get.find.

¿Puedo usar GetX solo para inyección de dependencias sin la navegación?

Sí. Puedes mantener MaterialApp normal y usar únicamente Get.put/Get.find para DI, sin GetMaterialApp. Así combinas GetX con tu sistema de rutas existente (go_router, etc.).