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. Create a new Flutter project:
    flutter create smart_todo
       cd smart_todo
  2. Setup Firebase CLI:
    • Install the Firebase CLI if you haven't already (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. Create a Firebase Project:
    • Go to the Firebase Console.
    • Click Add project and follow the steps to create a new project.
    • Note down your Project ID (e.g., smart-todo-12345).
  5. Configure Firebase in your Flutter app:
    • Run the following command in your project root, replacing with your actual Firebase Project ID:
      flutterfire configure --project=
    • Follow the prompts to select the platforms you want to support (Android, iOS, Web, etc.). This command will generate lib/firebase_options.dart.
  6. Add the required dependencies:
    flutter pub add uuid firebase_core cloud_firestore firebase_ai flutter_chat_ui flutter_chat_types flutter_chat_core
  7. 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 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'),
            ),
          ],
        ),
      ),
    );
  }
}

---

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

5.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});
}

5.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();
},

---

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.

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

  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:
    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> _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'};
      }

    ---

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.