diff --git a/firebase_ai_logic_showcase/README/flutter_firebase_ai_sample_hero.png b/firebase_ai_logic_showcase/README/flutter_firebase_ai_sample_hero.png index 0718317..781363e 100644 Binary files a/firebase_ai_logic_showcase/README/flutter_firebase_ai_sample_hero.png and b/firebase_ai_logic_showcase/README/flutter_firebase_ai_sample_hero.png differ diff --git a/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart b/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart index 5ee5670..73eab04 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart +++ b/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart @@ -24,7 +24,7 @@ import '../../shared/ui/app_frame.dart'; import '../../shared/ui/app_spacing.dart'; import './ui_components/ui_components.dart'; import './firebaseai_chat_service.dart'; -import 'ui_components/model_picker.dart'; + import './models/models.dart'; class ChatDemo extends ConsumerStatefulWidget { @@ -45,17 +45,17 @@ class _ChatDemoState extends ConsumerState { Uint8List? _attachment; final ScrollController _scrollController = ScrollController(); bool _loading = false; - OverlayPortalController opController = OverlayPortalController(); @override void initState() { super.initState(); - _chatService = ChatService(ref); + final model = geminiModels.selectModel('gemini-2.5-flash'); + _chatService = ChatService(ref, model); _chatService.init(); - _userTextInputController.text = geminiModels.selectedModel.defaultPrompt; - + _userTextInputController.text = + 'Hey Gemini! Can you set the app color to purple?'; WidgetsBinding.instance.addPostFrameCallback((_) { - opController.show(); + sendMessage(_userTextInputController.text); }); } @@ -162,79 +162,34 @@ class _ChatDemoState extends ConsumerState { } } - void showModelPicker() { - opController.hide(); - showDialog( - context: context, - builder: (context) { - return ModelPicker( - selectedModel: geminiModels.selectedModel, - onSelected: (value) { - _chatService.changeModel(value); - setState(() { - _userTextInputController.text = - geminiModels.selectedModel.defaultPrompt; - _messages.clear(); - }); - }, - ); - }, - ); - } - @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - title: const Text('Chat Demo'), - actions: [ - OverlayPortal( - controller: opController, - child: IconButton( - onPressed: showModelPicker, - icon: Icon(Icons.settings_outlined), - ), - overlayChildBuilder: (context) { - return Positioned( - right: 0, - top: 40, - child: Dialog( - insetAnimationDuration: Duration(milliseconds: 2000), - constraints: BoxConstraints(maxWidth: 500), - insetPadding: EdgeInsets.all(8), - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [Text('Try another model!')], - ), + body: Column( + children: [ + Expanded( + child: AppFrame( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.s16), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: MessageListView( + messages: _messages, + scrollController: _scrollController, + ), + ), + if (_loading) const LinearProgressIndicator(), + AttachmentPreview(attachment: _attachment), + ], ), ), - ); - }, - ), - ], - ), - body: AppFrame( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.s16), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: MessageListView( - messages: _messages, - scrollController: _scrollController, - ), - ), - if (_loading) const LinearProgressIndicator(), - AttachmentPreview(attachment: _attachment), - ], + ), ), ), - ), + ], ), bottomNavigationBar: MessageInputBar( textController: _userTextInputController, diff --git a/firebase_ai_logic_showcase/lib/demos/chat/firebaseai_chat_service.dart b/firebase_ai_logic_showcase/lib/demos/chat/firebaseai_chat_service.dart index 75b5b0a..e2d4f82 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/firebaseai_chat_service.dart +++ b/firebase_ai_logic_showcase/lib/demos/chat/firebaseai_chat_service.dart @@ -32,21 +32,18 @@ import './models/models.dart'; /// https://firebase.google.com/docs/ai-logic/chat?api=dev class ChatService { final WidgetRef _ref; - ChatService(this._ref); + final GeminiModel _gemini; + + ChatService(this._ref, this._gemini); - GeminiModel? _gemini = geminiModels.selectedModel; late ChatSession _chat; void init() { - var gemini = _gemini; - if (gemini != null) { - _chat = gemini.model.startChat(); - } + _chat = _gemini.model.startChat(); } void changeModel(String modelName) { - _gemini = geminiModels.selectModel(modelName); - init(); + // This is now handled in the UI layer } Future sendMessage(Content message) async { diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/chat_nano_demo.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/chat_nano_demo.dart new file mode 100644 index 0000000..4754441 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/chat_nano_demo.dart @@ -0,0 +1,219 @@ +// 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 'dart:developer'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:permission_handler/permission_handler.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../shared/ui/app_frame.dart'; +import '../../shared/ui/app_spacing.dart'; +import './ui_components/ui_components.dart'; +import './firebaseai_chat_service.dart'; +import 'ui_components/model_picker.dart'; +import './models/gemini_model_nano.dart'; + +class ChatDemoNano extends ConsumerStatefulWidget { + const ChatDemoNano({super.key}); + + @override + ConsumerState createState() => ChatDemoNanoState(); +} + +class ChatDemoNanoState extends ConsumerState { + // Service for interacting with the Gemini API. + late final ChatServiceNano _chatService; + + // UI State + final List _messages = []; + final TextEditingController _userTextInputController = + TextEditingController(); + Uint8List? _attachment; + final ScrollController _scrollController = ScrollController(); + bool _loading = false; + OverlayPortalController opController = OverlayPortalController(); + + @override + void initState() { + super.initState(); + _chatService = ChatServiceNano(ref); + geminiModels.selectModel('gemini-2.5-flash-image-preview'); + _chatService.init(); + _userTextInputController.text = + 'Hot air balloons rising over the San Francisco Bay at golden hour with a view of the Golden Gate Bridge. Make it anime style.'; + } + + @override + void didChangeDependencies() { + requestPermissions(); + super.didChangeDependencies(); + } + + @override + void dispose() { + _scrollController.dispose(); + _userTextInputController.dispose(); + super.dispose(); + } + + Future requestPermissions() async { + if (!kIsWeb) { + await Permission.manageExternalStorage.request(); + } + } + + void _scrollToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + void _pickImage() async { + final pickedImage = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + + if (pickedImage != null) { + final imageBytes = await pickedImage.readAsBytes(); + setState(() { + _attachment = imageBytes; + }); + log('attachment saved!'); + } + } + + void sendMessage(String text) async { + if (text.isEmpty) return; + + setState(() { + _loading = true; + }); + + // Add user message to UI + final userMessageText = text.trim(); + final userAttachment = _attachment; + _messages.add( + MessageData( + text: userMessageText, + image: userAttachment != null ? Image.memory(userAttachment) : null, + fromUser: true, + ), + ); + setState(() { + _attachment = null; + _userTextInputController.clear(); + }); + _scrollToEnd(); + + // Construct the Content object for the service + final content = (userAttachment != null) + ? Content.multi([ + TextPart(userMessageText), + InlineDataPart('image/jpeg', userAttachment), + ]) + : Content.text(userMessageText); + + // Call the service and handle the response + try { + final chatResponse = await _chatService.sendMessage(content); + _messages.add( + MessageData( + text: chatResponse.text, + image: chatResponse.image, + fromUser: false, + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + setState(() { + _loading = false; + }); + _scrollToEnd(); + } + } + + void showModelPicker() { + showDialog( + context: context, + builder: (context) { + return ModelPicker( + selectedModel: geminiModels.selectedModel, + onSelected: (value) { + _chatService.changeModel(value); + setState(() { + _userTextInputController.text = + geminiModels.selectedModel.defaultPrompt; + _messages.clear(); + }); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: AppFrame( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.s16), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: MessageListView( + messages: _messages, + scrollController: _scrollController, + ), + ), + if (_loading) const LinearProgressIndicator(), + AttachmentPreview(attachment: _attachment), + ], + ), + ), + ), + ), + ), + ], + ), + bottomNavigationBar: MessageInputBar( + textController: _userTextInputController, + loading: _loading, + sendMessage: sendMessage, + onPickImagePressed: _pickImage, + ), + ); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/firebaseai_chat_service.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/firebaseai_chat_service.dart new file mode 100644 index 0000000..9827945 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/firebaseai_chat_service.dart @@ -0,0 +1,122 @@ +// 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:developer'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/app_state.dart'; +import '../../shared/firebaseai_imagen_service.dart'; +import './models/chat_response.dart'; +import './models/gemini_model_nano.dart'; + +/// A service that handles all communication with the Firebase AI Gemini API +/// for the Chat Demo. +/// +/// This service demonstrates how to use the `startChat()` method on a +/// `GenerativeModel` to create a persistent conversation. The `ChatSession` +/// object automatically handles the conversation history, making it easy to +/// build multi-turn chat experiences. +/// +/// For more information, see the official documentation: +/// https://firebase.google.com/docs/ai-logic/chat?api=dev +class ChatServiceNano { + final WidgetRef _ref; + ChatServiceNano(this._ref); + + GeminiModel? _gemini = geminiModels.selectedModel; + late ChatSession _chat; + + void init() { + var gemini = _gemini; + if (gemini != null) { + _chat = gemini.model.startChat(); + } + } + + void changeModel(String modelName) { + _gemini = geminiModels.selectModel(modelName); + init(); + } + + Future sendMessage(Content message) async { + try { + var response = await _chat.sendMessage(message); + + if (response.functionCalls.isNotEmpty) { + return _handleFunctionCall(response.functionCalls); + } else { + if (response.inlineDataParts.isNotEmpty) { + final imageBytes = response.inlineDataParts.first.bytes; + var image = Image.memory(imageBytes); + return ChatResponse(text: response.text, image: image); + } + + return ChatResponse(text: response.text); + } + } catch (e) { + log('Error sending message: $e'); + rethrow; + } + } + + Future _handleFunctionCall( + Iterable functionCalls, + ) async { + var functionCall = functionCalls.first; + log("Gemini made a function call: ${functionCall.name}"); + + switch (functionCall.name) { + case 'SetAppColor': + final response = await _handleSetAppColor(functionCall); + return ChatResponse(text: response.text); + case 'GenerateImage': + return await _handleGenerateImage(functionCall); + default: + final response = await _chat.sendMessage( + Content.text( + 'Function Call name was not found! Please try another function call.', + ), + ); + return ChatResponse(text: response.text); + } + } + + Future _handleSetAppColor( + FunctionCall functionCall, + ) async { + log('Set app color!'); + int red = functionCall.args['red']! as int; + int green = functionCall.args['green']! as int; + int blue = functionCall.args['blue']! as int; + var newSeedColor = Color.fromRGBO(red, green, blue, 1); + var executedFunctionCall = _ref + .read(appStateProvider) + .setAppColor(newSeedColor); + return await _chat.sendMessage(Content.text(executedFunctionCall)); + } + + Future _handleGenerateImage(FunctionCall functionCall) async { + log('Generate image!'); + String description = functionCall.args['description']! as String; + var imageBytes = await ImageGenerationService().generateImage(description); + var response = await _chat.sendMessage( + Content.text( + 'Successfully generated an image of $description! Please send back a message to include with the image.', + ), + ); + var responseImage = Image.memory(imageBytes); + return ChatResponse(text: response.text, image: responseImage); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/models/chat_response.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/chat_response.dart new file mode 100644 index 0000000..ebdb9de --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/chat_response.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +/// A simple container for the response from the ChatService. +class ChatResponse { + final String? text; + final Image? image; + + ChatResponse({this.text, this.image}); +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/models/gemini_model.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/gemini_model.dart new file mode 100644 index 0000000..8b892af --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/gemini_model.dart @@ -0,0 +1,71 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import '../../../shared/function_calling/tools.dart'; + +var geminiModels = GeminiModels(); + +class GeminiModel { + final String name; + final String description; + final GenerativeModel model; + final String defaultPrompt; + + GeminiModel({ + required this.name, + required this.description, + required this.model, + required this.defaultPrompt, + }); +} + +class GeminiModels { + String selectedModelName = 'gemini-2.5-flash-image-preview'; + GeminiModel get selectedModel => models[selectedModelName]!; + + /// A map of Gemini models that can be used in the Chat Demo. + Map models = { + 'gemini-2.5-flash': GeminiModel( + name: 'gemini-2.5-flash', + description: + 'Our thinking model that offers great, well-rounded capabilities. It\'s designed to offer a balance between price and performance.', + model: FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash', + tools: [ + Tool.functionDeclarations([setAppColorTool, generateImageTool]), + ], + generationConfig: GenerationConfig( + responseModalities: [ResponseModalities.text], + ), + ), + defaultPrompt: 'Hey Gemini! Can you set the app color to purple?', + ), + 'gemini-2.5-flash-image-preview': GeminiModel( + name: 'gemini-2.5-flash-image-preview', + description: + 'Our standard Flash model upgraded for rapid creative workflows with image generation and conversational, multi-turn editing capabilities.', + model: FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash-image-preview', + generationConfig: GenerationConfig( + responseModalities: [ + ResponseModalities.text, + ResponseModalities.image, + ], + ), + ), + defaultPrompt: + 'Hot air balloons rising over the San Francisco Bay at golden hour ' + 'with a view of the Golden Gate Bridge. Make it anime style.', + ), + }; + + GeminiModel selectModel(String modelName) { + if (models.containsKey(modelName)) { + selectedModelName = modelName; + } else { + throw Exception('Model $modelName not found'); + } + return selectedModel; + } + + List get modelNames => models.keys.toList(); + GeminiModel operator [](String name) => models[name]!; +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/models/gemini_model_nano.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/gemini_model_nano.dart new file mode 100644 index 0000000..8bd8ddf --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/gemini_model_nano.dart @@ -0,0 +1,55 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +var geminiModels = GeminiModelsNano(); + +class GeminiModel { + final String name; + final String description; + final GenerativeModel model; + final String defaultPrompt; + + GeminiModel({ + required this.name, + required this.description, + required this.model, + required this.defaultPrompt, + }); +} + +class GeminiModelsNano { + String selectedModelName = 'gemini-2.5-flash-image-preview'; + GeminiModel get selectedModel => models[selectedModelName]!; + + /// A map of Gemini models that can be used in the Chat Demo. + Map models = { + 'gemini-2.5-flash-image-preview': GeminiModel( + name: 'gemini-2.5-flash-image-preview', + description: + 'Our standard Flash model upgraded for rapid creative workflows with image generation and conversational, multi-turn editing capabilities.', + model: FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash-image-preview', + generationConfig: GenerationConfig( + responseModalities: [ + ResponseModalities.text, + ResponseModalities.image, + ], + ), + ), + defaultPrompt: + 'Hot air balloons rising over the San Francisco Bay at golden hour ' + 'with a view of the Golden Gate Bridge. Make it anime style.', + ), + }; + + GeminiModel selectModel(String modelName) { + if (models.containsKey(modelName)) { + selectedModelName = modelName; + } else { + throw Exception('Model $modelName not found'); + } + return selectedModel; + } + + List get modelNames => models.keys.toList(); + GeminiModel operator [](String name) => models[name]!; +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/models/models.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/models.dart new file mode 100644 index 0000000..bebb95e --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/models/models.dart @@ -0,0 +1,2 @@ +export './chat_response.dart'; +export './gemini_model.dart'; diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/attachment_preview.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/attachment_preview.dart new file mode 100644 index 0000000..18fed23 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/attachment_preview.dart @@ -0,0 +1,47 @@ +// 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 '../../../shared/ui/app_spacing.dart'; + +class AttachmentPreview extends StatelessWidget { + final Uint8List? attachment; + + const AttachmentPreview({super.key, this.attachment}); + + @override + Widget build(BuildContext context) { + return attachment != null + ? Row( + children: [ + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s16), + child: Container( + height: 95, + width: 95, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppSpacing.s8), + image: DecorationImage( + fit: BoxFit.cover, + image: MemoryImage(attachment!), + ), + ), + ), + ), + ], + ) + : Container(); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_bubble.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_bubble.dart new file mode 100644 index 0000000..def2442 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_bubble.dart @@ -0,0 +1,70 @@ +// 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:flutter_animate/flutter_animate.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import './message_widget.dart'; + +class MessageBubble extends StatelessWidget { + final MessageData message; + + const MessageBubble({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + final isFromUser = message.fromUser ?? false; + return ListTile( + minVerticalPadding: 4, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadiusGeometry.circular(16), + ), + contentPadding: isFromUser + ? EdgeInsets.only(left: 16, top: 8, right: 8, bottom: 8) + : EdgeInsets.only(left: 8, top: 8, right: 16, bottom: 8), + leading: (!isFromUser) + ? CircleAvatar( + backgroundColor: Colors.transparent, + child: Image.asset('assets/gemini-logo.png'), + ) + : null, + title: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: isFromUser + ? Theme.of(context).colorScheme.surfaceBright + : Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + if (message.text != null) + Align( + alignment: Alignment.centerLeft, + child: MarkdownBody(data: message.text!), + ), + if (message.image != null) + Padding( + padding: EdgeInsets.only(top: 8), + child: ClipRSuperellipse( + borderRadius: BorderRadius.circular(16), + child: message.image!, + ), + ), + ], + ), + ), + ).animate().fadeIn().slideY().scaleXY(); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_input_bar.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_input_bar.dart new file mode 100644 index 0000000..1ecd2d6 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_input_bar.dart @@ -0,0 +1,93 @@ +// 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 '../../../shared/ui/app_spacing.dart'; + +class MessageInputBar extends StatelessWidget { + final TextEditingController textController; + final bool loading; + final void Function(String) sendMessage; + final VoidCallback onPickImagePressed; + + const MessageInputBar({ + super.key, + required this.textController, + required this.loading, + required this.sendMessage, + required this.onPickImagePressed, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(AppSpacing.s16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 1, + color: Theme.of(context).colorScheme.outline.withAlpha(125), + ), + ), + ), + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.maybeViewInsetsOf(context)?.bottom ?? 0, + ), + child: SafeArea( + child: Row( + children: [ + IconButton( + onPressed: onPickImagePressed, + icon: const Icon(Icons.image), + ), + const SizedBox.square(dimension: AppSpacing.s8), + Expanded( + child: TextField( + onTapOutside: (PointerDownEvent event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + controller: textController, + minLines: 2, + maxLines: 2, + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: const BorderSide(width: 0), + borderRadius: BorderRadius.all( + Radius.circular(AppSpacing.s8), + ), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + ), + ), + ), + const SizedBox.square(dimension: AppSpacing.s16), + IconButton.filled( + onPressed: !loading + ? () => sendMessage(textController.text) + : null, + icon: const Icon(Icons.arrow_upward_rounded), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_list_view.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_list_view.dart new file mode 100644 index 0000000..b78d8a0 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_list_view.dart @@ -0,0 +1,40 @@ +// 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 './message_bubble.dart'; +import './message_widget.dart'; + +class MessageListView extends StatelessWidget { + final List messages; + final ScrollController scrollController; + + const MessageListView({ + super.key, + required this.messages, + required this.scrollController, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: scrollController, + itemCount: messages.length, + itemBuilder: (context, idx) { + final message = messages[idx]; + return MessageBubble(message: message); + }, + ); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_widget.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_widget.dart new file mode 100644 index 0000000..92fdf86 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/message_widget.dart @@ -0,0 +1,78 @@ +// 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:flutter_markdown/flutter_markdown.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../../../shared/ui/app_spacing.dart'; + +class FunctionCallResponse { + final GenerateContentResponse? response; + final Image? image; + + FunctionCallResponse(this.response, this.image); +} + +class MessageData { + MessageData({this.image, this.text, this.fromUser}); + final Image? image; + final String? text; + final bool? fromUser; +} + +class MessageWidget extends StatelessWidget { + final Image? image; + final String? text; + final bool isFromUser; + + const MessageWidget({ + super.key, + this.image, + this.text, + required this.isFromUser, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: isFromUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + color: isFromUser + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppSpacing.s16), + ), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.s16, + horizontal: AppSpacing.s24, + ), + margin: const EdgeInsets.only(bottom: AppSpacing.s8), + child: Column( + children: [ + if (text case final text?) MarkdownBody(data: text), + if (image case final image?) image, + ], + ), + ), + ), + ], + ); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/model_picker.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/model_picker.dart new file mode 100644 index 0000000..14e3350 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/model_picker.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import '../../../shared/ui/app_spacing.dart'; +import '../../../shared/ui/blaze_warning.dart'; +import '../models/gemini_model_nano.dart'; + +class ModelPicker extends StatefulWidget { + const ModelPicker({ + required this.selectedModel, + required this.onSelected, + super.key, + }); + + final GeminiModel selectedModel; + final Function(String value) onSelected; + + @override + State createState() => _ModelPickerState(); +} + +class _ModelPickerState extends State { + late String _selectedModelName; + late String _selectedModelDescription; + + @override + void initState() { + super.initState(); + _selectedModelName = widget.selectedModel.name; + _selectedModelDescription = widget.selectedModel.description; + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Theme.of(context).colorScheme.surface, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.s16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownMenu( + label: const Text('Select a Gemini Model'), + initialSelection: _selectedModelName, + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + fillColor: Theme.of(context).colorScheme.primaryContainer, + ), + dropdownMenuEntries: geminiModels.models.entries + .map( + (entry) => + DropdownMenuEntry(value: entry.key, label: entry.key), + ) + .toList(), + onSelected: (value) { + if (value != null) { + setState(() { + _selectedModelName = value; + _selectedModelDescription = + geminiModels[_selectedModelName].description; + }); + widget.onSelected(value); + } + }, + ), + const SizedBox.square(dimension: AppSpacing.s8), + Padding( + padding: const EdgeInsets.all(AppSpacing.s8), + child: Text( + textAlign: TextAlign.center, + _selectedModelDescription, + ), + ), + if (_selectedModelName.contains('preview')) BlazeWarning(), + ], + ), + ), + ), + ); + } +} diff --git a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/ui_components.dart b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/ui_components.dart similarity index 81% rename from firebase_ai_logic_showcase/lib/demos/imagen/ui_components/ui_components.dart rename to firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/ui_components.dart index 759ffad..8572431 100644 --- a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/ui_components.dart +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/ui_components/ui_components.dart @@ -12,5 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -export 'image_display.dart'; -export 'prompt_input.dart'; +export 'attachment_preview.dart'; +export 'message_input_bar.dart'; +export 'message_list_view.dart'; +export 'message_widget.dart'; diff --git a/firebase_ai_logic_showcase/lib/demos/imagen/imagen_demo.dart b/firebase_ai_logic_showcase/lib/demos/imagen/imagen_demo.dart deleted file mode 100644 index 5d32ba9..0000000 --- a/firebase_ai_logic_showcase/lib/demos/imagen/imagen_demo.dart +++ /dev/null @@ -1,95 +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 'dart:typed_data'; -import '../../shared/ui/app_frame.dart'; -import '../../shared/ui/app_spacing.dart'; -import '../../shared/firebaseai_imagen_service.dart'; -import './ui_components/ui_components.dart'; - -class ImageGenerationDemo extends StatefulWidget { - const ImageGenerationDemo({super.key}); - - @override - State createState() => _ImageGenerationDemoState(); -} - -class _ImageGenerationDemoState extends State { - // Service for interacting with the Gemini API. - final _imagenService = ImageGenerationService(); - - // UI State - bool _loading = false; - List images = []; - TextEditingController promptController = TextEditingController( - text: - 'Hot air balloons rising over the San Francisco Bay at golden hour ' - 'with a view of the Golden Gate Bridge. Make it anime style.', - ); - - void generateImages(BuildContext context, String prompt) async { - setState(() { - _loading = true; - images = []; // Clear previous images while loading - }); - - try { - final image = await _imagenService.generateImage(prompt); - setState(() { - images = [image]; - }); - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toString()), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } finally { - setState(() { - _loading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Image Generation Demo')), - body: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - child: AppFrame( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.s8), - child: Column( - children: [ - ImageDisplay(loading: _loading, images: images), - const SizedBox.square(dimension: AppSpacing.s8), - PromptInput( - promptController: promptController, - loading: _loading, - generateImages: generateImages, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/image_display.dart b/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/image_display.dart deleted file mode 100644 index 01224ab..0000000 --- a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/image_display.dart +++ /dev/null @@ -1,67 +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:typed_data'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import '../../../shared/ui/app_spacing.dart'; -import '../../../shared/ui/blaze_warning.dart'; - -class ImageDisplay extends StatelessWidget { - final bool loading; - final List images; - - const ImageDisplay({super.key, required this.loading, required this.images}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(AppSpacing.s4), - child: LayoutBuilder( - builder: (context, constraints) { - return ConstrainedBox( - constraints: BoxConstraints.loose( - Size(double.infinity, constraints.maxWidth), - ), - child: Center( - child: loading - ? CircularProgressIndicator() - : images.isEmpty - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - 'Write a prompt below to generate images.', - ), - SizedBox.square(dimension: AppSpacing.s8), - BlazeWarning(), - ], - ) - : CarouselView.weighted( - enableSplash: false, - itemSnapping: true, - flexWeights: [1, 6, 1], - children: images - .map((image) => Image.memory(image)) - .toList(), - ), - ), - ); - }, - ), - ); - } -} diff --git a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/prompt_input.dart b/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/prompt_input.dart deleted file mode 100644 index b203ba2..0000000 --- a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/prompt_input.dart +++ /dev/null @@ -1,92 +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 '../../../shared/ui/app_spacing.dart'; - -class PromptInput extends StatelessWidget { - final TextEditingController promptController; - final bool loading; - final void Function(BuildContext, String) generateImages; - - const PromptInput({ - super.key, - required this.promptController, - required this.loading, - required this.generateImages, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: AppSpacing.s8), - child: TextField( - decoration: InputDecoration( - label: const Text('Prompt'), - fillColor: Theme.of(context).colorScheme.onSecondaryFixed, - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppSpacing.s16), - ), - ), - maxLines: 4, - controller: promptController, - enabled: !loading, - onTap: () { - promptController.selection = TextSelection( - baseOffset: 0, - extentOffset: promptController.text.length, - ); - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.s8), - child: ElevatedButton( - style: ButtonStyle( - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppSpacing.s16), - ), - ), - backgroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.primaryContainer, - ), - ), - onPressed: loading - ? null - : () => generateImages(context, promptController.text), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.s24, - horizontal: 0, - ), - child: Column( - children: [ - const Icon(size: 32, Icons.brush), - const SizedBox.square(dimension: AppSpacing.s8), - const Text(textAlign: TextAlign.center, 'Create\nImage'), - ], - ), - ), - ), - ), - ], - ); - } -} diff --git a/firebase_ai_logic_showcase/lib/demos/live_api/live_api_demo.dart b/firebase_ai_logic_showcase/lib/demos/live_api/live_api_demo.dart index 3fc5fac..a1785cd 100644 --- a/firebase_ai_logic_showcase/lib/demos/live_api/live_api_demo.dart +++ b/firebase_ai_logic_showcase/lib/demos/live_api/live_api_demo.dart @@ -63,11 +63,6 @@ class _LiveAPIDemoState extends ConsumerState { onImageGenerated: _onImageGenerated, onError: _showErrorSnackBar, ); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeAudio(); - _initializeVideo(); - }); } @override @@ -115,6 +110,14 @@ class _LiveAPIDemoState extends ConsumerState { } Future startCall() async { + // Initialize audio and video streams if they haven't been already. + if (!_audioIsInitialized) { + await _initializeAudio(); + } + if (!_videoIsInitialized) { + await _initializeVideo(); + } + // Initialize the camera controller here to ensure it's fresh for each call. // This prevents a bug where the camera preview freezes on subsequent calls. if (_videoIsInitialized) { @@ -245,39 +248,49 @@ class _LiveAPIDemoState extends ConsumerState { final audioInput = _audioInput; final videoInput = _videoInput; - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - appBar: const LiveApiDemoAppBar(), - body: LiveApiBody( - cameraIsActive: _cameraIsActive, - cameraController: videoInput.controllerInitialized - ? videoInput.cameraController - : null, - settingUpLiveSession: _isConnecting, - loadingImage: _loadingImage, - ), - bottomNavigationBar: BottomBar( - children: [ - FlipCameraButton( - onPressed: _cameraIsActive && videoInput.cameras.length > 1 - ? videoInput.flipCamera - : null, + return ListenableBuilder( + listenable: audioInput, + builder: (context, child) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Column( + children: [ + Expanded( + child: LiveApiBody( + cameraIsActive: _cameraIsActive, + cameraController: videoInput.controllerInitialized + ? videoInput.cameraController + : null, + settingUpLiveSession: _isConnecting, + loadingImage: _loadingImage, + ), + ), + BottomBar( + children: [ + FlipCameraButton( + onPressed: _cameraIsActive && videoInput.cameras.length > 1 + ? videoInput.flipCamera + : null, + ), + VideoButton( + isActive: _cameraIsActive, + onPressed: toggleVideoStream, + ), + AudioVisualizer( + audioStreamIsActive: _isCallActive, + amplitudeStream: audioInput.amplitudeStream, + ), + MuteButton( + isMuted: audioInput.isPaused, + onPressed: _isCallActive ? toggleMuteInput : null, + ), + CallButton(isActive: _isCallActive, onPressed: toggleCall), + ], + ), + ], ), - VideoButton(isActive: _cameraIsActive, onPressed: toggleVideoStream), - AudioVisualizer( - audioStreamIsActive: _isCallActive, - amplitudeStream: audioInput.amplitudeStream, - ), - MuteButton( - isMuted: audioInput.isPaused, - onPressed: _isCallActive ? toggleMuteInput : null, - ), - CallButton( - isActive: _isCallActive, - onPressed: _audioIsInitialized ? toggleCall : null, - ), - ], - ), + ); + }, ); } } diff --git a/firebase_ai_logic_showcase/lib/demos/live_api/utilities/audio_input.dart b/firebase_ai_logic_showcase/lib/demos/live_api/utilities/audio_input.dart index bbdc482..1747c5e 100644 --- a/firebase_ai_logic_showcase/lib/demos/live_api/utilities/audio_input.dart +++ b/firebase_ai_logic_showcase/lib/demos/live_api/utilities/audio_input.dart @@ -23,7 +23,7 @@ class AudioInput extends ChangeNotifier { AudioRecorder? _recorder; RecordConfig recordConfig = RecordConfig( encoder: AudioEncoder.pcm16bits, - sampleRate: 24000, + sampleRate: 16000, numChannels: 1, echoCancel: true, noiseSuppress: true, diff --git a/firebase_ai_logic_showcase/lib/demos/multimodal/multimodal_demo.dart b/firebase_ai_logic_showcase/lib/demos/multimodal/multimodal_demo.dart index d1d95a9..d1f0dfd 100644 --- a/firebase_ai_logic_showcase/lib/demos/multimodal/multimodal_demo.dart +++ b/firebase_ai_logic_showcase/lib/demos/multimodal/multimodal_demo.dart @@ -83,7 +83,6 @@ class _MultimodalDemoState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('Multimodal Demo')), body: SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.only( diff --git a/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart b/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart index 0a7025f..fc692d2 100644 --- a/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart +++ b/firebase_ai_logic_showcase/lib/flutter_firebase_ai_demo.dart @@ -13,203 +13,134 @@ // limitations under the License. import 'package:flutter/material.dart'; -import 'package:url_launcher/link.dart'; -import 'shared/ui/app_frame.dart'; import 'demos/chat/chat_demo.dart'; +import 'demos/chat_nano/chat_nano_demo.dart'; import 'demos/multimodal/multimodal_demo.dart'; -import './demos/imagen/imagen_demo.dart'; -import './demos/live_api/live_api_demo.dart'; -import 'firebase_options.dart'; -import 'shared/ui/blaze_warning.dart'; +import 'demos/live_api/live_api_demo.dart'; -class Demo { - final String name; - final String description; - final Widget icon; - final Widget page; +class DemoHomeScreen extends StatefulWidget { + const DemoHomeScreen({super.key}); - Demo({ - required this.name, - required this.description, - required this.icon, - required this.page, - }); + @override + State createState() => _DemoHomeScreenState(); } -List demos = [ - Demo( - name: 'Gemini Live API', - description: 'Real-time bidirectional audio & video streaming with Gemini.', - icon: Icon(size: 32, Icons.video_call), - page: LiveAPIDemo(), - ), - Demo( - name: 'Multimodal Prompt', - description: - 'Ask a Gemini model about an image, audio, video, or PDF file.', - icon: Icon(size: 32, Icons.attach_file), - page: MultimodalDemo(), - ), - Demo( - name: 'Create & Edit Images with Nano Banana *', - description: - 'Chat with a Gemini model, including a chat history, tool calling, and even image generation.', - icon: Text(style: TextStyle(fontSize: 28), '🍌'), - page: ChatDemo(), - ), -]; +class _DemoHomeScreenState extends State { + int _selectedIndex = 0; + final GlobalKey _chatNanoKey = GlobalKey(); + bool _nanoPickerHasBeenShown = false; -class DemoHomeScreen extends StatelessWidget { - const DemoHomeScreen({super.key}); + late final List _demoPages; - void showMoreInfo(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (context) => SizedBox( - width: double.infinity, - child: Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - title: Text('Questions or Feedback?'), - actions: [ - IconButton( - icon: Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 400), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: Theme.of(context).textTheme.bodyMedium, - text: - 'Have features you want to see in the app? Please file an issue for us at: ', - children: [ - WidgetSpan( - baseline: TextBaseline.ideographic, - alignment: PlaceholderAlignment.top, - child: Link( - uri: Uri.parse( - 'https://github.com/flutter/demos/issues', - ), - target: LinkTarget.blank, - builder: (context, followLink) => GestureDetector( - onTap: followLink, - child: Text( - style: Theme.of(context).textTheme.bodyMedium! - .copyWith( - fontWeight: FontWeight.bold, - height: 1.15, - decoration: TextDecoration.underline, - color: Theme.of( - context, - ).colorScheme.primary, - ), - 'github.com/flutter/demos/issues', - ), - ), - ), - ), - TextSpan(text: '.'), - ], - ), - ), - SizedBox.square(dimension: 32), - Text( - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - 'This app was made with ❤️\nby the Flutter & Firebase AI Logic Teams', - ), - ], - ), - ), - ), - ), - ), - ); + @override + void initState() { + super.initState(); + _demoPages = [ + const ChatDemo(), + const LiveAPIDemo(), + const MultimodalDemo(), + ChatDemoNano(key: _chatNanoKey), + ]; + } + + void _onItemTapped(int index) { + if (index == 3 && !_nanoPickerHasBeenShown) { + _chatNanoKey.currentState?.showModelPicker(); + _nanoPickerHasBeenShown = true; + } + setState(() { + _selectedIndex = index; + }); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - leading: Padding( - padding: EdgeInsets.fromLTRB(16, 8, 4, 8), - child: Image.asset('assets/firebase-ai-logic.png'), - ), - title: Text( - style: Theme.of(context).textTheme.titleLarge, - 'Flutter AI Playground', - ), - actions: [ - Padding( - padding: EdgeInsets.fromLTRB(4, 8, 16, 8), - child: IconButton( - icon: Icon(Icons.info_outline), - onPressed: () => showMoreInfo(context), - ), - ), - ], - ), - body: AppFrame( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), - child: Text( - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - "Build AI features in your Flutter apps – use the Firebase AI Logic SDK to access Google's AI models directly from your app.", - ), + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 600) { + // Use BottomNavigationBar for smaller screens + return Scaffold( + body: IndexedStack(index: _selectedIndex, children: _demoPages), + bottomNavigationBar: BottomNavigationBar( + items: [ + const BottomNavigationBarItem( + icon: Icon(Icons.chat), + label: 'Chat', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.video_chat), + label: 'Live API', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.photo_library), + label: 'Multimodal', + ), + BottomNavigationBarItem( + icon: RichText( + text: const TextSpan( + style: TextStyle(fontSize: 24.0), + text: '🍌', + ), + ), + label: 'Nano Banana', + ), + ], + currentIndex: _selectedIndex, + onTap: _onItemTapped, ), - Expanded( - child: ListView.builder( - padding: EdgeInsets.all(8), - itemBuilder: (context, index) { - final demo = demos[index]; - - return Padding( - padding: EdgeInsets.all(8), - child: ListTile( - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => demo.page), - ), - shape: RoundedSuperellipseBorder( - borderRadius: BorderRadiusGeometry.circular(16), - ), - leading: demo.icon, - title: Text( - demo.name, - style: TextStyle(fontWeight: FontWeight.bold), + ); + } else { + // Use NavigationRail for larger screens + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: _onItemTapped, + labelType: NavigationRailLabelType.all, + destinations: [ + const NavigationRailDestination( + padding: EdgeInsets.symmetric(vertical: 8.0), + icon: Icon(Icons.chat), + label: Text('Chat'), + ), + const NavigationRailDestination( + padding: EdgeInsets.symmetric(vertical: 8.0), + icon: Icon(Icons.video_chat), + label: Text('Live API'), + ), + const NavigationRailDestination( + padding: EdgeInsets.symmetric(vertical: 8.0), + icon: Icon(Icons.photo_library), + label: Text('Multimodal', textAlign: TextAlign.center), + ), + NavigationRailDestination( + padding: const EdgeInsets.symmetric(vertical: 8.0), + icon: RichText( + text: const TextSpan( + style: TextStyle(fontSize: 24.0), + text: '🍌', + ), ), - subtitle: Text(demo.description), - tileColor: Theme.of(context).colorScheme.primaryContainer, - trailing: Icon( - Icons.arrow_forward, - color: Theme.of(context).colorScheme.primaryFixedDim, + label: const Text( + 'Nano\nBanana', + textAlign: TextAlign.center, ), ), - ); - }, - itemCount: demos.length, - ), + ], + ), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: _demoPages, + ), + ), + ], ), - BlazeFooter(), - ], - ), - ), + ); + } + }, ); } } diff --git a/firebase_ai_logic_showcase/lib/main.dart b/firebase_ai_logic_showcase/lib/main.dart index 6cb44a6..cb2a0d0 100644 --- a/firebase_ai_logic_showcase/lib/main.dart +++ b/firebase_ai_logic_showcase/lib/main.dart @@ -41,6 +41,11 @@ class MyApp extends ConsumerWidget { brightness: Brightness.dark, dynamicSchemeVariant: DynamicSchemeVariant.tonalSpot, ).copyWith(surface: appColor), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: Colors.grey.shade900, + selectedItemColor: Colors.white, + unselectedItemColor: Colors.grey.shade400, + ), ), debugShowCheckedModeBanner: false, ); diff --git a/firebase_ai_logic_showcase/lib/shared/ui/blaze_warning.dart b/firebase_ai_logic_showcase/lib/shared/ui/blaze_warning.dart index 229020d..3b23bfb 100644 --- a/firebase_ai_logic_showcase/lib/shared/ui/blaze_warning.dart +++ b/firebase_ai_logic_showcase/lib/shared/ui/blaze_warning.dart @@ -39,6 +39,33 @@ class BlazeWarning extends StatelessWidget { ), ), TextSpan(text: '.'), + TextSpan(text: '\n\n'), + TextSpan( + text: + 'Eligible developers can claim ', + ), + WidgetSpan( + baseline: TextBaseline.ideographic, + alignment: PlaceholderAlignment.top, + child: Link( + uri: Uri.parse( + 'https://firebase.blog/posts/2024/11/claim-300-to-get-started', + ), + target: LinkTarget.blank, + builder: (context, followLink) => GestureDetector( + onTap: followLink, + child: Text( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + height: 1.15, + decoration: TextDecoration.underline, + color: Theme.of(context).colorScheme.primary, + ), + '\$300 of credits', + ), + ), + ), + ), + TextSpan(text: ' to get started.'), ], ), ),