Supabase en Flutter: ejercicio resuelto con auth y CRUD de tareas

Supabase en Flutter: ejercicio resuelto con auth y CRUD

Supabase es la alternativa open-source a Firebase más usada en 2025-26. Combina base de datos PostgreSQL, autenticación, almacenamiento y funciones edge en un solo servicio. supabase_flutter proporciona un cliente oficial que gestiona sesión, tokens y comunicación con la API de forma reactiva.

Enunciado

Implementa una app de tareas que:

  • Permita registro e inicio de sesión con email y contraseña.
  • Muestre la pantalla de tareas solo cuando haya sesión activa.
  • Cree, complete y elimine tareas en Supabase.
  • Aplique Row Level Security para que cada usuario solo vea sus propias tareas.
  • Maneje errores de autenticación con mensajes legibles.

Dependencias

1
2
3
4
dependencies:
  flutter:
    sdk: flutter
  supabase_flutter: ^2.8.0

Configuración

1. Crear proyecto en Supabase (supabase.com) y copiar la URL y la anon key del panel Settings → API.

2. Crear la tabla tasks en el SQL Editor de Supabase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
create table tasks (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid references auth.users not null,
  title      text not null,
  done       boolean not null default false,
  created_at timestamptz default now()
);

-- Activar Row Level Security
alter table tasks enable row level security;

-- Política: cada usuario solo accede a sus propias tareas
create policy "crud_own_tasks"
  on tasks
  for all
  using  (auth.uid() = user_id)
  with check (auth.uid() = user_id);

3. Inicializar Supabase antes de runApp:

1
2
3
4
await Supabase.initialize(
  url: 'https://TU_PROYECTO.supabase.co',
  anonKey: 'tu_anon_key',
);

En producción guarda las credenciales con --dart-define o variables de entorno, nunca en el código fuente.

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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

// ── Punto de entrada ──────────────────────────────────────────────────────────
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Supabase.initialize(
    url: const String.fromEnvironment('SUPABASE_URL'),
    anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'),
  );
  runApp(const MyApp());
}

// Acceso global al cliente (patrón oficial de supabase_flutter)
final supabase = Supabase.instance.client;

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Supabase Tasks',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),
      home: const AuthGate(),
    );
  }
}

// ── AuthGate: muestra Login o Tasks según el estado de sesión ─────────────────
class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<AuthState>(
      stream: supabase.auth.onAuthStateChange,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }
        final session = snapshot.data!.session;
        return session != null ? const TasksPage() : const LoginPage();
      },
    );
  }
}

// ── Modelo ────────────────────────────────────────────────────────────────────
class Task {
  final String id;
  final String title;
  final bool done;

  const Task({required this.id, required this.title, required this.done});

  factory Task.fromJson(Map<String, dynamic> json) => Task(
        id: json['id'] as String,
        title: json['title'] as String,
        done: json['done'] as bool,
      );
}

// ── LoginPage ─────────────────────────────────────────────────────────────────
class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailCtrl = TextEditingController();
  final _passCtrl = TextEditingController();
  bool _loading = false;

  Future<void> _signIn() async {
    setState(() => _loading = true);
    try {
      await supabase.auth.signInWithPassword(
        email: _emailCtrl.text.trim(),
        password: _passCtrl.text,
      );
    } on AuthException catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(SnackBar(content: Text(e.message)));
      }
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  Future<void> _signUp() async {
    setState(() => _loading = true);
    try {
      await supabase.auth.signUp(
        email: _emailCtrl.text.trim(),
        password: _passCtrl.text,
      );
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
              content: Text('Revisa tu email para confirmar el registro')),
        );
      }
    } on AuthException catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(SnackBar(content: Text(e.message)));
      }
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  void dispose() {
    _emailCtrl.dispose();
    _passCtrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Acceder a Supabase Tasks')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _emailCtrl,
              decoration: const InputDecoration(labelText: 'Email'),
              keyboardType: TextInputType.emailAddress,
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _passCtrl,
              decoration: const InputDecoration(labelText: 'Contraseña'),
              obscureText: true,
            ),
            const SizedBox(height: 24),
            if (_loading)
              const CircularProgressIndicator()
            else
              Row(
                children: [
                  Expanded(
                    child: FilledButton(
                      onPressed: _signIn,
                      child: const Text('Entrar'),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Expanded(
                    child: OutlinedButton(
                      onPressed: _signUp,
                      child: const Text('Registrarse'),
                    ),
                  ),
                ],
              ),
          ],
        ),
      ),
    );
  }
}

// ── TasksPage ─────────────────────────────────────────────────────────────────
class TasksPage extends StatefulWidget {
  const TasksPage({super.key});

  @override
  State<TasksPage> createState() => _TasksPageState();
}

class _TasksPageState extends State<TasksPage> {
  List<Task> _tasks = [];
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    _loadTasks();
  }

  Future<void> _loadTasks() async {
    setState(() => _loading = true);
    final data = await supabase
        .from('tasks')
        .select()
        .order('created_at', ascending: false);
    if (mounted) {
      setState(() {
        _tasks = (data as List).map((e) => Task.fromJson(e)).toList();
        _loading = false;
      });
    }
  }

  Future<void> _addTask(String title) async {
    await supabase.from('tasks').insert({
      'title': title,
      'done': false,
      'user_id': supabase.auth.currentUser!.id,
    });
    await _loadTasks();
  }

  Future<void> _toggleTask(Task task) async {
    await supabase
        .from('tasks')
        .update({'done': !task.done})
        .eq('id', task.id);
    await _loadTasks();
  }

  Future<void> _deleteTask(String id) async {
    await supabase.from('tasks').delete().eq('id', id);
    await _loadTasks();
  }

  void _showAddDialog() {
    final ctrl = TextEditingController();
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Nueva tarea'),
        content: TextField(
          controller: ctrl,
          autofocus: true,
          decoration: const InputDecoration(hintText: 'Descripción'),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: const Text('Cancelar'),
          ),
          FilledButton(
            onPressed: () async {
              Navigator.pop(ctx);
              if (ctrl.text.trim().isNotEmpty) {
                await _addTask(ctrl.text.trim());
              }
            },
            child: const Text('Añadir'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Mis tareas'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            tooltip: 'Cerrar sesión',
            onPressed: () => supabase.auth.signOut(),
          ),
        ],
      ),
      body: _loading
          ? const Center(child: CircularProgressIndicator())
          : _tasks.isEmpty
              ? const Center(
                  child: Text('Sin tareas. Pulsa + para añadir una.'))
              : ListView.builder(
                  itemCount: _tasks.length,
                  itemBuilder: (context, index) {
                    final task = _tasks[index];
                    return ListTile(
                      leading: Checkbox(
                        value: task.done,
                        onChanged: (_) => _toggleTask(task),
                      ),
                      title: Text(
                        task.title,
                        style: task.done
                            ? const TextStyle(
                                decoration: TextDecoration.lineThrough)
                            : null,
                      ),
                      trailing: IconButton(
                        icon: const Icon(Icons.delete_outline),
                        onPressed: () => _deleteTask(task.id),
                      ),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showAddDialog,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Conceptos clave

APIPropósito
Supabase.initialize()Inicializa el cliente global una vez al arrancar
supabase.auth.onAuthStateChangeStream reactivo del estado de sesión
supabase.auth.signInWithPassword()Login con email y contraseña
supabase.auth.signUp()Registro de nuevo usuario
supabase.auth.signOut()Cierra la sesión activa
supabase.from('tabla').select()Lee registros (respeta RLS automáticamente)
supabase.from('tabla').insert({})Crea un registro
supabase.from('tabla').update({}).eq()Actualiza por condición
supabase.from('tabla').delete().eq()Elimina por condición
AuthExceptionError tipado de autenticación (mensaje legible)

Errores frecuentes

  • Credenciales en código fuente: usa --dart-define o un gestor de secretos. La anon key de Supabase no es un secreto absoluto (está protegida por RLS), pero la URL sí debe controlarse en producción.
  • Olvidar await en operaciones de escritura: insert, update y delete son Future; sin await no verás errores y el estado no se actualizará.
  • RLS sin política: si activas RLS sin crear políticas, todas las consultas devuelven 0 registros aunque existan datos — el comportamiento es correcto por diseño, pero confunde al no estar documentado.
  • No usar mounted tras await: el widget puede haberse desmontado durante la operación asíncrona; comprueba mounted antes de llamar a setState o ScaffoldMessenger.

Aplicación práctica

Este patrón es la base de cualquier app con usuarios y datos propios: gestores de notas, CRMs ligeros, apps de seguimiento personal o paneles de administración internos. Supabase añade suscripciones en tiempo real con supabase.from('tasks').stream() cuando necesitas actualizaciones instantáneas sin recargar.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Supabase es gratis para proyectos pequeños?

Sí. El plan gratuito incluye 500 MB de base de datos, 50.000 usuarios activos al mes y 2 GB de almacenamiento. Es suficiente para MVPs y proyectos personales.

¿Cuál es la diferencia principal con Firebase?

Firebase usa una base de datos NoSQL (Firestore) mientras que Supabase usa PostgreSQL relacional. Supabase es open-source y puede autoalojarse; Firebase es un servicio de Google sin opción de auto-hosting.

¿Cómo protejo la anon key en producción?

La anon key no es un secreto crítico porque el acceso a los datos está controlado por Row Level Security. Lo que sí debes proteger son las operaciones de rol service_role, que nunca deben estar en el cliente.