8.7 KiB
| title | description |
|---|---|
| Flutter UI Integration | Wire Drift databases into Flutter widgets with Provider or Riverpod |
Provider Package
Use this pattern when the app uses the provider package.
Provider<AppDatabase>(
create: (_) => AppDatabase(),
dispose: (_, database) => database.close(),
child: const MyApp(),
)
Read the database from widgets with Provider.of or context.read.
final database = Provider.of<AppDatabase>(context, listen: false);
Riverpod
Use this pattern when the app uses flutter_riverpod.
final databaseProvider = Provider<AppDatabase>((ref) {
final database = AppDatabase();
ref.onDispose(database.close);
return database;
});
final todosProvider = StreamProvider<List<TodoItem>>((ref) {
final database = ref.watch(databaseProvider);
return (database.select(database.todoItems)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.watch();
});
Consume AsyncValue with when, not as a list.
class TodoList extends ConsumerWidget {
const TodoList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todosProvider);
return todosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Text('Error: $error'),
data: (todos) {
if (todos.isEmpty) {
return const Center(child: Text('No todos yet'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoTile(todo: todos[index]);
},
);
},
);
}
}
StreamBuilder
Use StreamBuilder directly when the widget owns the query and no state-management wrapper is needed.
class TodoList extends StatelessWidget {
const TodoList({required this.database, super.key});
final AppDatabase database;
@override
Widget build(BuildContext context) {
return StreamBuilder<List<TodoItem>>(
stream: (database.select(database.todoItems)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.watch(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final todos = snapshot.data ?? [];
if (todos.isEmpty) {
return const Center(child: Text('No todos yet'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoTile(todo: todos[index]);
},
);
},
);
}
}
Todo Tile
Use partial updates for checkbox changes. Do not replace a full row unless every non-nullable field is present.
class TodoTile extends StatelessWidget {
const TodoTile({required this.todo, super.key});
final TodoItem todo;
@override
Widget build(BuildContext context) {
final database = Provider.of<AppDatabase>(context, listen: false);
return CheckboxListTile(
value: todo.isCompleted,
title: Text(todo.title),
subtitle: todo.content == null ? null : Text(todo.content!),
onChanged: (value) async {
await (database.update(database.todoItems)
..where((t) => t.id.equals(todo.id)))
.write(
TodoItemsCompanion(
isCompleted: Value(value ?? false),
),
);
},
secondary: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
await (database.delete(database.todoItems)
..where((t) => t.id.equals(todo.id)))
.go();
},
),
);
}
}
Add Todo Dialog
Dispose controllers in a StatefulWidget and insert with a companion.
class AddTodoDialog extends StatefulWidget {
const AddTodoDialog({super.key});
@override
State<AddTodoDialog> createState() => _AddTodoDialogState();
}
class _AddTodoDialogState extends State<AddTodoDialog> {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final database = Provider.of<AppDatabase>(context, listen: false);
return AlertDialog(
title: const Text('Add Todo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: _contentController,
decoration: const InputDecoration(labelText: 'Notes'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
final title = _titleController.text.trim();
if (title.isEmpty) return;
await database.into(database.todoItems).insert(
TodoItemsCompanion.insert(
title: title,
content: Value(
_contentController.text.trim().isEmpty
? null
: _contentController.text.trim(),
),
),
);
if (context.mounted) {
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
);
}
}
Filtered List
For nullable filters, branch the query so null means the intended behavior.
Stream<List<TodoItem>> watchTodosByCategory(
AppDatabase database,
int? categoryId,
) {
final query = database.select(database.todoItems)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]);
if (categoryId == null) {
query.where((t) => t.category.isNull());
} else {
query.where((t) => t.category.equals(categoryId));
}
return query.watch();
}
Search With Debounce
Debounce the input state, then rebuild the query from the debounced value.
class SearchTodoList extends StatefulWidget {
const SearchTodoList({required this.database, super.key});
final AppDatabase database;
@override
State<SearchTodoList> createState() => _SearchTodoListState();
}
class _SearchTodoListState extends State<SearchTodoList> {
final _searchController = TextEditingController();
Timer? _debounce;
String _query = '';
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final stream = (widget.database.select(widget.database.todoItems)
..where((t) => t.title.contains(_query))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.watch();
return Column(
children: [
TextField(
controller: _searchController,
decoration: const InputDecoration(labelText: 'Search todos'),
onChanged: (value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
setState(() => _query = value.trim());
});
},
),
Expanded(
child: StreamBuilder<List<TodoItem>>(
stream: stream,
builder: (context, snapshot) {
final todos = snapshot.data ?? [];
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoTile(todo: todos[index]);
},
);
},
),
),
],
);
}
}
Pagination
For simple pagination, load pages with limit and offset. For infinite scroll, keep an accumulated list in state instead of replacing the list with only the last page.
Future<List<TodoItem>> loadTodoPage(
AppDatabase database, {
required int page,
int pageSize = 20,
}) {
return (database.select(database.todoItems)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
..limit(pageSize, offset: page * pageSize))
.get();
}
UI Checklist
- Close the database from the provider owner.
- Keep database objects out of
buildmethods unless they are read from a provider. - Handle loading, empty, error, and data states.
- Use partial companion updates for row edits from controls.
- Do not call broad update or delete statements from row-level UI actions.
- Avoid rebuilding expensive streams on every keystroke; debounce or use provider parameters.