From d1b62dcbdaaf621675b6f9cd6d8abb44122761a7 Mon Sep 17 00:00:00 2001 From: VincenzoImp Date: Sat, 29 Aug 2026 17:24:49 +0200 Subject: [PATCH] fix: improve scanner accessibility and platform support --- lib/screens/barcode_scan_screen.dart | 174 +++++---- lib/screens/corner_adjust_screen.dart | 357 ++++++++++-------- lib/screens/scanner_home_page.dart | 118 +++--- lib/services/platform_capabilities.dart | 12 + lib/widgets/transient_message.dart | 32 +- pubspec.lock | 24 +- pubspec.yaml | 2 +- test/screens/barcode_scan_screen_test.dart | 133 +++++++ .../corner_adjust_accessibility_test.dart | 163 ++++++++ .../scanner_home_accessibility_test.dart | 134 +++++++ test/screens/scanner_home_draft_test.dart | 23 ++ test/services/image_metadata_test.dart | 3 +- test/services/platform_capabilities_test.dart | 20 + test/widgets/transient_message_test.dart | 63 ++++ 14 files changed, 974 insertions(+), 284 deletions(-) create mode 100644 lib/services/platform_capabilities.dart create mode 100644 test/screens/barcode_scan_screen_test.dart create mode 100644 test/screens/corner_adjust_accessibility_test.dart create mode 100644 test/screens/scanner_home_accessibility_test.dart create mode 100644 test/services/platform_capabilities_test.dart create mode 100644 test/widgets/transient_message_test.dart diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart index f608923..7a6b3b2 100644 --- a/lib/screens/barcode_scan_screen.dart +++ b/lib/screens/barcode_scan_screen.dart @@ -5,59 +5,107 @@ import 'package:url_launcher/url_launcher.dart'; import '../widgets/transient_message.dart'; -/// A live QR/barcode scanning mode, separate from the document-scan flow: -/// point the camera at a code and get the decoded value with quick actions -/// (copy, open link), rather than treating the code as a document page. +class BarcodeScanResult { + const BarcodeScanResult({required this.isValid, required this.text}); + + final bool isValid; + final String? text; +} + +typedef BarcodeScanViewBuilder = + Widget Function( + BuildContext context, + ValueChanged onScan, + ); +typedef BarcodeClipboardWriter = Future Function(String text); +typedef BarcodeUriLauncher = Future Function(Uri uri); + +Widget _defaultScanView( + BuildContext context, + ValueChanged onScan, +) => ReaderWidget( + onScan: (code) => + onScan(BarcodeScanResult(isValid: code.isValid, text: code.text)), + showGallery: true, + // cropPercent must stay 0 here: ReaderWidget's crop-indicator square only + // lines up with the region it actually decodes when the widget is truly + // full-screen. See khoren93/flutter_zxing#196. + cropPercent: 0, + // At cropPercent 0 the built-in overlay suggests tapping is required. The + // aiming guide below is cosmetic; decoding always uses the whole frame. + showScannerOverlay: false, + // Common 1D formats need a more exhaustive per-frame decode attempt. + tryHarder: true, +); + +Future _writeClipboard(String text) => + Clipboard.setData(ClipboardData(text: text)); + +Future _launchBarcodeUri(Uri uri) => + launchUrl(uri, mode: LaunchMode.externalApplication); + +/// A live QR/barcode scanning mode, separate from the document-scan flow. class BarcodeScanScreen extends StatefulWidget { - const BarcodeScanScreen({super.key}); + const BarcodeScanScreen({ + super.key, + this.scanViewBuilder = _defaultScanView, + this.clipboardWriter = _writeClipboard, + this.uriLauncher = _launchBarcodeUri, + }); + + final BarcodeScanViewBuilder scanViewBuilder; + final BarcodeClipboardWriter clipboardWriter; + final BarcodeUriLauncher uriLauncher; @override State createState() => _BarcodeScanScreenState(); } class _BarcodeScanScreenState extends State { - String? _lastResult; + String? _latchedResult; Uri? get _resultUri { - final result = _lastResult; + final result = _latchedResult; if (result == null) return null; final uri = Uri.tryParse(result); - if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + if (uri == null || + !(uri.scheme == 'http' || uri.scheme == 'https') || + !uri.hasAuthority || + uri.host.isEmpty) { return null; } return uri; } - void _handleScan(Code code) { - if (!mounted) return; - final text = code.text; - if (!code.isValid || text == null || text.isEmpty) return; - if (text == _lastResult) return; - setState(() => _lastResult = text); + void _handleScan(BarcodeScanResult scan) { + if (!mounted || _latchedResult != null) return; + final text = scan.text; + if (!scan.isValid || text == null || text.isEmpty) return; + setState(() => _latchedResult = text); } void _dismissResult() { - setState(() => _lastResult = null); + setState(() => _latchedResult = null); } Future _copyResult() async { - final result = _lastResult; + final result = _latchedResult; if (result == null) return; - await Clipboard.setData(ClipboardData(text: result)); - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); + try { + await widget.clipboardWriter(result); + if (mounted) showTransientMessage(context, 'Copied to clipboard'); + } catch (_) { + if (mounted) { + showTransientMessage(context, 'Could not copy to clipboard.'); + } + } } Future _openResult() async { final uri = _resultUri; if (uri == null) return; try { - final launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); + final launched = await widget.uriLauncher(uri); if (!launched && mounted) { showTransientMessage(context, 'Could not open the link.'); } @@ -68,39 +116,12 @@ class _BarcodeScanScreenState extends State { @override Widget build(BuildContext context) { - final result = _lastResult; + final result = _latchedResult; return Scaffold( appBar: AppBar(title: const Text('Scan QR / Barcode')), body: Stack( children: [ - // cropPercent must stay 0 here: ReaderWidget's crop-indicator square - // only lines up with the region it actually decodes when the widget - // is truly full-screen. This screen has an AppBar above it (so the - // preview is letterboxed), which is exactly the case the flutter_zxing - // maintainers flag as producing a crop guide that visually looks - // centered on the code while the real decode window is offset - // elsewhere — the code never gets read even though it's framed - // correctly on screen. See khoren93/flutter_zxing#196. - // - // showScannerOverlay is also off: at cropPercent 0, flutter_zxing's - // built-in overlay switches to a "tap the highlighted code" mode - // instead of a plain guide, which reads as scanning requiring a - // tap when it doesn't — onScan already fires as soon as a frame - // decodes. The plain square below is a purely cosmetic aiming hint - // with no effect on what actually gets decoded (the whole frame - // always does), so it can't drift out of sync the way the built-in - // one did. - ReaderWidget( - onScan: _handleScan, - showGallery: true, - cropPercent: 0, - showScannerOverlay: false, - // 1D formats (EAN/UPC/Code128, common on physical product - // packaging) carry far less redundancy than a QR code and are - // much more sensitive to a slight skew/angle, so they need the - // more exhaustive per-frame decode attempt this enables. - tryHarder: true, - ), + widget.scanViewBuilder(context, _handleScan), if (result == null) IgnorePointer( child: Center( @@ -136,10 +157,17 @@ class _BarcodeScanScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - result, - maxLines: 3, - overflow: TextOverflow.ellipsis, + child: Semantics( + container: true, + liveRegion: true, + label: 'Scanned code result: $result', + child: ExcludeSemantics( + child: Text( + result, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), ), ), IconButton( @@ -157,19 +185,35 @@ class _BarcodeScanScreenState extends State { children: [ if (_resultUri != null) ...[ Expanded( - child: FilledButton.icon( - onPressed: _openResult, - icon: const Icon(Icons.open_in_new), - label: const Text('Open'), + child: Semantics( + container: true, + button: true, + label: 'Open scanned link', + onTap: _openResult, + child: ExcludeSemantics( + child: FilledButton.icon( + onPressed: _openResult, + icon: const Icon(Icons.open_in_new), + label: const Text('Open'), + ), + ), ), ), const SizedBox(width: 12), ], Expanded( - child: OutlinedButton.icon( - onPressed: _copyResult, - icon: const Icon(Icons.copy), - label: const Text('Copy'), + child: Semantics( + container: true, + button: true, + label: 'Copy scanned result', + onTap: _copyResult, + child: ExcludeSemantics( + child: OutlinedButton.icon( + onPressed: _copyResult, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ), ), ), ], diff --git a/lib/screens/corner_adjust_screen.dart b/lib/screens/corner_adjust_screen.dart index 48ad7da..fe4f44e 100644 --- a/lib/screens/corner_adjust_screen.dart +++ b/lib/screens/corner_adjust_screen.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:flutter/foundation.dart' show compute; +import 'package:flutter/foundation.dart' show compute, debugPrint, kDebugMode; import 'package:flutter/material.dart'; import '../models/scanned_page.dart'; @@ -312,10 +312,13 @@ class _CornerAdjustScreenState extends State { _corners = corners; }); unawaited(_updatePreviews()); - } catch (e) { + } catch (error) { + if (kDebugMode) { + debugPrint('Corner initialization failed (${error.runtimeType}).'); + } if (!mounted) return; setState(() { - _error = 'Could not read this photo: $e'; + _error = 'Could not read this photo.'; }); } } @@ -648,109 +651,162 @@ class _CornerAdjustScreenState extends State { Widget _buildFilterStep(BuildContext context) { final previewBytes = _finalPreviewBytes ?? _filterPreviews?[_selectedFilter]; - return Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: Stack( - fit: StackFit.expand, - children: [ - if (previewBytes != null) - _boundedPreviewImage( - previewBytes, - fit: BoxFit.contain, - maxDimension: _fullPreviewDecodeSize, - ), - if (previewBytes == null || _isGeneratingFinalPreview) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( + return LayoutBuilder( + builder: (context, constraints) { + final useScrollableLayout = + constraints.maxHeight < 480 || + MediaQuery.textScalerOf(context).scale(16) > 20; + if (!useScrollableLayout) { + return Column( children: [ - const SizedBox(width: 72, child: Text('Brightness')), - Expanded( - child: Slider( - value: _brightness, - min: -100, - max: 100, - onChanged: _isProcessing - ? null - : (v) => setState(() => _brightness = v), - onChangeEnd: _isProcessing - ? null - : (_) => unawaited(_updateFinalPreview()), - ), + Expanded(child: _filterPreview(previewBytes)), + _adjustmentSlider( + label: 'Brightness', + value: _brightness, + min: -100, + max: 100, + onChanged: (value) => _brightness = value, + ), + _adjustmentSlider( + label: 'Contrast', + value: _contrast, + min: 0.5, + max: 2.0, + onChanged: (value) => _contrast = value, ), + _filterStrip(context, height: 92), + _filterActions(scrollable: false), ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( + ); + } + + final previewHeight = (constraints.maxHeight * 0.45).clamp( + 120.0, + 280.0, + ); + final stripHeight = + 72 + MediaQuery.textScalerOf(context).scale(12) * 1.4; + return SingleChildScrollView( + key: const Key('filter_step_scroll_view'), + child: Column( children: [ - const SizedBox(width: 72, child: Text('Contrast')), - Expanded( - child: Slider( - value: _contrast, - min: 0.5, - max: 2.0, - onChanged: _isProcessing - ? null - : (v) => setState(() => _contrast = v), - onChangeEnd: _isProcessing - ? null - : (_) => unawaited(_updateFinalPreview()), - ), + SizedBox( + height: previewHeight, + child: _filterPreview(previewBytes), + ), + _adjustmentSlider( + label: 'Brightness', + value: _brightness, + min: -100, + max: 100, + onChanged: (value) => _brightness = value, ), + _adjustmentSlider( + label: 'Contrast', + value: _contrast, + min: 0.5, + max: 2.0, + onChanged: (value) => _contrast = value, + ), + _filterStrip(context, height: stripHeight), + _filterActions(scrollable: true), ], ), - ), - SizedBox( - height: 92, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - for (final filter in PageFilter.values) - _filterChip(context, filter), - ], + ); + }, + ); + } + + Widget _filterPreview(Uint8List? previewBytes) => Padding( + padding: const EdgeInsets.all(16), + child: Stack( + fit: StackFit.expand, + children: [ + if (previewBytes != null) + _boundedPreviewImage( + previewBytes, + fit: BoxFit.contain, + maxDimension: _fullPreviewDecodeSize, ), - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _isProcessing ? null : _leaveFilterStep, - child: const Text('Back'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: FilledButton( - onPressed: _isProcessing ? null : _confirm, - child: _isProcessing - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Confirm'), - ), - ), - ], - ), + if (previewBytes == null || _isGeneratingFinalPreview) + const Center(child: CircularProgressIndicator()), + ], + ), + ); + + Widget _adjustmentSlider({ + required String label, + required double value, + required double min, + required double max, + required ValueChanged onChanged, + }) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + SizedBox(width: 88, child: Text(label)), + Expanded( + child: Slider( + value: value, + min: min, + max: max, + onChanged: _isProcessing + ? null + : (newValue) => setState(() => onChanged(newValue)), + onChangeEnd: _isProcessing + ? null + : (_) => unawaited(_updateFinalPreview()), ), ), ], + ), + ); + + Widget _filterStrip(BuildContext context, {required double height}) => + SizedBox( + height: height, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + for (final filter in PageFilter.values) + _filterChip(context, filter), + ], + ), + ); + + Widget _filterActions({required bool scrollable}) { + final back = OutlinedButton( + onPressed: _isProcessing ? null : _leaveFilterStep, + child: const Text('Back'), + ); + final confirm = FilledButton( + onPressed: _isProcessing ? null : _confirm, + child: _isProcessing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Confirm'), + ); + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: scrollable + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [back, const SizedBox(height: 8), confirm], + ) + : Row( + children: [ + Expanded(child: back), + const SizedBox(width: 16), + Expanded(child: confirm), + ], + ), + ), ); } @@ -758,61 +814,68 @@ class _CornerAdjustScreenState extends State { final selected = _selectedFilter == filter; final previewBytes = _filterPreviews?[filter]; final primary = Theme.of(context).colorScheme.primary; - return Padding( - padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: _isProcessing - ? null - : () { - setState(() => _selectedFilter = filter); - unawaited(_updateFinalPreview()); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56, - height: 56, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: selected ? primary : Colors.transparent, - width: 2, + return Semantics( + key: ValueKey('filter_${filter.name}'), + button: true, + enabled: !_isProcessing, + selected: selected, + label: '${_filterLabels[filter]} filter', + child: Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: _isProcessing + ? null + : () { + setState(() => _selectedFilter = filter); + unawaited(_updateFinalPreview()); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected ? primary : Colors.transparent, + width: 2, + ), ), - ), - child: previewBytes != null - ? _boundedPreviewImage( - previewBytes, - fit: BoxFit.cover, - maxDimension: _filterChipDecodeSize, - ) - : ColoredBox( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: _isGeneratingPreviews - ? const Center( - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, + child: previewBytes != null + ? _boundedPreviewImage( + previewBytes, + fit: BoxFit.cover, + maxDimension: _filterChipDecodeSize, + ) + : ColoredBox( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: _isGeneratingPreviews + ? const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), ), - ), - ) - : null, - ), - ), - const SizedBox(height: 4), - Text( - _filterLabels[filter]!, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: selected ? primary : null, - fontWeight: selected ? FontWeight.bold : null, + ) + : null, + ), ), - ), - ], + const SizedBox(height: 4), + Text( + _filterLabels[filter]!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: selected ? primary : null, + fontWeight: selected ? FontWeight.bold : null, + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index e0c0e1c..45f05bd 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' - show TargetPlatform, defaultTargetPlatform, kIsWeb; + show TargetPlatform, debugPrint, defaultTargetPlatform, kDebugMode, kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart' + show CustomSemanticsAction, OrdinalSortKey, SemanticsService; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -15,6 +17,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../models/scanned_page.dart'; import '../services/draft_store.dart'; import '../services/image_metadata.dart'; +import '../services/platform_capabilities.dart'; import '../widgets/transient_message.dart'; import 'barcode_scan_screen.dart'; import 'corner_adjust_screen.dart'; @@ -682,6 +685,11 @@ class _ScannerHomePageState extends State { _pages.insert(toIndex, page); }); _queueDraftSave(); + SemanticsService.sendAnnouncement( + View.of(context), + 'Page moved to position ${toIndex + 1} of ${_pages.length}.', + Directionality.of(context), + ); } Future _clearPages() async { @@ -824,12 +832,11 @@ class _ScannerHomePageState extends State { // after presenting the share UI. Only an explicit dismissal means the // user definitely did not share or download the PDF. shared = shareResult.status != ShareResultStatus.dismissed; - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error generating PDF: $e'))); + } catch (error) { + if (kDebugMode) { + debugPrint('PDF generation failed (${error.runtimeType}).'); } + if (mounted) _showMessage('Could not generate or share the PDF.'); } finally { if (mounted) { setState(() { @@ -848,11 +855,10 @@ class _ScannerHomePageState extends State { appBar: AppBar( title: const Text('FOSScanner'), actions: [ - // flutter_zxing has no web decoding backend (its web implementation - // throws UnimplementedError on every frame) — same platform gap as - // opencv_dart, so this follows the same kIsWeb convention used for - // the detect/adjust flow elsewhere in this screen. - if (!kIsWeb) + if (supportsBarcodeCamera( + platform: defaultTargetPlatform, + isWeb: kIsWeb, + )) IconButton( icon: const Icon(Icons.qr_code_scanner), onPressed: () => Navigator.of(context).push( @@ -986,45 +992,63 @@ class _ScannerHomePageState extends State { ), ); - // Drag-to-reorder: long-press a page to pick it up, drop it - // on another page's slot to swap it into that position. - return DragTarget( - onWillAcceptWithDetails: (details) => - !_isClearingDraft && + // Drag-to-reorder remains available alongside equivalent + // semantic actions for switch and screen-reader users. + final reorderActions = { + if (!_isClearingDraft && !_isOpeningEditor && index > 0) + const CustomSemanticsAction(label: 'Move earlier'): () => + _reorderPage(index, index - 1), + if (!_isClearingDraft && !_isOpeningEditor && - details.data != index, - onAcceptWithDetails: (details) => - _reorderPage(details.data, index), - builder: (context, candidateData, rejectedData) { - final isDropTarget = candidateData.isNotEmpty; - return LongPressDraggable( - data: index, - maxSimultaneousDrags: _isClearingDraft || _isOpeningEditor - ? 0 - : 1, - feedback: SizedBox( - width: 140, - height: 200, - child: Material( - color: Colors.transparent, - child: Opacity(opacity: 0.85, child: card), + index < _pages.length - 1) + const CustomSemanticsAction(label: 'Move later'): () => + _reorderPage(index, index + 1), + }; + return Semantics( + key: ValueKey('page_semantics_$index'), + label: 'Page ${index + 1} of ${_pages.length}', + sortKey: OrdinalSortKey(index.toDouble()), + container: true, + customSemanticsActions: reorderActions, + child: DragTarget( + onWillAcceptWithDetails: (details) => + !_isClearingDraft && + !_isOpeningEditor && + details.data != index, + onAcceptWithDetails: (details) => + _reorderPage(details.data, index), + builder: (context, candidateData, rejectedData) { + final isDropTarget = candidateData.isNotEmpty; + return LongPressDraggable( + data: index, + maxSimultaneousDrags: + _isClearingDraft || _isOpeningEditor ? 0 : 1, + feedback: SizedBox( + width: 140, + height: 200, + child: Material( + color: Colors.transparent, + child: Opacity(opacity: 0.85, child: card), + ), ), - ), - childWhenDragging: Opacity(opacity: 0.3, child: card), - child: isDropTarget - ? Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.primary, - width: 3, + childWhenDragging: Opacity(opacity: 0.3, child: card), + child: isDropTarget + ? Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(4), ), - borderRadius: BorderRadius.circular(4), - ), - child: card, - ) - : card, - ); - }, + child: card, + ) + : card, + ); + }, + ), ); }, ), diff --git a/lib/services/platform_capabilities.dart b/lib/services/platform_capabilities.dart new file mode 100644 index 0000000..37468e8 --- /dev/null +++ b/lib/services/platform_capabilities.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart' show TargetPlatform; + +/// Whether the bundled barcode scanner has a camera backend on [platform]. +/// +/// Keep this pure so platform presentation can be verified without loading a +/// camera plugin. +bool supportsBarcodeCamera({ + required TargetPlatform platform, + required bool isWeb, +}) => + !isWeb && + (platform == TargetPlatform.android || platform == TargetPlatform.iOS); diff --git a/lib/widgets/transient_message.dart b/lib/widgets/transient_message.dart index 03fd767..2ef6843 100644 --- a/lib/widgets/transient_message.dart +++ b/lib/widgets/transient_message.dart @@ -1,15 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; -/// Shows a short-lived SnackBar, deferred to a post-frame callback so it -/// works even when called from code that runs before this frame's Scaffold -/// has registered with the surrounding ScaffoldMessenger (e.g. startup -/// recovery, or a callback fired mid-build). +var _messageGeneration = 0; + +/// Shows one short-lived message, replacing any stale queued message. void showTransientMessage(BuildContext context, String message) { if (!context.mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(SnackBar(content: Text(message))); - }); + final generation = ++_messageGeneration; + + void show() { + if (!context.mounted || generation != _messageGeneration) return; + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.clearSnackBars(); + messenger?.showSnackBar(SnackBar(content: Text(message))); + } + + // Showing a SnackBar while widgets are being built would mutate the + // messenger during its frame. Defer only that case; async callbacks and + // user actions can update it immediately. + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) => show()); + } else { + show(); + } } diff --git a/pubspec.lock b/pubspec.lock index 707edaf..e1c273a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -258,10 +258,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "6.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -452,10 +452,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -468,10 +468,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -484,10 +484,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mime: dependency: transitive description: @@ -745,10 +745,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" typed_data: dependency: transitive description: @@ -833,10 +833,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index be32e78..084ae49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^3.0.1 + flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.3 image_picker_platform_interface: ^2.11.1 share_plus_platform_interface: ^7.2.0 diff --git a/test/screens/barcode_scan_screen_test.dart b/test/screens/barcode_scan_screen_test.dart new file mode 100644 index 0000000..03a341a --- /dev/null +++ b/test/screens/barcode_scan_screen_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fosscanner/screens/barcode_scan_screen.dart'; + +void main() { + late ValueChanged emitScan; + + Widget buildScreen({ + Future Function(String)? clipboardWriter, + Future Function(Uri)? uriLauncher, + }) => MaterialApp( + home: BarcodeScanScreen( + scanViewBuilder: (context, onScan) { + emitScan = onScan; + return const SizedBox.expand(child: ColoredBox(color: Colors.black)); + }, + clipboardWriter: clipboardWriter ?? (_) async {}, + uriLauncher: uriLauncher ?? (_) async => true, + ), + ); + + testWidgets('latches the first valid result until it is dismissed', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(buildScreen()); + + emitScan(const BarcodeScanResult(isValid: false, text: 'invalid')); + emitScan(const BarcodeScanResult(isValid: true, text: '')); + emitScan(const BarcodeScanResult(isValid: true, text: 'first')); + await tester.pump(); + emitScan(const BarcodeScanResult(isValid: true, text: 'second')); + await tester.pump(); + + expect(find.text('first'), findsOneWidget); + expect(find.text('second'), findsNothing); + final resultNode = tester.getSemantics( + find.bySemanticsLabel('Scanned code result: first'), + ); + expect(resultNode.getSemanticsData().flagsCollection.isLiveRegion, isTrue); + expect(find.bySemanticsLabel('Copy scanned result'), findsOneWidget); + + await tester.tap(find.byTooltip('Dismiss and keep scanning')); + await tester.pump(); + emitScan(const BarcodeScanResult(isValid: true, text: 'second')); + await tester.pump(); + + expect(find.text('first'), findsNothing); + expect(find.text('second'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('clipboard failures show stable transient feedback', ( + tester, + ) async { + await tester.pumpWidget( + buildScreen( + clipboardWriter: (_) async => throw StateError('secret clipboard'), + ), + ); + emitScan(const BarcodeScanResult(isValid: true, text: 'copy me')); + await tester.pump(); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Copy')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Could not copy to clipboard.'), findsOneWidget); + expect(find.textContaining('secret clipboard'), findsNothing); + }); + + testWidgets('offers and launches an HTTP URI', (tester) async { + Uri? launched; + await tester.pumpWidget( + buildScreen( + uriLauncher: (uri) async { + launched = uri; + return true; + }, + ), + ); + emitScan( + const BarcodeScanResult(isValid: true, text: 'http://example.com/a'), + ); + await tester.pump(); + + await tester.tap(find.widgetWithText(FilledButton, 'Open')); + await tester.pump(); + + expect(launched, Uri.parse('http://example.com/a')); + }); + + testWidgets('does not offer Open for a non-HTTP URI', (tester) async { + await tester.pumpWidget(buildScreen()); + emitScan( + const BarcodeScanResult(isValid: true, text: 'mailto:test@example.com'), + ); + await tester.pump(); + + expect(find.bySemanticsLabel('Open scanned link'), findsNothing); + }); + + testWidgets('does not offer Open for a hostless HTTPS URI', (tester) async { + await tester.pumpWidget(buildScreen()); + emitScan(const BarcodeScanResult(isValid: true, text: 'https:foo')); + await tester.pump(); + + expect(find.bySemanticsLabel('Open scanned link'), findsNothing); + }); + + for (final launchFailure in Function(Uri)>{ + 'false': (_) async => false, + 'throw': (_) async => throw StateError('private launcher details'), + }.entries) { + testWidgets('shows stable feedback when URL launch ${launchFailure.key}s', ( + tester, + ) async { + await tester.pumpWidget(buildScreen(uriLauncher: launchFailure.value)); + emitScan( + const BarcodeScanResult(isValid: true, text: 'https://example.com'), + ); + await tester.pump(); + + await tester.tap(find.widgetWithText(FilledButton, 'Open')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Could not open the link.'), findsOneWidget); + expect(find.textContaining('private launcher details'), findsNothing); + }); + } +} diff --git a/test/screens/corner_adjust_accessibility_test.dart b/test/screens/corner_adjust_accessibility_test.dart new file mode 100644 index 0000000..591b8b5 --- /dev/null +++ b/test/screens/corner_adjust_accessibility_test.dart @@ -0,0 +1,163 @@ +import 'dart:ui' show Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fosscanner/models/scanned_page.dart'; +import 'package:fosscanner/screens/corner_adjust_screen.dart'; + +class _Operations implements CornerAdjustOperations { + _Operations(this.bytes, {this.decodeError}); + + final Uint8List bytes; + final Object? decodeError; + + @override + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ) async => {for (final filter in PageFilter.values) filter: bytes}; + + @override + Future buildFinalPreview( + Uint8List imageBytes, { + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => imageBytes; + + @override + Future decodeSize(Uint8List imageBytes) async { + if (decodeError case final error?) throw error; + return const Size(100, 100); + } + + @override + Future?> detectCorners(Uint8List imageBytes) async => null; + + @override + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) async => bytes; +} + +void main() { + late Uint8List imageBytes; + + setUp(() async { + final data = await rootBundle.load('assets/icon/icon.png'); + imageBytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + }); + + Future openFilterStep( + WidgetTester tester, { + TextScaler? textScaler, + }) async { + final screen = CornerAdjustScreen( + originalBytes: imageBytes, + initialCorners: const [ + Offset(5, 5), + Offset(95, 5), + Offset(95, 95), + Offset(5, 95), + ], + operations: _Operations(imageBytes), + ); + await tester.pumpWidget( + MaterialApp( + home: textScaler == null + ? screen + : MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: screen, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + } + + testWidgets('short landscape with large text scrolls to Confirm', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 300); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await openFilterStep(tester, textScaler: const TextScaler.linear(2)); + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('filter_step_scroll_view')), findsOneWidget); + + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Confirm')); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(FilledButton, 'Confirm')); + await tester.pumpAndSettle(); + + expect(find.text('Preview'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('normal-height filter screen keeps the fixed layout', ( + tester, + ) async { + await openFilterStep(tester); + + expect(find.byKey(const Key('filter_step_scroll_view')), findsNothing); + expect(find.widgetWithText(FilledButton, 'Confirm'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('corner initialization hides raw exception details', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: CornerAdjustScreen( + originalBytes: imageBytes, + operations: _Operations( + imageBytes, + decodeError: StateError('private file path'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Could not read this photo.'), findsOneWidget); + expect(find.textContaining('private file path'), findsNothing); + }); + + testWidgets('filter choices expose selected semantics', (tester) async { + final semantics = tester.ensureSemantics(); + await openFilterStep(tester); + + final original = tester.getSemantics( + find.byKey(const ValueKey('filter_original')), + ); + final grayscale = tester.getSemantics( + find.byKey(const ValueKey('filter_grayscale')), + ); + expect( + original.getSemanticsData().flagsCollection.isSelected, + Tristate.isTrue, + ); + expect( + grayscale.getSemanticsData().flagsCollection.isSelected, + Tristate.isFalse, + ); + + semantics.dispose(); + }); +} diff --git a/test/screens/scanner_home_accessibility_test.dart b/test/screens/scanner_home_accessibility_test.dart new file mode 100644 index 0000000..9ba16e4 --- /dev/null +++ b/test/screens/scanner_home_accessibility_test.dart @@ -0,0 +1,134 @@ +import 'dart:ui' show SemanticsAction; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart' show CustomSemanticsAction; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; + +import 'package:fosscanner/models/scanned_page.dart'; +import 'package:fosscanner/screens/scanner_home_page.dart'; +import 'package:fosscanner/services/draft_store.dart'; + +class _PickerPlatform extends ImagePickerPlatform { + @override + bool supportsImageSource(ImageSource source) => false; + + @override + Future getLostData() async => LostDataResponse.empty(); +} + +class _DraftStore implements DraftStore { + final saves = >[]; + + @override + Future clear() async {} + + @override + Future> load() async => const []; + + @override + Future save(List pages) async { + saves.add(List.of(pages)); + } +} + +void main() { + late ImagePickerPlatform originalPicker; + late Uint8List imageBytes; + + setUp(() async { + originalPicker = ImagePickerPlatform.instance; + ImagePickerPlatform.instance = _PickerPlatform(); + final data = await rootBundle.load('assets/icon/icon.png'); + imageBytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + }); + + tearDown(() { + ImagePickerPlatform.instance = originalPicker; + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('barcode button is visible only on supported native platforms', ( + tester, + ) async { + for (final platform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = platform; + await tester.pumpWidget(const MaterialApp(home: ScannerHomePage())); + await tester.pump(); + + expect( + find.byTooltip('Scan QR/barcode'), + platform == TargetPlatform.android || platform == TargetPlatform.iOS + ? findsOneWidget + : findsNothing, + reason: '$platform', + ); + await tester.pumpWidget(const SizedBox()); + } + debugDefaultTargetPlatformOverride = null; + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('semantic reorder actions update and persist page order', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final store = _DraftStore(); + ScannedPage page(int marker) => ScannedPage( + originalBytes: Uint8List.fromList(imageBytes), + processedBytes: Uint8List.fromList(imageBytes), + corners: [ + Offset(marker.toDouble(), 0), + const Offset(10, 0), + const Offset(10, 10), + const Offset(0, 10), + ], + ); + final first = page(1); + final second = page(2); + final third = page(3); + + await tester.pumpWidget( + MaterialApp( + home: ScannerHomePage( + initialPages: [first, second, third], + draftStore: store, + ), + ), + ); + + final node = tester.getSemantics( + find.byKey(const ValueKey('page_semantics_1')), + ); + final actionIds = node.getSemanticsData().customSemanticsActionIds!; + final actionLabels = actionIds + .map((id) => CustomSemanticsAction.getAction(id)!.label) + .toSet(); + expect(actionLabels, containsAll({'Move earlier', 'Move later'})); + final moveEarlierId = actionIds.firstWhere( + (id) => CustomSemanticsAction.getAction(id)!.label == 'Move earlier', + ); + // ignore: deprecated_member_use + tester.binding.pipelineOwner.semanticsOwner!.performAction( + node.id, + SemanticsAction.customAction, + moveEarlierId, + ); + await tester.pump(); + + expect(store.saves.last, orderedEquals([second, first, third])); + expect( + tester + .getSemantics(find.byKey(const ValueKey('page_semantics_0'))) + .getSemanticsData() + .label, + contains('Page 1 of 3'), + ); + semantics.dispose(); + }); +} diff --git a/test/screens/scanner_home_draft_test.dart b/test/screens/scanner_home_draft_test.dart index 9384ff9..2d2b1e1 100644 --- a/test/screens/scanner_home_draft_test.dart +++ b/test/screens/scanner_home_draft_test.dart @@ -70,11 +70,15 @@ class _ImagePickerPlatform extends ImagePickerPlatform { } class _SharePlatform implements SharePlatform { + _SharePlatform({this.error}); + + final Object? error; var calls = 0; @override Future share(ShareParams params) async { calls++; + if (error case final value?) throw value; return const ShareResult('shared', ShareResultStatus.success); } } @@ -360,6 +364,25 @@ void main() { expect(iconButton(tester, 'Clear all').onPressed, isNotNull); }); + testWidgets('PDF failures hide raw exception details', (tester) async { + final store = _DraftStore(); + final sharePlatform = _SharePlatform( + error: StateError('private share provider details'), + ); + await pumpHome( + tester, + store, + pages: [page(1)], + sharePlus: SharePlus.custom(sharePlatform), + ); + + await tester.tap(find.text('Save as PDF (1 pages)')); + await tester.pumpAndSettle(); + + expect(find.text('Could not generate or share the PDF.'), findsOneWidget); + expect(find.textContaining('private share provider details'), findsNothing); + }); + testWidgets('successful share keeps the draft unless clear is chosen', ( tester, ) async { diff --git a/test/services/image_metadata_test.dart b/test/services/image_metadata_test.dart index da178af..d3bd7e5 100644 --- a/test/services/image_metadata_test.dart +++ b/test/services/image_metadata_test.dart @@ -166,8 +166,7 @@ void main() { const size = Size(4000, 5000); const encodedBytes = 64 * 1024; const transientBytes = encodedBytes + 224000000; - const retainedHeadroom = - maxImageProcessingWorkingSetBytes - transientBytes; + const retainedHeadroom = maxImageProcessingWorkingSetBytes - transientBytes; expect( canProcessSourceImage( diff --git a/test/services/platform_capabilities_test.dart b/test/services/platform_capabilities_test.dart new file mode 100644 index 0000000..dbc878d --- /dev/null +++ b/test/services/platform_capabilities_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fosscanner/services/platform_capabilities.dart'; + +void main() { + test('barcode camera is supported only on native Android and iOS', () { + for (final platform in TargetPlatform.values) { + expect( + supportsBarcodeCamera(platform: platform, isWeb: false), + platform == TargetPlatform.android || platform == TargetPlatform.iOS, + reason: '$platform on a native build', + ); + expect( + supportsBarcodeCamera(platform: platform, isWeb: true), + isFalse, + reason: '$platform on a web build', + ); + } + }); +} diff --git a/test/widgets/transient_message_test.dart b/test/widgets/transient_message_test.dart new file mode 100644 index 0000000..b42d8d6 --- /dev/null +++ b/test/widgets/transient_message_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fosscanner/widgets/transient_message.dart'; + +void main() { + testWidgets('a new transient message replaces the current SnackBar', ( + tester, + ) async { + late BuildContext pageContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + pageContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + showTransientMessage(pageContext, 'First message'); + await tester.pumpAndSettle(); + expect(find.text('First message'), findsOneWidget); + + showTransientMessage(pageContext, 'Second message'); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('First message'), findsNothing); + expect(find.text('Second message'), findsOneWidget); + }); + + testWidgets('a deferred stale message cannot replace a newer message', ( + tester, + ) async { + var scheduleMessages = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + if (scheduleMessages) { + scheduleMessages = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + showTransientMessage(context, 'New message'); + }); + showTransientMessage(context, 'Stale message'); + } + return const SizedBox(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('New message'), findsOneWidget); + expect(find.text('Stale message'), findsNothing); + }); +}