Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
private var delegate: PluginRegistry.ActivityResultListener? = null
private var binding: ActivityPluginBinding? = null
private var pendingResult: Result? = null
private var singleDocumentMode: Boolean = false
private lateinit var activity: Activity
private val START_DOCUMENT_ACTIVITY: Int = 0x362738
private val START_DOCUMENT_FB_ACTIVITY: Int = 0x362737
Expand All @@ -48,8 +49,10 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
if (call.method == "getPictures") {
val noOfPages = call.argument<Int>("noOfPages") ?: 50;
val isGalleryImportAllowed = call.argument<Boolean>("isGalleryImportAllowed") ?: false;
singleDocumentMode = call.argument<Boolean>("singleDocumentMode") ?: false;
val frameColor = call.argument<String>("frameColor");
this.pendingResult = result
startScan(noOfPages, isGalleryImportAllowed)
startScan(noOfPages, isGalleryImportAllowed, singleDocumentMode, frameColor)
} else {
result.notImplemented()
}
Expand Down Expand Up @@ -87,9 +90,15 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
data?.extras?.getParcelable("extra_scanning_result")
?: return@ActivityResultListener false

val successResponse = scanningResult.pages?.map {
var successResponse = scanningResult.pages?.map {
it.imageUri.toString().removePrefix("file://")
}?.toList()
}?.toList() ?: emptyList()

// If single document mode is enabled, return only the first page
if (singleDocumentMode && successResponse.isNotEmpty()) {
successResponse = listOf(successResponse[0])
}

// trigger the success event handler with an array of cropped images
pendingResult?.success(successResponse)
}
Expand Down Expand Up @@ -120,9 +129,15 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA

// return a list of file paths
// removing file uri prefix as Flutter file will have problems with it
val successResponse = croppedImageResults.map {
var successResponse = croppedImageResults.map {
it.removePrefix("file://")
}.toList()

// If single document mode is enabled, return only the first page
if (singleDocumentMode && successResponse.isNotEmpty()) {
successResponse = listOf(successResponse[0])
}

// trigger the success event handler with an array of cropped images
pendingResult?.success(successResponse)
}
Expand All @@ -140,6 +155,7 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
if (handled) {
// Clear the pending result to avoid reuse
pendingResult = null
singleDocumentMode = false
}
return@ActivityResultListener handled
}
Expand All @@ -154,13 +170,23 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
/**
* create intent to launch document scanner and set custom options
*/
private fun createDocumentScanIntent(noOfPages: Int): Intent {
private fun createDocumentScanIntent(noOfPages: Int, singleDocumentMode: Boolean, frameColor: String?): Intent {
val documentScanIntent = Intent(activity, DocumentScannerActivity::class.java)

documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_MAX_NUM_DOCUMENTS,
noOfPages
)
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_SINGLE_DOCUMENT_MODE,
singleDocumentMode
)
if (frameColor != null) {
documentScanIntent.putExtra(
DocumentScannerExtra.EXTRA_FRAME_COLOR,
frameColor
)
}

return documentScanIntent
}
Expand All @@ -169,10 +195,13 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
/**
* add document scanner result handler and launch the document scanner
*/
private fun startScan(noOfPages: Int, isGalleryImportAllowed: Boolean) {
private fun startScan(noOfPages: Int, isGalleryImportAllowed: Boolean, singleDocumentMode: Boolean, frameColor: String?) {
// If single document mode is enabled, limit pages to 1
val pageLimit = if (singleDocumentMode) 1 else noOfPages

val options = GmsDocumentScannerOptions.Builder()
.setGalleryImportAllowed(isGalleryImportAllowed)
.setPageLimit(noOfPages)
.setPageLimit(pageLimit)
.setResultFormats(RESULT_FORMAT_JPEG)
.setScannerMode(SCANNER_MODE_FULL)
.build()
Expand All @@ -187,7 +216,7 @@ class CunningDocumentScannerPlugin : FlutterPlugin, MethodCallHandler, ActivityA
}
}.addOnFailureListener {
if (it is MlKitException) {
val intent = createDocumentScanIntent(noOfPages)
val intent = createDocumentScanIntent(noOfPages, singleDocumentMode, frameColor)
try {
ActivityCompat.startActivityForResult(
this.activity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ class DocumentScannerActivity : AppCompatActivity() {
*/
private var croppedImageQuality = DefaultSetting.CROPPED_IMAGE_QUALITY

/**
* @property singleDocumentMode when true, only one document can be scanned
*/
private var singleDocumentMode = false

/**
* @property frameColor the color of the document detection frame
*/
private var frameColor: String? = null

/**
* @property cropperOffsetWhenCornersNotFound if we can't find document corners, we set
* corners to image size with a slight margin
Expand Down Expand Up @@ -71,13 +81,15 @@ class DocumentScannerActivity : AppCompatActivity() {
// user takes photo
originalPhotoPath ->

// if maxNumDocuments is 3 and this is the 3rd photo, hide the new photo button since
// we reach the allowed limit
// Hide new photo button only if we're at max documents limit
// For singleDocumentMode, we'll try auto-return but allow manual continuation if needed
if (documents.size == maxNumDocuments - 1) {
val newPhotoButton: ImageButton = findViewById(R.id.new_photo_button)
newPhotoButton.isClickable = false
newPhotoButton.visibility = View.INVISIBLE
newPhotoButton.visibility = View.GONE
}
// Note: For singleDocumentMode, we don't hide the button immediately
// This allows fallback to manual scanning if auto-return doesn't work

// get bitmap from photo file path
val photo: Bitmap? = try {
Expand Down Expand Up @@ -124,8 +136,37 @@ class DocumentScannerActivity : AppCompatActivity() {
imageView.imagePreviewBounds.height() / photo.height
)

// Apply frame color before setting cropper (so it's used when drawing)
if (frameColor != null) {
imageView.setFrameColor(frameColor)
}

// display cropper, and allow user to move corners
imageView.setCropper(cornersInImagePreviewCoordinates)

// If singleDocumentMode is enabled and this is the first document, try auto-return
// If auto-return fails for any reason, fall back to manual scanning (default behavior)
if (singleDocumentMode && documents.size == 0) {
// Use a post with a small delay to attempt auto-return
// If user interacts before this completes, they can continue manually
imageView.postDelayed({
try {
// Only auto-return if still on first document (user didn't continue manually)
if (documents.size == 0 && document != null) {
// Add the document to the list with the detected corners
addSelectedCornersAndOriginalPhotoPathToDocuments()
// Automatically finish and return to app immediately
cropDocumentAndFinishIntent()
}
// If documents.size > 0, user already continued manually, so don't auto-return
} catch (e: Exception) {
// If auto-return fails, allow manual scanning (default behavior)
// User can continue scanning normally
}
}, 100) // Small delay to allow for potential user interaction
// Don't return here - allow the UI to show so user can see the document
// If auto-return succeeds, it will finish. If not, user can continue manually.
}
} catch (exception: Exception) {
finishIntentWithError(
"unable get image preview ready: ${exception.message}"
Expand Down Expand Up @@ -161,6 +202,11 @@ class DocumentScannerActivity : AppCompatActivity() {
// doesn't see this until they finish taking a photo
setContentView(R.layout.activity_image_crop)
imageView = findViewById(R.id.image_view)

// Apply frame color early if specified (before any images are set)
if (frameColor != null) {
imageView.setFrameColor(frameColor)
}

try {
// validate maxNumDocuments option, and update default if user sets it
Expand All @@ -185,13 +231,32 @@ class DocumentScannerActivity : AppCompatActivity() {
}
croppedImageQuality = it
}

// read singleDocumentMode option
intent.extras?.get(DocumentScannerExtra.EXTRA_SINGLE_DOCUMENT_MODE)?.let {
if (it is Boolean) {
singleDocumentMode = it
}
}

// read frameColor option
intent.extras?.get(DocumentScannerExtra.EXTRA_FRAME_COLOR)?.let {
if (it is String) {
frameColor = it
}
}
} catch (exception: Exception) {
finishIntentWithError(
"invalid extra: ${exception.message}"
)
return
}

// Apply frame color to imageView after it's initialized
if (frameColor != null) {
imageView.setFrameColor(frameColor)
}

// set click event handlers for new document button, accept and crop document button,
// and retake document photo button
val newPhotoButton: ImageButton = findViewById(R.id.new_photo_button)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ class DocumentScannerExtra {
companion object {
const val EXTRA_CROPPED_IMAGE_QUALITY = "croppedImageQuality"
const val EXTRA_MAX_NUM_DOCUMENTS = "maxNumDocuments"
const val EXTRA_SINGLE_DOCUMENT_MODE = "singleDocumentMode"
const val EXTRA_FRAME_COLOR = "frameColor"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,58 @@ class ImageCropView(context: Context, attrs: AttributeSet) : AppCompatImageView(
quad = cropperCorners
}

/**
* Sets the frame color for the document detection overlay.
* Supports hex colors (e.g., "#FF0000" or "FF0000") or named colors (e.g., "red", "blue").
*
* @param colorString the color as a string (hex or named color)
*/
fun setFrameColor(colorString: String?) {
val color = parseColor(colorString)
cropperLinesAndCornersStyles.color = color
invalidate()
}

/**
* Parses a color string and returns an Android Color integer.
* Supports hex colors (e.g., "#FF0000", "#F00", "FF0000") or named colors (e.g., "red", "blue").
*
* @param colorString the color as a string (hex or named color)
* @return the parsed color as an integer, or Color.WHITE if invalid or null
*/
private fun parseColor(colorString: String?): Int {
if (colorString == null || colorString.isEmpty()) {
return Color.WHITE
}

// Try hex color first
try {
if (colorString.startsWith("#")) {
return Color.parseColor(colorString)
} else if (colorString.length == 6 || colorString.length == 3) {
return Color.parseColor("#$colorString")
}
} catch (e: IllegalArgumentException) {
// Not a valid hex color, try named color
}

// Try named colors
return when (colorString.lowercase()) {
"red" -> Color.RED
"blue" -> Color.BLUE
"green" -> Color.GREEN
"white" -> Color.WHITE
"black" -> Color.BLACK
"yellow" -> Color.YELLOW
"cyan" -> Color.CYAN
"magenta" -> Color.MAGENTA
"gray", "grey" -> Color.GRAY
"darkgray", "darkgrey" -> Color.DKGRAY
"lightgray", "lightgrey" -> Color.LTGRAY
else -> Color.WHITE // Default
}
}

/**
* @property imagePreviewBounds image coordinates - if the image ratio is different than
* the image container ratio then there's blank space either at the top and bottom of the
Expand Down
2 changes: 1 addition & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ void main() {
}

class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
const MyApp({super.key});

@override
State<MyApp> createState() => _MyAppState();
Expand Down
10 changes: 5 additions & 5 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ packages:
path: ".."
relative: true
source: path
version: "2.0.0"
version: "2.0.1"
cupertino_icons:
dependency: "direct main"
description:
Expand Down Expand Up @@ -147,10 +147,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
path:
dependency: transitive
description:
Expand Down Expand Up @@ -320,10 +320,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
vector_math:
dependency: transitive
description:
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Demonstrates how to use the cunning_document_scanner plugin.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

environment:
sdk: ">=2.15.1 <4.0.0"
sdk: ">=2.17.0 <4.0.0"

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
Expand Down
3 changes: 2 additions & 1 deletion example/test/widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:cunning_document_scanner_example/main.dart';
import '../lib/main.dart';


void main() {
testWidgets('View is created', (WidgetTester tester) async {
Expand Down
Loading