diff --git a/lib/helper/image_quality_helper.dart b/lib/helper/image_quality_helper.dart new file mode 100644 index 00000000..44cb1b59 --- /dev/null +++ b/lib/helper/image_quality_helper.dart @@ -0,0 +1,166 @@ +import 'dart:developer'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:image_blur_detection/image_blur_detection.dart'; + +/// Longest edge (in pixels) used for quality analysis. +/// +/// The picked image is downscaled to this size *before* any pixel analysis. +/// This is the single most important guard against OOM crashes on low-end +/// devices: a 12MP photo decodes to ~48 MB of RGBA, and the Laplacian pass in +/// `image_blur_detection` then allocates a growable `List` of one entry +/// per pixel (~96 MB, with transient spikes to ~190 MB while the list grows). +/// +/// At 1024px the same pipeline peaks at roughly 10 MB. +const int _kMaxAnalysisDimension = 1024; + +/// Result of an image quality check. +/// +/// [analysisFailed] is `true` when the image could not be analysed at all +/// (unsupported format, decode failure, out of memory, ...). Callers should +/// treat that as "allow the image through" rather than blocking the user. +@immutable +class ImageQualityCheckResult { + final bool isValid; + final List issues; + final bool analysisFailed; + + const ImageQualityCheckResult({ + required this.isValid, + this.issues = const [], + this.analysisFailed = false, + }); + + /// Used when analysis is impossible — fails open so the user is never stuck. + static const ImageQualityCheckResult skipped = ImageQualityCheckResult( + isValid: true, + analysisFailed: true, + ); +} + +/// Payload sent to the background isolate. +@immutable +class _AnalysisRequest { + final Uint8List pngBytes; + final double blurThreshold; + final double minBrightness; + final double maxBrightness; + final double minContrast; + + const _AnalysisRequest({ + required this.pngBytes, + required this.blurThreshold, + required this.minBrightness, + required this.maxBrightness, + required this.minContrast, + }); +} + +/// Analyses [file] for blur / brightness / contrast problems. +/// +/// Safe to call on large photos: the image is downscaled using the platform's +/// native decoder, and the (pure Dart, CPU-heavy) analysis runs in a background +/// isolate so the UI thread never blocks. +/// +/// Never throws — on any failure it returns [ImageQualityCheckResult.skipped]. +Future analyzeImageQuality( + File file, { + QualityConfig config = const QualityConfig(), +}) async { + try { + final Uint8List originalBytes = await file.readAsBytes(); + + // Downscale natively first. This keeps peak memory low and is far faster + // than decoding at full resolution in Dart. + final Uint8List? preview = await _downscaleToPng(originalBytes); + if (preview == null) return ImageQualityCheckResult.skipped; + + return await compute( + _analyzeInIsolate, + _AnalysisRequest( + pngBytes: preview, + blurThreshold: config.blurThreshold, + minBrightness: config.minBrightness, + maxBrightness: config.maxBrightness, + minContrast: config.minContrast, + ), + ); + } catch (e, s) { + log('Image quality analysis failed', name: 'ImageQuality', error: e, stackTrace: s); + return ImageQualityCheckResult.skipped; + } +} + +/// Decodes [bytes] at a reduced resolution using the engine's native codec and +/// re-encodes the small result as PNG. +/// +/// PNG is used because it is lossless (so brightness/contrast statistics are +/// preserved exactly) and because `ui.Image.toByteData` can produce it without +/// pulling in a Dart encoder. +Future _downscaleToPng(Uint8List bytes) async { + ui.ImmutableBuffer? buffer; + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + ui.Image? image; + + try { + buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + descriptor = await ui.ImageDescriptor.encoded(buffer); + + final int width = descriptor.width; + final int height = descriptor.height; + if (width <= 0 || height <= 0) return null; + + final int longestEdge = width >= height ? width : height; + // Only ever scale down, never up. + final double scale = longestEdge > _kMaxAnalysisDimension ? _kMaxAnalysisDimension / longestEdge : 1.0; + + final int targetWidth = (width * scale).round().clamp(1, width); + final int targetHeight = (height * scale).round().clamp(1, height); + + codec = await descriptor.instantiateCodec( + targetWidth: targetWidth, + targetHeight: targetHeight, + ); + + final ui.FrameInfo frame = await codec.getNextFrame(); + image = frame.image; + + final ByteData? png = await image.toByteData(format: ui.ImageByteFormat.png); + return png?.buffer.asUint8List(); + } catch (e, s) { + log('Downscale failed', name: 'ImageQuality', error: e, stackTrace: s); + return null; + } finally { + image?.dispose(); + codec?.dispose(); + descriptor?.dispose(); + buffer?.dispose(); + } +} + +/// Runs in a background isolate. Must stay a top-level function. +Future _analyzeInIsolate(_AnalysisRequest request) async { + try { + final validator = ImageQualityValidator( + config: QualityConfig( + blurThreshold: request.blurThreshold, + minBrightness: request.minBrightness, + maxBrightness: request.maxBrightness, + minContrast: request.minContrast, + ), + ); + + final result = await validator.validate(request.pngBytes); + return ImageQualityCheckResult( + isValid: result.isValid, + issues: List.unmodifiable(result.issues), + ); + } catch (_) { + // Decode/analysis problems must not crash the isolate — fail open. + return ImageQualityCheckResult.skipped; + } +} + diff --git a/lib/modules/copilot/controller/copilot_provider.dart b/lib/modules/copilot/controller/copilot_provider.dart index 8387f3de..fcb0a74c 100644 --- a/lib/modules/copilot/controller/copilot_provider.dart +++ b/lib/modules/copilot/controller/copilot_provider.dart @@ -120,7 +120,7 @@ class CopilotProvider extends ChangeNotifier { "query": query, }, ); - await _consumeStream(streamedResponse, assistantMsg); + await _consumeStream(streamedResponse, assistantMsg, null); } catch (e) { _onStreamError(assistantMsg, e); } @@ -129,8 +129,8 @@ class CopilotProvider extends ChangeNotifier { Future sendAudio(File audioFile) async { if (isStreaming) return; sessionId ??= _generateSessionId(); - - messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now())); + final humanAudioMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now()); + messages.add(humanAudioMsg); final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now()); messages.add(assistantMsg); isStreaming = true; @@ -148,7 +148,7 @@ class CopilotProvider extends ChangeNotifier { file: audioFile, filename: audioFile.path.split('/').last, ); - await _consumeStream(streamedResponse, assistantMsg); + await _consumeStream(streamedResponse, assistantMsg, humanAudioMsg); } catch (e) { _onStreamError(assistantMsg, e); } @@ -164,7 +164,7 @@ class CopilotProvider extends ChangeNotifier { return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}'; } - Future _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg) async { + Future _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) async { if (streamedResponse.statusCode < 200 || streamedResponse.statusCode >= 300) { _onStreamError(assistantMsg, "Server error ${streamedResponse.statusCode}"); return; @@ -178,12 +178,12 @@ class CopilotProvider extends ChangeNotifier { final idx = buffer.indexOf('\n\n'); final rawEvent = buffer.substring(0, idx); buffer = buffer.substring(idx + 2); - _handleSseEvent(rawEvent, assistantMsg); + _handleSseEvent(rawEvent, assistantMsg, humanAudioMsg); } }, onDone: () { if (buffer.trim().isNotEmpty) { - _handleSseEvent(buffer, assistantMsg); + _handleSseEvent(buffer, assistantMsg, humanAudioMsg); } assistantMsg.isStreaming = false; isStreaming = false; @@ -194,7 +194,7 @@ class CopilotProvider extends ChangeNotifier { ); } - void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg) { + void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) { final dataLines = rawEvent.split('\n').where((line) => line.startsWith('data:')).map((line) => line.substring(5).trim()).toList(); if (dataLines.isEmpty) return; @@ -219,6 +219,10 @@ class CopilotProvider extends ChangeNotifier { _onStreamError(assistantMsg, event.content?.answer ?? 'Something went wrong'); return; } + if (event.type == 'transcription' && event.transcriptionContent != null) { + humanAudioMsg?.content = event.transcriptionContent ?? ''; + humanAudioMsg?.localAudioPath = null; + } notifyListeners(); } catch (_) {} } diff --git a/lib/modules/copilot/model/copilot_message_model.dart b/lib/modules/copilot/model/copilot_message_model.dart index 0162c6e6..bfc46dab 100644 --- a/lib/modules/copilot/model/copilot_message_model.dart +++ b/lib/modules/copilot/model/copilot_message_model.dart @@ -84,12 +84,14 @@ class CopilotMessage { class CopilotStreamEvent { final String type; final CopilotStreamContent? content; + final String? transcriptionContent; - CopilotStreamEvent({required this.type, this.content}); + CopilotStreamEvent({required this.type, this.content, this.transcriptionContent}); factory CopilotStreamEvent.fromJson(Map json) => CopilotStreamEvent( type: json['type'] ?? '', content: json['content'] is Map ? CopilotStreamContent.fromJson(json['content']) : null, + transcriptionContent: json['content'] is String ? json['content'] : null, ); } diff --git a/lib/views/widgets/images/multi_image_picker.dart b/lib/views/widgets/images/multi_image_picker.dart index af3dd86c..2d419ff7 100644 --- a/lib/views/widgets/images/multi_image_picker.dart +++ b/lib/views/widgets/images/multi_image_picker.dart @@ -2,19 +2,19 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:image_blur_detection/image_blur_detection.dart'; import 'package:image_picker/image_picker.dart'; import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; -import 'package:test_sa/helper/utils.dart'; +import 'package:test_sa/helper/image_quality_helper.dart'; import 'package:test_sa/models/generic_attachment_model.dart'; import 'package:test_sa/models/lookup.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import '../../../new_views/common_widgets/app_dashed_button.dart'; +import '../../../new_views/common_widgets/app_lazy_loading.dart'; import 'multi_image_picker_item.dart'; class AttachmentModel { @@ -178,6 +178,7 @@ class _AttachmentPickerState extends State with SingleTickerPr if (widget.onChange != null) { widget.onChange!(widget.attachment); } + if (!mounted) return; setState(() {}); } } @@ -251,8 +252,11 @@ class _AttachmentPickerState extends State with SingleTickerPr icon: 'file_icon', label: '${context.translation.open}\n${context.translation.files}', onTap: () async { + // Capture before the await so the sheet is dismissed safely even + // if this element's context is gone by the time we resume. + final NavigatorState sheetNavigator = Navigator.of(context); await fromFilePicker(); - Navigator.pop(context); + if (sheetNavigator.canPop()) sheetNavigator.pop(); }, ), ]; @@ -317,11 +321,12 @@ class _AttachmentPickerState extends State with SingleTickerPr if (widget.pickMultiple && source == ImageSource.gallery) { final pickedFiles = await ImagePicker().pickMultiImage(imageQuality: 70); + if (!mounted) return; if (pickedFiles.isNotEmpty) { - pickedFiles.forEach((pickedFile) { + for (final pickedFile in pickedFiles) { File fileImage = File(pickedFile.path); widget.attachment.add(GenericAttachmentModel(id: 0, name: fileImage.path, documentTypeId: widget.documentType)); - }); + } if (widget.onChange != null) { widget.onChange!(widget.attachment); } @@ -331,22 +336,46 @@ class _AttachmentPickerState extends State with SingleTickerPr } final pickedFile = await ImagePicker().pickImage(source: source, imageQuality: 50); + if (!mounted) return; if (pickedFile != null) { File fileImage = File(pickedFile.path); if (checkQuality) { - ImageQualityValidator imageQualityValidator = ImageQualityValidator(config: const QualityConfig(blurThreshold: 70.0)); - final result = await imageQualityValidator.validate(await fileImage.readAsBytes()); + // NOTE: deliberately not using Utils.showLoading here — it defers the + // showDialog into addPostFrameCallback, which can race with a fast + // dismiss and leave the loader stuck on screen. showDialog pushes its + // route synchronously, so the matching pop below is always safe. + final NavigatorState navigator = Navigator.of(context); + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + barrierColor: Colors.black.withValues(alpha: 0.5), + builder: (_) => const AppLazyLoading(), + ); + + // Runs on a downscaled copy in a background isolate; never throws. + final result = await analyzeImageQuality( + fileImage, + config: const QualityConfig(blurThreshold: 25.0), + ); + + if (!mounted) return; + if (navigator.canPop()) navigator.pop(); if (!result.isValid) { String issues = "Your image have following problems:"; - result.issues.forEach((issue) => (issues += "\n- $issue")); + for (final issue in result.issues) { + issues += "\n- $issue"; + } context.showInfoDialog(issues, title: "Bad Quality Found"); return; } } + if (!mounted) return; + widget.attachment.add(GenericAttachmentModel(id: 0, name: fileImage.path, documentTypeId: widget.documentType)); if (widget.onChange != null) { widget.onChange!(widget.attachment);