static_capture_screen.dart 15 KB

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