analysis_screen.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. import 'dart:io';
  2. import 'dart:math';
  3. import 'dart:ui';
  4. import 'package:flutter/material.dart';
  5. import 'package:path_provider/path_provider.dart';
  6. import 'package:path/path.dart' as p;
  7. import '../services/tflite_service.dart';
  8. import '../services/database_helper.dart';
  9. import '../models/palm_record.dart';
  10. import '../widgets/palm_bounding_box.dart';
  11. import '../services/app_strings.dart';
  12. import '../theme/locale_controller.dart';
  13. import 'batch_report_screen.dart';
  14. class AnalysisScreen extends StatefulWidget {
  15. const AnalysisScreen({super.key});
  16. @override
  17. State<AnalysisScreen> createState() => _AnalysisScreenState();
  18. }
  19. class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProviderStateMixin {
  20. final TfliteService _tfliteService = TfliteService();
  21. final DatabaseHelper _dbHelper = DatabaseHelper();
  22. late AnimationController _scanningController;
  23. bool _isAnalyzing = false;
  24. File? _selectedImage;
  25. List<DetectionResult>? _detections;
  26. String? _errorMessage;
  27. // One batch groups every image analyzed during this screen visit.
  28. late final String _batchId = 'batch_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1 << 32)}';
  29. // Multi-image pick progress.
  30. int _batchTotal = 0;
  31. int _batchProcessed = 0;
  32. int _batchSkipped = 0;
  33. @override
  34. void initState() {
  35. super.initState();
  36. _scanningController = AnimationController(
  37. vsync: this,
  38. duration: const Duration(seconds: 2),
  39. );
  40. _initModel();
  41. }
  42. Future<void> _initModel() async {
  43. try {
  44. await _tfliteService.initModel();
  45. } catch (e) {
  46. if (mounted) {
  47. setState(() {
  48. _errorMessage = AppStrings.tp('analysis_init_error', {'error': '$e'});
  49. });
  50. }
  51. }
  52. }
  53. Future<void> _processGalleryImage() async {
  54. final images = await _tfliteService.pickMultiImage();
  55. if (images.isEmpty) return;
  56. if (mounted) {
  57. setState(() {
  58. _isAnalyzing = true;
  59. _detections = null;
  60. _errorMessage = null;
  61. _batchTotal = images.length;
  62. _batchProcessed = 0;
  63. _batchSkipped = 0;
  64. });
  65. _scanningController.repeat(reverse: true);
  66. // Give the UI a frame to paint the loading state
  67. await Future.delayed(const Duration(milliseconds: 50));
  68. }
  69. for (final image in images) {
  70. if (!mounted) return;
  71. setState(() => _selectedImage = File(image.path));
  72. try {
  73. // 1. Run Inference
  74. final stopwatch = Stopwatch()..start();
  75. final detections = await _tfliteService.runInference(image.path);
  76. stopwatch.stop();
  77. if (detections.isEmpty) {
  78. setState(() => _batchSkipped++);
  79. continue;
  80. }
  81. // 2. Persist Image to Documents Directory
  82. final appDocDir = await getApplicationDocumentsDirectory();
  83. final fileName = p.basename(image.path);
  84. final persistentPath = p.join(appDocDir.path, 'palm_${DateTime.now().millisecondsSinceEpoch}_$fileName');
  85. await File(image.path).copy(persistentPath);
  86. // 3. Update UI
  87. if (mounted) {
  88. setState(() {
  89. _detections = detections;
  90. _selectedImage = File(persistentPath);
  91. });
  92. }
  93. // 4. Save to Database
  94. final best = detections.first;
  95. final record = PalmRecord(
  96. imagePath: persistentPath,
  97. ripenessClass: best.className,
  98. confidence: best.confidence,
  99. timestamp: DateTime.now(),
  100. x1: best.normalizedBox.left,
  101. y1: best.normalizedBox.top,
  102. x2: best.normalizedBox.right,
  103. y2: best.normalizedBox.bottom,
  104. detections: detections.map((d) => {
  105. 'className': d.className,
  106. 'classIndex': d.classIndex,
  107. 'confidence': d.confidence,
  108. 'x1': d.normalizedBox.left,
  109. 'y1': d.normalizedBox.top,
  110. 'x2': d.normalizedBox.right,
  111. 'y2': d.normalizedBox.bottom,
  112. }).toList(),
  113. batchId: _batchId,
  114. inferenceMs: stopwatch.elapsedMilliseconds,
  115. );
  116. await _dbHelper.insertRecord(record);
  117. setState(() => _batchProcessed++);
  118. } catch (e) {
  119. setState(() => _batchSkipped++);
  120. debugPrint('Analysis error for ${image.path}: $e');
  121. }
  122. }
  123. if (mounted) {
  124. setState(() {
  125. _isAnalyzing = false;
  126. });
  127. _scanningController.stop();
  128. _scanningController.reset();
  129. if (_batchProcessed > 0) {
  130. Navigator.push(
  131. context,
  132. MaterialPageRoute(builder: (context) => BatchReportScreen(batchId: _batchId)),
  133. );
  134. } else {
  135. setState(() {
  136. _errorMessage = AppStrings.t('analysis_no_detections_batch');
  137. });
  138. }
  139. }
  140. }
  141. bool _isHealthAlert(String label) {
  142. return label == 'Empty_Bunch' || label == 'Abnormal';
  143. }
  144. @override
  145. Widget build(BuildContext context) {
  146. return ValueListenableBuilder<String>(
  147. valueListenable: LocaleController.localeCode,
  148. builder: (context, _, _) => _buildScaffold(context),
  149. );
  150. }
  151. Widget _buildScaffold(BuildContext context) {
  152. final colorScheme = Theme.of(context).colorScheme;
  153. return Scaffold(
  154. appBar: AppBar(title: Text(AppStrings.t('analysis_appbar_title'))),
  155. body: Stack(
  156. children: [
  157. Column(
  158. children: [
  159. Expanded(
  160. child: Center(
  161. child: _selectedImage == null
  162. ? Text(AppStrings.t('analysis_select_photo_hint'))
  163. : AspectRatio(
  164. aspectRatio: 1.0,
  165. child: Padding(
  166. padding: const EdgeInsets.all(8.0),
  167. child: Stack(
  168. children: [
  169. // Main Image
  170. ClipRRect(
  171. borderRadius: BorderRadius.circular(12),
  172. child: Image.file(
  173. _selectedImage!,
  174. fit: BoxFit.contain,
  175. width: double.infinity,
  176. height: double.infinity,
  177. ),
  178. ),
  179. // Bounding Box Overlays
  180. if (_detections != null)
  181. Positioned.fill(
  182. child: LayoutBuilder(
  183. builder: (context, constraints) {
  184. return Stack(
  185. children: _detections!
  186. .map((d) => PalmBoundingBox(
  187. normalizedRect: d.normalizedBox,
  188. label: d.className,
  189. confidence: d.confidence,
  190. constraints: constraints,
  191. ))
  192. .toList(),
  193. );
  194. },
  195. ),
  196. ),
  197. // Scanning Animation
  198. if (_isAnalyzing)
  199. _ScanningOverlay(animation: _scanningController, color: colorScheme.primary),
  200. ],
  201. ),
  202. ),
  203. ),
  204. ),
  205. ),
  206. if (_detections != null) _buildResultSummary(),
  207. Padding(
  208. padding: const EdgeInsets.all(32.0),
  209. child: ElevatedButton.icon(
  210. onPressed: _isAnalyzing ? null : _processGalleryImage,
  211. icon: const Icon(Icons.add_a_photo),
  212. label: Text(AppStrings.t('analysis_pick_button')),
  213. style: ElevatedButton.styleFrom(
  214. minimumSize: const Size(double.infinity, 54),
  215. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  216. ),
  217. ),
  218. ),
  219. ],
  220. ),
  221. if (_isAnalyzing) _buildLoadingOverlay(),
  222. if (_errorMessage != null) _buildErrorToast(),
  223. ],
  224. ),
  225. );
  226. }
  227. // Bounding box logic replaced by shared PalmBoundingBox widget
  228. // Removed _getStatusColor local method in favor of DetectionResult.getStatusColor()
  229. Widget _buildResultSummary() {
  230. final colorScheme = Theme.of(context).colorScheme;
  231. final best = _detections!.first;
  232. return Container(
  233. padding: const EdgeInsets.all(16),
  234. margin: const EdgeInsets.symmetric(horizontal: 16),
  235. decoration: BoxDecoration(
  236. color: colorScheme.surfaceContainerHighest,
  237. borderRadius: BorderRadius.circular(12),
  238. boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 10)],
  239. ),
  240. child: Column(
  241. children: [
  242. Row(
  243. mainAxisAlignment: MainAxisAlignment.center,
  244. children: [
  245. Icon(Icons.check_circle, color: best.getStatusColor(), size: 32),
  246. const SizedBox(width: 12),
  247. Text(
  248. AppStrings.className(best.className),
  249. style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
  250. ),
  251. ],
  252. ),
  253. Text(
  254. AppStrings.tp('confidence_label', {'percent': (best.confidence * 100).toStringAsFixed(1)}),
  255. style: TextStyle(fontSize: 14, color: colorScheme.onSurface.withValues(alpha: 0.6)),
  256. ),
  257. if (_isHealthAlert(best.className))
  258. Padding(
  259. padding: const EdgeInsets.only(top: 8.0),
  260. child: Text(
  261. AppStrings.t('health_alert_abnormal'),
  262. style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
  263. ),
  264. ),
  265. if (_detections!.length > 1)
  266. Padding(
  267. padding: const EdgeInsets.only(top: 8.0),
  268. child: Text(
  269. AppStrings.tp('analysis_bunches_detected', {'count': '${_detections!.length}'}),
  270. style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.6)),
  271. ),
  272. ),
  273. ],
  274. ),
  275. );
  276. }
  277. Widget _buildLoadingOverlay() {
  278. final isBatch = _batchTotal > 1;
  279. return Positioned.fill(
  280. child: BackdropFilter(
  281. filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
  282. child: Container(
  283. color: Colors.black.withValues(alpha: 0.3),
  284. child: Center(
  285. child: Column(
  286. mainAxisSize: MainAxisSize.min,
  287. children: [
  288. const CircularProgressIndicator(
  289. color: Colors.white,
  290. strokeWidth: 6,
  291. ),
  292. const SizedBox(height: 24),
  293. Text(
  294. isBatch
  295. ? AppStrings.tp('analysis_progress_of', {
  296. 'current': '${(_batchProcessed + _batchSkipped) + 1}',
  297. 'total': '$_batchTotal',
  298. })
  299. : AppStrings.t('ai_analyzing'),
  300. style: const TextStyle(
  301. color: Colors.white,
  302. fontSize: 22,
  303. fontWeight: FontWeight.bold,
  304. letterSpacing: 1.2,
  305. ),
  306. ),
  307. Text(
  308. isBatch
  309. ? AppStrings.tp('analysis_progress_stats', {'processed': '$_batchProcessed', 'skipped': '$_batchSkipped'})
  310. : AppStrings.t('analysis_optimizing_hint'),
  311. style: const TextStyle(color: Colors.white70, fontSize: 14),
  312. ),
  313. ],
  314. ),
  315. ),
  316. ),
  317. ),
  318. );
  319. }
  320. Widget _buildErrorToast() {
  321. final colorScheme = Theme.of(context).colorScheme;
  322. return Positioned(
  323. bottom: 20,
  324. left: 20,
  325. right: 20,
  326. child: Container(
  327. padding: const EdgeInsets.all(16),
  328. decoration: BoxDecoration(
  329. color: colorScheme.error,
  330. borderRadius: BorderRadius.circular(12),
  331. boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8)],
  332. ),
  333. child: Column(
  334. mainAxisSize: MainAxisSize.min,
  335. crossAxisAlignment: CrossAxisAlignment.start,
  336. children: [
  337. Row(
  338. children: [
  339. Icon(Icons.error_outline, color: colorScheme.onError),
  340. const SizedBox(width: 8),
  341. Text(AppStrings.t('error_details_label'), style: TextStyle(color: colorScheme.onError, fontWeight: FontWeight.bold)),
  342. ],
  343. ),
  344. const SizedBox(height: 8),
  345. Text(
  346. _errorMessage!,
  347. style: TextStyle(color: colorScheme.onError, fontSize: 12),
  348. ),
  349. const SizedBox(height: 8),
  350. TextButton(
  351. onPressed: () => setState(() => _errorMessage = null),
  352. child: Text(AppStrings.t('dismiss_button'), style: TextStyle(color: colorScheme.onError)),
  353. ),
  354. ],
  355. ),
  356. ),
  357. );
  358. }
  359. @override
  360. void dispose() {
  361. _scanningController.dispose();
  362. _tfliteService.dispose();
  363. super.dispose();
  364. }
  365. }
  366. class _ScanningOverlay extends StatelessWidget {
  367. final Animation<double> animation;
  368. final Color color;
  369. const _ScanningOverlay({required this.animation, required this.color});
  370. @override
  371. Widget build(BuildContext context) {
  372. return AnimatedBuilder(
  373. animation: animation,
  374. builder: (context, child) {
  375. return Stack(
  376. children: [
  377. Positioned(
  378. top: animation.value * MediaQuery.of(context).size.width, // Rough estimation since AspectRatio is 1.0
  379. left: 0,
  380. right: 0,
  381. child: Container(
  382. height: 4,
  383. decoration: BoxDecoration(
  384. gradient: LinearGradient(
  385. colors: [
  386. Colors.transparent,
  387. color.withValues(alpha: 0.8),
  388. Colors.transparent,
  389. ],
  390. ),
  391. boxShadow: [
  392. BoxShadow(
  393. color: color.withValues(alpha: 0.6),
  394. blurRadius: 15,
  395. spreadRadius: 2,
  396. ),
  397. ],
  398. ),
  399. ),
  400. ),
  401. ],
  402. );
  403. },
  404. );
  405. }
  406. }