Don’t have time for reading? Skip to the example.
Brick is an all-in-one data manager for Flutter that handles querying and uploading between Supabase and local caches like SQLite. Using Brick, developers can focus on implementing the application without worrying about translating or storing their data.
Most significantly, Brick focuses on offline-first data parity: an app should function the same with or without connectivity.
Why Offline?
The worst version of your app is always the unusable one. People use their phones on subways, airplanes, and on sub-3G connections. Building for offline-first provides the best user experience when you can’t guarantee steady bandwidth.
Even if you’re online-only, Brick’s round trip time is drastically shorter because all data from Supabase is stored in a local cache. When you query the same data again, your app retrieves the local copy, reducing the time and expense of a round trip. And, if SQLite isn’t performant enough, Brick also offers a third cache in memory. When requests are made while the app is offline, they’ll be continually retried until the app comes online, ensuring that your local state syncs up to your remote state.
Of course, you can always opt-out of the cache on a request-by-request basis for sensitive or must-be-fresh data.
Getting Started
Create a Flutter app:
_10flutter create my_app
Add the Brick dependencies to your pubspec.yaml
:
_10dependencies:_10 brick_offline_first_with_supabase: ^1.0.0_10 sqflite: ^2.3.0_10 brick_sqlite: ^3.1.0_10 uuid: ^3.0.4_10_10dev_dependencies:_10 brick_offline_first_with_supabase_build: ^1.0.0_10 build_runner: ^2.4.0
Set up directories for Brick’s generated code:
_10mkdir -p lib/brick/adapters lib/brick/db;
Brick synthesizes your remote data to your local data through code generation. From a Supabase table, create Dart fields that match the table’s columns:
_26// Your model definition can live anywhere in lib/**/* as long as it has the .model.dart suffix_26// Assume this file is saved at my_app/lib/src/users/user.model.dart_26_26import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';_26import 'package:brick_sqlite/brick_sqlite.dart';_26import 'package:brick_supabase/brick_supabase.dart';_26import 'package:uuid/uuid.dart';_26_26@ConnectOfflineFirstWithSupabase(_26 supabaseConfig: SupabaseSerializable(tableName: 'users'),_26)_26class User extends OfflineFirstWithSupabaseModel {_26 final String name;_26_26 // Be sure to specify an index that **is not** auto incremented in your table._26 // An offline-first strategy requires distributed clients to create_26 // indexes without fear of collision._26 @Supabase(unique: true)_26 @Sqlite(index: true, unique: true)_26 final String id;_26_26 User({_26 String? id,_26 required this.name,_26 }) : this.id = id ?? const Uuid().v4();_26}
When some (or all) of your models have been defined, generate the code:
_10dart run build_runner build
This will generate adapters to serialize/deserialize to and from Supabase. Migrations for SQLite are also generated for any new, dropped, or changed columns. Check these migrations after they are generated - Brick is smart, but not as smart as you.
After every model change, run this command to ensure your adapters will serialize/deserialize the way they need to.
The Repository
Your application does not need to touch SQLite or Supabase directly. By interacting with this single entrypoint, Brick makes the hard choices under the hood about where to fetch and when to cache while the application code remains consistent in online or offline modes.
Finally, run your app:
_54// Saved in my_app/lib/src/brick/repository.dart_54import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';_54import 'package:brick_sqlite/brick_sqlite.dart';_54// This hide is for Brick's @Supabase annotation; in most cases,_54// supabase_flutter **will not** be imported in application code._54import 'package:brick_supabase/brick_supabase.dart' hide Supabase;_54import 'package:sqflite_common/sqlite_api.dart';_54import 'package:supabase_flutter/supabase_flutter.dart';_54_54import 'brick.g.dart';_54_54class Repository extends OfflineFirstWithSupabaseRepository {_54 static late Repository? _instance;_54_54 Repository._({_54 required super.supabaseProvider,_54 required super.sqliteProvider,_54 required super.migrations,_54 required super.offlineRequestQueue,_54 super.memoryCacheProvider,_54 });_54_54 factory Repository() => _instance!;_54_54 static Future<void> configure(DatabaseFactory databaseFactory) async {_54 final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue(_54 databaseFactory: databaseFactory,_54 );_54_54 await Supabase.initialize(_54 url: supabaseUrl,_54 anonKey: supabaseAnonKey,_54 httpClient: client,_54 );_54_54 final provider = SupabaseProvider(_54 Supabase.instance.client,_54 modelDictionary: supabaseModelDictionary,_54 );_54_54 _instance = Repository._(_54 supabaseProvider: provider,_54 sqliteProvider: SqliteProvider(_54 'my_repository.sqlite',_54 databaseFactory: databaseFactory,_54 modelDictionary: sqliteModelDictionary,_54 ),_54 migrations: migrations,_54 offlineRequestQueue: queue,_54 // Specify class types that should be cached in memory_54 memoryCacheProvider: MemoryCacheProvider(),_54 );_54 }_54}
_11import 'package:my_app/brick/repository.dart';_11import 'package:sqflite/sqflite.dart' show databaseFactory;_11_11Future<void> main() async {_11 await Repository.configure(databaseFactory);_11 // .initialize() does not need to be invoked within main()_11 // It can be invoked from within a state manager or within_11 // an initState()_11 await Repository().initialize();_11 runApp(MyApp());_11}
Usage
The fun part. Brick’s DSL queries are written once and transformed for local and remote integration. For example, to retrieve all users with the name “Thomas”:
_10await Repository().get<User>(query: Query.where('name', 'Thomas'));
Or query by association:
_10// Assuming we had a model `Order` with a `user` association_10await Repository().get<Order>(query: Query.where('user', Where.exact('name', 'Thomas'));
Queries can be much more advanced, leveraging contains
, not
, like
operators as well as sub clauses. Please note that, as of writing, not all Supabase operators are supported.
Reactivity
Beyond async requests, you can subscribe to a stream of updated local data from anywhere in your app (for example, if you pull-to-refresh a list of users, all listeners will be notified of the new data):
_10final Stream<List<User>> usersStream = Repository().subscribe<User>(query: Query.where('name', 'Thomas'));
This does not leverage Supabase’s channels by default; if Supabase updates, your app will not be notified. This opt-in feature is currently under active development.
Upserting
After a model has been created, it can uploaded to Supabase without serializing it to JSON first:
_10await Repository().upsert<User>(User(name: 'Thomas'));
All attached associations will be upserted too.
Other Tips
Foreign Keys/Associations
Easily connect related models/tables:
_25import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';_25import 'package:brick_sqlite/brick_sqlite.dart';_25import 'package:brick_supabase/brick_supabase.dart';_25import 'package:my_app/lib/src/users/user.model.dart';_25import 'package:uuid/uuid.dart';_25_25@ConnectOfflineFirstWithSupabase(_25 supabaseConfig: SupabaseSerializable(tableName: 'orders'),_25)_25class Order extends OfflineFirstWithSupabaseModel {_25 // Like Supabase's client, specifying a foreign_key_25 // is possible but only necessary if there are joins_25 // with multiple foreign keys_25 // @Supabase(foreignKey: 'user_id')_25 final User user;_25_25 @Supabase(unique: true)_25 @Sqlite(index: true, unique: true)_25 final String id;_25_25 Order({_25 String? id,_25 required this.user,_25 }) : this.id = id ?? const Uuid().v4();_25}
Brick allows very granular model configuration - you can specify specific tables, individual columns, and more.
Testing
Quickly mock your Supabase endpoints to add uncluttered unit testing:
_33import 'package:brick_supabase/testing.dart';_33import 'package:test/test.dart'_33_33void main() {_33 // Pass an instance of your model dictionary to the mock server._33 // This permits quick generation of fields and generated responses_33 final mock = SupabaseMockServer(modelDictionary: supabaseModelDictionary);_33_33 group('MyClass', () {_33 setUp(mock.setUp);_33_33 tearDown(mock.tearDown);_33_33 test('#myMethod', () async {_33 // If your request won't exactly match the columns of MyModel, provide_33 // the query list to the `fields:` parameter_33 final req = SupabaseRequest<MyModel>();_33 final resp = SupabaseResponse([_33 // mock.serialize converts models to expected Supabase payloads_33 // but you don't need to use it - any jsonEncode-able object_33 // can be passed to SupabaseRepsonse_33 await mock.serialize(MyModel(name: 'Demo 1', id: '1')),_33 await mock.serialize(MyModel(name: 'Demo 2', id: '2')),_33 ]);_33 // This method stubs the server based on the described requests_33 // and their matched responses_33 mock.handle({req: resp});_33 final provider = SupabaseProvider(mock.client, modelDictionary: supabaseModelDictionary);_33 final retrieved = await provider.get<MyModel>();_33 expect(retrieved, hasLength(2));_33 });_33 });_33}
Further Reading
Brick manages a lot. It can be overwhelming at times. But it’s been used in production across thousands of devices for more than five years, so it’s got a sturdy CV. There’s likely an existing solution to a seemingly novel problem. Please reach out to the community or package maintainers with any questions.
- Example: Brick with Supabase
- Video: Brick Architecture. An explanation of Brick parlance with a supplemental analogy.
- Video: Brick Basics. An overview of essential Brick mechanics.
- Build a User Management App with Flutter