import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:tflite_flutter/tflite_flutter.dart'; import 'package:image/image.dart' as img; import 'package:google_fonts/google_fonts.dart'; import '../../widgets/color.dart'; import 'package:flutter/services.dart'; class KlasifikasiScreen extends StatefulWidget { const KlasifikasiScreen({super.key}); @override State createState() => _KlasifikasiScreenState(); } class _KlasifikasiScreenState extends State { File? _image; Interpreter? _interpreter; List? _labels; bool _isProcessing = false; String _jenisKopi = ""; String _tingkatRoasting = ""; double _akurasi = 0.0; List> _top3Predictions = []; bool _isValidCoffee = true; @override void initState() { super.initState(); _loadModel(); } Future _loadModel() async { try { _interpreter = await Interpreter.fromAsset( 'assets/model/best_model_epoch15.tflite', ); final labelData = await rootBundle.loadString('assets/model/label.txt'); _labels = labelData .split('\n') .where((label) => label.trim().isNotEmpty) .toList(); } catch (e) { debugPrint("Gagal memuat model atau label: $e"); } } // VALIDASI TEKSTUR & WARNA (Fokus area tengah 50%) bool _checkImageAuthenticity(img.Image image) { int rSum = 0, gSum = 0, bSum = 0; int sampleStep = 10; int count = 0; int startX = (image.width * 0.25).toInt(); int startY = (image.height * 0.25).toInt(); int endX = (image.width * 0.75).toInt(); int endY = (image.height * 0.75).toInt(); for (int y = startY; y < endY; y += sampleStep) { for (int x = startX; x < endX; x += sampleStep) { var pixel = image.getPixel(x, y); rSum += pixel.r.toInt(); gSum += pixel.g.toInt(); bSum += pixel.b.toInt(); count++; } } if (count == 0) return false; double avgR = rSum / count; double avgG = gSum / count; double avgB = bSum / count; double brightness = (0.299 * avgR + 0.587 * avgG + 0.114 * avgB); // Kriteria warna biji kopi (Cokelat/Gelap) if (brightness < 20 || brightness > 235) return false; if (avgG > avgR * 1.3 || avgB > avgR * 1.3) return false; return true; } Future _pickImage(ImageSource source) async { final picker = ImagePicker(); final pickedFile = await picker.pickImage( source: source, imageQuality: 100, ); if (pickedFile != null) { setState(() { _image = File(pickedFile.path); _isProcessing = true; _jenisKopi = ""; _isValidCoffee = true; }); _classifyImage(_image!); } } Future _classifyImage(File image) async { if (_interpreter == null) return; final bytes = await image.readAsBytes(); final decodedImage = img.decodeImage(bytes); if (decodedImage == null) return; // 1. Validasi Keaslian if (!_checkImageAuthenticity(decodedImage)) { setState(() { _isValidCoffee = false; _jenisKopi = "Invalid"; _isProcessing = false; }); return; } // 2. PEMBENARAN: Auto Center Crop int size = decodedImage.width < decodedImage.height ? decodedImage.width : decodedImage.height; var cropped = img.copyCrop( decodedImage, x: (decodedImage.width - size) ~/ 2, y: (decodedImage.height - size) ~/ 2, width: size, height: size, ); // 3. Resize ke 224x224 var resized = img.copyResize(cropped, width: 224, height: 224); // 4. Input Tensor var input = List.generate( 1, (i) => List.generate( 224, (j) => List.generate(224, (k) => List.generate(3, (l) => 0.0)), ), ); for (var y = 0; y < 224; y++) { for (var x = 0; x < 224; x++) { var p = resized.getPixel(x, y); input[0][y][x][0] = p.r.toDouble(); input[0][y][x][1] = p.g.toDouble(); input[0][y][x][2] = p.b.toDouble(); } } // 5. Inference var output = List.filled(1 * 6, 0.0).reshape([1, 6]); _interpreter!.run(input, output); List results = List.from(output[0]); List> temp = []; for (int i = 0; i < _labels!.length; i++) { temp.add({"label": _labels![i], "score": results[i]}); } temp.sort((a, b) => b['score'].compareTo(a['score'])); setState(() { _top3Predictions = temp.take(3).toList(); double topScore = _top3Predictions[0]['score']; if (topScore < 0.70) { _isValidCoffee = false; _jenisKopi = "Unknown"; } else { _isValidCoffee = true; List split = _top3Predictions[0]['label'].split(" "); _jenisKopi = split[0]; _tingkatRoasting = split.length > 1 ? split.sublist(1).join(" ") : ""; _akurasi = topScore * 100; } _isProcessing = false; }); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: AppColors.cardWhite, elevation: 0, title: Row( children: [ Image.asset( 'assets/images/Logo_Coffee_Scan.png', width: 30, errorBuilder: (c, e, s) => const Icon(Icons.coffee, color: AppColors.brownMain), ), const SizedBox(width: 10), RichText( text: TextSpan( style: GoogleFonts.montserrat( fontSize: 20, fontWeight: FontWeight.w800, ), children: const [ TextSpan( text: 'Coffee', style: TextStyle(color: AppColors.brownMain), ), TextSpan( text: 'Scan', style: TextStyle(color: AppColors.greenMain), ), ], ), ), ], ), ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.only( left: 16, right: 16, top: 16, bottom: 100, ), child: Column( children: [ _buildInputCard(), const SizedBox(height: 25), if (_isProcessing) const Center( child: CircularProgressIndicator(color: AppColors.brownMain), ), if (!_isProcessing && _jenisKopi.isNotEmpty) _isValidCoffee ? _buildResultSection() : _buildInvalidCard(), ], ), ), ), ); } Widget _buildInputCard() { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.heroCokelat, borderRadius: BorderRadius.circular(25), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Stack( alignment: Alignment.center, children: [ Container( width: 135, height: 135, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), ), child: _image == null ? const Icon( Icons.image_search, size: 50, color: Colors.grey, ) : ClipRRect( borderRadius: BorderRadius.circular(20), child: Image.file(_image!, fit: BoxFit.cover), ), ), if (_image == null) Container( width: 100, height: 100, decoration: BoxDecoration( border: Border.all( color: Colors.white.withOpacity(0.5), width: 2, ), borderRadius: BorderRadius.circular(15), ), ), ], ), const SizedBox(width: 16), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Posisikan biji kopi di tengah bingkai kotak agar hasil lebih akurat", style: GoogleFonts.montserrat( fontSize: 8, fontWeight: FontWeight.w500, color: Colors.white, height: 1.2, ), ), const SizedBox(height: 10), _buildActionButton( Icons.camera_alt_rounded, "Buka Kamera", () => _pickImage(ImageSource.camera), ), const SizedBox(height: 8), _buildActionButton( Icons.photo_library_rounded, "Unggah File", () => _pickImage(ImageSource.gallery), ), ], ), ), ], ), ); } Widget _buildActionButton(IconData icon, String label, VoidCallback onTap) { return ElevatedButton.icon( onPressed: onTap, icon: Icon(icon, size: 18, color: Colors.black), label: Text( label, style: GoogleFonts.montserrat( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black, ), ), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFF5E6CA), elevation: 0, minimumSize: const Size(double.infinity, 38), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ); } Widget _buildInvalidCard() { return Container( padding: const EdgeInsets.all(25), decoration: BoxDecoration( color: Colors.red[50], borderRadius: BorderRadius.circular(25), border: Border.all(color: Colors.red.shade200), ), child: Column( children: [ const Icon(Icons.block_flipped, color: Colors.red, size: 60), const SizedBox(height: 15), Text( "Objek Tidak Dikenali", style: GoogleFonts.montserrat( fontWeight: FontWeight.bold, fontSize: 18, color: Colors.red[900], ), ), const SizedBox(height: 10), Text( "Pastikan objek berada di tengah kotak dan memiliki pencahayaan yang cukup.", textAlign: TextAlign.center, style: GoogleFonts.montserrat(fontSize: 13, color: Colors.red[700]), ), ], ), ); } Widget _buildResultSection() { return Column( children: [ const Icon(Icons.check_circle, color: AppColors.greenSuccess, size: 70), const SizedBox(height: 8), Text( "Hasil Klasifikasi", style: GoogleFonts.montserrat( fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 25), Row( children: [ _buildDetailBox( "Jenis Biji Kopi", _jenisKopi, Icons.coffee_rounded, ), const SizedBox(width: 12), _buildDetailBox( "Tingkat Roasting", _tingkatRoasting, Icons.local_fire_department_rounded, ), ], ), const SizedBox(height: 20), _buildAccuracyMainCard(), const SizedBox(height: 20), _buildTop3AnalisisCard(), ], ); } Widget _buildAccuracyMainCard() { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: AppColors.greenLight, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Akurasi Prediksi", style: GoogleFonts.montserrat( color: AppColors.greenSuccess, fontWeight: FontWeight.bold, ), ), Text( "${_akurasi.toStringAsFixed(2)}%", style: GoogleFonts.montserrat( fontSize: 32, fontWeight: FontWeight.bold, color: AppColors.greenSuccess, ), ), ], ), const Icon( Icons.analytics_rounded, color: AppColors.greenSuccess, size: 50, ), ], ), ); } Widget _buildTop3AnalisisCard() { List> filtered = _top3Predictions .where((pred) => (pred['score'] * 100) >= 0.1) .toList(); double s1 = filtered.isNotEmpty ? filtered[0]['score'] : 0.0; double s2 = filtered.length > 1 ? filtered[1]['score'] : 0.0; double s3 = filtered.length > 2 ? filtered[2]['score'] : 0.0; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(25), boxShadow: [ BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 15), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Analisis Kemungkinan", style: GoogleFonts.montserrat( fontWeight: FontWeight.bold, fontSize: 14, ), ), const SizedBox(height: 25), Row( children: [ SizedBox( width: 120, height: 120, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 120, height: 120, child: CircularProgressIndicator( value: 1.0, strokeWidth: 12, color: Colors.grey[100], ), ), if (filtered.length > 2) SizedBox( width: 120, height: 120, child: CircularProgressIndicator( value: s1 + s2 + s3, strokeWidth: 12, strokeCap: StrokeCap.round, color: AppColors.rank3, ), ), if (filtered.length > 1) SizedBox( width: 120, height: 120, child: CircularProgressIndicator( value: s1 + s2, strokeWidth: 12, strokeCap: StrokeCap.round, color: AppColors.rank2, ), ), SizedBox( width: 120, height: 120, child: CircularProgressIndicator( value: s1, strokeWidth: 12, strokeCap: StrokeCap.round, color: AppColors.rank1, ), ), Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "${(s1 * 100).toStringAsFixed(2)}%", style: GoogleFonts.montserrat( fontWeight: FontWeight.bold, fontSize: 18, color: AppColors.rank1, ), ), Text( "Match", style: GoogleFonts.montserrat( fontSize: 9, color: AppColors.textGrey, ), ), ], ), ], ), ), const SizedBox(width: 15), Expanded( child: Column( children: List.generate(filtered.length, (index) { Color c = index == 0 ? AppColors.rank1 : (index == 1 ? AppColors.rank2 : AppColors.rank3); return _buildTop3Item(filtered[index], c, index == 0); }), ), ), ], ), ], ), ); } Widget _buildTop3Item(Map pred, Color color, bool isTop) { return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row( children: [ Container( width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 8), Expanded( child: Text( pred['label'], maxLines: 1, style: GoogleFonts.montserrat( fontSize: 11, color: isTop ? AppColors.textDark : AppColors.textGrey, fontWeight: isTop ? FontWeight.bold : FontWeight.w500, ), ), ), const SizedBox(width: 4), Text( "${(pred['score'] * 100).toStringAsFixed(2)}%", style: GoogleFonts.montserrat( fontSize: 11, fontWeight: FontWeight.bold, color: color, ), ), ], ), ); } Widget _buildDetailBox(String t, String v, IconData i) { return Expanded( child: Container( padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10), decoration: BoxDecoration( color: AppColors.buttonCream.withOpacity(0.4), borderRadius: BorderRadius.circular(20), ), child: Column( children: [ Icon(i, color: AppColors.brownMain, size: 28), const SizedBox(height: 8), Text( t, style: GoogleFonts.montserrat( fontSize: 10, color: AppColors.textGrey, fontWeight: FontWeight.w600, ), ), Text( v, textAlign: TextAlign.center, style: GoogleFonts.montserrat( fontSize: 14, fontWeight: FontWeight.bold, color: AppColors.brownMain, ), ), ], ), ), ); } }