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

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