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.

1.1 Claim your GCP credit and setup billing account

  1. Go to the Google Cloud Platform website and sign in with your Google account.
  2. If you have a GCP credit coupon code, redeem it at the GCP Education Credits redemption page.
  3. Open the Billing Console and click Create account.
  4. Fill in your personal/business information and a valid payment method.
  5. Link the billing account to the project you will use for Firebase.
  6. Verify that the credit has been applied by checking Billing → Credits.

1.2 Prepare your Firebase project

  1. Go to the Firebase Console.
  2. Click Add project and enter a project name (e.g., smart-todo).
  3. (Optional) Enable Google Analytics.
  4. Wait for Firebase to provision the project, then click Continue.
  5. Link your Firebase project to the GCP billing account:
    • Go to Project settings → Usage and billing → Details & settings.
    • Click Modify plan and upgrade to the Blaze (pay-as-you-go) plan.

      ---

  1. In the Firebase Console, go to Database & Storage → Firestore.
  2. Click Create database.
  3. Choose Test mode for now (open for 30 days; convenient for development).
  4. Select a Cloud Firestore location (e.g., asia-southeast1, us-central1). This cannot be changed later.
  5. Click Enable and wait for the database to be provisioned.

    ---

  1. Create a new Flutter project:
    flutter create smart_todo
       cd smart_todo
  2. Setup Firebase CLI:
    • Install the Firebase CLI (requires Node.js):
      npm install -g firebase-tools
    • Log into Firebase:
      firebase login
  3. Activate FlutterFire CLI:
    • This tool helps automate the Firebase configuration for Flutter:
      dart pub global activate flutterfire_cli
  4. Configure FlutterFire:
    • Run the following command in your project root:
      flutterfire configure
    • Select the Firebase project you created in Step 1.
    • Select the platforms you want to support (Android, iOS, Web, etc.). This will generate lib/firebase_options.dart.
  5. Add the required dependencies:

    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
  6. Initialize Firebase in 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'),
            ),
          ],
        ),
      ),
    );
  }
}

---

6.1 Update Navigation in TodoListPage

To allow users to access the manual entry form, update lib/todo_list_page.dart to navigate to the new page.

  1. Add the import at the top of lib/todo_list_page.dart:
    import 'add_new_todo_item.dart';
  2. Update the 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:

7.1 Update lib/todo_list_page.dart

Replace 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});
}

7.2 Update lib/add_new_todo_item.dart

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

8.1 Enable AI Logic

  1. Open the Firebase Console and select your project.
  2. In the left sidebar, go to AI Service → AI Logic.
  3. Click Get started.
    • Note: Enabling AI services requires your project to be on the Blaze (pay-as-you-go) plan. If you haven't already, you will need to upgrade and link a Google Cloud billing account.
  4. Choose Vertex AI API for this tutorial (simpler for prototyping).
  5. Click Enable required APIs. Firebase will enable the necessary Google Cloud APIs and provision a backend service.
  6. Wait for the setup to complete until you see "AI Logic is enabled".

8.2 (Recommended) Enable Firebase App Check

App Check protects your AI Logic endpoint by verifying requests come from your genuine app.

  1. In the Firebase Console, go to Security → App Check.
  2. Register your apps (Android/iOS/Web) with an attestation provider (e.g., Play Integrity, App Attest, or reCAPTCHA).
  3. For development, use the Debug provider so your local requests aren't blocked.
  4. In the AI Logic section of App Check, set enforcement to Enforced once your app is ready.

    ---

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.

9.1 Update Navigation in TodoListPage

Now let's change the FloatingActionButton in lib/todo_list_page.dart to open the AI Chat instead of the manual form.

  1. Update the imports in lib/todo_list_page.dart:
    import 'ai_chat_page.dart';
  2. Update the 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.

  1. Run the app:
    flutter run
  2. Open the AI Chat and try a prompt like:
    • "Remind me to buy milk tomorrow."
  3. Observe the result:

    The AI will likely respond politely, saying something like "Okay, I'll remember that!" or "I've noted that down."

  4. Check your TODO list:

    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.

  1. Add imports at the top of the file:
    import 'package:cloud_firestore/cloud_firestore.dart';
    import 'package:smart_todo/data/todo_item.dart';
    
    part './extensions/tool_function_declaration_extension.dart';
  2. Update 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();
      }
  3. Update _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!

  1. Run your app:
    flutter run
  2. Navigate to the AI Chat:

    Tap the chat icon to open the Smart AI TODO Manager.

  3. Try these sample prompts:
    • "Remind me to buy groceries tomorrow morning."
    • "Add a task to call my mom at 5 PM."
    • "List all my current TODO items."
  4. Verify the results:

    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.