diff --git a/lib/helpers/services/gemini_service.dart b/lib/helpers/services/gemini_service.dart new file mode 100644 index 0000000..dcaf907 --- /dev/null +++ b/lib/helpers/services/gemini_service.dart @@ -0,0 +1,36 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +class GeminiService { + static late GenerativeModel _model; + + /// Initializes Gemini Developer API + static Future initialize() async { + _model = FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash', + ); + } + + /// Generate text from a text prompt + static Future generateText(String prompt) async { + try { + final content = [Content.text(prompt)]; + + final response = await _model.generateContent(content); + + return response.text ?? "No response text found."; + } catch (e) { + return "Error: $e"; + } + } + + /// Stream text response (optional) + static Stream streamText(String prompt) async* { + final content = [Content.text(prompt)]; + + final stream = _model.generateContentStream(content); + + await for (final chunk in stream) { + if (chunk.text != null) yield chunk.text!; + } + } +} diff --git a/lib/view/dashboard/gemini_chat_card.dart b/lib/view/dashboard/gemini_chat_card.dart new file mode 100644 index 0000000..0565740 --- /dev/null +++ b/lib/view/dashboard/gemini_chat_card.dart @@ -0,0 +1,548 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_lucide/flutter_lucide.dart'; +import 'package:get/get.dart'; +import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; +import 'package:on_field_work/helpers/services/gemini_service.dart'; +import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; +import 'package:on_field_work/helpers/widgets/my_spacing.dart'; +import 'package:on_field_work/helpers/widgets/my_text.dart'; + +/// Floating Gemini assistant for mobile dashboard. +/// Shows a circular FAB, which expands into a small chat panel from bottom-right. +class GeminiFloatingAssistant extends StatefulWidget { + final DashboardController dashboardController; + + const GeminiFloatingAssistant({ + super.key, + required this.dashboardController, + }); + + @override + State createState() => + _GeminiFloatingAssistantState(); +} + +class _GeminiFloatingAssistantState extends State + with SingleTickerProviderStateMixin, UIMixin { + // State + final TextEditingController _promptController = TextEditingController(); + final RxList> _chatHistory = + >[].obs; + final RxBool _isLoading = false.obs; + final RxBool _isAnalyzingData = false.obs; + final RxString _error = ''.obs; + final RxBool _isOpen = false.obs; + + final ScrollController _scrollController = ScrollController(); + + late final AnimationController _animController; + late final Animation _scaleAnimation; + late final Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + _scaleAnimation = CurvedAnimation( + parent: _animController, + curve: Curves.fastOutSlowIn, + ); + _opacityAnimation = CurvedAnimation( + parent: _animController, + curve: Curves.easeInOut, + ); + } + + // Scroll helper + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + // Toggle FAB / panel + void _toggleOpen() { + _error.value = ''; + final newValue = !_isOpen.value; + _isOpen.value = newValue; + if (newValue) { + _animController.forward(); + } else { + _animController.reverse(); + } + } + + // Chat bubble + Widget _buildResponseBubble(String text, bool isAI) { + if (text.isEmpty) return const SizedBox.shrink(); + + final Color backgroundColor = + isAI ? Colors.blue.shade50 : contentTheme.primary.withOpacity(0.9); + final Color textColor = isAI ? Colors.black87 : Colors.white; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + isAI ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + if (isAI) ...[ + const Icon(LucideIcons.bot, color: Colors.blue, size: 20), + MySpacing.width(8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(14), + topRight: const Radius.circular(14), + bottomLeft: + isAI ? Radius.zero : const Radius.circular(14), + bottomRight: + isAI ? const Radius.circular(14) : Radius.zero, + ), + ), + child: MyText.bodySmall( + text, + color: textColor, + height: 1.4, + ), + ), + ), + if (!isAI) ...[ + MySpacing.width(8), + const Icon(LucideIcons.user, color: Colors.black54, size: 18), + ], + ], + ), + ); + } + + // Prompt submit + Future _handlePromptSubmission() async { + final promptText = _promptController.text.trim(); + if (promptText.isEmpty || _isLoading.value || _isAnalyzingData.value) { + return; + } + + _chatHistory.add({'sender': 'user', 'message': promptText}); + _promptController.clear(); + _scrollToBottom(); + + _isLoading.value = true; + _error.value = ''; + + // Placeholder + _chatHistory.add({'sender': 'ai', 'message': 'Thinking...'}); + _scrollToBottom(); + final placeholderIndex = _chatHistory.length - 1; + + try { + final result = await GeminiService.generateText(promptText); + if (placeholderIndex >= 0 && + _chatHistory.length > placeholderIndex) { + _chatHistory[placeholderIndex] = { + 'sender': 'ai', + 'message': result, + }; + } + _scrollToBottom(); + } catch (e, stack) { + // ignore: avoid_print + print("Gemini Error: $e\n$stack"); + _error.value = + "An error occurred. Failed to get response: ${e.toString().split(':').first}"; + if (placeholderIndex >= 0 && + _chatHistory.length > placeholderIndex) { + _chatHistory.removeAt(placeholderIndex); + } + } finally { + _isLoading.value = false; + } + } + + // Expense analysis + Future _handleDataAnalysis() async { + if (_isLoading.value || _isAnalyzingData.value) return; + + _isAnalyzingData.value = true; + _error.value = ''; + + const String analysisPrompt = 'Analyze my monthly expense overview.'; + _chatHistory.add({'sender': 'user', 'message': analysisPrompt}); + _chatHistory + .add({'sender': 'ai', 'message': 'Analyzing monthly expenses...'}); + _scrollToBottom(); + final placeholderIndex = _chatHistory.length - 1; + + try { + await widget.dashboardController.fetchMonthlyExpenses(); + final rawDataList = widget.dashboardController.monthlyExpenseList; + + if (rawDataList.isEmpty) { + _error.value = "No monthly expense data found for analysis."; + _chatHistory.removeAt(placeholderIndex); + return; + } + + final rawDataJson = + jsonEncode(rawDataList.map((e) => e.toJson()).toList()); + + final prompt = + "Analyze the following monthly expense data and provide a concise " + "summary, key trends (like increasing/decreasing expenses), and " + "highlight the highest expense months. The data is a list of " + "monthly expense summaries over time in JSON format:\n\n$rawDataJson"; + + final result = await GeminiService.generateText(prompt); + + if (placeholderIndex >= 0 && + _chatHistory.length > placeholderIndex) { + _chatHistory[placeholderIndex] = { + 'sender': 'ai', + 'message': result, + }; + } + _scrollToBottom(); + } catch (e, stack) { + // ignore: avoid_print + print("Gemini Data Analysis Error: $e\n$stack"); + _error.value = + "Failed to analyze data. An error occurred: ${e.toString().split(':').first}"; + if (placeholderIndex >= 0 && + _chatHistory.length > placeholderIndex) { + _chatHistory.removeAt(placeholderIndex); + } + } finally { + _isAnalyzingData.value = false; + } + } + + // Small floating chat panel (mobile-friendly) + Widget _buildFloatingPanel(BuildContext context) { + final media = MediaQuery.of(context).size; + final double panelWidth = media.width * 0.9; + final double panelHeight = media.height * 0.45; + + return FadeTransition( + opacity: _opacityAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + alignment: Alignment.bottomRight, + child: Align( + alignment: Alignment.bottomRight, + child: Container( + width: panelWidth, + height: panelHeight, + margin: const EdgeInsets.only(right: 16, bottom: 80), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: contentTheme.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.bot, + size: 18, + color: Colors.blue, + ), + ), + MySpacing.width(8), + Expanded( + child: MyText.titleSmall( + "Gemini AI Assistant", + fontWeight: 700, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + splashRadius: 18, + iconSize: 18, + onPressed: _toggleOpen, + icon: const Icon(Icons.close, color: Colors.black54), + ), + ], + ), + MySpacing.height(4), + MyText.labelSmall( + "Ask questions or analyze expenses.", + color: Colors.grey.shade600, + ), + MySpacing.height(8), + + // Error + Obx( + () => _error.value.isNotEmpty + ? Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: + Border.all(color: Colors.red.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + LucideIcons.badge_alert, + color: Colors.red, + size: 16, + ), + MySpacing.width(6), + Expanded( + child: MyText.bodySmall( + _error.value, + color: Colors.red.shade800, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + + // Chat list + Expanded( + child: Obx( + () { + if (_chatHistory.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.bot, + size: 40, + color: Colors.blue.shade300, + ), + MySpacing.height(6), + Text( + "Ask me about your data!", + style: TextStyle( + color: Colors.grey.shade500, + ), + ), + ], + ), + ); + } + return ListView.builder( + controller: _scrollController, + itemCount: _chatHistory.length, + padding: + const EdgeInsets.symmetric(vertical: 4), + itemBuilder: (context, index) { + final message = _chatHistory[index]; + return _buildResponseBubble( + message['message'] ?? '', + message['sender'] == 'ai', + ); + }, + ); + }, + ), + ), + MySpacing.height(6), + + // Bottom row: Analyze + input + Row( + children: [ + Obx( + () => IconButton( + splashRadius: 18, + onPressed: (_isAnalyzingData.value || + _isLoading.value) + ? null + : _handleDataAnalysis, + iconSize: 22, + icon: _isAnalyzingData.value + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: contentTheme.primary, + ), + ) + : const Icon( + LucideIcons.chart_line, + color: Colors.blue, + ), + tooltip: 'Analyze expenses', + ), + ), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.grey.shade300, + ), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _promptController, + minLines: 1, + maxLines: 3, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + hintText: "Ask Gemini…", + border: InputBorder.none, + isDense: true, + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + onSubmitted: (_) => + _handlePromptSubmission(), + enabled: !_isAnalyzingData.value, + ), + ), + Obx( + () => IconButton( + splashRadius: 18, + iconSize: 18, + onPressed: (_isLoading.value || + _isAnalyzingData.value || + _promptController.text + .trim() + .isEmpty) + ? null + : _handlePromptSubmission, + icon: _isLoading.value + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: contentTheme.primary, + ), + ) + : Icon( + LucideIcons.send, + color: _promptController.text + .trim() + .isEmpty + ? Colors.grey.shade400 + : contentTheme.primary, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + // FAB + Widget _buildFab() { + return Obx( + () => AnimatedScale( + scale: _isOpen.value ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: FloatingActionButton.extended( + onPressed: _toggleOpen, + heroTag: 'gemini_fab', + backgroundColor: contentTheme.primary, + icon: const Icon(LucideIcons.bot), + label: const Text( + 'Ask Me Anything', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ), + ); + } + + // Dim background when open + Widget _buildScrim() { + return Obx( + () => _isOpen.value + ? FadeTransition( + opacity: _opacityAnimation, + child: GestureDetector( + onTap: _toggleOpen, + child: Container( + color: Colors.black.withOpacity(0.25), + ), + ), + ) + : const SizedBox.shrink(), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Scrim behind panel + _buildScrim(), + // Panel + _buildFloatingPanel(context), + // FAB + Positioned( + bottom: 16, + right: 16, + child: _buildFab(), + ), + ], + ); + } + + @override + void dispose() { + _promptController.dispose(); + _scrollController.dispose(); + _animController.dispose(); + super.dispose(); + } +}