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:

Add the Brick dependencies to your pubspec.yaml:


_10

dependencies:

_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

_10

dev_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:


_10

mkdir -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

_26

import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';

_26

import 'package:brick_sqlite/brick_sqlite.dart';

_26

import 'package:brick_supabase/brick_supabase.dart';

_26

import 'package:uuid/uuid.dart';

_26

_26

@ConnectOfflineFirstWithSupabase(

_26

supabaseConfig: SupabaseSerializable(tableName: 'users'),

_26

)

_26

class 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:


_10

dart 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.

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

_54

import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';

_54

import '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.

_54

import 'package:brick_supabase/brick_supabase.dart' hide Supabase;

_54

import 'package:sqflite_common/sqlite_api.dart';

_54

import 'package:supabase_flutter/supabase_flutter.dart';

_54

_54

import 'brick.g.dart';

_54

_54

class 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

}



_11

import 'package:my_app/brick/repository.dart';

_11

import 'package:sqflite/sqflite.dart' show databaseFactory;

_11

_11

Future<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

}


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”:


_10

await Repository().get<User>(query: Query.where('name', 'Thomas'));


Or query by association:


_10

// Assuming we had a model `Order` with a `user` association

_10

await 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):


_10

final 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:


_10

await Repository().upsert<User>(User(name: 'Thomas'));


All attached associations will be upserted too.

Foreign Keys/Associations#

Easily connect related models/tables:


_25

import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';

_25

import 'package:brick_sqlite/brick_sqlite.dart';

_25

import 'package:brick_supabase/brick_supabase.dart';

_25

import 'package:my_app/lib/src/users/user.model.dart';

_25

import 'package:uuid/uuid.dart';

_25

_25

@ConnectOfflineFirstWithSupabase(

_25

supabaseConfig: SupabaseSerializable(tableName: 'orders'),

_25

)

_25

class 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:


_33

import 'package:brick_supabase/testing.dart';

_33

import 'package:test/test.dart'

_33

_33

void 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

}


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.