From b8bb0733f644cfcf2da62c6f53cdb9904621d318 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 25 Mar 2026 13:21:18 -0400 Subject: [PATCH 01/13] feat(ai): add Google Maps Grounding support Introduces support for Google Maps Grounding to the firebase_ai SDK, establishing feature parity with other platforms. API Changes: - Added `Tool.googleMaps()` factory and `GoogleMaps` class. - Introduced `RetrievalConfig` and `LatLng` for dynamic location-based tool configurations. - Expanded `ToolConfig` to include an optional `retrievalConfig` property. - Added `GoogleMapsGroundingChunk` to represent parsed Maps location properties (`uri`, `title`, `placeId`). - Updated `GroundingChunk` to expose the new `maps` property alongside `web`. - Exported all new grounding models publicly via `firebase_ai.dart`. Example App: - Added a dedicated "Grounding" page (`grounding_page.dart`) to the main navigation menu. - Implemented dynamic UI toggles for both Search and Maps Grounding. - Implemented dynamic parsing and Markdown rendering of returned grounding chunks. Testing: - Added comprehensive JSON roundtrip serialization tests for all new Tool configurations. - Added parsing tests mapping Developer API JSON payloads to `GoogleMapsGroundingChunk`. - Manually tested newly added web app section with Vertex and Developer APIs --- .../firebase_ai/example/lib/main.dart | 12 + .../example/lib/pages/grounding_page.dart | 296 ++++++++++++++++++ .../firebase_ai/lib/firebase_ai.dart | 7 +- .../firebase_ai/firebase_ai/lib/src/api.dart | 37 ++- .../firebase_ai/firebase_ai/lib/src/tool.dart | 97 +++++- .../firebase_ai/test/developer_api_test.dart | 37 +++ .../test/firebase_vertexai_test.dart | 17 + .../test/response_parsing_test.dart | 37 +++ .../firebase_ai/test/tool_test.dart | 58 +++- 9 files changed, 589 insertions(+), 9 deletions(-) create mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/grounding_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 133f2c6ec257..4e3b45a6548d 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -198,6 +198,11 @@ class _HomeScreenState extends State { title: 'Server Template', useVertexBackend: useVertexBackend, ); + case 12: + return GroundingPage( + title: 'Grounding', + useVertexBackend: useVertexBackend, + ); default: // Fallback to the first page in case of an unexpected index @@ -336,6 +341,13 @@ class _HomeScreenState extends State { label: 'Server', tooltip: 'Server Template', ), + BottomNavigationBarItem( + icon: Icon( + Icons.location_on, + ), + label: 'Grounding', + tooltip: 'Search & Maps Grounding', + ), ], currentIndex: _selectedIndex, onTap: _onItemTapped, 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 new file mode 100644 index 000000000000..0f0b7338b40e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart @@ -0,0 +1,296 @@ +// 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 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; + +class GroundingPage extends StatefulWidget { + const GroundingPage({ + super.key, + required this.title, + required this.useVertexBackend, + }); + + final String title; + final bool useVertexBackend; + + @override + State createState() => _GroundingPageState(); +} + +class _GroundingPageState extends State { + GenerativeModel? _model; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final TextEditingController _latController = TextEditingController(); + final TextEditingController _lngController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + + bool _loading = false; + bool _enableSearchGrounding = false; + bool _enableMapsGrounding = false; + + @override + void initState() { + super.initState(); + _latController.text = '37.422'; // Default Googleplex lat + _lngController.text = '-122.084'; // Default Googleplex lng + } + + void _initializeModel() { + List tools = []; + ToolConfig? toolConfig; + + if (_enableSearchGrounding) { + tools.add(Tool.googleSearch()); + } + + if (_enableMapsGrounding) { + tools.add(Tool.googleMaps()); + + final lat = double.tryParse(_latController.text); + final lng = double.tryParse(_lngController.text); + + if (lat != null && lng != null) { + toolConfig = ToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: lat, longitude: lng), + ), + ); + } + } + + if (widget.useVertexBackend) { + _model = FirebaseAI.vertexAI(auth: FirebaseAuth.instance).generativeModel( + model: 'gemini-2.5-flash', + tools: tools.isNotEmpty ? tools : null, + toolConfig: toolConfig, + ); + } else { + _model = FirebaseAI.googleAI(auth: FirebaseAuth.instance).generativeModel( + model: 'gemini-2.5-flash', + tools: tools.isNotEmpty ? tools : null, + toolConfig: toolConfig, + ); + } + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 750), + curve: Curves.easeOutCirc, + ), + ); + } + + Future _sendPrompt(String message) async { + if (message.isEmpty) return; + + _initializeModel(); // Re-initialize before sending to capture current toggles + + setState(() { + _loading = true; + }); + + try { + _messages.add(MessageData(text: message, fromUser: true)); + + final response = await _model?.generateContent([Content.text(message)]); + + var text = response?.text; + + // Extract grounding metadata to display + final groundingMetadata = + response?.candidates.firstOrNull?.groundingMetadata; + if (groundingMetadata != null) { + final chunks = groundingMetadata.groundingChunks.map((chunk) { + if (chunk.web != null) { + final title = chunk.web!.title ?? chunk.web!.uri; + return '- [${title}](${chunk.web!.uri})'; + } + if (chunk.maps != null) { + return '- [${chunk.maps!.title}](${chunk.maps!.uri})'; + } + return '- Unknown chunk'; + }).join('\n'); + + if (chunks.isNotEmpty) { + text = '$text\n\n**Grounding Sources:**\n$chunks'; + } + } + + _messages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + return; + } + } catch (e) { + _showError(e.toString()); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + _scrollDown(); + } + } + + 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'), + ) + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: SwitchListTile( + title: const Text('Search Grounding', + style: TextStyle(fontSize: 12)), + value: _enableSearchGrounding, + onChanged: (bool value) { + setState(() { + _enableSearchGrounding = value; + }); + }, + ), + ), + Expanded( + child: SwitchListTile( + title: const Text('Maps Grounding', + style: TextStyle(fontSize: 12)), + value: _enableMapsGrounding, + onChanged: (bool value) { + setState(() { + _enableMapsGrounding = value; + }); + }, + ), + ), + ], + ), + if (_enableMapsGrounding) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _latController, + decoration: + const InputDecoration(labelText: 'Latitude'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, signed: true), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _lngController, + decoration: + const InputDecoration(labelText: 'Longitude'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, signed: true), + ), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + final message = _messages[idx]; + return MessageWidget( + text: message.text, + isFromUser: message.fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + onSubmitted: _sendPrompt, + decoration: const InputDecoration( + hintText: 'Enter a prompt...', + ), + ), + ), + const SizedBox.square(dimension: 15), + if (!_loading) + IconButton( + onPressed: () { + _sendPrompt(_textController.text); + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 681989831e53..6b4bff98898b 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -18,6 +18,8 @@ export 'src/api.dart' Candidate, CitationMetadata, Citation, + GoogleMapsGroundingChunk, + GroundingChunk, CountTokensResponse, FinishReason, GenerateContentResponse, @@ -121,5 +123,8 @@ export 'src/tool.dart' Tool, ToolConfig, GoogleSearch, + GoogleMaps, CodeExecution, - UrlContext; + UrlContext, + LatLng, + RetrievalConfig; diff --git a/packages/firebase_ai/firebase_ai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart index 653ef2097013..66d68afb0dd2 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -347,6 +347,23 @@ final class WebGroundingChunk { final String? domain; } +/// A grounding chunk sourced from Google Maps. +final class GoogleMapsGroundingChunk { + // ignore: public_member_api_docs + GoogleMapsGroundingChunk({this.uri, this.title, this.placeId}); + + /// The URI of the place. + final String? uri; + + /// The title of the place. + final String? title; + + /// This Place's resource name, in `places/{place_id}` format. + /// + /// This can be used to look up the place using the Google Maps API. + final String? placeId; +} + /// Represents a chunk of retrieved data that supports a claim in the model's /// response. /// @@ -354,10 +371,13 @@ final class WebGroundingChunk { /// enabled. final class GroundingChunk { // ignore: public_member_api_docs - GroundingChunk({this.web}); + GroundingChunk({this.web, this.maps}); /// Contains details if the grounding chunk is from a web source. final WebGroundingChunk? web; + + /// Contains details if the grounding chunk is from a Google Maps source. + final GoogleMapsGroundingChunk? maps; } /// Provides information about how a specific segment of the model's response @@ -1689,6 +1709,18 @@ WebGroundingChunk _parseWebGroundingChunk(Object? jsonObject) { ); } +GoogleMapsGroundingChunk _parseGoogleMapsGroundingChunk(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('GoogleMapsGroundingChunk', jsonObject); + } + + return GoogleMapsGroundingChunk( + uri: jsonObject['uri'] as String?, + title: jsonObject['title'] as String?, + placeId: jsonObject['placeId'] as String?, + ); +} + GroundingChunk _parseGroundingChunk(Object? jsonObject) { if (jsonObject is! Map) { throw unhandledFormat('GroundingChunk', jsonObject); @@ -1698,6 +1730,9 @@ GroundingChunk _parseGroundingChunk(Object? jsonObject) { web: jsonObject['web'] != null ? _parseWebGroundingChunk(jsonObject['web']) : null, + maps: jsonObject['maps'] != null + ? _parseGoogleMapsGroundingChunk(jsonObject['maps']) + : null, ); } diff --git a/packages/firebase_ai/firebase_ai/lib/src/tool.dart b/packages/firebase_ai/firebase_ai/lib/src/tool.dart index 6b92fbd5d96c..8e4d7de097c6 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/tool.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/tool.dart @@ -24,12 +24,12 @@ import 'schema.dart'; final class Tool { // ignore: public_member_api_docs Tool._(this._functionDeclarations, this._googleSearch, this._codeExecution, - this._urlContext); + this._urlContext, this._googleMaps); /// Returns a [Tool] instance with list of [FunctionDeclaration]. static Tool functionDeclarations( List functionDeclarations) { - return Tool._(functionDeclarations, null, null, null); + return Tool._(functionDeclarations, null, null, null, null); } /// Creates a tool that allows the model to use Grounding with Google Search. @@ -50,13 +50,13 @@ final class Tool { /// /// Returns a `Tool` configured for Google Search. static Tool googleSearch({GoogleSearch googleSearch = const GoogleSearch()}) { - return Tool._(null, googleSearch, null, null); + return Tool._(null, googleSearch, null, null, null); } /// Returns a [Tool] instance that enables the model to use Code Execution. static Tool codeExecution( {CodeExecution codeExecution = const CodeExecution()}) { - return Tool._(null, null, codeExecution, null); + return Tool._(null, null, codeExecution, null, null); } /// Creates a tool that allows you to provide additional context to the models @@ -73,7 +73,27 @@ final class Tool { /// is in Public Preview, which means that the feature is not subject to any SLA /// or deprecation policy and could change in backwards-incompatible ways. static Tool urlContext({UrlContext urlContext = const UrlContext()}) { - return Tool._(null, null, null, urlContext); + return Tool._(null, null, null, urlContext, null); + } + + /// Creates a tool that allows the model to use Grounding with Google Maps. + /// + /// Grounding with Google Maps can be used to allow the model to connect to + /// Google Maps to access and incorporate location-based information into its + /// responses. + /// + /// When using this feature, you are required to comply with the + /// "Grounding with Google Maps" usage requirements for your chosen API + /// provider: + /// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-maps) + /// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) + /// section within the Service Specific Terms). + /// + /// - [googleMaps]: An empty [GoogleMaps] object. + /// + /// Returns a `Tool` configured for Google Maps. + static Tool googleMaps({GoogleMaps googleMaps = const GoogleMaps()}) { + return Tool._(null, null, null, null, googleMaps); } /// A list of `FunctionDeclarations` available to the model that can be used @@ -97,6 +117,10 @@ final class Tool { /// A tool that allows providing URL context to the model. final UrlContext? _urlContext; + /// A tool that allows the model to connect to Google Maps to access + /// location-based information. + final GoogleMaps? _googleMaps; + /// Returns a list of all [AutoFunctionDeclaration] objects /// found within the [_functionDeclarations] list. List get autoFunctionDeclarations { @@ -117,6 +141,8 @@ final class Tool { 'codeExecution': _codeExecution.toJson(), if (_urlContext case final _urlContext?) 'urlContext': _urlContext.toJson(), + if (_googleMaps case final _googleMaps?) + 'googleMaps': _googleMaps.toJson(), }; } @@ -138,6 +164,23 @@ final class GoogleSearch { Map toJson() => {}; } +/// A tool that allows a Gemini model to connect to Google Maps to access and +/// incorporate location-based information into its responses. +/// +/// Important: If using Grounding with Google Maps, you are required to comply +/// with the "Grounding with Google Maps" usage requirements for your chosen API +/// provider: +/// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-maps) +/// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) +/// section within the Service Specific Terms). +final class GoogleMaps { + // ignore: public_member_api_docs + const GoogleMaps(); + + /// Convert to json object. + Map toJson() => {}; +} + /// A tool that allows you to provide additional context to the models in the /// form of public web URLs. By including URLs in your request, the Gemini /// model will access the content from those pages to inform and enhance its @@ -222,15 +265,57 @@ final class AutoFunctionDeclaration extends FunctionDeclaration { /// Config for tools to use with model. final class ToolConfig { // ignore: public_member_api_docs - ToolConfig({this.functionCallingConfig}); + ToolConfig({this.functionCallingConfig, this.retrievalConfig}); /// Config for function calling. final FunctionCallingConfig? functionCallingConfig; + /// Config that specifies information which can be used by tools during inference calls. + final RetrievalConfig? retrievalConfig; + /// Convert to json object. Map toJson() => { if (functionCallingConfig case final config?) 'functionCallingConfig': config.toJson(), + if (retrievalConfig case final config?) + 'retrievalConfig': config.toJson(), + }; +} + +/// An object that represents a latitude/longitude pair. +final class LatLng { + // ignore: public_member_api_docs + LatLng({required this.latitude, required this.longitude}); + + /// The latitude in degrees. It must be in the range [-90.0, +90.0]. + final double latitude; + + /// The longitude in degrees. It must be in the range [-180.0, +180.0]. + final double longitude; + + /// Convert to json object. + Map toJson() => { + 'latitude': latitude, + 'longitude': longitude, + }; +} + +/// The configuration that specifies information which can be used by tools +/// during inference calls. +final class RetrievalConfig { + // ignore: public_member_api_docs + RetrievalConfig({this.latLng, this.languageCode}); + + /// A latitude/longitude pair. + final LatLng? latLng; + + /// The language code. + final String? languageCode; + + /// Convert to json object. + Map toJson() => { + if (latLng case final latLng?) 'latLng': latLng.toJson(), + if (languageCode case final languageCode?) 'languageCode': languageCode, }; } diff --git a/packages/firebase_ai/firebase_ai/test/developer_api_test.dart b/packages/firebase_ai/firebase_ai/test/developer_api_test.dart index 40ce3739e1b9..85116e454646 100644 --- a/packages/firebase_ai/firebase_ai/test/developer_api_test.dart +++ b/packages/firebase_ai/firebase_ai/test/developer_api_test.dart @@ -219,6 +219,43 @@ void main() { expect(groundingSupports.groundingChunkIndices, [0]); }); + test('parses json with google maps grounding chunk', () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a maps response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + 'groundingChunks': [ + { + 'maps': { + 'uri': 'https://maps.google.com/?cid=123', + 'title': 'Google HQ', + 'placeId': 'ChIJS5dFe_cZzosR26ZvwqWaMAM', + } + } + ], + } + } + ] + }; + + final response = DeveloperSerialization() + .parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + final groundingChunk = groundingMetadata!.groundingChunks.first; + expect(groundingChunk.maps?.uri, 'https://maps.google.com/?cid=123'); + expect(groundingChunk.maps?.title, 'Google HQ'); + expect(groundingChunk.maps?.placeId, 'ChIJS5dFe_cZzosR26ZvwqWaMAM'); + expect(groundingChunk.web, isNull); + }); + test( 'parses groundingMetadata with all optional fields null/missing and empty lists', () { diff --git a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart index ace2fa4d673c..c2926e58dd1c 100644 --- a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart +++ b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart @@ -96,6 +96,23 @@ void main() { expect(vertexAIAppCheck.useLimitedUseAppCheckTokens, true); }); + test('generativeModel creation with Grounding tools', () { + final ai = FirebaseAI.googleAI(); + + final model = ai.generativeModel( + model: 'gemini-2.5-flash', + tools: [Tool.googleMaps()], + toolConfig: ToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 37.42, longitude: -122.08), + languageCode: 'en-US', + ), + ), + ); + + expect(model, isA()); + }); + // ... other tests (e.g., with different parameters) }); } diff --git a/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart b/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart index 5aa1809d2023..3078177f180b 100644 --- a/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart +++ b/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart @@ -1115,6 +1115,43 @@ void main() { expect(urlContextMetadata.urlMetadata[0].urlRetrievalStatus, UrlRetrievalStatus.error); }); + + test('parses json with google maps grounding chunk', () { + final jsonResponse = { + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'This is a maps response.'} + ] + }, + 'finishReason': 'STOP', + 'groundingMetadata': { + 'groundingChunks': [ + { + 'maps': { + 'uri': 'https://maps.google.com/?cid=123', + 'title': 'Google HQ', + 'placeId': 'ChIJS5dFe_cZzosR26ZvwqWaMAM', + } + } + ], + } + } + ] + }; + + final response = + VertexSerialization().parseGenerateContentResponse(jsonResponse); + final groundingMetadata = response.candidates.first.groundingMetadata; + + expect(groundingMetadata, isNotNull); + final groundingChunk = groundingMetadata!.groundingChunks.first; + expect(groundingChunk.maps?.uri, 'https://maps.google.com/?cid=123'); + expect(groundingChunk.maps?.title, 'Google HQ'); + expect(groundingChunk.maps?.placeId, 'ChIJS5dFe_cZzosR26ZvwqWaMAM'); + expect(groundingChunk.web, isNull); + }); }); group('parses and throws error responses', () { diff --git a/packages/firebase_ai/firebase_ai/test/tool_test.dart b/packages/firebase_ai/firebase_ai/test/tool_test.dart index affd00691b59..0d9a749e39a2 100644 --- a/packages/firebase_ai/firebase_ai/test/tool_test.dart +++ b/packages/firebase_ai/firebase_ai/test/tool_test.dart @@ -200,6 +200,14 @@ void main() { }); }); + // Test Tool.googleMaps() + test('Tool.googleMaps()', () { + final tool = Tool.googleMaps(); + expect(tool.toJson(), { + 'googleMaps': {}, + }); + }); + // Test ToolConfig test('ToolConfig with FunctionCallingConfig', () { final config = ToolConfig( @@ -215,7 +223,50 @@ void main() { expect(config.toJson(), {}); }); - // Test GoogleSearch, CodeExecution, UrlContext toJson() + test('ToolConfig with RetrievalConfig', () { + final config = ToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 37.422, longitude: -122.084), + languageCode: 'en-US', + ), + ); + expect(config.toJson(), { + 'retrievalConfig': { + 'latLng': {'latitude': 37.422, 'longitude': -122.084}, + 'languageCode': 'en-US', + }, + }); + }); + + // Test LatLng and RetrievalConfig + test('LatLng.toJson()', () { + final latLng = LatLng(latitude: 37.42, longitude: -122.08); + expect(latLng.toJson(), {'latitude': 37.42, 'longitude': -122.08}); + }); + + test('RetrievalConfig.toJson() with all fields', () { + final config = RetrievalConfig( + latLng: LatLng(latitude: 1.0, longitude: 2.0), + languageCode: 'fr', + ); + expect(config.toJson(), { + 'latLng': {'latitude': 1.0, 'longitude': 2.0}, + 'languageCode': 'fr', + }); + }); + + test('RetrievalConfig.toJson() with partial fields', () { + final config1 = + RetrievalConfig(latLng: LatLng(latitude: 1.0, longitude: 2.0)); + expect(config1.toJson(), { + 'latLng': {'latitude': 1.0, 'longitude': 2.0} + }); + + final config2 = RetrievalConfig(languageCode: 'fr'); + expect(config2.toJson(), {'languageCode': 'fr'}); + }); + + // Test GoogleSearch, CodeExecution, UrlContext, GoogleMaps toJson() test('GoogleSearch.toJson()', () { const search = GoogleSearch(); expect(search.toJson(), {}); @@ -230,5 +281,10 @@ void main() { const context = UrlContext(); expect(context.toJson(), {}); }); + + test('GoogleMaps.toJson()', () { + const maps = GoogleMaps(); + expect(maps.toJson(), {}); + }); }); } From 83275998fb563ad444d993701ade98f6b079169f Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 25 Mar 2026 14:26:56 -0400 Subject: [PATCH 02/13] Fixes --- .../example/lib/pages/grounding_page.dart | 14 +++++++------- .../firebase_ai/firebase_ai/test/tool_test.dart | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) 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 0f0b7338b40e..6a101cd1725a 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 @@ -122,7 +122,7 @@ class _GroundingPageState extends State { final chunks = groundingMetadata.groundingChunks.map((chunk) { if (chunk.web != null) { final title = chunk.web!.title ?? chunk.web!.uri; - return '- [${title}](${chunk.web!.uri})'; + return '- [$title](${chunk.web!.uri})'; } if (chunk.maps != null) { return '- [${chunk.maps!.title}](${chunk.maps!.uri})'; @@ -168,7 +168,7 @@ class _GroundingPageState extends State { Navigator.of(context).pop(); }, child: const Text('OK'), - ) + ), ], ); }, @@ -190,7 +190,7 @@ class _GroundingPageState extends State { Expanded( child: SwitchListTile( title: const Text('Search Grounding', - style: TextStyle(fontSize: 12)), + style: TextStyle(fontSize: 12),), value: _enableSearchGrounding, onChanged: (bool value) { setState(() { @@ -202,7 +202,7 @@ class _GroundingPageState extends State { Expanded( child: SwitchListTile( title: const Text('Maps Grounding', - style: TextStyle(fontSize: 12)), + style: TextStyle(fontSize: 12),), value: _enableMapsGrounding, onChanged: (bool value) { setState(() { @@ -215,7 +215,7 @@ class _GroundingPageState extends State { ), if (_enableMapsGrounding) Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Expanded( @@ -224,7 +224,7 @@ class _GroundingPageState extends State { decoration: const InputDecoration(labelText: 'Latitude'), keyboardType: const TextInputType.numberWithOptions( - decimal: true, signed: true), + decimal: true, signed: true,), ), ), const SizedBox(width: 16), @@ -234,7 +234,7 @@ class _GroundingPageState extends State { decoration: const InputDecoration(labelText: 'Longitude'), keyboardType: const TextInputType.numberWithOptions( - decimal: true, signed: true), + decimal: true, signed: true,), ), ), ], diff --git a/packages/firebase_ai/firebase_ai/test/tool_test.dart b/packages/firebase_ai/firebase_ai/test/tool_test.dart index 0d9a749e39a2..cf4816b338ac 100644 --- a/packages/firebase_ai/firebase_ai/test/tool_test.dart +++ b/packages/firebase_ai/firebase_ai/test/tool_test.dart @@ -246,20 +246,20 @@ void main() { test('RetrievalConfig.toJson() with all fields', () { final config = RetrievalConfig( - latLng: LatLng(latitude: 1.0, longitude: 2.0), + latLng: LatLng(latitude: 1.2, longitude: 2.1), languageCode: 'fr', ); expect(config.toJson(), { - 'latLng': {'latitude': 1.0, 'longitude': 2.0}, + 'latLng': {'latitude': 1.2, 'longitude': 2.1}, 'languageCode': 'fr', }); }); test('RetrievalConfig.toJson() with partial fields', () { final config1 = - RetrievalConfig(latLng: LatLng(latitude: 1.0, longitude: 2.0)); + RetrievalConfig(latLng: LatLng(latitude: 1.2, longitude: 2.1)); expect(config1.toJson(), { - 'latLng': {'latitude': 1.0, 'longitude': 2.0} + 'latLng': {'latitude': 1.2, 'longitude': 2.1} }); final config2 = RetrievalConfig(languageCode: 'fr'); From 4301b4b7b47f8bcb982cbb1ddbb1530ca423b1ab Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 25 Mar 2026 20:46:40 -0400 Subject: [PATCH 03/13] Formatting and import fix --- .../firebase_ai/example/lib/main.dart | 1 + .../example/lib/pages/grounding_page.dart | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 4e3b45a6548d..3454502edd75 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -32,6 +32,7 @@ import 'pages/schema_page.dart'; import 'pages/token_count_page.dart'; import 'pages/video_page.dart'; import 'pages/server_template_page.dart'; +import 'pages/grounding_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); 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 6a101cd1725a..393203447795 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 @@ -189,8 +189,10 @@ class _GroundingPageState extends State { children: [ Expanded( child: SwitchListTile( - title: const Text('Search Grounding', - style: TextStyle(fontSize: 12),), + title: const Text( + 'Search Grounding', + style: TextStyle(fontSize: 12), + ), value: _enableSearchGrounding, onChanged: (bool value) { setState(() { @@ -201,8 +203,10 @@ class _GroundingPageState extends State { ), Expanded( child: SwitchListTile( - title: const Text('Maps Grounding', - style: TextStyle(fontSize: 12),), + title: const Text( + 'Maps Grounding', + style: TextStyle(fontSize: 12), + ), value: _enableMapsGrounding, onChanged: (bool value) { setState(() { @@ -224,7 +228,9 @@ class _GroundingPageState extends State { decoration: const InputDecoration(labelText: 'Latitude'), keyboardType: const TextInputType.numberWithOptions( - decimal: true, signed: true,), + decimal: true, + signed: true, + ), ), ), const SizedBox(width: 16), @@ -234,7 +240,9 @@ class _GroundingPageState extends State { decoration: const InputDecoration(labelText: 'Longitude'), keyboardType: const TextInputType.numberWithOptions( - decimal: true, signed: true,), + decimal: true, + signed: true, + ), ), ), ], From 9880afbf444861465edb299dbe25236abd88a2ae Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 25 Mar 2026 22:03:58 -0400 Subject: [PATCH 04/13] Update packages/firebase_ai/firebase_ai/example/lib/pages/grounding_page.dart Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../firebase_ai/example/lib/pages/grounding_page.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 393203447795..01b47ca423f1 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 @@ -125,7 +125,8 @@ class _GroundingPageState extends State { return '- [$title](${chunk.web!.uri})'; } if (chunk.maps != null) { - return '- [${chunk.maps!.title}](${chunk.maps!.uri})'; + final title = chunk.maps!.title ?? chunk.maps!.uri; + return '- [${title ?? 'Maps Result'}](${chunk.maps!.uri ?? ''})'; } return '- Unknown chunk'; }).join('\n'); From 8851d18f386ac19ded505ffd85a7c47ea3dbae31 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 25 Mar 2026 22:06:11 -0400 Subject: [PATCH 05/13] Gemini Code Review feedback --- .../example/lib/pages/grounding_page.dart | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) 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 01b47ca423f1..8456be1a9e4c 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 @@ -74,19 +74,15 @@ class _GroundingPageState extends State { } } - if (widget.useVertexBackend) { - _model = FirebaseAI.vertexAI(auth: FirebaseAuth.instance).generativeModel( - model: 'gemini-2.5-flash', - tools: tools.isNotEmpty ? tools : null, - toolConfig: toolConfig, - ); - } else { - _model = FirebaseAI.googleAI(auth: FirebaseAuth.instance).generativeModel( - model: 'gemini-2.5-flash', - tools: tools.isNotEmpty ? tools : null, - toolConfig: toolConfig, - ); - } + final aiProvider = widget.useVertexBackend + ? FirebaseAI.vertexAI(auth: FirebaseAuth.instance) + : FirebaseAI.googleAI(auth: FirebaseAuth.instance); + + _model = aiProvider.generativeModel( + model: 'gemini-2.5-flash', + tools: tools.isNotEmpty ? tools : null, + toolConfig: toolConfig, + ); } void _scrollDown() { @@ -145,12 +141,14 @@ class _GroundingPageState extends State { } catch (e) { _showError(e.toString()); } finally { - _textController.clear(); - setState(() { - _loading = false; - }); - _textFieldFocus.requestFocus(); - _scrollDown(); + if (mounted) { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + _scrollDown(); + } } } From dad79a34ff166bcb05fda1b40e5727cb2f354c27 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Fri, 27 Mar 2026 13:44:15 -0400 Subject: [PATCH 06/13] Remove unnecessary comment --- .../firebase_ai/firebase_ai/test/firebase_vertexai_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart index c2926e58dd1c..71c347f24333 100644 --- a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart +++ b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart @@ -112,7 +112,5 @@ void main() { expect(model, isA()); }); - - // ... other tests (e.g., with different parameters) }); } From 222a57510031564b677ddc8e04477782d70d503d Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Fri, 27 Mar 2026 13:47:15 -0400 Subject: [PATCH 07/13] Add WebGroundingChunk, improved sorting --- packages/firebase_ai/firebase_ai/lib/firebase_ai.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 6b4bff98898b..07d88159e68b 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -18,12 +18,12 @@ export 'src/api.dart' Candidate, CitationMetadata, Citation, - GoogleMapsGroundingChunk, - GroundingChunk, CountTokensResponse, FinishReason, GenerateContentResponse, GenerationConfig, + GoogleMapsGroundingChunk, + GroundingChunk, ThinkingConfig, ThinkingLevel, HarmBlockThreshold, @@ -34,7 +34,8 @@ export 'src/api.dart' ResponseModalities, SafetyRating, SafetySetting, - UsageMetadata; + UsageMetadata, + WebGroundingChunk; export 'src/base_model.dart' show GenerativeModel, From 34672afd9375e0a2dd7be205fd2fa733da4a0af1 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 7 Apr 2026 11:37:45 -0700 Subject: [PATCH 08/13] update example to merge multimodel and add nano banana page --- .../firebase_ai/example/lib/main.dart | 43 +-- .../example/lib/pages/audio_page.dart | 185 ---------- .../example/lib/pages/document.dart | 117 ------- .../lib/pages/image_generation_page.dart | 198 +++++++++++ .../example/lib/pages/multimodal_page.dart | 319 ++++++++++++++++++ .../example/lib/pages/video_page.dart | 114 ------- 6 files changed, 535 insertions(+), 441 deletions(-) delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/audio_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/document.dart create mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart create mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart delete mode 100644 packages/firebase_ai/firebase_ai/example/lib/pages/video_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 69f37490f6ba..77afe3faa236 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -18,19 +18,18 @@ 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/audio_page.dart'; import 'pages/bidi_page.dart'; import 'pages/chat_page.dart'; -import 'pages/document.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/token_count_page.dart'; -import 'pages/video_page.dart'; import 'pages/server_template_page.dart'; +import 'pages/token_count_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -142,7 +141,7 @@ class _HomeScreenState extends State { useVertexBackend: useVertexBackend, ); case 1: - return AudioPage(title: 'Audio', model: currentModel); + return MultimodalPage(title: 'Multimodal', model: currentModel); case 2: return TokenCountPage(title: 'Token Count', model: currentModel); case 3: @@ -154,20 +153,19 @@ class _HomeScreenState extends State { case 4: return ImagePromptPage(title: 'Image Prompt', model: currentModel); case 5: - return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); + return ImageGenerationPage( + title: 'Image Gen', useVertexBackend: useVertexBackend); case 6: - return JsonSchemaPage(title: 'JSON Schema', model: currentModel); + return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); case 7: - return DocumentPage(title: 'Document Prompt', model: currentModel); + return JsonSchemaPage(title: 'JSON Schema', model: currentModel); case 8: - return VideoPage(title: 'Video Prompt', model: currentModel); - case 9: return BidiPage( title: 'Live Stream', model: currentModel, useVertexBackend: useVertexBackend, ); - case 10: + case 9: return ServerTemplatePage( title: 'Server Template', useVertexBackend: useVertexBackend, @@ -251,9 +249,9 @@ class _HomeScreenState extends State { tooltip: 'Chat', ), BottomNavigationBarItem( - icon: Icon(Icons.mic), - label: 'Audio', - tooltip: 'Audio Prompt', + icon: Icon(Icons.perm_media), + label: 'Multimodal', + tooltip: 'Multimodal Prompt', ), BottomNavigationBarItem( icon: Icon(Icons.numbers), @@ -270,6 +268,11 @@ class _HomeScreenState extends State { label: 'Image', tooltip: 'Image Prompt', ), + BottomNavigationBarItem( + icon: Icon(Icons.brush), + label: 'NanoBanana', + tooltip: 'Image Generation', + ), BottomNavigationBarItem( icon: Icon(Icons.schema), label: 'Schema', @@ -280,16 +283,6 @@ class _HomeScreenState extends State { label: 'JSON', tooltip: 'JSON Schema', ), - BottomNavigationBarItem( - icon: Icon(Icons.edit_document), - label: 'Document', - tooltip: 'Document Prompt', - ), - BottomNavigationBarItem( - icon: Icon(Icons.video_collection), - label: 'Video', - tooltip: 'Video Prompt', - ), BottomNavigationBarItem( icon: Icon( Icons.stream, diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/audio_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/audio_page.dart deleted file mode 100644 index 4af259693bac..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/audio_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:io'; - -import 'package:flutter/material.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import '../widgets/message_widget.dart'; -import 'package:record/record.dart'; -import 'package:path_provider/path_provider.dart'; - -final record = AudioRecorder(); - -class AudioPage extends StatefulWidget { - const AudioPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _AudioPageState(); -} - -class _AudioPageState extends State { - ChatSession? chat; - final ScrollController _scrollController = ScrollController(); - final List _messages = []; - bool _recording = false; - - @override - void initState() { - super.initState(); - chat = widget.model.startChat(); - } - - Future recordAudio() async { - if (!await record.hasPermission()) { - print('Audio recording permission denied'); - return; - } - - final dir = Directory( - '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', - ); - - // ignore: avoid_slow_async_io - if (!await dir.exists()) { - 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) { - print('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(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)); - }); - - final response = await widget.model.generateContent([ - Content.multi([prompt, audioPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - }); - - debugPrint(response.text); - } catch (e) { - debugPrint('Error sending audio to model: $e'); - } - } - - @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: [ - IconButton( - onPressed: () async { - setState(() { - _recording = !_recording; - }); - if (_recording) { - await recordAudio(); - } else { - await stopRecord(); - } - }, - icon: Icon( - Icons.mic, - color: _recording - ? Colors.blueGrey - : Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox.square( - dimension: 15, - ), - const Text( - 'Tap the mic to record, tap again to submit', - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/document.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/document.dart deleted file mode 100644 index db2715c402e0..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/document.dart +++ /dev/null @@ -1,117 +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 DocumentPage extends StatefulWidget { - const DocumentPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _DocumentPageState(); -} - -class _DocumentPageState extends State { - ChatSession? chat; - late final GenerativeModel model; - final List _messages = []; - bool _loading = false; - - @override - void initState() { - super.initState(); - chat = widget.model.startChat(); - } - - Future _testDocumentReading(model) async { - try { - ByteData docBytes = - await rootBundle.load('assets/documents/gemini_summary.pdf'); - - const _prompt = - 'Write me a summary in one sentence what this document is about.'; - - const prompt = TextPart(_prompt); - - setState(() { - _messages.add(MessageData(text: _prompt, fromUser: true)); - }); - - final pdfPart = - InlineDataPart('application/pdf', docBytes.buffer.asUint8List()); - - final response = await widget.model.generateContent([ - Content.multi([prompt, pdfPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - }); - } catch (e) { - print('Error sending document to model: $e'); - } - } - - @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: Center( - child: SizedBox( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _testDocumentReading(widget.model); - } - : null, - child: const Text('Test Document Reading'), - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart new file mode 100644 index 000000000000..29c6e897eb5a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart @@ -0,0 +1,198 @@ +// 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:typed_data'; +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../widgets/message_widget.dart'; + +class ImageGenerationPage extends StatefulWidget { + const ImageGenerationPage({ + super.key, + required this.title, + required this.useVertexBackend, + }); + + final String title; + final bool useVertexBackend; + + @override + State createState() => _ImageGenerationPageState(); +} + +class _ImageGenerationPageState extends State { + late GenerativeModel _model; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + _initializeModel(); + } + + void _initializeModel() { + final aiClient = widget.useVertexBackend + ? FirebaseAI.vertexAI(auth: FirebaseAuth.instance) + : FirebaseAI.googleAI(auth: FirebaseAuth.instance); + + _model = aiClient.generativeModel( + model: 'gemini-2.5-flash-image', + generationConfig: GenerationConfig( + responseModalities: [ResponseModalities.text, ResponseModalities.image], + ), + ); + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 750), + curve: Curves.easeOutCirc, + ), + ); + } + + Future _generateImage(String prompt) async { + if (prompt.trim().isEmpty) return; + + setState(() { + _loading = true; + _messages.add(MessageData(text: prompt, fromUser: true)); + }); + _textController.clear(); + _scrollDown(); + + try { + final response = await _model.generateContent([Content.text(prompt)]); + + String? textResponse = response.text; + Uint8List? imageBytes; + + if (response.inlineDataParts.isNotEmpty) { + imageBytes = response.inlineDataParts.first.bytes; + } + + setState(() { + _messages.add( + MessageData( + text: (textResponse ?? '') + + (imageBytes != null + ? '\nGenerated Image:' + : 'No picture generated'), + imageBytes: imageBytes, + fromUser: false, + ), + ); + }); + } catch (e) { + _showError(e.toString()); + } finally { + setState(() { + _loading = false; + }); + _scrollDown(); + _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'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + return MessageWidget( + text: message.text, + image: message.imageBytes == null + ? null + : Image.memory( + message.imageBytes!, + fit: BoxFit.contain, + ), + isFromUser: message.fromUser ?? false, + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 25, horizontal: 15), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + decoration: const InputDecoration( + hintText: 'Enter image prompt...', + ), + onSubmitted: _generateImage, + ), + ), + const SizedBox(width: 15), + if (!_loading) + IconButton( + onPressed: () => _generateImage(_textController.text), + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 000000000000..b9333090ac56 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart @@ -0,0 +1,319 @@ +// 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: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', + ); + + if (!await dir.exists()) { + 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.0), + 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/video_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/video_page.dart deleted file mode 100644 index 565555e19cd6..000000000000 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/video_page.dart +++ /dev/null @@ -1,114 +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 VideoPage extends StatefulWidget { - const VideoPage({super.key, required this.title, required this.model}); - - final String title; - final GenerativeModel model; - - @override - State createState() => _VideoPageState(); -} - -class _VideoPageState extends State { - ChatSession? chat; - late final GenerativeModel model; - final List _messages = []; - bool _loading = false; - - @override - void initState() { - super.initState(); - chat = widget.model.startChat(); - } - - Future _testVideo(model) async { - try { - ByteData videoBytes = - await rootBundle.load('assets/videos/landscape.mp4'); - - const _prompt = 'Can you tell me what is in the video?'; - - setState(() { - _messages.add(MessageData(text: _prompt, fromUser: true)); - }); - - final videoPart = - InlineDataPart('video/mp4', videoBytes.buffer.asUint8List()); - - final response = await widget.model.generateContent([ - Content.multi([const TextPart(_prompt), videoPart]), - ]); - - setState(() { - _messages.add(MessageData(text: response.text, fromUser: false)); - }); - } catch (e) { - print('Error sending video to model: $e'); - } - } - - @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: Center( - child: SizedBox( - child: ElevatedButton( - onPressed: !_loading - ? () async { - await _testVideo(widget.model); - } - : null, - child: const Text('Test Video Prompt'), - ), - ), - ), - ), - ], - ), - ), - ); - } -} From ad06fab59e2e4f25eaaf5ca578e2c01e26f0ac4f Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Tue, 7 Apr 2026 11:39:37 -0700 Subject: [PATCH 09/13] fix the new file year header --- .../firebase_ai/example/lib/pages/image_generation_page.dart | 2 +- .../firebase_ai/example/lib/pages/multimodal_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart index 29c6e897eb5a..e6830292bde3 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// 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. 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 index b9333090ac56..f9fd48aedcf2 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// 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. From f3b22520348369be57c4d59c9b3fd668ad6279bf Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Wed, 8 Apr 2026 11:12:54 -0700 Subject: [PATCH 10/13] fix analyzer --- packages/firebase_ai/firebase_ai/example/lib/main.dart | 2 +- .../example/lib/pages/image_generation_page.dart | 2 +- .../firebase_ai/example/lib/pages/multimodal_page.dart | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 77afe3faa236..b46aa9b99e49 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -154,7 +154,7 @@ class _HomeScreenState extends State { return ImagePromptPage(title: 'Image Prompt', model: currentModel); case 5: return ImageGenerationPage( - title: 'Image Gen', useVertexBackend: useVertexBackend); + title: 'Image Gen', useVertexBackend: useVertexBackend,); case 6: return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); case 7: diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart index e6830292bde3..f4e2f527d3c4 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/image_generation_page.dart @@ -139,7 +139,7 @@ class _ImageGenerationPageState extends State { title: Text(widget.title), ), body: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( children: [ Expanded( 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 index f9fd48aedcf2..9c559abdaada 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/multimodal_page.dart @@ -55,9 +55,7 @@ class _MultimodalPageState extends State { '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', ); - if (!await dir.exists()) { - await dir.create(recursive: true); - } + await dir.create(recursive: true); String filePath = '${dir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; @@ -233,7 +231,7 @@ class _MultimodalPageState extends State { ), if (_loading) const Padding( - padding: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8), child: CircularProgressIndicator(), ), Padding( From 211ec5e8c7943085ac5cdeb579c0f926ea447838 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Wed, 8 Apr 2026 11:17:34 -0700 Subject: [PATCH 11/13] fix the format --- packages/firebase_ai/firebase_ai/example/lib/main.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index b46aa9b99e49..c978b210a066 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -154,7 +154,9 @@ class _HomeScreenState extends State { return ImagePromptPage(title: 'Image Prompt', model: currentModel); case 5: return ImageGenerationPage( - title: 'Image Gen', useVertexBackend: useVertexBackend,); + title: 'Image Gen', + useVertexBackend: useVertexBackend, + ); case 6: return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); case 7: From 7519fa9c300fa959b483529cc1b872f561f272f3 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Fri, 10 Apr 2026 15:37:48 -0700 Subject: [PATCH 12/13] Add maps grounding to server prompt template --- .../firebase_ai/example/lib/main.dart | 2 +- .../lib/pages/server_template_page.dart | 56 ++++++++++++++++ .../src/server_template/template_chat.dart | 15 ++--- .../template_generative_model.dart | 17 ++--- .../src/server_template/template_tool.dart | 11 +++- .../test/server_template_test.dart | 66 +++++++++++++++++++ 6 files changed, 148 insertions(+), 19 deletions(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 6671cc7bb41f..bb42d139229c 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -173,7 +173,7 @@ class _HomeScreenState extends State { title: 'Server Template', useVertexBackend: useVertexBackend, ); - case 12: + case 10: return GroundingPage( title: 'Grounding', useVertexBackend: useVertexBackend, diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart index c10c6e243323..ad351e4778c7 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/server_template_page.dart @@ -260,6 +260,18 @@ class _ServerTemplatePageState extends State { ), tooltip: 'URL Context', ), + IconButton( + onPressed: () async { + await _serverTemplateMapsGrounding( + _textController.text, + ); + }, + icon: Icon( + Icons.map, + color: Theme.of(context).colorScheme.primary, + ), + tooltip: 'Maps Grounding', + ), IconButton( onPressed: () async { await _sendServerTemplateMessage(_textController.text); @@ -356,6 +368,50 @@ class _ServerTemplatePageState extends State { }); } + Future _serverTemplateMapsGrounding(String message) async { + await _handleServerTemplateMessage(message, (message) async { + var response = await _templateGenerativeModel + // ignore: experimental_member_use + ?.generateContent( + 'cj-googlemaps', + inputs: {'question': message}, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 37.422, longitude: -122.084), // Googleplex + ), + ), + ); + + final candidate = response?.candidates.first; + if (candidate == null) { + _messages.add(MessageData(text: 'No response', fromUser: false)); + } else { + final responseText = candidate.text ?? ''; + final groundingMetadata = candidate.groundingMetadata; + + final buffer = StringBuffer(responseText); + if (groundingMetadata != null) { + buffer.writeln('\n\n--- Grounding Metadata ---'); + buffer.writeln('Grounding Chunks:'); + for (final chunk in groundingMetadata.groundingChunks) { + if (chunk.web != null) { + buffer.writeln(' - Web Chunk:'); + buffer.writeln(' - Title: ${chunk.web!.title}'); + buffer.writeln(' - URI: ${chunk.web!.uri}'); + } + if (chunk.maps != null) { + buffer.writeln(' - Maps Chunk:'); + buffer.writeln(' - Title: ${chunk.maps!.title}'); + buffer.writeln(' - URI: ${chunk.maps!.uri}'); + } + } + } + + _messages.add(MessageData(text: buffer.toString(), fromUser: false)); + } + }); + } + Future _serverTemplateAutoFunctionCall(String message) async { await _handleServerTemplateMessage(message, (message) async { // Inputs are no longer passed during sendMessage diff --git a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart index 26fb90dabc7d..2efa63689dba 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_chat.dart @@ -47,14 +47,13 @@ final class TemplateChatSession { Iterable content, String templateId, {required Map inputs, List? tools, - TemplateToolConfig? templateToolConfig}) _templateHistoryGenerateContent; + TemplateToolConfig? toolConfig}) _templateHistoryGenerateContent; final Stream Function( - Iterable content, String templateId, - {required Map inputs, - List? tools, - TemplateToolConfig? templateToolConfig}) - _templateHistoryGenerateContentStream; + Iterable content, String templateId, + {required Map inputs, + List? tools, + TemplateToolConfig? toolConfig}) _templateHistoryGenerateContentStream; final String _templateId; final Map _inputs; @@ -93,7 +92,7 @@ final class TemplateChatSession { _templateId, inputs: _inputs, tools: _tools, - templateToolConfig: _toolConfig, + toolConfig: _toolConfig, ); final functionCalls = response.functionCalls; @@ -161,7 +160,7 @@ final class TemplateChatSession { _templateId, inputs: _inputs, tools: _tools, - templateToolConfig: _toolConfig, + toolConfig: _toolConfig, ); final turnChunks = []; diff --git a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart index 41efccb4f460..0b94fc9eaea4 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_generative_model.dart @@ -69,14 +69,15 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { /// Sends a "templateGenerateContent" API request for the configured model. @experimental Future generateContent(String templateId, - {required Map inputs}) => + {required Map inputs, + TemplateToolConfig? toolConfig}) => makeTemplateRequest( TemplateTask.templateGenerateContent, templateId, inputs, null, // history null, // tools - null, // toolConfig + toolConfig, _serializationStrategy.parseGenerateContentResponse); /// Generates a stream of content responding to [templateId] and [inputs]. @@ -85,14 +86,14 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { /// and waits for the response. @experimental Stream generateContentStream(String templateId, - {required Map inputs}) { + {required Map inputs, TemplateToolConfig? toolConfig}) { return streamTemplateRequest( TemplateTask.templateStreamGenerateContent, templateId, inputs, null, // history null, // tools - null, // toolConfig + toolConfig, _serializationStrategy.parseGenerateContentResponse); } @@ -103,14 +104,14 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { Iterable history, String templateId, {required Map inputs, List? tools, - TemplateToolConfig? templateToolConfig}) => + TemplateToolConfig? toolConfig}) => makeTemplateRequest( TemplateTask.templateGenerateContent, templateId, inputs, history, tools, - templateToolConfig, + toolConfig, _serializationStrategy.parseGenerateContentResponse); /// Generates a stream of content from a template with the given [templateId], @@ -120,14 +121,14 @@ final class TemplateGenerativeModel extends BaseTemplateApiClientModel { Iterable history, String templateId, {required Map inputs, List? tools, - TemplateToolConfig? templateToolConfig}) { + TemplateToolConfig? toolConfig}) { return streamTemplateRequest( TemplateTask.templateStreamGenerateContent, templateId, inputs, history, tools, - templateToolConfig, + toolConfig, _serializationStrategy.parseGenerateContentResponse); } } diff --git a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart index 31c60af4b82f..603875020c3b 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/server_template/template_tool.dart @@ -14,6 +14,7 @@ import 'dart:async'; import '../schema.dart'; +import '../tool.dart'; /// A collection of template tools. final class TemplateTool { @@ -98,8 +99,14 @@ final class TemplateAutoFunctionDeclaration /// Config for template tools to use with server prompts. final class TemplateToolConfig { // ignore: public_member_api_docs - TemplateToolConfig(); + TemplateToolConfig({RetrievalConfig? retrievalConfig}) + : _retrievalConfig = retrievalConfig; + + final RetrievalConfig? _retrievalConfig; /// Convert to json object. - Map toJson() => {}; + Map toJson() => { + if (_retrievalConfig case final retrievalConfig?) + 'retrievalConfig': retrievalConfig.toJson(), + }; } diff --git a/packages/firebase_ai/firebase_ai/test/server_template_test.dart b/packages/firebase_ai/firebase_ai/test/server_template_test.dart index ebc2170e7037..71d0905a7424 100644 --- a/packages/firebase_ai/firebase_ai/test/server_template_test.dart +++ b/packages/firebase_ai/firebase_ai/test/server_template_test.dart @@ -86,6 +86,37 @@ void main() { expect(response.text, 'Some response'); }); + test('generateContent with TemplateToolConfig passes retrievalConfig', + () async { + final mockHttp = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + expect(request.url.path, + endsWith('/templates/$templateId:templateGenerateContent')); + expect(body['inputs'], {'prompt': 'Some prompt'}); + expect(body['toolConfig'], { + 'retrievalConfig': { + 'latLng': {'latitude': 1.0, 'longitude': 2.0}, + 'languageCode': 'en' + } + }); + return http.Response(jsonEncode(_arbitraryGenerateContentResponse), 200, + headers: {'content-type': 'application/json'}); + }); + + final model = createModel(mockHttp); + final response = await model.generateContent( + templateId, + inputs: {'prompt': 'Some prompt'}, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 1, longitude: 2), + languageCode: 'en', + ), + ), + ); + expect(response.text, 'Some response'); + }); + test('generateContentStream can make successful request', () async { final mockHttp = MockClient((request) async { final body = jsonDecode(request.body) as Map; @@ -105,6 +136,41 @@ void main() { final response = await responseStream.first; expect(response.text, 'Some response'); }); + + test('generateContentStream with TemplateToolConfig passes retrievalConfig', + () async { + final mockHttp = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + expect(request.url.path, + endsWith('/templates/$templateId:templateStreamGenerateContent')); + expect(body['inputs'], {'prompt': 'Some prompt'}); + expect(body['toolConfig'], { + 'retrievalConfig': { + 'latLng': {'latitude': 1.0, 'longitude': 2.0}, + 'languageCode': 'en' + } + }); + final responsePayload = jsonEncode(_arbitraryGenerateContentResponse); + final stream = Stream.value(utf8.encode('data: $responsePayload')); + final streamedResponse = http.StreamedResponse(stream, 200, + headers: {'content-type': 'application/json'}); + return http.Response.fromStream(streamedResponse); + }); + + final model = createModel(mockHttp); + final responseStream = model.generateContentStream( + templateId, + inputs: {'prompt': 'Some prompt'}, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: 1, longitude: 2), + languageCode: 'en', + ), + ), + ); + final response = await responseStream.first; + expect(response.text, 'Some response'); + }); }); group('TemplateImagenModel', () { From cb11ce55a70608dd92d6eaddd1bacd67b89295e7 Mon Sep 17 00:00:00 2001 From: Cynthia J Date: Fri, 10 Apr 2026 15:49:36 -0700 Subject: [PATCH 13/13] extra white space --- packages/firebase_ai/firebase_ai/example/lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart index 6e2a9b7f8501..bb42d139229c 100644 --- a/packages/firebase_ai/firebase_ai/example/lib/main.dart +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -18,7 +18,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';