Build a Mobile AI Learning Buddy

Build a Flutter chatbot with a 3-model AI pipeline using Cerebras and Groq.

Introduction

30 Second Summary

When you ask a programming question online, you usually get one answer from one source. What if your learning assistant could plan, write, and then double-check its answer using three different AI models?

In this project, you will build a cross-platform mobile chatbot called "Mobile Learning Best Buddy" that chains three AI models together to produce verified programming content.

What You'll Build

A Flutter mobile app that sends programming questions through a 3-model AI pipeline (plan, generate, validate) using Cerebras and Groq APIs.

By the end of this project, you'll have:

  • A working Flutter mobile app called "Mobile Learning Best Buddy" with a polished dark-themed chat interface.
  • A reusable API service class that connects to any OpenAI-compatible endpoint.
  • A 3-model AI pipeline that plans with Cerebras gpt-oss-120b, generates with Groq openai/gpt-oss-20b, and validates with Groq openai/gpt-oss-120b.
  • Secret Mission: Add real-time streaming responses so tokens appear as they are generated.

Are there any prerequisites?

You need Android Studio installed on your computer. This project guides you through installing the Flutter SDK and setting up free API accounts for Cerebras and Groq from scratch.

Set Up Flutter and Create the Project

Before you can build a mobile AI app, you need a working development environment. Flutter is a cross-platform framework that lets you write one codebase and deploy it to Android, iOS, web, and desktop.

In this step, you will install the Flutter SDK, create a new project in Android Studio, and grab free API keys from two AI providers.

In this step, get ready to:
  • Install the Flutter SDK and verify it with flutter doctor.
  • Create the learning_best_buddy project in Android Studio.
  • Get free API keys from Cerebras and Groq.
Install the Flutter SDK

The Flutter SDK gives you the flutter command-line tool, the Dart language runtime, and everything needed to build mobile apps.

Windows

  • Create the folder C:\src if it does not already exist.
  • Extract the downloaded .zip file into C:\src. This creates a flutter subfolder at C:\src\flutter.
  • Add C:\src\flutter\bin to your system PATH. Open the Start menu, search for "Environment Variables", click **Edit the system environment variables**, then click **Environment Variables**. Under **User variables**, select **Path**, click **Edit**, and add a new entry: C:\src\flutter\bin.
  • Click **OK** on all dialogs to save your changes.

Why C:\src\flutter?

Flutter recommends installing to a short path without spaces. C:\src\flutter avoids issues with special characters or deeply nested directories that can break build tools.

macOS

  • Open Finder and create a folder called development in your home directory (~/development).
  • Extract the downloaded .zip file into ~/development. This creates a flutter subfolder.
  • Add the Flutter bin directory to your PATH by opening a terminal and running the command below:
echo 'export PATH="$HOME/development/flutter/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

Using a different shell?

If you use Bash instead of Zsh, replace ~/.zshrc with ~/.bashrc in the command above.

  • Close all open terminals, then open a brand-new terminal window so the PATH changes take effect.
  • Verify the installation by running this command:
flutter --version

✔️ I see a version number

You should see output that includes Flutter 3.44.0 (or a newer version). Flutter is installed correctly.

ⓧ Command not found

The terminal cannot find the flutter command because the PATH is not set up correctly.

  • On Windows, double-check that C:\src\flutter\bin is listed in your Path environment variable. Make sure you opened a new terminal after saving the change.
  • On macOS, run echo $PATH and confirm the flutter directory appears. If not, re-run the echo 'export PATH...' command from the macOS tab above.
  • Try closing and reopening your terminal completely, then run flutter --version again.
  • Check your development environment for any issues by running:
flutter doctor

What does flutter doctor do?

This command scans your system for required tools like the Android SDK, Android Studio, and connected devices. It reports a checklist with green checkmarks for items that are ready and red X marks for anything that needs attention.

If you see issues under the Android toolchain section asking you to accept licenses, run the following command and type y to accept each license:

flutter doctor --android-licenses
  • After accepting any licenses, run flutter doctor again to confirm everything passes.

Now install the Flutter plugin in Android Studio so it recognizes Flutter projects.

  • Switch to Android Studio.
  • Click **File** in the top menu bar.
  • Click **Settings** (Windows) or **Preferences** (macOS).
  • In the left sidebar, click **Plugins**.
  • Click the **Marketplace** tab at the top.
  • Type flutter in the search field.
  • Click **Install** next to the Flutter plugin. This also installs the Dart plugin automatically.
  • Click **Restart IDE** when prompted to restart Android Studio.

Don't see the Flutter plugin?

Make sure you are on the **Marketplace** tab, not the **Installed** tab. If the search returns no results, check your internet connection and try again.

help me find and install the Flutter plugin in Android Studio

Create the project

With Flutter installed and the plugin ready, you can create your first Flutter project directly in Android Studio.

  • In the Android Studio Welcome window, click **New Flutter Project**.
  • In the left sidebar of the dialog, select **Flutter**.
  • Set the **Flutter SDK path** field to C:\src\flutter (Windows) or ~/development/flutter (macOS).
  • Click **Next**.
  • Enter learning_best_buddy in the **Project name** field.
  • Leave all other settings at their defaults.
  • Click **Finish**.

Android Studio creates a new Flutter project with a default counter app template. You should see the project files appear in the left sidebar.

  • Add the HTTP networking package by opening the terminal in Android Studio (click **Terminal** at the bottom of the window) and running:
flutter pub add http

What does this command do?

The flutter pub add http command adds the http package to your project's pubspec.yaml file. This package lets your app make HTTP requests to external APIs like Cerebras and Groq.

  • Confirm the package was added by checking that pubspec.yaml now lists http under dependencies.

Seeing a "not a Flutter project" error?

Make sure the terminal is inside the learning_best_buddy folder. If the terminal opened to a different directory, navigate into the project folder first by running cd learning_best_buddy.

help me fix a flutter pub add error in my project

Get API keys

Your app will call two AI providers. Cerebras provides ultra-fast inference for the planning model. Groq provides fast inference for the generation and validation models. Both offer free API access.

  • Open https://cloud.cerebras.ai in your browser.
  • Create a free account (or sign in if you already have one).
  • Navigate to the **API Keys** page in the left sidebar.
  • Click **Create API Key** and give it a name like learning-buddy.
  • Copy the key and save it somewhere safe (a text file or password manager). You will not be able to see it again after you close the dialog.

Is Cerebras free?

Yes. All models on the Cerebras public endpoint are free to use, subject to rate limits. This is more than enough for development and testing.

  • Open https://console.groq.com in your browser.
  • Create a free account (or sign in if you already have one).
  • Navigate to the **API Keys** section in the left sidebar.
  • Click **Create API Key** and give it a name.
  • Copy the key and save it in the same safe place as your Cerebras key.

Is Groq free?

Groq offers a free tier with rate limits of 30 requests per minute and 8,000 tokens per minute per model. This is plenty for building and testing the pipeline in this project.

Keep your keys private

Never share your API keys publicly or commit them to version control. In the next step, you will paste them into your code as placeholder constants. For production apps, you would use environment variables or a secrets manager instead.

Your development environment is fully set up. Next, you will build the chat interface that users interact with when asking programming questions.

Build the Chat UI

Your project is set up and ready for code. Right now lib/main.dart still contains the default Flutter counter app, which is not what you need.

In this step, you will replace that default code with a polished chat interface. You will create a data model that represents each message, build a scrollable chat screen with a text input bar, and style message bubbles that display differently for user and assistant messages.

In this step, get ready to:
  • Create a message model with role, content, and pipeline phase tracking.
  • Build the main chat screen with a message list and input bar.
  • Style message bubbles with code block parsing and a copy button.
Create the message model

Every chat app needs a data structure to represent messages. Your model needs to track who sent the message (user or assistant), the text content, and which pipeline phase the assistant is currently in.

  • In Android Studio's Project panel on the left, right-click the lib folder.
  • Select New then Directory.
  • Type models and press Enter.
  • Right-click the new models folder.
  • Select New then Dart File.
  • Name it message and press Enter.
  • Paste the following code into lib/models/message.dart:
enum MessageRole { user, assistant }

enum PipelinePhase { planning, generating, validating, complete, error }

class ChatMessage {
  final MessageRole role;
  String content;
  PipelinePhase? phase;
  final DateTime timestamp;

  ChatMessage({
    required this.role,
    required this.content,
    this.phase,
    DateTime? timestamp,
  }) : timestamp = timestamp ?? DateTime.now();
}

What does this code do?

MessageRole is an enum with two values: user and assistant. This tells the UI which side of the screen to display each message on.

PipelinePhase tracks which stage of the AI pipeline is currently running. The UI uses this to show progress chips like "Planning..." or "Generating..." above assistant messages.

ChatMessage holds everything about a single message. The content field is not final because the pipeline updates it as each phase completes.

  • Save message.dart with Ctrl+S.
  • Confirm the file appears under lib/models/message.dart in the Project panel.

Don't see the models folder?

Make sure you right-clicked the lib folder specifically, not the project root or the android folder. The models directory must be inside lib.

help me create a models folder inside my Flutter project's lib directory

Build the chat screen

The chat screen is the core of your app. It manages the list of messages, handles user input, and orchestrates the AI pipeline calls.

  • Open lib/main.dart in the editor by double-clicking it in the Project panel.
  • Select all the existing code with Ctrl+A and delete it.
  • Replace it with the following app entry point:
import 'package:flutter/material.dart';
import 'screens/chat_screen.dart';

void main() {
  runApp(const LearningBestBuddyApp());
}

class LearningBestBuddyApp extends StatelessWidget {
  const LearningBestBuddyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Mobile Learning Best Buddy',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: const Color(0xFF0F0F1A),
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF6366F1),
          surface: Color(0xFF1A1A2E),
        ),
      ),
      home: const ChatScreen(),
    );
  }
}

What does this code do?

LearningBestBuddyApp creates a MaterialApp with a dark color scheme. The deep navy background (0xFF0F0F1A) and indigo primary color (0xFF6366F1) give the app a clean, modern look.

The home property points to ChatScreen, which you will create next. The import on line 2 references a file that does not exist yet, so Android Studio will show a red underline for now.

  • Save main.dart.

Now create the chat screen file that main.dart is importing.

  • Right-click the lib folder in the Project panel.
  • Select New then Directory.
  • Type screens and press Enter.
  • Right-click the new screens folder, select New then Dart File.
  • Name it chat_screen and press Enter.
  • Paste the following code into lib/screens/chat_screen.dart:
import 'package:flutter/material.dart';
import '../models/message.dart';
import '../services/pipeline_service.dart';
import '../widgets/message_bubble.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];
  final TextEditingController _controller = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final PipelineService _pipeline = PipelineService();
  bool _isProcessing = false;

  void _sendMessage() async {
    final text = _controller.text.trim();
    if (text.isEmpty || _isProcessing) return;

    setState(() {
      _messages.add(ChatMessage(role: MessageRole.user, content: text));
      _isProcessing = true;
    });
    _controller.clear();
    _scrollToBottom();

    final assistantMessage = ChatMessage(
      role: MessageRole.assistant,
      content: '',
      phase: PipelinePhase.planning,
    );

    setState(() {
      _messages.add(assistantMessage);
    });
    _scrollToBottom();

    try {
      await _pipeline.generateContent(
        text,
        (phase, content) {
          setState(() {
            assistantMessage.phase = phase;
            if (phase == PipelinePhase.complete ||
                phase == PipelinePhase.error) {
              assistantMessage.content = content;
            }
          });
          _scrollToBottom();
        },
      );
    } catch (e) {
      setState(() {
        assistantMessage.phase = PipelinePhase.error;
        assistantMessage.content = 'Error: ${e.toString()}';
      });
    } finally {
      setState(() {
        _isProcessing = false;
      });
    }
  }

  void _scrollToBottom() {
    Future.delayed(const Duration(milliseconds: 100), () {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0F0F1A),
      appBar: AppBar(
        backgroundColor: const Color(0xFF0F0F1A),
        elevation: 0,
        title: const Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Mobile Learning Best Buddy',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
            ),
            Text(
              'Cerebras + Groq Pipeline',
              style: TextStyle(fontSize: 12, color: Colors.white38),
            ),
          ],
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.only(top: 16, bottom: 16),
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                return MessageBubble(message: _messages[index]);
              },
            ),
          ),
          _buildInputBar(),
        ],
      ),
    );
  }

  Widget _buildInputBar() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
      decoration: const BoxDecoration(
        color: Color(0xFF1A1A2E),
        border: Border(top: BorderSide(color: Color(0xFF2D2D3F))),
      ),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _controller,
              style: const TextStyle(color: Colors.white, fontSize: 15),
              decoration: InputDecoration(
                hintText: 'Ask a programming question...',
                hintStyle: const TextStyle(color: Colors.white30),
                filled: true,
                fillColor: const Color(0xFF0F0F1A),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                  borderSide: BorderSide.none,
                ),
                contentPadding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 12,
                ),
              ),
              onSubmitted: (_) => _sendMessage(),
            ),
          ),
          const SizedBox(width: 10),
          GestureDetector(
            onTap: _sendMessage,
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: _isProcessing
                    ? Colors.grey
                    : const Color(0xFF6366F1),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Icon(Icons.arrow_upward, color: Colors.white, size: 20),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _scrollController.dispose();
    super.dispose();
  }
}

What does this code do?

_messages is the list of all chat messages. _controller manages the text input field, and _scrollController auto-scrolls the list when new messages arrive.

_sendMessage() adds the user's text to the message list, creates an empty assistant message with the "planning" phase, then calls the pipeline. As each phase completes, setState rebuilds the UI so the learner sees real-time progress.

_buildInputBar() creates the text field and send button pinned to the bottom of the screen. The send button turns grey while processing to prevent duplicate sends.

Why are there red underlines?

The file imports pipeline_service.dart and message_bubble.dart, which do not exist yet. You will create the message bubble widget next, and the pipeline service in a later step. The red underlines will disappear as you add those files.

  • Save chat_screen.dart.

Seeing errors in the editor?

Red underlines on the import lines are expected at this point. They will resolve after you create the remaining files. If you see errors inside the code itself (not the imports), double-check that you pasted the entire file without cutting off the closing braces.

help me fix Dart errors in my chat_screen.dart file

Create styled message bubbles

Message bubbles give the chat its visual personality. User messages appear on the right with a colored background. Assistant messages appear on the left with a border, a phase indicator chip, and code block rendering.

  • Right-click the lib folder, select New then Directory.
  • Type widgets and press Enter.
  • Right-click the new widgets folder, select New then Dart File.
  • Name it message_bubble and press Enter.
  • Paste the following code into lib/widgets/message_bubble.dart:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/message.dart';

class MessageBubble extends StatelessWidget {
  final ChatMessage message;

  const MessageBubble({super.key, required this.message});

  @override
  Widget build(BuildContext context) {
    final isUser = message.role == MessageRole.user;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: Column(
        crossAxisAlignment:
            isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          if (!isUser && message.phase != null) _buildPhaseChip(),
          Container(
            constraints: BoxConstraints(
              maxWidth: MediaQuery.of(context).size.width * 0.8,
            ),
            padding: const EdgeInsets.all(14),
            decoration: BoxDecoration(
              color: isUser
                  ? const Color(0xFF6366F1)
                  : const Color(0xFF1E1E2E),
              borderRadius: BorderRadius.circular(16),
              border: isUser
                  ? null
                  : Border.all(color: const Color(0xFF2D2D3F)),
            ),
            child: isUser
                ? Text(
                    message.content,
                    style: const TextStyle(color: Colors.white, fontSize: 15),
                  )
                : _buildAssistantContent(),
          ),
        ],
      ),
    );
  }

  Widget _buildPhaseChip() {
    String label;
    Color color;

    switch (message.phase) {
      case PipelinePhase.planning:
        label = 'Planning...';
        color = const Color(0xFFF59E0B);
        break;
      case PipelinePhase.generating:
        label = 'Generating...';
        color = const Color(0xFF3B82F6);
        break;
      case PipelinePhase.validating:
        label = 'Validating...';
        color = const Color(0xFF8B5CF6);
        break;
      case PipelinePhase.complete:
        label = 'Complete';
        color = const Color(0xFF10B981);
        break;
      case PipelinePhase.error:
        label = 'Error';
        color = const Color(0xFFEF4444);
        break;
      default:
        label = '';
        color = Colors.grey;
    }

    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
        decoration: BoxDecoration(
          color: color.withOpacity(0.15),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          label,
          style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }

  Widget _buildAssistantContent() {
    final parts = _parseCodeBlocks(message.content);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: parts,
    );
  }

  List<Widget> _parseCodeBlocks(String text) {
    final widgets = <Widget>[];
    final codeBlockRegex = RegExp(r'```(\w*)\n?([\s\S]*?)```');
    int lastEnd = 0;

    for (final match in codeBlockRegex.allMatches(text)) {
      if (match.start > lastEnd) {
        widgets.add(Text(
          text.substring(lastEnd, match.start).trim(),
          style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
        ));
      }

      final code = match.group(2) ?? '';
      widgets.add(const SizedBox(height: 8));
      widgets.add(_buildCodeBlock(code));
      widgets.add(const SizedBox(height: 8));

      lastEnd = match.end;
    }

    if (lastEnd < text.length) {
      widgets.add(Text(
        text.substring(lastEnd).trim(),
        style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
      ));
    }

    return widgets;
  }

  Widget _buildCodeBlock(String code) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: const Color(0xFF0D0D14),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Align(
            alignment: Alignment.topRight,
            child: GestureDetector(
              onTap: () => Clipboard.setData(ClipboardData(text: code)),
              child: const Icon(Icons.copy, size: 16, color: Colors.white38),
            ),
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Text(
              code.trim(),
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 13,
                color: Color(0xFFE2E8F0),
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

What does this code do?

_buildPhaseChip() renders a colored label above assistant messages showing the current pipeline stage. Each phase gets its own color: amber for planning, blue for generating, purple for validating, and green for complete.

_parseCodeBlocks() uses a regex to find markdown-style triple backtick code fences in the response text. Text outside fences renders normally. Text inside fences renders in a dark container with monospace font.

_buildCodeBlock() creates the styled code container with a copy button in the top-right corner. Tapping the copy icon uses Clipboard.setData to copy the code to the device clipboard.

  • Save message_bubble.dart.
  • Check the Project panel. You should see three new folders under lib: models, screens, and widgets.

Why can't I run the app yet?

chat_screen.dart imports pipeline_service.dart, which you have not created yet. The app will not compile until you build the API service and pipeline in the next steps. This is normal for a multi-file project where you build pieces incrementally.

Seeing a "Target URI doesn't exist" error?

This error on the import '../services/pipeline_service.dart' line is expected. You will create that file in a later step. As long as the other imports (message.dart and message_bubble.dart) do not show errors, your code is correct.

help me verify my Flutter project file structure is correct

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point. Compare yours against these:

import 'package:flutter/material.dart';
import 'screens/chat_screen.dart';

void main() {
  runApp(const LearningBestBuddyApp());
}

class LearningBestBuddyApp extends StatelessWidget {
  const LearningBestBuddyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Mobile Learning Best Buddy',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: const Color(0xFF0F0F1A),
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF6366F1),
          surface: Color(0xFF1A1A2E),
        ),
      ),
      home: const ChatScreen(),
    );
  }
}
enum MessageRole { user, assistant }

enum PipelinePhase { planning, generating, validating, complete, error }

class ChatMessage {
  final MessageRole role;
  String content;
  PipelinePhase? phase;
  final DateTime timestamp;

  ChatMessage({
    required this.role,
    required this.content,
    this.phase,
    DateTime? timestamp,
  }) : timestamp = timestamp ?? DateTime.now();
}
import 'package:flutter/material.dart';
import '../models/message.dart';
import '../services/pipeline_service.dart';
import '../widgets/message_bubble.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];
  final TextEditingController _controller = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final PipelineService _pipeline = PipelineService();
  bool _isProcessing = false;

  void _sendMessage() async {
    final text = _controller.text.trim();
    if (text.isEmpty || _isProcessing) return;

    setState(() {
      _messages.add(ChatMessage(role: MessageRole.user, content: text));
      _isProcessing = true;
    });
    _controller.clear();
    _scrollToBottom();

    final assistantMessage = ChatMessage(
      role: MessageRole.assistant,
      content: '',
      phase: PipelinePhase.planning,
    );

    setState(() {
      _messages.add(assistantMessage);
    });
    _scrollToBottom();

    try {
      await _pipeline.generateContent(
        text,
        (phase, content) {
          setState(() {
            assistantMessage.phase = phase;
            if (phase == PipelinePhase.complete ||
                phase == PipelinePhase.error) {
              assistantMessage.content = content;
            }
          });
          _scrollToBottom();
        },
      );
    } catch (e) {
      setState(() {
        assistantMessage.phase = PipelinePhase.error;
        assistantMessage.content = 'Error: ${e.toString()}';
      });
    } finally {
      setState(() {
        _isProcessing = false;
      });
    }
  }

  void _scrollToBottom() {
    Future.delayed(const Duration(milliseconds: 100), () {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0F0F1A),
      appBar: AppBar(
        backgroundColor: const Color(0xFF0F0F1A),
        elevation: 0,
        title: const Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Mobile Learning Best Buddy',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
            ),
            Text(
              'Cerebras + Groq Pipeline',
              style: TextStyle(fontSize: 12, color: Colors.white38),
            ),
          ],
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.only(top: 16, bottom: 16),
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                return MessageBubble(message: _messages[index]);
              },
            ),
          ),
          _buildInputBar(),
        ],
      ),
    );
  }

  Widget _buildInputBar() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
      decoration: const BoxDecoration(
        color: Color(0xFF1A1A2E),
        border: Border(top: BorderSide(color: Color(0xFF2D2D3F))),
      ),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _controller,
              style: const TextStyle(color: Colors.white, fontSize: 15),
              decoration: InputDecoration(
                hintText: 'Ask a programming question...',
                hintStyle: const TextStyle(color: Colors.white30),
                filled: true,
                fillColor: const Color(0xFF0F0F1A),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                  borderSide: BorderSide.none,
                ),
                contentPadding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 12,
                ),
              ),
              onSubmitted: (_) => _sendMessage(),
            ),
          ),
          const SizedBox(width: 10),
          GestureDetector(
            onTap: _sendMessage,
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: _isProcessing
                    ? Colors.grey
                    : const Color(0xFF6366F1),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Icon(Icons.arrow_upward, color: Colors.white, size: 20),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _scrollController.dispose();
    super.dispose();
  }
}
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/message.dart';

class MessageBubble extends StatelessWidget {
  final ChatMessage message;

  const MessageBubble({super.key, required this.message});

  @override
  Widget build(BuildContext context) {
    final isUser = message.role == MessageRole.user;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: Column(
        crossAxisAlignment:
            isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          if (!isUser && message.phase != null) _buildPhaseChip(),
          Container(
            constraints: BoxConstraints(
              maxWidth: MediaQuery.of(context).size.width * 0.8,
            ),
            padding: const EdgeInsets.all(14),
            decoration: BoxDecoration(
              color: isUser
                  ? const Color(0xFF6366F1)
                  : const Color(0xFF1E1E2E),
              borderRadius: BorderRadius.circular(16),
              border: isUser
                  ? null
                  : Border.all(color: const Color(0xFF2D2D3F)),
            ),
            child: isUser
                ? Text(
                    message.content,
                    style: const TextStyle(color: Colors.white, fontSize: 15),
                  )
                : _buildAssistantContent(),
          ),
        ],
      ),
    );
  }

  Widget _buildPhaseChip() {
    String label;
    Color color;

    switch (message.phase) {
      case PipelinePhase.planning:
        label = 'Planning...';
        color = const Color(0xFFF59E0B);
        break;
      case PipelinePhase.generating:
        label = 'Generating...';
        color = const Color(0xFF3B82F6);
        break;
      case PipelinePhase.validating:
        label = 'Validating...';
        color = const Color(0xFF8B5CF6);
        break;
      case PipelinePhase.complete:
        label = 'Complete';
        color = const Color(0xFF10B981);
        break;
      case PipelinePhase.error:
        label = 'Error';
        color = const Color(0xFFEF4444);
        break;
      default:
        label = '';
        color = Colors.grey;
    }

    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
        decoration: BoxDecoration(
          color: color.withOpacity(0.15),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          label,
          style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }

  Widget _buildAssistantContent() {
    final parts = _parseCodeBlocks(message.content);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: parts,
    );
  }

  List<Widget> _parseCodeBlocks(String text) {
    final widgets = <Widget>[];
    final codeBlockRegex = RegExp(r'```(\w*)\n?([\s\S]*?)```');
    int lastEnd = 0;

    for (final match in codeBlockRegex.allMatches(text)) {
      if (match.start > lastEnd) {
        widgets.add(Text(
          text.substring(lastEnd, match.start).trim(),
          style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
        ));
      }

      final code = match.group(2) ?? '';
      widgets.add(const SizedBox(height: 8));
      widgets.add(_buildCodeBlock(code));
      widgets.add(const SizedBox(height: 8));

      lastEnd = match.end;
    }

    if (lastEnd < text.length) {
      widgets.add(Text(
        text.substring(lastEnd).trim(),
        style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
      ));
    }

    return widgets;
  }

  Widget _buildCodeBlock(String code) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: const Color(0xFF0D0D14),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Align(
            alignment: Alignment.topRight,
            child: GestureDetector(
              onTap: () => Clipboard.setData(ClipboardData(text: code)),
              child: const Icon(Icons.copy, size: 16, color: Colors.white38),
            ),
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Text(
              code.trim(),
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 13,
                color: Color(0xFFE2E8F0),
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Your chat UI is fully built. Next up, you will create the API service that connects to Cerebras and Groq so the pipeline can generate real responses.

Create the Multi-Provider API Service

Your chat UI looks great, but it cannot talk to any AI model yet. To get intelligent responses, the app needs to send HTTP requests to external AI providers and parse their replies.

Both Cerebras and Groq expose OpenAI-compatible endpoints. That means one reusable service class can handle all three model calls with different configurations. In this step, you will build that service class and wire it up to three provider instances.

In this step, get ready to:
  • Build a reusable API service class that works with any OpenAI-compatible endpoint.
  • Configure three provider instances for planning, generating, and validating content.
  • Test that your API keys connect successfully to the Cerebras endpoint.
Build the API service class

Every AI provider in this project uses the same request format: a POST to /chat/completions with a JSON body containing the model name and a messages array. A single class that accepts the base URL, API key, and model name can handle all of them.

  • In the Android Studio project panel, right-click the lib folder.
  • Select New > Package (or Directory depending on your Android Studio version).
  • Name it services.
  • Right-click the new services folder, select New > Dart File, and name it api_service.
  • Paste the following code into api_service.dart:
import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiService {
  final String baseUrl;
  final String apiKey;
  final String model;

  ApiService({
    required this.baseUrl,
    required this.apiKey,
    required this.model,
  });

  Future<String> chatCompletion(List<Map<String, String>> messages) async {
    final url = Uri.parse('$baseUrl/chat/completions');

    final response = await http.post(
      url,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer $apiKey',
      },
      body: jsonEncode({
        'model': model,
        'messages': messages,
      }),
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body) as Map<String, dynamic>;
      final choices = data['choices'] as List<dynamic>;
      final message = choices[0]['message'] as Map<String, dynamic>;
      return message['content'] as String;
    } else if (response.statusCode == 429) {
      throw Exception('Rate limit exceeded. Please wait a moment and try again.');
    } else {
      throw Exception('API error: ${response.statusCode} - ${response.body}');
    }
  }
}

What does this code do?

The ApiService class is a reusable HTTP client for any OpenAI-compatible API. It stores three configuration values: the provider's base URL, your API key, and which model to call.

  • chatCompletion sends a POST request to {baseUrl}/chat/completions with the standard JSON body format.
  • On a 200 response, it extracts the text from choices[0].message.content.
  • A 429 status code means you hit a rate limit. The method throws a descriptive error the UI can display.
  • Any other error status throws a generic exception with the status code and response body for debugging.

Why use the raw http package?

Higher-level packages like dart_openai abstract away the HTTP details. By using the raw http package, you see exactly how REST APIs work: building URLs, setting headers, encoding JSON, and parsing responses.

This knowledge transfers to any API you work with in the future, not just AI providers.

  • Save lib/services/api_service.dart.

Seeing a red underline on the http import?

If Android Studio highlights package:http/http.dart as an error, the http package may not have been added yet. Open the terminal in Android Studio and run flutter pub add http to install it.

help me fix the http import error in my Flutter project

Configure the three provider instances

Now you need three separate instances of ApiService, each pointing to a different provider and model. The planner uses Cerebras for fast outline generation. The generator and validator both use Groq with different model sizes.

  • Right-click the services folder, select New > Dart File, and name it ai_providers.
  • Paste the following code into ai_providers.dart:
import 'api_service.dart';

class AIProviders {
  static const String _cerebrasApiKey = 'your-cerebras-api-key-here';
  static const String _groqApiKey = 'your-groq-api-key-here';

  static final ApiService planner = ApiService(
    baseUrl: 'https://api.cerebras.ai/v1',
    apiKey: _cerebrasApiKey,
    model: 'gpt-oss-120b',
  );

  static final ApiService generator = ApiService(
    baseUrl: 'https://api.groq.com/openai/v1',
    apiKey: _groqApiKey,
    model: 'openai/gpt-oss-20b',
  );

  static final ApiService validator = ApiService(
    baseUrl: 'https://api.groq.com/openai/v1',
    apiKey: _groqApiKey,
    model: 'openai/gpt-oss-120b',
  );
}

What does this code do?

The AIProviders class holds three static instances of ApiService, each configured for a different role in the pipeline:

  • planner talks to Cerebras using the gpt-oss-120b model (120 billion parameters, very fast inference).
  • generator talks to Groq using the smaller openai/gpt-oss-20b model for detailed code generation.
  • validator talks to Groq using the larger openai/gpt-oss-120b model for reviewing and verifying the generated content.
  • Replace your-cerebras-api-key-here with the Cerebras API key you generated earlier.
  • Replace your-groq-api-key-here with the Groq API key you generated earlier.
  • Save lib/services/ai_providers.dart.

Keep your keys private

These keys are stored directly in the source code for simplicity during development. Never commit real API keys to a public repository. For production apps, store keys in environment variables or a secure secrets manager.

Not sure where your API keys are?

Your Cerebras key is at https://cloud.cerebras.ai under the API Keys page. Your Groq key is at https://console.groq.com under API Keys.

help me find my Cerebras or Groq API key

Test the API connection

Before building the full pipeline, you should confirm that the API service can successfully reach Cerebras and return a response. You will create a temporary placeholder for the pipeline service so the app compiles, then verify the connection on the emulator.

  • Right-click the services folder, select New > Dart File, and name it pipeline_service.
  • Paste this temporary test code into pipeline_service.dart:
import '../models/message.dart';
import 'ai_providers.dart';

class PipelineService {
  // Temporary test: calls the Cerebras planner directly
  Future<String> generateContent(
    String userQuestion,
    void Function(PipelinePhase phase, String content) onPhaseUpdate,
  ) async {
    onPhaseUpdate(PipelinePhase.planning, '');

    final result = await AIProviders.planner.chatCompletion([
      {'role': 'user', 'content': userQuestion},
    ]);

    onPhaseUpdate(PipelinePhase.complete, result);
    return result;
  }
}

Why create this placeholder?

The chat screen already imports pipeline_service.dart from the previous step. Without this file, the app will not compile. This temporary version calls only the Cerebras planner so you can verify your API key works.

You will replace this file with the full 3-model pipeline in the next step.

  • Save lib/services/pipeline_service.dart.
  • In the Android Studio toolbar, select your emulator from the device dropdown.
  • Click the green Run button (or press Shift+F10) to launch the app on the emulator.
  • Once the app loads, type Hello in the message input field and tap the send button.

You should see the phase indicator change to "Planning..." and then to "Complete" with a text response from the Cerebras model.

Not getting a response?

  • Check that you replaced the placeholder strings in ai_providers.dart with your real API keys.
  • Make sure your emulator has internet access. Try opening a browser inside the emulator to confirm.
  • Look at the Run panel at the bottom of Android Studio for error messages. A 401 status means your API key is invalid.

help me debug why my Flutter app is not getting a response from the Cerebras API

✔️ Awesome, I've got everything!

Great. Make sure both api_service.dart and ai_providers.dart are saved. The temporary pipeline_service.dart will be replaced with the full implementation in the next step.

ⓧ I'd like to double check the full code

Here is the complete code for lib/services/api_service.dart:

import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiService {
  final String baseUrl;
  final String apiKey;
  final String model;

  ApiService({
    required this.baseUrl,
    required this.apiKey,
    required this.model,
  });

  Future<String> chatCompletion(List<Map<String, String>> messages) async {
    final url = Uri.parse('$baseUrl/chat/completions');

    final response = await http.post(
      url,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer $apiKey',
      },
      body: jsonEncode({
        'model': model,
        'messages': messages,
      }),
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body) as Map<String, dynamic>;
      final choices = data['choices'] as List<dynamic>;
      final message = choices[0]['message'] as Map<String, dynamic>;
      return message['content'] as String;
    } else if (response.statusCode == 429) {
      throw Exception('Rate limit exceeded. Please wait a moment and try again.');
    } else {
      throw Exception('API error: ${response.statusCode} - ${response.body}');
    }
  }
}

Here is the complete code for lib/services/ai_providers.dart:

import 'api_service.dart';

class AIProviders {
  static const String _cerebrasApiKey = 'your-cerebras-api-key-here';
  static const String _groqApiKey = 'your-groq-api-key-here';

  static final ApiService planner = ApiService(
    baseUrl: 'https://api.cerebras.ai/v1',
    apiKey: _cerebrasApiKey,
    model: 'gpt-oss-120b',
  );

  static final ApiService generator = ApiService(
    baseUrl: 'https://api.groq.com/openai/v1',
    apiKey: _groqApiKey,
    model: 'openai/gpt-oss-20b',
  );

  static final ApiService validator = ApiService(
    baseUrl: 'https://api.groq.com/openai/v1',
    apiKey: _groqApiKey,
    model: 'openai/gpt-oss-120b',
  );
}

Your API service layer is live and connected. Next, you will build the full 3-model content pipeline that chains planning, generation, and validation into a single response flow.

Implement the 3-Model Content Pipeline

Your chat UI is polished and your API service is ready to call any OpenAI-compatible endpoint. The missing piece is the brain that ties everything together.

In this step, you will build the pipeline orchestrator. This single service coordinates three models in sequence: one plans the answer, one generates detailed code, and one validates the final output. Each model has a distinct job, and the pipeline feeds each model's output into the next.

In this step, get ready to:
  • Build a pipeline orchestrator that chains three AI model calls in sequence.
  • Add phase callbacks so the UI updates in real time as each stage completes.
  • Test the full pipeline end-to-end in the emulator.
Build the pipeline orchestrator

A multi-model pipeline sends one model's output as input to the next. Your pipeline has three stages: planning (Cerebras outlines the answer), generating (Groq produces code and explanations), and validating (Groq reviews the result for correctness).

  • In the lib/services/ folder inside Android Studio's Project panel, right-click and create a new Dart file called pipeline_service.dart.
  • Paste the following code into pipeline_service.dart:
import '../models/message.dart';
import 'ai_providers.dart';

class PipelineService {
  Future<String> generateContent(
    String userQuestion,
    void Function(PipelinePhase phase, String content) onPhaseUpdate,
  ) async {
    // Phase 1: Call Cerebras planner to outline the answer
    onPhaseUpdate(PipelinePhase.planning, '');

    final outline = await AIProviders.planner.chatCompletion([
      {
        'role': 'system',
        'content':
            'You are a programming instructor. Given a programming question, outline clear steps or phases to answer it. Be concise and structured. Use numbered steps.',
      },
      {'role': 'user', 'content': userQuestion},
    ]);

    // Phase 2: Pass the outline to Groq generator for detailed content
    onPhaseUpdate(PipelinePhase.generating, outline);

    final generated = await AIProviders.generator.chatCompletion([
      {
        'role': 'system',
        'content':
            'You are a code generator. Given an outline of steps, produce detailed code examples and explanations for each step. Use markdown code fences for code blocks. Be clear and beginner-friendly.',
      },
      {
        'role': 'user',
        'content': 'Based on this outline, generate detailed code and explanations:\n\n$outline',
      },
    ]);

    // Phase 3: Send generated content to Groq validator for review
    onPhaseUpdate(PipelinePhase.validating, generated);

    final validated = await AIProviders.validator.chatCompletion([
      {
        'role': 'system',
        'content':
            'You are a code reviewer. Review the following programming content for correctness, clarity, and completeness. If it is correct, return it as-is with minor formatting improvements. If there are errors, fix them and note what was corrected. Always return the final polished content.',
      },
      {
        'role': 'user',
        'content': 'Please validate this programming content:\n\n$generated',
      },
    ]);

    // Signal completion with the final validated content
    onPhaseUpdate(PipelinePhase.complete, validated);
    return validated;
  }
}

What does this code do?

The PipelineService class has a single method, generateContent, that orchestrates three sequential API calls. Each call uses the previous call's output as input.

  • Stage 1 (Planning): AIProviders.planner calls Cerebras gpt-oss-120b to create a structured outline of steps.
  • Stage 2 (Generating): AIProviders.generator calls Groq openai/gpt-oss-20b with that outline to produce detailed code and explanations.
  • Stage 3 (Validating): AIProviders.validator calls Groq openai/gpt-oss-120b to review the generated content for errors and clarity.
  • Save pipeline_service.dart.

Seeing red underlines in the file?

If Android Studio shows errors on the import lines, make sure the file is saved inside lib/services/ (the same folder as api_service.dart and ai_providers.dart).

Also confirm that message.dart exists at lib/models/message.dart. The import path must match your folder structure exactly.

help me fix import errors in my pipeline_service.dart file

Understand the progress callbacks

The second parameter of generateContent is a callback function called onPhaseUpdate. This lets the UI react to each phase transition without the pipeline needing to know anything about widgets or state.

Why use a callback instead of returning all results at once?

Without callbacks, the user would stare at a blank screen for 10-15 seconds while all three API calls complete. The callback fires at each phase transition, so the UI can show "Planning...", "Generating...", and "Validating..." as the work happens.

The callback signature void Function(PipelinePhase phase, String content) passes two things: which phase the pipeline just entered, and the content produced so far. The chat screen uses this to update the phase chip above the assistant message bubble.

Test the full pipeline in the emulator

Your chat screen already has all the wiring to call the pipeline. The _sendMessage method in chat_screen.dart creates a placeholder assistant message, calls _pipeline.generateContent, and updates the message's phase and content via setState as each stage completes. Now that pipeline_service.dart exists, everything connects.

  • Make sure your API keys are set in ai_providers.dart (replace the placeholder strings with your real Cerebras and Groq keys).
  • Run the app on your emulator by clicking the green Run button in Android Studio's toolbar (or press Shift+F10).
  • Type a programming question into the text field, such as How do I sort a list in Python?.
  • Tap the send button (the purple arrow icon).

You should see the phase chip above the assistant message cycle through "Planning...", "Generating...", "Validating...", and finally "Complete". After a few seconds, the validated response appears in the message bubble.

What should I see?

The phase chip changes color at each stage: amber for planning, blue for generating, purple for validating, and green for complete. The final message contains formatted code blocks with explanations.

The full pipeline takes roughly 5-15 seconds depending on the complexity of your question and current API load. This is normal for three sequential model calls.

Seeing an error message in the chat?

If the phase chip shows "Error" with a red badge, check these common causes:

  • "Rate limit exceeded" means you have hit the free tier limit. Wait 60 seconds and try again. Groq's free tier allows 30 requests per minute.
  • "API error: 401" means your API key is invalid. Double-check that you pasted the correct key into ai_providers.dart with no extra spaces.
  • "SocketException" or "Connection refused" means the emulator cannot reach the internet. Check that your computer has an active network connection.

help me debug my pipeline API errors

✔️ Awesome, I've got everything!

Great. Double check you have saved pipeline_service.dart before moving on.

ⓧ I'd like to double check the full code

Here is the complete pipeline_service.dart file:

import '../models/message.dart';
import 'ai_providers.dart';

class PipelineService {
  Future<String> generateContent(
    String userQuestion,
    void Function(PipelinePhase phase, String content) onPhaseUpdate,
  ) async {
    onPhaseUpdate(PipelinePhase.planning, '');

    final outline = await AIProviders.planner.chatCompletion([
      {
        'role': 'system',
        'content':
            'You are a programming instructor. Given a programming question, outline clear steps or phases to answer it. Be concise and structured. Use numbered steps.',
      },
      {'role': 'user', 'content': userQuestion},
    ]);

    onPhaseUpdate(PipelinePhase.generating, outline);

    final generated = await AIProviders.generator.chatCompletion([
      {
        'role': 'system',
        'content':
            'You are a code generator. Given an outline of steps, produce detailed code examples and explanations for each step. Use markdown code fences for code blocks. Be clear and beginner-friendly.',
      },
      {
        'role': 'user',
        'content': 'Based on this outline, generate detailed code and explanations:\n\n$outline',
      },
    ]);

    onPhaseUpdate(PipelinePhase.validating, generated);

    final validated = await AIProviders.validator.chatCompletion([
      {
        'role': 'system',
        'content':
            'You are a code reviewer. Review the following programming content for correctness, clarity, and completeness. If it is correct, return it as-is with minor formatting improvements. If there are errors, fix them and note what was corrected. Always return the final polished content.',
      },
      {
        'role': 'user',
        'content': 'Please validate this programming content:\n\n$generated',
      },
    ]);

    onPhaseUpdate(PipelinePhase.complete, validated);
    return validated;
  }
}

Your three-model pipeline is working end-to-end. Next up, you will polish how the output displays by formatting code blocks, adding loading animations, and handling errors gracefully.

Polish the Output Display

Your pipeline is running end-to-end. The three-model chain plans, generates, and validates content correctly.

Now let's verify that the output display is polished. Code blocks need to render in a clean, readable format with copy functionality. Errors should surface gracefully. The chat should auto-scroll so the learner always sees the latest content.

In this step, get ready to:
  • Verify code block parsing renders markdown fences with copy functionality.
  • Confirm error handling displays friendly messages when API calls fail.
  • Test auto-scroll behavior and the app bar subtitle.
Verify code block parsing and copy functionality

When the AI models return programming content, they wrap code samples in markdown-style triple backtick fences. Your app needs to detect these and render them differently from regular text.

  • In Android Studio, open lib/widgets/message_bubble.dart from the Explorer panel on the left.
  • Find the _parseCodeBlocks method. This is the core of your code rendering logic.
  List<Widget> _parseCodeBlocks(String text) {
    final widgets = <Widget>[];
    final codeBlockRegex = RegExp(r'```(\w*)\n?([\s\S]*?)```');
    int lastEnd = 0;

    for (final match in codeBlockRegex.allMatches(text)) {
      if (match.start > lastEnd) {
        widgets.add(Text(
          text.substring(lastEnd, match.start).trim(),
          style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
        ));
      }

      final code = match.group(2) ?? '';
      widgets.add(const SizedBox(height: 8));
      widgets.add(_buildCodeBlock(code));
      widgets.add(const SizedBox(height: 8));

      lastEnd = match.end;
    }

    if (lastEnd < text.length) {
      widgets.add(Text(
        text.substring(lastEnd).trim(),
        style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
      ));
    }

    return widgets;
  }

What does this code do?

The regex ```(\w*)\n?([\s\S]*?)``` matches everything between triple backtick fences, capturing the language identifier and the code content.

Text before and after code fences renders as normal white text. Text inside code fences gets routed to _buildCodeBlock for special styling.

  • Now find the _buildCodeBlock method directly below it.
  Widget _buildCodeBlock(String code) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: const Color(0xFF0D0D14),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Align(
            alignment: Alignment.topRight,
            child: GestureDetector(
              onTap: () => Clipboard.setData(ClipboardData(text: code)),
              child: const Icon(Icons.copy, size: 16, color: Colors.white38),
            ),
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Text(
              code.trim(),
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 13,
                color: Color(0xFFE2E8F0),
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }

What does this code do?

Each code block renders inside a dark container (Color(0xFF0D0D14)) with rounded corners and padding. The text uses a monospace font so code aligns properly.

The GestureDetector wrapping the copy icon calls Clipboard.setData when tapped. This copies the raw code string to the device clipboard. The SingleChildScrollView allows horizontal scrolling for long lines that overflow the container width.

  • Run the app on your emulator by clicking the green play button in the toolbar.
  • Type a programming question like How do I reverse a string in Python? and tap the send button.
  • Wait for the pipeline to complete all three phases.

You should see code snippets rendered in dark boxes with monospace text and a small copy icon in the top right corner of each code block.

Code blocks not rendering?

If all text appears the same style without dark code boxes, check that your AI models are returning content with triple backtick fences. The generator model's system prompt instructs it to use markdown code fences.

If the app is not running at all, make sure your API keys are set correctly in lib/services/ai_providers.dart.

Need more help? Debug my code block rendering in Flutter

Confirm error handling and the disabled send button

When an API call fails, the app needs to communicate the error clearly instead of crashing or going silent. Your chat screen already handles this gracefully.

  • Switch to lib/screens/chat_screen.dart in the editor.
  • Find the _sendMessage method and locate the try/catch/finally block.
    try {
      await _pipeline.generateContent(
        text,
        (phase, content) {
          setState(() {
            assistantMessage.phase = phase;
            if (phase == PipelinePhase.complete ||
                phase == PipelinePhase.error) {
              assistantMessage.content = content;
            }
          });
          _scrollToBottom();
        },
      );
    } catch (e) {
      setState(() {
        assistantMessage.phase = PipelinePhase.error;
        assistantMessage.content = 'Error: ${e.toString()}';
      });
    } finally {
      setState(() {
        _isProcessing = false;
      });
    }

What does this code do?

If any of the three API calls throws an exception (network error, rate limit, invalid key), the catch block sets the message phase to PipelinePhase.error and displays the error text in the chat bubble.

The finally block always resets _isProcessing to false, which re-enables the send button regardless of success or failure.

  • Now find the send button inside _buildInputBar. Notice how the button color changes based on _isProcessing:
          GestureDetector(
            onTap: _sendMessage,
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: _isProcessing
                    ? Colors.grey
                    : const Color(0xFF6366F1),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Icon(Icons.arrow_upward, color: Colors.white, size: 20),
            ),
          ),

Why does the button turn grey?

While the pipeline runs, _isProcessing is true. The button color switches from indigo to grey as a visual cue that a request is in progress. The _sendMessage method also returns early when _isProcessing is true, preventing duplicate submissions.

  • In the running emulator, send a message and watch the send button turn grey while the pipeline processes.
  • Confirm the button returns to its purple color once the response appears.

If you want to test error handling, you can temporarily change one of the API keys in ai_providers.dart to an invalid value, send a message, and verify the red "Error" phase chip appears with an error message.

Remember to restore your real key

If you modified a key to test errors, change it back to your real API key before continuing.

Test auto-scroll and the app bar

A chat app that does not scroll to the latest message forces the user to scroll manually after every response. Your app handles this automatically.

  • In chat_screen.dart, find the _scrollToBottom method:
  void _scrollToBottom() {
    Future.delayed(const Duration(milliseconds: 100), () {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

What does this code do?

The 100-millisecond delay gives Flutter time to lay out the new message widget before scrolling. Without this delay, maxScrollExtent might not yet reflect the new content.

The animateTo call uses Curves.easeOut for a smooth deceleration effect. This method is called after the user sends a message, when the assistant placeholder appears, and each time a phase update arrives.

  • Now look at the appBar section in the build method:
      appBar: AppBar(
        backgroundColor: const Color(0xFF0F0F1A),
        elevation: 0,
        title: const Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Mobile Learning Best Buddy',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
            ),
            Text(
              'Cerebras + Groq Pipeline',
              style: TextStyle(fontSize: 12, color: Colors.white38),
            ),
          ],
        ),
      ),

Why a subtitle in the app bar?

The subtitle "Cerebras + Groq Pipeline" tells the user which AI providers are powering their responses. This transparency builds trust and helps differentiate this app from single-model chatbots.

  • In the emulator, send 3-4 messages in a row to build up a conversation.
  • Verify that the chat automatically scrolls to the bottom each time new content arrives.
  • Confirm the app bar displays "Mobile Learning Best Buddy" as the title and "Cerebras + Groq Pipeline" as the subtitle.

You should see the chat smoothly scroll down as each phase completes, keeping the latest content visible at all times.

Chat not scrolling?

Make sure _scrollController is attached to your ListView.builder via the controller parameter. If the controller has no clients, the hasClients guard prevents the scroll call entirely.

Need help? Debug my auto-scroll in Flutter ListView

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in your project.

ⓧ I'd like to double check the full code

Here is the complete lib/widgets/message_bubble.dart:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/message.dart';

class MessageBubble extends StatelessWidget {
  final ChatMessage message;

  const MessageBubble({super.key, required this.message});

  @override
  Widget build(BuildContext context) {
    final isUser = message.role == MessageRole.user;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: Column(
        crossAxisAlignment:
            isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          if (!isUser && message.phase != null) _buildPhaseChip(),
          Container(
            constraints: BoxConstraints(
              maxWidth: MediaQuery.of(context).size.width * 0.8,
            ),
            padding: const EdgeInsets.all(14),
            decoration: BoxDecoration(
              color: isUser
                  ? const Color(0xFF6366F1)
                  : const Color(0xFF1E1E2E),
              borderRadius: BorderRadius.circular(16),
              border: isUser
                  ? null
                  : Border.all(color: const Color(0xFF2D2D3F)),
            ),
            child: isUser
                ? Text(
                    message.content,
                    style: const TextStyle(color: Colors.white, fontSize: 15),
                  )
                : _buildAssistantContent(),
          ),
        ],
      ),
    );
  }

  Widget _buildPhaseChip() {
    String label;
    Color color;

    switch (message.phase) {
      case PipelinePhase.planning:
        label = 'Planning...';
        color = const Color(0xFFF59E0B);
        break;
      case PipelinePhase.generating:
        label = 'Generating...';
        color = const Color(0xFF3B82F6);
        break;
      case PipelinePhase.validating:
        label = 'Validating...';
        color = const Color(0xFF8B5CF6);
        break;
      case PipelinePhase.complete:
        label = 'Complete';
        color = const Color(0xFF10B981);
        break;
      case PipelinePhase.error:
        label = 'Error';
        color = const Color(0xFFEF4444);
        break;
      default:
        label = '';
        color = Colors.grey;
    }

    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
        decoration: BoxDecoration(
          color: color.withOpacity(0.15),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          label,
          style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }

  Widget _buildAssistantContent() {
    final parts = _parseCodeBlocks(message.content);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: parts,
    );
  }

  List<Widget> _parseCodeBlocks(String text) {
    final widgets = <Widget>[];
    final codeBlockRegex = RegExp(r'```(\w*)\n?([\s\S]*?)```');
    int lastEnd = 0;

    for (final match in codeBlockRegex.allMatches(text)) {
      if (match.start > lastEnd) {
        widgets.add(Text(
          text.substring(lastEnd, match.start).trim(),
          style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
        ));
      }

      final code = match.group(2) ?? '';
      widgets.add(const SizedBox(height: 8));
      widgets.add(_buildCodeBlock(code));
      widgets.add(const SizedBox(height: 8));

      lastEnd = match.end;
    }

    if (lastEnd < text.length) {
      widgets.add(Text(
        text.substring(lastEnd).trim(),
        style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
      ));
    }

    return widgets;
  }

  Widget _buildCodeBlock(String code) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: const Color(0xFF0D0D14),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Align(
            alignment: Alignment.topRight,
            child: GestureDetector(
              onTap: () => Clipboard.setData(ClipboardData(text: code)),
              child: const Icon(Icons.copy, size: 16, color: Colors.white38),
            ),
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Text(
              code.trim(),
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 13,
                color: Color(0xFFE2E8F0),
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Here is the complete lib/screens/chat_screen.dart:

import 'package:flutter/material.dart';
import '../models/message.dart';
import '../services/pipeline_service.dart';
import '../widgets/message_bubble.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];
  final TextEditingController _controller = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final PipelineService _pipeline = PipelineService();
  bool _isProcessing = false;

  void _sendMessage() async {
    final text = _controller.text.trim();
    if (text.isEmpty || _isProcessing) return;

    setState(() {
      _messages.add(ChatMessage(role: MessageRole.user, content: text));
      _isProcessing = true;
    });
    _controller.clear();
    _scrollToBottom();

    final assistantMessage = ChatMessage(
      role: MessageRole.assistant,
      content: '',
      phase: PipelinePhase.planning,
    );

    setState(() {
      _messages.add(assistantMessage);
    });
    _scrollToBottom();

    try {
      await _pipeline.generateContent(
        text,
        (phase, content) {
          setState(() {
            assistantMessage.phase = phase;
            if (phase == PipelinePhase.complete ||
                phase == PipelinePhase.error) {
              assistantMessage.content = content;
            }
          });
          _scrollToBottom();
        },
      );
    } catch (e) {
      setState(() {
        assistantMessage.phase = PipelinePhase.error;
        assistantMessage.content = 'Error: ${e.toString()}';
      });
    } finally {
      setState(() {
        _isProcessing = false;
      });
    }
  }

  void _scrollToBottom() {
    Future.delayed(const Duration(milliseconds: 100), () {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0F0F1A),
      appBar: AppBar(
        backgroundColor: const Color(0xFF0F0F1A),
        elevation: 0,
        title: const Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Mobile Learning Best Buddy',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
            ),
            Text(
              'Cerebras + Groq Pipeline',
              style: TextStyle(fontSize: 12, color: Colors.white38),
            ),
          ],
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.only(top: 16, bottom: 16),
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                return MessageBubble(message: _messages[index]);
              },
            ),
          ),
          _buildInputBar(),
        ],
      ),
    );
  }

  Widget _buildInputBar() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
      decoration: const BoxDecoration(
        color: Color(0xFF1A1A2E),
        border: Border(top: BorderSide(color: Color(0xFF2D2D3F))),
      ),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _controller,
              style: const TextStyle(color: Colors.white, fontSize: 15),
              decoration: InputDecoration(
                hintText: 'Ask a programming question...',
                hintStyle: const TextStyle(color: Colors.white30),
                filled: true,
                fillColor: const Color(0xFF0F0F1A),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                  borderSide: BorderSide.none,
                ),
                contentPadding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 12,
                ),
              ),
              onSubmitted: (_) => _sendMessage(),
            ),
          ),
          const SizedBox(width: 10),
          GestureDetector(
            onTap: _sendMessage,
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: _isProcessing
                    ? Colors.grey
                    : const Color(0xFF6366F1),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Icon(Icons.arrow_upward, color: Colors.white, size: 20),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _scrollController.dispose();
    super.dispose();
  }
}

Your output display is polished. Code renders cleanly, errors surface gracefully, and the chat stays scrolled to the latest content. You have a complete, production-style mobile AI chatbot.

Secret mission

Add Streaming Responses

Your pipeline currently waits for the entire planner response before moving on. What if you could watch the plan being written, token by token, as Cerebras generates it? Implement Server-Sent Events (SSE) streaming so the planning phase delivers real-time output.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with free-tier API keys, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Cerebras Cloud API key (free tier, no charges).
  • Groq API key (free tier, no charges).
  • The learning_best_buddy Flutter project on your local machine.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the app or adding new features.

  • Your Cerebras API key stays active on the free public endpoint, subject to rate limits.
  • Your Groq API key stays active on the free tier. Rate limits are 30 RPM and 8K TPM per model.
  • Your local project files remain on your machine, ready to open in Android Studio at any time.

Pause - I'll come back to this later

No action needed. Free tier accounts on both Cerebras and Groq do not incur any charges while idle.

  • Your API keys will remain valid when you return.
  • Your local project files stay on disk. You can reopen the project in Android Studio whenever you are ready.

Delete - I don't want to use this again

Remove your API keys and local project files to start fresh.

  • Revoke your Cerebras API key by going to https://cloud.cerebras.ai, opening the API Keys page, and deleting the key you created for this project.
  • Revoke your Groq API key by going to https://console.groq.com, navigating to your API keys, and deleting the key you created for this project.
  • Delete the local project folder by running this command in your terminal:

macOS

rm -rf ~/AndroidStudioProjects/learning_best_buddy

Windows

rmdir /s /q %USERPROFILE%\AndroidStudioProjects\learning_best_buddy

Nice Work!

Nice Work!

Nice work! You just built a cross-platform mobile AI chatbot from scratch using Flutter and connected it to a multi-model pipeline that plans, generates, and validates programming content.

You've learned how to:

  • Build a Flutter mobile app called "Mobile Learning Best Buddy" with a dark-themed chat interface, message bubbles, code block rendering, and a copy-to-clipboard button.
  • Create a reusable API service class that works with any OpenAI-compatible endpoint, from Cerebras to Groq and beyond.
  • Implement a 3-model AI pipeline that chains Cerebras gpt-oss-120b for planning, Groq openai/gpt-oss-20b for generation, and Groq openai/gpt-oss-120b for validation.
  • Secret Mission: Added SSE streaming to display tokens from the planner in real-time as they are generated.

Ready to quiz yourself?