Skip to content

Commit 385d933

Browse files
ryanwilsongemini-code-assist[bot]cynthiajoan
authored
feat(firebaseai): add Google Maps Grounding support (#18144)
* 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 * Fixes * Formatting and import fix * 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> * Gemini Code Review feedback * Remove unnecessary comment * Add WebGroundingChunk, improved sorting * update example to merge multimodel and add nano banana page * fix the new file year header * fix analyzer * fix the format * Add maps grounding to server prompt template * extra white space --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Cynthia J <cynthiajoan@gmail.com>
1 parent c986356 commit 385d933

File tree

14 files changed

+745
-29
lines changed

14 files changed

+745
-29
lines changed

packages/firebase_ai/firebase_ai/example/lib/main.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import 'pages/json_schema_page.dart';
2929
import 'pages/multimodal_page.dart';
3030
import 'pages/schema_page.dart';
3131
import 'pages/server_template_page.dart';
32+
import 'pages/grounding_page.dart';
3233
import 'pages/token_count_page.dart';
3334

3435
void main() async {
@@ -172,6 +173,11 @@ class _HomeScreenState extends State<HomeScreen> {
172173
title: 'Server Template',
173174
useVertexBackend: useVertexBackend,
174175
);
176+
case 10:
177+
return GroundingPage(
178+
title: 'Grounding',
179+
useVertexBackend: useVertexBackend,
180+
);
175181

176182
default:
177183
// Fallback to the first page in case of an unexpected index
@@ -299,6 +305,13 @@ class _HomeScreenState extends State<HomeScreen> {
299305
label: 'Server',
300306
tooltip: 'Server Template',
301307
),
308+
BottomNavigationBarItem(
309+
icon: Icon(
310+
Icons.location_on,
311+
),
312+
label: 'Grounding',
313+
tooltip: 'Search & Maps Grounding',
314+
),
302315
],
303316
currentIndex: _selectedIndex,
304317
onTap: _onItemTapped,
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'package:firebase_auth/firebase_auth.dart';
16+
import 'package:flutter/material.dart';
17+
import 'package:firebase_ai/firebase_ai.dart';
18+
import '../widgets/message_widget.dart';
19+
20+
class GroundingPage extends StatefulWidget {
21+
const GroundingPage({
22+
super.key,
23+
required this.title,
24+
required this.useVertexBackend,
25+
});
26+
27+
final String title;
28+
final bool useVertexBackend;
29+
30+
@override
31+
State<GroundingPage> createState() => _GroundingPageState();
32+
}
33+
34+
class _GroundingPageState extends State<GroundingPage> {
35+
GenerativeModel? _model;
36+
final ScrollController _scrollController = ScrollController();
37+
final TextEditingController _textController = TextEditingController();
38+
final TextEditingController _latController = TextEditingController();
39+
final TextEditingController _lngController = TextEditingController();
40+
final FocusNode _textFieldFocus = FocusNode();
41+
final List<MessageData> _messages = <MessageData>[];
42+
43+
bool _loading = false;
44+
bool _enableSearchGrounding = false;
45+
bool _enableMapsGrounding = false;
46+
47+
@override
48+
void initState() {
49+
super.initState();
50+
_latController.text = '37.422'; // Default Googleplex lat
51+
_lngController.text = '-122.084'; // Default Googleplex lng
52+
}
53+
54+
void _initializeModel() {
55+
List<Tool> tools = [];
56+
ToolConfig? toolConfig;
57+
58+
if (_enableSearchGrounding) {
59+
tools.add(Tool.googleSearch());
60+
}
61+
62+
if (_enableMapsGrounding) {
63+
tools.add(Tool.googleMaps());
64+
65+
final lat = double.tryParse(_latController.text);
66+
final lng = double.tryParse(_lngController.text);
67+
68+
if (lat != null && lng != null) {
69+
toolConfig = ToolConfig(
70+
retrievalConfig: RetrievalConfig(
71+
latLng: LatLng(latitude: lat, longitude: lng),
72+
),
73+
);
74+
}
75+
}
76+
77+
final aiProvider = widget.useVertexBackend
78+
? FirebaseAI.vertexAI(auth: FirebaseAuth.instance)
79+
: FirebaseAI.googleAI(auth: FirebaseAuth.instance);
80+
81+
_model = aiProvider.generativeModel(
82+
model: 'gemini-2.5-flash',
83+
tools: tools.isNotEmpty ? tools : null,
84+
toolConfig: toolConfig,
85+
);
86+
}
87+
88+
void _scrollDown() {
89+
WidgetsBinding.instance.addPostFrameCallback(
90+
(_) => _scrollController.animateTo(
91+
_scrollController.position.maxScrollExtent,
92+
duration: const Duration(milliseconds: 750),
93+
curve: Curves.easeOutCirc,
94+
),
95+
);
96+
}
97+
98+
Future<void> _sendPrompt(String message) async {
99+
if (message.isEmpty) return;
100+
101+
_initializeModel(); // Re-initialize before sending to capture current toggles
102+
103+
setState(() {
104+
_loading = true;
105+
});
106+
107+
try {
108+
_messages.add(MessageData(text: message, fromUser: true));
109+
110+
final response = await _model?.generateContent([Content.text(message)]);
111+
112+
var text = response?.text;
113+
114+
// Extract grounding metadata to display
115+
final groundingMetadata =
116+
response?.candidates.firstOrNull?.groundingMetadata;
117+
if (groundingMetadata != null) {
118+
final chunks = groundingMetadata.groundingChunks.map((chunk) {
119+
if (chunk.web != null) {
120+
final title = chunk.web!.title ?? chunk.web!.uri;
121+
return '- [$title](${chunk.web!.uri})';
122+
}
123+
if (chunk.maps != null) {
124+
final title = chunk.maps!.title ?? chunk.maps!.uri;
125+
return '- [${title ?? 'Maps Result'}](${chunk.maps!.uri ?? ''})';
126+
}
127+
return '- Unknown chunk';
128+
}).join('\n');
129+
130+
if (chunks.isNotEmpty) {
131+
text = '$text\n\n**Grounding Sources:**\n$chunks';
132+
}
133+
}
134+
135+
_messages.add(MessageData(text: text, fromUser: false));
136+
137+
if (text == null) {
138+
_showError('No response from API.');
139+
return;
140+
}
141+
} catch (e) {
142+
_showError(e.toString());
143+
} finally {
144+
if (mounted) {
145+
_textController.clear();
146+
setState(() {
147+
_loading = false;
148+
});
149+
_textFieldFocus.requestFocus();
150+
_scrollDown();
151+
}
152+
}
153+
}
154+
155+
void _showError(String message) {
156+
showDialog<void>(
157+
context: context,
158+
builder: (context) {
159+
return AlertDialog(
160+
title: const Text('Something went wrong'),
161+
content: SingleChildScrollView(
162+
child: SelectableText(message),
163+
),
164+
actions: [
165+
TextButton(
166+
onPressed: () {
167+
Navigator.of(context).pop();
168+
},
169+
child: const Text('OK'),
170+
),
171+
],
172+
);
173+
},
174+
);
175+
}
176+
177+
@override
178+
Widget build(BuildContext context) {
179+
return Scaffold(
180+
appBar: AppBar(
181+
title: Text(widget.title),
182+
),
183+
body: Padding(
184+
padding: const EdgeInsets.all(8),
185+
child: Column(
186+
children: [
187+
Row(
188+
children: [
189+
Expanded(
190+
child: SwitchListTile(
191+
title: const Text(
192+
'Search Grounding',
193+
style: TextStyle(fontSize: 12),
194+
),
195+
value: _enableSearchGrounding,
196+
onChanged: (bool value) {
197+
setState(() {
198+
_enableSearchGrounding = value;
199+
});
200+
},
201+
),
202+
),
203+
Expanded(
204+
child: SwitchListTile(
205+
title: const Text(
206+
'Maps Grounding',
207+
style: TextStyle(fontSize: 12),
208+
),
209+
value: _enableMapsGrounding,
210+
onChanged: (bool value) {
211+
setState(() {
212+
_enableMapsGrounding = value;
213+
});
214+
},
215+
),
216+
),
217+
],
218+
),
219+
if (_enableMapsGrounding)
220+
Padding(
221+
padding: const EdgeInsets.symmetric(horizontal: 16),
222+
child: Row(
223+
children: [
224+
Expanded(
225+
child: TextField(
226+
controller: _latController,
227+
decoration:
228+
const InputDecoration(labelText: 'Latitude'),
229+
keyboardType: const TextInputType.numberWithOptions(
230+
decimal: true,
231+
signed: true,
232+
),
233+
),
234+
),
235+
const SizedBox(width: 16),
236+
Expanded(
237+
child: TextField(
238+
controller: _lngController,
239+
decoration:
240+
const InputDecoration(labelText: 'Longitude'),
241+
keyboardType: const TextInputType.numberWithOptions(
242+
decimal: true,
243+
signed: true,
244+
),
245+
),
246+
),
247+
],
248+
),
249+
),
250+
const Divider(),
251+
Expanded(
252+
child: ListView.builder(
253+
controller: _scrollController,
254+
itemBuilder: (context, idx) {
255+
final message = _messages[idx];
256+
return MessageWidget(
257+
text: message.text,
258+
isFromUser: message.fromUser ?? false,
259+
);
260+
},
261+
itemCount: _messages.length,
262+
),
263+
),
264+
Padding(
265+
padding: const EdgeInsets.symmetric(
266+
vertical: 25,
267+
horizontal: 15,
268+
),
269+
child: Row(
270+
children: [
271+
Expanded(
272+
child: TextField(
273+
autofocus: true,
274+
focusNode: _textFieldFocus,
275+
controller: _textController,
276+
onSubmitted: _sendPrompt,
277+
decoration: const InputDecoration(
278+
hintText: 'Enter a prompt...',
279+
),
280+
),
281+
),
282+
const SizedBox.square(dimension: 15),
283+
if (!_loading)
284+
IconButton(
285+
onPressed: () {
286+
_sendPrompt(_textController.text);
287+
},
288+
icon: Icon(
289+
Icons.send,
290+
color: Theme.of(context).colorScheme.primary,
291+
),
292+
)
293+
else
294+
const CircularProgressIndicator(),
295+
],
296+
),
297+
),
298+
],
299+
),
300+
),
301+
);
302+
}
303+
}

0 commit comments

Comments
 (0)