skills/flutter-drift/references/flutter-ui.md

342 lines
8.7 KiB
Markdown

---
title: Flutter UI Integration
description: Wire Drift databases into Flutter widgets with Provider or Riverpod
---
## Provider Package
Use this pattern when the app uses the `provider` package.
```dart
Provider<AppDatabase>(
create: (_) => AppDatabase(),
dispose: (_, database) => database.close(),
child: const MyApp(),
)
```
Read the database from widgets with `Provider.of` or `context.read`.
```dart
final database = Provider.of<AppDatabase>(context, listen: false);
```
## Riverpod
Use this pattern when the app uses `flutter_riverpod`.
```dart
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.
```dart
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.
```dart
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.
```dart
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.
```dart
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.
```dart
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.
```dart
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.
```dart
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 `build` methods 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.