946 lines
30 KiB
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';
|
|
}
|
|
}
|
|
}
|