virnavi_ai_agent_compose 0.0.3
virnavi_ai_agent_compose: ^0.0.3 copied to clipboard
Ties Flutter UI to MCP tool results. Widgets rebuild reactively when AI agents invoke tools — no setState needed.
import 'package:flutter/material.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:virnavi_ai_agent_compose/virnavi_ai_agent_compose.dart';
import 'package:virnavi_ai_agent_mcp/virnavi_ai_agent_mcp.dart';
import 'app_summary.dart';
import 'gemini_mcp_bridge.dart';
import 'number_cards.dart';
import 'number_models.dart';
import 'number_service.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Number Checker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ChatPage(),
);
}
}
// ── Chat message model ────────────────────────────────────────────────────────
sealed class ChatMessage {}
class UserMessage extends ChatMessage {
final String text;
UserMessage(this.text);
}
class LoadingMessage extends ChatMessage {}
class AiMessage extends ChatMessage {
final String assessment;
final NextPrimeResult? prime;
final NextOddResult? odd;
final NextEvenResult? even;
AiMessage({
required this.assessment,
this.prime,
this.odd,
this.even,
});
}
// ── Chat page ─────────────────────────────────────────────────────────────────
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
final _inputController = TextEditingController();
final _apiKeyController = TextEditingController();
final _scrollController = ScrollController();
final _store = McpResultStore();
final _service = NumberService();
late final McpSummary _summary;
final _messages = <ChatMessage>[];
bool _loading = false;
@override
void initState() {
super.initState();
final binding = McpComposeBinding(_store);
_summary = $AppSummaryMcpSummary.bindWithViews(_service.mcpTools);
_summary.tools.values
.toList()
.registerWith(AgentBridge.instance.initialize(), binding);
}
@override
void dispose() {
_inputController.dispose();
_apiKeyController.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
Future<void> _send() async {
final apiKey = _apiKeyController.text.trim();
if (apiKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your Gemini API key first.')),
);
return;
}
final input = _inputController.text.trim();
final number = int.tryParse(input);
if (number == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a valid integer.')),
);
return;
}
_inputController.clear();
setState(() {
_messages.add(UserMessage(input));
_messages.add(LoadingMessage());
_loading = true;
});
_scrollToBottom();
NextPrimeResult? prime;
NextOddResult? odd;
NextEvenResult? even;
try {
final model = GenerativeModel(
model: 'gemini-2.5-flash',
apiKey: apiKey,
tools: [
Tool(functionDeclarations: _summary.toFunctionDeclarations()),
],
);
var contents = [
Content.text(
'The user entered the number $number. '
'Determine which categories it belongs to: prime, odd, and/or even. '
'Then call ALL applicable tools — next_prime if it is prime, '
'next_odd if it is odd, next_even if it is even. '
'A number can belong to multiple categories (e.g. 7 is both prime and odd). '
'After calling the tools, give a brief summary of what the number is.',
),
];
String assessment = '';
while (true) {
final response = await model.generateContent(contents);
final parts = response.candidates.first.content.parts;
final calls = parts.whereType<FunctionCall>().toList();
if (calls.isEmpty) {
assessment =
parts.whereType<TextPart>().map((p) => p.text).join('\n').trim();
break;
}
contents = [...contents, response.candidates.first.content];
final responses = <FunctionResponse>[];
for (final call in calls) {
final mcpName = _summary.mcpName(call.name) ?? call.name;
final args =
call.args.map((k, v) => MapEntry(k, v is num ? v.toInt() : v));
final toolResult =
await AgentBridge.instance.callTool(mcpName, args);
if (!toolResult.isError && toolResult.data is Map) {
final data = (toolResult.data as Map).cast<String, dynamic>();
switch (call.name) {
case 'next_prime':
prime = NextPrimeResult.fromJson(data);
case 'next_odd':
odd = NextOddResult.fromJson(data);
case 'next_even':
even = NextEvenResult.fromJson(data);
}
}
responses.add(FunctionResponse(
call.name,
toolResult.isError
? {'error': toolResult.errorMessage}
: {'result': toolResult.data},
));
}
contents = [...contents, Content.functionResponses(responses)];
}
setState(() {
_messages.removeLast(); // remove LoadingMessage
_messages.add(AiMessage(
assessment: assessment.isEmpty ? '(no response)' : assessment,
prime: prime,
odd: odd,
even: even,
));
});
} catch (e) {
setState(() {
_messages.removeLast();
_messages.add(AiMessage(assessment: 'Error: $e'));
});
} finally {
setState(() => _loading = false);
_scrollToBottom();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: colorScheme.inversePrimary,
title: const Text('Number Checker'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: TextField(
controller: _apiKeyController,
decoration: InputDecoration(
hintText: 'Gemini API Key',
isDense: true,
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
obscureText: true,
style: const TextStyle(fontSize: 13),
),
),
),
),
body: Column(
children: [
Expanded(
child: _messages.isEmpty
? Center(
child: Text(
'Enter a number below to get started.',
style: TextStyle(color: Colors.grey.shade500),
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
itemCount: _messages.length,
itemBuilder: (context, i) =>
_buildMessage(context, _messages[i]),
),
),
_buildInputBar(context),
],
),
);
}
Widget _buildMessage(BuildContext context, ChatMessage msg) {
return switch (msg) {
UserMessage(:final text) => _UserBubble(text: text),
LoadingMessage() => const _TypingIndicator(),
AiMessage(:final assessment, :final prime, :final odd, :final even) =>
_AiBubble(
assessment: assessment,
prime: prime,
odd: odd,
even: even,
),
};
}
Widget _buildInputBar(BuildContext context) {
return SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)],
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
decoration: InputDecoration(
hintText: 'Enter a number…',
isDense: true,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
),
keyboardType: TextInputType.number,
onSubmitted: (_) {
if (!_loading) _send();
},
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _loading ? null : _send,
icon: const Icon(Icons.send_rounded),
),
],
),
),
);
}
}
// ── Chat bubble widgets ───────────────────────────────────────────────────────
class _UserBubble extends StatelessWidget {
final String text;
const _UserBubble({required this.text});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 12, left: 72),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
bottomLeft: Radius.circular(18),
bottomRight: Radius.circular(4),
),
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
class _TypingIndicator extends StatelessWidget {
const _TypingIndicator();
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12, right: 72),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(18),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey.shade600,
),
),
const SizedBox(width: 10),
Text(
'Thinking…',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
),
],
),
),
);
}
}
class _AiBubble extends StatelessWidget {
final String assessment;
final NextPrimeResult? prime;
final NextOddResult? odd;
final NextEvenResult? even;
const _AiBubble({
required this.assessment,
this.prime,
this.odd,
this.even,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12, right: 72),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(18),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (prime != null) PrimeCard(result: prime!),
if (odd != null) OddCard(result: odd!),
if (even != null) EvenCard(result: even!),
if (assessment.isNotEmpty)
Padding(
padding: EdgeInsets.only(
top: (prime != null || odd != null || even != null) ? 4 : 0,
left: 4,
right: 4,
),
child: Text(
assessment,
style: const TextStyle(fontSize: 14),
),
),
],
),
),
);
}
}