diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 1196355..f22542f 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -8,6 +8,7 @@ import 'package:on_field_work/helpers/services/firebase/firebase_messaging_servi import 'package:on_field_work/helpers/services/device_info_service.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/app_theme.dart'; +import 'package:on_field_work/helpers/services/gemini_service.dart'; Future initializeApp() async { try { @@ -38,11 +39,16 @@ Future initializeApp() async { } } +/// --------------------------------------------------------------------------- +/// πŸ”Ή AUTH TOKEN HANDLER +/// --------------------------------------------------------------------------- Future _handleAuthTokens() async { final refreshToken = await LocalStorage.getRefreshToken(); + if (refreshToken?.isNotEmpty ?? false) { logSafe("πŸ” Refresh token found. Attempting to refresh JWT..."); final success = await AuthService.refreshToken(); + if (!success) { logSafe("⚠️ Refresh token invalid or expired. User must login again."); } @@ -51,43 +57,68 @@ Future _handleAuthTokens() async { } } +/// --------------------------------------------------------------------------- +/// πŸ”Ή UI SETUP +/// --------------------------------------------------------------------------- Future _setupUI() async { setPathUrlStrategy(); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - logSafe("πŸ’‘ UI setup completed with default system behavior."); + logSafe("πŸ’‘ UI setup completed."); } +/// --------------------------------------------------------------------------- +/// πŸ”Ή FIREBASE + GEMINI SETUP +/// --------------------------------------------------------------------------- Future _setupFirebase() async { + // Firebase Core await Firebase.initializeApp(); - logSafe("πŸ’‘ Firebase initialized."); + logSafe("πŸ”₯ Firebase initialized."); + await GeminiService.initialize(); + logSafe("✨ Gemini service initialized (Gemini 2.5 Flash)."); } +/// --------------------------------------------------------------------------- +/// πŸ”Ή LOCAL STORAGE SETUP +/// --------------------------------------------------------------------------- Future _setupLocalStorage() async { if (!LocalStorage.isInitialized) { await LocalStorage.init(); - logSafe("πŸ’‘ Local storage initialized."); + logSafe("πŸ’Ύ Local storage initialized."); } else { - logSafe("ℹ️ Local storage already initialized, skipping."); + logSafe("ℹ️ Local storage already initialized. Skipping."); } } +/// --------------------------------------------------------------------------- +/// πŸ”Ή DEVICE INFO +/// --------------------------------------------------------------------------- Future _setupDeviceInfo() async { final deviceInfoService = DeviceInfoService(); await deviceInfoService.init(); - logSafe("πŸ“± Device Info: ${deviceInfoService.deviceData}"); + + logSafe("πŸ“± Device Info Loaded: ${deviceInfoService.deviceData}"); } +/// --------------------------------------------------------------------------- +/// πŸ”Ή THEME SETUP +/// --------------------------------------------------------------------------- Future _setupTheme() async { await ThemeCustomizer.init(); - logSafe("πŸ’‘ Theme customizer initialized."); + logSafe("🎨 Theme customizer initialized."); } +/// --------------------------------------------------------------------------- +/// πŸ”Ή FIREBASE CLOUD MESSAGING (PUSH) +/// --------------------------------------------------------------------------- Future _setupFirebaseMessaging() async { await FirebaseNotificationService().initialize(); - logSafe("πŸ’‘ Firebase Messaging initialized."); + logSafe("πŸ“¨ Firebase Messaging initialized."); } +/// --------------------------------------------------------------------------- +/// πŸ”Ή FINAL APP STYLE +/// --------------------------------------------------------------------------- void _finalizeAppStyle() { AppStyle.init(); - logSafe("πŸ’‘ AppStyle initialized."); + logSafe("🎯 AppStyle initialized."); } 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/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index b14af24..ec94488 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -20,6 +20,7 @@ import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/model/attendance/attendence_action_button.dart'; import 'package:on_field_work/model/attendance/log_details_view.dart'; import 'package:on_field_work/view/layouts/layout.dart'; +import 'package:on_field_work/view/dashboard/gemini_chat_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -77,6 +78,7 @@ class _DashboardScreenState extends State with UIMixin { ); } + Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), @@ -558,51 +560,62 @@ class _DashboardScreenState extends State with UIMixin { // --------------------------------------------------------------------------- @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xfff5f6fa), - body: Layout( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _projectSelector(), - MySpacing.height(20), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _quickActions(), - MySpacing.height(20), - _dashboardModules(), - MySpacing.height(20), - _sectionTitle('Reports & Analytics'), - _cardWrapper( - child: ExpenseTypeReportChart(), - ), - _cardWrapper( - child: ExpenseByStatusWidget( - controller: dashboardController, +Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xfff5f6fa), + body: Layout( + child: Stack( + children: [ + // Main content + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _projectSelector(), + MySpacing.height(20), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _quickActions(), + MySpacing.height(20), + _sectionTitle('Modules'), + _dashboardModules(), + MySpacing.height(20), + _sectionTitle('Reports & Analytics'), + _cardWrapper( + child: ExpenseTypeReportChart(), ), - ), - _cardWrapper( - child: MonthlyExpenseDashboardChart(), - ), - MySpacing.height(20), - ], + _cardWrapper( + child: ExpenseByStatusWidget( + controller: dashboardController, + ), + ), + _cardWrapper( + child: MonthlyExpenseDashboardChart(), + ), + MySpacing.height(80), // give space under content + ], + ), ), ), - ), - ], + ], + ), ), - ), - ), - ); - } -} + // Floating Gemini assistant overlay + GeminiFloatingAssistant( + dashboardController: dashboardController, + ), + ], + ), + ), + ); +} +} class _DashboardCardMeta { final IconData icon; final Color color; 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(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 5789100..ce08c89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,7 +73,7 @@ dependencies: tab_indicator_styler: ^2.0.0 connectivity_plus: ^7.0.0 geocoding: ^4.0.0 - firebase_core: ^4.0.0 + firebase_core: ^4.2.1 firebase_messaging: ^16.0.0 googleapis_auth: ^2.0.0 device_info_plus: ^12.3.0 @@ -88,6 +88,7 @@ dependencies: timeline_tile: ^2.0.0 encrypt: ^5.0.3 flutter_in_store_app_version_checker: ^1.10.0 + firebase_ai: ^3.6.0 dev_dependencies: flutter_test: