MIF_E31222656/lib/screens/admin/news_management.dart

946 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tugas_akhir_supabase/core/theme/app_colors.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class NewsManagement extends StatefulWidget {
const NewsManagement({super.key});
@override
State<NewsManagement> createState() => _NewsManagementState();
}
class _NewsManagementState extends State<NewsManagement> {
final _supabase = Supabase.instance.client;
bool _isLoading = true;
bool _isLoadingMore = false;
// NewsAPI data
String _apiKey = '';
List<Map<String, dynamic>> _articles = [];
List<Map<String, dynamic>> _savedArticles = [];
String _searchQuery = '';
String _currentCategory = 'agriculture';
int _currentPage = 1;
bool _hasMorePages = true;
// Form controllers
final _apiKeyController = TextEditingController();
final _searchController = TextEditingController();
// Categories for agriculture news
final List<String> _categories = [
'agriculture',
'farming',
'crops',
'organic farming',
'sustainable agriculture',
'agricultural technology',
'food production',
];
@override
void initState() {
super.initState();
_loadApiKey();
}
@override
void dispose() {
_apiKeyController.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadApiKey() async {
try {
// Load API key from settings table
final response =
await _supabase
.from('app_settings')
.select()
.eq('key', 'newsapi_key')
.single();
if (mounted) {
setState(() {
_apiKey = response['value'] ?? '';
_apiKeyController.text = _apiKey;
});
}
// If we have an API key, load news
if (_apiKey.isNotEmpty) {
await _loadNews();
}
// Load saved articles
await _loadSavedArticles();
if (mounted) {
setState(() => _isLoading = false);
}
} catch (e) {
debugPrint('Error loading API key: $e');
// Create default entry if not exists
try {
await _supabase.from('app_settings').insert({
'key': 'newsapi_key',
'value': '',
'description': 'API key for NewsAPI.org',
});
} catch (insertError) {
// Ignore if already exists
debugPrint('Error creating API key setting: $insertError');
}
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _saveApiKey() async {
final newApiKey = _apiKeyController.text.trim();
if (newApiKey == _apiKey) return;
setState(() => _isLoading = true);
try {
await _supabase
.from('app_settings')
.update({'value': newApiKey})
.eq('key', 'newsapi_key');
setState(() {
_apiKey = newApiKey;
_isLoading = false;
});
// Reload news with new API key
if (_apiKey.isNotEmpty) {
_currentPage = 1;
_articles = [];
await _loadNews();
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('API key berhasil disimpan'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Error saving API key: $e');
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _loadNews() async {
if (_apiKey.isEmpty) return;
if (_currentPage == 1) {
setState(() => _isLoading = true);
} else {
setState(() => _isLoadingMore = true);
}
try {
final query = _searchQuery.isNotEmpty ? _searchQuery : _currentCategory;
final url = Uri.parse(
'https://newsapi.org/v2/everything?q=$query&language=id&pageSize=10&page=$_currentPage&apiKey=$_apiKey',
);
final response = await http.get(url);
final data = json.decode(response.body);
if (data['status'] == 'ok') {
final articles = List<Map<String, dynamic>>.from(data['articles']);
// Check if we have more pages
final totalResults = data['totalResults'] ?? 0;
final hasMore = _currentPage * 10 < totalResults;
setState(() {
if (_currentPage == 1) {
_articles = articles;
} else {
_articles.addAll(articles);
}
_hasMorePages = hasMore;
_isLoading = false;
_isLoadingMore = false;
});
} else {
throw Exception(data['message'] ?? 'Failed to load news');
}
} catch (e) {
debugPrint('Error loading news: $e');
setState(() {
_isLoading = false;
_isLoadingMore = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _loadSavedArticles() async {
try {
final response = await _supabase
.from('saved_news')
.select('*')
.order('published_at', ascending: false);
final savedArticles = List<Map<String, dynamic>>.from(response);
if (mounted) {
setState(() {
_savedArticles = savedArticles;
});
}
} catch (e) {
debugPrint('Error loading saved articles: $e');
// Try to create table if not exists
try {
// This would typically be done with a migration, but for simplicity
await _supabase.rpc('create_saved_news_table_if_not_exists');
if (mounted) {
setState(() {
_savedArticles = [];
});
}
} catch (tableError) {
debugPrint('Error creating saved_news table: $tableError');
}
}
}
Future<void> _saveArticle(Map<String, dynamic> article) async {
try {
// Check if already saved
final exists = _savedArticles.any((a) => a['url'] == article['url']);
if (exists) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Berita sudah disimpan sebelumnya'),
backgroundColor: Colors.orange,
),
);
return;
}
// Prepare data for saving
final articleData = {
'title': article['title'],
'description': article['description'],
'url': article['url'],
'url_to_image': article['urlToImage'],
'published_at': article['publishedAt'],
'source_name': article['source']['name'],
'content': article['content'],
'is_featured': false,
};
await _supabase.from('saved_news').insert(articleData);
// Reload saved articles
await _loadSavedArticles();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Berita berhasil disimpan'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Error saving article: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _deleteArticle(int id) async {
// Show confirmation dialog
final shouldDelete = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Konfirmasi Hapus'),
content: const Text(
'Apakah Anda yakin ingin menghapus berita ini? '
'Tindakan ini tidak dapat dibatalkan.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Hapus'),
),
],
),
);
if (shouldDelete != true) return;
try {
await _supabase.from('saved_news').delete().eq('id', id);
// Reload saved articles
await _loadSavedArticles();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Berita berhasil dihapus'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Error deleting article: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _toggleFeatured(int id, bool currentValue) async {
try {
await _supabase
.from('saved_news')
.update({'is_featured': !currentValue})
.eq('id', id);
// Reload saved articles
await _loadSavedArticles();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
currentValue
? 'Berita dihapus dari featured'
: 'Berita ditambahkan ke featured',
),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Error toggling featured: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
void _showApiKeyDialog() {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Pengaturan NewsAPI'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _apiKeyController,
decoration: const InputDecoration(
labelText: 'API Key',
border: OutlineInputBorder(),
hintText: 'Masukkan NewsAPI key Anda',
),
),
const SizedBox(height: 16),
const Text(
'Dapatkan API key di newsapi.org',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
_saveApiKey();
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
child: const Text('Simpan'),
),
],
),
);
}
void _searchNews() {
_currentPage = 1;
_articles = [];
_searchQuery = _searchController.text.trim();
_loadNews();
}
void _changeCategory(String category) {
setState(() {
_currentCategory = category;
_searchQuery = '';
_searchController.clear();
_currentPage = 1;
_articles = [];
});
_loadNews();
}
void _loadMoreNews() {
if (!_isLoadingMore && _hasMorePages) {
_currentPage++;
_loadNews();
}
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
bottom: TabBar(
tabs: const [
Tab(text: 'Pencarian Berita'),
Tab(text: 'Berita Tersimpan'),
],
labelColor: AppColors.primary,
unselectedLabelColor: Colors.grey,
indicatorColor: AppColors.primary,
),
),
),
body: TabBarView(
children: [
// Tab 1: News Search
_buildNewsSearchTab(),
// Tab 2: Saved News
_buildSavedNewsTab(),
],
),
),
);
}
Widget _buildNewsSearchTab() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Cari berita pertanian...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
onSubmitted: (_) => _searchNews(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _showApiKeyDialog,
icon: const Icon(Icons.settings),
tooltip: 'Pengaturan API',
),
],
),
const SizedBox(height: 16),
// Categories
SizedBox(
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _categories.length,
itemBuilder: (context, index) {
final category = _categories[index];
final isSelected =
category == _currentCategory && _searchQuery.isEmpty;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (_) => _changeCategory(category),
backgroundColor: Colors.grey[200],
selectedColor: AppColors.primary.withOpacity(0.2),
labelStyle: TextStyle(
color: isSelected ? AppColors.primary : Colors.black87,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
},
),
),
const SizedBox(height: 16),
// News list
Expanded(
child:
_apiKey.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.vpn_key,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
const Text(
'API Key belum dikonfigurasi',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Silakan tambahkan API Key NewsAPI untuk mulai',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _showApiKeyDialog,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
child: const Text('Tambahkan API Key'),
),
],
),
)
: _isLoading && _currentPage == 1
? const Center(child: CircularProgressIndicator())
: _articles.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.article,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
const Text(
'Tidak ada berita ditemukan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Coba gunakan kata kunci pencarian yang berbeda',
style: TextStyle(color: Colors.grey),
),
],
),
)
: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification scrollInfo) {
if (scrollInfo.metrics.pixels ==
scrollInfo.metrics.maxScrollExtent) {
_loadMoreNews();
return true;
}
return false;
},
child: ListView.builder(
itemCount: _articles.length + (_hasMorePages ? 1 : 0),
itemBuilder: (context, index) {
if (index == _articles.length) {
return _isLoadingMore
? const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
)
: const SizedBox.shrink();
}
final article = _articles[index];
return _buildNewsCard(article, true);
},
),
),
),
],
),
);
}
Widget _buildSavedNewsTab() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Berita Tersimpan',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
const SizedBox(height: 16),
Expanded(
child:
_isLoading
? const Center(child: CircularProgressIndicator())
: _savedArticles.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.article,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
const Text(
'Belum ada berita tersimpan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Simpan berita dari tab pencarian',
style: TextStyle(color: Colors.grey),
),
],
),
)
: ListView.builder(
itemCount: _savedArticles.length,
itemBuilder: (context, index) {
final article = _savedArticles[index];
return _buildSavedNewsCard(article);
},
),
),
],
),
);
}
Widget _buildNewsCard(Map<String, dynamic> article, bool isSearchResult) {
final title = article['title'] ?? 'Tanpa Judul';
final description = article['description'] ?? 'Tidak ada deskripsi';
final imageUrl = article['urlToImage'];
final source = article['source']?['name'] ?? 'Unknown Source';
final publishedAt = _formatDate(article['publishedAt']);
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageUrl != null)
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.network(
imageUrl,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
height: 180,
color: Colors.grey[300],
child: const Center(
child: Icon(
Icons.broken_image,
size: 64,
color: Colors.white70,
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
source,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
Text(
publishedAt,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
if (isSearchResult)
ElevatedButton.icon(
onPressed: () => _saveArticle(article),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.save),
label: const Text('Simpan'),
),
],
),
],
),
),
],
),
);
}
Widget _buildSavedNewsCard(Map<String, dynamic> article) {
final id = article['id'];
final title = article['title'] ?? 'Tanpa Judul';
final description = article['description'] ?? 'Tidak ada deskripsi';
final imageUrl = article['url_to_image'];
final source = article['source_name'] ?? 'Unknown Source';
final publishedAt = _formatDate(article['published_at']);
final isFeatured = article['is_featured'] ?? false;
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageUrl != null)
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.network(
imageUrl,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
height: 180,
color: Colors.grey[300],
child: const Center(
child: Icon(
Icons.broken_image,
size: 64,
color: Colors.white70,
),
),
),
),
),
if (isFeatured)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Featured',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
source,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
Text(
publishedAt,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
Row(
children: [
IconButton(
onPressed: () => _toggleFeatured(id, isFeatured),
icon: Icon(
isFeatured ? Icons.star : Icons.star_border,
color: isFeatured ? Colors.amber : Colors.grey,
),
tooltip:
isFeatured
? 'Hapus dari Featured'
: 'Jadikan Featured',
),
IconButton(
onPressed: () => _deleteArticle(id),
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Hapus',
),
],
),
],
),
],
),
),
],
),
);
}
String _formatDate(String? dateStr) {
if (dateStr == null) return 'N/A';
try {
final date = DateTime.parse(dateStr);
return '${date.day}/${date.month}/${date.year}';
} catch (e) {
return 'N/A';
}
}
}