From c1373ffff8860477b8a02c85ae6f81f9c9effcb3 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 19 May 2026 15:13:24 -0700 Subject: [PATCH 1/5] organize pages --- .../firebase_ai/example/lib/main.dart | 61 +- .../example/lib/pages/capabilities_page.dart | 799 ++++++++++++++++++ .../example/lib/pages/grounding_page.dart | 2 +- .../example/lib/pages/image_prompt_page.dart | 248 ------ .../example/lib/pages/json_schema_page.dart | 200 ----- .../example/lib/pages/multimodal_page.dart | 317 ------- .../example/lib/pages/schema_page.dart | 185 ---- .../example/lib/pages/token_count_page.dart | 145 ---- 8 files changed, 816 insertions(+), 1141 deletions(-) create mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 296fbbc79a91..2f44dd370596 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -19,27 +19,23 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; // Import after file is generated through flutterfire_cli. -// import 'package:firebase_ai_example/firebase_options.dart'; +import 'package:firebase_ai_example/firebase_options.dart'; import 'pages/bidi_page.dart'; import 'pages/chat_page.dart'; import 'pages/function_calling_page.dart'; import 'pages/image_generation_page.dart'; -import 'pages/image_prompt_page.dart'; -import 'pages/json_schema_page.dart'; -import 'pages/multimodal_page.dart'; -import 'pages/schema_page.dart'; +import 'pages/capabilities_page.dart'; import 'pages/server_template_page.dart'; import 'pages/grounding_page.dart'; -import 'pages/token_count_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Enable this line instead once have the firebase_options.dart generated and // imported through flutterfire_cli. - // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - await Firebase.initializeApp(); - await FirebaseAuth.instance.signInAnonymously(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // await Firebase.initializeApp(); + // await FirebaseAuth.instance.signInAnonymously(); runApp(const GenerativeAISample()); } @@ -144,38 +140,33 @@ class _HomeScreenState extends State { useVertexBackend: useVertexBackend, ); case 1: - return MultimodalPage(title: 'Multimodal', model: currentModel); + return CapabilitiesPage( + title: 'Capabilities', + model: currentModel, + ); case 2: - return TokenCountPage(title: 'Token Count', model: currentModel); - case 3: // FunctionCallingPage initializes its own model as per original design return FunctionCallingPage( title: 'Function Calling', useVertexBackend: useVertexBackend, ); - case 4: - return ImagePromptPage(title: 'Image Prompt', model: currentModel); - case 5: + case 3: return ImageGenerationPage( title: 'Image Gen', useVertexBackend: useVertexBackend, ); - case 6: - return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); - case 7: - return JsonSchemaPage(title: 'JSON Schema', model: currentModel); - case 8: + case 4: return BidiPage( title: 'Live Stream', model: currentModel, useVertexBackend: useVertexBackend, ); - case 9: + case 5: return ServerTemplatePage( title: 'Server Template', useVertexBackend: useVertexBackend, ); - case 10: + case 6: return GroundingPage( title: 'Grounding', useVertexBackend: useVertexBackend, @@ -259,40 +250,20 @@ class _HomeScreenState extends State { tooltip: 'Chat', ), BottomNavigationBarItem( - icon: Icon(Icons.perm_media), - label: 'Multimodal', - tooltip: 'Multimodal Prompt', - ), - BottomNavigationBarItem( - icon: Icon(Icons.numbers), - label: 'Tokens', - tooltip: 'Token Count', + icon: Icon(Icons.star), + label: 'Capabilities', + tooltip: 'Model Capabilities', ), BottomNavigationBarItem( icon: Icon(Icons.functions), label: 'Functions', tooltip: 'Function Calling', ), - BottomNavigationBarItem( - icon: Icon(Icons.image), - label: 'Image', - tooltip: 'Image Prompt', - ), BottomNavigationBarItem( icon: Icon(Icons.brush), label: 'NanoBanana', tooltip: 'Image Generation', ), - BottomNavigationBarItem( - icon: Icon(Icons.schema), - label: 'Schema', - tooltip: 'Schema Prompt', - ), - BottomNavigationBarItem( - icon: Icon(Icons.data_object), - label: 'JSON', - tooltip: 'JSON Schema', - ), BottomNavigationBarItem( icon: Icon( Icons.stream, diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart new file mode 100644 index 000000000000..c9a9d08ae2a1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart @@ -0,0 +1,799 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/services.dart'; +import 'package:record/record.dart'; +import 'package:path_provider/path_provider.dart'; +import '../widgets/message_widget.dart'; + +final record = AudioRecorder(); + +class CapabilitiesPage extends StatefulWidget { + const CapabilitiesPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _CapabilitiesPageState(); +} + +class _CapabilitiesPageState extends State with TickerProviderStateMixin { + late final TabController _tabController; + + // Multimodal Tab State + final ScrollController _multimodalScrollController = ScrollController(); + final TextEditingController _multimodalTextController = TextEditingController(); + final FocusNode _multimodalTextFieldFocus = FocusNode(); + final List _multimodalMessages = []; + bool _multimodalLoading = false; + bool _recording = false; + + // Structured Tab State + final ScrollController _structuredScrollController = ScrollController(); + final List _structuredMessages = []; + bool _structuredLoading = false; + + // Tokens Tab State + final ScrollController _tokensScrollController = ScrollController(); + final List _tokensMessages = []; + bool _tokensLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _multimodalScrollController.dispose(); + _multimodalTextController.dispose(); + _multimodalTextFieldFocus.dispose(); + _structuredScrollController.dispose(); + _tokensScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Multimodal'), + Tab(text: 'Structured'), + Tab(text: 'Tokens'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildMultimodalTab(), + _buildStructuredTab(), + _buildTokensTab(), + ], + ), + ); + } + + void _scrollDown(ScrollController controller) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (controller.hasClients) { + controller.animateTo( + controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 750), + curve: Curves.easeOutCirc, + ); + } + }, + ); + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } + + // ========================================== + // MULTIMODAL TAB LOGIC + // ========================================== + + Future _sendImagePrompt(String message) async { + setState(() { + _multimodalLoading = true; + }); + try { + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + ByteData sconeBytes = await rootBundle.load('assets/images/scones.jpg'); + final content = [ + Content.multi([ + TextPart(message), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + InlineDataPart('image/jpeg', sconeBytes.buffer.asUint8List()), + ]), + ]; + _multimodalMessages.add( + MessageData( + imageBytes: catBytes.buffer.asUint8List(), + text: message, + fromUser: true, + ), + ); + _multimodalMessages.add( + MessageData( + imageBytes: sconeBytes.buffer.asUint8List(), + fromUser: true, + ), + ); + + var response = await widget.model.generateContent(content); + var text = response.text; + _multimodalMessages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + } else { + setState(() { + _multimodalLoading = false; + _scrollDown(_multimodalScrollController); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _multimodalLoading = false; + }); + } finally { + _multimodalTextController.clear(); + setState(() { + _multimodalLoading = false; + }); + _multimodalTextFieldFocus.requestFocus(); + } + } + + Future _sendStorageUriPrompt(String message) async { + setState(() { + _multimodalLoading = true; + }); + try { + final content = [ + Content.multi([ + TextPart(message), + const FileData( + 'image/jpeg', + 'gs://vertex-ai-example-ef5a2.appspot.com/foodpic.jpg', + ), + ]), + ]; + _multimodalMessages.add(MessageData(text: message, fromUser: true)); + + var response = await widget.model.generateContent(content); + var text = response.text; + _multimodalMessages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + } else { + setState(() { + _multimodalLoading = false; + _scrollDown(_multimodalScrollController); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _multimodalLoading = false; + }); + } finally { + _multimodalTextController.clear(); + setState(() { + _multimodalLoading = false; + }); + _multimodalTextFieldFocus.requestFocus(); + } + } + + Future recordAudio() async { + if (!await record.hasPermission()) { + debugPrint('Audio recording permission denied'); + return; + } + + final dir = Directory( + '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', + ); + + await dir.create(recursive: true); + + String filePath = + '${dir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; + + await record.start( + const RecordConfig( + encoder: AudioEncoder.wav, + ), + path: filePath, + ); + } + + Future stopRecord() async { + var path = await record.stop(); + + if (path == null) { + debugPrint('Failed to stop recording'); + return; + } + + debugPrint('Recording saved to: $path'); + + try { + File file = File(path); + final audio = await file.readAsBytes(); + debugPrint('Audio file size: ${audio.length} bytes'); + + final audioPart = InlineDataPart('audio/wav', audio); + + await _submitAudioToModel(audioPart); + + await file.delete(); + debugPrint('Recording deleted successfully.'); + } catch (e) { + debugPrint('Error processing recording: $e'); + } + } + + Future _submitAudioToModel(InlineDataPart audioPart) async { + try { + String textPrompt = 'What is in the audio recording?'; + const prompt = TextPart('What is in the audio recording?'); + + setState(() { + _multimodalMessages.add(MessageData(text: textPrompt, fromUser: true)); + _multimodalLoading = true; + }); + + final response = await widget.model.generateContent([ + Content.multi([prompt, audioPart]), + ]); + + setState(() { + _multimodalMessages.add(MessageData(text: response.text, fromUser: false)); + _multimodalLoading = false; + }); + + _scrollDown(_multimodalScrollController); + } catch (e) { + debugPrint('Error sending audio to model: $e'); + setState(() { + _multimodalLoading = false; + }); + } + } + + Future _testVideo() async { + try { + setState(() { + _multimodalLoading = true; + }); + + ByteData videoBytes = + await rootBundle.load('assets/videos/landscape.mp4'); + + const promptText = 'Can you tell me what is in the video?'; + + setState(() { + _multimodalMessages.add(MessageData(text: promptText, fromUser: true)); + }); + + final videoPart = + InlineDataPart('video/mp4', videoBytes.buffer.asUint8List()); + + final response = await widget.model.generateContent([ + Content.multi([const TextPart(promptText), videoPart]), + ]); + + setState(() { + _multimodalMessages.add(MessageData(text: response.text, fromUser: false)); + _multimodalLoading = false; + }); + + _scrollDown(_multimodalScrollController); + } catch (e) { + debugPrint('Error sending video to model: $e'); + setState(() { + _multimodalLoading = false; + }); + } + } + + Future _testDocumentReading() async { + try { + setState(() { + _multimodalLoading = true; + }); + + ByteData docBytes = + await rootBundle.load('assets/documents/gemini_summary.pdf'); + + const promptText = + 'Write me a summary in one sentence what this document is about.'; + + setState(() { + _multimodalMessages.add(MessageData(text: promptText, fromUser: true)); + }); + + final pdfPart = + InlineDataPart('application/pdf', docBytes.buffer.asUint8List()); + + final response = await widget.model.generateContent([ + Content.multi([const TextPart(promptText), pdfPart]), + ]); + + setState(() { + _multimodalMessages.add(MessageData(text: response.text, fromUser: false)); + _multimodalLoading = false; + }); + + _scrollDown(_multimodalScrollController); + } catch (e) { + debugPrint('Error sending document to model: $e'); + setState(() { + _multimodalLoading = false; + }); + } + } + + Widget _buildMultimodalTab() { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _multimodalScrollController, + itemBuilder: (context, idx) { + var content = _multimodalMessages[idx]; + return MessageWidget( + text: content.text, + image: content.imageBytes == null + ? null + : Image.memory( + content.imageBytes!, + cacheWidth: 400, + cacheHeight: 400, + ), + isFromUser: content.fromUser ?? false, + ); + }, + itemCount: _multimodalMessages.length, + ), + ), + if (_multimodalLoading && !_recording) + const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 5), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _multimodalTextFieldFocus, + controller: _multimodalTextController, + decoration: const InputDecoration( + hintText: 'Enter prompt for image...', + ), + ), + ), + IconButton( + onPressed: _multimodalLoading + ? null + : () async { + if (_multimodalTextController.text.isNotEmpty) { + await _sendImagePrompt(_multimodalTextController.text); + } + }, + icon: const Icon(Icons.image), + tooltip: 'Send Image Prompt', + ), + IconButton( + onPressed: _multimodalLoading + ? null + : () async { + if (_multimodalTextController.text.isNotEmpty) { + await _sendStorageUriPrompt(_multimodalTextController.text); + } + }, + icon: const Icon(Icons.storage), + tooltip: 'Send GCS Image Prompt', + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: _multimodalLoading + ? null + : () async { + setState(() { + _recording = !_recording; + }); + if (_recording) { + await recordAudio(); + } else { + await stopRecord(); + } + }, + icon: Icon( + Icons.mic, + color: _recording ? Colors.red : null, + ), + label: Text(_recording ? 'Stop Rec' : 'Record Audio'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _testVideo, + icon: const Icon(Icons.video_collection), + label: const Text('Test Video'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _testDocumentReading, + icon: const Icon(Icons.edit_document), + label: const Text('Test Doc'), + ), + ], + ), + ), + ], + ), + ); + } + + // ========================================== + // STRUCTURED TAB LOGIC + // ========================================== + + Future _promptSchemaTest() async { + setState(() { + _structuredLoading = true; + }); + try { + final content = [ + Content.text( + "For use in a children's card game, generate 10 animal-based " + 'characters.', + ), + ]; + + final jsonSchema = Schema.object( + properties: { + 'characters': Schema.array( + items: Schema.object( + properties: { + 'name': Schema.string(), + 'age': Schema.integer(), + 'species': Schema.string(), + 'accessory': + Schema.enumString(enumValues: ['hat', 'belt', 'shoes']), + }, + ), + ), + }, + optionalProperties: ['accessory'], + ); + + _structuredMessages.add( + MessageData( + text: 'Generate 10 animal-based characters (Schema)', + fromUser: true, + ), + ); + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseSchema: jsonSchema, + ), + ); + + if (response.text == null) { + _showError('No response from API.'); + } else { + final text = const JsonEncoder.withIndent(' ') + .convert(json.decode(response.text!) as Object?); + _structuredMessages + .add(MessageData(text: '```json\n$text\n```', fromUser: false)); + setState(() { + _structuredLoading = false; + _scrollDown(_structuredScrollController); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _structuredLoading = false; + }); + } + } + + Future _promptJsonSchemaTest() async { + setState(() { + _structuredLoading = true; + }); + try { + final content = [ + Content.text( + 'Generate a widget hierarchy with a column containing two text widgets ', + ), + ]; + + final jsonSchema = { + r'$defs': { + 'text_widget': { + r'$anchor': 'text_widget', + 'type': 'object', + 'properties': { + 'type': {'const': 'Text'}, + 'text': {'type': 'string'}, + }, + 'required': ['type', 'text'], + }, + }, + 'type': 'object', + 'properties': { + 'type': {'const': 'Column'}, + 'children': { + 'type': 'array', + 'items': { + 'anyOf': [ + {r'$ref': '#text_widget'}, + { + 'type': 'object', + 'properties': { + 'type': {'const': 'Row'}, + 'children': { + 'type': 'array', + 'items': {r'$ref': '#text_widget'}, + }, + }, + 'required': ['type', 'children'], + } + ], + }, + }, + }, + 'required': ['type', 'children'], + }; + + _structuredMessages.add( + MessageData( + text: 'Generate a widget hierarchy... (JSON Schema)', + fromUser: true, + ), + ); + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseJsonSchema: jsonSchema, + ), + ); + + var text = const JsonEncoder.withIndent(' ') + .convert(json.decode(response.text ?? '') as Object?); + _structuredMessages.add(MessageData(text: '```json\n$text\n```', fromUser: false)); + + setState(() { + _structuredLoading = false; + _scrollDown(_structuredScrollController); + }); + } catch (e) { + _showError(e.toString()); + setState(() { + _structuredLoading = false; + }); + } + } + + Widget _buildStructuredTab() { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _structuredScrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _structuredMessages[idx].text, + isFromUser: _structuredMessages[idx].fromUser ?? false, + ); + }, + itemCount: _structuredMessages.length, + ), + ), + if (_structuredLoading) + const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _structuredLoading ? null : _promptSchemaTest, + child: const Text('Schema Prompt'), + ), + ElevatedButton( + onPressed: _structuredLoading ? null : _promptJsonSchemaTest, + child: const Text('JSON Schema Prompt'), + ), + ], + ), + ), + ], + ), + ); + } + + // ========================================== + // TOKENS TAB LOGIC + // ========================================== + + Future _testCountToken() async { + setState(() { + _tokensLoading = true; + }); + + const prompt = 'tell a short story'; + _tokensMessages.add(MessageData(text: 'Count tokens for: "$prompt"', fromUser: true)); + + try { + final content = Content.text(prompt); + final tokenResponse = await widget.model.countTokens([content]); + final tokenResult = 'Token Count: ${tokenResponse.totalTokens}'; + _tokensMessages.add(MessageData(text: tokenResult, fromUser: false)); + } catch (e) { + _showError(e.toString()); + } finally { + setState(() { + _tokensLoading = false; + _scrollDown(_tokensScrollController); + }); + } + } + + Future _testUsageMetadata() async { + setState(() { + _tokensLoading = true; + }); + + const prompt = 'Tell a story about a magic backpack and the person who found it.'; + _tokensMessages.add(MessageData(text: prompt, fromUser: true)); + + try { + final content = [Content.text(prompt)]; + final response = await widget.model.generateContent(content); + final usageMetadata = response.usageMetadata; + + if (usageMetadata != null) { + final message = ''' +Usage Metadata: +- promptTokenCount: ${usageMetadata.promptTokenCount} +- candidatesTokenCount: ${usageMetadata.candidatesTokenCount} +- totalTokenCount: ${usageMetadata.totalTokenCount} +- thoughtsTokenCount: ${usageMetadata.thoughtsTokenCount} +- toolUsePromptTokenCount: ${usageMetadata.toolUsePromptTokenCount} +- cachedContentTokenCount: ${usageMetadata.cachedContentTokenCount} +- promptTokensDetails: ${usageMetadata.promptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} +- candidatesTokensDetails: ${usageMetadata.candidatesTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} +- toolUsePromptTokensDetails: ${usageMetadata.toolUsePromptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} +- cacheTokensDetails: ${usageMetadata.cacheTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} +'''; + _tokensMessages.add(MessageData(text: message, fromUser: false)); + } else { + _tokensMessages.add( + MessageData(text: 'No usage metadata available.', fromUser: false), + ); + } + } catch (e) { + _showError(e.toString()); + } finally { + setState(() { + _tokensLoading = false; + _scrollDown(_tokensScrollController); + }); + } + } + + Widget _buildTokensTab() { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _tokensScrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _tokensMessages[idx].text, + isFromUser: _tokensMessages[idx].fromUser ?? false, + ); + }, + itemCount: _tokensMessages.length, + ), + ), + if (_tokensLoading) + const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _tokensLoading ? null : _testCountToken, + child: const Text('Count Tokens'), + ), + ElevatedButton( + onPressed: _tokensLoading ? null : _testUsageMetadata, + child: const Text('Usage Metadata'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart index 6c5aad8fb6e0..68e52d5b5fe9 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart @@ -78,7 +78,7 @@ class _GroundingPageState extends State { : FirebaseAI.googleAI(); _model = aiProvider.generativeModel( - model: 'gemini-3.1-flash-lite', + model: 'gemini-3.5-flash', tools: tools.isNotEmpty ? tools : null, toolConfig: toolConfig, ); diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart deleted file mode 100644 index 5c5009ca3158..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/services.dart'; -import '../widgets/message_widget.dart'; - -class ImagePromptPage extends StatefulWidget { - const ImagePromptPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _ImagePromptPageState(); -} - -class _ImagePromptPageState extends State { - final ScrollController _scrollController = ScrollController(); - final TextEditingController _textController = TextEditingController(); - final FocusNode _textFieldFocus = FocusNode(); - final List _generatedContent = []; - bool _loading = false; - - void _scrollDown() { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration( - milliseconds: 750, - ), - curve: Curves.easeOutCirc, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemBuilder: (context, idx) { - var content = _generatedContent[idx]; - return MessageWidget( - text: content.text, - image: content.imageBytes == null - ? null - : Image.memory( - content.imageBytes!, - cacheWidth: 400, - cacheHeight: 400, - ), - isFromUser: content.fromUser ?? false, - ); - }, - itemCount: _generatedContent.length, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 25, - horizontal: 15, - ), - child: Row( - children: [ - Expanded( - child: TextField( - autofocus: true, - focusNode: _textFieldFocus, - controller: _textController, - ), - ), - const SizedBox.square( - dimension: 15, - ), - if (!_loading) - IconButton( - onPressed: () async { - await _sendImagePrompt(_textController.text); - }, - icon: Icon( - Icons.image, - color: Theme.of(context).colorScheme.primary, - ), - ), - if (!_loading) - IconButton( - onPressed: () async { - await _sendStorageUriPrompt(_textController.text); - }, - icon: Icon( - Icons.storage, - color: Theme.of(context).colorScheme.primary, - ), - ) - else - const CircularProgressIndicator(), - ], - ), - ), - ], - ), - ), - ); - } - - Future _sendImagePrompt(String message) async { - setState(() { - _loading = true; - }); - try { - ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); - ByteData sconeBytes = await rootBundle.load('assets/images/scones.jpg'); - final content = [ - Content.multi([ - TextPart(message), - // The only accepted mime types are image/*. - InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), - InlineDataPart('image/jpeg', sconeBytes.buffer.asUint8List()), - ]), - ]; - _generatedContent.add( - MessageData( - imageBytes: catBytes.buffer.asUint8List(), - text: message, - fromUser: true, - ), - ); - _generatedContent.add( - MessageData( - imageBytes: sconeBytes.buffer.asUint8List(), - fromUser: true, - ), - ); - - var response = await widget.model.generateContent(content); - var text = response.text; - _generatedContent.add(MessageData(text: text, fromUser: false)); - - if (text == null) { - _showError('No response from API.'); - return; - } else { - setState(() { - _loading = false; - _scrollDown(); - }); - } - } catch (e) { - _showError(e.toString()); - setState(() { - _loading = false; - }); - } finally { - _textController.clear(); - setState(() { - _loading = false; - }); - _textFieldFocus.requestFocus(); - } - } - - Future _sendStorageUriPrompt(String message) async { - setState(() { - _loading = true; - }); - try { - final content = [ - Content.multi([ - TextPart(message), - const FileData( - 'image/jpeg', - 'gs://vertex-ai-example-ef5a2.appspot.com/foodpic.jpg', - ), - ]), - ]; - _generatedContent.add(MessageData(text: message, fromUser: true)); - - var response = await widget.model.generateContent(content); - var text = response.text; - _generatedContent.add(MessageData(text: text, fromUser: false)); - - if (text == null) { - _showError('No response from API.'); - return; - } else { - setState(() { - _loading = false; - _scrollDown(); - }); - } - } catch (e) { - _showError(e.toString()); - setState(() { - _loading = false; - }); - } finally { - _textController.clear(); - setState(() { - _loading = false; - }); - _textFieldFocus.requestFocus(); - } - } - - void _showError(String message) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Something went wrong'), - content: SingleChildScrollView( - child: SelectableText(message), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - } -} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart deleted file mode 100644 index 18f0b6e4939d..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/json_schema_page.dart +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import '../widgets/message_widget.dart'; - -class JsonSchemaPage extends StatefulWidget { - const JsonSchemaPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _JsonSchemaPageState(); -} - -class _JsonSchemaPageState extends State { - final ScrollController _scrollController = ScrollController(); - final TextEditingController _textController = TextEditingController(); - final FocusNode _textFieldFocus = FocusNode(); - final List _messages = []; - bool _loading = false; - - void _scrollDown() { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration( - milliseconds: 750, - ), - curve: Curves.easeOutCirc, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemBuilder: (context, idx) { - return MessageWidget( - text: _messages[idx].text, - isFromUser: _messages[idx].fromUser ?? false, - ); - }, - itemCount: _messages.length, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 25, - horizontal: 15, - ), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _promptJsonSchemaTest(); - } - : null, - child: const Text('JSON Schema Prompt'), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Future _promptJsonSchemaTest() async { - setState(() { - _loading = true; - }); - try { - final content = [ - Content.text( - 'Generate a widget hierarchy with a column containing two text widgets ', - ), - ]; - - final jsonSchema = { - r'$defs': { - 'text_widget': { - r'$anchor': 'text_widget', - 'type': 'object', - 'properties': { - 'type': {'const': 'Text'}, - 'text': {'type': 'string'}, - }, - 'required': ['type', 'text'], - }, - }, - 'type': 'object', - 'properties': { - 'type': {'const': 'Column'}, - 'children': { - 'type': 'array', - 'items': { - 'anyOf': [ - {r'$ref': '#text_widget'}, - { - 'type': 'object', - 'properties': { - 'type': {'const': 'Row'}, - 'children': { - 'type': 'array', - 'items': {r'$ref': '#text_widget'}, - }, - }, - 'required': ['type', 'children'], - } - ], - }, - }, - }, - 'required': ['type', 'children'], - }; - - final response = await widget.model.generateContent( - content, - generationConfig: GenerationConfig( - responseMimeType: 'application/json', - responseJsonSchema: jsonSchema, - ), - ); - - var text = const JsonEncoder.withIndent(' ') - .convert(json.decode(response.text ?? '') as Object?); - _messages.add(MessageData(text: '```json$text```', fromUser: false)); - - setState(() { - _loading = false; - _scrollDown(); - }); - } catch (e) { - _showError(e.toString()); - setState(() { - _loading = false; - }); - } finally { - _textController.clear(); - setState(() { - _loading = false; - }); - _textFieldFocus.requestFocus(); - } - } - - void _showError(String message) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Something went wrong'), - content: SingleChildScrollView( - child: SelectableText(message), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - } -} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart deleted file mode 100644 index 9c559abdaada..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/services.dart'; -import '../widgets/message_widget.dart'; -import 'package:record/record.dart'; -import 'package:path_provider/path_provider.dart'; - -final record = AudioRecorder(); - -class MultimodalPage extends StatefulWidget { - const MultimodalPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _MultimodalPageState(); -} - -class _MultimodalPageState extends State { - final ScrollController _scrollController = ScrollController(); - final List _messages = []; - bool _recording = false; - bool _loading = false; - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - Future recordAudio() async { - if (!await record.hasPermission()) { - debugPrint('Audio recording permission denied'); - return; - } - - final dir = Directory( - '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', - ); - - await dir.create(recursive: true); - - String filePath = - '${dir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; - - await record.start( - const RecordConfig( - encoder: AudioEncoder.wav, - ), - path: filePath, - ); - } - - Future stopRecord() async { - var path = await record.stop(); - - if (path == null) { - debugPrint('Failed to stop recording'); - return; - } - - debugPrint('Recording saved to: $path'); - - try { - File file = File(path); - final audio = await file.readAsBytes(); - debugPrint('Audio file size: ${audio.length} bytes'); - - final audioPart = InlineDataPart('audio/wav', audio); - - await _submitAudioToModel(audioPart); - - await file.delete(); - debugPrint('Recording deleted successfully.'); - } catch (e) { - debugPrint('Error processing recording: $e'); - } - } - - Future _submitAudioToModel(InlineDataPart audioPart) async { - try { - String textPrompt = 'What is in the audio recording?'; - const prompt = TextPart('What is in the audio recording?'); - - setState(() { - _messages.add(MessageData(text: textPrompt, fromUser: true)); - _loading = true; - }); - - final response = await widget.model.generateContent([ - Content.multi([prompt, audioPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - _loading = false; - }); - - _scrollToBottom(); - } catch (e) { - debugPrint('Error sending audio to model: $e'); - setState(() { - _loading = false; - }); - } - } - - Future _testVideo() async { - try { - setState(() { - _loading = true; - }); - - ByteData videoBytes = - await rootBundle.load('assets/videos/landscape.mp4'); - - const promptText = 'Can you tell me what is in the video?'; - - setState(() { - _messages.add(MessageData(text: promptText, fromUser: true)); - }); - - final videoPart = - InlineDataPart('video/mp4', videoBytes.buffer.asUint8List()); - - final response = await widget.model.generateContent([ - Content.multi([const TextPart(promptText), videoPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - _loading = false; - }); - - _scrollToBottom(); - } catch (e) { - debugPrint('Error sending video to model: $e'); - setState(() { - _loading = false; - }); - } - } - - Future _testDocumentReading() async { - try { - setState(() { - _loading = true; - }); - - ByteData docBytes = - await rootBundle.load('assets/documents/gemini_summary.pdf'); - - const promptText = - 'Write me a summary in one sentence what this document is about.'; - - setState(() { - _messages.add(MessageData(text: promptText, fromUser: true)); - }); - - final pdfPart = - InlineDataPart('application/pdf', docBytes.buffer.asUint8List()); - - final response = await widget.model.generateContent([ - Content.multi([const TextPart(promptText), pdfPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - _loading = false; - }); - - _scrollToBottom(); - } catch (e) { - debugPrint('Error sending document to model: $e'); - setState(() { - _loading = false; - }); - } - } - - void _scrollToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemBuilder: (context, idx) { - return MessageWidget( - text: _messages[idx].text, - isFromUser: _messages[idx].fromUser ?? false, - ); - }, - itemCount: _messages.length, - ), - ), - if (_loading) - const Padding( - padding: EdgeInsets.all(8), - child: CircularProgressIndicator(), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 15, - horizontal: 10, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _loading - ? null - : () async { - setState(() { - _recording = !_recording; - }); - if (_recording) { - await recordAudio(); - } else { - await stopRecord(); - } - }, - icon: Icon( - Icons.mic, - color: _recording - ? Colors.red - : Theme.of(context).colorScheme.primary, - ), - iconSize: 32, - ), - Text( - _recording ? 'Stop' : 'Record', - style: const TextStyle(fontSize: 12), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _loading ? null : _testVideo, - icon: Icon( - Icons.video_collection, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: 32, - ), - const Text( - 'Test Video', - style: TextStyle(fontSize: 12), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _loading ? null : _testDocumentReading, - icon: Icon( - Icons.edit_document, - color: Theme.of(context).colorScheme.primary, - ), - iconSize: 32, - ), - const Text( - 'Test Doc', - style: TextStyle(fontSize: 12), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart deleted file mode 100644 index e59378ac6100..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import '../widgets/message_widget.dart'; - -class SchemaPromptPage extends StatefulWidget { - const SchemaPromptPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _SchemaPromptPageState(); -} - -class _SchemaPromptPageState extends State { - final ScrollController _scrollController = ScrollController(); - final TextEditingController _textController = TextEditingController(); - final FocusNode _textFieldFocus = FocusNode(); - final List _messages = []; - bool _loading = false; - - void _scrollDown() { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration( - milliseconds: 750, - ), - curve: Curves.easeOutCirc, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemBuilder: (context, idx) { - return MessageWidget( - text: _messages[idx].text, - isFromUser: _messages[idx].fromUser ?? false, - ); - }, - itemCount: _messages.length, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 25, - horizontal: 15, - ), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _promptSchemaTest(); - } - : null, - child: const Text('Schema Prompt'), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Future _promptSchemaTest() async { - setState(() { - _loading = true; - }); - try { - final content = [ - Content.text( - "For use in a children's card game, generate 10 animal-based " - 'characters.', - ), - ]; - - final jsonSchema = Schema.object( - properties: { - 'characters': Schema.array( - items: Schema.object( - properties: { - 'name': Schema.string(), - 'age': Schema.integer(), - 'species': Schema.string(), - 'accessory': - Schema.enumString(enumValues: ['hat', 'belt', 'shoes']), - }, - ), - ), - }, - optionalProperties: ['accessory'], - ); - - final response = await widget.model.generateContent( - content, - generationConfig: GenerationConfig( - responseMimeType: 'application/json', - responseSchema: jsonSchema, - ), - ); - - if (response.text == null) { - _showError('No response from API.'); - return; - } else { - final text = const JsonEncoder.withIndent(' ') - .convert(json.decode(response.text!) as Object?); - _messages - .add(MessageData(text: '```json\n$text\n```', fromUser: false)); - setState(() { - _loading = false; - _scrollDown(); - }); - } - } catch (e) { - _showError(e.toString()); - setState(() { - _loading = false; - }); - } finally { - _textController.clear(); - setState(() { - _loading = false; - }); - _textFieldFocus.requestFocus(); - } - } - - void _showError(String message) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Something went wrong'), - content: SingleChildScrollView( - child: SelectableText(message), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - } -} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart deleted file mode 100644 index 90b6b374cbfb..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import '../widgets/message_widget.dart'; - -class TokenCountPage extends StatefulWidget { - const TokenCountPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _TokenCountPageState(); -} - -class _TokenCountPageState extends State { - final List _messages = []; - bool _loading = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ListView.builder( - itemBuilder: (context, idx) { - return MessageWidget( - text: _messages[idx].text, - isFromUser: _messages[idx].fromUser ?? false, - ); - }, - itemCount: _messages.length, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 25, - horizontal: 15, - ), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _testCountToken(); - } - : null, - child: const Text('Count Tokens'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _testUsageMetadata(); - } - : null, - child: const Text('Usage Metadata'), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Future _testCountToken() async { - setState(() { - _loading = true; - }); - - const prompt = 'tell a short story'; - final content = Content.text(prompt); - final tokenResponse = await widget.model.countTokens([content]); - final tokenResult = 'Token Count: ${tokenResponse.totalTokens}'; - _messages.add(MessageData(text: tokenResult, fromUser: false)); - - setState(() { - _loading = false; - }); - } - - Future _testUsageMetadata() async { - setState(() { - _loading = true; - }); - - const prompt = - 'Tell a story about a magic backpack and the person who found it.'; - final content = [Content.text(prompt)]; - final response = await widget.model.generateContent(content); - final usageMetadata = response.usageMetadata; - - if (usageMetadata != null) { - final message = ''' -Usage Metadata: -- promptTokenCount: ${usageMetadata.promptTokenCount} -- candidatesTokenCount: ${usageMetadata.candidatesTokenCount} -- totalTokenCount: ${usageMetadata.totalTokenCount} -- thoughtsTokenCount: ${usageMetadata.thoughtsTokenCount} -- toolUsePromptTokenCount: ${usageMetadata.toolUsePromptTokenCount} -- cachedContentTokenCount: ${usageMetadata.cachedContentTokenCount} -- promptTokensDetails: ${usageMetadata.promptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- candidatesTokensDetails: ${usageMetadata.candidatesTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- toolUsePromptTokensDetails: ${usageMetadata.toolUsePromptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- cacheTokensDetails: ${usageMetadata.cacheTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -'''; - _messages.add(MessageData(text: message, fromUser: false)); - } else { - _messages.add( - MessageData(text: 'No usage metadata available.', fromUser: false), - ); - } - - setState(() { - _loading = false; - }); - } -} From 1acbd4f8bfe9b921822250767cc46a7da9b499ae Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 19 May 2026 15:41:19 -0700 Subject: [PATCH 2/5] improve capability page test coverage --- .../example/lib/pages/capabilities_page.dart | 339 +++++++++++------- 1 file changed, 212 insertions(+), 127 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart index c9a9d08ae2a1..6787f1cce1aa 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/capabilities_page.dart @@ -33,13 +33,12 @@ class CapabilitiesPage extends StatefulWidget { State createState() => _CapabilitiesPageState(); } -class _CapabilitiesPageState extends State with TickerProviderStateMixin { +class _CapabilitiesPageState extends State + with TickerProviderStateMixin { late final TabController _tabController; // Multimodal Tab State final ScrollController _multimodalScrollController = ScrollController(); - final TextEditingController _multimodalTextController = TextEditingController(); - final FocusNode _multimodalTextFieldFocus = FocusNode(); final List _multimodalMessages = []; bool _multimodalLoading = false; bool _recording = false; @@ -64,8 +63,6 @@ class _CapabilitiesPageState extends State with TickerProvider void dispose() { _tabController.dispose(); _multimodalScrollController.dispose(); - _multimodalTextController.dispose(); - _multimodalTextFieldFocus.dispose(); _structuredScrollController.dispose(); _tokensScrollController.dispose(); super.dispose(); @@ -85,13 +82,15 @@ class _CapabilitiesPageState extends State with TickerProvider ], ), ), - body: TabBarView( - controller: _tabController, - children: [ - _buildMultimodalTab(), - _buildStructuredTab(), - _buildTokensTab(), - ], + body: SelectionArea( + child: TabBarView( + controller: _tabController, + children: [ + _buildMultimodalTab(), + _buildStructuredTab(), + _buildTokensTab(), + ], + ), ), ); } @@ -136,16 +135,18 @@ class _CapabilitiesPageState extends State with TickerProvider // MULTIMODAL TAB LOGIC // ========================================== - Future _sendImagePrompt(String message) async { + Future _sendImagePrompt() async { setState(() { _multimodalLoading = true; }); try { ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); ByteData sconeBytes = await rootBundle.load('assets/images/scones.jpg'); + const promptText = + "describe the two pictures and compare what's in common"; final content = [ Content.multi([ - TextPart(message), + const TextPart(promptText), InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), InlineDataPart('image/jpeg', sconeBytes.buffer.asUint8List()), ]), @@ -153,7 +154,7 @@ class _CapabilitiesPageState extends State with TickerProvider _multimodalMessages.add( MessageData( imageBytes: catBytes.buffer.asUint8List(), - text: message, + text: promptText, fromUser: true, ), ); @@ -182,29 +183,28 @@ class _CapabilitiesPageState extends State with TickerProvider _multimodalLoading = false; }); } finally { - _multimodalTextController.clear(); setState(() { _multimodalLoading = false; }); - _multimodalTextFieldFocus.requestFocus(); } } - Future _sendStorageUriPrompt(String message) async { + Future _sendStorageUriPrompt() async { setState(() { _multimodalLoading = true; }); try { + const promptText = 'Describe this image'; final content = [ Content.multi([ - TextPart(message), + const TextPart(promptText), const FileData( 'image/jpeg', 'gs://vertex-ai-example-ef5a2.appspot.com/foodpic.jpg', ), ]), ]; - _multimodalMessages.add(MessageData(text: message, fromUser: true)); + _multimodalMessages.add(MessageData(text: promptText, fromUser: true)); var response = await widget.model.generateContent(content); var text = response.text; @@ -224,11 +224,9 @@ class _CapabilitiesPageState extends State with TickerProvider _multimodalLoading = false; }); } finally { - _multimodalTextController.clear(); setState(() { _multimodalLoading = false; }); - _multimodalTextFieldFocus.requestFocus(); } } @@ -296,7 +294,8 @@ class _CapabilitiesPageState extends State with TickerProvider ]); setState(() { - _multimodalMessages.add(MessageData(text: response.text, fromUser: false)); + _multimodalMessages + .add(MessageData(text: response.text, fromUser: false)); _multimodalLoading = false; }); @@ -332,7 +331,8 @@ class _CapabilitiesPageState extends State with TickerProvider ]); setState(() { - _multimodalMessages.add(MessageData(text: response.text, fromUser: false)); + _multimodalMessages + .add(MessageData(text: response.text, fromUser: false)); _multimodalLoading = false; }); @@ -369,7 +369,8 @@ class _CapabilitiesPageState extends State with TickerProvider ]); setState(() { - _multimodalMessages.add(MessageData(text: response.text, fromUser: false)); + _multimodalMessages + .add(MessageData(text: response.text, fromUser: false)); _multimodalLoading = false; }); @@ -412,49 +413,12 @@ class _CapabilitiesPageState extends State with TickerProvider padding: EdgeInsets.all(8), child: CircularProgressIndicator(), ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 5), - child: Row( - children: [ - Expanded( - child: TextField( - autofocus: true, - focusNode: _multimodalTextFieldFocus, - controller: _multimodalTextController, - decoration: const InputDecoration( - hintText: 'Enter prompt for image...', - ), - ), - ), - IconButton( - onPressed: _multimodalLoading - ? null - : () async { - if (_multimodalTextController.text.isNotEmpty) { - await _sendImagePrompt(_multimodalTextController.text); - } - }, - icon: const Icon(Icons.image), - tooltip: 'Send Image Prompt', - ), - IconButton( - onPressed: _multimodalLoading - ? null - : () async { - if (_multimodalTextController.text.isNotEmpty) { - await _sendStorageUriPrompt(_multimodalTextController.text); - } - }, - icon: const Icon(Icons.storage), - tooltip: 'Send GCS Image Prompt', - ), - ], - ), - ), Padding( padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.spaceEvenly, children: [ ElevatedButton.icon( onPressed: _multimodalLoading @@ -475,6 +439,16 @@ class _CapabilitiesPageState extends State with TickerProvider ), label: Text(_recording ? 'Stop Rec' : 'Record Audio'), ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _sendImagePrompt, + icon: const Icon(Icons.image), + label: const Text('Test Images'), + ), + ElevatedButton.icon( + onPressed: _multimodalLoading ? null : _sendStorageUriPrompt, + icon: const Icon(Icons.storage), + label: const Text('Test GCS Image'), + ), ElevatedButton.icon( onPressed: _multimodalLoading ? null : _testVideo, icon: const Icon(Icons.video_collection), @@ -514,15 +488,40 @@ class _CapabilitiesPageState extends State with TickerProvider 'characters': Schema.array( items: Schema.object( properties: { - 'name': Schema.string(), - 'age': Schema.integer(), - 'species': Schema.string(), - 'accessory': - Schema.enumString(enumValues: ['hat', 'belt', 'shoes']), + 'name': + Schema.string(description: 'The name of the character.'), + 'species': Schema.string(description: 'The animal species.'), + 'age': Schema.integer( + description: 'The age of the character in years.', + minimum: 1, + maximum: 1000, + ), + 'isMythical': Schema.boolean( + description: 'Whether the animal is a mythical creature.', + ), + 'powerLevel': Schema.number( + description: 'Power level from 0.0 to 10.0.', + minimum: 0, + maximum: 10, + ), + 'accessory': Schema.enumString( + enumValues: ['hat', 'belt', 'shoes'], + description: 'Optional accessory.', + nullable: true, + ), }, + propertyOrdering: [ + 'name', + 'species', + 'age', + 'isMythical', + 'powerLevel', + 'accessory', + ], ), ), }, + description: 'A list of characters for a card game.', optionalProperties: ['accessory'], ); @@ -572,43 +571,38 @@ class _CapabilitiesPageState extends State with TickerProvider ), ]; - final jsonSchema = { - r'$defs': { - 'text_widget': { - r'$anchor': 'text_widget', - 'type': 'object', - 'properties': { - 'type': {'const': 'Text'}, - 'text': {'type': 'string'}, - }, - 'required': ['type', 'text'], - }, + final textWidgetSchema = JSONSchema.object( + properties: { + 'type': JSONSchema.enumString(enumValues: ['Text']), + 'text': JSONSchema.string(), }, - 'type': 'object', - 'properties': { - 'type': {'const': 'Column'}, - 'children': { - 'type': 'array', - 'items': { - 'anyOf': [ - {r'$ref': '#text_widget'}, - { - 'type': 'object', - 'properties': { - 'type': {'const': 'Row'}, - 'children': { - 'type': 'array', - 'items': {r'$ref': '#text_widget'}, - }, - }, - 'required': ['type', 'children'], - } + ); + + final rowWidgetSchema = JSONSchema.object( + properties: { + 'type': JSONSchema.enumString(enumValues: ['Row']), + 'children': JSONSchema.array( + items: JSONSchema.ref(r'#/$defs/text_widget'), + ), + }, + ); + + final jsonSchema = JSONSchema.object( + defs: { + 'text_widget': textWidgetSchema, + }, + properties: { + 'type': JSONSchema.enumString(enumValues: ['Column']), + 'children': JSONSchema.array( + items: JSONSchema.anyOf( + schemas: [ + JSONSchema.ref(r'#/$defs/text_widget'), + rowWidgetSchema, ], - }, - }, + ), + ), }, - 'required': ['type', 'children'], - }; + ); _structuredMessages.add( MessageData( @@ -621,13 +615,14 @@ class _CapabilitiesPageState extends State with TickerProvider content, generationConfig: GenerationConfig( responseMimeType: 'application/json', - responseJsonSchema: jsonSchema, + responseJsonSchema: jsonSchema.toJson(), ), ); var text = const JsonEncoder.withIndent(' ') .convert(json.decode(response.text ?? '') as Object?); - _structuredMessages.add(MessageData(text: '```json\n$text\n```', fromUser: false)); + _structuredMessages + .add(MessageData(text: '```json\n$text\n```', fromUser: false)); setState(() { _structuredLoading = false; @@ -693,14 +688,56 @@ class _CapabilitiesPageState extends State with TickerProvider _tokensLoading = true; }); - const prompt = 'tell a short story'; - _tokensMessages.add(MessageData(text: 'Count tokens for: "$prompt"', fromUser: true)); - try { + // 1. Text only + const prompt = 'tell a short story'; + _tokensMessages.add( + MessageData( + text: 'Count tokens for text: "$prompt"', + fromUser: true, + ), + ); + final content = Content.text(prompt); final tokenResponse = await widget.model.countTokens([content]); - final tokenResult = 'Token Count: ${tokenResponse.totalTokens}'; - _tokensMessages.add(MessageData(text: tokenResult, fromUser: false)); + _tokensMessages.add( + MessageData( + text: 'Token Count (Text): ${tokenResponse.totalTokens}', + fromUser: false, + ), + ); + + // 2. Multimodal (Text + Image + PDF) + _tokensMessages.add( + MessageData( + text: + 'Count tokens for Multimodal (Text + cat.jpg + gemini_summary.pdf)', + fromUser: true, + ), + ); + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + ByteData docBytes = + await rootBundle.load('assets/documents/gemini_summary.pdf'); + final multimodalContent = Content.multi([ + const TextPart('Describe this cat and summarize this document.'), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + InlineDataPart('application/pdf', docBytes.buffer.asUint8List()), + ]); + + final multimodalTokenResponse = + await widget.model.countTokens([multimodalContent]); + final promptDetails = multimodalTokenResponse.promptTokensDetails + ?.map((d) => '${d.modality.name}: ${d.tokenCount}') + .join(', '); + + _tokensMessages.add( + MessageData( + text: + 'Token Count (Multimodal): ${multimodalTokenResponse.totalTokens}\n' + 'Details: $promptDetails', + fromUser: false, + ), + ); } catch (e) { _showError(e.toString()); } finally { @@ -716,32 +753,78 @@ class _CapabilitiesPageState extends State with TickerProvider _tokensLoading = true; }); - const prompt = 'Tell a story about a magic backpack and the person who found it.'; - _tokensMessages.add(MessageData(text: prompt, fromUser: true)); + _tokensMessages.add( + MessageData( + text: + 'Generate content with text + cat.jpg (with Thinking enabled) and check Usage Metadata details', + fromUser: true, + ), + ); try { - final content = [Content.text(prompt)]; - final response = await widget.model.generateContent(content); + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + final content = [ + Content.multi([ + const TextPart('Describe this image in detail.'), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + ]), + ]; + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + thinkingConfig: + ThinkingConfig.withThinkingBudget(2048, includeThoughts: true), + ), + ); + + // Extract thoughts if any + final thoughts = response.candidates.firstOrNull?.content.parts + .whereType() + .where((p) => p.isThought ?? false) + .map((p) => p.text) + .join(); + + if (thoughts != null && thoughts.isNotEmpty) { + _tokensMessages.add( + MessageData( + text: thoughts, + fromUser: false, + isThought: true, + ), + ); + } + final usageMetadata = response.usageMetadata; if (usageMetadata != null) { + final promptDetails = usageMetadata.promptTokensDetails + ?.map((d) => '${d.modality.name}: ${d.tokenCount}') + .join(', '); + final candidateDetails = usageMetadata.candidatesTokensDetails + ?.map((d) => '${d.modality.name}: ${d.tokenCount}') + .join(', '); + final message = ''' Usage Metadata: -- promptTokenCount: ${usageMetadata.promptTokenCount} -- candidatesTokenCount: ${usageMetadata.candidatesTokenCount} +- promptTokenCount: ${usageMetadata.promptTokenCount} (Details: $promptDetails) +- candidatesTokenCount: ${usageMetadata.candidatesTokenCount} (Details: $candidateDetails) - totalTokenCount: ${usageMetadata.totalTokenCount} - thoughtsTokenCount: ${usageMetadata.thoughtsTokenCount} -- toolUsePromptTokenCount: ${usageMetadata.toolUsePromptTokenCount} - cachedContentTokenCount: ${usageMetadata.cachedContentTokenCount} -- promptTokensDetails: ${usageMetadata.promptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- candidatesTokensDetails: ${usageMetadata.candidatesTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- toolUsePromptTokensDetails: ${usageMetadata.toolUsePromptTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} -- cacheTokensDetails: ${usageMetadata.cacheTokensDetails?.map((d) => '${d.modality}: ${d.tokenCount}')} '''; - _tokensMessages.add(MessageData(text: message, fromUser: false)); + _tokensMessages.add( + MessageData( + text: message, + fromUser: false, + ), + ); } else { _tokensMessages.add( - MessageData(text: 'No usage metadata available.', fromUser: false), + MessageData( + text: 'No usage metadata available.', + fromUser: false, + ), ); } } catch (e) { @@ -763,9 +846,11 @@ Usage Metadata: child: ListView.builder( controller: _tokensScrollController, itemBuilder: (context, idx) { + final msg = _tokensMessages[idx]; return MessageWidget( - text: _tokensMessages[idx].text, - isFromUser: _tokensMessages[idx].fromUser ?? false, + text: msg.text, + isFromUser: msg.fromUser ?? false, + isThought: msg.isThought, ); }, itemCount: _tokensMessages.length, From 9c20b0b65cc6a1aff288fc5fe97cf2866d64c76f Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 19 May 2026 19:13:40 -0700 Subject: [PATCH 3/5] add integration test page --- .../firebase_ai/example/lib/main.dart | 21 +- .../lib/pages/integration_test_page.dart | 795 ++++++++++++++++++ 2 files changed, 812 insertions(+), 4 deletions(-) create mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 2f44dd370596..16eef3c46e8d 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -19,7 +19,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; // Import after file is generated through flutterfire_cli. -import 'package:firebase_ai_example/firebase_options.dart'; +// import 'package:firebase_ai_example/firebase_options.dart'; import 'pages/bidi_page.dart'; import 'pages/chat_page.dart'; @@ -28,14 +28,15 @@ import 'pages/image_generation_page.dart'; import 'pages/capabilities_page.dart'; import 'pages/server_template_page.dart'; import 'pages/grounding_page.dart'; +import 'pages/integration_test_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Enable this line instead once have the firebase_options.dart generated and // imported through flutterfire_cli. - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - // await Firebase.initializeApp(); - // await FirebaseAuth.instance.signInAnonymously(); + // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + await Firebase.initializeApp(); + await FirebaseAuth.instance.signInAnonymously(); runApp(const GenerativeAISample()); } @@ -189,6 +190,18 @@ class _HomeScreenState extends State { 'Flutter + ${widget.useVertexBackend ? 'Vertex AI' : 'Google AI'}', ), actions: [ + IconButton( + icon: const Icon(Icons.playlist_play), + tooltip: 'Run Integration Tests', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const IntegrationTestPage(), + ), + ); + }, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart new file mode 100644 index 000000000000..b78397be2e68 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart @@ -0,0 +1,795 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:firebase_ai/firebase_ai.dart'; + +class IntegrationTestPage extends StatefulWidget { + const IntegrationTestPage({super.key}); + + @override + State createState() => _IntegrationTestPageState(); +} + +enum TestStatus { pending, running, passed, failed } + +class TestResult { + final TestStatus status; + final String logs; + final String? responseJson; + final String? errorMessage; + + TestResult({ + required this.status, + required this.logs, + this.responseJson, + this.errorMessage, + }); + + static TestResult pending() => TestResult(status: TestStatus.pending, logs: 'Pending...'); +} + +class TestItem { + final String id; + final String name; + final String description; + final Future Function(FirebaseAI provider, TestLogger logger) run; + TestResult googleAIResult; + TestResult vertexAIResult; + + TestItem({ + required this.id, + required this.name, + required this.description, + required this.run, + }) : googleAIResult = TestResult.pending(), + vertexAIResult = TestResult.pending(); +} + +class TestLogger { + final _buffer = StringBuffer(); + void log(String message) { + final time = DateTime.now().toIso8601String().substring(11, 19); + _buffer.writeln('[$time] $message'); + } + + @override + String toString() => _buffer.toString(); +} + +class _IntegrationTestPageState extends State { + bool _isRunning = false; + double _progress = 0.0; + late List _testCases; + + @override + void initState() { + super.initState(); + _initializeTestCases(); + } + + void _initializeTestCases() { + _testCases = [ + TestItem( + id: '1', + name: 'Stateless Text Gen (gemini-3.1-flash-lite)', + description: 'Verifies simple stateless text generation with a precise answer target using gemini-3.1-flash-lite.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.1-flash-lite...'); + final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + const prompt = "Reply with exactly the word 'SUCCESS' in uppercase."; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response text: "${response.text}"'); + if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? false) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected response to contain "SUCCESS"', + ); + } + }, + ), + TestItem( + id: '2', + name: 'Stateless Text Gen (gemini-3.5-flash)', + description: 'Verifies simple stateless text generation with a precise answer target using gemini-3.5-flash.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.5-flash...'); + final model = provider.generativeModel(model: 'gemini-3.5-flash'); + const prompt = "Reply with exactly the word 'SUCCESS' in uppercase."; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response text: "${response.text}"'); + if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? false) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected response to contain "SUCCESS"', + ); + } + }, + ), + TestItem( + id: '3', + name: 'System Instructions', + description: 'Verifies system instructions config is serialized and respected.', + run: (provider, logger) async { + logger.log('Initializing model with medieval system instruction...'); + final model = provider.generativeModel( + model: 'gemini-3.1-flash-lite', + systemInstruction: Content.text("You are a medieval knight. Respond only with Shakespearean knightly terms."), + ); + const prompt = "Who are you?"; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response received: "${response.text}"'); + final responseText = response.text?.toLowerCase() ?? ''; + final containsKnightTerms = responseText.contains('thou') || + responseText.contains('thee') || + responseText.contains('sir') || + responseText.contains('knight') || + responseText.contains('ye') || + responseText.contains('hath') || + responseText.contains('doth'); + if (containsKnightTerms) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Response did not seem to contain Shakespearean knightly terms.', + ); + } + }, + ), + TestItem( + id: '4', + name: 'Stateful Chat History', + description: 'Verifies stateful conversation preservation across turns via ChatSession.', + run: (provider, logger) async { + logger.log('Initializing model and starting ChatSession...'); + final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + final chat = model.startChat(); + + const prompt1 = "My secret agent name is Agent Orange."; + logger.log('Sending message 1: "$prompt1"'); + final resp1 = await chat.sendMessage(Content.text(prompt1)); + logger.log('Response 1: "${resp1.text}"'); + + const prompt2 = "What is my secret agent name?"; + logger.log('Sending message 2: "$prompt2"'); + final resp2 = await chat.sendMessage(Content.text(prompt2)); + logger.log('Response 2: "${resp2.text}"'); + + if (resp2.text?.toLowerCase().contains('agent orange') ?? false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: 'Turn 1 Response:\n${resp1.text}\n\nTurn 2 Response:\n${resp2.text}', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: resp2.text, + errorMessage: 'Expected second response to recall "Agent Orange"', + ); + } + }, + ), + TestItem( + id: '5', + name: 'Auto Function Calling', + description: 'Verifies automatic function/tool call execution via AutoFunctionDeclaration.', + run: (provider, logger) async { + logger.log('Declaring AutoFunctionDeclaration for getSuperHeroPower...'); + final autoPowerTool = AutoFunctionDeclaration( + name: 'getSuperHeroPower', + description: 'Returns the superpower of a given superhero by name.', + parameters: { + 'heroName': Schema.string(description: 'The name of the superhero.'), + }, + callable: (args) async { + final hero = args['heroName'] as String?; + logger.log('CALLBACK TRIGGERED: getSuperHeroPower called for "$hero"'); + if (hero?.toLowerCase().contains('laserman') ?? false) { + return {'power': 'Laser Eyes'}; + } + return {'power': 'Super Strength'}; + }, + ); + + final model = provider.generativeModel( + model: 'gemini-3.1-flash-lite', + tools: [Tool.functionDeclarations([autoPowerTool])], + ); + final chat = model.startChat(); + + const prompt = "What superpower does LaserMan have?"; + logger.log('Sending message triggering auto-function: "$prompt"'); + final response = await chat.sendMessage(Content.text(prompt)); + logger.log('Final response: "${response.text}"'); + + if (response.text?.toLowerCase().contains('laser') ?? false) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected final response to mention "Laser Eyes" or "Laser"', + ); + } + }, + ), + TestItem( + id: '6', + name: 'Manual Function Calling', + description: 'Verifies manual function call prediction and response handling.', + run: (provider, logger) async { + logger.log('Declaring FunctionDeclaration...'); + final powerTool = FunctionDeclaration( + 'getSuperHeroPower', + 'Returns the superpower of a given superhero by name.', + parameters: { + 'heroName': Schema.string(description: 'The name of the superhero.'), + }, + ); + + final model = provider.generativeModel( + model: 'gemini-3.1-flash-lite', + tools: [Tool.functionDeclarations([powerTool])], + ); + + const prompt = "What superpower does LaserMan have?"; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + final functionCalls = response.functionCalls; + + logger.log('Function calls predicted: ${functionCalls.map((c) => c.name).toList()}'); + + if (functionCalls.isEmpty) { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected function call prediction but got none.', + ); + } + + final call = functionCalls.first; + logger.log('Intercepted call details: name=${call.name}, args=${call.args}'); + final hero = call.args['heroName'] as String?; + String power = 'Super Strength'; + if (hero?.toLowerCase().contains('laserman') ?? false) { + power = 'Laser Eyes'; + } + + logger.log('Manually constructing FunctionResponse: {"power": "$power"}'); + final manualResponse = FunctionResponse(call.name, {'power': power}); + + logger.log('Sending second request with history + FunctionResponse...'); + final nextResponse = await model.generateContent([ + Content.text(prompt), + response.candidates.first.content, + Content.functionResponses([manualResponse]), + ]); + + logger.log('Final response: "${nextResponse.text}"'); + if (nextResponse.text?.toLowerCase().contains('laser') ?? false) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: nextResponse.text); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: nextResponse.text, + errorMessage: 'Expected final response to mention "Laser Eyes"', + ); + } + }, + ), + TestItem( + id: '7', + name: 'Code Execution Tool', + description: 'Verifies Tool.codeExecution() internal code execution and parts extraction.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.5-flash with Tool.codeExecution()...'); + final model = provider.generativeModel( + model: 'gemini-3.5-flash', + tools: [Tool.codeExecution()], + ); + + const prompt = "Write a Python script to print the 10th Fibonacci number, then execute it."; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + + final parts = response.candidates.firstOrNull?.content.parts ?? []; + final hasExecutableCode = parts.any((p) => p is ExecutableCodePart); + final hasCodeResult = parts.any((p) => p is CodeExecutionResultPart); + + logger.log('Response received. Parsing parts:'); + for (var i = 0; i < parts.length; i++) { + final p = parts[i]; + logger.log(' Part $i: Type=${p.runtimeType}'); + if (p is ExecutableCodePart) { + logger.log(' Code:\n${p.code}'); + } else if (p is CodeExecutionResultPart) { + logger.log(' Output:\n${p.output}'); + } + } + + if (hasExecutableCode && hasCodeResult) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Missing ExecutableCodePart or CodeExecutionResultPart in response parts.', + ); + } + }, + ), + TestItem( + id: '8', + name: 'Search Grounding (gemini-3.5-flash)', + description: 'Verifies Google Search Grounding tool configuration and grounding metadata.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.5-flash with Tool.googleSearch()...'); + final model = provider.generativeModel( + model: 'gemini-3.5-flash', + tools: [Tool.googleSearch()], + ); + const prompt = "Who is the current CEO of Google?"; + logger.log('Sending prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + logger.log('Response text: "${response.text}"'); + + final grounding = response.candidates.firstOrNull?.groundingMetadata; + if (grounding != null) { + final sources = grounding.groundingChunks.map((c) => c.web?.uri ?? '').join('\n'); + logger.log('Grounding chunks found. Web sources:\n$sources'); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: 'Text Response: ${response.text}\n\nGrounding Sources:\n$sources', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'Expected GroundingMetadata in response but got null.', + ); + } + }, + ), + TestItem( + id: '9', + name: 'Streaming Generation', + description: 'Verifies generateContentStream works and aggregates chunks correctly.', + run: (provider, logger) async { + logger.log('Initializing model for streaming...'); + final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + + const prompt = "Write a 2-paragraph poem about a computer."; + logger.log('Starting prompt stream: "$prompt"'); + final stream = model.generateContentStream([Content.text(prompt)]); + + final textBuffer = StringBuffer(); + int chunkCount = 0; + + await for (final chunk in stream) { + chunkCount++; + final chunkText = chunk.text ?? ''; + logger.log('Chunk $chunkCount: length=${chunkText.length}'); + textBuffer.write(chunkText); + } + + logger.log('Stream finished. Total chunks=$chunkCount, aggregated length=${textBuffer.length}'); + if (textBuffer.isNotEmpty) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: textBuffer.toString()); + } else { + return TestResult(status: TestStatus.failed, logs: logger.toString(), errorMessage: 'Stream aggregated output was empty.'); + } + }, + ), + TestItem( + id: '10', + name: 'Advanced Token Counting', + description: 'Verifies counting tokens on multimodal inputs (text + image asset).', + run: (provider, logger) async { + logger.log('Loading cat.jpg asset...'); + final catBytes = await rootBundle.load('assets/images/cat.jpg'); + + logger.log('Initializing model for token counting...'); + final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + + final content = [ + Content.multi([ + const TextPart('Describe this cat.'), + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + ]), + ]; + + logger.log('Invoking countTokens API...'); + final response = await model.countTokens(content); + logger.log('Total Tokens: ${response.totalTokens}'); + + if (response.totalTokens > 0) { + return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: 'Total Tokens: ${response.totalTokens}'); + } else { + return TestResult(status: TestStatus.failed, logs: logger.toString(), errorMessage: 'Expected token count > 0'); + } + }, + ), + TestItem( + id: '11', + name: 'Usage Metadata & Thinking', + description: 'Verifies extraction of thought logs and usage metadata with ThinkingConfig.', + run: (provider, logger) async { + logger.log('Initializing model gemini-3.5-flash with ThinkingConfig...'); + final model = provider.generativeModel( + model: 'gemini-3.5-flash', + generationConfig: GenerationConfig( + thinkingConfig: ThinkingConfig.withThinkingBudget(2048, includeThoughts: true), + ), + ); + + const prompt = "If a train travels 100 miles at 50 mph, and another travels 120 miles at 60 mph, which arrives first?"; + logger.log('Sending prompt with thinking enabled: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + + final usage = response.usageMetadata; + final thoughts = response.thoughtSummary; + logger.log('Response text: "${response.text}"'); + logger.log('Thoughts extracted: "$thoughts"'); + logger.log('Usage Details: prompt=${usage?.promptTokenCount}, candidates=${usage?.candidatesTokenCount}, total=${usage?.totalTokenCount}, thoughts=${usage?.thoughtsTokenCount}'); + + if (usage != null && usage.totalTokenCount! > 0) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: 'Thoughts:\n$thoughts\n\nResponse:\n${response.text}\n\nMetadata:\n- Total Tokens: ${usage.totalTokenCount}\n- Thoughts Tokens: ${usage.thoughtsTokenCount}', + ); + } else { + return TestResult(status: TestStatus.failed, logs: logger.toString(), errorMessage: 'Usage metadata was null or invalid.'); + } + }, + ), + TestItem( + id: '12', + name: 'Multimodal Response Modality', + description: 'Verifies text + image response output generation via gemini-2.5-flash-image model.', + run: (provider, logger) async { + logger.log('Initializing gemini-2.5-flash-image with dual response modalities...'); + final model = provider.generativeModel( + model: 'gemini-2.5-flash-image', + generationConfig: GenerationConfig( + responseModalities: [ResponseModalities.text, ResponseModalities.image], + ), + ); + + const prompt = "Draw a simple red triangle on a blue background."; + logger.log('Sending multimodal output prompt: "$prompt"'); + final response = await model.generateContent([Content.text(prompt)]); + + final imageParts = response.inlineDataParts.where((p) => p.mimeType.startsWith('image/')); + logger.log('Response text: "${response.text}"'); + logger.log('Image parts returned: ${imageParts.length}'); + + if (imageParts.isNotEmpty) { + final img = imageParts.first; + logger.log('Generated Image: mime=${img.mimeType}, size=${img.bytes.length} bytes'); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: 'Text Response: ${response.text}\nImage generated successfully: mime=${img.mimeType}, size=${img.bytes.length} bytes', + ); + } else { + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + responseJson: response.text, + errorMessage: 'No generated image was returned in response inline parts.', + ); + } + }, + ), + ]; + } + + Future _runTestItem(TestItem item, bool isVertex) async { + final provider = isVertex ? FirebaseAI.vertexAI(location: 'global') : FirebaseAI.googleAI(); + final logger = TestLogger(); + + setState(() { + if (isVertex) { + item.vertexAIResult = TestResult(status: TestStatus.running, logs: 'Running...'); + } else { + item.googleAIResult = TestResult(status: TestStatus.running, logs: 'Running...'); + } + }); + + try { + logger.log('Starting execution for ${item.name} (${isVertex ? 'Vertex AI' : 'Google AI'})...'); + final result = await item.run(provider, logger); + setState(() { + if (isVertex) { + item.vertexAIResult = result; + } else { + item.googleAIResult = result; + } + }); + } catch (e, s) { + logger.log('CRITICAL ERROR: $e\n$s'); + setState(() { + final failResult = TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: e.toString(), + ); + if (isVertex) { + item.vertexAIResult = failResult; + } else { + item.googleAIResult = failResult; + } + }); + } + } + + Future _runSuite() async { + if (_isRunning) return; + + setState(() { + _isRunning = true; + _progress = 0.0; + for (var item in _testCases) { + item.googleAIResult = TestResult.pending(); + item.vertexAIResult = TestResult.pending(); + } + }); + + final totalSteps = _testCases.length * 2; + int completedSteps = 0; + + for (var item in _testCases) { + // Run Google AI + await _runTestItem(item, false); + completedSteps++; + setState(() { + _progress = completedSteps / totalSteps; + }); + + // Run Vertex AI + await _runTestItem(item, true); + completedSteps++; + setState(() { + _progress = completedSteps / totalSteps; + }); + } + + setState(() { + _isRunning = false; + }); + } + + Widget _buildProviderSummary(String title, List results) { + final total = results.length; + final passed = results.where((r) => r.status == TestStatus.passed).length; + final failed = results.where((r) => r.status == TestStatus.failed).length; + final running = results.where((r) => r.status == TestStatus.running).length; + + Color cardColor = Colors.blueGrey.shade900; + if (total > 0 && running == 0) { + if (failed > 0) { + cardColor = Colors.red.shade900.withAlpha(150); + } else if (passed == total) { + cardColor = Colors.green.shade900.withAlpha(150); + } + } + + return Card( + color: cardColor, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Pass: $passed/$total', style: TextStyle(color: Colors.green.shade300)), + Text('Fail: $failed', style: TextStyle(color: failed > 0 ? Colors.red.shade300 : Colors.grey)), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusBadge(TestResult result) { + switch (result.status) { + case TestStatus.pending: + return Chip( + label: const Text('PENDING', style: TextStyle(fontSize: 10, color: Colors.grey)), + backgroundColor: Colors.grey.shade800, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + case TestStatus.running: + return const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ); + case TestStatus.passed: + return Chip( + label: const Text('PASS', style: TextStyle(fontSize: 10, color: Colors.green, fontWeight: FontWeight.bold)), + backgroundColor: Colors.green.withAlpha(40), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + case TestStatus.failed: + return Chip( + label: const Text('FAIL', style: TextStyle(fontSize: 10, color: Colors.red, fontWeight: FontWeight.bold)), + backgroundColor: Colors.red.withAlpha(40), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + } + + Widget _buildLogsConsole(String title, TestResult result) { + return Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.black.withAlpha(150), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade800), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue)), + const Divider(height: 8), + if (result.errorMessage != null) ...[ + Text('ERROR:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red.shade300)), + SelectableText(result.errorMessage!, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.red.shade200)), + const SizedBox(height: 8), + ], + if (result.responseJson != null) ...[ + const Text('RESPONSE:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purpleAccent)), + SelectableText(result.responseJson!, style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.purpleAccent)), + const SizedBox(height: 8), + ], + const Text('EXECUTION LOGS:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white70)), + SelectableText(result.logs, style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white70)), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final googleAIResults = _testCases.map((item) => item.googleAIResult).toList(); + final vertexAIResults = _testCases.map((item) => item.vertexAIResult).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('One-Click SDK Integration Tests'), + actions: [ + if (!_isRunning) + IconButton( + icon: const Icon(Icons.play_arrow, color: Colors.greenAccent), + onPressed: _runSuite, + tooltip: 'Run All Tests', + ) + else + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.cyanAccent), + ), + ), + ], + ), + body: Column( + children: [ + if (_isRunning) + LinearProgressIndicator(value: _progress, minHeight: 6, color: Colors.cyanAccent), + Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + Expanded(child: _buildProviderSummary('Google AI Suite', googleAIResults)), + const SizedBox(width: 8), + Expanded(child: _buildProviderSummary('Vertex AI Suite', vertexAIResults)), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: _testCases.length, + itemBuilder: (context, index) { + final item = _testCases[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ExpansionTile( + leading: CircleAvatar( + backgroundColor: Colors.grey.shade800, + child: Text(item.id, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + title: Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(item.description, style: const TextStyle(fontSize: 12, color: Colors.grey)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('G: ', style: TextStyle(fontSize: 10, color: Colors.grey)), + _buildStatusBadge(item.googleAIResult), + ], + ), + const SizedBox(width: 8), + Row( + children: [ + const Text('V: ', style: TextStyle(fontSize: 10, color: Colors.grey)), + _buildStatusBadge(item.vertexAIResult), + ], + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildLogsConsole('Google AI Details', item.googleAIResult)), + const SizedBox(width: 8), + Expanded(child: _buildLogsConsole('Vertex AI Details', item.vertexAIResult)), + ], + ), + ], + ), + ) + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} From 64bc0a8b0732c0e8a2252f29f7f1e9f46c6ce9d5 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 19 May 2026 19:16:41 -0700 Subject: [PATCH 4/5] fix analyzer and format --- .../lib/pages/integration_test_page.dart | 378 ++++++++++++------ 1 file changed, 266 insertions(+), 112 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart index b78397be2e68..42f8b92d7b4c 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart @@ -39,7 +39,8 @@ class TestResult { this.errorMessage, }); - static TestResult pending() => TestResult(status: TestStatus.pending, logs: 'Pending...'); + static TestResult pending() => + TestResult(status: TestStatus.pending, logs: 'Pending...'); } class TestItem { @@ -72,7 +73,7 @@ class TestLogger { class _IntegrationTestPageState extends State { bool _isRunning = false; - double _progress = 0.0; + double _progress = 0; late List _testCases; @override @@ -86,16 +87,22 @@ class _IntegrationTestPageState extends State { TestItem( id: '1', name: 'Stateless Text Gen (gemini-3.1-flash-lite)', - description: 'Verifies simple stateless text generation with a precise answer target using gemini-3.1-flash-lite.', + description: + 'Verifies simple stateless text generation with a precise answer target using gemini-3.1-flash-lite.', run: (provider, logger) async { logger.log('Initializing model gemini-3.1-flash-lite...'); - final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); const prompt = "Reply with exactly the word 'SUCCESS' in uppercase."; logger.log('Sending prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); logger.log('Response text: "${response.text}"'); - if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? false) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? + false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text); } else { return TestResult( status: TestStatus.failed, @@ -109,7 +116,8 @@ class _IntegrationTestPageState extends State { TestItem( id: '2', name: 'Stateless Text Gen (gemini-3.5-flash)', - description: 'Verifies simple stateless text generation with a precise answer target using gemini-3.5-flash.', + description: + 'Verifies simple stateless text generation with a precise answer target using gemini-3.5-flash.', run: (provider, logger) async { logger.log('Initializing model gemini-3.5-flash...'); final model = provider.generativeModel(model: 'gemini-3.5-flash'); @@ -117,8 +125,12 @@ class _IntegrationTestPageState extends State { logger.log('Sending prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); logger.log('Response text: "${response.text}"'); - if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? false) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + if (response.text?.trim().toUpperCase().contains('SUCCESS') ?? + false) { + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text); } else { return TestResult( status: TestStatus.failed, @@ -132,14 +144,16 @@ class _IntegrationTestPageState extends State { TestItem( id: '3', name: 'System Instructions', - description: 'Verifies system instructions config is serialized and respected.', + description: + 'Verifies system instructions config is serialized and respected.', run: (provider, logger) async { logger.log('Initializing model with medieval system instruction...'); final model = provider.generativeModel( model: 'gemini-3.1-flash-lite', - systemInstruction: Content.text("You are a medieval knight. Respond only with Shakespearean knightly terms."), + systemInstruction: Content.text( + 'You are a medieval knight. Respond only with Shakespearean knightly terms.'), ); - const prompt = "Who are you?"; + const prompt = 'Who are you?'; logger.log('Sending prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); logger.log('Response received: "${response.text}"'); @@ -152,13 +166,17 @@ class _IntegrationTestPageState extends State { responseText.contains('hath') || responseText.contains('doth'); if (containsKnightTerms) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), responseJson: response.text, - errorMessage: 'Response did not seem to contain Shakespearean knightly terms.', + errorMessage: + 'Response did not seem to contain Shakespearean knightly terms.', ); } }, @@ -166,18 +184,20 @@ class _IntegrationTestPageState extends State { TestItem( id: '4', name: 'Stateful Chat History', - description: 'Verifies stateful conversation preservation across turns via ChatSession.', + description: + 'Verifies stateful conversation preservation across turns via ChatSession.', run: (provider, logger) async { logger.log('Initializing model and starting ChatSession...'); - final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); final chat = model.startChat(); - const prompt1 = "My secret agent name is Agent Orange."; + const prompt1 = 'My secret agent name is Agent Orange.'; logger.log('Sending message 1: "$prompt1"'); final resp1 = await chat.sendMessage(Content.text(prompt1)); logger.log('Response 1: "${resp1.text}"'); - const prompt2 = "What is my secret agent name?"; + const prompt2 = 'What is my secret agent name?'; logger.log('Sending message 2: "$prompt2"'); final resp2 = await chat.sendMessage(Content.text(prompt2)); logger.log('Response 2: "${resp2.text}"'); @@ -186,7 +206,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: 'Turn 1 Response:\n${resp1.text}\n\nTurn 2 Response:\n${resp2.text}', + responseJson: + 'Turn 1 Response:\n${resp1.text}\n\nTurn 2 Response:\n${resp2.text}', ); } else { return TestResult( @@ -201,18 +222,22 @@ class _IntegrationTestPageState extends State { TestItem( id: '5', name: 'Auto Function Calling', - description: 'Verifies automatic function/tool call execution via AutoFunctionDeclaration.', + description: + 'Verifies automatic function/tool call execution via AutoFunctionDeclaration.', run: (provider, logger) async { - logger.log('Declaring AutoFunctionDeclaration for getSuperHeroPower...'); + logger.log( + 'Declaring AutoFunctionDeclaration for getSuperHeroPower...'); final autoPowerTool = AutoFunctionDeclaration( name: 'getSuperHeroPower', description: 'Returns the superpower of a given superhero by name.', parameters: { - 'heroName': Schema.string(description: 'The name of the superhero.'), + 'heroName': + Schema.string(description: 'The name of the superhero.'), }, callable: (args) async { final hero = args['heroName'] as String?; - logger.log('CALLBACK TRIGGERED: getSuperHeroPower called for "$hero"'); + logger.log( + 'CALLBACK TRIGGERED: getSuperHeroPower called for "$hero"'); if (hero?.toLowerCase().contains('laserman') ?? false) { return {'power': 'Laser Eyes'}; } @@ -222,23 +247,29 @@ class _IntegrationTestPageState extends State { final model = provider.generativeModel( model: 'gemini-3.1-flash-lite', - tools: [Tool.functionDeclarations([autoPowerTool])], + tools: [ + Tool.functionDeclarations([autoPowerTool]) + ], ); final chat = model.startChat(); - const prompt = "What superpower does LaserMan have?"; + const prompt = 'What superpower does LaserMan have?'; logger.log('Sending message triggering auto-function: "$prompt"'); final response = await chat.sendMessage(Content.text(prompt)); logger.log('Final response: "${response.text}"'); if (response.text?.toLowerCase().contains('laser') ?? false) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), responseJson: response.text, - errorMessage: 'Expected final response to mention "Laser Eyes" or "Laser"', + errorMessage: + 'Expected final response to mention "Laser Eyes" or "Laser"', ); } }, @@ -246,28 +277,33 @@ class _IntegrationTestPageState extends State { TestItem( id: '6', name: 'Manual Function Calling', - description: 'Verifies manual function call prediction and response handling.', + description: + 'Verifies manual function call prediction and response handling.', run: (provider, logger) async { logger.log('Declaring FunctionDeclaration...'); final powerTool = FunctionDeclaration( 'getSuperHeroPower', 'Returns the superpower of a given superhero by name.', parameters: { - 'heroName': Schema.string(description: 'The name of the superhero.'), + 'heroName': + Schema.string(description: 'The name of the superhero.'), }, ); final model = provider.generativeModel( model: 'gemini-3.1-flash-lite', - tools: [Tool.functionDeclarations([powerTool])], + tools: [ + Tool.functionDeclarations([powerTool]) + ], ); - const prompt = "What superpower does LaserMan have?"; + const prompt = 'What superpower does LaserMan have?'; logger.log('Sending prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); final functionCalls = response.functionCalls; - logger.log('Function calls predicted: ${functionCalls.map((c) => c.name).toList()}'); + logger.log( + 'Function calls predicted: ${functionCalls.map((c) => c.name).toList()}'); if (functionCalls.isEmpty) { return TestResult( @@ -279,17 +315,20 @@ class _IntegrationTestPageState extends State { } final call = functionCalls.first; - logger.log('Intercepted call details: name=${call.name}, args=${call.args}'); + logger.log( + 'Intercepted call details: name=${call.name}, args=${call.args}'); final hero = call.args['heroName'] as String?; String power = 'Super Strength'; if (hero?.toLowerCase().contains('laserman') ?? false) { power = 'Laser Eyes'; } - logger.log('Manually constructing FunctionResponse: {"power": "$power"}'); + logger.log( + 'Manually constructing FunctionResponse: {"power": "$power"}'); final manualResponse = FunctionResponse(call.name, {'power': power}); - logger.log('Sending second request with history + FunctionResponse...'); + logger + .log('Sending second request with history + FunctionResponse...'); final nextResponse = await model.generateContent([ Content.text(prompt), response.candidates.first.content, @@ -298,7 +337,10 @@ class _IntegrationTestPageState extends State { logger.log('Final response: "${nextResponse.text}"'); if (nextResponse.text?.toLowerCase().contains('laser') ?? false) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: nextResponse.text); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: nextResponse.text); } else { return TestResult( status: TestStatus.failed, @@ -312,15 +354,18 @@ class _IntegrationTestPageState extends State { TestItem( id: '7', name: 'Code Execution Tool', - description: 'Verifies Tool.codeExecution() internal code execution and parts extraction.', + description: + 'Verifies Tool.codeExecution() internal code execution and parts extraction.', run: (provider, logger) async { - logger.log('Initializing model gemini-3.5-flash with Tool.codeExecution()...'); + logger.log( + 'Initializing model gemini-3.5-flash with Tool.codeExecution()...'); final model = provider.generativeModel( model: 'gemini-3.5-flash', tools: [Tool.codeExecution()], ); - const prompt = "Write a Python script to print the 10th Fibonacci number, then execute it."; + const prompt = + 'Write a Python script to print the 10th Fibonacci number, then execute it.'; logger.log('Sending prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); @@ -340,13 +385,17 @@ class _IntegrationTestPageState extends State { } if (hasExecutableCode && hasCodeResult) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: response.text); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: response.text); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), responseJson: response.text, - errorMessage: 'Missing ExecutableCodePart or CodeExecutionResultPart in response parts.', + errorMessage: + 'Missing ExecutableCodePart or CodeExecutionResultPart in response parts.', ); } }, @@ -354,33 +403,39 @@ class _IntegrationTestPageState extends State { TestItem( id: '8', name: 'Search Grounding (gemini-3.5-flash)', - description: 'Verifies Google Search Grounding tool configuration and grounding metadata.', + description: + 'Verifies Google Search Grounding tool configuration and grounding metadata.', run: (provider, logger) async { - logger.log('Initializing model gemini-3.5-flash with Tool.googleSearch()...'); + logger.log( + 'Initializing model gemini-3.5-flash with Tool.googleSearch()...'); final model = provider.generativeModel( model: 'gemini-3.5-flash', tools: [Tool.googleSearch()], ); - const prompt = "Who is the current CEO of Google?"; + const prompt = 'Who is the current CEO of Google?'; logger.log('Sending prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); logger.log('Response text: "${response.text}"'); final grounding = response.candidates.firstOrNull?.groundingMetadata; if (grounding != null) { - final sources = grounding.groundingChunks.map((c) => c.web?.uri ?? '').join('\n'); + final sources = grounding.groundingChunks + .map((c) => c.web?.uri ?? '') + .join('\n'); logger.log('Grounding chunks found. Web sources:\n$sources'); return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: 'Text Response: ${response.text}\n\nGrounding Sources:\n$sources', + responseJson: + 'Text Response: ${response.text}\n\nGrounding Sources:\n$sources', ); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), responseJson: response.text, - errorMessage: 'Expected GroundingMetadata in response but got null.', + errorMessage: + 'Expected GroundingMetadata in response but got null.', ); } }, @@ -388,12 +443,14 @@ class _IntegrationTestPageState extends State { TestItem( id: '9', name: 'Streaming Generation', - description: 'Verifies generateContentStream works and aggregates chunks correctly.', + description: + 'Verifies generateContentStream works and aggregates chunks correctly.', run: (provider, logger) async { logger.log('Initializing model for streaming...'); - final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); - const prompt = "Write a 2-paragraph poem about a computer."; + const prompt = 'Write a 2-paragraph poem about a computer.'; logger.log('Starting prompt stream: "$prompt"'); final stream = model.generateContentStream([Content.text(prompt)]); @@ -407,24 +464,33 @@ class _IntegrationTestPageState extends State { textBuffer.write(chunkText); } - logger.log('Stream finished. Total chunks=$chunkCount, aggregated length=${textBuffer.length}'); + logger.log( + 'Stream finished. Total chunks=$chunkCount, aggregated length=${textBuffer.length}'); if (textBuffer.isNotEmpty) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: textBuffer.toString()); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: textBuffer.toString()); } else { - return TestResult(status: TestStatus.failed, logs: logger.toString(), errorMessage: 'Stream aggregated output was empty.'); + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: 'Stream aggregated output was empty.'); } }, ), TestItem( id: '10', name: 'Advanced Token Counting', - description: 'Verifies counting tokens on multimodal inputs (text + image asset).', + description: + 'Verifies counting tokens on multimodal inputs (text + image asset).', run: (provider, logger) async { logger.log('Loading cat.jpg asset...'); final catBytes = await rootBundle.load('assets/images/cat.jpg'); logger.log('Initializing model for token counting...'); - final model = provider.generativeModel(model: 'gemini-3.1-flash-lite'); + final model = + provider.generativeModel(model: 'gemini-3.1-flash-lite'); final content = [ Content.multi([ @@ -438,26 +504,36 @@ class _IntegrationTestPageState extends State { logger.log('Total Tokens: ${response.totalTokens}'); if (response.totalTokens > 0) { - return TestResult(status: TestStatus.passed, logs: logger.toString(), responseJson: 'Total Tokens: ${response.totalTokens}'); + return TestResult( + status: TestStatus.passed, + logs: logger.toString(), + responseJson: 'Total Tokens: ${response.totalTokens}'); } else { - return TestResult(status: TestStatus.failed, logs: logger.toString(), errorMessage: 'Expected token count > 0'); + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: 'Expected token count > 0'); } }, ), TestItem( id: '11', name: 'Usage Metadata & Thinking', - description: 'Verifies extraction of thought logs and usage metadata with ThinkingConfig.', + description: + 'Verifies extraction of thought logs and usage metadata with ThinkingConfig.', run: (provider, logger) async { - logger.log('Initializing model gemini-3.5-flash with ThinkingConfig...'); + logger.log( + 'Initializing model gemini-3.5-flash with ThinkingConfig...'); final model = provider.generativeModel( model: 'gemini-3.5-flash', generationConfig: GenerationConfig( - thinkingConfig: ThinkingConfig.withThinkingBudget(2048, includeThoughts: true), + thinkingConfig: ThinkingConfig.withThinkingBudget(2048, + includeThoughts: true), ), ); - const prompt = "If a train travels 100 miles at 50 mph, and another travels 120 miles at 60 mph, which arrives first?"; + const prompt = + 'If a train travels 100 miles at 50 mph, and another travels 120 miles at 60 mph, which arrives first?'; logger.log('Sending prompt with thinking enabled: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); @@ -465,54 +541,68 @@ class _IntegrationTestPageState extends State { final thoughts = response.thoughtSummary; logger.log('Response text: "${response.text}"'); logger.log('Thoughts extracted: "$thoughts"'); - logger.log('Usage Details: prompt=${usage?.promptTokenCount}, candidates=${usage?.candidatesTokenCount}, total=${usage?.totalTokenCount}, thoughts=${usage?.thoughtsTokenCount}'); + logger.log( + 'Usage Details: prompt=${usage?.promptTokenCount}, candidates=${usage?.candidatesTokenCount}, total=${usage?.totalTokenCount}, thoughts=${usage?.thoughtsTokenCount}'); if (usage != null && usage.totalTokenCount! > 0) { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: 'Thoughts:\n$thoughts\n\nResponse:\n${response.text}\n\nMetadata:\n- Total Tokens: ${usage.totalTokenCount}\n- Thoughts Tokens: ${usage.thoughtsTokenCount}', + responseJson: + 'Thoughts:\n$thoughts\n\nResponse:\n${response.text}\n\nMetadata:\n- Total Tokens: ${usage.totalTokenCount}\n- Thoughts Tokens: ${usage.thoughtsTokenCount}', ); } else { - return TestResult(status: TestStatus.failed, logs: logger.toString(), errorMessage: 'Usage metadata was null or invalid.'); + return TestResult( + status: TestStatus.failed, + logs: logger.toString(), + errorMessage: 'Usage metadata was null or invalid.'); } }, ), TestItem( id: '12', name: 'Multimodal Response Modality', - description: 'Verifies text + image response output generation via gemini-2.5-flash-image model.', + description: + 'Verifies text + image response output generation via gemini-2.5-flash-image model.', run: (provider, logger) async { - logger.log('Initializing gemini-2.5-flash-image with dual response modalities...'); + logger.log( + 'Initializing gemini-2.5-flash-image with dual response modalities...'); final model = provider.generativeModel( model: 'gemini-2.5-flash-image', generationConfig: GenerationConfig( - responseModalities: [ResponseModalities.text, ResponseModalities.image], + responseModalities: [ + ResponseModalities.text, + ResponseModalities.image + ], ), ); - const prompt = "Draw a simple red triangle on a blue background."; + const prompt = 'Draw a simple red triangle on a blue background.'; logger.log('Sending multimodal output prompt: "$prompt"'); final response = await model.generateContent([Content.text(prompt)]); - final imageParts = response.inlineDataParts.where((p) => p.mimeType.startsWith('image/')); + final imageParts = response.inlineDataParts + .where((p) => p.mimeType.startsWith('image/')); logger.log('Response text: "${response.text}"'); logger.log('Image parts returned: ${imageParts.length}'); if (imageParts.isNotEmpty) { final img = imageParts.first; - logger.log('Generated Image: mime=${img.mimeType}, size=${img.bytes.length} bytes'); + logger.log( + 'Generated Image: mime=${img.mimeType}, size=${img.bytes.length} bytes'); return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: 'Text Response: ${response.text}\nImage generated successfully: mime=${img.mimeType}, size=${img.bytes.length} bytes', + responseJson: + 'Text Response: ${response.text}\nImage generated successfully: mime=${img.mimeType}, size=${img.bytes.length} bytes', ); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), responseJson: response.text, - errorMessage: 'No generated image was returned in response inline parts.', + errorMessage: + 'No generated image was returned in response inline parts.', ); } }, @@ -521,19 +611,24 @@ class _IntegrationTestPageState extends State { } Future _runTestItem(TestItem item, bool isVertex) async { - final provider = isVertex ? FirebaseAI.vertexAI(location: 'global') : FirebaseAI.googleAI(); + final provider = isVertex + ? FirebaseAI.vertexAI(location: 'global') + : FirebaseAI.googleAI(); final logger = TestLogger(); setState(() { if (isVertex) { - item.vertexAIResult = TestResult(status: TestStatus.running, logs: 'Running...'); + item.vertexAIResult = + TestResult(status: TestStatus.running, logs: 'Running...'); } else { - item.googleAIResult = TestResult(status: TestStatus.running, logs: 'Running...'); + item.googleAIResult = + TestResult(status: TestStatus.running, logs: 'Running...'); } }); try { - logger.log('Starting execution for ${item.name} (${isVertex ? 'Vertex AI' : 'Google AI'})...'); + logger.log( + 'Starting execution for ${item.name} (${isVertex ? 'Vertex AI' : 'Google AI'})...'); final result = await item.run(provider, logger); setState(() { if (isVertex) { @@ -564,8 +659,8 @@ class _IntegrationTestPageState extends State { setState(() { _isRunning = true; - _progress = 0.0; - for (var item in _testCases) { + _progress = 0; + for (final item in _testCases) { item.googleAIResult = TestResult.pending(); item.vertexAIResult = TestResult.pending(); } @@ -574,7 +669,7 @@ class _IntegrationTestPageState extends State { final totalSteps = _testCases.length * 2; int completedSteps = 0; - for (var item in _testCases) { + for (final item in _testCases) { // Run Google AI await _runTestItem(item, false); completedSteps++; @@ -613,17 +708,22 @@ class _IntegrationTestPageState extends State { return Card( color: cardColor, child: Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text(title, + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Pass: $passed/$total', style: TextStyle(color: Colors.green.shade300)), - Text('Fail: $failed', style: TextStyle(color: failed > 0 ? Colors.red.shade300 : Colors.grey)), + Text('Pass: $passed/$total', + style: TextStyle(color: Colors.green.shade300)), + Text('Fail: $failed', + style: TextStyle( + color: failed > 0 ? Colors.red.shade300 : Colors.grey)), ], ), ], @@ -636,7 +736,8 @@ class _IntegrationTestPageState extends State { switch (result.status) { case TestStatus.pending: return Chip( - label: const Text('PENDING', style: TextStyle(fontSize: 10, color: Colors.grey)), + label: const Text('PENDING', + style: TextStyle(fontSize: 10, color: Colors.grey)), backgroundColor: Colors.grey.shade800, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -649,14 +750,22 @@ class _IntegrationTestPageState extends State { ); case TestStatus.passed: return Chip( - label: const Text('PASS', style: TextStyle(fontSize: 10, color: Colors.green, fontWeight: FontWeight.bold)), + label: const Text('PASS', + style: TextStyle( + fontSize: 10, + color: Colors.green, + fontWeight: FontWeight.bold)), backgroundColor: Colors.green.withAlpha(40), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); case TestStatus.failed: return Chip( - label: const Text('FAIL', style: TextStyle(fontSize: 10, color: Colors.red, fontWeight: FontWeight.bold)), + label: const Text('FAIL', + style: TextStyle( + fontSize: 10, + color: Colors.red, + fontWeight: FontWeight.bold)), backgroundColor: Colors.red.withAlpha(40), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -666,7 +775,7 @@ class _IntegrationTestPageState extends State { Widget _buildLogsConsole(String title, TestResult result) { return Container( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black.withAlpha(150), borderRadius: BorderRadius.circular(6), @@ -675,20 +784,42 @@ class _IntegrationTestPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue)), + Text(title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.blue)), const Divider(height: 8), if (result.errorMessage != null) ...[ - Text('ERROR:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red.shade300)), - SelectableText(result.errorMessage!, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.red.shade200)), + Text('ERROR:', + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.red.shade300)), + SelectableText(result.errorMessage!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.red.shade200)), const SizedBox(height: 8), ], if (result.responseJson != null) ...[ - const Text('RESPONSE:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purpleAccent)), - SelectableText(result.responseJson!, style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.purpleAccent)), + const Text('RESPONSE:', + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.purpleAccent)), + SelectableText(result.responseJson!, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.purpleAccent)), const SizedBox(height: 8), ], - const Text('EXECUTION LOGS:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white70)), - SelectableText(result.logs, style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white70)), + const Text('EXECUTION LOGS:', + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.white70)), + SelectableText(result.logs, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.white70)), ], ), ); @@ -696,8 +827,10 @@ class _IntegrationTestPageState extends State { @override Widget build(BuildContext context) { - final googleAIResults = _testCases.map((item) => item.googleAIResult).toList(); - final vertexAIResults = _testCases.map((item) => item.vertexAIResult).toList(); + final googleAIResults = + _testCases.map((item) => item.googleAIResult).toList(); + final vertexAIResults = + _testCases.map((item) => item.vertexAIResult).toList(); return Scaffold( appBar: AppBar( @@ -711,11 +844,12 @@ class _IntegrationTestPageState extends State { ) else const Padding( - padding: EdgeInsets.all(16.0), + padding: EdgeInsets.all(16), child: SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.cyanAccent), + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.cyanAccent), ), ), ], @@ -723,14 +857,19 @@ class _IntegrationTestPageState extends State { body: Column( children: [ if (_isRunning) - LinearProgressIndicator(value: _progress, minHeight: 6, color: Colors.cyanAccent), + LinearProgressIndicator( + value: _progress, minHeight: 6, color: Colors.cyanAccent), Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(12), child: Row( children: [ - Expanded(child: _buildProviderSummary('Google AI Suite', googleAIResults)), + Expanded( + child: _buildProviderSummary( + 'Google AI Suite', googleAIResults)), const SizedBox(width: 8), - Expanded(child: _buildProviderSummary('Vertex AI Suite', vertexAIResults)), + Expanded( + child: _buildProviderSummary( + 'Vertex AI Suite', vertexAIResults)), ], ), ), @@ -740,27 +879,36 @@ class _IntegrationTestPageState extends State { itemBuilder: (context, index) { final item = _testCases[index]; return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: ExpansionTile( leading: CircleAvatar( backgroundColor: Colors.grey.shade800, - child: Text(item.id, style: const TextStyle(fontWeight: FontWeight.bold)), + child: Text(item.id, + style: const TextStyle(fontWeight: FontWeight.bold)), ), - title: Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(item.description, style: const TextStyle(fontSize: 12, color: Colors.grey)), + title: Text(item.name, + style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(item.description, + style: + const TextStyle(fontSize: 12, color: Colors.grey)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ - const Text('G: ', style: TextStyle(fontSize: 10, color: Colors.grey)), + const Text('G: ', + style: TextStyle( + fontSize: 10, color: Colors.grey)), _buildStatusBadge(item.googleAIResult), ], ), const SizedBox(width: 8), Row( children: [ - const Text('V: ', style: TextStyle(fontSize: 10, color: Colors.grey)), + const Text('V: ', + style: TextStyle( + fontSize: 10, color: Colors.grey)), _buildStatusBadge(item.vertexAIResult), ], ), @@ -768,20 +916,26 @@ class _IntegrationTestPageState extends State { ), children: [ Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(12), child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _buildLogsConsole('Google AI Details', item.googleAIResult)), + Expanded( + child: _buildLogsConsole( + 'Google AI Details', + item.googleAIResult)), const SizedBox(width: 8), - Expanded(child: _buildLogsConsole('Vertex AI Details', item.vertexAIResult)), + Expanded( + child: _buildLogsConsole( + 'Vertex AI Details', + item.vertexAIResult)), ], ), ], ), - ) + ), ], ), ); From 695b5533200c02b44f758b22e50d7ff13ae7c5bc Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Wed, 20 May 2026 21:32:43 -0700 Subject: [PATCH 5/5] fix analyzer --- .../lib/pages/integration_test_page.dart | 182 +++++++++++++----- 1 file changed, 129 insertions(+), 53 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart index 42f8b92d7b4c..46064e810058 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/integration_test_page.dart @@ -102,7 +102,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: response.text); + responseJson: response.text, + ); } else { return TestResult( status: TestStatus.failed, @@ -130,7 +131,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: response.text); + responseJson: response.text, + ); } else { return TestResult( status: TestStatus.failed, @@ -151,7 +153,8 @@ class _IntegrationTestPageState extends State { final model = provider.generativeModel( model: 'gemini-3.1-flash-lite', systemInstruction: Content.text( - 'You are a medieval knight. Respond only with Shakespearean knightly terms.'), + 'You are a medieval knight. Respond only with Shakespearean knightly terms.', + ), ); const prompt = 'Who are you?'; logger.log('Sending prompt: "$prompt"'); @@ -169,7 +172,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: response.text); + responseJson: response.text, + ); } else { return TestResult( status: TestStatus.failed, @@ -226,7 +230,8 @@ class _IntegrationTestPageState extends State { 'Verifies automatic function/tool call execution via AutoFunctionDeclaration.', run: (provider, logger) async { logger.log( - 'Declaring AutoFunctionDeclaration for getSuperHeroPower...'); + 'Declaring AutoFunctionDeclaration for getSuperHeroPower...', + ); final autoPowerTool = AutoFunctionDeclaration( name: 'getSuperHeroPower', description: 'Returns the superpower of a given superhero by name.', @@ -237,7 +242,8 @@ class _IntegrationTestPageState extends State { callable: (args) async { final hero = args['heroName'] as String?; logger.log( - 'CALLBACK TRIGGERED: getSuperHeroPower called for "$hero"'); + 'CALLBACK TRIGGERED: getSuperHeroPower called for "$hero"', + ); if (hero?.toLowerCase().contains('laserman') ?? false) { return {'power': 'Laser Eyes'}; } @@ -248,7 +254,7 @@ class _IntegrationTestPageState extends State { final model = provider.generativeModel( model: 'gemini-3.1-flash-lite', tools: [ - Tool.functionDeclarations([autoPowerTool]) + Tool.functionDeclarations([autoPowerTool]), ], ); final chat = model.startChat(); @@ -262,7 +268,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: response.text); + responseJson: response.text, + ); } else { return TestResult( status: TestStatus.failed, @@ -293,7 +300,7 @@ class _IntegrationTestPageState extends State { final model = provider.generativeModel( model: 'gemini-3.1-flash-lite', tools: [ - Tool.functionDeclarations([powerTool]) + Tool.functionDeclarations([powerTool]), ], ); @@ -303,7 +310,8 @@ class _IntegrationTestPageState extends State { final functionCalls = response.functionCalls; logger.log( - 'Function calls predicted: ${functionCalls.map((c) => c.name).toList()}'); + 'Function calls predicted: ${functionCalls.map((c) => c.name).toList()}', + ); if (functionCalls.isEmpty) { return TestResult( @@ -316,7 +324,8 @@ class _IntegrationTestPageState extends State { final call = functionCalls.first; logger.log( - 'Intercepted call details: name=${call.name}, args=${call.args}'); + 'Intercepted call details: name=${call.name}, args=${call.args}', + ); final hero = call.args['heroName'] as String?; String power = 'Super Strength'; if (hero?.toLowerCase().contains('laserman') ?? false) { @@ -324,7 +333,8 @@ class _IntegrationTestPageState extends State { } logger.log( - 'Manually constructing FunctionResponse: {"power": "$power"}'); + 'Manually constructing FunctionResponse: {"power": "$power"}', + ); final manualResponse = FunctionResponse(call.name, {'power': power}); logger @@ -340,7 +350,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: nextResponse.text); + responseJson: nextResponse.text, + ); } else { return TestResult( status: TestStatus.failed, @@ -358,7 +369,8 @@ class _IntegrationTestPageState extends State { 'Verifies Tool.codeExecution() internal code execution and parts extraction.', run: (provider, logger) async { logger.log( - 'Initializing model gemini-3.5-flash with Tool.codeExecution()...'); + 'Initializing model gemini-3.5-flash with Tool.codeExecution()...', + ); final model = provider.generativeModel( model: 'gemini-3.5-flash', tools: [Tool.codeExecution()], @@ -388,7 +400,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: response.text); + responseJson: response.text, + ); } else { return TestResult( status: TestStatus.failed, @@ -407,7 +420,8 @@ class _IntegrationTestPageState extends State { 'Verifies Google Search Grounding tool configuration and grounding metadata.', run: (provider, logger) async { logger.log( - 'Initializing model gemini-3.5-flash with Tool.googleSearch()...'); + 'Initializing model gemini-3.5-flash with Tool.googleSearch()...', + ); final model = provider.generativeModel( model: 'gemini-3.5-flash', tools: [Tool.googleSearch()], @@ -465,17 +479,20 @@ class _IntegrationTestPageState extends State { } logger.log( - 'Stream finished. Total chunks=$chunkCount, aggregated length=${textBuffer.length}'); + 'Stream finished. Total chunks=$chunkCount, aggregated length=${textBuffer.length}', + ); if (textBuffer.isNotEmpty) { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: textBuffer.toString()); + responseJson: textBuffer.toString(), + ); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), - errorMessage: 'Stream aggregated output was empty.'); + errorMessage: 'Stream aggregated output was empty.', + ); } }, ), @@ -507,12 +524,14 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.passed, logs: logger.toString(), - responseJson: 'Total Tokens: ${response.totalTokens}'); + responseJson: 'Total Tokens: ${response.totalTokens}', + ); } else { return TestResult( status: TestStatus.failed, logs: logger.toString(), - errorMessage: 'Expected token count > 0'); + errorMessage: 'Expected token count > 0', + ); } }, ), @@ -523,12 +542,14 @@ class _IntegrationTestPageState extends State { 'Verifies extraction of thought logs and usage metadata with ThinkingConfig.', run: (provider, logger) async { logger.log( - 'Initializing model gemini-3.5-flash with ThinkingConfig...'); + 'Initializing model gemini-3.5-flash with ThinkingConfig...', + ); final model = provider.generativeModel( model: 'gemini-3.5-flash', generationConfig: GenerationConfig( thinkingConfig: ThinkingConfig.withThinkingBudget(2048, - includeThoughts: true), + includeThoughts: true, + ), ), ); @@ -542,7 +563,8 @@ class _IntegrationTestPageState extends State { logger.log('Response text: "${response.text}"'); logger.log('Thoughts extracted: "$thoughts"'); logger.log( - 'Usage Details: prompt=${usage?.promptTokenCount}, candidates=${usage?.candidatesTokenCount}, total=${usage?.totalTokenCount}, thoughts=${usage?.thoughtsTokenCount}'); + 'Usage Details: prompt=${usage?.promptTokenCount}, candidates=${usage?.candidatesTokenCount}, total=${usage?.totalTokenCount}, thoughts=${usage?.thoughtsTokenCount}', + ); if (usage != null && usage.totalTokenCount! > 0) { return TestResult( @@ -555,7 +577,8 @@ class _IntegrationTestPageState extends State { return TestResult( status: TestStatus.failed, logs: logger.toString(), - errorMessage: 'Usage metadata was null or invalid.'); + errorMessage: 'Usage metadata was null or invalid.', + ); } }, ), @@ -566,13 +589,14 @@ class _IntegrationTestPageState extends State { 'Verifies text + image response output generation via gemini-2.5-flash-image model.', run: (provider, logger) async { logger.log( - 'Initializing gemini-2.5-flash-image with dual response modalities...'); + 'Initializing gemini-2.5-flash-image with dual response modalities...', + ); final model = provider.generativeModel( model: 'gemini-2.5-flash-image', generationConfig: GenerationConfig( responseModalities: [ ResponseModalities.text, - ResponseModalities.image + ResponseModalities.image, ], ), ); @@ -589,7 +613,8 @@ class _IntegrationTestPageState extends State { if (imageParts.isNotEmpty) { final img = imageParts.first; logger.log( - 'Generated Image: mime=${img.mimeType}, size=${img.bytes.length} bytes'); + 'Generated Image: mime=${img.mimeType}, size=${img.bytes.length} bytes', + ); return TestResult( status: TestStatus.passed, logs: logger.toString(), @@ -628,7 +653,8 @@ class _IntegrationTestPageState extends State { try { logger.log( - 'Starting execution for ${item.name} (${isVertex ? 'Vertex AI' : 'Google AI'})...'); + 'Starting execution for ${item.name} (${isVertex ? 'Vertex AI' : 'Google AI'})...', + ); final result = await item.run(provider, logger); setState(() { if (isVertex) { @@ -714,16 +740,20 @@ class _IntegrationTestPageState extends State { children: [ Text(title, style: - const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Pass: $passed/$total', - style: TextStyle(color: Colors.green.shade300)), + style: TextStyle(color: Colors.green.shade300), + ), Text('Fail: $failed', style: TextStyle( - color: failed > 0 ? Colors.red.shade300 : Colors.grey)), + color: failed > 0 ? Colors.red.shade300 : Colors.grey, + ), + ), ], ), ], @@ -737,7 +767,8 @@ class _IntegrationTestPageState extends State { case TestStatus.pending: return Chip( label: const Text('PENDING', - style: TextStyle(fontSize: 10, color: Colors.grey)), + style: TextStyle(fontSize: 10, color: Colors.grey), + ), backgroundColor: Colors.grey.shade800, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -754,7 +785,9 @@ class _IntegrationTestPageState extends State { style: TextStyle( fontSize: 10, color: Colors.green, - fontWeight: FontWeight.bold)), + fontWeight: FontWeight.bold, + ), + ), backgroundColor: Colors.green.withAlpha(40), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -765,7 +798,9 @@ class _IntegrationTestPageState extends State { style: TextStyle( fontSize: 10, color: Colors.red, - fontWeight: FontWeight.bold)), + fontWeight: FontWeight.bold, + ), + ), backgroundColor: Colors.red.withAlpha(40), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -788,38 +823,55 @@ class _IntegrationTestPageState extends State { style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, - color: Colors.blue)), + color: Colors.blue, + ), + ), const Divider(height: 8), if (result.errorMessage != null) ...[ Text('ERROR:', style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.red.shade300)), + fontWeight: FontWeight.bold, + color: Colors.red.shade300, + ), + ), SelectableText(result.errorMessage!, style: TextStyle( fontFamily: 'monospace', fontSize: 11, - color: Colors.red.shade200)), + color: Colors.red.shade200, + ), + ), const SizedBox(height: 8), ], if (result.responseJson != null) ...[ const Text('RESPONSE:', style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.purpleAccent)), + fontWeight: FontWeight.bold, + color: Colors.purpleAccent, + ), + ), SelectableText(result.responseJson!, style: const TextStyle( fontFamily: 'monospace', fontSize: 11, - color: Colors.purpleAccent)), + color: Colors.purpleAccent, + ), + ), const SizedBox(height: 8), ], const Text('EXECUTION LOGS:', style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.white70)), + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), SelectableText(result.logs, style: const TextStyle( fontFamily: 'monospace', fontSize: 11, - color: Colors.white70)), + color: Colors.white70, + ), + ), ], ), ); @@ -849,7 +901,9 @@ class _IntegrationTestPageState extends State { width: 20, height: 20, child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.cyanAccent), + strokeWidth: 2, + color: Colors.cyanAccent, + ), ), ), ], @@ -858,18 +912,27 @@ class _IntegrationTestPageState extends State { children: [ if (_isRunning) LinearProgressIndicator( - value: _progress, minHeight: 6, color: Colors.cyanAccent), + value: _progress, + minHeight: 6, + color: Colors.cyanAccent, + ), Padding( padding: const EdgeInsets.all(12), child: Row( children: [ Expanded( child: _buildProviderSummary( - 'Google AI Suite', googleAIResults)), + 'Google AI Suite', + googleAIResults, + ), + ), const SizedBox(width: 8), Expanded( child: _buildProviderSummary( - 'Vertex AI Suite', vertexAIResults)), + 'Vertex AI Suite', + vertexAIResults, + ), + ), ], ), ), @@ -885,13 +948,16 @@ class _IntegrationTestPageState extends State { leading: CircleAvatar( backgroundColor: Colors.grey.shade800, child: Text(item.id, - style: const TextStyle(fontWeight: FontWeight.bold)), + style: const TextStyle(fontWeight: FontWeight.bold), + ), ), title: Text(item.name, - style: const TextStyle(fontWeight: FontWeight.bold)), + style: const TextStyle(fontWeight: FontWeight.bold), + ), subtitle: Text(item.description, style: - const TextStyle(fontSize: 12, color: Colors.grey)), + const TextStyle(fontSize: 12, color: Colors.grey), + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -899,7 +965,10 @@ class _IntegrationTestPageState extends State { children: [ const Text('G: ', style: TextStyle( - fontSize: 10, color: Colors.grey)), + fontSize: 10, + color: Colors.grey, + ), + ), _buildStatusBadge(item.googleAIResult), ], ), @@ -908,7 +977,10 @@ class _IntegrationTestPageState extends State { children: [ const Text('V: ', style: TextStyle( - fontSize: 10, color: Colors.grey)), + fontSize: 10, + color: Colors.grey, + ), + ), _buildStatusBadge(item.vertexAIResult), ], ), @@ -925,12 +997,16 @@ class _IntegrationTestPageState extends State { Expanded( child: _buildLogsConsole( 'Google AI Details', - item.googleAIResult)), + item.googleAIResult, + ), + ), const SizedBox(width: 8), Expanded( child: _buildLogsConsole( 'Vertex AI Details', - item.vertexAIResult)), + item.vertexAIResult, + ), + ), ], ), ],