diff --git a/firebase_ai_logic_showcase/.gitignore b/firebase_ai_logic_showcase/.gitignore index df0e787..3948605 100644 --- a/firebase_ai_logic_showcase/.gitignore +++ b/firebase_ai_logic_showcase/.gitignore @@ -51,4 +51,5 @@ ephemeral/ #firebase google-services.json GoogleService-Info.plist +firebase.json .firebaserc 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..63aaf84 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart +++ b/firebase_ai_logic_showcase/lib/demos/chat/chat_demo.dart @@ -22,10 +22,9 @@ 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/models.dart'; +import '../../shared/ui/chat_components/ui_components.dart'; +import '../../shared/chat_service.dart'; +import '../../shared/models/models.dart'; class ChatDemo extends ConsumerStatefulWidget { const ChatDemo({super.key}); @@ -45,18 +44,15 @@ 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; - - WidgetsBinding.instance.addPostFrameCallback((_) { - opController.show(); - }); + _userTextInputController.text = + 'Hey Gemini! Can you set the app color to purple?'; } @override @@ -162,79 +158,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/models/chat_response.dart b/firebase_ai_logic_showcase/lib/demos/chat/models/chat_response.dart deleted file mode 100644 index ebdb9de..0000000 --- a/firebase_ai_logic_showcase/lib/demos/chat/models/chat_response.dart +++ /dev/null @@ -1,9 +0,0 @@ -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/models/models.dart b/firebase_ai_logic_showcase/lib/demos/chat/models/models.dart deleted file mode 100644 index bebb95e..0000000 --- a/firebase_ai_logic_showcase/lib/demos/chat/models/models.dart +++ /dev/null @@ -1,2 +0,0 @@ -export './chat_response.dart'; -export './gemini_model.dart'; 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..1ebabb3 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/demos/chat_nano/chat_nano_demo.dart @@ -0,0 +1,241 @@ +// 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 '../../shared/ui/chat_components/ui_components.dart'; +import '../../shared/chat_service.dart'; +import '../../shared/ui/chat_components/model_picker.dart'; +import '../../shared/models/models.dart'; + +class ChatDemoNano extends ConsumerStatefulWidget { + const ChatDemoNano({super.key, this.isSelected = false}); + final bool isSelected; + + @override + ConsumerState createState() => ChatDemoNanoState(); +} + +class ChatDemoNanoState extends ConsumerState { + // Service for interacting with the Gemini API. + late final ChatService _chatService; + + // UI State + final List _messages = []; + final TextEditingController _userTextInputController = + TextEditingController(); + Uint8List? _attachment; + final ScrollController _scrollController = ScrollController(); + bool _loading = false; + OverlayPortalController opController = OverlayPortalController(); + static bool _pickerHasBeenShown = false; + + @override + void initState() { + super.initState(); + _chatService = ChatService(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.'; + _checkAndShowPicker(); + } + + @override + void didUpdateWidget(ChatDemoNano oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isSelected != oldWidget.isSelected) { + _checkAndShowPicker(); + } + } + + void _checkAndShowPicker() { + if (widget.isSelected && !_pickerHasBeenShown) { + _pickerHasBeenShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + showModelPicker(); + } + }); + } + } + + @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/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..2d80d41 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 @@ -23,7 +23,8 @@ import 'utilities/utilities.dart'; import 'firebaseai_live_api_service.dart'; class LiveAPIDemo extends ConsumerStatefulWidget { - const LiveAPIDemo({super.key}); + const LiveAPIDemo({super.key, this.isSelected = false}); + final bool isSelected; @override ConsumerState createState() => _LiveAPIDemoState(); @@ -36,7 +37,7 @@ class LiveAPIDemo extends ConsumerStatefulWidget { /// with the [LiveApiService] and I/O utilities. class _LiveAPIDemoState extends ConsumerState { // Service for interacting with the Gemini API via Firebase AI. - late final LiveApiService _liveApiService; + late LiveApiService _liveApiService; // Utilities for handling device I/O. late final AudioInput _audioInput = AudioInput(); @@ -46,6 +47,7 @@ class _LiveAPIDemoState extends ConsumerState { // Initialization flags. bool _audioIsInitialized = false; bool _videoIsInitialized = false; + static bool _hasBeenSelected = false; // UI State flags. bool _isConnecting = false; // True when setting up the Gemini session. @@ -56,20 +58,34 @@ class _LiveAPIDemoState extends ConsumerState { @override void initState() { super.initState(); - _liveApiService = LiveApiService( - audioOutput: _audioOutput, - ref: ref, // Pass the ref to the service - onImageLoadingChange: _onImageLoadingChange, - onImageGenerated: _onImageGenerated, - onError: _showErrorSnackBar, - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeAudio(); - _initializeVideo(); + _checkAndInitializeIO(); }); } + @override + void didUpdateWidget(LiveAPIDemo oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isSelected != oldWidget.isSelected) { + _checkAndInitializeIO(); + } + } + + Future _checkAndInitializeIO() async { + if (widget.isSelected && !_hasBeenSelected) { + _hasBeenSelected = true; + await _initializeAudio(); + await _initializeVideo(); + _liveApiService = LiveApiService( + audioOutput: _audioOutput, + ref: ref, // Pass the ref to the service + onImageLoadingChange: _onImageLoadingChange, + onImageGenerated: _onImageGenerated, + onError: _showErrorSnackBar, + ); + } + } + @override void dispose() { _audioInput.dispose(); @@ -245,39 +261,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, - ), - VideoButton(isActive: _cameraIsActive, onPressed: toggleVideoStream), - AudioVisualizer( - audioStreamIsActive: _isCallActive, - amplitudeStream: audioInput.amplitudeStream, + 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), + ], + ), + ], ), - 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..29c4bc6 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,106 @@ // 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 DemoHomeScreen extends StatelessWidget { - const DemoHomeScreen({super.key}); +class _DemoHomeScreenState extends State { + int _selectedIndex = 0; - 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', - ), - ], - ), - ), - ), - ), - ), - ); + void _onItemTapped(int index) { + 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), - ), + final List demoPages = [ + const ChatDemo(), + LiveAPIDemo(isSelected: _selectedIndex == 1), + const MultimodalDemo(), + ChatDemoNano(isSelected: _selectedIndex == 3), + ]; + + final List<({Widget icon, String label, Widget? selectedIcon})> destinations = [ + (icon: const Icon(Icons.chat), label: 'Chat', selectedIcon: null), + (icon: const Icon(Icons.video_chat), label: 'Live API', selectedIcon: null), + (icon: const Icon(Icons.photo_library), label: 'Multimodal', selectedIcon: null), + ( + icon: RichText( + text: const TextSpan( + style: TextStyle(fontSize: 24.0), + text: '🍌', ), - ], + ), + label: 'Nano Banana', + selectedIcon: null ), - 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.", - ), - ), - 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), - ), - subtitle: Text(demo.description), - tileColor: Theme.of(context).colorScheme.primaryContainer, - trailing: Icon( - Icons.arrow_forward, - color: Theme.of(context).colorScheme.primaryFixedDim, - ), + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 600) { + // Use BottomNavigationBar for smaller screens + return Scaffold( + body: IndexedStack(index: _selectedIndex, children: demoPages), + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: destinations + .map( + (e) => BottomNavigationBarItem( + icon: e.icon, + label: e.label, + activeIcon: e.selectedIcon, ), - ); - }, - itemCount: demos.length, - ), + ) + .toList(), + currentIndex: _selectedIndex, + onTap: _onItemTapped, ), - BlazeFooter(), - ], - ), - ), + ); + } else { + // Use NavigationRail for larger screens + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: _onItemTapped, + labelType: NavigationRailLabelType.all, + destinations: destinations + .map( + (e) => NavigationRailDestination( + padding: const EdgeInsets.symmetric(vertical: 8.0), + icon: e.icon, + label: Text(e.label.replaceAll(' ', '\n'), + textAlign: TextAlign.center), + selectedIcon: e.selectedIcon, + ), + ) + .toList(), + ), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: demoPages, + ), + ), + ], + ), + ); + } + }, ); } } 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/demos/chat/firebaseai_chat_service.dart b/firebase_ai_logic_showcase/lib/shared/chat_service.dart similarity index 93% rename from firebase_ai_logic_showcase/lib/demos/chat/firebaseai_chat_service.dart rename to firebase_ai_logic_showcase/lib/shared/chat_service.dart index 75b5b0a..eaf5ba1 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/firebaseai_chat_service.dart +++ b/firebase_ai_logic_showcase/lib/shared/chat_service.dart @@ -16,8 +16,8 @@ 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 '../shared/app_state.dart'; +import '../shared/firebaseai_imagen_service.dart'; import './models/models.dart'; /// A service that handles all communication with the Firebase AI Gemini API @@ -32,16 +32,15 @@ import './models/models.dart'; /// https://firebase.google.com/docs/ai-logic/chat?api=dev class ChatService { final WidgetRef _ref; - ChatService(this._ref); + 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(); - } + var gemini = _gemini ?? geminiModels.selectedModel; + _chat = gemini.model.startChat(); } void changeModel(String modelName) { diff --git a/firebase_ai_logic_showcase/lib/shared/models/chat_response.dart b/firebase_ai_logic_showcase/lib/shared/models/chat_response.dart new file mode 100644 index 0000000..e46bd91 --- /dev/null +++ b/firebase_ai_logic_showcase/lib/shared/models/chat_response.dart @@ -0,0 +1,23 @@ +// 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'; + +/// 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/models/gemini_model.dart b/firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart similarity index 80% rename from firebase_ai_logic_showcase/lib/demos/chat/models/gemini_model.dart rename to firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart index 8b892af..33c7507 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/models/gemini_model.dart +++ b/firebase_ai_logic_showcase/lib/shared/models/gemini_model.dart @@ -1,3 +1,17 @@ +// 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:firebase_ai/firebase_ai.dart'; import '../../../shared/function_calling/tools.dart'; diff --git a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/ui_components.dart b/firebase_ai_logic_showcase/lib/shared/models/models.dart similarity index 90% rename from firebase_ai_logic_showcase/lib/demos/imagen/ui_components/ui_components.dart rename to firebase_ai_logic_showcase/lib/shared/models/models.dart index 759ffad..4bc9e2a 100644 --- a/firebase_ai_logic_showcase/lib/demos/imagen/ui_components/ui_components.dart +++ b/firebase_ai_logic_showcase/lib/shared/models/models.dart @@ -12,5 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -export 'image_display.dart'; -export 'prompt_input.dart'; +export './chat_response.dart'; +export './gemini_model.dart'; 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.'), ], ), ), diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/attachment_preview.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/attachment_preview.dart similarity index 100% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/attachment_preview.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/attachment_preview.dart diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_bubble.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_bubble.dart similarity index 100% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_bubble.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_bubble.dart diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_input_bar.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_input_bar.dart similarity index 100% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_input_bar.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_input_bar.dart diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_list_view.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_list_view.dart similarity index 100% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_list_view.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_list_view.dart diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_widget.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_widget.dart similarity index 100% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/message_widget.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/message_widget.dart diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/model_picker.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/model_picker.dart similarity index 98% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/model_picker.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/model_picker.dart index ab7218e..1157537 100644 --- a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/model_picker.dart +++ b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/model_picker.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../shared/ui/app_spacing.dart'; import '../../../shared/ui/blaze_warning.dart'; -import '../models/gemini_model.dart'; +import '../../../shared/models/gemini_model.dart'; class ModelPicker extends StatefulWidget { const ModelPicker({ diff --git a/firebase_ai_logic_showcase/lib/demos/chat/ui_components/ui_components.dart b/firebase_ai_logic_showcase/lib/shared/ui/chat_components/ui_components.dart similarity index 100% rename from firebase_ai_logic_showcase/lib/demos/chat/ui_components/ui_components.dart rename to firebase_ai_logic_showcase/lib/shared/ui/chat_components/ui_components.dart