marco.pms.mobileapp/lib/view/dashboard/gemini_chat_card.dart
2025-12-15 17:18:56 +05:30

549 lines
19 KiB
Dart

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<GeminiFloatingAssistant> createState() =>
_GeminiFloatingAssistantState();
}
class _GeminiFloatingAssistantState extends State<GeminiFloatingAssistant>
with SingleTickerProviderStateMixin, UIMixin {
// State
final TextEditingController _promptController = TextEditingController();
final RxList<Map<String, String>> _chatHistory =
<Map<String, String>>[].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<double> _scaleAnimation;
late final Animation<double> _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<void> _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<void> _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();
}
}