Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c3e4c56
feat: add in-app about screen with version and source link
lorenzocamilli Aug 26, 2026
600fbe0
fix: use FOSScanner consistently in user-facing display strings
lorenzocamilli Aug 26, 2026
b810a37
docs: clarify PR base branch and document recent features in README
lorenzocamilli Aug 26, 2026
71592fe
fix: clarify version display and expand the about dialog
lorenzocamilli Aug 26, 2026
0e8d2ce
Changed the info button
lorenzocamilli Aug 27, 2026
d726d4d
fix: reject corner quads with mismatched tl/tr/br/bl roles
lorenzocamilli Aug 27, 2026
012d0fe
fix: show corner-adjust errors in a snackbar instead of shrinking the…
lorenzocamilli Aug 27, 2026
45caa8d
ci: upload the debug APK CI already builds as an artifact
lorenzocamilli Aug 27, 2026
18dc243
feat: add QR/barcode scanning as a standalone camera mode
lorenzocamilli Aug 28, 2026
1e92ad7
fix: QR/barcode scanning never detecting a code
lorenzocamilli Aug 28, 2026
01c2c36
fix: QR scan result never clears, and the found-code overlay looked c…
lorenzocamilli Aug 28, 2026
d0661ba
fix: enable tryHarder so 1D barcodes (EAN/UPC/Code128) actually decode
lorenzocamilli Aug 28, 2026
e61972b
fix: corner role validation could reject valid, correctly-ordered crops
lorenzocamilli Aug 28, 2026
c478ecb
fix: dedupe snackbar helper, guard a post-dispose setState, surface l…
lorenzocamilli Aug 28, 2026
b0eeadc
ci: upload pubspec.lock as an artifact
lorenzocamilli Aug 28, 2026
e9c9fa3
fix: use_build_context_synchronously lint in _openResult
lorenzocamilli Aug 28, 2026
97d17a0
chore: regenerate pubspec.lock via CI (adds flutter_zxing)
lorenzocamilli Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:label="fosscanner"
android:label="FOSScanner"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
Expand Down Expand Up @@ -43,5 +43,17 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Lets url_launcher open the About screen's source-code link, and
http(s) links decoded from a scanned QR/barcode, in the user's
browser; without this, package visibility restrictions on
Android 11+ can make the launch silently fail. -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
</queries>
</manifest>
4 changes: 2 additions & 2 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Fosscanner</string>
<string>FOSScanner</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>fosscanner</string>
<string>FOSScanner</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
Expand Down
187 changes: 187 additions & 0 deletions lib/screens/barcode_scan_screen.dart
Original file line number Diff line number Diff line change
@@ -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<BarcodeScanScreen> createState() => _BarcodeScanScreenState();
}

class _BarcodeScanScreenState extends State<BarcodeScanScreen> {
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<void> _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<void> _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'),
),
),
],
),
],
),
),
),
),
),
],
),
);
}
}
46 changes: 12 additions & 34 deletions lib/screens/corner_adjust_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,14 +133,15 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
];
}

void _showError(String message) => showTransientMessage(context, message);

Future<void> _updatePreviews() async {
final corners = _corners;
if (corners == null) return;
setState(() {
_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.
Expand All @@ -151,11 +153,10 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
);
} 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;
}

Expand All @@ -177,10 +178,8 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
_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.');
}
}

Expand Down Expand Up @@ -244,10 +243,7 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
final rotationQuarterTurns = _rotationQuarterTurns;
final brightness = _brightness;
final contrast = _contrast;
setState(() {
_isProcessing = true;
_error = null;
});
setState(() => _isProcessing = true);
try {
final processed = await _processForExport(
corners,
Expand All @@ -270,10 +266,8 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
);
} 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.');
}
}

Expand Down Expand Up @@ -326,14 +320,6 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
) {
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),
Expand Down Expand Up @@ -400,14 +386,6 @@ class _CornerAdjustScreenState extends State<CornerAdjustScreen> {
_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),
Expand Down
Loading
Loading