630 lines
19 KiB
Dart
630 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/simple_news_card.dart';
|
|
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:tugas_akhir_supabase/screens/community/components/news_web_view.dart';
|
|
import 'package:tugas_akhir_supabase/services/gemini_service.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class SimpleNewsTab extends StatefulWidget {
|
|
const SimpleNewsTab({super.key});
|
|
|
|
@override
|
|
_SimpleNewsTabState createState() => _SimpleNewsTabState();
|
|
}
|
|
|
|
class _SimpleNewsTabState extends State<SimpleNewsTab> {
|
|
bool _isLoading = true;
|
|
List<dynamic> _newsArticles = [];
|
|
String? _errorMessage;
|
|
int _currentPage = 1;
|
|
bool _hasMoreData = true;
|
|
final ScrollController _scrollController = ScrollController();
|
|
int _totalResults = 0;
|
|
final int _pageSize = 10;
|
|
bool _isFiltering = false;
|
|
|
|
// Konstanta untuk caching
|
|
static const String _cachedNewsKey = 'cached_farming_news';
|
|
static const String _lastFetchTimeKey = 'last_news_fetch_time';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadNewsWithCache();
|
|
_scrollController.addListener(_scrollListener);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_scrollListener);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollListener() {
|
|
if (_scrollController.position.pixels ==
|
|
_scrollController.position.maxScrollExtent) {
|
|
if (_hasMoreData && !_isLoading) {
|
|
_loadMoreNews();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Metode baru untuk load berita dengan caching
|
|
Future<void> _loadNewsWithCache() async {
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final lastFetchTime = prefs.getInt(_lastFetchTimeKey);
|
|
final currentTime = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
// Cek apakah cache masih valid (kurang dari 24 jam)
|
|
final cacheValid =
|
|
lastFetchTime != null &&
|
|
currentTime - lastFetchTime <
|
|
24 * 60 * 60 * 1000; // 24 jam dalam milidetik
|
|
|
|
if (cacheValid) {
|
|
// Ambil berita dari cache
|
|
final cachedNewsJson = prefs.getString(_cachedNewsKey);
|
|
if (cachedNewsJson != null && cachedNewsJson.isNotEmpty) {
|
|
final cachedNews = json.decode(cachedNewsJson);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_newsArticles = cachedNews;
|
|
_isLoading = false;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
debugPrint('Loaded ${_newsArticles.length} news articles from cache');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Jika cache tidak valid atau kosong, load berita baru
|
|
if (mounted) {
|
|
await _loadNews();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error loading cached news: $e');
|
|
// Fallback ke load berita normal jika ada error
|
|
if (mounted) {
|
|
await _loadNews();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadNews() async {
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_currentPage = 1;
|
|
});
|
|
|
|
try {
|
|
// API Key for NewsAPI.org
|
|
final String apiKey = '75571d40e2d743bc837012edce849d98';
|
|
|
|
// Get the date 30 days ago (sesuai batasan free plan)
|
|
final DateTime now = DateTime.now();
|
|
final DateTime oneMonthAgo = now.subtract(Duration(days: 30));
|
|
final String fromDate = DateFormat('yyyy-MM-dd').format(oneMonthAgo);
|
|
|
|
// Build URL with parameters for general agriculture news
|
|
final url = Uri.parse('https://newsapi.org/v2/everything').replace(
|
|
queryParameters: {
|
|
'q': 'pertanian OR petani OR budidaya OR tanaman',
|
|
'language': 'id',
|
|
'from': fromDate,
|
|
'sortBy': 'publishedAt',
|
|
'pageSize': '20',
|
|
'page': '1',
|
|
'apiKey': apiKey,
|
|
},
|
|
);
|
|
|
|
debugPrint('Making API request to: $url');
|
|
|
|
// Make API request
|
|
final response = await http.get(url);
|
|
|
|
// Check if widget is still mounted before proceeding
|
|
if (!mounted) return;
|
|
|
|
// Debug response
|
|
debugPrint('API Response Status: ${response.statusCode}');
|
|
debugPrint(
|
|
'API Response Body: ${response.body.substring(0, min(500, response.body.length))}...',
|
|
);
|
|
|
|
// Check response status
|
|
if (response.statusCode == 200) {
|
|
final jsonData = json.decode(response.body);
|
|
|
|
debugPrint('Response status: ${jsonData['status']}');
|
|
debugPrint('Total results: ${jsonData['totalResults']}');
|
|
|
|
if (jsonData['status'] == 'ok' &&
|
|
jsonData['articles'] != null &&
|
|
jsonData['articles'] is List) {
|
|
List<dynamic> articles = jsonData['articles'];
|
|
|
|
// Filter with Gemini API
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isFiltering = true;
|
|
});
|
|
|
|
final filteredArticles = await _filterWithGeminiAPI(articles);
|
|
|
|
// Check if widget is still mounted
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_newsArticles = filteredArticles;
|
|
_isLoading = false;
|
|
_isFiltering = false;
|
|
_totalResults = jsonData['totalResults'] ?? 0;
|
|
_hasMoreData = _newsArticles.length < _totalResults;
|
|
});
|
|
|
|
// Simpan hasil ke cache
|
|
if (mounted) {
|
|
_saveNewsToCache(filteredArticles);
|
|
}
|
|
} else {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = 'Tidak ada artikel pertanian yang ditemukan';
|
|
_isLoading = false;
|
|
_newsArticles = [];
|
|
});
|
|
}
|
|
} else {
|
|
final jsonData = json.decode(response.body);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage =
|
|
'Gagal memuat berita: ${response.statusCode}\n${jsonData['message'] ?? response.body}';
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Error loading news: $e');
|
|
debugPrint('Stack trace: $stackTrace');
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = 'Terjadi kesalahan: $e';
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Metode untuk menyimpan berita ke cache
|
|
Future<void> _saveNewsToCache(List<dynamic> news) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final newsJson = json.encode(news);
|
|
final currentTime = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
await prefs.setString(_cachedNewsKey, newsJson);
|
|
await prefs.setInt(_lastFetchTimeKey, currentTime);
|
|
|
|
debugPrint('Saved ${news.length} news articles to cache');
|
|
} catch (e) {
|
|
debugPrint('Error saving news to cache: $e');
|
|
}
|
|
}
|
|
|
|
// Filter articles using Gemini API
|
|
Future<List<dynamic>> _filterWithGeminiAPI(List<dynamic> articles) async {
|
|
if (!mounted) return [];
|
|
|
|
List<dynamic> filteredArticles = [];
|
|
|
|
// Process in batches to avoid overloading
|
|
final batchSize = 5;
|
|
for (int i = 0; i < articles.length; i += batchSize) {
|
|
// Check if widget is still mounted before processing each batch
|
|
if (!mounted) return filteredArticles;
|
|
|
|
final end =
|
|
(i + batchSize < articles.length) ? i + batchSize : articles.length;
|
|
final batch = articles.sublist(i, end);
|
|
|
|
// Process each article in the batch
|
|
for (final article in batch) {
|
|
// Check if widget is still mounted before processing each article
|
|
if (!mounted) return filteredArticles;
|
|
|
|
final title = article['title'] ?? '';
|
|
final description = article['description'] ?? '';
|
|
|
|
// Combine title and description for better context
|
|
final content = '$title. $description';
|
|
|
|
try {
|
|
// Ask Gemini if this is useful farming content
|
|
final prompt =
|
|
"Apakah teks berikut berisi tips atau informasi bermanfaat tentang pertanian? Jawab hanya dengan 'ya' atau 'tidak'. Teks: '$content'";
|
|
final result = await GeminiService.askGemini(prompt);
|
|
|
|
// Check if Gemini thinks it's relevant
|
|
if (result.toLowerCase().contains('ya')) {
|
|
filteredArticles.add(article);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error filtering with Gemini: $e');
|
|
// If Gemini fails, include the article by default
|
|
filteredArticles.add(article);
|
|
}
|
|
|
|
// Small delay to avoid rate limiting
|
|
await Future.delayed(Duration(milliseconds: 200));
|
|
}
|
|
}
|
|
|
|
// If filtering removed too many articles, return some original ones
|
|
if (filteredArticles.length < 3 && articles.isNotEmpty) {
|
|
return articles.take(5).toList();
|
|
}
|
|
|
|
return filteredArticles;
|
|
}
|
|
|
|
Future<void> _loadMoreNews() async {
|
|
if (_isLoading || !_hasMoreData || !mounted) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final String apiKey = '75571d40e2d743bc837012edce849d98';
|
|
final nextPage = _currentPage + 1;
|
|
|
|
// Get the date 30 days ago (sesuai batasan free plan)
|
|
final DateTime now = DateTime.now();
|
|
final DateTime oneMonthAgo = now.subtract(Duration(days: 30));
|
|
final String fromDate = DateFormat('yyyy-MM-dd').format(oneMonthAgo);
|
|
|
|
final url = Uri.parse('https://newsapi.org/v2/everything').replace(
|
|
queryParameters: {
|
|
'q': 'pertanian OR petani OR budidaya OR tanaman',
|
|
'language': 'id',
|
|
'from': fromDate,
|
|
'sortBy': 'publishedAt',
|
|
'pageSize': '20',
|
|
'page': nextPage.toString(),
|
|
'apiKey': apiKey,
|
|
},
|
|
);
|
|
|
|
final response = await http.get(url);
|
|
|
|
// Check if widget is still mounted
|
|
if (!mounted) return;
|
|
|
|
if (response.statusCode == 200) {
|
|
final jsonData = json.decode(response.body);
|
|
|
|
if (jsonData['status'] == 'ok' &&
|
|
jsonData['articles'] != null &&
|
|
jsonData['articles'] is List) {
|
|
List<dynamic> articles = jsonData['articles'];
|
|
|
|
// Filter with Gemini API
|
|
final filteredArticles = await _filterWithGeminiAPI(articles);
|
|
|
|
// Check if widget is still mounted
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_newsArticles.addAll(filteredArticles);
|
|
_currentPage = nextPage;
|
|
_hasMoreData = _newsArticles.length < (_totalResults ?? 0);
|
|
_isLoading = false;
|
|
});
|
|
|
|
// Update cache with new articles
|
|
if (mounted) {
|
|
_saveNewsToCache(_newsArticles);
|
|
}
|
|
} else {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_hasMoreData = false;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} else {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_hasMoreData = false;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Metode untuk memaksa refresh berita (untuk tombol refresh)
|
|
Future<void> _forceRefresh() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_lastFetchTimeKey);
|
|
if (mounted) {
|
|
await _loadNews();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error during force refresh: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _launchURL(String url, String title) async {
|
|
if (!mounted) return;
|
|
|
|
if (url.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('URL tidak tersedia untuk artikel ini')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
debugPrint('Attempting to open URL: $url');
|
|
|
|
// Use in-app WebView
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => NewsWebView(url: url, title: title),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Error opening URL: $e');
|
|
|
|
// Fallback to external browser if WebView fails
|
|
try {
|
|
final uri = Uri.parse(url);
|
|
if (!mounted) return;
|
|
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
} else {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Tidak dapat membuka URL: $url')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
// Header for Farming Tips
|
|
Container(
|
|
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
color: Colors.white,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Berita Pertanian',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
SizedBox(height: 2),
|
|
Text(
|
|
'Tips dan panduan untuk meningkatkan hasil pertanian',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 1),
|
|
// Tab content
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh:
|
|
_forceRefresh, // Gunakan force refresh untuk pull-to-refresh
|
|
child:
|
|
_isLoading && _newsArticles.isEmpty
|
|
? _buildLoadingView()
|
|
: _errorMessage != null
|
|
? _buildErrorView()
|
|
: _newsArticles.isEmpty
|
|
? _buildEmptyState()
|
|
: _buildNewsListView(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.eco_outlined, size: 64, color: Colors.grey[400]),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Belum ada tips pertanian',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Tips dan panduan bertani akan muncul di sini',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
|
),
|
|
SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed:
|
|
_forceRefresh, // Gunakan force refresh untuk tombol refresh
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
icon: Icon(Icons.refresh),
|
|
label: Text('Muat Ulang'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorView() {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.error_outline, size: 48, color: Colors.red),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Gagal memuat berita pertanian',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
_errorMessage ?? 'Terjadi kesalahan tidak diketahui',
|
|
style: TextStyle(color: Colors.red[700]),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed:
|
|
_forceRefresh, // Gunakan force refresh untuk tombol coba lagi
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
icon: Icon(Icons.refresh),
|
|
label: Text('Coba Lagi'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoadingView() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(color: AppColors.primary),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
_isFiltering
|
|
? 'Memfilter konten pertanian bermanfaat...'
|
|
: 'Memuat informasi pertanian...',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[700]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNewsListView() {
|
|
return Stack(
|
|
children: [
|
|
ListView.builder(
|
|
controller: _scrollController,
|
|
padding: EdgeInsets.only(top: 8, bottom: 16),
|
|
itemCount: _newsArticles.length + (_hasMoreData ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == _newsArticles.length) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
),
|
|
);
|
|
}
|
|
|
|
final article = _newsArticles[index];
|
|
final title = article['title'] ?? 'Judul tidak tersedia';
|
|
final content = article['description'] ?? 'Konten tidak tersedia';
|
|
final source =
|
|
article['source']?['name'] ?? 'Sumber tidak diketahui';
|
|
final imageUrl = article['urlToImage'];
|
|
|
|
// Ensure URL is properly formatted
|
|
String url = article['url'] ?? '';
|
|
if (url.isNotEmpty && !url.startsWith('http')) {
|
|
url = 'https://$url';
|
|
}
|
|
|
|
final date =
|
|
article['publishedAt'] != null
|
|
? DateTime.tryParse(article['publishedAt'])
|
|
: null;
|
|
|
|
return SimpleNewsCard(
|
|
title: title,
|
|
content: content,
|
|
source: source,
|
|
imageUrl: imageUrl,
|
|
onTap: () => _launchURL(url, title),
|
|
publishDate: date,
|
|
);
|
|
},
|
|
),
|
|
if (_isLoading && _newsArticles.isNotEmpty)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Container(
|
|
color: Colors.white.withOpacity(0.7),
|
|
padding: EdgeInsets.all(8.0),
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|