Supadart: Typesafe Supabase Queries in Flutter
Introduction
Developing mobile applications often involves interacting with databases to store and retrieve user data, preferences, and application state. Supabase, a Firebase alternative, provides a robust backend-as-a-service (BaaS) solution with a PostgreSQL database and powerful tools for building real-time applications. Flutter, a popular cross-platform framework, offers an intuitive and efficient way to build native-looking mobile apps.
Combining Supabase with Flutter leads to a powerful development stack, but the process of querying the database can become cumbersome and error-prone without proper safeguards. Supadart emerges as a game-changer, introducing a layer of type safety and code clarity to your Flutter-Supabase interactions.
Why Type Safety Matters
Type safety, in essence, ensures that the data you work with adheres to specific types defined in your code. This helps you catch potential errors early, reducing bugs and making your code more reliable. In the context of Supabase queries, type safety helps prevent:
- Incorrect data types: Attempting to assign a string to an integer field or vice versa.
- Missing fields: Omitting required fields when creating or updating database records.
- Unexpected data formats: Receiving data in a format not compatible with your Flutter models.
Introducing Supadart
Supadart is a powerful library that brings the benefits of type safety to your Supabase queries within Flutter. It provides a clean and intuitive API for defining your database schema, generating typesafe models, and executing queries in a secure and structured manner.
Key Features of Supadart
Schema Definition: Supadart allows you to define your Supabase database schema using a simple declarative syntax. This ensures that your code always reflects the actual database structure.
Automatic Model Generation: Based on your defined schema, Supadart automatically generates Dart models that represent your database tables. These models enforce type safety, preventing accidental type mismatches.
Type-Safe Queries: Supadart offers a type-safe query builder that allows you to write queries using familiar SQL syntax but with the added benefits of type checking and code completion.
Data Validation: Supadart integrates seamlessly with popular validation libraries like "built_value" to ensure data integrity and prevent invalid entries from reaching your database.
Real-Time Updates: Supadart supports real-time updates, allowing your Flutter app to automatically react to changes in the Supabase database.
Getting Started with Supadart
Let's explore a step-by-step guide to integrate Supadart into your Flutter project and start building type-safe Supabase queries.
1. Set Up Supabase
First, you need a Supabase project. If you don't have one, create a free account at https://supabase.com/. Create a new database and define your tables and their corresponding columns.
2. Set Up Flutter Project
Create a new Flutter project using the flutter create
command.
3. Install Supadart
Add the Supadart package to your pubspec.yaml
file:
dependencies:
# ... other dependencies
supadart: ^x.x.x # Replace with the latest version
Run flutter pub get
to fetch the package.
4. Define Your Database Schema
Create a new file, for example, schema.dart
, to define your database schema using Supadart's syntax:
import 'package:supadart/supadart.dart';
final schema = Schema(
tables: {
'users': Table(
columns: {
'id': Column(type: ColumnType.text, isPrimaryKey: true),
'username': Column(type: ColumnType.text),
'email': Column(type: ColumnType.text),
},
),
},
);
5. Generate Typesafe Models
Run the following command to generate type-safe Dart models from your schema:
flutter pub run build_runner build
6. Initialize Supabase Client
In your main app file, initialize the Supabase client using your project's credentials:
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supadart/supadart.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Replace with your actual Supabase credentials
await Supabase.initialize(
url: 'your-supabase-url',
anonKey: 'your-supabase-anon-key',
);
runApp(MyApp());
}
7. Use Supadart for Type-Safe Queries
Now you can use Supadart to interact with your database in a type-safe manner. Let's look at some examples:
a) Fetching Data
import 'package:flutter/material.dart';
import 'package:supadart/supadart.dart';
class UserList extends StatefulWidget {
@override
_UserListState createState() => _UserListState();
}
class _UserListState extends State
<userlist>
{
final _supabase = Supabase.instance.client;
Future
<list<user>
> _fetchUsers() async {
final query = _supabase.from('users').select('*');
final response = await query.execute();
return response.map((row) => User.fromJson(row)).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Users')),
body: FutureBuilder
<list<user>
>(
future: _fetchUsers(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final user = snapshot.data![index];
return ListTile(
title: Text(user.username),
subtitle: Text(user.email),
);
},
);
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
);
}
}
b) Inserting Data
import 'package:flutter/material.dart';
import 'package:supadart/supadart.dart';
class AddUser extends StatefulWidget {
@override
_AddUserState createState() => _AddUserState();
}
class _AddUserState extends State
<adduser>
{
final _supabase = Supabase.instance.client;
final _formKey = GlobalKey
<formstate>
();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
Future
<void>
_addUser() async {
final isValid = _formKey.currentState!.validate();
if (isValid) {
final user = User(
username: _usernameController.text,
email: _emailController.text,
);
final response = await _supabase.from('users').insert(user.toJson());
// Handle the response (e.g., show a success message)
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Add User')),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: _usernameController,
decoration: InputDecoration(labelText: 'Username'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username';
}
return null;
},
),
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an email';
}
return null;
},
),
ElevatedButton(
onPressed: _addUser,
child: Text('Add User'),
),
],
),
),
),
);
}
}
c) Updating Data
import 'package:flutter/material.dart';
import 'package:supadart/supadart.dart';
class EditUser extends StatefulWidget {
final User user;
const EditUser({Key? key, required this.user}) : super(key: key);
@override
_EditUserState createState() => _EditUserState();
}
class _EditUserState extends State
<edituser>
{
final _supabase = Supabase.instance.client;
final _formKey = GlobalKey
<formstate>
();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
@override
void initState() {
super.initState();
_usernameController.text = widget.user.username;
_emailController.text = widget.user.email;
}
Future
<void>
_updateUser() async {
final isValid = _formKey.currentState!.validate();
if (isValid) {
final updatedUser = User(
id: widget.user.id,
username: _usernameController.text,
email: _emailController.text,
);
final response = await _supabase
.from('users')
.update(updatedUser.toJson())
.eq('id', widget.user.id);
// Handle the response (e.g., show a success message)
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Edit User')),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: _usernameController,
decoration: InputDecoration(labelText: 'Username'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username';
}
return null;
},
),
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an email';
}
return null;
},
),
ElevatedButton(
onPressed: _updateUser,
child: Text('Update User'),
),
],
),
),
),
);
}
}
d) Deleting Data
import 'package:flutter/material.dart';
import 'package:supadart/supadart.dart';
class DeleteUser extends StatefulWidget {
final User user;
const DeleteUser({Key? key, required this.user}) : super(key: key);
@override
_DeleteUserState createState() => _DeleteUserState();
}
class _DeleteUserState extends State
<deleteuser>
{
final _supabase = Supabase.instance.client;
Future
<void>
_deleteUser() async {
final response = await _supabase
.from('users')
.delete()
.eq('id', widget.user.id);
// Handle the response (e.g., navigate back to the previous screen)
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Delete User')),
body: Center(
child: ElevatedButton(
onPressed: _deleteUser,
child: Text('Delete User'),
),
),
);
}
}
Example Application
Let's bring these concepts together in a simple example application that allows users to manage a list of tasks:
import 'package:flutter/material.dart';
import 'package:supadart/supadart.dart';
// Schema definition (schema.dart)
final schema = Schema(
tables: {
'tasks': Table(
columns: {
'id': Column(type: ColumnType.text, isPrimaryKey: true),
'title': Column(type: ColumnType.text),
'description': Column(type: ColumnType.text),
'isCompleted': Column(type: ColumnType.boolean),
},
),
},
);
// Generated model (models.dart)
class Task extends Object with Mappable {
final String id;
final String title;
final String description;
final bool isCompleted;
Task({
required this.id,
required this.title,
required this.description,
required this.isCompleted,
});
factory Task.fromJson(Map
<string, dynamic="">
json) => _$TaskFromJson(json);
Map
<string, dynamic="">
toJson() => _$TaskToJson(this);
}
// Main app (main.dart)
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Replace with your actual Supabase credentials
await Supabase.initialize(
url: 'your-supabase-url',
anonKey: 'your-supabase-anon-key',
);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: TaskList(),
);
}
}
class TaskList extends StatefulWidget {
@override
_TaskListState createState() => _TaskListState();
}
class _TaskListState extends State
<tasklist>
{
final _supabase = Supabase.instance.client;
Future
<list<task>
> _fetchTasks() async {
final query = _supabase.from('tasks').select('*');
final response = await query.execute();
return response.map((row) => Task.fromJson(row)).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Tasks')),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => AddTask()),
);
},
child: Icon(Icons.add),
),
body: FutureBuilder
<list<task>
>(
future: _fetchTasks(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final task = snapshot.data![index];
return ListTile(
title: Text(task.title),
subtitle: Text(task.description),
trailing: Checkbox(
value: task.isCompleted,
onChanged: (value) {
// Update task completion status
},
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EditTask(task: task),
),
);
},
);
},
);
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
);
}
}
class AddTask extends StatefulWidget {
@override
_AddTaskState createState() => _AddTaskState();
}
class _AddTaskState extends State
<addtask>
{
final _supabase = Supabase.instance.client;
final _formKey = GlobalKey
<formstate>
();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
Future
<void>
_addTask() async {
final isValid = _formKey.currentState!.validate();
if (isValid) {
final task = Task(
id: Uuid().v4(), // Generate a unique ID
title: _titleController.text,
description: _descriptionController.text,
isCompleted: false,
);
final response = await _supabase.from('tasks').insert(task.toJson());
// Handle the response (e.g., show a success message)
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Add Task')),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Title'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
TextFormField(
controller: _descriptionController,
decoration: InputDecoration(labelText: 'Description'),
),
ElevatedButton(
onPressed: _addTask,
child: Text('Add Task'),
),
],
),
),
),
);
}
}
class EditTask extends StatefulWidget {
final Task task;
const EditTask({Key? key, required this.task}) : super(key: key);
@override
_EditTaskState createState() => _EditTaskState();
}
class _EditTaskState extends State
<edittask>
{
final _supabase = Supabase.instance.client;
final _formKey = GlobalKey
<formstate>
();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void initState() {
super.initState();
_titleController.text = widget.task.title;
_descriptionController.text = widget.task.description;
}
Future
<void>
_updateTask() async {
final isValid = _formKey.currentState!.validate();
if (isValid) {
final updatedTask = Task(
id: widget.task.id,
title: _titleController.text,
description: _descriptionController.text,
isCompleted: widget.task.isCompleted,
);
final response = await _supabase
.from('tasks')
.update(updatedTask.toJson())
.eq('id', widget.task.id);
// Handle the response (e.g., show a success message)
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Edit Task')),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: InputDecoration(labelText: 'Title'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
TextFormField(
controller: _descriptionController,
decoration: InputDecoration(labelText: 'Description'),
),
ElevatedButton(
onPressed: _updateTask,
child: Text('Update Task'),
),
],
),
),
),
);
}
}
Conclusion
Supadart empowers you to build robust and reliable Flutter applications that interact seamlessly with Supabase. By embracing type safety and leveraging its powerful features, you can streamline your development process, reduce errors, and create code that is easier to maintain and understand.
Best Practices for Using Supadart
- Define Your Schema Thoroughly: A well-defined schema ensures that your code accurately reflects your database structure, preventing unexpected errors.
- Validate Data Input: Use Supadart's integration with validation libraries to enforce data integrity and prevent invalid entries from reaching your database.
- Utilize Real-Time Updates: Leverage Supadart's real-time capabilities to create dynamic and responsive user experiences.
- Follow Code Conventions: Maintain code consistency and readability by adhering to Flutter's coding conventions and best practices.
- Consider Async Operations: Use async/await or FutureBuilder widgets to handle asynchronous database interactions gracefully.
By following these best practices and utilizing Supadart's features effectively, you can unlock the full potential of Supabase within your Flutter projects, building secure, scalable, and user-friendly mobile applications.