static_capture_screen.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import 'dart:io';
  2. import 'dart:ui';
  3. import 'package:flutter/material.dart';
  4. import 'package:camera/camera.dart';
  5. import 'package:path_provider/path_provider.dart';
  6. import 'package:path/path.dart' as p;
  7. import 'package:permission_handler/permission_handler.dart';
  8. import '../services/tflite_service.dart';
  9. import '../services/database_helper.dart';
  10. import '../models/palm_record.dart';
  11. class StaticCaptureScreen extends StatefulWidget {
  12. const StaticCaptureScreen({super.key});
  13. @override
  14. State<StaticCaptureScreen> createState() => _StaticCaptureScreenState();
  15. }
  16. class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
  17. CameraController? _controller;
  18. final TfliteService _tfliteService = TfliteService();
  19. final DatabaseHelper _dbHelper = DatabaseHelper();
  20. bool _isInitialized = false;
  21. bool _isAnalyzing = false;
  22. XFile? _capturedPhoto;
  23. List<DetectionResult>? _detections;
  24. String? _errorMessage;
  25. @override
  26. void initState() {
  27. super.initState();
  28. _initializeCamera();
  29. }
  30. Future<void> _initializeCamera() async {
  31. final status = await Permission.camera.request();
  32. if (status.isDenied) {
  33. if (mounted) {
  34. setState(() => _errorMessage = "Camera permission denied.");
  35. }
  36. return;
  37. }
  38. final cameras = await availableCameras();
  39. if (cameras.isEmpty) {
  40. if (mounted) {
  41. setState(() => _errorMessage = "No cameras found.");
  42. }
  43. return;
  44. }
  45. _controller = CameraController(
  46. cameras[0],
  47. ResolutionPreset.high,
  48. enableAudio: false,
  49. );
  50. try {
  51. await _controller!.initialize();
  52. await _tfliteService.initModel();
  53. if (mounted) {
  54. setState(() => _isInitialized = true);
  55. }
  56. } catch (e) {
  57. if (mounted) {
  58. setState(() => _errorMessage = "Camera init error: $e");
  59. }
  60. }
  61. }
  62. Future<void> _takeAndAnalyze() async {
  63. if (_controller == null || !_controller!.value.isInitialized || _isAnalyzing) return;
  64. setState(() {
  65. _isAnalyzing = true;
  66. _errorMessage = null;
  67. _detections = null;
  68. });
  69. try {
  70. // 1. Capture Photo
  71. final XFile photo = await _controller!.takePicture();
  72. if (mounted) {
  73. setState(() => _capturedPhoto = photo);
  74. }
  75. // 2. Run Inference
  76. final detections = await _tfliteService.runInference(photo.path);
  77. if (detections.isNotEmpty) {
  78. // 3. Persist Image
  79. final appDocDir = await getApplicationDocumentsDirectory();
  80. final fileName = p.basename(photo.path);
  81. final persistentPath = p.join(appDocDir.path, 'palm_static_${DateTime.now().millisecondsSinceEpoch}_$fileName');
  82. await File(photo.path).copy(persistentPath);
  83. // 4. Save to Database
  84. final best = detections.first;
  85. final record = PalmRecord(
  86. imagePath: persistentPath,
  87. ripenessClass: best.className,
  88. confidence: best.confidence,
  89. timestamp: DateTime.now(),
  90. x1: best.normalizedBox.left,
  91. y1: best.normalizedBox.top,
  92. x2: best.normalizedBox.right,
  93. y2: best.normalizedBox.bottom,
  94. detections: detections.map((d) => {
  95. 'className': d.className,
  96. 'classIndex': d.classIndex,
  97. 'confidence': d.confidence,
  98. 'x1': d.normalizedBox.left,
  99. 'y1': d.normalizedBox.top,
  100. 'x2': d.normalizedBox.right,
  101. 'y2': d.normalizedBox.bottom,
  102. }).toList(),
  103. );
  104. await _dbHelper.insertRecord(record);
  105. if (mounted) {
  106. setState(() {
  107. _detections = detections;
  108. _capturedPhoto = XFile(persistentPath);
  109. });
  110. _showResultSheet(record);
  111. }
  112. } else {
  113. if (mounted) {
  114. setState(() => _errorMessage = "No palm bunches detected.");
  115. }
  116. }
  117. } catch (e) {
  118. if (mounted) {
  119. setState(() => _errorMessage = "Error during capture/analysis: $e");
  120. }
  121. } finally {
  122. if (mounted) {
  123. setState(() => _isAnalyzing = false);
  124. }
  125. }
  126. }
  127. Future<void> _showResultSheet(PalmRecord record) async {
  128. await showModalBottomSheet(
  129. context: context,
  130. isScrollControlled: true,
  131. backgroundColor: Colors.transparent,
  132. builder: (context) => _ResultSheet(record: record),
  133. );
  134. // Auto-reset once dismissed
  135. if (mounted) {
  136. setState(() {
  137. _capturedPhoto = null;
  138. _detections = null;
  139. });
  140. }
  141. }
  142. @override
  143. Widget build(BuildContext context) {
  144. if (_errorMessage != null) {
  145. return Scaffold(
  146. appBar: AppBar(title: const Text('Capture & Analyze')),
  147. body: Center(
  148. child: Column(
  149. mainAxisAlignment: MainAxisAlignment.center,
  150. children: [
  151. Text(_errorMessage!, style: const TextStyle(color: Colors.red)),
  152. const SizedBox(height: 16),
  153. ElevatedButton(onPressed: _initializeCamera, child: const Text("Retry")),
  154. ],
  155. ),
  156. ),
  157. );
  158. }
  159. if (!_isInitialized || _controller == null) {
  160. return const Scaffold(body: Center(child: CircularProgressIndicator()));
  161. }
  162. return Scaffold(
  163. backgroundColor: Colors.black,
  164. body: Stack(
  165. children: [
  166. // Camera Preview or Captured Image
  167. Center(
  168. child: _capturedPhoto != null && _detections != null
  169. ? AspectRatio(
  170. aspectRatio: _controller!.value.aspectRatio,
  171. child: Stack(
  172. children: [
  173. Image.file(File(_capturedPhoto!.path), fit: BoxFit.cover, width: double.infinity),
  174. Positioned.fill(
  175. child: LayoutBuilder(
  176. builder: (context, constraints) {
  177. return Stack(
  178. children: _detections!
  179. .map((d) => _buildBoundingBox(d, constraints))
  180. .toList(),
  181. );
  182. },
  183. ),
  184. ),
  185. ],
  186. ),
  187. )
  188. : CameraPreview(_controller!),
  189. ),
  190. // Top UI
  191. Positioned(
  192. top: 40,
  193. left: 20,
  194. right: 20,
  195. child: Row(
  196. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  197. children: [
  198. CircleAvatar(
  199. backgroundColor: Colors.black54,
  200. child: IconButton(
  201. icon: const Icon(Icons.arrow_back, color: Colors.white),
  202. onPressed: () => Navigator.pop(context),
  203. ),
  204. ),
  205. if (_capturedPhoto != null)
  206. CircleAvatar(
  207. backgroundColor: Colors.black54,
  208. child: IconButton(
  209. icon: const Icon(Icons.refresh, color: Colors.white),
  210. onPressed: () => setState(() {
  211. _capturedPhoto = null;
  212. _detections = null;
  213. }),
  214. ),
  215. ),
  216. ],
  217. ),
  218. ),
  219. // Capture Button
  220. if (_capturedPhoto == null && !_isAnalyzing)
  221. Align(
  222. alignment: Alignment.bottomCenter,
  223. child: Padding(
  224. padding: const EdgeInsets.only(bottom: 48.0),
  225. child: GestureDetector(
  226. onTap: _takeAndAnalyze,
  227. child: Container(
  228. height: 80,
  229. width: 80,
  230. padding: const EdgeInsets.all(4),
  231. decoration: BoxDecoration(
  232. shape: BoxShape.circle,
  233. border: Border.all(color: Colors.white, width: 4),
  234. ),
  235. child: Container(
  236. decoration: const BoxDecoration(
  237. color: Colors.white,
  238. shape: BoxShape.circle,
  239. ),
  240. ),
  241. ),
  242. ),
  243. ),
  244. ),
  245. // Analyzing Overlay
  246. if (_isAnalyzing) _buildLoadingOverlay(),
  247. ],
  248. ),
  249. );
  250. }
  251. Widget _buildBoundingBox(DetectionResult detection, BoxConstraints constraints) {
  252. final rect = detection.normalizedBox;
  253. final color = detection.getStatusColor();
  254. return Positioned(
  255. left: rect.left * constraints.maxWidth,
  256. top: rect.top * constraints.maxHeight,
  257. width: rect.width * constraints.maxWidth,
  258. height: rect.height * constraints.maxHeight,
  259. child: Container(
  260. decoration: BoxDecoration(
  261. border: Border.all(color: color, width: 3),
  262. borderRadius: BorderRadius.circular(4),
  263. ),
  264. child: Align(
  265. alignment: Alignment.topLeft,
  266. child: Container(
  267. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
  268. color: color,
  269. child: Text(
  270. "${detection.className} ${(detection.confidence * 100).toStringAsFixed(0)}%",
  271. style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
  272. ),
  273. ),
  274. ),
  275. ),
  276. );
  277. }
  278. Widget _buildLoadingOverlay() {
  279. return Positioned.fill(
  280. child: BackdropFilter(
  281. filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
  282. child: Container(
  283. color: Colors.black.withOpacity(0.5),
  284. child: const Center(
  285. child: Column(
  286. mainAxisSize: MainAxisSize.min,
  287. children: [
  288. CircularProgressIndicator(color: Colors.white, strokeWidth: 6),
  289. SizedBox(height: 24),
  290. Text(
  291. "AI is Analyzing...",
  292. style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
  293. ),
  294. ],
  295. ),
  296. ),
  297. ),
  298. ),
  299. );
  300. }
  301. @override
  302. void dispose() {
  303. _controller?.dispose();
  304. _tfliteService.dispose();
  305. super.dispose();
  306. }
  307. }
  308. class _ResultSheet extends StatelessWidget {
  309. final PalmRecord record;
  310. const _ResultSheet({required this.record});
  311. @override
  312. Widget build(BuildContext context) {
  313. Color statusColor = const Color(0xFFFF9800); // Default orange
  314. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') {
  315. statusColor = const Color(0xFFF44336);
  316. } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') {
  317. statusColor = const Color(0xFF4CAF50);
  318. }
  319. final bool isWarning = record.ripenessClass == 'Unripe' || record.ripenessClass == 'Underripe';
  320. return Container(
  321. decoration: const BoxDecoration(
  322. color: Colors.white,
  323. borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
  324. ),
  325. padding: const EdgeInsets.all(24),
  326. child: Column(
  327. mainAxisSize: MainAxisSize.min,
  328. children: [
  329. Container(
  330. width: 40,
  331. height: 4,
  332. decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)),
  333. ),
  334. const SizedBox(height: 24),
  335. Row(
  336. children: [
  337. Icon(Icons.analytics, color: statusColor, size: 32),
  338. const SizedBox(width: 12),
  339. const Text("Analysis Result", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
  340. ],
  341. ),
  342. const SizedBox(height: 24),
  343. _buildInfoRow("Ripeness", record.ripenessClass, statusColor),
  344. _buildInfoRow("Confidence", "${(record.confidence * 100).toStringAsFixed(1)}%", Colors.grey[700]!),
  345. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
  346. _buildAlertCard("HEALTH ALERT", "Abnormal features detected. Check palm health.", Colors.red),
  347. if (isWarning)
  348. _buildAlertCard("YIELD WARNING", "Potential Yield Loss due to premature harvest.", Colors.orange),
  349. const SizedBox(height: 32),
  350. SizedBox(
  351. width: double.infinity,
  352. child: ElevatedButton(
  353. onPressed: () => Navigator.pop(context),
  354. style: ElevatedButton.styleFrom(
  355. backgroundColor: statusColor,
  356. foregroundColor: Colors.white,
  357. padding: const EdgeInsets.symmetric(vertical: 16),
  358. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  359. ),
  360. child: const Text("Acknowledge", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
  361. ),
  362. ),
  363. const SizedBox(height: 12),
  364. ],
  365. ),
  366. );
  367. }
  368. Widget _buildInfoRow(String label, String value, Color color) {
  369. return Padding(
  370. padding: const EdgeInsets.symmetric(vertical: 8.0),
  371. child: Row(
  372. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  373. children: [
  374. Text(label, style: const TextStyle(fontSize: 16, color: Colors.grey)),
  375. Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
  376. ],
  377. ),
  378. );
  379. }
  380. Widget _buildAlertCard(String title, String message, Color color) {
  381. return Container(
  382. margin: const EdgeInsets.only(top: 16),
  383. padding: const EdgeInsets.all(16),
  384. decoration: BoxDecoration(
  385. color: color.withOpacity(0.1),
  386. borderRadius: BorderRadius.circular(12),
  387. border: Border.all(color: color.withOpacity(0.3)),
  388. ),
  389. child: Row(
  390. children: [
  391. Icon(Icons.warning_amber_rounded, color: color),
  392. const SizedBox(width: 12),
  393. Expanded(
  394. child: Column(
  395. crossAxisAlignment: CrossAxisAlignment.start,
  396. children: [
  397. Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14)),
  398. Text(message, style: TextStyle(color: color.withOpacity(0.8), fontSize: 12)),
  399. ],
  400. ),
  401. ),
  402. ],
  403. ),
  404. );
  405. }
  406. }