|
@@ -1,17 +1,13 @@
|
|
|
import 'dart:io';
|
|
import 'dart:io';
|
|
|
-import 'dart:math';
|
|
|
|
|
-import 'dart:ui';
|
|
|
|
|
import 'dart:async';
|
|
import 'dart:async';
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
import 'package:camera/camera.dart';
|
|
import 'package:camera/camera.dart';
|
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
-import 'package:path_provider/path_provider.dart';
|
|
|
|
|
-import 'package:path/path.dart' as p;
|
|
|
|
|
import '../services/tflite_service.dart';
|
|
import '../services/tflite_service.dart';
|
|
|
-import '../services/database_helper.dart';
|
|
|
|
|
-import '../models/palm_record.dart';
|
|
|
|
|
import '../widgets/palm_bounding_box.dart';
|
|
import '../widgets/palm_bounding_box.dart';
|
|
|
import '../constants/mpob_colors.dart';
|
|
import '../constants/mpob_colors.dart';
|
|
|
|
|
+import '../services/app_strings.dart';
|
|
|
|
|
+import '../theme/locale_controller.dart';
|
|
|
|
|
|
|
|
enum DetectionState { searching, locking, capturing, cooldown }
|
|
enum DetectionState { searching, locking, capturing, cooldown }
|
|
|
|
|
|
|
@@ -25,7 +21,6 @@ class LiveAnalysisScreen extends StatefulWidget {
|
|
|
class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
CameraController? _controller;
|
|
CameraController? _controller;
|
|
|
final TfliteService _tfliteService = TfliteService();
|
|
final TfliteService _tfliteService = TfliteService();
|
|
|
- final DatabaseHelper _dbHelper = DatabaseHelper();
|
|
|
|
|
|
|
|
|
|
bool _isInitialized = false;
|
|
bool _isInitialized = false;
|
|
|
bool _isProcessing = false;
|
|
bool _isProcessing = false;
|
|
@@ -45,8 +40,14 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
double _lockProgress = 0.0;
|
|
double _lockProgress = 0.0;
|
|
|
bool _showFlash = false;
|
|
bool _showFlash = false;
|
|
|
|
|
|
|
|
- // One batch groups every capture made during this screen visit.
|
|
|
|
|
- late final String _batchId = 'batch_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1 << 32)}';
|
|
|
|
|
|
|
+ // Raw CameraImage stream frames arrive in the sensor's native (landscape)
|
|
|
|
|
+ // orientation, never pre-rotated for display — unlike CameraPreview's own
|
|
|
|
|
+ // texture, which is rotated internally to appear upright. Detection boxes
|
|
|
|
|
+ // decoded from the stream must be rotated by this same amount before
|
|
|
|
|
+ // rendering, or they land in the wrong place/orientation relative to what's
|
|
|
|
|
+ // shown on screen. Defaults to 90 (the standard rear-camera value on
|
|
|
|
|
+ // Android) until the actual camera's sensorOrientation is known.
|
|
|
|
|
+ int _sensorOrientation = 90;
|
|
|
|
|
|
|
|
@override
|
|
@override
|
|
|
void initState() {
|
|
void initState() {
|
|
@@ -61,6 +62,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
final cameras = await availableCameras();
|
|
final cameras = await availableCameras();
|
|
|
if (cameras.isEmpty) return;
|
|
if (cameras.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
+ _sensorOrientation = cameras[0].sensorOrientation;
|
|
|
|
|
+
|
|
|
_controller = CameraController(
|
|
_controller = CameraController(
|
|
|
cameras[0],
|
|
cameras[0],
|
|
|
ResolutionPreset.medium, // Upgraded resolution for YOLO26 performance
|
|
ResolutionPreset.medium, // Upgraded resolution for YOLO26 performance
|
|
@@ -80,7 +83,7 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- print("Camera init error: $e");
|
|
|
|
|
|
|
+ debugPrint("Camera init error: $e");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -96,8 +99,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
Future<void> _processStreamFrame(CameraImage image) async {
|
|
Future<void> _processStreamFrame(CameraImage image) async {
|
|
|
setState(() => _isProcessing = true);
|
|
setState(() => _isProcessing = true);
|
|
|
try {
|
|
try {
|
|
|
- final detections = await _tfliteService.runInferenceOnStream(image);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ final detections = _rotateDetections(await _tfliteService.runInferenceOnStream(image));
|
|
|
|
|
+
|
|
|
bool currentFrameHasFruit = false;
|
|
bool currentFrameHasFruit = false;
|
|
|
if (detections.isNotEmpty) {
|
|
if (detections.isNotEmpty) {
|
|
|
currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
|
|
currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
|
|
@@ -127,7 +130,7 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
// Removed the old strict cancel logic.
|
|
// Removed the old strict cancel logic.
|
|
|
// _startLockTimer now safely handles momentum drain.
|
|
// _startLockTimer now safely handles momentum drain.
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- print("Stream processing error: $e");
|
|
|
|
|
|
|
+ debugPrint("Stream processing error: $e");
|
|
|
} finally {
|
|
} finally {
|
|
|
if (mounted) {
|
|
if (mounted) {
|
|
|
setState(() => _isProcessing = false);
|
|
setState(() => _isProcessing = false);
|
|
@@ -135,6 +138,37 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Rotates each detection's normalized box from raw sensor-space into
|
|
|
|
|
+ /// display-space, matching the rotation CameraPreview's own texture
|
|
|
|
|
+ /// undergoes internally. Confidence/class are untouched.
|
|
|
|
|
+ List<DetectionResult> _rotateDetections(List<DetectionResult> detections) {
|
|
|
|
|
+ final quarterTurns = (_sensorOrientation ~/ 90) % 4;
|
|
|
|
|
+ if (quarterTurns == 0) return detections;
|
|
|
|
|
+ return detections
|
|
|
|
|
+ .map((d) => DetectionResult(
|
|
|
|
|
+ className: d.className,
|
|
|
|
|
+ classIndex: d.classIndex,
|
|
|
|
|
+ confidence: d.confidence,
|
|
|
|
|
+ normalizedBox: _rotateNormalizedRect(d.normalizedBox, quarterTurns),
|
|
|
|
|
+ ))
|
|
|
|
|
+ .toList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Rotates an axis-aligned rect in normalized [0,1]x[0,1] space by
|
|
|
|
|
+ /// [quarterTurns] * 90° clockwise.
|
|
|
|
|
+ Rect _rotateNormalizedRect(Rect r, int quarterTurns) {
|
|
|
|
|
+ switch (quarterTurns) {
|
|
|
|
|
+ case 1: // 90° clockwise
|
|
|
|
|
+ return Rect.fromLTRB(1 - r.bottom, r.left, 1 - r.top, r.right);
|
|
|
|
|
+ case 2: // 180°
|
|
|
|
|
+ return Rect.fromLTRB(1 - r.right, 1 - r.bottom, 1 - r.left, 1 - r.top);
|
|
|
|
|
+ case 3: // 270° clockwise (90° counter-clockwise)
|
|
|
|
|
+ return Rect.fromLTRB(r.top, 1 - r.right, r.bottom, 1 - r.left);
|
|
|
|
|
+ default:
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
void _startLockTimer() {
|
|
void _startLockTimer() {
|
|
|
_lockTimer?.cancel();
|
|
_lockTimer?.cancel();
|
|
|
const duration = Duration(milliseconds: 100);
|
|
const duration = Duration(milliseconds: 100);
|
|
@@ -174,11 +208,6 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- void _cancelLockTimer() {
|
|
|
|
|
- _lockTimer?.cancel();
|
|
|
|
|
- _lockTimer = null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
Future<void> _triggerCapture() async {
|
|
Future<void> _triggerCapture() async {
|
|
|
setState(() {
|
|
setState(() {
|
|
|
_state = DetectionState.capturing;
|
|
_state = DetectionState.capturing;
|
|
@@ -194,12 +223,15 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
await _captureAndAnalyze();
|
|
await _captureAndAnalyze();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Live Inference is a quick verification tool, not a capture mode — the
|
|
|
|
|
+ /// result is shown once and then discarded. Nothing is written to history;
|
|
|
|
|
+ /// use Snap & Analyze if you want the result kept.
|
|
|
Future<void> _captureAndAnalyze() async {
|
|
Future<void> _captureAndAnalyze() async {
|
|
|
if (_controller == null || !_controller!.value.isInitialized) return;
|
|
if (_controller == null || !_controller!.value.isInitialized) return;
|
|
|
|
|
|
|
|
// 1. Stop stream to avoid resource conflict
|
|
// 1. Stop stream to avoid resource conflict
|
|
|
await _controller!.stopImageStream();
|
|
await _controller!.stopImageStream();
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
if (!mounted) return;
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
@@ -207,57 +239,27 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
final XFile photo = await _controller!.takePicture();
|
|
final XFile photo = await _controller!.takePicture();
|
|
|
|
|
|
|
|
// 3. Run final inference on high-res
|
|
// 3. Run final inference on high-res
|
|
|
- final stopwatch = Stopwatch()..start();
|
|
|
|
|
final detections = await _tfliteService.runInference(photo.path);
|
|
final detections = await _tfliteService.runInference(photo.path);
|
|
|
- stopwatch.stop();
|
|
|
|
|
|
|
|
|
|
- if (detections.isNotEmpty) {
|
|
|
|
|
- // 4. Archive
|
|
|
|
|
- final appDocDir = await getApplicationDocumentsDirectory();
|
|
|
|
|
- final fileName = p.basename(photo.path);
|
|
|
|
|
- final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName');
|
|
|
|
|
- await File(photo.path).copy(persistentPath);
|
|
|
|
|
|
|
+ // 4. Discard the temp capture — nothing persists past this quick check.
|
|
|
|
|
+ unawaited(File(photo.path).delete().catchError((_) => File(photo.path)));
|
|
|
|
|
|
|
|
|
|
+ if (detections.isNotEmpty) {
|
|
|
final best = detections.first;
|
|
final best = detections.first;
|
|
|
- final record = PalmRecord(
|
|
|
|
|
- imagePath: persistentPath,
|
|
|
|
|
- ripenessClass: best.className,
|
|
|
|
|
- confidence: best.confidence,
|
|
|
|
|
- timestamp: DateTime.now(),
|
|
|
|
|
- x1: best.normalizedBox.left,
|
|
|
|
|
- y1: best.normalizedBox.top,
|
|
|
|
|
- x2: best.normalizedBox.right,
|
|
|
|
|
- y2: best.normalizedBox.bottom,
|
|
|
|
|
- detections: detections.map((d) => {
|
|
|
|
|
- 'className': d.className,
|
|
|
|
|
- 'classIndex': d.classIndex,
|
|
|
|
|
- 'confidence': d.confidence,
|
|
|
|
|
- 'x1': d.normalizedBox.left,
|
|
|
|
|
- 'y1': d.normalizedBox.top,
|
|
|
|
|
- 'x2': d.normalizedBox.right,
|
|
|
|
|
- 'y2': d.normalizedBox.bottom,
|
|
|
|
|
- }).toList(),
|
|
|
|
|
- batchId: _batchId,
|
|
|
|
|
- inferenceMs: stopwatch.elapsedMilliseconds,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- await _dbHelper.insertRecord(record);
|
|
|
|
|
-
|
|
|
|
|
- // 5. Show result and resume camera
|
|
|
|
|
if (mounted) {
|
|
if (mounted) {
|
|
|
- await _showResultSheet(record);
|
|
|
|
|
|
|
+ await _showResultSheet(best.className, best.confidence);
|
|
|
_startCooldown();
|
|
_startCooldown();
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
if (mounted) {
|
|
if (mounted) {
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
- const SnackBar(content: Text("No palm bunches detected in final snap."))
|
|
|
|
|
|
|
+ SnackBar(content: Text(AppStrings.t('live_no_detections_snackbar')))
|
|
|
);
|
|
);
|
|
|
_startCooldown();
|
|
_startCooldown();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- print("Capture error: $e");
|
|
|
|
|
|
|
+ debugPrint("Capture error: $e");
|
|
|
if (mounted) _startCooldown();
|
|
if (mounted) _startCooldown();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -282,8 +284,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Future<void> _showResultSheet(PalmRecord record) async {
|
|
|
|
|
- final Color statusColor = getStatusColor(record.ripenessClass);
|
|
|
|
|
|
|
+ Future<void> _showResultSheet(String ripenessClass, double confidence) async {
|
|
|
|
|
+ final Color statusColor = getStatusColor(ripenessClass);
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
|
|
|
await showModalBottomSheet(
|
|
await showModalBottomSheet(
|
|
@@ -303,21 +305,24 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
children: [
|
|
children: [
|
|
|
Icon(Icons.check_circle, color: statusColor, size: 64),
|
|
Icon(Icons.check_circle, color: statusColor, size: 64),
|
|
|
const SizedBox(height: 16),
|
|
const SizedBox(height: 16),
|
|
|
- Text(record.ripenessClass, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
|
|
|
|
- Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6))),
|
|
|
|
|
|
|
+ Text(AppStrings.className(ripenessClass), style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ AppStrings.tp('confidence_label', {'percent': (confidence * 100).toStringAsFixed(1)}),
|
|
|
|
|
+ style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6)),
|
|
|
|
|
+ ),
|
|
|
const SizedBox(height: 24),
|
|
const SizedBox(height: 24),
|
|
|
- if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
|
|
|
|
|
|
|
+ if (ripenessClass == 'Empty_Bunch' || ripenessClass == 'Abnormal')
|
|
|
Container(
|
|
Container(
|
|
|
padding: const EdgeInsets.all(12),
|
|
padding: const EdgeInsets.all(12),
|
|
|
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
|
|
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
|
|
|
- child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
|
|
|
|
|
|
|
+ child: Text(AppStrings.t('health_alert_abnormal'), style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
|
|
|
),
|
|
),
|
|
|
const SizedBox(height: 24),
|
|
const SizedBox(height: 24),
|
|
|
SizedBox(
|
|
SizedBox(
|
|
|
width: double.infinity,
|
|
width: double.infinity,
|
|
|
child: ElevatedButton(
|
|
child: ElevatedButton(
|
|
|
onPressed: () => Navigator.pop(context),
|
|
onPressed: () => Navigator.pop(context),
|
|
|
- child: const Text("Done"),
|
|
|
|
|
|
|
+ child: Text(AppStrings.t('done_button')),
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
],
|
|
],
|
|
@@ -328,6 +333,13 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
|
|
|
|
|
@override
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
Widget build(BuildContext context) {
|
|
|
|
|
+ return ValueListenableBuilder<String>(
|
|
|
|
|
+ valueListenable: LocaleController.localeCode,
|
|
|
|
|
+ builder: (context, _, _) => _buildScaffold(context),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Widget _buildScaffold(BuildContext context) {
|
|
|
if (!_isInitialized || _controller == null) {
|
|
if (!_isInitialized || _controller == null) {
|
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
|
}
|
|
}
|
|
@@ -338,30 +350,35 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
backgroundColor: Colors.black,
|
|
backgroundColor: Colors.black,
|
|
|
body: Stack(
|
|
body: Stack(
|
|
|
children: [
|
|
children: [
|
|
|
- // Camera Preview
|
|
|
|
|
|
|
+ // Camera Preview + Bounding Box Overlays. The overlay is passed as
|
|
|
|
|
+ // CameraPreview's own `child` slot (not a separate sibling) so it's
|
|
|
|
|
+ // measured against the exact same orientation-aware AspectRatio box
|
|
|
|
|
+ // CameraPreview computes internally for the texture itself —
|
|
|
|
|
+ // otherwise the overlay measures the full screen while the preview
|
|
|
|
|
+ // is letterboxed/pillarboxed to the camera's real aspect ratio,
|
|
|
|
|
+ // causing misaligned/stretched boxes.
|
|
|
Center(
|
|
Center(
|
|
|
- child: CameraPreview(_controller!),
|
|
|
|
|
- ),
|
|
|
|
|
-
|
|
|
|
|
- // Bounding Box Overlays
|
|
|
|
|
- if (_detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown)
|
|
|
|
|
- Positioned.fill(
|
|
|
|
|
- child: LayoutBuilder(
|
|
|
|
|
- builder: (context, constraints) {
|
|
|
|
|
- return Stack(
|
|
|
|
|
- children: _detections!
|
|
|
|
|
- .map((d) => PalmBoundingBox(
|
|
|
|
|
- normalizedRect: d.normalizedBox,
|
|
|
|
|
- label: d.className,
|
|
|
|
|
- confidence: d.confidence,
|
|
|
|
|
- constraints: constraints,
|
|
|
|
|
- isLocked: (_state == DetectionState.locking || _state == DetectionState.capturing) && d.confidence > _lockThreshold,
|
|
|
|
|
- ))
|
|
|
|
|
- .toList(),
|
|
|
|
|
- );
|
|
|
|
|
- },
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ child: CameraPreview(
|
|
|
|
|
+ _controller!,
|
|
|
|
|
+ child: _detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown
|
|
|
|
|
+ ? LayoutBuilder(
|
|
|
|
|
+ builder: (context, constraints) {
|
|
|
|
|
+ return Stack(
|
|
|
|
|
+ children: _detections!
|
|
|
|
|
+ .map((d) => PalmBoundingBox(
|
|
|
|
|
+ normalizedRect: d.normalizedBox,
|
|
|
|
|
+ label: d.className,
|
|
|
|
|
+ confidence: d.confidence,
|
|
|
|
|
+ constraints: constraints,
|
|
|
|
|
+ isLocked: (_state == DetectionState.locking || _state == DetectionState.capturing) && d.confidence > _lockThreshold,
|
|
|
|
|
+ ))
|
|
|
|
|
+ .toList(),
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ : null,
|
|
|
),
|
|
),
|
|
|
|
|
+ ),
|
|
|
|
|
|
|
|
// Top Info Bar
|
|
// Top Info Bar
|
|
|
Positioned(
|
|
Positioned(
|
|
@@ -384,8 +401,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
),
|
|
),
|
|
|
const SizedBox(width: 8),
|
|
const SizedBox(width: 8),
|
|
|
Text(
|
|
Text(
|
|
|
- _state == DetectionState.cooldown ? "COOLDOWN" :
|
|
|
|
|
- isLockedVisual ? "LOCKING" : "SEARCHING...",
|
|
|
|
|
|
|
+ _state == DetectionState.cooldown ? AppStrings.t('live_state_cooldown') :
|
|
|
|
|
+ isLockedVisual ? AppStrings.t('live_state_locking') : AppStrings.t('live_state_searching'),
|
|
|
style: TextStyle(
|
|
style: TextStyle(
|
|
|
color: _state == DetectionState.cooldown ? Colors.blue :
|
|
color: _state == DetectionState.cooldown ? Colors.blue :
|
|
|
isLockedVisual ? Colors.green : Colors.yellow,
|
|
isLockedVisual ? Colors.green : Colors.yellow,
|
|
@@ -448,10 +465,10 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
Positioned.fill(
|
|
Positioned.fill(
|
|
|
child: Container(
|
|
child: Container(
|
|
|
color: Colors.black45,
|
|
color: Colors.black45,
|
|
|
- child: const Center(
|
|
|
|
|
|
|
+ child: Center(
|
|
|
child: Text(
|
|
child: Text(
|
|
|
- "Resuming scan...",
|
|
|
|
|
- style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
|
|
|
|
+ AppStrings.t('live_resuming_scan'),
|
|
|
|
|
+ style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
@@ -459,14 +476,14 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
|
|
|
|
|
|
|
|
// Bottom Hint
|
|
// Bottom Hint
|
|
|
if (_state == DetectionState.searching)
|
|
if (_state == DetectionState.searching)
|
|
|
- const Positioned(
|
|
|
|
|
|
|
+ Positioned(
|
|
|
bottom: 40,
|
|
bottom: 40,
|
|
|
left: 0,
|
|
left: 0,
|
|
|
right: 0,
|
|
right: 0,
|
|
|
child: Center(
|
|
child: Center(
|
|
|
child: Text(
|
|
child: Text(
|
|
|
- "Hold steady to lock target",
|
|
|
|
|
- style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
|
|
|
|
|
|
|
+ AppStrings.t('live_hold_steady_hint'),
|
|
|
|
|
+ style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|