MIF_E31222656/lib/screens/admin/guide_management.dart

941 lines
41 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:tugas_akhir_supabase/screens/community/models/farming_guide_model.dart';
import 'package:tugas_akhir_supabase/screens/community/utils/plant_categorizer.dart';
import 'package:tugas_akhir_supabase/screens/community/services/guide_service.dart';
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:uuid/uuid.dart';
import 'package:path/path.dart' as path;
import 'package:permission_handler/permission_handler.dart';
class GuideManagement extends StatefulWidget {
const GuideManagement({super.key});
@override
State<GuideManagement> createState() => _GuideManagementState();
}
class _GuideManagementState extends State<GuideManagement> {
final _supabase = Supabase.instance.client;
final _guideService = GuideService();
bool _isLoading = true;
List<FarmingGuideModel> _guides = [];
String _searchQuery = '';
// Detected storage bucket
String _storageBucket = 'images';
// Controller untuk form tambah/edit
final _titleController = TextEditingController();
final _contentController = TextEditingController();
final _categoryController = TextEditingController();
String? _selectedImagePath;
String? _currentGuideId;
bool _isFormLoading = false;
// Modern Color Scheme
static const Color primaryGreen = Color(0xFF0F6848);
static const Color lightGreen = Color(0xFF4CAF50);
static const Color surfaceGreen = Color(0xFFF1F8E9);
static const Color cardWhite = Colors.white;
static const Color textPrimary = Color(0xFF1B5E20);
static const Color textSecondary = Color(0xFF757575);
@override
void initState() {
super.initState();
_detectStorageBucket();
_loadGuides();
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
_categoryController.dispose();
super.dispose();
}
Future<void> _detectStorageBucket() async {
try {
// Get bucket names
final buckets = await _supabase.storage.listBuckets();
final bucketNames = buckets.map((b) => b.name).toList();
debugPrint('Available buckets: ${bucketNames.join(', ')}');
// Same logic as GuideService
if (bucketNames.contains('images')) {
_storageBucket = 'images';
} else if (bucketNames.contains('guide_images')) {
_storageBucket = 'guide_images';
} else if (bucketNames.contains('avatars')) {
_storageBucket = 'avatars';
} else if (bucketNames.isNotEmpty) {
_storageBucket = bucketNames.first;
}
debugPrint('Selected bucket for guide images: $_storageBucket');
} catch (e) {
debugPrint('Error detecting buckets: $e');
// Keep default bucket
}
}
Future<void> _loadGuides() async {
setState(() => _isLoading = true);
try {
// Fetch all guides from the database
final response = await _supabase
.from('farming_guides')
.select('*')
.order('created_at', ascending: false);
debugPrint('Guides loaded: ${response.length}');
// Convert to List<FarmingGuideModel>
final guides =
List<Map<String, dynamic>>.from(
response,
).map((map) => FarmingGuideModel.fromMap(map)).toList();
if (mounted) {
setState(() {
_guides = guides;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Error loading guides: $e');
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: Unable to load guides. ${e.toString()}'),
backgroundColor: Colors.red.shade400,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
}
void _filterGuides(String query) {
setState(() {
_searchQuery = query;
if (query.isEmpty) {
// No need to reload, just display all guides
} else {
// Filter locally for better performance
// In a real app with many guides, you might want to do this on the server
}
});
}
List<FarmingGuideModel> get _filteredGuides {
if (_searchQuery.isEmpty) {
return _guides;
}
final query = _searchQuery.toLowerCase();
return _guides.where((guide) {
final title = guide.title.toLowerCase();
final category = guide.category.toLowerCase();
final content = guide.content.toLowerCase();
return title.contains(query) ||
category.contains(query) ||
content.contains(query);
}).toList();
}
Future<void> _pickImage() async {
try {
debugPrint('Memulai proses pemilihan gambar...');
// Pendekatan yang lebih sederhana tanpa permission handler
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
// Tanpa pembatasan ukuran
);
if (image != null) {
debugPrint('Gambar dipilih: ${image.path}');
if (mounted) {
setState(() {
_selectedImagePath = image.path;
});
}
} else {
debugPrint('Pemilihan gambar dibatalkan');
}
} catch (e) {
debugPrint('Error saat memilih gambar: $e');
// Jangan tampilkan error ke user untuk menghindari crash lanjutan
}
}
Future<String?> _uploadImage() async {
if (_selectedImagePath == null) return null;
try {
debugPrint('Mulai upload gambar sederhana dari $_selectedImagePath');
final file = File(_selectedImagePath!);
if (!await file.exists()) {
debugPrint('File tidak ditemukan: $_selectedImagePath');
return null;
}
final fileExtension = path.extension(file.path).toLowerCase();
final fileName =
'guide_${DateTime.now().millisecondsSinceEpoch}$fileExtension';
debugPrint(
'Mencoba upload dengan nama file: $fileName ke bucket $_storageBucket',
);
// Baca file sebagai bytes untuk menghindari masalah path
final bytes = await file.readAsBytes();
// Upload sebagai binary ke bucket yang terdeteksi
await _supabase.storage
.from(_storageBucket)
.uploadBinary(
fileName,
bytes,
fileOptions: const FileOptions(contentType: 'image/jpeg'),
);
// Get public URL - Gunakan metode yang benar untuk mendapatkan URL publik
final imageUrl = _supabase.storage
.from(_storageBucket)
.getPublicUrl(fileName);
debugPrint('Gambar berhasil diupload: $imageUrl');
// Tambahkan pengecekan URL
if (!imageUrl.startsWith('http')) {
debugPrint('Warning: URL tidak dimulai dengan http: $imageUrl');
}
return imageUrl;
} catch (e) {
debugPrint('Error uploading image: $e');
// Jangan tampilkan error ke user untuk menghindari crash
return null;
}
}
void _showAddEditGuideDialog({FarmingGuideModel? guide}) {
// Reset form or fill with guide data if editing
_currentGuideId = guide?.id;
_titleController.text = guide?.title ?? '';
_contentController.text = guide?.content ?? '';
_categoryController.text = guide?.category ?? '';
_selectedImagePath = null; // Reset selected image
// Pilihan kategori untuk dropdown
final List<String> categoryOptions = [
PlantCategorizer.TANAMAN_PANGAN,
PlantCategorizer.SAYURAN,
PlantCategorizer.BUAH_BUAHAN,
PlantCategorizer.REMPAH,
'Kalender Tanam',
PlantCategorizer.UMUM,
];
showDialog(
context: context,
builder:
(context) => StatefulBuilder(
builder:
(context, setDialogState) => AlertDialog(
title: Text(
guide == null ? 'Tambah Panduan Baru' : 'Edit Panduan',
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Judul',
border: OutlineInputBorder(),
),
onChanged: (value) {
// Auto-kategorisasi berdasarkan judul
if (_categoryController.text.isEmpty ||
_categoryController.text ==
PlantCategorizer.UMUM) {
final suggestedCategory =
PlantCategorizer.categorize(
value,
description: _contentController.text,
);
if (suggestedCategory != PlantCategorizer.UMUM) {
setDialogState(() {
_categoryController.text = suggestedCategory;
});
}
}
},
),
const SizedBox(height: 16),
// Kategori dropdown daripada text field
DropdownButtonFormField<String>(
value:
categoryOptions.contains(_categoryController.text)
? _categoryController.text
: PlantCategorizer.UMUM,
decoration: const InputDecoration(
labelText: 'Kategori',
border: OutlineInputBorder(),
),
items:
categoryOptions.map((String category) {
return DropdownMenuItem<String>(
value: category,
child: Text(category),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setDialogState(() {
_categoryController.text = newValue;
});
}
},
),
const SizedBox(height: 16),
TextField(
controller: _contentController,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Konten Panduan',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
onChanged: (value) {
// Auto-kategorisasi berdasarkan konten
if (_categoryController.text.isEmpty ||
_categoryController.text ==
PlantCategorizer.UMUM) {
final suggestedCategory =
PlantCategorizer.categorize(
_titleController.text,
description: value,
);
if (suggestedCategory != PlantCategorizer.UMUM) {
setDialogState(() {
_categoryController.text = suggestedCategory;
});
}
}
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
_selectedImagePath != null
? 'Gambar dipilih: ${path.basename(_selectedImagePath!)}'
: guide?.imageUrl != null
? 'Gambar saat ini akan dipertahankan'
: 'Belum ada gambar dipilih',
style: TextStyle(color: Colors.grey[600]),
),
),
TextButton.icon(
onPressed: () async {
await _pickImage();
setDialogState(() {}); // Update dialog state
},
icon: const Icon(Icons.image),
label: const Text('Pilih Gambar'),
),
],
),
if (_selectedImagePath != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
height: 100,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image, color: Colors.grey[700]),
const SizedBox(width: 8),
Flexible(
child: Text(
'Gambar dipilih: ${path.basename(_selectedImagePath!)}',
style: TextStyle(
color: Colors.grey[700],
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
if (guide?.imageUrl != null &&
_selectedImagePath == null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
height: 100,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image, color: Colors.grey[700]),
const SizedBox(width: 8),
const Flexible(
child: Text(
'Gambar sudah tersimpan',
style: TextStyle(color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
if (_isFormLoading)
const Padding(
padding: EdgeInsets.only(top: 16.0),
child: Center(child: CircularProgressIndicator()),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
ElevatedButton(
onPressed:
_isFormLoading
? null
: () async {
// Validate form
if (_titleController.text.trim().isEmpty ||
_contentController.text.trim().isEmpty ||
_categoryController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Semua field harus diisi'),
backgroundColor: Colors.red,
),
);
return;
}
setDialogState(() => _isFormLoading = true);
try {
// Upload image if selected
String? imageUrl;
if (_selectedImagePath != null) {
imageUrl = await _uploadImage();
// Jika upload gagal, lanjutkan tanpa gambar
if (imageUrl == null) {
debugPrint(
'Upload gambar gagal, melanjutkan tanpa gambar',
);
} else {
debugPrint(
'Image URL setelah upload: $imageUrl',
);
}
}
// Persiapkan data guide
final guideData = {
'title': _titleController.text.trim(),
'content': _contentController.text.trim(),
'category': _categoryController.text.trim(),
};
// Tambahkan image_url jika ada gambar baru yang berhasil diupload
if (imageUrl != null) {
guideData['image_url'] = imageUrl;
debugPrint(
'Menambahkan image_url ke data: $imageUrl',
);
} else if (guide != null &&
guide.imageUrl != null) {
// Pertahankan image_url yang sudah ada jika tidak ada gambar baru
guideData['image_url'] = guide.imageUrl!;
debugPrint(
'Mempertahankan image_url yang ada: ${guide.imageUrl}',
);
}
if (_currentGuideId != null) {
// Update existing guide
await _supabase
.from('farming_guides')
.update(guideData)
.eq('id', _currentGuideId!);
debugPrint(
'Berhasil update guide dengan ID: $_currentGuideId',
);
} else {
// Add new guide
final response = await _supabase
.from('farming_guides')
.insert(guideData)
.select('id');
if (response.isNotEmpty) {
debugPrint(
'Berhasil insert guide dengan ID: ${response[0]['id']}',
);
}
}
// Reload guides
await _loadGuides();
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_currentGuideId != null
? 'Panduan berhasil diperbarui'
: 'Panduan baru berhasil ditambahkan',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('Error saving guide: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setDialogState(
() => _isFormLoading = false,
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
child: Text(
_currentGuideId != null ? 'Perbarui' : 'Simpan',
),
),
],
),
),
);
}
Future<void> _deleteGuide(String 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 panduan 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;
setState(() => _isLoading = true);
try {
// Delete guide from database
await _supabase.from('farming_guides').delete().eq('id', id);
// Reload guides
await _loadGuides();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Panduan berhasil dihapus'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('Error deleting guide: $e');
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'Manajemen Panduan Pertanian',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: primaryGreen,
),
),
),
ElevatedButton.icon(
onPressed: () => _showAddEditGuideDialog(),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
icon: const Icon(Icons.add),
label: const Text('Tambah Panduan'),
),
],
),
const SizedBox(height: 16),
TextField(
onChanged: _filterGuides,
decoration: InputDecoration(
hintText: 'Cari panduan...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 16),
Expanded(
child:
_isLoading
? const Center(child: CircularProgressIndicator())
: _filteredGuides.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.menu_book,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
_searchQuery.isEmpty
? 'Belum ada panduan'
: 'Tidak ada panduan yang sesuai',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
if (_searchQuery.isNotEmpty)
TextButton(
onPressed: () {
setState(() => _searchQuery = '');
},
child: const Text('Tampilkan semua panduan'),
),
],
),
)
: ListView.builder(
itemCount: _filteredGuides.length,
itemBuilder: (context, index) {
final guide = _filteredGuides[index];
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (guide.imageUrl != null)
Builder(
builder: (context) {
// Perbaiki URL gambar menggunakan GuideService
final imageUrl =
_guideService.fixImageUrl(
guide.imageUrl,
) ??
'';
debugPrint('Guide image URL: $imageUrl');
return Container(
height: 150,
width: double.infinity,
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(
top: Radius.circular(8),
),
),
child: ClipRRect(
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(8),
),
child:
imageUrl.isEmpty
? Container(
color: Colors.grey[200],
child: const Center(
child: Row(
mainAxisAlignment:
MainAxisAlignment
.center,
children: [
Icon(
Icons
.image_not_supported,
),
SizedBox(width: 8),
Text(
'URL gambar tidak valid',
),
],
),
),
)
: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (
context,
error,
stackTrace,
) {
// Log error untuk debugging
debugPrint(
'Error loading image: $error for URL $imageUrl',
);
return Container(
color: Colors.grey[200],
child: const Center(
child: Row(
mainAxisAlignment:
MainAxisAlignment
.center,
children: [
Icon(
Icons
.error_outline,
),
SizedBox(
width: 8,
),
Text(
'Gagal memuat gambar',
),
],
),
),
);
},
loadingBuilder: (
context,
child,
loadingProgress,
) {
if (loadingProgress ==
null)
return child;
return Container(
color: Colors.grey[200],
child: Center(
child: CircularProgressIndicator(
value:
loadingProgress
.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
),
),
);
},
),
),
);
},
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
guide.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: surfaceGreen,
borderRadius:
BorderRadius.circular(4),
),
child: Text(
guide.category,
style: const TextStyle(
color: primaryGreen,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 8),
Text(
guide.content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey[600],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
'Dibuat: ${guide.getFormattedDate()}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
Row(
children: [
IconButton(
onPressed:
() =>
_showAddEditGuideDialog(
guide: guide,
),
icon: const Icon(
Icons.edit,
color: primaryGreen,
),
tooltip: 'Edit',
),
IconButton(
onPressed:
() =>
_deleteGuide(guide.id),
icon: const Icon(
Icons.delete,
color: Colors.red,
),
tooltip: 'Hapus',
),
],
),
],
),
],
),
),
],
),
);
},
),
),
],
),
),
);
}
}