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(); } }