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.
---
smart-todo).
---
asia-southeast1, us-central1). This cannot be changed later.
---
flutter create smart_todo
cd smart_todo
npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli
flutterfire configure
lib/firebase_options.dart.
The flutter pub add command is the recommended way to add dependencies to your project. It automatically adds the packages to your pubspec.yaml file with the correct version and indentation, and then automatically runs flutter pub get for you. This is much safer and faster than editing the pubspec.yaml file directly, as it avoids common YAML syntax errors. You can find the exact names for any dependency on pub.dev.
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<String, dynamic> json)
: id = json['id'],
isDone = json['isDone'] ?? false,
title = json['title'],
description = json['description'];
Map<String, dynamic> 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<TodoListPage> createState() => _TodoListPageState();
}
class _TodoListPageState extends State<TodoListPage> {
final List<TodoItem> _todos = [];
final ValueNotifier<List<TodoItem>> _undoneItems = ValueNotifier([]);
final ValueNotifier<List<TodoItem>> _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<List<TodoItem>>(
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<List<TodoItem>>(
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<AddNewToDoItemPage> createState() => _AddNewToDoItemPageState();
}
class _AddNewToDoItemPageState extends State<AddNewToDoItemPage> {
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();
},
At this point, you have a fully functional app with a real-time Firestore backend. You can now run the app to verify that everything is working as expected:
flutter run
At this state, the app runs with manual input only. You can click on the floating action button on the bottom right, manually input the title and description of a TODO item, and then click Save to see the item saved into your list. You can add, view, and complete items, but it doesn't have any AI assistance yet. In the next steps, we will add the "Smart" part of our Smart TODO app!
(Optional) For an even better experience, open the Firebase Console, navigate to your project, and select Firestore Database. Click on the Data tab, select the todo collection, and watch your TODO items appear and update in the online database in real-time as you interact with the app!
---
Before you can use Gemini AI in your app, you must enable the AI Logic service and optionally App Check for security.
App Check protects your AI Logic endpoint by verifying requests come from your genuine app.
---
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<AiChatPage> createState() => _AiChatPageState();
}
class _AiChatPageState extends State<AiChatPage> {
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<AiChatPage> {
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.
---
At this stage, you have a functioning chatbot, but it's just a "brain in a box"—it can talk, but it can't actually do anything in your app yet.
flutter run
The AI will likely respond politely, saying something like "Okay, I'll remember that!" or "I've noted that down."
Notice that nothing was added to your list. The AI is smart, but it doesn't have "hands" to reach into your database yet.
In the next step, we will enable Agentic AI by giving the model "Tools" (Function Calling) so it can actually manage your Firestore data!
---
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<FunctionDeclaration> 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: {},
),
];
}
The FunctionDeclaration objects are the "blueprints" for the tools you're giving to the AI. They describe the function name, what it does, and exactly what parameters (like title or description) it needs. This metadata allows the Gemini model to decide which tool to use and how to format the data when it needs to perform a task.
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:
The _handleFunctionCall method is where the real work happens. While the model decides to call a function, it cannot access your database directly. Instead, it sends a request back to your app. This method listens for those requests, performs the actual Firestore operations (like adding or listing items), and sends the result back to the AI so it can confirm the action to the user.
void _sendToGemini(String text) async {
var response = await _chatSession.sendMessage(Content.text(text));
// Handle tool calls iteratively
while (response.functionCalls.isNotEmpty) {
final responses = <FunctionResponse>[];
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<Map<String, dynamic>> _handleFunctionCall(FunctionCall call) async {
if (call.name == 'addTodoItem') {
final title = call.args['title'] as String;
final description = call.args['description'] as String;
final item = TodoItem(
id: const UuidV4().generate(),
isDone: false,
title: title,
description: description,
);
await FirebaseFirestore.instance
.collection('todo')
.doc(item.id)
.set(item.toJson());
return {'status': 'success', 'id': item.id};
}
if (call.name == 'listTodoItems') {
final snapshot =
await FirebaseFirestore.instance.collection('todo').get();
final items = snapshot.docs.map((doc) => doc.data()).toList();
return {'items': items};
}
return {'status': 'error', 'message': 'Unknown function'};
}
---
Now that you've enabled tool calling, your AI is no longer just a chatbot—it's an agent capable of managing your tasks!
flutter run
Tap the chat icon to open the Smart AI TODO Manager.
Go back to your main TODO list. You should see the new items added automatically by the AI! You can also check the Firebase Console to see the data appearing in the todo collection.
---
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.