In this codelab, you will build a TODO application that uses Google's Gemini models via Vertex AI for Firebase to help you manage your tasks. You'll learn how to integrate Firebase, build a chat interface, and use "Tool Calling" to turn an AI into a functional agent that can actually edit your data.
---
flutter create smart_todo
cd smart_todo
npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli
smart-todo-12345).
with your actual Firebase Project ID:
flutterfire configure --project=
lib/firebase_options.dart.
flutter pub add uuid firebase_core cloud_firestore firebase_ai flutter_chat_ui flutter_chat_types flutter_chat_core
lib/main.dart:
Replace the content of lib/main.dart with:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:smart_todo/firebase_options.dart';
import 'package:smart_todo/todo_list_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Smart TODO',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const TodoListPage(),
);
}
}
NOTE: there will be syntax errors in the code above, but this is expected. We will build the UI for TodoListPage later.
---
Create lib/data/todo_item.dart:
final class TodoItem {
final String id;
final bool isDone;
final String title;
final String description;
TodoItem({required this.id, required this.isDone, required this.title, required this.description});
TodoItem update({required bool status}) => TodoItem(id: id, isDone: status, title: title, description: description);
TodoItem.fromJson(Map json)
: id = json['id'],
isDone = json['isDone'] ?? false,
title = json['title'],
description = json['description'];
Map toJson() => {'id': id, 'isDone': isDone, 'title': title, 'description': description};
}
---
Create lib/todo_list_page.dart. This version contains the UI structure but no Firestore logic. You will add the Firestore synchronization in the next step.
import 'package:flutter/material.dart';
import 'package:smart_todo/data/todo_item.dart';
typedef UpdateTodoItem = void Function(TodoItem item, bool status);
class TodoListPage extends StatefulWidget {
const TodoListPage({super.key});
@override
State createState() => _TodoListPageState();
}
class _TodoListPageState extends State {
final List _todos = [];
final ValueNotifier> _undoneItems = ValueNotifier([]);
final ValueNotifier> _completedItems = ValueNotifier([]);
@override
void initState() {
_prepareSyncData();
super.initState();
}
void _prepareSyncData() async {
// TODO: Implement Firestore listener here
}
@override
void dispose() {
_undoneItems.dispose();
_completedItems.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Smart TODO List')),
body: SingleChildScrollView(
child: Column(
children: [
ExpansionTile(
title: const Text('TODO items'),
initiallyExpanded: true,
children: [
ValueListenableBuilder>(
valueListenable: _undoneItems,
builder: (context, undoneItems, child) => ListView.builder(
itemBuilder: (context, index) => TodoItemView(
item: undoneItems[index],
onChange: _updateItem,
),
itemCount: undoneItems.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
),
),
],
),
ExpansionTile(
title: const Text('Completed items'),
children: [
ValueListenableBuilder>(
valueListenable: _completedItems,
builder: (context, completedItems, child) => ListView.builder(
itemBuilder: (context, index) => TodoItemView(
item: completedItems[index],
onChange: _updateItem,
),
itemCount: completedItems.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
),
),
],
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO navigate to Add new todo item page
},
child: const Icon(Icons.wechat_outlined),
),
);
}
void _updateItem(TodoItem item, bool status) {
// TODO: Implement Firestore update here
}
void _updateItemSorting() {
_undoneItems.value = _todos.where((element) => !element.isDone).toList();
_completedItems.value = _todos.where((element) => element.isDone).toList();
}
}
class TodoItemView extends StatelessWidget {
const TodoItemView({required this.item, required this.onChange, super.key});
final TodoItem item;
final UpdateTodoItem onChange;
@override
Widget build(BuildContext context) {
return CheckboxListTile.adaptive(
value: item.isDone,
onChanged: (status) => onChange(item, status ?? false),
title: Text(item.title),
subtitle: Text(item.description),
);
}
}
---
Create lib/add_new_todo_item.dart to allow users to add TODO items manually via a simple form. The Firestore save is left as a // TODO — you will implement it in the next step.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:smart_todo/data/todo_item.dart';
import 'package:uuid/v4.dart';
class AddNewToDoItemPage extends StatefulWidget {
const AddNewToDoItemPage({super.key});
@override
State createState() => _AddNewToDoItemPageState();
}
class _AddNewToDoItemPageState extends State {
final TextEditingController _title = TextEditingController();
final TextEditingController _description = TextEditingController();
@override
void dispose() {
_title.dispose();
_description.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add new Todo Item')),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
TextField(
controller: _title,
decoration: const InputDecoration(
labelText: 'Title',
hintText: 'Enter your TODO title',
border: UnderlineInputBorder(),
),
),
TextField(
controller: _description,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'Enter your TODO details',
border: UnderlineInputBorder(),
),
),
TextButton(
onPressed: () {
final title = _title.text;
final description = _description.text;
if (title.isEmpty || description.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Title and Description are needed')),
);
return;
}
final item = TodoItem(
id: const UuidV4().generate(),
isDone: false,
title: title,
description: description,
);
// TODO add Firestore implementation here
},
child: const Text('Save'),
),
],
),
),
);
}
}
---
TodoListPageTo allow users to access the manual entry form, update lib/todo_list_page.dart to navigate to the new page.
lib/todo_list_page.dart:
import 'add_new_todo_item.dart';
floatingActionButton logic:
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const AddNewToDoItemPage()),
);
},
child: const Icon(Icons.add),
),
---
Now that the UI scaffolding is in place, it's time to wire up real-time Firestore data. In this step you will:
todo_list_page.dart so the UI updates automatically
todo_list_page.dart
add_new_todo_item.dart
lib/todo_list_page.dartReplace the two // TODO placeholders with the following implementations.
_prepareSyncData() — listen to the Firestore todo collection in real time using .withConverter() for type-safe deserialization:
void _prepareSyncData() async {
FirebaseFirestore.instance
.collection('todo')
.withConverter(
fromFirestore: (snapshot, options) =>
TodoItem.fromJson(snapshot.data() ?? {}),
toFirestore: (value, options) => value.toJson(),
)
.snapshots()
.listen((snapshot) {
_todos
..clear()
..addAll(snapshot.docs.map((doc) => doc.data()));
_updateItemSorting();
});
}
_updateItem() — write the new completion status back to Firestore when a checkbox is tapped:
void _updateItem(TodoItem item, bool status) {
FirebaseFirestore.instance
.collection('todo')
.doc(item.id)
.update({'isDone': status});
}
lib/add_new_todo_item.dartReplace the // TODO comment inside the onPressed handler with the actual Firestore save call. Also make the callback async:
onPressed: () async {
// ... validation ...
await FirebaseFirestore.instance
.collection('todo')
.doc(item.id)
.set(item.toJson());
if (mounted) Navigator.of(context).pop();
},
---
Create lib/ai_chat_page.dart. In this step you will build only the UI shell for the AI chat screen using the flutter_chat_ui package. You will wire up the actual Gemini AI integration in the next step.
import 'package:flutter/material.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:uuid/v4.dart';
class AiChatPage extends StatefulWidget {
const AiChatPage({super.key});
@override
State createState() => _AiChatPageState();
}
class _AiChatPageState extends State {
final String _userId = 'user';
final ChatController _chatController = InMemoryChatController();
@override
void dispose() {
_chatController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Smart AI TODO Manager')),
body: Chat(
currentUserId: _userId,
resolveUser: (id) => Future.value(User(id: id)),
chatController: _chatController,
onMessageSend: (text) {
// Add the user's message to the chat UI immediately.
_chatController.insertMessage(
TextMessage(
id: const UuidV4().generate(),
text: text,
authorId: _userId,
),
);
// TODO: Send the message to Gemini in the next step.
},
),
);
}
}
At this point you can run the app and navigate to the AI Chat page. You should see a fully functional chat UI where you can type messages and they will appear in the conversation — but no AI response will be sent yet. That comes in the next step.
TodoListPageNow let's change the FloatingActionButton in lib/todo_list_page.dart to open the AI Chat instead of the manual form.
lib/todo_list_page.dart:
import 'ai_chat_page.dart';
floatingActionButton:
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const AiChatPage()),
);
},
child: const Icon(Icons.wechat_outlined),
),
---
Now let's wire up the chat UI to the Gemini model. Update lib/ai_chat_page.dart to initialize the Vertex AI model and send messages to it.
Add the firebase_ai import and update your _AiChatPageState class:
import 'package:firebase_ai/firebase_ai.dart';
// ... other imports
class _AiChatPageState extends State {
final String _userId = 'user';
final String _modelId = 'model'; // Add model ID
final ChatController _chatController = InMemoryChatController();
late final ChatSession _chatSession; // Add chat session
@override
void initState() {
super.initState();
// Initialize the Gemini model
_chatSession = FirebaseAI.vertexAI()
.generativeModel(model: 'gemini-2.5-flash-lite')
.startChat();
}
// ... keep dispose method unchanged
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Smart AI TODO Manager')),
body: Chat(
currentUserId: _userId,
resolveUser: (id) => Future.value(User(id: id)),
chatController: _chatController,
onMessageSend: (text) {
_chatController.insertMessage(
TextMessage(
id: const UuidV4().generate(),
text: text,
authorId: _userId,
),
);
// Replace the TODO with a call to Gemini
_sendToGemini(text);
},
),
);
}
// Add this new method to handle sending and receiving messages
void _sendToGemini(String text) async {
final response = await _chatSession.sendMessage(Content.text(text));
final textResponse = response.text;
if (textResponse != null) {
_chatController.insertMessage(
TextMessage(
id: const UuidV4().generate(),
authorId: _modelId,
text: textResponse,
),
);
}
}
}
If you run the app now, the AI chat will work! However, it doesn't know about your TODO items yet.
---
To make the AI capable of managing your TODOs, define the "system instruction" and "Tools" in a new file lib/extensions/tool_function_declaration_extension.dart:
part of '../ai_chat_page.dart';
extension _ToolFunctionDeclarationExtension on _AiChatPageState {
String get _systemInstruction => '''
You are a smart TODO manager. You can help users add, list and manage their TODO items.
Be concise and helpful. Use the provided tools to interact with the TODO list.
''';
List get _functions => [
FunctionDeclaration(
'addTodoItem',
'Add a new todo item to the list',
parameters: {
'title': Schema.string(description: 'The title of the todo item'),
'description': Schema.string(
description: 'The description of the todo item',
),
},
),
FunctionDeclaration(
'listTodoItems',
'List all todo items',
parameters: {},
),
];
}
Next, update lib/ai_chat_page.dart to register these tools and handle function calls.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:smart_todo/data/todo_item.dart';
part './extensions/tool_function_declaration_extension.dart';
initState to provide the model with the tools and system instruction:
@override
void initState() {
super.initState();
_chatSession = FirebaseAI.vertexAI()
.generativeModel(
model: 'gemini-2.5-flash-lite',
systemInstruction: Content.system(_systemInstruction),
tools: [Tool.functionDeclarations(_functions)],
toolConfig: ToolConfig(
functionCallingConfig: FunctionCallingConfig.auto(),
),
generationConfig: GenerationConfig(candidateCount: 1),
)
.startChat();
}
_sendToGemini and add _handleFunctionCall to process tool requests from the model:
void _sendToGemini(String text) async {
var response = await _chatSession.sendMessage(Content.text(text));
// Handle tool calls iteratively
while (response.functionCalls.isNotEmpty) {
final responses = [];
for (final call in response.functionCalls) {
final result = await _handleFunctionCall(call);
responses.add(FunctionResponse(call.name, result));
}
response = await _chatSession.sendMessage(
Content.functionResponses(responses),
);
}
final textResponse = response.text;
if (textResponse != null) {
_chatController.insertMessage(
TextMessage(
id: const UuidV4().generate(),
authorId: _modelId,
text: textResponse,
),
);
}
}
Future
---
You now have a Smart TODO app where you can say "Hey, remind me to buy milk tomorrow" and the AI will actually create the record in your database!
Here is a challenge for you: Add more tools to let Gemini mark a task as completed or incomplete, or delete a task, or edit a task, and so on.
You can also download the full source code from this GitHub repository to try it out or compare your implementation with the final version.