Data layer
One interface, three adapters. Screens never talk to HTTP.
The contract
typedef JsonMap = Map<String, Object?>;
abstract interface class DataProvider {
Future<ListResult> getList(String resource, ListParams params);
Future<JsonMap> getOne (String resource, String id);
Future<JsonMap> create (String resource, JsonMap data);
Future<JsonMap> update (String resource, String id, JsonMap data);
Future<void> delete (String resource, String id);
}
Deliberately untyped at the boundary. The template cannot know your models, and forcing a codegen step on someone evaluating a template is how you lose them in the first ten minutes. Decode into your own types above this line.
ListParams and ListResult
const ListParams({
int page = 1,
int perPage = 25,
SortSpec? sort,
String search = '',
Map<String, Object?> filters = const {},
});
class ListResult {
final List<JsonMap> rows; // this page
final int total; // across ALL pages
}
total is the count across every page, not the length of the
page you were handed. Getting this wrong still renders a table — it just
paginates wrongly, which is the kind of bug nobody notices until a customer
reports missing records.
Switching backend
One line, in lib/core/data/mock_data_provider.dart. Every screen
follows, including the ⌘K command palette.
// The default — no backend needed
final dataProvider = Provider<DataProvider>((ref) => MockDataProvider());
// A conventional REST API
final dataProvider = Provider<DataProvider>(
(ref) => RestDataProvider(
baseUrl: 'https://api.example.com',
headers: {'Authorization': 'Bearer $token'},
),
);
// Supabase
final dataProvider = Provider<DataProvider>(
(ref) => SupabaseDataProvider(
url: 'https://xyzcompany.supabase.co',
anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'),
),
);
MockDataProvider
The most important adapter, because it is what someone sees in the first sixty seconds. It does real work — search, filter, sort and paginate all execute against in-memory tables and return a page plus a total — so your screens exercise the same code paths a real backend will.
Seeded deterministically: 1,284 orders, 642 customers, 312 products. Latency is simulated at 180ms and configurable, so loading states are visible during development instead of flashing past.
MockDataProvider(latency: Duration.zero) // for tests
RestDataProvider
Assumes the shape most JSON APIs already use:
GET /orders?_page=1&_limit=25&_sort=total&_order=desc&q=ada&status=Paid
GET /orders/10004
POST /orders
PATCH /orders/10004
DELETE /orders/10004
The row count comes from an X-Total-Count header, falling back to
a total field on an envelope. Bodies may be a bare array or
{ "data": [...], "total": n } — both are unwrapped.
If your API spells any of that differently, rest_data_provider.dart
is the one file to change. It is about 120 lines.
SupabaseDataProvider
Talks to PostgREST directly over http — deliberately not
supabase_flutter. Pulling a large SDK plus its auth, realtime and
storage into a template most people will re-point at their own backend is a
poor trade. If you want the SDK, this class is the seam to replace.
// Count comes from Content-Range, requested with Prefer: count=exact
Range: 25-49
Content-Range: 25-49/1284
Search needs columns named explicitly — PostgREST has no "search everything".
Edit SupabaseDataProvider.searchColumns for your tables.
Errors
Every adapter throws DataProviderException with a readable
message. Screens render it through ErrorState, which offers a
retry — a spinner that never resolves is worse than an error.
async.when(
loading: () => const LoadingState(),
error: (e, _) => ErrorState(
message: e is DataProviderException ? e.message : '$e',
onRetry: () => ref.invalidate(listProvider(resource)),
),
data: (result) => …,
)
Writing your own
Implement the five methods. The tests in
test/rest_provider_test.dart show the shape — a fake
http.Client, then assertions on query construction, total
parsing, and a 404 becoming an exception rather than a crash. Copy that file
and point it at yours.