From c3e4c56cc940cab7e39e5c2e2e61b7bb8caf85c1 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Wed, 26 Aug 2026 17:32:37 +0200 Subject: [PATCH 01/17] feat: add in-app about screen with version and source link Adds an info button to the home screen AppBar that shows the running version (via package_info_plus) and a link to the source repo (via url_launcher). --- android/app/src/main/AndroidManifest.xml | 9 +++- lib/screens/scanner_home_page.dart | 35 ++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 4 ++ pubspec.lock | 48 +++++++++++++++++++ pubspec.yaml | 2 + 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 31d0bdf..b0be735 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + + + + + diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index a2bac77..ce810e9 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -6,15 +6,18 @@ import 'package:flutter/foundation.dart' import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../models/scanned_page.dart'; import '../services/image_metadata.dart'; import 'corner_adjust_screen.dart'; const _thumbnailCacheWidth = 512; +const _sourceCodeUrl = 'https://github.com/FOSScanner/fosscanner-app'; class _DocumentCapacityException implements Exception { const _DocumentCapacityException(); @@ -111,6 +114,33 @@ class _ScannerHomePageState extends State { }); } + Future _showAppInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + if (!mounted) return; + showAboutDialog( + context: context, + applicationName: 'FOSScanner', + applicationVersion: '${packageInfo.version}+${packageInfo.buildNumber}', + applicationLegalese: 'Licensed under the GNU GPL v3.0', + children: [ + const SizedBox(height: 16), + InkWell( + onTap: () => launchUrl( + Uri.parse(_sourceCodeUrl), + mode: LaunchMode.externalApplication, + ), + child: Text( + _sourceCodeUrl, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ); + } + Future _recoverLostImages() async { // Block a second picker request until startup recovery has completed; two // simultaneous results could otherwise push overlapping adjustment routes. @@ -477,6 +507,11 @@ class _ScannerHomePageState extends State { onPressed: _clearPages, tooltip: 'Clear all', ), + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: _showAppInfo, + tooltip: 'About FOSScanner', + ), ], ), body: _pages.isEmpty diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8ad9182..f3e5e80 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,13 @@ import FlutterMacOS import Foundation import file_selector_macos +import package_info_plus import share_plus +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e973163..2b32861 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,6 +480,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -685,6 +701,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -693,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 217e118..b960279 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: pdf: ^3.10.7 share_plus: ^13.3.0 opencv_dart: ^2.2.1 + package_info_plus: ^10.2.1 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: From 600fbe0b5b320c657fcb03b28aba121127cecf3d Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Wed, 26 Aug 2026 17:32:47 +0200 Subject: [PATCH 02/17] fix: use FOSScanner consistently in user-facing display strings Several platforms still showed the app name as 'fosscanner' or 'Fosscanner' (Android's home-screen label was already fixed separately in the previous commit): iOS bundle display name, macOS product name (plus the Xcode project/scheme references to the resulting .app bundle), Linux window title, and Windows window title. Deliberately left as lowercase: the Dart package name and every package:fosscanner/... import (Dart forbids uppercase in package names), the com.fosscanner.app bundle identifiers (reverse-DNS convention), and build-artifact filenames. --- ios/Runner/Info.plist | 4 ++-- linux/runner/my_application.cc | 4 ++-- macos/Runner.xcodeproj/project.pbxproj | 12 ++++++------ .../xcshareddata/xcschemes/Runner.xcscheme | 10 +++++----- macos/Runner/Configs/AppInfo.xcconfig | 2 +- windows/runner/main.cpp | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ba3000a..c842436 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Fosscanner + FOSScanner CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - fosscanner + FOSScanner CFBundlePackageType APPL CFBundleShortVersionString diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 25c3632..73c2a8c 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "fosscanner"); + gtk_header_bar_set_title(header_bar, "FOSScanner"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "fosscanner"); + gtk_window_set_title(window, "FOSScanner"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index d239140..3d368af 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -65,7 +65,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* fosscanner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "fosscanner.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* FOSScanner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FOSScanner.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -134,7 +134,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* fosscanner.app */, + 33CC10ED2044A3C60003C045 /* FOSScanner.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -224,7 +224,7 @@ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, ); productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* fosscanner.app */; + productReference = 33CC10ED2044A3C60003C045 /* FOSScanner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -398,7 +398,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.fosscanner.app.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/fosscanner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/fosscanner"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FOSScanner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FOSScanner"; }; name = Debug; }; @@ -412,7 +412,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.fosscanner.app.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/fosscanner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/fosscanner"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FOSScanner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FOSScanner"; }; name = Release; }; @@ -426,7 +426,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.fosscanner.app.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/fosscanner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/fosscanner"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FOSScanner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FOSScanner"; }; name = Profile; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 779c1f2..95d2bc8 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -33,7 +33,7 @@ @@ -49,7 +49,7 @@ @@ -84,7 +84,7 @@ @@ -101,7 +101,7 @@ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 716cec4..66ff51c 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = fosscanner +PRODUCT_NAME = FOSScanner // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.fosscanner.app diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 26dcd67..5d31218 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"fosscanner", origin, size)) { + if (!window.Create(L"FOSScanner", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); From b810a379d7e6f1c48e11a30aed2b8ef4a3a1ef71 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Wed, 26 Aug 2026 17:32:52 +0200 Subject: [PATCH 03/17] docs: clarify PR base branch and document recent features in README CONTRIBUTING.md never actually said to base pull requests on dev rather than main. Also adds the gallery-import feature (shipped since 1.2.0 but never listed) and the new about screen to the README's feature list. --- CONTRIBUTING.md | 3 +++ README.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fc9d3a..99f93b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,9 @@ add `Co-Authored-By` trailers for AI coding assistants. ## Pull requests +- Base your branch on `dev` and open pull requests against `dev`, not + `main`. `main` only advances when `dev` is deliberately promoted for a + release — PRs opened against it directly won't be merged there. - Keep PRs focused — one logical change per PR is easier to review and keeps `git bisect` useful. - CI (`flutter analyze` + `flutter test`) must pass before merge. diff --git a/README.md b/README.md index 4271ff9..1568fec 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,14 @@ and this page list.* - Re-edit any page after the fact (corners, filter, rotation, brightness, and contrast) without re-scanning - Capture multiple pages in sequence and reorder them with drag-and-drop +- Import existing photos from your gallery instead of (or alongside) + capturing new ones - Combine captured pages into a single PDF - Share the PDF via the OS share sheet (or download it directly on web) - Material 3 UI that follows the system's light/dark theme - No accounts, no cloud storage, no tracking +- An in-app About screen (the ⓘ icon) shows the exact running version and + links straight to this source repo Edge detection, perspective correction, and filters run on real OpenCV (`opencv_dart`) on Android/iOS/desktop. Desktop builds support importing From 71592fed3f0f2af32555de41840250402dc77c83 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Wed, 26 Aug 2026 22:52:54 +0200 Subject: [PATCH 04/17] fix: clarify version display and expand the about dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version was shown as a bare '1.2.2+5', which reads like a typo rather than "version 1.2.2, build 5". Also gives the dialog an actual layout (icon, name, clear version line, a short description of what the app is) instead of relying on the default AboutDialog's plain stacked children, and demotes the open-source licenses list to a secondary button rather than the dialog's main content — it's still one tap away (required for license compliance with bundled dependencies), just not the first thing shown. --- lib/screens/scanner_home_page.dart | 92 ++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 18 deletions(-) diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index ce810e9..bee04ea 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -117,27 +117,83 @@ class _ScannerHomePageState extends State { Future _showAppInfo() async { final packageInfo = await PackageInfo.fromPlatform(); if (!mounted) return; - showAboutDialog( + final theme = Theme.of(context); + final mutedStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + + await showDialog( context: context, - applicationName: 'FOSScanner', - applicationVersion: '${packageInfo.version}+${packageInfo.buildNumber}', - applicationLegalese: 'Licensed under the GNU GPL v3.0', - children: [ - const SizedBox(height: 16), - InkWell( - onTap: () => launchUrl( - Uri.parse(_sourceCodeUrl), - mode: LaunchMode.externalApplication, - ), - child: Text( - _sourceCodeUrl, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), + builder: (dialogContext) => AlertDialog( + title: Row( + children: [ + Image.asset('assets/icon/icon.png', width: 40, height: 40), + const SizedBox(width: 12), + const Text('FOSScanner'), + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Version ${packageInfo.version} (build ${packageInfo.buildNumber})', + style: mutedStyle, + ), + const SizedBox(height: 16), + const Text( + 'A privacy-first, free and open-source document scanner. ' + 'Scan documents with your camera, auto-crop and dewarp them, ' + 'and export a PDF — all on-device. No accounts, no cloud, ' + 'no tracking.', + ), + const SizedBox(height: 20), + const Divider(height: 1), + const SizedBox(height: 12), + InkWell( + onTap: () => launchUrl( + Uri.parse(_sourceCodeUrl), + mode: LaunchMode.externalApplication, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.code, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + 'View source code', + style: TextStyle( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Text('Licensed under the GNU GPL v3.0', style: mutedStyle), + ], ), ), - ], + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + showLicensePage( + context: context, + applicationName: 'FOSScanner', + applicationVersion: packageInfo.version, + ); + }, + child: const Text('Open-source licenses'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + ), ); } From 0e8d2ce088415fe727ece60e96b34f191548b178 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Thu, 27 Aug 2026 23:33:24 +0200 Subject: [PATCH 05/17] Changed the info button --- lib/screens/scanner_home_page.dart | 45 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index bee04ea..b541e0a 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -177,17 +177,6 @@ class _ScannerHomePageState extends State { ), ), actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - showLicensePage( - context: context, - applicationName: 'FOSScanner', - applicationVersion: packageInfo.version, - ); - }, - child: const Text('Open-source licenses'), - ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close'), @@ -563,11 +552,6 @@ class _ScannerHomePageState extends State { onPressed: _clearPages, tooltip: 'Clear all', ), - IconButton( - icon: const Icon(Icons.info_outline), - onPressed: _showAppInfo, - tooltip: 'About FOSScanner', - ), ], ), body: _pages.isEmpty @@ -716,11 +700,14 @@ class _ScannerHomePageState extends State { child: const Icon(Icons.camera_alt), ) : null, - bottomNavigationBar: _pages.isNotEmpty - ? SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: ElevatedButton.icon( + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_pages.isNotEmpty) ...[ + ElevatedButton.icon( key: _shareButtonKey, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -740,9 +727,21 @@ class _ScannerHomePageState extends State { style: const TextStyle(fontSize: 16), ), ), + const SizedBox(height: 4), + ], + TextButton( + onPressed: _showAppInfo, + child: Text( + 'About FOSScanner', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), - ) - : null, + ], + ), + ), + ), ); } } From d726d4df7e77cd792bf9ff5164f73f1574ee412d Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 00:07:01 +0200 Subject: [PATCH 06/17] fix: reject corner quads with mismatched tl/tr/br/bl roles A convex, consistently-wound quad can still have its points in the wrong array positions (e.g. two adjacent handles dragged past each other, or all four roles shifted by one) without tripping the existing self-intersection/winding checks, since the shape itself stays valid. That silently warped the page into a flipped or rotated output instead of being caught. calculateWarpSize now re-canonicalizes the corners with orderCorners (already used at detection time) and rejects if it disagrees with the given order. Fixes #20 --- lib/services/corner_geometry.dart | 18 ++++++++++++++++++ test/services/corner_geometry_test.dart | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/services/corner_geometry.dart b/lib/services/corner_geometry.dart index 111ccc2..6a79a67 100644 --- a/lib/services/corner_geometry.dart +++ b/lib/services/corner_geometry.dart @@ -99,6 +99,24 @@ void _validateConvexQuad(List corners) { ); } } + + // A convex, consistently-wound quad can still have its 4 points in the + // wrong tl/tr/br/bl positions — e.g. two adjacent handles dragged past + // each other, or all 4 roles shifted by one — which looks like a + // perfectly valid crop shape but warps into a flipped/rotated page. + // orderCorners applies the same geometric role assignment used at + // detection time; if it disagrees with the order given here, the corners + // are mislabeled even though the shape itself is fine. + final canonical = orderCorners(corners); + for (var i = 0; i < 4; i++) { + if (canonical[i] != corners[i]) { + throw ArgumentError.value( + corners, + 'corners', + 'Corners must be ordered top-left, top-right, bottom-right, bottom-left', + ); + } + } } /// Calculates a bounded output size from ordered document [corners], preserving diff --git a/test/services/corner_geometry_test.dart b/test/services/corner_geometry_test.dart index 3d47f4f..5ec3d01 100644 --- a/test/services/corner_geometry_test.dart +++ b/test/services/corner_geometry_test.dart @@ -94,6 +94,27 @@ void main() { ); }); + test( + 'warp dimensions reject a valid quad whose corners are role-shifted', + () { + // Same rectangle as tl,tr,br,bl but rotated one position in the array + // (tr,br,bl,tl). Still convex and consistently wound — the shape + // itself is completely valid — so this only fails if role order is + // checked, not just geometry. Regression test for the corner-adjust + // bug where dragging handles past each other silently flipped output + // instead of being rejected. + expect( + () => calculateWarpSize(const [ + Offset(100, 0), + Offset(100, 100), + Offset(0, 100), + Offset(0, 0), + ]), + throwsArgumentError, + ); + }, + ); + test('warp dimensions reject self-intersecting corners', () { expect( () => calculateWarpSize(const [ From 012d0fe10144b8aef596ea8c9334996ca2c34c3e Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 00:07:06 +0200 Subject: [PATCH 07/17] fix: show corner-adjust errors in a snackbar instead of shrinking the preview Validation/processing errors were rendered as an inline Text widget stacked above the image inside the same Column, so showing an error visibly shrank the photo preview the user was trying to work with. Errors now surface via a SnackBar, matching the pattern already used on the home screen. The inline _error field is now only used for the one case that legitimately owns the whole screen: a fatal read failure during initialization. Fixes #21 --- lib/screens/corner_adjust_screen.dart | 55 ++++++++++----------------- 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/lib/screens/corner_adjust_screen.dart b/lib/screens/corner_adjust_screen.dart index 0473cb0..bca20df 100644 --- a/lib/screens/corner_adjust_screen.dart +++ b/lib/screens/corner_adjust_screen.dart @@ -132,6 +132,18 @@ class _CornerAdjustScreenState extends State { ]; } + void _showError(String message) { + if (!mounted) return; + // Defer to a post-frame callback so the SnackBar shows against this + // screen's own Scaffold rather than racing an in-flight build. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.maybeOf( + context, + )?.showSnackBar(SnackBar(content: Text(message))); + }); + } + Future _updatePreviews() async { final corners = _corners; if (corners == null) return; @@ -139,7 +151,6 @@ class _CornerAdjustScreenState extends State { _isGeneratingPreviews = true; _filterPreviews = null; _finalPreviewBytes = null; - _error = null; }); // Keep geometry failures separate from decoder/backend failures so the // recovery guidance matches what the user can actually fix. @@ -151,11 +162,10 @@ class _CornerAdjustScreenState extends State { ); } on ArgumentError { if (!mounted) return; - setState(() { - _isGeneratingPreviews = false; - _error = - 'Could not preview this crop. Adjust the corners and try again.'; - }); + setState(() => _isGeneratingPreviews = false); + _showError( + 'Could not preview this crop. Adjust the corners and try again.', + ); return; } @@ -177,10 +187,8 @@ class _CornerAdjustScreenState extends State { _updateFinalPreview(); } catch (_) { if (!mounted) return; - setState(() { - _isGeneratingPreviews = false; - _error = 'Could not process this photo. Try another image.'; - }); + setState(() => _isGeneratingPreviews = false); + _showError('Could not process this photo. Try another image.'); } } @@ -244,10 +252,7 @@ class _CornerAdjustScreenState extends State { final rotationQuarterTurns = _rotationQuarterTurns; final brightness = _brightness; final contrast = _contrast; - setState(() { - _isProcessing = true; - _error = null; - }); + setState(() => _isProcessing = true); try { final processed = await _processForExport( corners, @@ -270,10 +275,8 @@ class _CornerAdjustScreenState extends State { ); } catch (_) { if (!mounted) return; - setState(() { - _isProcessing = false; - _error = 'Could not process this page. Try another image.'; - }); + setState(() => _isProcessing = false); + _showError('Could not process this page. Try another image.'); } } @@ -326,14 +329,6 @@ class _CornerAdjustScreenState extends State { ) { return Column( children: [ - if (_error != null) - Padding( - padding: const EdgeInsets.all(12), - child: Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), Expanded( child: Padding( padding: const EdgeInsets.all(16), @@ -400,14 +395,6 @@ class _CornerAdjustScreenState extends State { _finalPreviewBytes ?? _filterPreviews?[_selectedFilter]; return Column( children: [ - if (_error != null) - Padding( - padding: const EdgeInsets.all(12), - child: Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), Expanded( child: Padding( padding: const EdgeInsets.all(16), From 45caa8dcddb7af60ac3bfede691b1bafbafd5a60 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 00:25:33 +0200 Subject: [PATCH 08/17] ci: upload the debug APK CI already builds as an artifact The Android ABI build in this job was previously discard-on-exit, existing only to catch Gradle/NDK/native-asset regressions. Uploading it makes it possible to actually grab a dev-branch build for manual testing without needing a local Flutter/Android toolchain. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff13f81..326304e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,9 @@ jobs: # Android ABI as well so Gradle/NDK/native-asset regressions fail before # a release tag is created. - run: flutter build apk --debug --target-platform android-arm64 + + - uses: actions/upload-artifact@v4 + with: + name: app-debug-arm64 + path: build/app/outputs/flutter-apk/app-debug.apk + retention-days: 14 From 18dc243bbe22f8fbd803a7d0e5558090b03e9cb5 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 09:24:53 +0200 Subject: [PATCH 09/17] feat: add QR/barcode scanning as a standalone camera mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Scan QR/barcode" action to the home screen, separate from the document-scan flow: a live camera view decodes a code and shows the result with copy/open actions, rather than treating the code as a document page. Uses flutter_zxing (MIT, on-device via zxing-cpp/FFI) rather than mobile_scanner/ML Kit, to avoid pulling in Google's proprietary, Play-Services-dependent barcode backend for a privacy-first app. flutter_zxing has no web decoding backend, so the entry point is hidden on web, matching the existing kIsWeb convention used for the opencv-dependent detect/adjust flow. Also allows launching plain http (not just https) links decoded from a scanned code, via the existing url_launcher package-visibility queries block. Note: pubspec.lock was not regenerated with this commit (no local Flutter toolchain available) — needs a `flutter pub get` run and commit before this is fully in sync. --- android/app/src/main/AndroidManifest.xml | 9 +- lib/screens/barcode_scan_screen.dart | 112 +++++++++++++++++++++++ lib/screens/scanner_home_page.dart | 13 +++ pubspec.yaml | 1 + 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 lib/screens/barcode_scan_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b0be735..c70bb41 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,12 +43,17 @@ - + + + + diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart new file mode 100644 index 0000000..b49caf2 --- /dev/null +++ b/lib/screens/barcode_scan_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter_zxing/flutter_zxing.dart'; +import 'package:url_launcher/url_launcher.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 BarcodeScanScreen extends StatefulWidget { + const BarcodeScanScreen({super.key}); + + @override + State createState() => _BarcodeScanScreenState(); +} + +class _BarcodeScanScreenState extends State { + String? _lastResult; + + Uri? get _resultUri { + final result = _lastResult; + if (result == null) return null; + final uri = Uri.tryParse(result); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + return null; + } + return uri; + } + + void _handleScan(Code code) { + final text = code.text; + if (!code.isValid || text == null || text.isEmpty) return; + if (text == _lastResult) return; + setState(() => _lastResult = text); + } + + Future _copyResult() async { + final result = _lastResult; + if (result == null) return; + await Clipboard.setData(ClipboardData(text: result)); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); + } + + Future _openResult() async { + final uri = _resultUri; + if (uri == null) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + final result = _lastResult; + return Scaffold( + appBar: AppBar(title: const Text('Scan QR / Barcode')), + body: Stack( + children: [ + ReaderWidget(onScan: _handleScan, showGallery: true), + if (result != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea( + top: false, + child: Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Row( + children: [ + if (_resultUri != null) ...[ + Expanded( + 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'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index b541e0a..0d3a652 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -14,6 +14,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../models/scanned_page.dart'; import '../services/image_metadata.dart'; +import 'barcode_scan_screen.dart'; import 'corner_adjust_screen.dart'; const _thumbnailCacheWidth = 512; @@ -539,6 +540,18 @@ 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) + IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BarcodeScanScreen()), + ), + tooltip: 'Scan QR/barcode', + ), IconButton( icon: const Icon(Icons.photo_library_outlined), onPressed: _isPickingImages || !_canStartImagePick diff --git a/pubspec.yaml b/pubspec.yaml index b960279..7e16d95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: opencv_dart: ^2.2.1 package_info_plus: ^10.2.1 url_launcher: ^6.3.2 + flutter_zxing: ^2.3.0 dev_dependencies: flutter_test: From 1e92ad76c256fcac45c0daf4c6b4849f6b174c53 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 10:50:31 +0200 Subject: [PATCH 10/17] fix: QR/barcode scanning never detecting a code Video of the scan screen showed a well-lit, correctly-framed QR code sitting inside the on-screen crop guide for several seconds without ever being picked up. This screen embeds ReaderWidget below an AppBar rather than full-screen, which is exactly the scenario flutter_zxing's maintainers flag in khoren93/flutter_zxing#196: the letterboxed preview makes the drawn crop-indicator square visually line up with the code while the actual pixel region handed to the native decoder is offset elsewhere, so nothing ever gets read despite looking correctly framed. Their recommended fix for non-fullscreen placement is cropPercent: 0, which decodes the full frame instead of the misaligned crop window. --- lib/screens/barcode_scan_screen.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart index b49caf2..0bcab21 100644 --- a/lib/screens/barcode_scan_screen.dart +++ b/lib/screens/barcode_scan_screen.dart @@ -56,7 +56,19 @@ class _BarcodeScanScreenState extends State { appBar: AppBar(title: const Text('Scan QR / Barcode')), body: Stack( children: [ - ReaderWidget(onScan: _handleScan, 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. 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. + ReaderWidget( + onScan: _handleScan, + showGallery: true, + cropPercent: 0, + ), if (result != null) Positioned( left: 0, From 01c2c361b916d9b962c49a1932d4cc32b6e2fc3b Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 11:30:52 +0200 Subject: [PATCH 11/17] fix: QR scan result never clears, and the found-code overlay looked clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues reported after the crop fix: - No way to dismiss a scanned result and resume scanning without leaving the screen — added a close button on the result card. - Disabling ReaderWidget's built-in crop guide (needed for actual detection to work) also switched its overlay to a "tap the highlighted code" mode, which read as scanning requiring a click when onScan already fires automatically on decode. Turned that off (showScannerOverlay: false) and replaced it with a plain, non-interactive centered square drawn locally, restoring the aiming hint without tying it to the real decode region again. --- lib/screens/barcode_scan_screen.dart | 53 +++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart index 0bcab21..8d6d434 100644 --- a/lib/screens/barcode_scan_screen.dart +++ b/lib/screens/barcode_scan_screen.dart @@ -33,6 +33,10 @@ class _BarcodeScanScreenState extends State { setState(() => _lastResult = text); } + void _dismissResult() { + setState(() => _lastResult = null); + } + Future _copyResult() async { final result = _lastResult; if (result == null) return; @@ -64,11 +68,37 @@ class _BarcodeScanScreenState extends State { // 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, ), + if (result == null) + IgnorePointer( + child: Center( + child: Container( + width: MediaQuery.sizeOf(context).shortestSide * 0.6, + height: MediaQuery.sizeOf(context).shortestSide * 0.6, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), if (result != null) Positioned( left: 0, @@ -84,10 +114,25 @@ class _BarcodeScanScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - result, - maxLines: 3, - overflow: TextOverflow.ellipsis, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + result, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Dismiss and keep scanning', + onPressed: _dismissResult, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), const SizedBox(height: 12), Row( From d0661bae8eb832f018bd93333da0e606736bf689 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 11:42:20 +0200 Subject: [PATCH 12/17] fix: enable tryHarder so 1D barcodes (EAN/UPC/Code128) actually decode Video showed a QR code decoding almost instantly while several 1D barcodes on a physical product box were never picked up at all, despite Format.any already covering those formats. tryHarder enables a more exhaustive per-frame decode pass, which 1D formats need much more than QR: they carry far less error-correction redundancy and are far more sensitive to being held at a slight skew, which is how a barcode is realistically framed by hand. --- lib/screens/barcode_scan_screen.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart index 8d6d434..3305be8 100644 --- a/lib/screens/barcode_scan_screen.dart +++ b/lib/screens/barcode_scan_screen.dart @@ -82,6 +82,11 @@ class _BarcodeScanScreenState extends State { 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, ), if (result == null) IgnorePointer( From e61972b4892716366409d94df18705777f9fb59c Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 12:16:21 +0200 Subject: [PATCH 13/17] fix: corner role validation could reject valid, correctly-ordered crops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: calculateWarpSize ran the new role-order check twice (once on the caller's corners, once on a rounded copy for OpenCV's integer source points), but orderCorners' tie-break on edge-midpoint y can flip once coordinates are rounded, even when the unrounded quad was unambiguous. That made a perfectly valid, correctly-labeled crop get rejected whenever that tie happened to land on opposite sides of the rounding boundary — a rounding artifact, not a real mislabeling. Split the role check out of _validateConvexQuad into its own function, called once on the original un-rounded corners only. The convexity/ degenerate checks still run on both the raw and rounded copies, same as before. --- lib/services/corner_geometry.dart | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/services/corner_geometry.dart b/lib/services/corner_geometry.dart index 6a79a67..7a085f8 100644 --- a/lib/services/corner_geometry.dart +++ b/lib/services/corner_geometry.dart @@ -99,14 +99,24 @@ void _validateConvexQuad(List corners) { ); } } +} - // A convex, consistently-wound quad can still have its 4 points in the - // wrong tl/tr/br/bl positions — e.g. two adjacent handles dragged past - // each other, or all 4 roles shifted by one — which looks like a - // perfectly valid crop shape but warps into a flipped/rotated page. - // orderCorners applies the same geometric role assignment used at - // detection time; if it disagrees with the order given here, the corners - // are mislabeled even though the shape itself is fine. +// A convex, consistently-wound quad can still have its 4 points in the +// wrong tl/tr/br/bl positions — e.g. two adjacent handles dragged past +// each other, or all 4 roles shifted by one — which looks like a +// perfectly valid crop shape but warps into a flipped/rotated page. +// orderCorners applies the same geometric role assignment used at +// detection time; if it disagrees with the order given here, the corners +// are mislabeled even though the shape itself is fine. +// +// Deliberately separate from _validateConvexQuad (and only ever called +// once, on the caller's original un-rounded corners): orderCorners' choice +// of starting edge is a tie-break on midpoint y that can flip once corners +// are rounded to integers, even when the un-rounded quad was unambiguous. +// Running this same check again against a rounded copy would then reject +// perfectly valid, correctly-labeled crops whenever that tie happened to +// land on opposite sides of the rounding boundary. +void _validateCornerRoles(List corners) { final canonical = orderCorners(corners); for (var i = 0; i < 4; i++) { if (canonical[i] != corners[i]) { @@ -130,6 +140,7 @@ void _validateConvexQuad(List corners) { throw ArgumentError('Warp limits must allow an image of at least 2×2'); } _validateConvexQuad(corners); + _validateCornerRoles(corners); // OpenCV receives integer source points. Validate and size from those exact // points so a valid-looking fractional quad cannot collapse when converted. From c478ecb64aff8424ce4a08be024edf244cb97e4c Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 12:16:30 +0200 Subject: [PATCH 14/17] fix: dedupe snackbar helper, guard a post-dispose setState, surface launchUrl failures Found in review: - scanner_home_page.dart's _showMessage and corner_adjust_screen.dart's _showError were identical deferred-SnackBar implementations copied verbatim. Extracted the shared logic into lib/widgets/transient_message.dart so a future fix to the postFrameCallback/mounted-race pattern only needs to happen once. - BarcodeScanScreen._handleScan could call setState after the widget was disposed: ReaderWidget's onScan fires from an async camera-frame decode that can complete after the user has already navigated away. Added the same mounted guard every other callback in this file uses. - The About dialog's "View source code" link and the barcode scanner's "Open" button both called launchUrl and ignored the result, unlike every other user-triggered action in these files, which shows a SnackBar on failure. Now checks the returned bool and catches exceptions, consistent with the rest of the app. --- lib/screens/barcode_scan_screen.dart | 13 ++++++++++++- lib/screens/corner_adjust_screen.dart | 13 ++----------- lib/screens/scanner_home_page.dart | 28 +++++++++++++-------------- lib/widgets/transient_message.dart | 15 ++++++++++++++ 4 files changed, 43 insertions(+), 26 deletions(-) create mode 100644 lib/widgets/transient_message.dart diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart index 3305be8..e84fec5 100644 --- a/lib/screens/barcode_scan_screen.dart +++ b/lib/screens/barcode_scan_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter_zxing/flutter_zxing.dart'; 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. @@ -27,6 +29,7 @@ class _BarcodeScanScreenState extends State { } void _handleScan(Code code) { + if (!mounted) return; final text = code.text; if (!code.isValid || text == null || text.isEmpty) return; if (text == _lastResult) return; @@ -50,7 +53,15 @@ class _BarcodeScanScreenState extends State { Future _openResult() async { final uri = _resultUri; if (uri == null) return; - await launchUrl(uri, mode: LaunchMode.externalApplication); + try { + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!launched) showTransientMessage(context, 'Could not open the link.'); + } catch (_) { + showTransientMessage(context, 'Could not open the link.'); + } } @override diff --git a/lib/screens/corner_adjust_screen.dart b/lib/screens/corner_adjust_screen.dart index bca20df..5ad0fff 100644 --- a/lib/screens/corner_adjust_screen.dart +++ b/lib/screens/corner_adjust_screen.dart @@ -10,6 +10,7 @@ import '../models/scanned_page.dart'; import '../services/corner_geometry.dart'; import '../services/document_processor.dart'; import '../widgets/corner_overlay.dart'; +import '../widgets/transient_message.dart'; const _fullPreviewDecodeSize = 2048; const _filterChipDecodeSize = 256; @@ -132,17 +133,7 @@ class _CornerAdjustScreenState extends State { ]; } - void _showError(String message) { - if (!mounted) return; - // Defer to a post-frame callback so the SnackBar shows against this - // screen's own Scaffold rather than racing an in-flight build. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(SnackBar(content: Text(message))); - }); - } + void _showError(String message) => showTransientMessage(context, message); Future _updatePreviews() async { final corners = _corners; diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index 0d3a652..dbaebde 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -14,6 +14,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../models/scanned_page.dart'; import '../services/image_metadata.dart'; +import '../widgets/transient_message.dart'; import 'barcode_scan_screen.dart'; import 'corner_adjust_screen.dart'; @@ -103,16 +104,18 @@ class _ScannerHomePageState extends State { return true; } - void _showMessage(String message) { - if (!mounted) return; - // Startup lost-data recovery can fail from initState, before this page's - // Scaffold has registered with the surrounding ScaffoldMessenger. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(SnackBar(content: Text(message))); - }); + void _showMessage(String message) => showTransientMessage(context, message); + + Future _openSourceCode() async { + try { + final launched = await launchUrl( + Uri.parse(_sourceCodeUrl), + mode: LaunchMode.externalApplication, + ); + if (!launched) _showMessage('Could not open the source code link.'); + } catch (_) { + _showMessage('Could not open the source code link.'); + } } Future _showAppInfo() async { @@ -153,10 +156,7 @@ class _ScannerHomePageState extends State { const Divider(height: 1), const SizedBox(height: 12), InkWell( - onTap: () => launchUrl( - Uri.parse(_sourceCodeUrl), - mode: LaunchMode.externalApplication, - ), + onTap: _openSourceCode, child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/transient_message.dart b/lib/widgets/transient_message.dart new file mode 100644 index 0000000..03fd767 --- /dev/null +++ b/lib/widgets/transient_message.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.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). +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))); + }); +} From b0eeadcdf7d6db3d9423d987c255045af2100446 Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 12:16:38 +0200 Subject: [PATCH 15/17] ci: upload pubspec.lock as an artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: pubspec.yaml added flutter_zxing but pubspec.lock was never regenerated to include it (no Flutter SDK was available to run pub get while making that change). CI's flutter pub get silently regenerates the missing entries in its own ephemeral checkout each run, which is why this didn't surface as a build failure, but the lockfile actually committed to the repo is inconsistent with pubspec.yaml — a problem for anything that installs strictly from the committed lockfile, including the F-Droid reproducible-build effort already on the roadmap. Uploading it here so the correct, tool-generated lockfile can be pulled down and committed as a follow-up, rather than hand-editing it. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 326304e..97602a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,13 @@ jobs: channel: stable - run: flutter pub get + + - uses: actions/upload-artifact@v4 + with: + name: pubspec-lock + path: pubspec.lock + retention-days: 14 + - run: flutter analyze - run: flutter test # Tests compile the host (Linux) native assets only. Compile one shipping From e9c9fa304f9349e43218231dd19a8ef8199f277b Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 12:19:10 +0200 Subject: [PATCH 16/17] fix: use_build_context_synchronously lint in _openResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flutter analyze correctly flagged context use after await without a local mounted guard — showTransientMessage's own internal check isn't visible to the analyzer at the call site. --- lib/screens/barcode_scan_screen.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart index e84fec5..f608923 100644 --- a/lib/screens/barcode_scan_screen.dart +++ b/lib/screens/barcode_scan_screen.dart @@ -58,9 +58,11 @@ class _BarcodeScanScreenState extends State { uri, mode: LaunchMode.externalApplication, ); - if (!launched) showTransientMessage(context, 'Could not open the link.'); + if (!launched && mounted) { + showTransientMessage(context, 'Could not open the link.'); + } } catch (_) { - showTransientMessage(context, 'Could not open the link.'); + if (mounted) showTransientMessage(context, 'Could not open the link.'); } } From 97d17a051510258eb1a04e4ac3b2c3a86ff1955d Mon Sep 17 00:00:00 2001 From: Lorenzo Camilli Date: Fri, 28 Aug 2026 12:37:49 +0200 Subject: [PATCH 17/17] chore: regenerate pubspec.lock via CI (adds flutter_zxing) --- pubspec.lock | 72 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 2b32861..bdd378c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: transitive + description: + name: camera + sha256: "558230d6ce6ccea856b32d390db7e7b557adf4d9320aa614481bd3f2f608953f" + url: "https://pub.dev" + source: hosted + version: "0.12.0+2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: a13f12f9c067679bf11f0f61e3f2079ac5c740388dec2e46d82e9f28ea093b4f + url: "https://pub.dev" + source: hosted + version: "0.7.4+7" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "843247230a583e04742ea1434ce3f91e536760922fab67c72ce46aa34fac3e9d" + url: "https://pub.dev" + source: hosted + version: "0.10.3" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "081441bd2f92b5841aa70ff4d6eddfe34078d97f9d085cc9e0f9d0102f145925" + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" change_case: dependency: transitive description: @@ -240,6 +280,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_zxing: + dependency: "direct main" + description: + name: flutter_zxing + sha256: "6bf0cb0fde122455e73ae78214457c106fbde3c633af4624ce29567ac590ef21" + url: "https://pub.dev" + source: hosted + version: "2.3.0" glob: dependency: transitive description: @@ -420,10 +468,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -436,10 +484,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -669,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -689,10 +745,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -777,10 +833,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: