Do you search the same prompt across different AI models?

I do it too.

So I thought, why not build a simple Flutter app that lets you chat with multiple AI models from one place?

In this tutorial, we’ll integrate GPT, Claude, Gemini, and Grok into a single Flutter app.

Let’s start building it.

flutter ai chat app

  • Access multiple AI models without switching between apps.
  • Full Markdown support, including headings, tables, and lists.
  • Tap URL in the response to open it in app.


okay enough talk…Let’s start build

Okay… Let’s make a simple Flutter project named **`multi_chat`** using the `flutter create` command.

If you don’t know how to make a Flutter project read this command guide and Flutter Android Studio setup guide.

lets add the packages in pubspec.yaml file and also setup .env setup declaration.


name: multi_chat
description: "A new Flutter project."
publish_to: 'none' 

version: 1.0.0+1

environment:
  sdk: ^3.12.2

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  http: ^1.6.0
  flutter_spinkit: ^5.2.2
  flutter_markdown_plus: ^1.0.12
  url_launcher: ^6.3.0
  flutter_dotenv: ^6.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^6.0.0

flutter:

  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  assets:
    - .env



  • http: For API integration.
  • flutter_spinkit: For showing a loading indicator.
  • flutter_markdown_plus: Converts AI responses into Markdown. It is the successor to flutter_markdown by Flutter.
  • url_launcher: Opens web URLs in the response.
  • flutter_dotenv: Loads API keys, still its not secure for productions apps.

main.dart
we will setup UI in main.dart file.


import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:multi_chat/models/llm_provider.dart';
import 'package:multi_chat/services/llm_service.dart';
import 'package:url_launcher/url_launcher.dart';

Future main() async {
  await dotenv.load();
  runApp(
    const MaterialApp(
      title: 'MultiChat',
      debugShowCheckedModeBanner: false,
      home: MultiChatScreen(),
    ),
  );
}

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

  @override
  State createState() => _MultiChatScreenState();
}

class _MultiChatScreenState extends State {
  final TextEditingController _searchController = TextEditingController();
  bool loading = false;
  String _response = '';

  LLMProvider _selectedProvider = LLMProvider.gemini;

  String _providerTitle(LLMProvider provider) => switch (provider) {
    LLMProvider.gemini => 'Gemini',
    LLMProvider.claude => 'Claude',
    LLMProvider.gpt => 'GPT',
    LLMProvider.grok => 'Grok',
  };

  String _providerSubTitle(LLMProvider provider) => switch (provider) {
    LLMProvider.gemini => "Google's AI Model",
    LLMProvider.claude => "Anthropic's AI Model",
    LLMProvider.gpt => "OpenAI's AI Model",
    LLMProvider.grok => "xAI's AI Model",
  };

  IconData _providerIcon(LLMProvider provider) => switch (provider) {
    LLMProvider.gemini => Icons.auto_awesome,
    LLMProvider.claude => Icons.psychology_outlined,
    LLMProvider.gpt => Icons.smart_toy_outlined,
    LLMProvider.grok => Icons.bolt_outlined,
  };

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return SafeArea(
      child: Scaffold(
        body: Column(
          children: [
            Expanded(
              child: _response.isEmpty
                  ? Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text(
                            'ANDROIDRIDE',
                            style: TextStyle(
                              fontWeight: FontWeight.bold,
                              fontSize: 35,
                            ),
                          ),
                          if (loading)
                            Column(
                              children: [
                                SizedBox(height: 8),
                                SpinKitThreeBounce(
                                  color: colorScheme.onSurface,
                                  size: 20,
                                ),
                              ],
                            ),
                        ],
                      ),
                    )
                  : SingleChildScrollView(
                      padding: const EdgeInsets.all(16),
                      child: MarkdownBody(
                        data: _response,
                        styleSheet: MarkdownStyleSheet(
                          p: TextStyle(
                            color: colorScheme.onSurface,
                            fontSize: 16,
                          ),
                          h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                        ),
                        onTapLink: (text, href, title) {
                          if (href != null) {
                            launchUrl(Uri.parse(href));
                          }
                        },
                      ),
                    ),
            ),
            Container(
              padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
              decoration: BoxDecoration(
                color: colorScheme.surface,
                border: Border(
                  top: BorderSide(color: colorScheme.outlineVariant),
                ),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  ActionChip(
                    label: Text(_providerTitle(_selectedProvider)),
                    avatar: Icon(_providerIcon(_selectedProvider), size: 18),
                    onPressed: loading ? null : showProviderBottomSheet,
                  ),
                  const SizedBox(height: 8),
                  Material(
                    elevation: 1,
                    borderRadius: BorderRadius.circular(24),
                    color: colorScheme.surfaceContainerHighest,
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.end,
                      children: [
                        Expanded(
                          child: TextField(
                            controller: _searchController,
                            minLines: 1,
                            maxLines: 4,
                            enabled: !loading,
                            textInputAction: TextInputAction.send,
                            onSubmitted: (_) => _ask(),
                            decoration: InputDecoration(
                              hintText: 'Ask something...',
                              border: InputBorder.none,
                              contentPadding: EdgeInsets.symmetric(
                                horizontal: 16,
                                vertical: 8,
                              ),
                            ),
                          ),
                        ),
                        Padding(
                          padding: const EdgeInsets.all(4.0),
                          child: IconButton.filled(
                            onPressed: loading ? null : _ask,
                            icon: const Icon(Icons.arrow_upward),
                          ),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  void showProviderBottomSheet() {
    showModalBottomSheet(
      context: context,
      showDragHandle: true,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      builder: (context) {
        return SafeArea(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Padding(
                padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text(
                    "Choose Provider",
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ),
              ),
              ...LLMProvider.values.map((provider) {
                final isSelected = provider == _selectedProvider;
                return ListTile(
                  leading: CircleAvatar(
                    child: Icon(
                      _providerIcon(provider),
                      size: 18,
                      color: Theme.of(context).colorScheme.onPrimaryContainer,
                    ),
                  ),
                  title: Text(_providerTitle(provider)),
                  subtitle: Text(_providerSubTitle(provider)),
                  trailing: isSelected
                      ? Icon(
                          Icons.check_circle,
                          color: Theme.of(context).colorScheme.primary,
                        )
                      : null,
                  onTap: () {
                    setState(() {
                      _selectedProvider = provider;
                      Navigator.pop(context);
                    });
                  },
                );
              }),
            ],
          ),
        );
      },
    );
  }

  void _ask() async {
    final prompt = _searchController.text.trim();

    if (prompt.isEmpty || loading) return;

    FocusManager.instance.primaryFocus?.unfocus();

    setState(() {
      loading = true;
      _response = '';
    });

    final response = switch (_selectedProvider) {
      LLMProvider.gemini => await LlmService.instance.generageGeminiContent(
        prompt,
      ),
      LLMProvider.claude => await LlmService.instance.generageClaudeContent(
        prompt,
      ),
      LLMProvider.gpt => await LlmService.instance.generageGPTContent(prompt),
      LLMProvider.grok => await LlmService.instance.generageGrokContent(prompt),
    };


    setState(() {
      loading = false;
      _response = response;
    });
  }
}


.env file
Please put your api keys here. If not, you can directly use your api keys in your code, but thats a bad practice.


GEMINI_API_KEY =“YOUR_API_KEY”
CLAUDE_API_KEY =“YOUR_API_KEY”
GPT_API_KEY =“YOUR_API_KEY”
GROK_API_KEY =“YOUR_API_KEY”

llm_provider.dart


enum LLMProvider { gemini, claude, gpt, grok }

Using an enum makes code easier to maintain, If more llms are added in future.

Before API Integration

  • You must obtain an API key from the AI provider (OpenAI, Google Gemini, Anthropic, xAI, etc.).
  • Most AI APIs use the POST method for sending requests.
  • Set the required headers and request body exactly as shown in the provider’s cURL example or official documentation.
  • Models, request formats, and response structures may change over time, and some models may be deprecated. Always check the HTTP status code before processing the response.
  • In a cURL command, -H specifies HTTP headers, while -d sends the request body (payload).
  • Use Postman (desktop app or web version) to test the API.

Gemini API Integration

Gemini API Key URL


  static const _geminiUrl =
      "https://generativelanguage.googleapis.com/v1/interactions";

 Future generageGeminiContent(String prompt) async {
    final geminiApiKey = dotenv.get('GEMINI_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_geminiUrl),
        headers: {
          'x-goog-api-key': geminiApiKey,
          'Content-Type': 'application/json',
        },
        body: jsonEncode({"model": "gemini-2.5-flash", "input": prompt}),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['steps'][1]["content"][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }


Use postman to API are working with needed headers and other fields.
gemini ai postman integration

Claude API Integration

Claude API Key URL
claude api integration in postman


  Future generageClaudeContent(String prompt) async {
    final claudeApiKey = dotenv.get('CLAUDE_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_claudeUrl),
        headers: {
          'x-api-key': claudeApiKey,
          'Content-Type': 'application/json',
          'anthropic-version': '2023-06-01',
        },
        body: jsonEncode({
          "model": "claude-haiku-4-5-20251001",
          "max_tokens": 10000,
          "messages": [
            {"role": "user", "content": prompt},
          ],
        }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['content'][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }

GPT API Integration

GPT API Key URL
gpt api integration in postman


  static const _openAiUrl = "https://api.openai.com/v1/responses";

Future generageGPTContent(String prompt) async {
    final gptApiKey = dotenv.get('GPT_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_openAiUrl),
        headers: {
          'Authorization': 'Bearer $gptApiKey',
          'Content-Type': 'application/json',
        },
        body: jsonEncode({"model": "gpt-5.4", "input": prompt}),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['output'][0]["content"][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }

Grok API Integration

GROK API Key URL
grok api integration

  static const _grokUrl = "https://api.x.ai/v1/responses";

  Future generageGrokContent(String prompt) async {
    final grokApiKey = dotenv.get('GROK_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_grokUrl),
        headers: {
          'Authorization': 'Bearer $grokApiKey',
          'Content-Type': 'application/json',
        },
        body: jsonEncode(
          {
           "model": "grok-4.5", 
          "input": prompt
          }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['output'][1]["content"][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }


llm_service.dart

All LLM API integration logic is handled in this file, making it easy to integrate additional LLMs in the future.


import 'dart:convert';

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

class LlmService {
  LlmService._();

  static final LlmService instance = LlmService._();

  static const _geminiUrl =
      "https://generativelanguage.googleapis.com/v1/interactions";

  static const _claudeUrl = "https://api.anthropic.com/v1/messages";

  static const _openAiUrl = "https://api.openai.com/v1/responses";

  static const _grokUrl = "https://api.x.ai/v1/responses";

  Future generageGrokContent(String prompt) async {
    final grokApiKey = dotenv.get('GROK_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_grokUrl),
        headers: {
          'Authorization': 'Bearer $grokApiKey',
          'Content-Type': 'application/json',
        },
        body: jsonEncode(
          {
           "model": "grok-4.5", 
          "input": prompt
          }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['output'][1]["content"][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }

  Future generageGPTContent(String prompt) async {
    final gptApiKey = dotenv.get('GPT_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_openAiUrl),
        headers: {
          'Authorization': 'Bearer $gptApiKey',
          'Content-Type': 'application/json',
        },
        body: jsonEncode({"model": "gpt-5.4", "input": prompt}),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['output'][0]["content"][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }

  Future generageGeminiContent(String prompt) async {
    final geminiApiKey = dotenv.get('GEMINI_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_geminiUrl),
        headers: {
          'x-goog-api-key': geminiApiKey,
          'Content-Type': 'application/json',
        },
        body: jsonEncode({"model": "gemini-2.5-flash", "input": prompt}),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['steps'][1]["content"][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }

  Future generageClaudeContent(String prompt) async {
    final claudeApiKey = dotenv.get('CLAUDE_API_KEY');

    try {
      final response = await http.post(
        Uri.parse(_claudeUrl),
        headers: {
          'x-api-key': claudeApiKey,
          'Content-Type': 'application/json',
          'anthropic-version': '2023-06-01',
        },
        body: jsonEncode({
          "model": "claude-haiku-4-5-20251001",
          "max_tokens": 10000,
          "messages": [
            {"role": "user", "content": prompt},
          ],
        }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        return data['content'][0]["text"];
      } else {
        return 'Error: ${response.statusCode} - ${response.body}';
      }
    } catch (e) {
      return 'Error: $e';
    }
  }
}


Thats all for now.