static_capture_screen.dart 14 KB

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