diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ff13f81..97602a9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,9 +17,22 @@ 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
# 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
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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 31d0bdf..c70bb41 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,7 @@
+
+
+
+
+
+
+
+
+
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/lib/screens/barcode_scan_screen.dart b/lib/screens/barcode_scan_screen.dart
new file mode 100644
index 0000000..f608923
--- /dev/null
+++ b/lib/screens/barcode_scan_screen.dart
@@ -0,0 +1,187 @@
+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';
+
+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 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) {
+ if (!mounted) return;
+ final text = code.text;
+ if (!code.isValid || text == null || text.isEmpty) return;
+ if (text == _lastResult) return;
+ setState(() => _lastResult = text);
+ }
+
+ void _dismissResult() {
+ setState(() => _lastResult = null);
+ }
+
+ 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;
+ try {
+ final launched = await launchUrl(
+ uri,
+ mode: LaunchMode.externalApplication,
+ );
+ if (!launched && mounted) {
+ showTransientMessage(context, 'Could not open the link.');
+ }
+ } catch (_) {
+ if (mounted) showTransientMessage(context, 'Could not open the link.');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final result = _lastResult;
+ 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,
+ ),
+ 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,
+ 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: [
+ 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(
+ 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/corner_adjust_screen.dart b/lib/screens/corner_adjust_screen.dart
index 0473cb0..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,6 +133,8 @@ class _CornerAdjustScreenState extends State {
];
}
+ void _showError(String message) => showTransientMessage(context, message);
+
Future _updatePreviews() async {
final corners = _corners;
if (corners == null) return;
@@ -139,7 +142,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 +153,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 +178,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 +243,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 +266,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 +320,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 +386,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),
diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart
index a2bac77..dbaebde 100644
--- a/lib/screens/scanner_home_page.dart
+++ b/lib/screens/scanner_home_page.dart
@@ -6,15 +6,20 @@ 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 '../widgets/transient_message.dart';
+import 'barcode_scan_screen.dart';
import 'corner_adjust_screen.dart';
const _thumbnailCacheWidth = 512;
+const _sourceCodeUrl = 'https://github.com/FOSScanner/fosscanner-app';
class _DocumentCapacityException implements Exception {
const _DocumentCapacityException();
@@ -99,16 +104,87 @@ class _ScannerHomePageState extends State {
return true;
}
- void _showMessage(String 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 {
+ final packageInfo = await PackageInfo.fromPlatform();
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)));
- });
+ final theme = Theme.of(context);
+ final mutedStyle = theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ );
+
+ await showDialog(
+ context: context,
+ 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: _openSourceCode,
+ 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(),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ );
}
Future _recoverLostImages() async {
@@ -464,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
@@ -625,11 +713,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),
@@ -649,9 +740,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,
+ ],
+ ),
+ ),
+ ),
);
}
}
diff --git a/lib/services/corner_geometry.dart b/lib/services/corner_geometry.dart
index 111ccc2..7a085f8 100644
--- a/lib/services/corner_geometry.dart
+++ b/lib/services/corner_geometry.dart
@@ -101,6 +101,34 @@ 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.
+//
+// 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]) {
+ 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
/// the selected crop's aspect ratio when it must be downscaled.
(int width, int height) calculateWarpSize(
@@ -112,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.
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)));
+ });
+}
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/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/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/pubspec.lock b/pubspec.lock
index e973163..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:
@@ -480,6 +528,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:
@@ -653,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:
@@ -673,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:
@@ -685,6 +757,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 +789,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:
@@ -729,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:
diff --git a/pubspec.yaml b/pubspec.yaml
index 217e118..7e16d95 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -14,6 +14,9 @@ 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
+ flutter_zxing: ^2.3.0
dev_dependencies:
flutter_test:
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 [
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);