virnavi_ai_agent_compose 0.0.3 copy "virnavi_ai_agent_compose: ^0.0.3" to clipboard
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.

example/lib/main.dart

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),
                ),
              ),
          ],
        ),
      ),
    );
  }
}
0
likes
160
points
151
downloads

Documentation

API reference

Publisher

verified publishervirnavi.com

Weekly Downloads

Ties Flutter UI to MCP tool results. Widgets rebuild reactively when AI agents invoke tools — no setState needed.

Repository (GitHub)
View/report issues

Topics

#mcp #ai #agent #flutter #reactive

License

MIT (license)

Dependencies

flutter, virnavi_ai_agent_mcp

More

Packages that depend on virnavi_ai_agent_compose