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.

- 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'; Futuremain() 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,
-Hspecifies HTTP headers, while-dsends 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.

Claude API Integration
Claude API Key URL

FuturegenerageClaudeContent(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

static const _openAiUrl = "https://api.openai.com/v1/responses"; FuturegenerageGPTContent(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

static const _grokUrl = "https://api.x.ai/v1/responses"; FuturegenerageGrokContent(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.