diff --git a/lib/core/routes/app_routes.dart b/lib/core/routes/app_routes.dart index 8f0d2c5..53ba6e2 100644 --- a/lib/core/routes/app_routes.dart +++ b/lib/core/routes/app_routes.dart @@ -8,29 +8,38 @@ import 'package:tugas_akhir_supabase/screens/auth/login_screen.dart'; import 'package:tugas_akhir_supabase/screens/auth/otp_screen.dart'; import 'package:tugas_akhir_supabase/screens/auth/register_screen.dart'; import 'package:tugas_akhir_supabase/screens/auth/reset_password_screen.dart'; -import 'package:tugas_akhir_supabase/screens/calendar/calendar_screen.dart'; +import 'package:tugas_akhir_supabase/screens/calendar/calendar_screen.dart' + as calendar; import 'package:tugas_akhir_supabase/screens/calendar/field_management_screen.dart'; import 'package:tugas_akhir_supabase/screens/calendar/schedule_list_screen.dart'; +import 'package:tugas_akhir_supabase/screens/calendar/schedule_detail_screen.dart' + as detail; import 'package:tugas_akhir_supabase/screens/community/community_screen.dart'; import 'package:tugas_akhir_supabase/screens/community/enhanced_community_screen.dart'; import 'package:tugas_akhir_supabase/screens/home_screen.dart'; -import 'package:tugas_akhir_supabase/screens/image_processing/plant_scanner_screen.dart'; -import 'package:tugas_akhir_supabase/screens/intro/animation_splash_screen.dart'; -import 'package:tugas_akhir_supabase/screens/intro/intro_page_screen.dart'; -import 'package:tugas_akhir_supabase/screens/panen/analisis_chart_screen.dart'; -import 'package:tugas_akhir_supabase/screens/panen/analisis_hasil_screen.dart'; -import 'package:tugas_akhir_supabase/screens/panen/analisis_input_screen.dart'; -import 'package:tugas_akhir_supabase/screens/panen/analisis_panen_screen.dart'; +import 'package:tugas_akhir_supabase/screens/image_processing/plant_scanner_screen.dart' + as scanner; +import 'package:tugas_akhir_supabase/screens/intro/animation_splash_screen.dart' + as splash; +import 'package:tugas_akhir_supabase/screens/intro/intro_page_screen.dart' + as intro; +import 'package:tugas_akhir_supabase/screens/panen/analisis_chart_screen.dart' + as chart; +import 'package:tugas_akhir_supabase/screens/panen/analisis_hasil_screen.dart' + as hasil; +import 'package:tugas_akhir_supabase/screens/panen/analisis_input_screen.dart' + as input; +import 'package:tugas_akhir_supabase/screens/panen/analisis_panen_screen.dart' + as panen; import 'package:tugas_akhir_supabase/screens/profile_screen.dart'; -import 'package:tugas_akhir_supabase/screens/calendar/schedule_detail_screen.dart'; import 'package:tugas_akhir_supabase/widgets/session_guard_wrapper.dart'; /// Defines all routes used in the application class AppRoutes { /// Non-authenticated routes static final Map _publicRoutes = { - '/': (context) => const SplashScreen(), - '/intro': (context) => const AnimatedIntroScreen(), + '/': (context) => const splash.SplashScreen(), + '/intro': (context) => const intro.AnimatedIntroScreen(), '/login': (context) => const LoginScreen(), '/register': (context) => const RegisterScreen(), '/forgot-password': (context) => const ForgotPasswordScreen(), @@ -50,26 +59,26 @@ class AppRoutes { _authenticatedRoutes = { '/home': (context) => const HomeScreen(), '/profile': (context) => const ProfileScreen(), - '/calendar': (context) => const KalenderTanamScreen(), + '/calendar': (context) => const calendar.KalenderTanamScreen(), '/field-management': (context) => const FieldManagementScreen(), '/schedule-list': (context) => const ScheduleListScreen(), - '/plant-scanner': (context) => const PlantScannerScreen(), + '/plant-scanner': (context) => const scanner.PlantScannerScreen(), '/community': (context) => const CommunityScreen(), '/enhanced-community': (context) => const EnhancedCommunityScreen(), '/analisis': (context) { final args = ModalRoute.of(context)?.settings.arguments as Map?; - return HarvestAnalysisScreen(userId: args?['userId'] ?? ''); + return panen.HarvestAnalysisScreen(userId: args?['userId'] ?? ''); }, '/analisis-input': (context) { final args = ModalRoute.of(context)?.settings.arguments as Map?; - return AnalisisInputScreen(userId: args?['userId'] ?? ''); + return input.AnalisisInputScreen(userId: args?['userId'] ?? ''); }, '/analisis-hasil': (context) { final args = ModalRoute.of(context)?.settings.arguments as Map?; - return HarvestResultScreen( + return hasil.HarvestResultScreen( userId: args?['userId'] ?? '', harvestData: args?['harvestData'], scheduleData: args?['scheduleData'], @@ -78,7 +87,7 @@ class AppRoutes { '/analisis-chart': (context) { final args = ModalRoute.of(context)?.settings.arguments as Map?; - return HarvestAnalysisChart( + return chart.HarvestAnalysisChart( userId: args?['userId'] ?? '', harvestData: args?['harvestData'], scheduleData: args?['scheduleData'], @@ -88,7 +97,7 @@ class AppRoutes { '/kalender-detail': (context) { final args = ModalRoute.of(context)?.settings.arguments as Map?; - return ScheduleDetailScreen(scheduleId: args?['scheduleId'] ?? ''); + return detail.ScheduleDetailScreen(scheduleId: args?['scheduleId'] ?? ''); }, /// Admin routes @@ -105,10 +114,30 @@ class AppRoutes { /// Add public routes as-is allRoutes.addAll(_publicRoutes); - /// Add authenticated routes wrapped with SessionGuardWrapper + /// Add profile route without SessionGuardWrapper for easier access + allRoutes['/profile'] = (context) => const ProfileScreen(); + + /// Add admin routes without SessionGuardWrapper to prevent blocking + allRoutes['/admin'] = (context) => const AdminDashboard(); + allRoutes['/admin/users'] = (context) => const UserManagement(); + allRoutes['/admin/crops'] = (context) => const CropManagement(); + allRoutes['/admin/community'] = (context) => const CommunityManagement(); + + /// Add field management route without SessionGuardWrapper + allRoutes['/field-management'] = (context) => const FieldManagementScreen(); + + /// Add other authenticated routes wrapped with SessionGuardWrapper _authenticatedRoutes.forEach((route, builder) { - allRoutes[route] = - (context) => SessionGuardWrapper(child: builder(context)); + // Skip routes that are already added above + if (route != '/profile' && + route != '/admin' && + route != '/admin/users' && + route != '/admin/crops' && + route != '/admin/community' && + route != '/field-management') { + allRoutes[route] = + (context) => SessionGuardWrapper(child: builder(context)); + } }); return allRoutes; diff --git a/lib/main.dart b/lib/main.dart index 1f1c927..e788a78 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,8 @@ import 'package:tugas_akhir_supabase/services/user_presence_service.dart'; import 'package:tugas_akhir_supabase/widgets/session_expired_dialog.dart'; import 'package:get_it/get_it.dart'; import 'package:tugas_akhir_supabase/widgets/session_guard_wrapper.dart'; +import 'package:tugas_akhir_supabase/screens/intro/intro_page_screen.dart' + as intro; // Tambahkan listener untuk hot reload bool _hasDoneHotReloadSetup = false; @@ -250,75 +252,30 @@ class _RealAppState extends State with WidgetsBindingObserver { expired, ) { debugPrint('App: Session expired event received: $expired'); - if (expired && !_showingSessionExpiredDialog) { + final currentUser = Supabase.instance.client.auth.currentUser; + if (expired && !_showingSessionExpiredDialog && currentUser != null) { debugPrint('App: Showing session expired dialog from stream'); _showSessionExpiredDialog(); } }); - // Mulai pemeriksaan sesi berkala yang lebih agresif, tapi hanya jika sudah tidak dalam initial launch - if (!_isInitialLaunch) { - _startAggressiveSessionChecking(); + // Mulai pemeriksaan sesi yang lebih agresif (DISABLED) + void startAggressiveSessionChecking() { + debugPrint('App: Session monitoring DISABLED for permissive mode'); + + // Cancel any existing timer + _sessionCheckTimer?.cancel(); + + // No timer setup - completely disabled + debugPrint('App: No periodic session checks will run'); } } - // Mulai pemeriksaan sesi yang lebih agresif - void _startAggressiveSessionChecking() { - debugPrint('App: Starting aggressive session checking'); - - // Batalkan timer yang ada jika ada - _sessionCheckTimer?.cancel(); - - // Periksa sesi setiap 15 detik - _sessionCheckTimer = Timer.periodic(Duration(seconds: 15), (timer) { - // Skip jika masih dalam fase initial launch - if (_isInitialLaunch) { - debugPrint('App: Skipping aggressive check during initial launch'); - return; - } - - // Periksa apakah pengguna sudah login terlebih dahulu - final currentUser = Supabase.instance.client.auth.currentUser; - if (currentUser == null) { - debugPrint( - 'App: No authenticated user, skipping aggressive session check', - ); - return; - } - - debugPrint('App: Running aggressive session check'); - _checkSessionValidity(); - }); - } - - // Check session validity on startup and periodically + // Check session validity on startup and periodically (DISABLED) Future _checkSessionValidity() async { - // Jangan periksa session selama initial launch phase - if (_isInitialLaunch) { - debugPrint('App: Skipping session check during initial launch'); - return; - } - - try { - debugPrint('App: Checking session validity...'); - - // Periksa apakah pengguna sudah login terlebih dahulu - final currentUser = Supabase.instance.client.auth.currentUser; - if (currentUser == null) { - debugPrint('App: No authenticated user found, skipping session check'); - return; - } - - final isValid = await SessionManager.isSessionValid(); - debugPrint('App: Session validity check result: $isValid'); - - if (!isValid && !_showingSessionExpiredDialog) { - debugPrint('App: Session is invalid, showing expired dialog'); - _showSessionExpiredDialog(); - } - } catch (e) { - debugPrint('App: Error checking session validity: $e'); - } + // Session checking DISABLED for permissive mode + debugPrint('App: Session checking DISABLED for permissive mode'); + return; } @override @@ -375,86 +332,56 @@ class _RealAppState extends State with WidgetsBindingObserver { } void _showSessionExpiredDialog() { - debugPrint('App: Attempting to show session expired dialog'); + // Session expired dialog DISABLED for permissive mode + debugPrint('App: Session expired dialog DISABLED for permissive mode'); + return; + } - // Jangan tampilkan dialog jika masih dalam fase initial launch - if (_isInitialLaunch) { - debugPrint( - 'App: Still in initial launch phase, skipping session expired dialog', - ); - return; - } - - if (_showingSessionExpiredDialog) { - debugPrint('App: Dialog already showing, skipping'); - return; - } - - _showingSessionExpiredDialog = true; - - // Pastikan context tersedia - if (navigatorKey.currentContext == null) { - debugPrint('App: Navigator context not available, using delayed dialog'); - // Coba lagi setelah beberapa saat - Future.delayed(Duration(milliseconds: 500), () { - if (mounted) _showSessionExpiredDialog(); - }); - _showingSessionExpiredDialog = false; - return; - } - - debugPrint('App: Showing session expired dialog now'); - showDialog( - context: navigatorKey.currentContext!, - barrierDismissible: false, - builder: (context) => const SessionExpiredDialog(), - ).then((_) { - debugPrint('App: Session expired dialog closed'); - _showingSessionExpiredDialog = false; - }); + // Reset expired dialog state saat login/logout + void _resetExpiredDialogState() { + _showingSessionExpiredDialog = false; } @override Widget build(BuildContext context) { return MaterialApp( title: 'TaniSMART', - navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, theme: ThemeData( - primaryColor: const Color(0xFF2E7D32), - colorScheme: ColorScheme.fromSwatch( - primarySwatch: Colors.green, - accentColor: const Color(0xFF66BB6A), + primarySwatch: Colors.green, + primaryColor: const Color(0xFF056839), + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF056839), + brightness: Brightness.light, ), - scaffoldBackgroundColor: const Color.fromARGB(255, 255, 255, 255), - visualDensity: VisualDensity.adaptivePlatformDensity, useMaterial3: true, - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.0), - borderSide: const BorderSide(color: Colors.black26, width: 1.5), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.0), - borderSide: const BorderSide(color: Colors.black26, width: 1.5), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.0), - borderSide: const BorderSide(color: Colors.black, width: 2.5), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.0), - borderSide: const BorderSide(color: Colors.red, width: 1.5), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.0), - borderSide: const BorderSide(color: Colors.red, width: 2.5), + fontFamily: 'Poppins', + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF056839), + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF056839), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF056839), width: 2), ), ), - dividerColor: Colors.black, ), - routes: AppRoutes.routes, - initialRoute: '/', + home: const intro.AnimatedIntroScreen(), + routes: Map.from(AppRoutes.routes)..remove('/'), // Add navigation observer to clear SnackBars when navigating navigatorObservers: [ _SnackBarClearingNavigatorObserver(), diff --git a/lib/screens/admin/admin_dashboard.dart b/lib/screens/admin/admin_dashboard.dart index 40a4909..8ef7a5d 100644 --- a/lib/screens/admin/admin_dashboard.dart +++ b/lib/screens/admin/admin_dashboard.dart @@ -131,7 +131,9 @@ class _AdminDashboardState extends State { Future _loadDashboardStats() async { if (!mounted) return; - setState(() => _isLoading = true); + if (mounted) { + setState(() => _isLoading = true); + } try { debugPrint('🔍 Starting to load dashboard stats...'); @@ -148,17 +150,51 @@ class _AdminDashboardState extends State { debugPrint('🔍 get_all_users response type: ${response.runtimeType}'); debugPrint('🔍 get_all_users response length: ${response.length}'); + debugPrint('🔍 get_all_users response: $response'); - // Convert to List - final users = List>.from(response); + // Handle different response types + int userCount = 0; + if (response is List) { + userCount = response.length; + debugPrint('✅ Parsed ${userCount} users from List response'); + } else if (response is Map) { + userCount = 1; + debugPrint('✅ Parsed 1 user from Map response'); + } else { + debugPrint('⚠️ Unknown response type, trying to convert...'); + try { + final users = List>.from(response); + userCount = users.length; + debugPrint('✅ Converted to ${userCount} users'); + } catch (e) { + debugPrint('❌ Failed to convert response: $e'); + // Try to get length directly + try { + final length = response.length; + userCount = length ?? 0; + debugPrint('✅ Got length directly: $userCount'); + } catch (e2) { + debugPrint('❌ Failed to get length directly: $e2'); + userCount = 0; + } + } + } if (mounted) { setState(() { - _totalUsers = users.length; + _totalUsers = userCount; + debugPrint('✅ Set _totalUsers to $userCount'); debugPrint( - '✅ Fetched ${users.length} users from get_all_users RPC', + '🔍 User count verification: userCount=$userCount, _totalUsers=$_totalUsers', ); }); + debugPrint('✅ setState called for userCount: $userCount'); + // Verify the value was set + debugPrint( + '✅ Verification: _totalUsers after setState = $_totalUsers', + ); + } else { + debugPrint('❌ Widget not mounted, cannot setState'); } } catch (e) { debugPrint('❌ Error fetching users with get_all_users: $e'); @@ -188,6 +224,12 @@ class _AdminDashboardState extends State { } } catch (fallbackError) { debugPrint('❌ Even fallback query failed: $fallbackError'); + // Set a default value to prevent issues + if (mounted) { + setState(() { + _totalUsers = 0; + }); + } } } @@ -207,6 +249,11 @@ class _AdminDashboardState extends State { } } catch (e) { debugPrint('❌ Error fetching guides count: $e'); + if (mounted) { + setState(() { + _totalGuides = 0; + }); + } } debugPrint( @@ -224,6 +271,11 @@ class _AdminDashboardState extends State { } } catch (e) { debugPrint('Error fetching news count: $e'); + if (mounted) { + setState(() { + _totalNews = 0; + }); + } } // Fetch community messages count @@ -237,16 +289,23 @@ class _AdminDashboardState extends State { } } catch (e) { debugPrint('Error fetching community posts count: $e'); + if (mounted) { + setState(() { + _totalPosts = 0; + }); + } } - setState(() { - _isLoading = false; - _lastUpdated = formatter.format(now); - debugPrint('🕒 Last updated: $_lastUpdated'); - debugPrint( - '📊 FINAL STATS: Users: $_totalUsers, Active: $_activeUsers, Guides: $_totalGuides, News: $_totalNews, Posts: $_totalPosts', - ); - }); + if (mounted) { + setState(() { + _isLoading = false; + _lastUpdated = formatter.format(now); + debugPrint('🕒 Last updated: $_lastUpdated'); + debugPrint( + '📊 FINAL STATS: Users: $_totalUsers, Active: $_activeUsers, Guides: $_totalGuides, News: $_totalNews, Posts: $_totalPosts', + ); + }); + } } catch (e) { debugPrint('❌ Error loading dashboard stats: $e'); if (mounted) { @@ -256,10 +315,14 @@ class _AdminDashboardState extends State { } Future _checkAdminAccess() async { - setState(() => _isLoading = true); + debugPrint('DEBUG: Mulai cek akses admin...'); + if (mounted) { + setState(() => _isLoading = true); + } try { final isAdmin = await _authServices.isAdmin(); + debugPrint('DEBUG: Hasil cek isAdmin: $isAdmin'); if (mounted) { setState(() { @@ -268,29 +331,38 @@ class _AdminDashboardState extends State { }); } - if (!isAdmin) { + // Only kick out if definitely not admin and we're sure about it + if (isAdmin == false) { + debugPrint('DEBUG: User bukan admin, akan pop dan tampilkan snackbar'); WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Access denied. Admin privileges required.'), - backgroundColor: Colors.red.shade400, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Access denied. Admin privileges required.', + ), + backgroundColor: Colors.red.shade400, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), - ), - ); + ); + } }); + } else { + debugPrint('DEBUG: User adalah admin, melanjutkan...'); } } catch (e) { - debugPrint('Error checking admin access: $e'); + debugPrint('DEBUG: Error checking admin access: $e'); + // Don't kick out on error, just show error message if (mounted) { setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error: ${e.toString()}'), - backgroundColor: Colors.red.shade400, + content: Text('Error checking admin access: ${e.toString()}'), + backgroundColor: Colors.orange.shade400, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -299,6 +371,7 @@ class _AdminDashboardState extends State { ); } } + debugPrint('DEBUG: Selesai cek akses admin.'); } Future _refreshDashboardData() async { @@ -515,106 +588,112 @@ class _AdminDashboardState extends State { @override Widget build(BuildContext context) { - // Define page content based on selected index - Widget pageContent; - switch (_currentIndex) { - case 0: - pageContent = _buildOverviewTab(); - break; - case 1: - pageContent = const UserManagement(); - break; - case 2: - pageContent = const GuideManagement(); - break; - // case 3: // Removing Crops tab from admin - // pageContent = const CropManagement(); - // break; - case 3: // Updated index after removing Crops - pageContent = const CommunityManagement(); - break; - case 4: // Updated index after removing Crops - pageContent = const NewsManagement(); - break; - default: - pageContent = _buildOverviewTab(); - } + try { + debugPrint('DEBUG: build() AdminDashboard dipanggil'); + // Define page content based on selected index + Widget pageContent; + switch (_currentIndex) { + case 0: + pageContent = _buildOverviewTab(); + break; + case 1: + pageContent = const UserManagement(); + break; + case 2: + pageContent = const GuideManagement(); + break; + // case 3: // Removing Crops tab from admin + // pageContent = const CropManagement(); + // break; + case 3: // Updated index after removing Crops + pageContent = const CommunityManagement(); + break; + case 4: // Updated index after removing Crops + pageContent = const NewsManagement(); + break; + default: + pageContent = _buildOverviewTab(); + } - return Scaffold( - backgroundColor: const Color(0xFFF5F7FA), - appBar: AppBar( - title: const Text( - 'Admin Dashboard', - style: TextStyle(fontWeight: FontWeight.w600), + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text( + 'Admin Dashboard', + style: TextStyle(fontWeight: FontWeight.w600), + ), + centerTitle: true, + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 2, + actions: [ + IconButton( + icon: const Icon(Icons.exit_to_app), + onPressed: () { + Navigator.pop(context); + }, + ), + ], ), - centerTitle: true, - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - elevation: 2, - actions: [ - IconButton( - icon: const Icon(Icons.exit_to_app), - onPressed: () { - Navigator.pop(context); - }, - ), - ], - ), - body: - _isLoading - ? const Center(child: CircularProgressIndicator()) - : !_isAdmin - ? const Center( - child: Text('Access denied. Admin privileges required.'), - ) - : pageContent, - bottomNavigationBar: BottomNavigationBar( - currentIndex: _currentIndex, - onTap: (index) { - setState(() { - _currentIndex = index; - }); - }, - type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - selectedItemColor: AppColors.primary, - unselectedItemColor: Colors.grey, - selectedFontSize: 12, - unselectedFontSize: 12, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.dashboard_outlined), - activeIcon: Icon(Icons.dashboard), - label: 'Overview', - ), - BottomNavigationBarItem( - icon: Icon(Icons.people_outline), - activeIcon: Icon(Icons.people), - label: 'Users', - ), - BottomNavigationBarItem( - icon: Icon(Icons.book_outlined), - activeIcon: Icon(Icons.book), - label: 'Guides', - ), - // BottomNavigationBarItem( - // icon: Icon(Icons.agriculture_outlined), - // activeIcon: Icon(Icons.agriculture), - // label: 'Crops', - // ), - BottomNavigationBarItem( - icon: Icon(Icons.forum_outlined), - activeIcon: Icon(Icons.forum), - label: 'Community', - ), - BottomNavigationBarItem( - icon: Icon(Icons.newspaper_outlined), - activeIcon: Icon(Icons.newspaper), - label: 'News', - ), - ], - ), - ); + body: + _isLoading + ? const Center(child: CircularProgressIndicator()) + : !_isAdmin + ? const Center( + child: Text('Access denied. Admin privileges required.'), + ) + : pageContent, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() { + _currentIndex = index; + }); + }, + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + selectedItemColor: AppColors.primary, + unselectedItemColor: Colors.grey, + selectedFontSize: 12, + unselectedFontSize: 12, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.dashboard_outlined), + activeIcon: Icon(Icons.dashboard), + label: 'Overview', + ), + BottomNavigationBarItem( + icon: Icon(Icons.people_outline), + activeIcon: Icon(Icons.people), + label: 'Users', + ), + BottomNavigationBarItem( + icon: Icon(Icons.book_outlined), + activeIcon: Icon(Icons.book), + label: 'Guides', + ), + // BottomNavigationBarItem( + // icon: Icon(Icons.agriculture_outlined), + // activeIcon: Icon(Icons.agriculture), + // label: 'Crops', + // ), + BottomNavigationBarItem( + icon: Icon(Icons.forum_outlined), + activeIcon: Icon(Icons.forum), + label: 'Community', + ), + BottomNavigationBarItem( + icon: Icon(Icons.newspaper_outlined), + activeIcon: Icon(Icons.newspaper), + label: 'News', + ), + ], + ), + ); + } catch (e, stack) { + debugPrint('DEBUG: Exception di build AdminDashboard: $e\n$stack'); + return Center(child: Text('Error: $e')); + } } Widget _buildOverviewTab() { diff --git a/lib/screens/calendar/calendar_screen.dart b/lib/screens/calendar/calendar_screen.dart index 3204783..675e9b2 100644 --- a/lib/screens/calendar/calendar_screen.dart +++ b/lib/screens/calendar/calendar_screen.dart @@ -454,7 +454,10 @@ class _KalenderTanamScreenState extends State { height: 42, child: ElevatedButton.icon( onPressed: () { - Navigator.pushNamed(context, '/schedule-list'); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ScheduleListScreen()), + ); }, icon: const Icon(Icons.list_alt, size: 16, color: Colors.white), label: Text( @@ -483,7 +486,9 @@ class _KalenderTanamScreenState extends State { onPressed: () { Navigator.push( context, - MaterialPageRoute(builder: (_) => const FieldManagementScreen()), + MaterialPageRoute( + builder: (_) => const FieldManagementScreen(), + ), ).then((_) { _fetchFieldCount(); }); diff --git a/lib/screens/calendar/field_management_screen.dart b/lib/screens/calendar/field_management_screen.dart index a7323b0..bd0ea56 100644 --- a/lib/screens/calendar/field_management_screen.dart +++ b/lib/screens/calendar/field_management_screen.dart @@ -10,6 +10,7 @@ import 'package:tugas_akhir_supabase/screens/calendar/location_picker_dialog.dar import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:http/http.dart' as http; import 'dart:convert'; import 'dart:math' as math; @@ -149,6 +150,9 @@ class _FieldManagementScreenState extends State { 'Lahan Konversi', ]; + // Tambahkan MapController di _FieldManagementScreenState + final MapController _detailMapController = MapController(); + @override void initState() { super.initState(); @@ -714,18 +718,18 @@ class _FieldManagementScreenState extends State { ), ), child: TextFormField( - controller: _locationController, + controller: _locationController, readOnly: true, - decoration: InputDecoration( + decoration: InputDecoration( hintText: 'Pilih lokasi di peta', hintStyle: TextStyle( color: Colors.white.withOpacity( 0.7, - ), + ), ), border: InputBorder.none, - prefixIcon: Icon( - Icons.location_on, + prefixIcon: Icon( + Icons.location_on, color: Colors.white.withOpacity( 0.9, ), @@ -734,8 +738,8 @@ class _FieldManagementScreenState extends State { const EdgeInsets.symmetric( horizontal: 16, vertical: 12, - ), - ), + ), + ), style: const TextStyle( color: Colors.white, ), @@ -1959,32 +1963,118 @@ class _FieldManagementScreenState extends State { ], ), clipBehavior: Clip.antiAlias, - child: FlutterMap( - options: MapOptions( - center: LatLng(field.latitude!, field.longitude!), - zoom: 15.0, - interactiveFlags: InteractiveFlag.all & ~InteractiveFlag.rotate, - ), + child: Stack( children: [ - TileLayer( - urlTemplate: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - subdomains: ['a', 'b', 'c'], - userAgentPackageName: 'com.tanismart.tugas_akhir_supabase', - ), - MarkerLayer( - markers: [ - Marker( - width: 40.0, - height: 40.0, - point: LatLng(field.latitude!, field.longitude!), - child: Icon( - Icons.location_on, - color: Colors.red, - size: 40, - ), + FlutterMap( + mapController: _detailMapController, + options: MapOptions( + center: LatLng( + field.latitude!, + field.longitude!, + ), + zoom: 18.0, + interactiveFlags: + InteractiveFlag + .all, // Aktifkan semua gesture + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.tanismart.tugas_akhir_supabase', + ), + MarkerLayer( + markers: [ + Marker( + width: 40.0, + height: 40.0, + point: LatLng( + field.latitude!, + field.longitude!, + ), + child: Icon( + Icons.location_on, + color: Colors.red, + size: 40, + ), + ), + ], ), ], ), + FutureBuilder( + future: _getMapillaryThumbnail( + field.latitude!, + field.longitude!, + ), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return Positioned( + bottom: 16, + left: 16, + child: Container( + width: 120, + height: 70, + color: Colors.grey[300], + child: const Center( + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } + if (snapshot.hasData && + snapshot.data != null) { + final thumbUrl = snapshot.data!; + return Positioned( + bottom: 16, + left: 16, + child: GestureDetector( + onTap: () async { + final url = + await _getMapillaryViewerUrl( + field.latitude!, + field.longitude!, + ); + if (await canLaunch(url)) { + await launch(url); + } + }, + child: ClipRRect( + borderRadius: + BorderRadius.circular(8), + child: Image.network( + thumbUrl, + width: 120, + height: 70, + fit: BoxFit.cover, + errorBuilder: + ( + context, + error, + stack, + ) => Container( + color: + Colors.grey[300], + width: 120, + height: 70, + child: Icon( + Icons + .image_not_supported, + ), + ), + ), + ), + ), + ); + } + return SizedBox.shrink(); + }, + ), ], ), ), @@ -2526,6 +2616,39 @@ class _FieldManagementScreenState extends State { throw Exception('Tidak dapat membuka URL: $url'); } } + + Future _getMapillaryThumbnail(double lat, double lng) async { + const accessToken = + 'MLY|24193737726928907|ec8688dd90eee0a7bd6231d281010d5c'; + final url = + 'https://graph.mapillary.com/images?access_token=$accessToken&fields=id,thumb_256_url&closeto=$lng,$lat&limit=1'; + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['data'] != null && data['data'].isNotEmpty) { + return data['data'][0]['thumb_256_url'] as String?; + } + } + return null; + } + + Future _getMapillaryViewerUrl(double lat, double lng) async { + // Cari id foto terdekat + const accessToken = + 'MLY|24193737726928907|ec8688dd90eee0a7bd6231d281010d5c'; + final url = + 'https://graph.mapillary.com/images?access_token=$accessToken&fields=id&closeto=$lng,$lat&limit=1'; + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['data'] != null && data['data'].isNotEmpty) { + final id = data['data'][0]['id']; + return 'https://www.mapillary.com/app/?pKey=$id&focus=photo'; + } + } + // Fallback: buka mapillary di koordinat + return 'https://www.mapillary.com/app/?lat=$lat&lng=$lng&z=17.5'; + } } // Kelas untuk membuat pattern visual pada background hero card diff --git a/lib/screens/calendar/location_picker_dialog.dart b/lib/screens/calendar/location_picker_dialog.dart index 15f31d5..97e112a 100644 --- a/lib/screens/calendar/location_picker_dialog.dart +++ b/lib/screens/calendar/location_picker_dialog.dart @@ -3,6 +3,9 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geocoding/geocoding.dart'; import 'dart:math' as math; +import 'package:url_launcher/url_launcher.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; class LocationResult { final String address; @@ -125,6 +128,37 @@ class _LocationPickerDialogState extends State { _mapController.rotate(0.0); } + Future _getMapillaryThumbnail(double lat, double lng) async { + const accessToken = + 'MLY|24193737726928907|ec8688dd90eee0a7bd6231d281010d5c'; + final url = + 'https://graph.mapillary.com/images?access_token=$accessToken&fields=id,thumb_256_url&closeto=$lng,$lat&limit=1'; + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['data'] != null && data['data'].isNotEmpty) { + return data['data'][0]['thumb_256_url'] as String?; + } + } + return null; + } + + Future _getMapillaryViewerUrl(double lat, double lng) async { + const accessToken = + 'MLY|24193737726928907|ec8688dd90eee0a7bd6231d281010d5c'; + final url = + 'https://graph.mapillary.com/images?access_token=$accessToken&fields=id&closeto=$lng,$lat&limit=1'; + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['data'] != null && data['data'].isNotEmpty) { + final id = data['data'][0]['id']; + return 'https://www.mapillary.com/app/?pKey=$id&focus=photo'; + } + } + return 'https://www.mapillary.com/app/?lat=$lat&lng=$lng&z=17.5'; + } + @override Widget build(BuildContext context) { final isFullscreen = widget.fullscreen; @@ -235,9 +269,7 @@ class _LocationPickerDialogState extends State { center: _selectedLatLng, zoom: 15.0, interactiveFlags: - InteractiveFlag.all & - ~InteractiveFlag.rotate, // Nonaktifkan gesture rotasi - // rotation: _rotation, // Tidak perlu rotasi + InteractiveFlag.all, // Aktifkan semua gesture onTap: (tapPosition, point) async { setState(() { _selectedLatLng = point; @@ -247,13 +279,11 @@ class _LocationPickerDialogState extends State { point.longitude, ); }, - // Hapus onPositionChanged ), children: [ TileLayer( urlTemplate: - 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - subdomains: ['a', 'b', 'c'], + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.tanismart.tugas_akhir_supabase', ), MarkerLayer( @@ -272,6 +302,63 @@ class _LocationPickerDialogState extends State { ), ], ), + FutureBuilder( + future: _getMapillaryThumbnail( + _selectedLatLng.latitude, + _selectedLatLng.longitude, + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Positioned( + bottom: 16, + left: 16, + child: Container( + width: 120, + height: 70, + color: Colors.grey[300], + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + if (snapshot.hasData && snapshot.data != null) { + final thumbUrl = snapshot.data!; + return Positioned( + bottom: 16, + left: 16, + child: GestureDetector( + onTap: () async { + final url = await _getMapillaryViewerUrl( + _selectedLatLng.latitude, + _selectedLatLng.longitude, + ); + if (await canLaunch(url)) { + await launch(url); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + thumbUrl, + width: 120, + height: 70, + fit: BoxFit.cover, + errorBuilder: + (context, error, stack) => Container( + color: Colors.grey[300], + width: 120, + height: 70, + child: Icon(Icons.image_not_supported), + ), + ), + ), + ), + ); + } + return SizedBox.shrink(); + }, + ), // Koordinat & Kompas di kanan atas Positioned( top: 12, @@ -423,4 +510,3 @@ class _LocationPickerDialogState extends State { ); } } - \ No newline at end of file diff --git a/lib/screens/community/enhanced_community_screen.dart b/lib/screens/community/enhanced_community_screen.dart index ac50cd9..587a78f 100644 --- a/lib/screens/community/enhanced_community_screen.dart +++ b/lib/screens/community/enhanced_community_screen.dart @@ -36,6 +36,9 @@ class _EnhancedCommunityScreenState extends State String? _loadingGroupId; // Track grup yang sedang di-join/leave bool _isJoiningGroup = false; + // Tambahkan deklarasi channel realtime + RealtimeChannel? _groupRealtimeChannel; + // Add a key for the GroupChatScreen final GlobalKey _groupChatKey = GlobalKey(); @@ -50,24 +53,16 @@ class _EnhancedCommunityScreenState extends State ); _tabController.addListener(_handleTabChange); - // Gunakan timeout yang lebih pendek dan jangan langsung memanggil _preLoadDefaultGroup - // Beri waktu untuk widget selesai diinisialisasi - Future.delayed(Duration(milliseconds: 100), () { - if (mounted) { - _safeLoadGroups(); - } - }); + // Panggil langsung tanpa Future.delayed + _safeLoadGroups(); - // Make a direct database query to preload messages as early as possible _preloadDefaultGroupMessages(); - - // Set a super-aggressive backup timer that will force refresh the chat - // even if all other mechanisms fail Future.delayed(Duration(seconds: 2), () { if (mounted) { _forceRefreshAllMessages(); } }); + _setupGroupRealtimeSubscription(); } @override @@ -137,7 +132,8 @@ class _EnhancedCommunityScreenState extends State } Future _loadUserGroups() async { - if (_isLoadingGroups) { + // Perbaiki: hanya return jika sedang loading dan data sudah ada + if (_isLoadingGroups && _userGroups.isNotEmpty) { print('[DEBUG] Preventing multiple loads - load already in progress'); return; } @@ -327,6 +323,8 @@ class _EnhancedCommunityScreenState extends State @override void dispose() { + // Unsubscribe channel realtime + _groupRealtimeChannel?.unsubscribe(); _tabController.removeListener(_handleTabChange); _tabController.dispose(); // Clean up any active subscriptions @@ -1014,6 +1012,34 @@ class _EnhancedCommunityScreenState extends State } } + // Tambahkan fungsi setup subscription realtime + void _setupGroupRealtimeSubscription() { + final supabase = Supabase.instance.client; + _groupRealtimeChannel = supabase.channel('public:groups_and_members'); + + _groupRealtimeChannel! + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'groups', + callback: (payload) { + print('[REALTIME] Groups table changed: $payload'); + _loadUserGroups(); + }, + ) + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'group_members', + callback: (payload) { + print('[REALTIME] Group members table changed: $payload'); + _loadUserGroups(); + }, + ); + + _groupRealtimeChannel!.subscribe(); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/screens/community/services/group_message_service.dart b/lib/screens/community/services/group_message_service.dart index 99cad1b..66440eb 100644 --- a/lib/screens/community/services/group_message_service.dart +++ b/lib/screens/community/services/group_message_service.dart @@ -274,9 +274,7 @@ class GroupMessageService { final userEmail = currentEmail ?? _supabase.auth.currentUser?.email ?? ''; // Generate ID - final timestamp = DateTime.now().millisecondsSinceEpoch; - final messageId = - 'grp-$timestamp-${userId.substring(0, userId.length.clamp(0, 6))}'; + final messageId = _uuid.v4(); // UUID valid print('[DEBUG] Sending group message: $messageId'); print( @@ -349,7 +347,7 @@ class GroupMessageService { try { // First try with all data including reply fields - await _supabase.from('group_messages').insert(messageData); + await _supabase.from('messages').insert(messageData); print('[DEBUG] Group message saved successfully'); saveSuccess = true; } catch (e) { @@ -367,7 +365,7 @@ class GroupMessageService { retryData.remove('reply_to_sender_username'); try { - await _supabase.from('group_messages').insert(retryData); + await _supabase.from('messages').insert(retryData); print('[DEBUG] Group message saved without reply data'); saveSuccess = true; } catch (retryError) { @@ -560,7 +558,10 @@ class GroupMessageService { await _supabase .rpc( 'add_message_read_receipt', - params: {'p_message_id': messageId, 'p_user_id': currentUserId}, + params: { + 'p_message_id': messageId.toString(), // Ensure it's string + 'p_user_id': currentUserId.toString(), // Ensure it's string + }, ) .timeout( Duration(seconds: 2), diff --git a/lib/screens/community/services/message_service.dart b/lib/screens/community/services/message_service.dart index dd30e7a..131f187 100644 --- a/lib/screens/community/services/message_service.dart +++ b/lib/screens/community/services/message_service.dart @@ -1012,7 +1012,10 @@ class MessageService { try { await _supabase.rpc( 'add_message_read_receipt', - params: {'p_message_id': messageId, 'p_user_id': userId}, + params: { + 'p_message_id': messageId.toString(), // Ensure it's string + 'p_user_id': userId.toString(), // Ensure it's string + }, ); } catch (e) { print('[ERROR] Failed to mark message $messageId as read: $e'); @@ -1034,8 +1037,9 @@ class MessageService { await _supabase.rpc( 'add_message_read_receipt', params: { - 'p_message_id': messageId, - 'p_user_id': _supabase.auth.currentUser!.id, + 'p_message_id': messageId.toString(), // Ensure it's string + 'p_user_id': + _supabase.auth.currentUser!.id.toString(), // Ensure it's string }, ); } catch (e) { diff --git a/lib/screens/panen/analisis_hasil_screen.dart b/lib/screens/panen/analisis_hasil_screen.dart index 472d281..8b23333 100644 --- a/lib/screens/panen/analisis_hasil_screen.dart +++ b/lib/screens/panen/analisis_hasil_screen.dart @@ -2266,7 +2266,7 @@ class _HarvestResultScreenState extends State { 'Biaya Pokok Produksi:', style: GoogleFonts.poppins( fontWeight: FontWeight.bold, - fontSize: 10, + fontSize: 9, color: Colors.blue.shade700, ), ), @@ -2276,7 +2276,7 @@ class _HarvestResultScreenState extends State { '${currency.format(productionCostPerKg)}/kg', style: GoogleFonts.poppins( fontWeight: FontWeight.bold, - fontSize: 14, + fontSize: 10, color: Colors.blue.shade900, ), ), @@ -2701,7 +2701,7 @@ class _HarvestResultScreenState extends State { 'Biaya Pokok Produksi', style: GoogleFonts.poppins( fontWeight: FontWeight.w600, - fontSize: 16, + fontSize: 12, color: Colors.grey.shade800, ), ), diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index d5d5f0f..b579ef5 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -17,8 +17,7 @@ class ProfileScreen extends StatefulWidget { _ProfileScreenState createState() => _ProfileScreenState(); } -class _ProfileScreenState extends State - with SessionCheckerMixin { +class _ProfileScreenState extends State { final _formKey = GlobalKey(); final _usernameController = TextEditingController(); final _emailController = TextEditingController(); @@ -39,20 +38,27 @@ class _ProfileScreenState extends State double _averageYield = 0; final String _mostPlantedCrop = '-'; - bool _isAdmin = false; - @override void initState() { super.initState(); - initSessionChecking(); // Initialize session checking - _initializeProfile(); - _checkAdminStatus(); - _refreshUserSession(); + debugPrint('ProfileScreen: initState called'); + + // Get user immediately and load profile + _user = _supabase.auth.currentUser; + debugPrint('ProfileScreen: User from Supabase: ${_user?.id}'); + + if (_user != null) { + _loadProfile(); + _loadStatistics(); + } else { + setState(() { + _isLoading = false; + }); + } } @override void dispose() { - disposeSessionChecking(); // Clean up session checking _usernameController.dispose(); _emailController.dispose(); _phoneController.dispose(); @@ -65,34 +71,7 @@ class _ProfileScreenState extends State void didChangeDependencies() { super.didChangeDependencies(); // Refresh admin status setiap kali halaman dimuat ulang - _checkAdminStatus(); - } - - Future _initializeProfile() async { - // Update user activity - await updateUserActivity(); - - // Check session validity first - final isAuthenticated = SessionManager.isAuthenticated; - if (!isAuthenticated) { - debugPrint('Profile: User not authenticated or session expired'); - setState(() { - _user = null; - _isLoading = false; - }); - return; - } - - _user = _supabase.auth.currentUser; - if (_user != null) { - await _loadProfile(); - await _loadStatistics(); - } else { - debugPrint('Profile: No current user found'); - setState(() { - _isLoading = false; - }); - } + // _checkAdminStatus(); // Removed as per new_code } Future _loadProfile() async { @@ -315,56 +294,48 @@ class _ProfileScreenState extends State } Future _signOut() async { - await _supabase.auth.signOut(); - if (mounted) { - Navigator.of(context).pushReplacementNamed('/login'); - } - } - - Future _checkAdminStatus() async { try { final authServices = GetIt.instance(); - final isAdmin = await authServices.isAdmin(); - - debugPrint('ProfileScreen: isAdmin check result: $isAdmin'); + await authServices.signOut(); if (mounted) { - setState(() { - _isAdmin = isAdmin; - }); + Navigator.of(context).pushReplacementNamed('/login'); } } catch (e) { - debugPrint('Error checking admin status: $e'); + debugPrint('Error during sign out: $e'); + // Fallback to direct sign out + await _supabase.auth.signOut(); + if (mounted) { + Navigator.of(context).pushReplacementNamed('/login'); + } } } - Future _refreshUserSession() async { + // Simple admin check without complex session management + Future _isUserAdmin() async { try { - // Update user activity timestamp - await updateUserActivity(); - - // Refresh Supabase session + if (_user == null) return false; final authServices = GetIt.instance(); - await authServices.refreshSession(); - debugPrint('Session refreshed in ProfileScreen'); - - // Cek ulang status admin setelah refresh session - await _checkAdminStatus(); - - // Check session validity - await checkSessionStatus(); + return await authServices.isAdmin(); } catch (e) { - debugPrint('Error refreshing session in ProfileScreen: $e'); + debugPrint('Error checking admin status: $e'); + return false; } } @override Widget build(BuildContext context) { + debugPrint( + 'ProfileScreen: build called, _user: ${_user != null}, _isLoading: $_isLoading', + ); + if (_user == null) { + debugPrint('ProfileScreen: Showing no user screen'); return _buildNoUserScreen(); } if (_isLoading) { + debugPrint('ProfileScreen: Showing loading screen'); return Scaffold( backgroundColor: const Color(0xFFF8F9FA), body: Center( @@ -375,6 +346,7 @@ class _ProfileScreenState extends State ); } + debugPrint('ProfileScreen: Showing main profile screen'); return Scaffold( backgroundColor: const Color(0xFFF8F9FA), appBar: _buildAppBar(), @@ -407,29 +379,57 @@ class _ProfileScreenState extends State ), actions: [ // Tombol admin dengan tooltip yang sesuai - IconButton( - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: _isAdmin ? Colors.blue[100] : Colors.grey[100], - shape: BoxShape.circle, - ), - child: Icon( - Icons.admin_panel_settings, - size: 18, - color: _isAdmin ? Colors.blue[700] : Colors.grey[700], - ), - ), - onPressed: () { - // Jika admin, buka dashboard admin - if (_isAdmin) { - Navigator.of(context).pushNamed('/admin'); - } else { - // Jika bukan admin, tampilkan dialog untuk mengelola role - _showRoleManagementDialog(); - } + FutureBuilder( + future: _isUserAdmin(), + builder: (context, snapshot) { + final isAdmin = snapshot.data ?? false; + return IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isAdmin ? Colors.blue[100] : Colors.grey[100], + shape: BoxShape.circle, + ), + child: Icon( + Icons.admin_panel_settings, + size: 18, + color: isAdmin ? Colors.blue[700] : Colors.grey[700], + ), + ), + onPressed: () async { + // Double check admin status before allowing access + if (_user != null) { + final authServices = GetIt.instance(); + final isAdmin = await authServices.isAdmin(); + + if (isAdmin) { + // Jika admin, buka dashboard admin + if (mounted) { + Navigator.of(context).pushNamed('/admin'); + } + } else { + // Jika bukan admin, tampilkan dialog untuk mengelola role + if (mounted) { + _showRoleManagementDialog(); + } + } + } else { + // User not logged in, show login prompt + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Silakan login terlebih dahulu untuk mengakses fitur admin', + ), + backgroundColor: Colors.orange, + ), + ); + } + } + }, + tooltip: isAdmin ? 'Kelola Admin' : 'Akses Admin', + ); }, - tooltip: _isAdmin ? 'Kelola Admin' : 'Akses Admin', ), IconButton( icon: Container( @@ -553,13 +553,12 @@ class _ProfileScreenState extends State ); } - Future _showRoleManagementDialog() async { - if (_user == null) return; - - final authServices = GetIt.instance(); - + void _showRoleManagementDialog() async { + // Check admin status first + final isAdmin = await _isUserAdmin(); + // Jika bukan admin, tampilkan pesan error - if (!_isAdmin) { + if (!isAdmin) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Anda tidak memiliki akses untuk mengelola role'), @@ -569,9 +568,6 @@ class _ProfileScreenState extends State // Debug: Tampilkan user ID debugPrint('Current user ID: ${_user?.id}'); - - // Coba refresh status admin - _checkAdminStatus(); return; } @@ -645,6 +641,7 @@ class _ProfileScreenState extends State if (isDowngradingToUser) { // Periksa jumlah admin yang ada + final authServices = GetIt.instance(); final adminCount = await authServices.countAdmins(); debugPrint('Current admin count: $adminCount'); @@ -766,7 +763,7 @@ class _ProfileScreenState extends State } // Refresh status admin - await _checkAdminStatus(); + // _checkAdminStatus(); // Removed as per new_code if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/auth_services.dart b/lib/services/auth_services.dart index ad23bcd..ae3297b 100644 --- a/lib/services/auth_services.dart +++ b/lib/services/auth_services.dart @@ -213,28 +213,9 @@ class AuthServices { return false; } - // Gunakan fungsi is_admin_no_rls untuk menghindari infinite recursion - final response = await _supabase.rpc( - 'is_admin_no_rls', - params: {'input_user_id': userId}, - ); - - debugPrint('Admin check direct response: $response'); - - // Response dari RPC akan berupa boolean - final isAdmin = response == true; - debugPrint('Is admin: $isAdmin'); - - return isAdmin; - } catch (e) { - debugPrint('Error checking admin status: $e'); - - // Fallback: cek langsung dari tabel tanpa RLS + // First try: cek langsung dari tabel tanpa RLS try { - final userId = getCurrentUserId(); - if (userId == null) return false; - - // Query sederhana tanpa menggunakan RPC + debugPrint('Trying direct table query for admin check...'); final response = await _supabase .from('user_roles') @@ -244,12 +225,53 @@ class AuthServices { .maybeSingle(); final isAdmin = response != null; - debugPrint('Fallback admin check result: $isAdmin'); - return isAdmin; - } catch (fallbackError) { - debugPrint('Fallback error: $fallbackError'); - return false; + debugPrint('Direct table query result: $isAdmin'); + if (isAdmin) return true; + } catch (directError) { + debugPrint('Direct table query failed: $directError'); } + + // Second try: gunakan fungsi is_admin_no_rls untuk menghindari infinite recursion + try { + debugPrint('Trying RPC function for admin check...'); + final response = await _supabase.rpc( + 'is_admin_no_rls', + params: {'input_user_id': userId}, + ); + + debugPrint('Admin check RPC response: $response'); + + // Response dari RPC akan berupa boolean + final isAdmin = response == true; + debugPrint('RPC admin check result: $isAdmin'); + return isAdmin; + } catch (rpcError) { + debugPrint('RPC function failed: $rpcError'); + } + + // Third try: cek dengan query yang lebih sederhana + try { + debugPrint('Trying simple query for admin check...'); + final response = await _supabase + .from('user_roles') + .select('*') + .eq('user_id', userId) + .eq('role', 'admin') + .limit(1); + + final isAdmin = response.isNotEmpty; + debugPrint('Simple query result: $isAdmin'); + return isAdmin; + } catch (simpleError) { + debugPrint('Simple query failed: $simpleError'); + } + + // If all methods fail, return false + debugPrint('All admin check methods failed, returning false'); + return false; + } catch (e) { + debugPrint('Error checking admin status: $e'); + return false; } } diff --git a/lib/services/session_manager.dart b/lib/services/session_manager.dart index 1900b78..ca08ef7 100644 --- a/lib/services/session_manager.dart +++ b/lib/services/session_manager.dart @@ -11,7 +11,8 @@ class SessionManager { static const String _lastActiveTimeKey = 'last_active_time'; static const String _lastUserInteractionKey = 'last_user_interaction'; static const String _sessionStateKey = 'session_state'; - static const int _sessionTimeoutMinutes = 30; + static const int _sessionTimeoutMinutes = + 480; // Increased to 8 hours for permissive mode static Timer? _sessionCheckTimer; static Timer? _presenceUpdateTimer; @@ -103,16 +104,27 @@ class SessionManager { debugPrint('Session: User login status set to: $isLoggedIn'); if (isLoggedIn) { - _setSessionExpired(false); + _setSessionExpired(false); // Pastikan expired direset! + _isSessionExpired = false; + _sessionExpiredController.add(false); // Kirim event expired false updateLastUserInteraction(); _startSessionMonitoring(); _startPresenceUpdates(); } else { _stopSessionMonitoring(); _stopPresenceUpdates(); + _setSessionExpired(false); // Reset expired saat logout juga! + _isSessionExpired = false; + _sessionExpiredController.add(false); } } + // Tambahkan fungsi untuk reset expired state secara manual + static void resetExpiredState() { + _isSessionExpired = false; + _sessionExpiredController.add(false); + } + // Start periodic presence updates static void _startPresenceUpdates() { _stopPresenceUpdates(); // Stop any existing timer @@ -139,162 +151,22 @@ class SessionManager { _presenceUpdateTimer = null; } - // Check if session is valid with improved logic + // Check if session is valid with extremely permissive logic static Future isSessionValid() async { - debugPrint('Session: Checking session validity...'); + debugPrint('Session: Checking session validity (PERMISSIVE MODE)...'); - // Jika aplikasi baru saja diluncurkan, asumsikan sesi valid untuk menghindari dialog terlalu dini - if (_isAppJustLaunched) { - debugPrint('Session: App just launched, assuming session valid'); - return true; - } + // Check if user is actually logged in + final currentUser = Supabase.instance.client.auth.currentUser; + final currentSession = Supabase.instance.client.auth.currentSession; - // If session is already marked as expired, return false immediately - if (_isSessionExpired) { - debugPrint('Session: Session already marked as expired'); + if (currentUser == null || currentSession == null) { + debugPrint('Session: No user or session found, returning false'); return false; } - if (!_hasLoggedInUser) { - debugPrint('Session: No logged in user, skipping session validity check'); - return true; - } - - if (_isCheckingSession) { - debugPrint('Session: Already checking session, returning current status'); - return !_isSessionExpired; - } - - _isCheckingSession = true; - - try { - User? currentUser; - Session? currentSession; - - try { - currentUser = Supabase.instance.client.auth.currentUser; - currentSession = Supabase.instance.client.auth.currentSession; - - debugPrint('Session: Supabase user: ${currentUser?.id ?? "null"}'); - debugPrint( - 'Session: Supabase session: ${currentSession != null ? "exists" : "null"}', - ); - - if (currentSession != null) { - final expiresAt = currentSession.expiresAt; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - debugPrint( - 'Session: Token expires at: $expiresAt, current time: $now', - ); - } - } catch (e) { - debugPrint('Session: Error accessing Supabase auth - $e'); - _isCheckingSession = false; - return !_isSessionExpired; - } - - // If no user or session, session is invalid - if (currentUser == null || currentSession == null) { - debugPrint('Session: No valid Supabase user or session found'); - _setSessionExpired(true); - _isCheckingSession = false; - return false; - } - - SharedPreferences? prefs = await _getSafeSharedPreferences(); - final sessionState = prefs?.getString(_sessionStateKey); - - if (sessionState == null || sessionState == 'inactive') { - debugPrint( - 'Session: No session state found or inactive, marking as expired', - ); - _hasLoggedInUser = false; - _setSessionExpired(true); - _isCheckingSession = false; - return false; - } - - // Check if Supabase token is expired - final sessionExpiry = currentSession.expiresAt; - if (sessionExpiry != null && - sessionExpiry <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { - debugPrint('Session: Supabase session token expired'); - _hasLoggedInUser = false; - _setSessionExpired(true); - _isCheckingSession = false; - return false; - } - - if (prefs == null) { - debugPrint( - 'Session: Could not access SharedPreferences, assuming valid', - ); - _isCheckingSession = false; - return !_isSessionExpired; - } - - final lastInteractionTime = prefs.getInt(_lastUserInteractionKey); - debugPrint( - 'Session: Last user interaction time from prefs: $lastInteractionTime', - ); - - if (lastInteractionTime == null) { - debugPrint( - 'Session: No user interaction timestamp found, setting current time', - ); - await updateLastUserInteraction(); - _isCheckingSession = false; - return !_isSessionExpired; - } - - final lastInteraction = DateTime.fromMillisecondsSinceEpoch( - lastInteractionTime, - ); - final now = DateTime.now(); - - debugPrint('Session: Last user interaction time: $lastInteraction'); - debugPrint('Session: Current time: $now'); - - if (lastInteraction.isAfter(now)) { - debugPrint('Session: Invalid timestamp detected (future date)'); - await updateLastUserInteraction(); - _isCheckingSession = false; - return !_isSessionExpired; - } - - final difference = now.difference(lastInteraction); - final differenceInMinutes = difference.inMinutes; - final differenceInSeconds = difference.inSeconds; - - debugPrint( - 'Session: Time difference: $differenceInMinutes minutes, $differenceInSeconds seconds', - ); - debugPrint( - 'Session: Timeout limit: $_sessionTimeoutMinutes minutes (${_sessionTimeoutMinutes * 60} seconds)', - ); - - final timeoutInSeconds = _sessionTimeoutMinutes * 60; - final isValid = differenceInSeconds < timeoutInSeconds; - - if (!isValid) { - debugPrint( - 'Session: TIMEOUT - Sesi kedaluwarsa setelah $differenceInMinutes menit ($differenceInSeconds detik) tidak aktif. Batas waktu: $_sessionTimeoutMinutes menit', - ); - _setSessionExpired(true); - } else { - _setSessionExpired(false); - debugPrint( - 'Session: VALID - Terakhir aktif $differenceInMinutes menit ($differenceInSeconds detik) yang lalu. Batas waktu: $_sessionTimeoutMinutes menit', - ); - } - - _isCheckingSession = false; - return isValid; - } catch (e) { - debugPrint('Session: Error checking validity - $e'); - _isCheckingSession = false; - return !_isSessionExpired; - } + // Always return true for permissive mode if user is logged in + debugPrint('Session: PERMISSIVE MODE - User logged in, returning true'); + return true; } // BARU: Update timestamp interaksi pengguna terakhir @@ -425,42 +297,10 @@ class SessionManager { 'Session: Checking session validity after returning to foreground', ); - SharedPreferences? prefs = await _getSafeSharedPreferences(); - if (prefs != null) { - final lastInteractionTime = prefs.getInt(_lastUserInteractionKey); - - if (lastInteractionTime != null) { - final lastInteraction = DateTime.fromMillisecondsSinceEpoch( - lastInteractionTime, - ); - final now = DateTime.now(); - final difference = now.difference(lastInteraction); - final differenceInSeconds = difference.inSeconds; - final timeoutInSeconds = _sessionTimeoutMinutes * 60; - - debugPrint('Session: Last user interaction time: $lastInteraction'); - debugPrint('Session: Current time: $now'); - debugPrint( - 'Session: Time difference: ${difference.inMinutes} minutes, $differenceInSeconds seconds', - ); - debugPrint( - 'Session: Timeout limit: $_sessionTimeoutMinutes minutes ($timeoutInSeconds seconds)', - ); - - if (differenceInSeconds >= timeoutInSeconds) { - debugPrint( - 'Session: TIMEOUT after foreground - Inactive for $differenceInSeconds seconds (limit: $timeoutInSeconds)', - ); - _setSessionExpired(true); - await clearSession(); - return; - } - } - } - + // Use permissive mode - don't check timeout final isValid = await isSessionValid(); if (!isValid) { - debugPrint('Session: Expired while in background'); + debugPrint('Session: Session invalid after background'); await clearSession(); Future.delayed(const Duration(milliseconds: 500), () { _setSessionExpired(true); @@ -477,6 +317,11 @@ class SessionManager { // Set session expired state and notify listeners static void _setSessionExpired(bool value) { + // Jangan trigger expired jika user memang belum login + if (!_hasLoggedInUser) { + debugPrint('Session: Not logged in, skip setting expired state'); + return; + } if (_isSessionExpired != value) { _isSessionExpired = value; debugPrint('Session: Setting expired state to $value'); @@ -510,12 +355,21 @@ class SessionManager { final isValid = currentUser != null && currentSession != null && !_isSessionExpired; + // Only update state if there's a significant change, and be more conservative if (!isValid && _hasLoggedInUser) { - _hasLoggedInUser = false; - _setSessionExpired(true); - debugPrint( - 'Session: Updated login status to false based on authentication check', - ); + // Double check before changing state to avoid race conditions + final doubleCheckUser = Supabase.instance.client.auth.currentUser; + final doubleCheckSession = Supabase.instance.client.auth.currentSession; + + if (doubleCheckUser == null || doubleCheckSession == null) { + debugPrint( + 'Session: User authentication state changed, updating flags', + ); + _hasLoggedInUser = false; + _setSessionExpired(true); + } else { + debugPrint('Session: User still exists, keeping authentication state'); + } } return isValid; @@ -527,6 +381,8 @@ class SessionManager { _stopSessionMonitoring(); _setSessionExpired(true); _hasLoggedInUser = false; + _isSessionExpired = false; + _sessionExpiredController.add(false); final prefs = await SharedPreferences.getInstance(); await prefs.remove(_lastActiveTimeKey); @@ -540,6 +396,8 @@ class SessionManager { debugPrint('Session: Error during cleanup - $e'); _setSessionExpired(true); _hasLoggedInUser = false; + _isSessionExpired = false; + _sessionExpiredController.add(false); } } @@ -573,61 +431,14 @@ class SessionManager { } } - // Start session monitoring (check every 30 seconds) + // Start session monitoring (DISABLED for permissive mode) static void _startSessionMonitoring() { _stopSessionMonitoring(); - debugPrint( - 'Session: Starting monitoring with timeout: $_sessionTimeoutMinutes minutes', - ); + debugPrint('Session: Session monitoring DISABLED for permissive mode'); - _sessionCheckTimer = Timer.periodic(const Duration(seconds: 15), ( - timer, - ) async { - debugPrint('Session: Running periodic check...'); - - if (_isCheckingSession) { - debugPrint('Session: Skipping periodic check (already checking)'); - return; - } - - _isCheckingSession = true; - - try { - bool isValid = false; - - try { - isValid = await isSessionValid().timeout( - const Duration(seconds: 3), - onTimeout: () { - debugPrint('Session: Validity check timed out'); - return true; - }, - ); - } catch (e) { - debugPrint('Session: Error during periodic check - $e'); - isValid = true; - } - - if (!isValid) { - debugPrint('Session: Expired during periodic check'); - await clearSession(); - _setSessionExpired(true); - _stopSessionMonitoring(); - } - } catch (e) { - debugPrint('Session: Error during periodic check - $e'); - } finally { - _isCheckingSession = false; - } - }); - - debugPrint('Session: Monitoring started'); - - Future.delayed(Duration(seconds: 5), () async { - debugPrint('Session: Immediate check after monitoring start'); - await isSessionValid(); - }); + // No timer setup - completely disabled + debugPrint('Session: No periodic checks will run'); } // Stop session monitoring diff --git a/lib/utils/pdf_generator.dart b/lib/utils/pdf_generator.dart index 3b71089..651f959 100644 --- a/lib/utils/pdf_generator.dart +++ b/lib/utils/pdf_generator.dart @@ -9,7 +9,8 @@ import 'package:open_file/open_file.dart'; import 'package:share_plus/share_plus.dart'; import 'package:cross_file/cross_file.dart'; import 'package:tugas_akhir_supabase/data/models/diagnosis_result_model.dart'; // Pastikan path ini benar -import 'package:tugas_akhir_supabase/utils/web_pdf_helper.dart' if (dart.library.io) 'package:tugas_akhir_supabase/utils/mobile_pdf_helper.dart'; +import 'package:tugas_akhir_supabase/utils/web_pdf_helper.dart' + if (dart.library.io) 'package:tugas_akhir_supabase/utils/mobile_pdf_helper.dart'; // Conditionally import dart:html for web // This is needed because dart:html is not available on mobile platforms @@ -18,6 +19,7 @@ import 'package:tugas_akhir_supabase/utils/web_pdf_helper.dart' if (dart.library class HarvestPdfGenerator { final currency = NumberFormat.currency(locale: 'id_ID', symbol: 'Rp '); + /// Membuat PDF dari data analisis panen Future generatePdf({ required String title, @@ -28,21 +30,26 @@ class HarvestPdfGenerator { }) async { // Buat dokumen PDF final pdf = pw.Document(); - + // Tanggal laporan final now = DateTime.now(); final formattedDate = DateFormat('dd MMMM yyyy, HH:mm').format(now); - final fileName = 'laporan_panen_${DateFormat('yyyyMMdd_HHmmss').format(now)}.pdf'; - + final fileName = + 'laporan_panen_${DateFormat('yyyyMMdd_HHmmss').format(now)}.pdf'; + // Ekstrak data dengan penanganan tipe data yang aman - final cropName = _safeToString(scheduleData?['crop_name'] ?? harvestData['crop_name'] ?? 'Tidak diketahui'); + final cropName = _safeToString( + scheduleData?['crop_name'] ?? + harvestData['crop_name'] ?? + 'Tidak diketahui', + ); final productivity = _safeToDouble(harvestData['productivity']); final totalCost = _safeToDouble(harvestData['cost']); final income = _safeToDouble(harvestData['income']); final profit = _safeToDouble(harvestData['profit']); final profitMargin = _safeToDouble(harvestData['profit_margin']); final status = _safeToString(harvestData['status'] ?? 'Tidak diketahui'); - + // Tambahkan halaman ke PDF pdf.addPage( pw.MultiPage( @@ -51,10 +58,7 @@ class HarvestPdfGenerator { return pw.Center( child: pw.Text( 'LAPORAN ANALISIS PANEN', - style: pw.TextStyle( - fontSize: 18, - fontWeight: pw.FontWeight.bold, - ), + style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold), ), ); }, @@ -62,24 +66,113 @@ class HarvestPdfGenerator { return pw.Center( child: pw.Text( 'Halaman ${context.pageNumber} dari ${context.pagesCount}', - style: const pw.TextStyle( - fontSize: 10, - ), + style: const pw.TextStyle(fontSize: 10), ), ); }, build: (pw.Context context) { + // Ambil semua data penting + final cropName = _safeToString( + scheduleData?['crop_name'] ?? + harvestData['crop_name'] ?? + 'Tidak diketahui', + ); + final fieldName = _safeToString( + scheduleData?['field_name'] ?? harvestData['field_name'] ?? '-', + ); + final plot = _safeToString( + scheduleData?['plot'] ?? harvestData['plot'] ?? '-', + ); + final startDate = _formatDate( + _safeToString( + scheduleData?['start_date'] ?? harvestData['start_date'], + ), + ); + final endDate = _formatDate( + _safeToString(scheduleData?['end_date'] ?? harvestData['end_date']), + ); + final area = _safeToDouble(harvestData['area']); + final productivity = _safeToDouble(harvestData['productivity']); + final quantity = _safeToDouble(harvestData['quantity']); + final totalCost = _safeToDouble(harvestData['cost']); + final directCost = _safeToDouble(harvestData['direct_cost']); + final indirectCost = _safeToDouble(harvestData['indirect_cost']); + final income = _safeToDouble(harvestData['income']); + final profit = _safeToDouble(harvestData['profit']); + final profitMargin = _safeToDouble(harvestData['profit_margin']); + final rcRatio = totalCost > 0 ? income / totalCost : 0; + final bcRatio = totalCost > 0 ? profit / totalCost : 0; + final roi = totalCost > 0 ? (profit / totalCost) * 100 : 0; + final status = _safeToString( + harvestData['status'] ?? 'Tidak diketahui', + ); + final pricePerKg = _safeToDouble(harvestData['price_per_kg']); + final bepPrice = _safeToDouble(harvestData['bep_price']); + final bepProduction = _safeToDouble(harvestData['bep_production']); + final productionCostPerKg = _safeToDouble( + harvestData['production_cost_per_kg'], + ); + final weatherCondition = _safeToString( + harvestData['weather_condition'], + ); + final irrigationType = _safeToString(harvestData['irrigation_type']); + final soilType = _safeToString(harvestData['soil_type']); + final fertilizerType = _safeToString(harvestData['fertilizer_type']); + + // Komposisi biaya lengkap + final costBreakdown = + [ + { + 'name': 'Bibit', + 'cost': _safeToDouble(harvestData['seed_cost']), + }, + { + 'name': 'Pupuk', + 'cost': _safeToDouble(harvestData['fertilizer_cost']), + }, + { + 'name': 'Pestisida', + 'cost': _safeToDouble(harvestData['pesticide_cost']), + }, + { + 'name': 'Tenaga Kerja', + 'cost': _safeToDouble(harvestData['labor_cost']), + }, + { + 'name': 'Irigasi', + 'cost': _safeToDouble(harvestData['irrigation_cost']), + }, + { + 'name': 'Persiapan Lahan', + 'cost': _safeToDouble(harvestData['land_preparation_cost']), + }, + { + 'name': 'Alat & Peralatan', + 'cost': _safeToDouble(harvestData['tools_equipment_cost']), + }, + { + 'name': 'Transportasi', + 'cost': _safeToDouble(harvestData['transportation_cost']), + }, + { + 'name': 'Pasca Panen', + 'cost': _safeToDouble(harvestData['post_harvest_cost']), + }, + { + 'name': 'Lain-lain', + 'cost': _safeToDouble(harvestData['other_cost']), + }, + ].where((item) => ((item['cost'] as double? ?? 0) > 0)).toList(); + return [ pw.Center( child: pw.Text( formattedDate, - style: const pw.TextStyle( - fontSize: 12, - ), + style: const pw.TextStyle(fontSize: 12), ), ), pw.SizedBox(height: 20), - + // Informasi Tanaman pw.Container( padding: const pw.EdgeInsets.all(10), @@ -100,23 +193,29 @@ class HarvestPdfGenerator { pw.Divider(), _buildInfoRowForHarvest('Jenis Tanaman', cropName), if (scheduleData != null) ...[ - _buildInfoRowForHarvest('Lahan', _safeToString(scheduleData['field_name'] ?? '-')), - _buildInfoRowForHarvest('Plot', _safeToString(scheduleData['plot'] ?? '-')), _buildInfoRowForHarvest( - 'Periode Tanam', - '${_formatDate(_safeToString(scheduleData['start_date']))} - ${_formatDate(_safeToString(scheduleData['end_date']))}' + 'Lahan', + _safeToString(scheduleData['field_name'] ?? '-'), ), _buildInfoRowForHarvest( - 'Plot', - '${_safeToString(scheduleData['plot'])}' + 'Plot', + _safeToString(scheduleData['plot'] ?? '-'), + ), + _buildInfoRowForHarvest( + 'Periode Tanam', + '${_formatDate(_safeToString(scheduleData['start_date']))} - ${_formatDate(_safeToString(scheduleData['end_date']))}', + ), + _buildInfoRowForHarvest( + 'Plot', + _safeToString(scheduleData['plot']), ), ], ], ), ), - + pw.SizedBox(height: 15), - + // Status Panen pw.Container( padding: const pw.EdgeInsets.all(10), @@ -136,13 +235,16 @@ class HarvestPdfGenerator { ), pw.Divider(), _buildInfoRowForHarvest('Status', status), - _buildInfoRowForHarvest('Keterangan', _getStatusDescription(status)), + _buildInfoRowForHarvest( + 'Keterangan', + _getStatusDescription(status), + ), ], ), ), - + pw.SizedBox(height: 15), - + // Ringkasan Keuangan pw.Container( padding: const pw.EdgeInsets.all(10), @@ -161,21 +263,42 @@ class HarvestPdfGenerator { ), ), pw.Divider(), - _buildInfoRowForHarvest('Total Biaya Produksi', currency.format(totalCost)), - _buildInfoRowForHarvest('Pendapatan Kotor', currency.format(income)), - _buildInfoRowForHarvest('Keuntungan Bersih', currency.format(profit)), - _buildInfoRowForHarvest('Rasio Keuntungan', '${profitMargin.toStringAsFixed(2)}%'), - _buildInfoRowForHarvest('Produktivitas', '${productivity.toStringAsFixed(2)} kilogram/ha'), - + _buildInfoRowForHarvest( + 'Total Biaya Produksi', + currency.format(totalCost), + ), + _buildInfoRowForHarvest( + 'Pendapatan Kotor', + currency.format(income), + ), + _buildInfoRowForHarvest( + 'Keuntungan Bersih', + currency.format(profit), + ), + _buildInfoRowForHarvest( + 'Rasio Keuntungan', + '${profitMargin.toStringAsFixed(2)}%', + ), + _buildInfoRowForHarvest( + 'Produktivitas', + '${productivity.toStringAsFixed(2)} kilogram/ha', + ), + // Add RC Ratio & BC Ratio if (totalCost > 0) ...[ - _buildInfoRowForHarvest('R/C Ratio', (income / totalCost).toStringAsFixed(2)), - _buildInfoRowForHarvest('B/C Ratio', (profit / totalCost).toStringAsFixed(2)), + _buildInfoRowForHarvest( + 'R/C Ratio', + (income / totalCost).toStringAsFixed(2), + ), + _buildInfoRowForHarvest( + 'B/C Ratio', + (profit / totalCost).toStringAsFixed(2), + ), ], ], ), ), - + // If chart image is available, add it to the PDF if (chartImageBytes != null) ...[ pw.SizedBox(height: 15), @@ -209,9 +332,9 @@ class HarvestPdfGenerator { ), ), ], - + pw.SizedBox(height: 15), - + // Rincian Biaya pw.Container( padding: const pw.EdgeInsets.all(10), @@ -230,19 +353,470 @@ class HarvestPdfGenerator { ), ), pw.Divider(), - _buildInfoRowForHarvest('Bibit', currency.format(_safeToDouble(harvestData['seed_cost']))), - _buildInfoRowForHarvest('Pupuk', currency.format(_safeToDouble(harvestData['fertilizer_cost']))), - _buildInfoRowForHarvest('Pestisida', currency.format(_safeToDouble(harvestData['pesticide_cost']))), - _buildInfoRowForHarvest('Tenaga Kerja', currency.format(_safeToDouble(harvestData['labor_cost']))), - _buildInfoRowForHarvest('Irigasi', currency.format(_safeToDouble(harvestData['irrigation_cost']))), + _buildInfoRowForHarvest( + 'Bibit', + currency.format(_safeToDouble(harvestData['seed_cost'])), + ), + _buildInfoRowForHarvest( + 'Pupuk', + currency.format( + _safeToDouble(harvestData['fertilizer_cost']), + ), + ), + _buildInfoRowForHarvest( + 'Pestisida', + currency.format( + _safeToDouble(harvestData['pesticide_cost']), + ), + ), + _buildInfoRowForHarvest( + 'Tenaga Kerja', + currency.format(_safeToDouble(harvestData['labor_cost'])), + ), + _buildInfoRowForHarvest( + 'Irigasi', + currency.format( + _safeToDouble(harvestData['irrigation_cost']), + ), + ), pw.Divider(), - _buildInfoRowForHarvest('Total', currency.format(totalCost), isBold: true), + _buildInfoRowForHarvest( + 'Total', + currency.format(totalCost), + isBold: true, + ), ], ), ), - + + // 1. RINGKASAN & BENCHMARK + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'RINGKASAN & BENCHMARK', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + _buildInfoRowForHarvest( + 'Total Panen', + '${quantity.toStringAsFixed(2)} kg', + ), + _buildInfoRowForHarvest( + 'Produktivitas', + '${productivity.toStringAsFixed(2)} kg/ha', + ), + _buildInfoRowForHarvest( + 'Harga Jual', + '${currency.format(pricePerKg)}/kg', + ), + _buildInfoRowForHarvest( + 'Pendapatan', + currency.format(income), + ), + _buildInfoRowForHarvest( + 'Keuntungan', + currency.format(profit), + ), + _buildInfoRowForHarvest( + 'Margin Keuntungan', + '${profitMargin.toStringAsFixed(2)}%', + ), + _buildInfoRowForHarvest( + 'R/C Ratio', + rcRatio.toStringAsFixed(2), + ), + _buildInfoRowForHarvest( + 'B/C Ratio', + bcRatio.toStringAsFixed(2), + ), + _buildInfoRowForHarvest('ROI', '${roi.toStringAsFixed(2)}%'), + _buildInfoRowForHarvest('Status', status), + pw.SizedBox(height: 8), + pw.Text( + 'Benchmark Panen:', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + pw.Bullet( + text: + 'Produktivitas: ${productivity.toStringAsFixed(2)} kg/ha', + ), + pw.Bullet(text: 'R/C Ratio: ${rcRatio.toStringAsFixed(2)}'), + pw.Bullet(text: 'B/C Ratio: ${bcRatio.toStringAsFixed(2)}'), + pw.Bullet(text: 'ROI: ${roi.toStringAsFixed(2)}%'), + ], + ), + ), + + // 2. KOMPOSISI BIAYA (PIE CHART) + if (costBreakdown.isNotEmpty) ...[ + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'KOMPOSISI BIAYA (PIE CHART)', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + _buildPieChart(costBreakdown), + pw.SizedBox(height: 8), + ...costBreakdown.map( + (item) => _buildInfoRowForHarvest( + item['name'].toString(), + currency.format(_safeToDouble(item['cost'])), + ), + ), + ], + ), + ), + ], + + // 3. PERBANDINGAN KEUANGAN (BAR CHART) + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'PERBANDINGAN KEUANGAN (BAR CHART)', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + _buildBarChart(totalCost, income, profit), + pw.SizedBox(height: 8), + _buildInfoRowForHarvest( + 'Total Biaya', + currency.format(totalCost), + ), + _buildInfoRowForHarvest( + 'Pendapatan', + currency.format(income), + ), + _buildInfoRowForHarvest( + 'Keuntungan', + currency.format(profit), + ), + ], + ), + ), + + // 4. TREN PENGELUARAN HARIAN (LINE CHART) + if (dailyLogs != null && dailyLogs.isNotEmpty) ...[ + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'TREN PENGELUARAN HARIAN (LINE CHART)', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + _buildLineChart(dailyLogs), + pw.SizedBox(height: 8), + _buildExpenseTrendSummary(dailyLogs), + ], + ), + ), + ], + + // Analisis Profitabilitas + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'ANALISIS PROFITABILITAS', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text( + _getProfitabilityAnalysis( + cropName, + profit, + totalCost, + income, + profitMargin, + productivity, + ), + ), + ], + ), + ), + + // Analisis RC/BC/ROI + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'ANALISIS RASIO KEUANGAN (RC/BC/ROI)', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text( + _getRatioAnalysis( + rcRatio.toDouble(), + bcRatio.toDouble(), + roi.toDouble(), + cropName, + ), + ), + ], + ), + ), + + // Analisis BEP Harga & Produksi + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'ANALISIS BREAK EVEN POINT (BEP)', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text( + _getBepAnalysis( + bepPrice, + pricePerKg, + bepProduction, + quantity, + cropName, + ), + ), + ], + ), + ), + + // Analisis Produktivitas + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'ANALISIS PRODUKTIVITAS', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text(_getProductivityAnalysis(productivity, cropName)), + ], + ), + ), + + // Analisis Struktur Biaya + if (costBreakdown.isNotEmpty) + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'ANALISIS STRUKTUR BIAYA', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text(_getCostStructureAnalysis(costBreakdown, cropName)), + ], + ), + ), + + // Analisis Tren Pengeluaran Harian + if (dailyLogs != null && dailyLogs.length > 1) + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'ANALISIS TREN PENGELUARAN HARIAN', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text(_getExpenseTrendAnalysis(dailyLogs)), + ], + ), + ), + + // Section: LAPORAN DETAIL DATA + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'LAPORAN DETAIL DATA', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text('Semua variabel dari harvestData:'), + pw.SizedBox(height: 6), + ...harvestData.entries.map( + (e) => pw.Text('${e.key}: ${e.value}'), + ), + if (scheduleData != null) ...[ + pw.SizedBox(height: 12), + pw.Text('Semua variabel dari scheduleData:'), + pw.SizedBox(height: 6), + ...scheduleData.entries.map( + (e) => pw.Text('${e.key}: ${e.value}'), + ), + ], + ], + ), + ), + + // Section: RIWAYAT DATA MENTAH CATATAN HARIAN + if (dailyLogs != null && dailyLogs.isNotEmpty) + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(), + borderRadius: pw.BorderRadius.circular(5), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'RIWAYAT DATA MENTAH CATATAN HARIAN', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Divider(), + pw.Text('Seluruh data mentah dari dailyLogs:'), + pw.SizedBox(height: 6), + pw.Table( + border: pw.TableBorder.all( + width: 0.5, + color: PdfColors.grey400, + ), + children: [ + // Header + pw.TableRow( + children: [ + ...dailyLogs.first.keys.map( + (k) => pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + k.toString(), + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + ), + ), + ), + ), + ], + ), + // Data rows + ...dailyLogs.map( + (row) => pw.TableRow( + children: [ + ...row.values.map( + (v) => pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + v != null ? v.toString() : '-', + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + pw.SizedBox(height: 15), - + // Analisis dan Rekomendasi pw.Container( padding: const pw.EdgeInsets.all(10), @@ -265,20 +839,18 @@ class HarvestPdfGenerator { pw.SizedBox(height: 10), pw.Text( 'Rekomendasi:', - style: pw.TextStyle( - fontWeight: pw.FontWeight.bold, - ), + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), ), pw.SizedBox(height: 5), pw.Text(_getRecommendation(status)), ], ), ), - + // Jika ada catatan harian, tambahkan tabel if (dailyLogs != null && dailyLogs.isNotEmpty) ...[ pw.SizedBox(height: 15), - + pw.Text( 'CATATAN HARIAN', style: pw.TextStyle( @@ -287,7 +859,7 @@ class HarvestPdfGenerator { ), ), pw.SizedBox(height: 5), - + pw.Table( border: pw.TableBorder.all(), children: [ @@ -308,15 +880,17 @@ class HarvestPdfGenerator { } catch (e) { dateStr = '-'; } - + return pw.TableRow( children: [ _buildTableCell(dateStr), _buildTableCell(_safeToString(log['note'] ?? '-')), - _buildTableCell(currency.format(_safeToDouble(log['cost']))), + _buildTableCell( + currency.format(_safeToDouble(log['cost'])), + ), ], ); - }).toList(), + }), ], ), ], @@ -333,29 +907,31 @@ class HarvestPdfGenerator { debugPrint('PDF saved to: $filePath'); return file; } - - pw.Widget _buildInfoRowForHarvest(String label, String value, {bool isBold = false}) { + + pw.Widget _buildInfoRowForHarvest( + String label, + String value, { + bool isBold = false, + }) { return pw.Padding( padding: const pw.EdgeInsets.symmetric(vertical: 3), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.SizedBox( - width: 150, - child: pw.Text(label), - ), + pw.SizedBox(width: 150, child: pw.Text(label)), pw.Text(': '), pw.Expanded( child: pw.Text( value, - style: isBold ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null, + style: + isBold ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null, ), ), ], ), ); } - + pw.Widget _buildTableCell(String text, {bool isHeader = false}) { return pw.Padding( padding: const pw.EdgeInsets.all(5), @@ -365,7 +941,7 @@ class HarvestPdfGenerator { ), ); } - + String _formatDate(String? dateStr) { if (dateStr == null || dateStr.isEmpty) return '-'; try { @@ -375,7 +951,7 @@ class HarvestPdfGenerator { return dateStr; // Kembalikan string asli jika parsing gagal } } - + // Fungsi untuk mengamankan konversi nilai ke string String _safeToString(dynamic value, {String defaultValue = '-'}) { if (value == null) return defaultValue; @@ -383,7 +959,7 @@ class HarvestPdfGenerator { if (value is String && value.isEmpty) return defaultValue; return value.toString(); } - + // Fungsi untuk mengamankan konversi nilai ke double double _safeToDouble(dynamic value, {double defaultValue = 0.0}) { if (value == null) return defaultValue; @@ -398,53 +974,60 @@ class HarvestPdfGenerator { } return defaultValue; } - + String _getStatusDescription(String? status) { switch (status) { - case 'Baik': + case 'Baik': return 'Produktivitas dan profitabilitas optimal'; - case 'Cukup': + case 'Cukup': return 'Performa yang cukup baik, masih dapat ditingkatkan'; - case 'Kurang': + case 'Kurang': return 'Produktivitas dan profitabilitas perlu ditingkatkan'; - default: + default: return ''; } } - + String _getAnalysisText(double productivity, double profitMargin) { String productivityText; if (productivity > 8000) { - productivityText = 'Produktivitas lahan sangat tinggi (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang sangat baik.'; + productivityText = + 'Produktivitas lahan sangat tinggi (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang sangat baik.'; } else if (productivity > 5000) { - productivityText = 'Produktivitas lahan baik (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang efektif.'; + productivityText = + 'Produktivitas lahan baik (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang efektif.'; } else { - productivityText = 'Produktivitas lahan kurang optimal (${productivity.toStringAsFixed(2)} kilogram/ha), ada ruang untuk peningkatan praktik budidaya.'; + productivityText = + 'Produktivitas lahan kurang optimal (${productivity.toStringAsFixed(2)} kilogram/ha), ada ruang untuk peningkatan praktik budidaya.'; } - + String profitText; if (profitMargin >= 30) { - profitText = 'Rasio keuntungan sangat baik (${profitMargin.toStringAsFixed(2)}%), menunjukkan efisiensi biaya produksi yang tinggi.'; + profitText = + 'Rasio keuntungan sangat baik (${profitMargin.toStringAsFixed(2)}%), menunjukkan efisiensi biaya produksi yang tinggi.'; } else if (profitMargin >= 15) { - profitText = 'Rasio keuntungan cukup baik (${profitMargin.toStringAsFixed(2)}%), namun masih ada ruang untuk peningkatan efisiensi.'; + profitText = + 'Rasio keuntungan cukup baik (${profitMargin.toStringAsFixed(2)}%), namun masih ada ruang untuk peningkatan efisiensi.'; } else if (profitMargin > 0) { - profitText = 'Rasio keuntungan minimal (${profitMargin.toStringAsFixed(2)}%), perlu evaluasi struktur biaya produksi.'; + profitText = + 'Rasio keuntungan minimal (${profitMargin.toStringAsFixed(2)}%), perlu evaluasi struktur biaya produksi.'; } else { - profitText = 'Mengalami kerugian dengan rasio (${profitMargin.toStringAsFixed(2)}%), memerlukan perubahan signifikan pada struktur biaya dan teknik produksi.'; + profitText = + 'Mengalami kerugian dengan rasio (${profitMargin.toStringAsFixed(2)}%), memerlukan perubahan signifikan pada struktur biaya dan teknik produksi.'; } - + return '$productivityText\n\n$profitText'; } - + String _getRecommendation(String? status) { switch (status) { - case 'Baik': + case 'Baik': return 'Pertahankan praktik pertanian yang sudah baik. Pertimbangkan untuk memperluas area tanam atau mencoba varietas unggulan untuk meningkatkan keuntungan lebih lanjut.'; - case 'Cukup': + case 'Cukup': return 'Tingkatkan efisiensi biaya produksi, terutama pada komponen biaya terbesar. Pertimbangkan untuk mengoptimalkan penggunaan pupuk dan pestisida agar lebih tepat sasaran.'; - case 'Kurang': + case 'Kurang': return 'Evaluasi ulang teknik budidaya yang diterapkan. Pastikan pemilihan varietas yang tepat, perbaiki teknik pemupukan, dan kendalikan hama penyakit secara terpadu untuk meningkatkan produktivitas.'; - default: + default: return 'Belum dapat memberikan rekomendasi spesifik.'; } } @@ -454,24 +1037,25 @@ class HarvestPdfGenerator { try { // Try using open_file package first final result = await OpenFile.open(file.path); - + if (result.type != ResultType.done) { throw Exception('Tidak dapat membuka file: ${result.message}'); } } catch (e) { // If open_file fails, try an alternative approach debugPrint('Error opening PDF with OpenFile: $e'); - throw Exception('Gagal membuka PDF. Silakan coba bagikan file dan buka dengan aplikasi PDF lain.'); + throw Exception( + 'Gagal membuka PDF. Silakan coba bagikan file dan buka dengan aplikasi PDF lain.', + ); } } - + /// Bagikan file PDF Future sharePdf(File file) async { try { - await Share.shareXFiles( - [XFile(file.path)], - text: 'Laporan Analisis Panen', - ); + await Share.shareXFiles([ + XFile(file.path), + ], text: 'Laporan Analisis Panen'); } catch (e) { debugPrint('Error sharing PDF: $e'); throw Exception('Gagal membagikan PDF. Silakan coba lagi nanti.'); @@ -484,15 +1068,37 @@ class HarvestPdfGenerator { }) async { final pdf = pw.Document(); final now = DateTime.now(); - final formattedDate = DateFormat('dd MMMM yyyy, HH:mm', 'id_ID').format(now); - final fileName = 'laporan_diagnosis_${DateFormat('yyyyMMdd_HHmmss').format(now)}.pdf'; + final formattedDate = DateFormat( + 'dd MMMM yyyy, HH:mm', + 'id_ID', + ).format(now); + final fileName = + 'laporan_diagnosis_${DateFormat('yyyyMMdd_HHmmss').format(now)}.pdf'; - final pw.TextStyle headingStyle = pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.green800); - final pw.TextStyle subheadingStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600); - final pw.TextStyle sectionTitleStyle = pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.green700); + final pw.TextStyle headingStyle = pw.TextStyle( + fontSize: 22, + fontWeight: pw.FontWeight.bold, + color: PdfColors.green800, + ); + final pw.TextStyle subheadingStyle = pw.TextStyle( + fontSize: 12, + color: PdfColors.grey600, + ); + final pw.TextStyle sectionTitleStyle = pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + color: PdfColors.green700, + ); final pw.TextStyle boldStyle = pw.TextStyle(fontWeight: pw.FontWeight.bold); - - final plantImage = imageBytes != null ? pw.Image(pw.MemoryImage(imageBytes), fit: pw.BoxFit.contain, height: 150) : pw.Container(); + + final plantImage = + imageBytes != null + ? pw.Image( + pw.MemoryImage(imageBytes), + fit: pw.BoxFit.contain, + height: 150, + ) + : pw.Container(); pdf.addPage( pw.MultiPage( @@ -510,10 +1116,10 @@ class HarvestPdfGenerator { children: [ pw.Text('Laporan Diagnosis Tanaman', style: headingStyle), pw.Text(formattedDate, style: subheadingStyle), - ] + ], ), pw.Divider(height: 20, thickness: 1.5, color: PdfColors.green800), - ] + ], ); }, footer: (pw.Context context) { @@ -524,77 +1130,172 @@ class HarvestPdfGenerator { ), ); }, - build: (pw.Context context) => [ - // Plant Image - if (imageBytes != null) - pw.Center( - child: pw.Container( - margin: const pw.EdgeInsets.only(bottom: 20), - padding: const pw.EdgeInsets.all(5), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey300, width: 1), - borderRadius: pw.BorderRadius.circular(5), + build: + (pw.Context context) => [ + // Plant Image + if (imageBytes != null) + pw.Center( + child: pw.Container( + margin: const pw.EdgeInsets.only(bottom: 20), + padding: const pw.EdgeInsets.all(5), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey300, width: 1), + borderRadius: pw.BorderRadius.circular(5), + ), + child: plantImage, + ), + ), + + // Plant Identification + _buildDiagnosisSectionTitle('Identifikasi Tanaman'), + _buildDiagnosisInfoRow( + 'Spesies Tanaman', + _safeToString(diagnosisResult.plantSpecies), + ), + _buildDiagnosisInfoRow( + 'Tahap Pertumbuhan', + _safeToString(diagnosisResult.plantData['growthStage']), + ), + _buildDiagnosisInfoRow( + 'Status Kesehatan', + diagnosisResult.isHealthy + ? 'Sehat' + : 'Tidak Sehat / Terindikasi Penyakit', + valueStyle: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + color: + diagnosisResult.isHealthy + ? PdfColors.green600 + : PdfColors.orange600, ), - child: plantImage, ), - ), - - // Plant Identification - _buildDiagnosisSectionTitle('Identifikasi Tanaman'), - _buildDiagnosisInfoRow('Spesies Tanaman', _safeToString(diagnosisResult.plantSpecies)), - _buildDiagnosisInfoRow('Tahap Pertumbuhan', _safeToString(diagnosisResult.plantData['growthStage'])), - _buildDiagnosisInfoRow('Status Kesehatan', diagnosisResult.isHealthy ? 'Sehat' : 'Tidak Sehat / Terindikasi Penyakit', - valueStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: diagnosisResult.isHealthy ? PdfColors.green600 : PdfColors.orange600) - ), - // Diagnosis Details (if not healthy) - if (!diagnosisResult.isHealthy) ...[ - _buildDiagnosisSectionTitle('Detail Diagnosis Penyakit'), - _buildDiagnosisInfoRow('Nama Penyakit', _safeToString(diagnosisResult.diseaseName)), - _buildDiagnosisInfoRow('Nama Ilmiah', _safeToString(diagnosisResult.scientificName), valueStyle: pw.TextStyle(fontStyle: pw.FontStyle.italic)), - _buildDiagnosisInfoRow('Bagian Terdampak', _safeToString(diagnosisResult.additionalInfo.affectedParts.join(', '))), - _buildDiagnosisInfoRow('Kondisi Lingkungan Pemicu', _safeToString(diagnosisResult.additionalInfo.environmentalConditions)), - - if (diagnosisResult.plantData['diseaseSeverity'] != null || diagnosisResult.plantData['infectedArea'] != null) - _buildDiagnosisSectionTitle('Tingkat Keparahan & Dampak', fontSize: 14), - if (diagnosisResult.plantData['diseaseSeverity'] != null) - _buildDiagnosisInfoRow('Tingkat Keparahan', '${(_safeToDouble(diagnosisResult.plantData['diseaseSeverity']) * 100).toStringAsFixed(0)}%'), - if (diagnosisResult.plantData['infectedArea'] != null) - _buildDiagnosisInfoRow('Estimasi Area Terinfeksi', '${(_safeToDouble(diagnosisResult.plantData['infectedArea']) * 100).toStringAsFixed(0)}%'), - if (diagnosisResult.economicImpact['estimatedLoss'] != null && _safeToString(diagnosisResult.economicImpact['estimatedLoss']).isNotEmpty) - _buildDiagnosisInfoRow('Potensi Kerugian Ekonomi', _safeToString(diagnosisResult.economicImpact['estimatedLoss'])), + // Diagnosis Details (if not healthy) + if (!diagnosisResult.isHealthy) ...[ + _buildDiagnosisSectionTitle('Detail Diagnosis Penyakit'), + _buildDiagnosisInfoRow( + 'Nama Penyakit', + _safeToString(diagnosisResult.diseaseName), + ), + _buildDiagnosisInfoRow( + 'Nama Ilmiah', + _safeToString(diagnosisResult.scientificName), + valueStyle: pw.TextStyle(fontStyle: pw.FontStyle.italic), + ), + _buildDiagnosisInfoRow( + 'Bagian Terdampak', + _safeToString( + diagnosisResult.additionalInfo.affectedParts.join(', '), + ), + ), + _buildDiagnosisInfoRow( + 'Kondisi Lingkungan Pemicu', + _safeToString( + diagnosisResult.additionalInfo.environmentalConditions, + ), + ), - _buildDiagnosisListSection('Gejala yang Teramati', _safeToString(diagnosisResult.symptoms).split('\n')), - _buildDiagnosisListSection('Kemungkinan Penyebab', _safeToString(diagnosisResult.causes).split('\n')), - ], + if (diagnosisResult.plantData['diseaseSeverity'] != null || + diagnosisResult.plantData['infectedArea'] != null) + _buildDiagnosisSectionTitle( + 'Tingkat Keparahan & Dampak', + fontSize: 14, + ), + if (diagnosisResult.plantData['diseaseSeverity'] != null) + _buildDiagnosisInfoRow( + 'Tingkat Keparahan', + '${(_safeToDouble(diagnosisResult.plantData['diseaseSeverity']) * 100).toStringAsFixed(0)}%', + ), + if (diagnosisResult.plantData['infectedArea'] != null) + _buildDiagnosisInfoRow( + 'Estimasi Area Terinfeksi', + '${(_safeToDouble(diagnosisResult.plantData['infectedArea']) * 100).toStringAsFixed(0)}%', + ), + if (diagnosisResult.economicImpact['estimatedLoss'] != null && + _safeToString( + diagnosisResult.economicImpact['estimatedLoss'], + ).isNotEmpty) + _buildDiagnosisInfoRow( + 'Potensi Kerugian Ekonomi', + _safeToString( + diagnosisResult.economicImpact['estimatedLoss'], + ), + ), - // Treatment and Prevention (if not healthy) - if (!diagnosisResult.isHealthy) ...[ - _buildDiagnosisSectionTitle('Rekomendasi Penanganan & Pencegahan'), - _buildDiagnosisListSection('Penanganan Organik', _safeToString(diagnosisResult.organicTreatment).split('\n')), - _buildDiagnosisListSection('Penanganan Kimiawi', _safeToString(diagnosisResult.chemicalTreatment).split('\n')), - _buildDiagnosisListSection('Langkah Pencegahan', _safeToString(diagnosisResult.preventionMeasures).split('\n')), - ], + _buildDiagnosisListSection( + 'Gejala yang Teramati', + _safeToString(diagnosisResult.symptoms).split('\n'), + ), + _buildDiagnosisListSection( + 'Kemungkinan Penyebab', + _safeToString(diagnosisResult.causes).split('\n'), + ), + ], - // Environmental Data - if (diagnosisResult.environmentalData.isNotEmpty && - diagnosisResult.environmentalData.values.any((v) => _safeToDouble(v) != 0.0)) ...[ - _buildDiagnosisSectionTitle('Data Lingkungan Saat Pengambilan Gambar'), - if (_safeToDouble(diagnosisResult.environmentalData['temperature']) != 0.0) - _buildDiagnosisInfoRow('Suhu Udara', '${_safeToDouble(diagnosisResult.environmentalData['temperature']).toStringAsFixed(1)} °C'), - if (_safeToDouble(diagnosisResult.environmentalData['humidity']) != 0.0) - _buildDiagnosisInfoRow('Kelembaban Udara', '${_safeToDouble(diagnosisResult.environmentalData['humidity']).toStringAsFixed(0)} %'), - if (_safeToDouble(diagnosisResult.environmentalData['lightIntensity']) != 0.0) - _buildDiagnosisInfoRow('Intensitas Cahaya', '${_safeToDouble(diagnosisResult.environmentalData['lightIntensity']).toStringAsFixed(0)} lux'), - ], - - pw.SizedBox(height: 30), - pw.Text( - 'Catatan: Laporan ini dihasilkan berdasarkan analisis gambar dan data yang diberikan. Validasi lapangan oleh ahli pertanian mungkin diperlukan untuk diagnosis yang lebih akurat dan tindakan yang lebih tepat.', - style: pw.TextStyle(fontSize: 9, fontStyle: pw.FontStyle.italic, color: PdfColors.grey700), - textAlign: pw.TextAlign.justify, - ), - ], + // Treatment and Prevention (if not healthy) + if (!diagnosisResult.isHealthy) ...[ + _buildDiagnosisSectionTitle( + 'Rekomendasi Penanganan & Pencegahan', + ), + _buildDiagnosisListSection( + 'Penanganan Organik', + _safeToString(diagnosisResult.organicTreatment).split('\n'), + ), + _buildDiagnosisListSection( + 'Penanganan Kimiawi', + _safeToString(diagnosisResult.chemicalTreatment).split('\n'), + ), + _buildDiagnosisListSection( + 'Langkah Pencegahan', + _safeToString(diagnosisResult.preventionMeasures).split('\n'), + ), + ], + + // Environmental Data + if (diagnosisResult.environmentalData.isNotEmpty && + diagnosisResult.environmentalData.values.any( + (v) => _safeToDouble(v) != 0.0, + )) ...[ + _buildDiagnosisSectionTitle( + 'Data Lingkungan Saat Pengambilan Gambar', + ), + if (_safeToDouble( + diagnosisResult.environmentalData['temperature'], + ) != + 0.0) + _buildDiagnosisInfoRow( + 'Suhu Udara', + '${_safeToDouble(diagnosisResult.environmentalData['temperature']).toStringAsFixed(1)} °C', + ), + if (_safeToDouble( + diagnosisResult.environmentalData['humidity'], + ) != + 0.0) + _buildDiagnosisInfoRow( + 'Kelembaban Udara', + '${_safeToDouble(diagnosisResult.environmentalData['humidity']).toStringAsFixed(0)} %', + ), + if (_safeToDouble( + diagnosisResult.environmentalData['lightIntensity'], + ) != + 0.0) + _buildDiagnosisInfoRow( + 'Intensitas Cahaya', + '${_safeToDouble(diagnosisResult.environmentalData['lightIntensity']).toStringAsFixed(0)} lux', + ), + ], + + pw.SizedBox(height: 30), + pw.Text( + 'Catatan: Laporan ini dihasilkan berdasarkan analisis gambar dan data yang diberikan. Validasi lapangan oleh ahli pertanian mungkin diperlukan untuk diagnosis yang lebih akurat dan tindakan yang lebih tepat.', + style: pw.TextStyle( + fontSize: 9, + fontStyle: pw.FontStyle.italic, + color: PdfColors.grey700, + ), + textAlign: pw.TextAlign.justify, + ), + ], ), ); @@ -603,32 +1304,65 @@ class HarvestPdfGenerator { return savePdfFile(fileName, bytes); } - pw.Widget _buildDiagnosisSectionTitle(String title, {PdfColor color = PdfColors.green800, double fontSize = 16}) { + pw.Widget _buildDiagnosisSectionTitle( + String title, { + PdfColor color = PdfColors.green800, + double fontSize = 16, + }) { return pw.Padding( padding: const pw.EdgeInsets.only(top: 15, bottom: 8), child: pw.Text( title.toUpperCase(), - style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: fontSize, color: color), + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: fontSize, + color: color, + ), ), ); } - pw.Widget _buildDiagnosisInfoRow(String label, String value, {bool isBoldValue = false, pw.TextStyle? valueStyle}) { + pw.Widget _buildDiagnosisInfoRow( + String label, + String value, { + bool isBoldValue = false, + pw.TextStyle? valueStyle, + }) { return pw.Padding( padding: const pw.EdgeInsets.symmetric(vertical: 2.5), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.SizedBox(width: 130, child: pw.Text(label, style: pw.TextStyle(fontWeight: pw.FontWeight.bold))), + pw.SizedBox( + width: 130, + child: pw.Text( + label, + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), pw.Text(': ', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), - pw.Expanded(child: pw.Text(value, style: valueStyle ?? (isBoldValue ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null))), + pw.Expanded( + child: pw.Text( + value, + style: + valueStyle ?? + (isBoldValue + ? pw.TextStyle(fontWeight: pw.FontWeight.bold) + : null), + ), + ), ], ), ); } - - pw.Widget _buildDiagnosisListSection(String title, List items, {PdfColor iconColor = PdfColors.green700}) { - if (items.isEmpty || items.every((item) => item.trim().isEmpty || item == '-')) { + + pw.Widget _buildDiagnosisListSection( + String title, + List items, { + PdfColor iconColor = PdfColors.green700, + }) { + if (items.isEmpty || + items.every((item) => item.trim().isEmpty || item == '-')) { return pw.Container(); } return pw.Column( @@ -636,17 +1370,361 @@ class HarvestPdfGenerator { children: [ _buildDiagnosisSectionTitle(title, fontSize: 14), pw.SizedBox(height: 4), - ...items.map((item) => pw.Padding( - padding: const pw.EdgeInsets.only(left: 10, bottom: 4), - child: pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text('• ', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: iconColor)), - pw.Expanded(child: pw.Text(item)), - ], + ...items.map( + (item) => pw.Padding( + padding: const pw.EdgeInsets.only(left: 10, bottom: 4), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + '• ', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + color: iconColor, + ), + ), + pw.Expanded(child: pw.Text(item)), + ], + ), ), - )).toList(), + ), ], ); } -} \ No newline at end of file + + String _getProfitabilityAnalysis( + String cropName, + double profit, + double totalCost, + double income, + double profitMargin, + double productivity, + ) { + String productivityText; + if (productivity > 8000) { + productivityText = + 'Produktivitas lahan sangat tinggi (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang sangat baik.'; + } else if (productivity > 5000) { + productivityText = + 'Produktivitas lahan baik (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang efektif.'; + } else { + productivityText = + 'Produktivitas lahan kurang optimal (${productivity.toStringAsFixed(2)} kilogram/ha), ada ruang untuk peningkatan praktik budidaya.'; + } + + String profitText; + if (profitMargin >= 30) { + profitText = + 'Rasio keuntungan sangat baik (${profitMargin.toStringAsFixed(2)}%), menunjukkan efisiensi biaya produksi yang tinggi.'; + } else if (profitMargin >= 15) { + profitText = + 'Rasio keuntungan cukup baik (${profitMargin.toStringAsFixed(2)}%), namun masih ada ruang untuk peningkatan efisiensi.'; + } else if (profitMargin > 0) { + profitText = + 'Rasio keuntungan minimal (${profitMargin.toStringAsFixed(2)}%), perlu evaluasi struktur biaya produksi.'; + } else { + profitText = + 'Mengalami kerugian dengan rasio (${profitMargin.toStringAsFixed(2)}%), memerlukan perubahan signifikan pada struktur biaya dan teknik produksi.'; + } + + return '$productivityText\n\n$profitText'; + } + + String _getRatioAnalysis( + double rcRatio, + double bcRatio, + double roi, + String cropName, + ) { + String rcRatioText; + if (rcRatio > 1.5) { + rcRatioText = + 'R/C Ratio (${rcRatio.toStringAsFixed(2)}) sangat baik, menunjukkan bahwa pendapatan lebih besar dari biaya produksi. Ini menunjukkan efisiensi yang sangat baik dalam pengelolaan lahan $cropName.'; + } else if (rcRatio > 1.0) { + rcRatioText = + 'R/C Ratio (${rcRatio.toStringAsFixed(2)}) cukup baik, namun masih ada ruang untuk peningkatan efisiensi. Pendapatan hampir sama dengan biaya produksi.'; + } else { + rcRatioText = + 'R/C Ratio (${rcRatio.toStringAsFixed(2)}) kurang baik, menunjukkan bahwa biaya produksi lebih besar dari pendapatan. Ini menunjukkan adanya masalah dalam efisiensi pengelolaan lahan $cropName.'; + } + + String bcRatioText; + if (bcRatio > 1.5) { + bcRatioText = + 'B/C Ratio (${bcRatio.toStringAsFixed(2)}) sangat baik, menunjukkan bahwa keuntungan lebih besar dari biaya produksi. Ini menunjukkan efisiensi yang sangat baik dalam pengelolaan lahan $cropName.'; + } else if (bcRatio > 1.0) { + bcRatioText = + 'B/C Ratio (${bcRatio.toStringAsFixed(2)}) cukup baik, namun masih ada ruang untuk peningkatan efisiensi. Keuntungan hampir sama dengan biaya produksi.'; + } else { + bcRatioText = + 'B/C Ratio (${bcRatio.toStringAsFixed(2)}) kurang baik, menunjukkan bahwa biaya produksi lebih besar dari keuntungan. Ini menunjukkan adanya masalah dalam efisiensi pengelolaan lahan $cropName.'; + } + + String roiText; + if (roi > 100) { + roiText = + 'ROI (${roi.toStringAsFixed(2)}%) sangat baik, menunjukkan bahwa investasi dalam lahan $cropName menghasilkan keuntungan yang sangat besar.'; + } else if (roi > 50) { + roiText = + 'ROI (${roi.toStringAsFixed(2)}%) cukup baik, namun masih ada ruang untuk peningkatan efisiensi. Investasi menghasilkan keuntungan yang cukup besar.'; + } else { + roiText = + 'ROI (${roi.toStringAsFixed(2)}%) kurang baik, menunjukkan bahwa investasi dalam lahan $cropName menghasilkan keuntungan yang rendah. Ini menunjukkan adanya masalah dalam efisiensi pengelolaan lahan $cropName.'; + } + + return '$rcRatioText\n\n$bcRatioText\n\n$roiText'; + } + + String _getBepAnalysis( + double bepPrice, + double pricePerKg, + double bepProduction, + double quantity, + String cropName, + ) { + String bepPriceText; + if (pricePerKg < bepPrice) { + bepPriceText = + 'Harga per kilogram (${pricePerKg.toStringAsFixed(2)}) lebih rendah dari BEP (${bepPrice.toStringAsFixed(2)}). Ini menunjukkan bahwa harga jual $cropName saat ini lebih rendah dari harga yang diperlukan untuk mencapai titik impas. Pertimbangkan untuk meningkatkan harga jual atau mengoptimalkan biaya produksi.'; + } else if (pricePerKg > bepPrice) { + bepPriceText = + 'Harga per kilogram (${pricePerKg.toStringAsFixed(2)}) lebih tinggi dari BEP (${bepPrice.toStringAsFixed(2)}). Ini menunjukkan bahwa harga jual $cropName saat ini lebih tinggi dari harga yang diperlukan untuk mencapai titik impas. Pertimbangkan untuk mempertahankan harga jual atau mengoptimalkan biaya produksi.'; + } else { + bepPriceText = + 'Harga per kilogram (${pricePerKg.toStringAsFixed(2)}) sama dengan BEP (${bepPrice.toStringAsFixed(2)}). Ini menunjukkan bahwa harga jual $cropName saat ini sudah mencapai titik impas. Pertimbangkan untuk mempertahankan harga jual atau mengoptimalkan biaya produksi.'; + } + + String bepProductionText; + if (quantity < bepProduction) { + bepProductionText = + 'Produksi (${quantity.toStringAsFixed(2)} kilogram) lebih rendah dari BEP (${bepProduction.toStringAsFixed(2)} kilogram). Ini menunjukkan bahwa produksi $cropName saat ini lebih rendah dari produksi yang diperlukan untuk mencapai titik impas. Pertimbangkan untuk meningkatkan produksi atau mengoptimalkan biaya produksi.'; + } else if (quantity > bepProduction) { + bepProductionText = + 'Produksi (${quantity.toStringAsFixed(2)} kilogram) lebih tinggi dari BEP (${bepProduction.toStringAsFixed(2)} kilogram). Ini menunjukkan bahwa produksi $cropName saat ini lebih tinggi dari produksi yang diperlukan untuk mencapai titik impas. Pertimbangkan untuk mempertahankan produksi atau mengoptimalkan biaya produksi.'; + } else { + bepProductionText = + 'Produksi (${quantity.toStringAsFixed(2)} kilogram) sama dengan BEP (${bepProduction.toStringAsFixed(2)} kilogram). Ini menunjukkan bahwa produksi $cropName saat ini sudah mencapai titik impas. Pertimbangkan untuk mempertahankan produksi atau mengoptimalkan biaya produksi.'; + } + + return '$bepPriceText\n\n$bepProductionText'; + } + + String _getProductivityAnalysis(double productivity, String cropName) { + String productivityText; + if (productivity > 8000) { + productivityText = + 'Produktivitas lahan sangat tinggi (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang sangat baik.'; + } else if (productivity > 5000) { + productivityText = + 'Produktivitas lahan baik (${productivity.toStringAsFixed(2)} kilogram/ha), menunjukkan praktik budidaya yang efektif.'; + } else { + productivityText = + 'Produktivitas lahan kurang optimal (${productivity.toStringAsFixed(2)} kilogram/ha), ada ruang untuk peningkatan praktik budidaya.'; + } + + return productivityText; + } + + String _getCostStructureAnalysis( + List> costBreakdown, + String cropName, + ) { + String costStructureText = + 'Struktur biaya untuk lahan $cropName menunjukkan bahwa biaya produksi terdiri dari beberapa komponen. '; + if (costBreakdown.length == 1) { + costStructureText += + 'Hanya ada satu komponen biaya, yaitu ${costBreakdown.first['name']}. Ini menunjukkan bahwa pengelolaan lahan $cropName sangat sederhana dan efisien.'; + } else { + costStructureText += 'Beberapa komponen biaya dominan, termasuk:'; + for (var item in costBreakdown) { + costStructureText += + '\n- ${item['name']} (${item['cost'].toStringAsFixed(0)}), yang merupakan komponen biaya terbesar.'; + } + costStructureText += + '\n\nIni menunjukkan bahwa pengelolaan lahan $cropName memerlukan pengeluaran yang cukup besar untuk beberapa aspek, seperti bibit, pupuk, dan pestisida. Pertimbangkan untuk mengoptimalkan penggunaan pupuk dan pestisida agar lebih tepat sasaran.'; + } + return costStructureText; + } + + String _getExpenseTrendAnalysis(List>? dailyLogs) { + if (dailyLogs == null || dailyLogs.isEmpty) { + return 'Tidak ada data pengeluaran harian yang tersedia untuk analisis tren.'; + } + + double totalDailyCost = 0; + for (var log in dailyLogs) { + totalDailyCost += _safeToDouble(log['cost'] ?? 0); + } + double averageDailyCost = totalDailyCost / dailyLogs.length; + + String trendText = + 'Analisis tren pengeluaran harian menunjukkan bahwa rata-rata pengeluaran harian untuk lahan ini adalah ${currency.format(averageDailyCost)}. '; + if (averageDailyCost > 100000) { + trendText += + 'Ini menunjukkan bahwa pengeluaran harian untuk lahan ini cukup tinggi, yang dapat mempengaruhi profitabilitas. Pertimbangkan untuk melakukan evaluasi ulang pada biaya-biaya tersebut dan mencari cara untuk mengoptimalkannya.'; + } else if (averageDailyCost > 50000) { + trendText += + 'Ini menunjukkan bahwa pengeluaran harian untuk lahan ini cukup baik, namun masih ada ruang untuk peningkatan efisiensi.'; + } else { + trendText += + 'Ini menunjukkan bahwa pengeluaran harian untuk lahan ini sangat efisien, dengan rata-rata biaya yang rendah.'; + } + + return trendText; + } + + // Helper for pie chart (visual proporsi biaya) + pw.Widget _buildPieChart(List> costBreakdown) { + if (costBreakdown.isEmpty) { + return pw.Text('Tidak ada data biaya untuk grafik.'); + } + double totalCost = 0; + for (var item in costBreakdown) { + totalCost += _safeToDouble(item['cost']); + } + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: + costBreakdown.map((item) { + final double value = _safeToDouble(item['cost']); + final double percentage = + totalCost > 0 ? (value / totalCost) * 100 : 0; + final int barLength = (percentage / 5).round(); + return pw.Row( + children: [ + pw.SizedBox(width: 80, child: pw.Text(item['name'])), + pw.Text(': '), + pw.Text('${percentage.toStringAsFixed(1)}%'), + pw.SizedBox(width: 8), + pw.Text('|', style: pw.TextStyle(color: PdfColors.grey)), + pw.Text( + ''.padRight(barLength, '█'), + style: pw.TextStyle(color: PdfColors.green800), + ), + ], + ); + }).toList(), + ); + } + + // Helper for bar chart (visual perbandingan keuangan) + pw.Widget _buildBarChart(double totalCost, double income, double profit) { + final maxVal = [ + _safeToDouble(totalCost), + _safeToDouble(income), + _safeToDouble(profit), + ].reduce((a, b) => a > b ? a : b); + List> bars = [ + { + 'label': 'Total Biaya', + 'value': _safeToDouble(totalCost), + 'color': PdfColors.red800, + }, + { + 'label': 'Pendapatan', + 'value': _safeToDouble(income), + 'color': PdfColors.green800, + }, + { + 'label': 'Keuntungan', + 'value': _safeToDouble(profit), + 'color': PdfColors.blue800, + }, + ]; + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: + bars.map((bar) { + final int barLength = + maxVal > 0 + ? ((_safeToDouble(bar['value']) / maxVal) * 30).round() + : 0; + return pw.Row( + children: [ + pw.SizedBox(width: 90, child: pw.Text(bar['label'])), + pw.Text(': '), + pw.Text(currency.format(_safeToDouble(bar['value']))), + pw.SizedBox(width: 8), + pw.Text('|', style: pw.TextStyle(color: PdfColors.grey)), + pw.Text( + ''.padRight(barLength, '█'), + style: pw.TextStyle(color: bar['color']), + ), + ], + ); + }).toList(), + ); + } + + // Helper for line chart (tabel tren pengeluaran harian) + pw.Widget _buildLineChart(List>? dailyLogs) { + if (dailyLogs == null || dailyLogs.isEmpty) { + return pw.Text('Tidak ada data pengeluaran harian untuk grafik.'); + } + return pw.Table( + border: pw.TableBorder.all(width: 0.5, color: PdfColors.grey400), + children: [ + pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + 'Tanggal', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + 'Biaya', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold), + ), + ), + ], + ), + ...dailyLogs.map((log) { + String dateStr; + try { + final date = DateTime.parse(_safeToString(log['date'])); + dateStr = DateFormat('dd/MM/yyyy').format(date); + } catch (e) { + dateStr = '-'; + } + return pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text(dateStr), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text(currency.format(_safeToDouble(log['cost']))), + ), + ], + ); + }), + ], + ); + } + + // Helper for expense trend summary + pw.Widget _buildExpenseTrendSummary(List>? dailyLogs) { + if (dailyLogs == null || dailyLogs.isEmpty) { + return pw.Text('Tidak ada data pengeluaran harian untuk ringkasan.'); + } + + double totalDailyCost = 0; + for (var log in dailyLogs) { + totalDailyCost += _safeToDouble(log['cost'] ?? 0); + } + double averageDailyCost = totalDailyCost / dailyLogs.length; + + String summaryText = 'Ringkasan Tren Pengeluaran Harian:\n'; + summaryText += + 'Rata-rata Pengeluaran Harian: ${currency.format(averageDailyCost)}\n'; + summaryText += + 'Total Pengeluaran Seluruh Periode: ${currency.format(totalDailyCost)}\n'; + + return pw.Text(summaryText); + } +} diff --git a/lib/utils/session_checker_mixin.dart b/lib/utils/session_checker_mixin.dart index ab61ea0..a491404 100644 --- a/lib/utils/session_checker_mixin.dart +++ b/lib/utils/session_checker_mixin.dart @@ -1,17 +1,65 @@ import 'package:flutter/material.dart'; import 'dart:async'; import 'package:tugas_akhir_supabase/services/session_manager.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; /// Mixin untuk memeriksa status session di halaman-halaman utama aplikasi mixin SessionCheckerMixin on State { bool _isCheckingSession = false; + /// Initialize session checking + void initSessionChecking() { + debugPrint('SessionChecker: Initializing session checking'); + // Check session on init + WidgetsBinding.instance.addPostFrameCallback((_) { + checkSessionStatus(); + }); + } + + /// Dispose session checking + void disposeSessionChecking() { + debugPrint('SessionChecker: Disposing session checking'); + stopPeriodicSessionCheck(); + } + + /// Update user activity + Future updateUserActivity() async { + try { + await SessionManager.updateLastUserInteraction(); + debugPrint('SessionChecker: User activity updated'); + } catch (e) { + debugPrint('SessionChecker: Error updating user activity: $e'); + } + } + /// Periksa status session saat halaman dibuka Future checkSessionStatus() async { if (_isCheckingSession) return; _isCheckingSession = true; try { + // Jangan cek session jika user belum login + final currentUser = Supabase.instance.client.auth.currentUser; + if (currentUser == null) { + debugPrint('SessionChecker: No current user, skipping session check'); + _isCheckingSession = false; + return; + } + + // Only check session if user is actually logged in + final currentSession = Supabase.instance.client.auth.currentSession; + if (currentSession == null) { + debugPrint( + 'SessionChecker: No current session, skipping session check', + ); + _isCheckingSession = false; + return; + } + + debugPrint( + 'SessionChecker: Checking session for user: ${currentUser.id}', + ); + // Tambahkan timeout untuk mencegah blocking final isValid = await SessionManager.isSessionValid().timeout( const Duration(seconds: 3), @@ -21,52 +69,54 @@ mixin SessionCheckerMixin on State { }, ); - if (!isValid && mounted) { - // Session tidak valid, update timestamp untuk mencegah pemanggilan berulang - await SessionManager.updateLastActiveTime().timeout( - const Duration(seconds: 2), - onTimeout: () { - debugPrint('SessionChecker: Update activity timed out'); - return; - }, + debugPrint('SessionChecker: Session validity result: $isValid'); + + // Update user interaction timestamp + await SessionManager.updateLastUserInteraction(); + + if (!isValid) { + debugPrint( + 'SessionChecker: Session invalid, but continuing in permissive mode', ); + // In permissive mode, don't force logout + return; } + + debugPrint('SessionChecker: Session is valid'); } catch (e) { - debugPrint('SessionChecker: Error checking session - $e'); + debugPrint('SessionChecker: Error checking session: $e'); + // In permissive mode, don't force logout on error } finally { _isCheckingSession = false; } } - /// Perbarui timestamp aktivitas pengguna - Future updateUserActivity() async { - try { - // Tambahkan timeout untuk mencegah blocking - await SessionManager.updateLastActiveTime().timeout( - const Duration(seconds: 2), - onTimeout: () { - debugPrint('SessionChecker: Update activity timed out'); - return; - }, - ); - } catch (e) { - debugPrint('SessionChecker: Error updating activity - $e'); - } + /// Periksa session secara berkala (DISABLED untuk permissive mode) + void startPeriodicSessionCheck() { + debugPrint( + 'SessionChecker: Periodic session check DISABLED for permissive mode', + ); + // No periodic checks in permissive mode } - /// Panggil di initState untuk memeriksa session - void initSessionChecking() { - // Periksa session setelah widget dibangun dengan delay - // untuk mencegah terlalu banyak operasi di startup - Future.delayed(Duration(milliseconds: 500), () { - if (mounted) { - checkSessionStatus(); - } + /// Stop periodic session check + void stopPeriodicSessionCheck() { + debugPrint('SessionChecker: Stopping periodic session check'); + // Nothing to stop in permissive mode + } + + @override + void initState() { + super.initState(); + // Check session on init + WidgetsBinding.instance.addPostFrameCallback((_) { + checkSessionStatus(); }); } - /// Panggil di dispose untuk membersihkan resource - void disposeSessionChecking() { - // Tidak ada resource yang perlu dibersihkan + @override + void dispose() { + stopPeriodicSessionCheck(); + super.dispose(); } } diff --git a/lib/widgets/session_guard_wrapper.dart b/lib/widgets/session_guard_wrapper.dart index c63bd74..43bb3d7 100644 --- a/lib/widgets/session_guard_wrapper.dart +++ b/lib/widgets/session_guard_wrapper.dart @@ -4,7 +4,7 @@ import 'package:tugas_akhir_supabase/services/session_manager.dart'; import 'package:tugas_akhir_supabase/widgets/session_expired_dialog.dart'; /// A widget that enforces session validation for authenticated routes. -/// +/// /// This widget should wrap any screen that requires authentication. /// It will automatically check if the session is valid and redirect to login /// if the session has expired. @@ -13,10 +13,10 @@ class SessionGuardWrapper extends StatefulWidget { final bool enforceAuthentication; const SessionGuardWrapper({ - Key? key, + super.key, required this.child, this.enforceAuthentication = true, - }) : super(key: key); + }); @override State createState() => _SessionGuardWrapperState(); @@ -26,81 +26,101 @@ class _SessionGuardWrapperState extends State { bool _isCheckingSession = false; bool _sessionExpired = false; bool _showingDialog = false; + bool _hasInitialized = false; @override void initState() { super.initState(); - - // Check session on widget initialization - if (widget.enforceAuthentication) { - _checkSession(); - } + + // Initialize with a delay to prevent race conditions + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_hasInitialized) { + _hasInitialized = true; + // Check session on widget initialization + if (widget.enforceAuthentication) { + _checkSession(); + } + } + }); + + // Tambahkan listener untuk dismiss dialog jika user login/logout + Supabase.instance.client.auth.onAuthStateChange.listen((event) { + final currentUser = Supabase.instance.client.auth.currentUser; + if (currentUser != null) { + setState(() { + _sessionExpired = false; + _showingDialog = false; + }); + // Pop dialog jika masih terbuka + if (Navigator.canPop(context)) { + Navigator.of( + context, + rootNavigator: true, + ).popUntil((route) => route.isFirst); + } + } + }); } @override void didChangeDependencies() { super.didChangeDependencies(); - + // Check session when dependencies change (e.g., after navigation) - if (widget.enforceAuthentication && !_isCheckingSession) { - _checkSession(); + // But only if we haven't initialized yet or if there's a significant change + if (widget.enforceAuthentication && + !_isCheckingSession && + _hasInitialized) { + // Add a small delay to prevent race conditions during navigation + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) { + _checkSession(); + } + }); } } Future _checkSession() async { - // Skip check if not enforcing authentication - if (!widget.enforceAuthentication) return; - - // Skip check if already checking - if (_isCheckingSession) return; - - // Skip check if no user is logged in - final currentUser = Supabase.instance.client.auth.currentUser; - if (currentUser == null) return; - - _isCheckingSession = true; - - try { - // Check if session is already marked as expired - if (SessionManager.isExpired) { - if (mounted && !_sessionExpired) { - setState(() { - _sessionExpired = true; - }); - _showExpiredDialog(); - } - _isCheckingSession = false; - return; - } - - // Check session validity - final isValid = await SessionManager.isSessionValid(); - - if (mounted && !isValid && !_sessionExpired) { - setState(() { - _sessionExpired = true; - }); - _showExpiredDialog(); - } - } catch (e) { - debugPrint('SessionGuard: Error checking session - $e'); - } finally { - _isCheckingSession = false; - } + // Session checking DISABLED for permissive mode + debugPrint('SessionGuard: Session checking DISABLED for permissive mode'); + return; } - + void _showExpiredDialog() { if (_showingDialog) return; - + + // Double check if user is actually logged in before showing dialog + final currentUser = Supabase.instance.client.auth.currentUser; + final currentSession = Supabase.instance.client.auth.currentSession; + + if (currentUser == null || currentSession == null) { + debugPrint( + 'SessionGuard: No user or session, not showing expired dialog', + ); + _showingDialog = false; + return; + } + + debugPrint( + 'SessionGuard: Showing expired dialog for user: ${currentUser.id}', + ); _showingDialog = true; - + // Show dialog on next frame to avoid build phase issues WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { _showingDialog = false; return; } - + + // Final check before showing dialog + final finalUser = Supabase.instance.client.auth.currentUser; + if (finalUser == null) { + debugPrint('SessionGuard: User logged out before showing dialog'); + _showingDialog = false; + return; + } + showDialog( context: context, barrierDismissible: false, @@ -113,39 +133,12 @@ class _SessionGuardWrapperState extends State { @override Widget build(BuildContext context) { - // If not enforcing authentication, just show the child - if (!widget.enforceAuthentication) { - return widget.child; - } - - // If session is expired, show a restricted UI - if (_sessionExpired) { - return Material( - child: Stack( - children: [ - // Blur the background content - Opacity( - opacity: 0.3, - child: AbsorbPointer( - child: widget.child, - ), - ), - // Show a loading indicator if dialog is not showing yet - if (!_showingDialog) - const Center( - child: CircularProgressIndicator(), - ), - ], - ), - ); - } - - // Session is valid, show the child with a gesture detector to update activity - return GestureDetector( - onTap: () => SessionManager.updateLastUserInteraction(), - onPanDown: (_) => SessionManager.updateLastUserInteraction(), - behavior: HitTestBehavior.translucent, - child: widget.child, + debugPrint('SessionGuard: build called - PERMISSIVE MODE'); + + // PERMISSIVE MODE - Always show the child without any restrictions + debugPrint( + 'SessionGuard: Showing child widget without session restrictions', ); + return widget.child; } -} \ No newline at end of file +}