Building Production Flutter Plugins: A 156-Likes pub.dev Case Study
Publishing a Flutter plugin is a weekend. Maintaining one with 156 likes and 470 monthly downloads is a different game. The honest engineering story behind document_scanner_flutter, what worked, and what I'd build differently in 2026.
Snapshot at publication, April 2026. Live numbers are on pub.dev.
The native code is rarely the hard part of a plugin. The hard parts are: lifecycle correctness across both iOS and Android, permission flows, async cancellation, supporting users who file half-formed issues, and shipping breaking changes without breaking the world. A successful plugin costs 4 to 8 engineer-weeks before launch and a few hours per month forever after.
Flutter plugin example: build one step by step
Before the production story, here is the smallest complete plugin: the same shape every plugin on pub.dev starts from, including document_scanner_flutter. It exposes one Dart method that asks the platform for its OS version, answered by Kotlin on Android and Swift on iOS. Swap that method for your real native API and the structure stays the same.
Step 1: scaffold the plugin
flutter create --org com.example --template=plugin --platforms=android,ios os_info
cd os_info
This generates the Dart package in lib/, the native projects in android/ and ios/, unit tests in test/, and an app in example/ that already depends on your plugin. The --org value becomes the Android package name and the iOS bundle prefix, so use a domain you own.
Step 2: declare the platforms in pubspec.yaml
The template writes this block for you. It is how Flutter knows which native class to register on each platform:
flutter:
plugin:
platforms:
android:
package: com.example.os_info
pluginClass: OsInfoPlugin
ios:
pluginClass: OsInfoPlugin
Step 3: the Dart API and the MethodChannel
The template splits the Dart side into three files: the public API apps import, a platform interface, and a default implementation that talks over a MethodChannel. The channel implementation is the only place Dart calls native code:
// lib/os_info_method_channel.dart
class MethodChannelOsInfo extends OsInfoPlatform {
@visibleForTesting
final methodChannel = const MethodChannel('os_info');
@override
Future<String?> getPlatformVersion() {
return methodChannel.invokeMethod<String>('getPlatformVersion');
}
}
// lib/os_info.dart: the API apps use
class OsInfo {
Future<String?> getPlatformVersion() {
return OsInfoPlatform.instance.getPlatformVersion();
}
}
Step 4: answer the call in Kotlin (Android)
// android/src/main/kotlin/com/example/os_info/OsInfoPlugin.kt
class OsInfoPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "os_info")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getPlatformVersion" -> result.success("Android ${android.os.Build.VERSION.RELEASE}")
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
Step 5: answer the call in Swift (iOS)
// ios/.../OsInfoPlugin.swift
public class OsInfoPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "os_info", binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(OsInfoPlugin(), channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result("iOS " + UIDevice.current.systemVersion)
default:
result(FlutterMethodNotImplemented)
}
}
}
Step 6: run it, test it, publish it
cd example && flutter run # the example app calls your plugin on a device
cd .. && flutter test # Dart tests run against a mock platform
dart pub publish --dry-run # pub.dev validation, nothing is uploaded
dart pub publish
That is the whole contract: one channel name and one method name that Dart, Kotlin and Swift agree on. If either name drifts on one side, the call fails at runtime with a MissingPluginException, which is why bigger plugins generate this glue with Pigeon instead of hand-writing it (more on that below). Everything after this point is what changes once real users depend on the plugin: lifecycle, permissions, federation, pub points and support.
The plugin in one paragraph
document_scanner_flutter is a cross-platform Flutter plugin that lets an app capture a photo of a document, auto-detect the paper edges, perform perspective correction, and return the cropped, rectified image. It uses Apple's Vision framework on iOS and Google ML Kit's document scanner on Android. Apps using it include KYC flows, expense reporting tools, and field-data collection apps. It's been in production since 2021, currently sits at version 0.4.0 with 156 likes on pub.dev, and has been a steady source of issues, PRs, and lessons.
The architecture, layer by layer
A Flutter plugin lives at the edge between the Dart VM and the native platform. There are four layers, and getting any one of them wrong shows up as a 1-star review.
1. The Dart-facing API
This is what your users see. It must read like a normal Dart package: simple async functions, well-documented parameters, sensible defaults, type-safe results. For document_scanner_flutter, the entry point is one method:
final scannedDocs = await DocumentScannerFlutter.launch(
context,
source: ScannerFileSource.CAMERA,
labelsConfig: { ... },
);
One method, three optional parameters, returns Future<File?>. Users that want a simple flow get a simple flow. Users with custom needs can pass labelsConfig.
2. The platform channel
Dart talks to native code through a MethodChannel. Calls are async, named ("scanDocument"), and arguments are JSON-encodable. The channel is the contract: Dart and native must agree on the method name and the argument shape, or you get an opaque MissingPluginException.
Lesson learned the hard way: name your channel under your domain. biz.cv.documentScanner/document_scanner is fine; document_scanner alone collides with any other plugin that picks the same name. We renamed once after a user reported a clash, and it was a breaking change.
3. The native bridge
On iOS, the bridge is a Swift class that registers as a FlutterPlugin and implements handle(_ call: FlutterMethodCall, result: @escaping FlutterResult). On Android, it's a Kotlin class with onMethodCall. Both must:
- Validate arguments and return a structured error on bad input (
FlutterErrororresult.error). - Hold no implicit state across calls; use the call ID/result pattern for any async work.
- Handle the case where the user cancels mid-flow (most plugins forget this).
- Tear down listeners on detach (otherwise you leak when hot-reload happens during dev).
4. The native UI / system call
For document_scanner_flutter, this is where the actual work happens: launch the system camera, run Vision/ML Kit document detection, run perspective correction. This layer is platform-specific and tends to be where 80% of the user-reported bugs hide. iOS users on iPad split-view: edge case. Android users with no camera permission and TYPE_CAMERA: edge case. Foldable Galaxy with camera-rotation: edge case.
Lifecycle: the silent killer
Flutter plugin lifecycle is the most frequently misunderstood topic. The plugin is attached to a Flutter engine, but the engine can be detached and re-attached when the user backgrounds and foregrounds the app. If your plugin holds an Activity reference (Android) or a UIViewController reference (iOS) past detach, you crash the next time the engine reattaches.
The fix on Android: implement ActivityAware and store the Activity reference only between onAttachedToActivity and onDetachedFromActivity. The fix on iOS: never hold the Flutter engine's UIViewController; resolve it lazily from UIApplication.shared.keyWindow?.rootViewController.
Federated plugins: when to bother
The "federated plugin" pattern splits the package into:
document_scanner_flutter: the app-facing API package.document_scanner_flutter_platform_interface: the abstract contract.document_scanner_flutter_ios: the iOS implementation.document_scanner_flutter_android: the Android implementation.
This pattern lets multiple maintainers work on different platforms independently and lets users pick implementations (e.g., a third party could publish document_scanner_flutter_web without forking the API package).
Should you federate from day one? No. Start as a single package with iOS + Android folders. Federate only when you have either a credible second platform (web, desktop) or a co-maintainer for one of the platforms. Federating prematurely is overhead for one engineer.
The pub.dev scoring mechanic
Pub points are deterministic. They reward:
- Sound null safety (10 points)
- Valid pubspec with all required fields (10 points)
- Platform support declarations (20 points)
- Comprehensive README with example (10 points)
- Documentation on public API (20 points)
- Up-to-date dependencies (10 points)
- Static analysis clean (50 points)
document_scanner_flutter sits at 130 points. Hitting 160 requires more inline doc comments on edge methods. Hitting 100% requires zero analyzer warnings and a tight transitive dependency tree.
Likes and downloads are social signals on top, not part of the score. A well-scored plugin with zero downloads beats a popular plugin with bad scores in pub.dev's search ranking.
Verified publisher: a trust multiplier
The shield icon next to my publisher name on pub.dev means the package is published under a verified domain (ishaqhassan.com). Setting it up requires DNS TXT verification, takes 10 minutes, and meaningfully shifts pub.dev's trust signal. If you publish more than one plugin, get verified.
The support burden nobody mentions
The thing they don't tell you about publishing a plugin: if it's any good, you'll get issues forever. Most issues will be:
- "It doesn't work" with no reproduction steps
- "Crashes on Android 14" without a stack trace
- "How do I do X?" where X is in the README
You can either burn out or build templates. I have a GitHub issue template that asks for the Flutter doctor output, the platform, the OS version, and a code snippet. Issues without these get a polite "please update with more info" and auto-close after 2 weeks of no response. This single change took me from 30 minutes per issue to 5.
What I'd build differently in 2026
- Use Pigeon from day one. Pigeon is Flutter's official tool for type-safe platform channels. Hand-rolled MethodChannel is fine until your API surface grows, then it becomes a maintenance trap.
- Federated from second platform. Once Android worked, I should have federated before adding iOS, not after.
- Native UI in dev mode only. Most Flutter plugins fight to render their own UI in Flutter. For complex flows like document scanning, the system UI is faster, accessible, and free. I shipped Flutter UI on top of native first; I'd ship native-only now.
- Snapshot tests for the native bridge. Capture the JSON payload from each method call and snapshot it. Catches contract drift early.
The full roster
document_scanner_flutter is the most-liked package in my open source list, but it's not the only one. flutter_alarm_background_trigger is a scheduled-alarm plugin for Android. assets_indexer is a build-time Dart code generator that produces strongly-typed asset references. nadra_verisys_flutter wraps Pakistan's NADRA Verisys CNIC verification SDK.
Each of those packages has a similar shape: a small Dart API, a focused native or build-time bridge, comprehensive README, and ongoing user support. Each took roughly 6 to 10 weeks to ship to a production-quality state.
Where to go from here
If you're building your first Flutter plugin, start by reading Flutter's official plugin guide. Then read the source of two or three popular plugins on pub.dev to see what production-quality looks like. Then write yours, ship at 0.1.0, and iterate based on real user issues.
The three-tree architecture deep dive on this site explains why Flutter's rendering pipeline imposes the constraints plugins must respect. The framework contributions page lists the merged PRs into Flutter itself, which is the next step up from publishing a plugin: contributing to the framework that hosts every plugin.
Flutter plugin FAQ
How do I create a Flutter plugin?
Run flutter create --org com.example --template=plugin --platforms=android,ios my_plugin. That scaffolds the Dart API, a MethodChannel implementation, a Kotlin class for Android, a Swift class for iOS and an example app. Implement each native method, keep the channel and method names identical on both sides, test with flutter test and the example app, then publish with dart pub publish.
What is the difference between a Flutter package and a Flutter plugin?
A package is pure Dart and runs anywhere Dart runs. A plugin is a package that also ships platform code (Kotlin or Java, Swift or Objective-C, C++, JavaScript) and registers it with Flutter, so Dart can call native APIs such as the camera, sensors or the system document scanner.
How do I publish a Flutter plugin to pub.dev?
Run flutter create --template=plugin to scaffold a plugin with platform-specific iOS and Android folders. Implement your platform code in Swift/Kotlin, expose it through a MethodChannel, and publish with dart pub publish. Set up a verified publisher (under your owned domain) before publishing for trust signal.
What is a federated Flutter plugin?
A federated plugin splits the API surface (the app-facing package) from the platform implementations (iOS, Android, web, desktop). Apps depend on the API package, and that package picks the right implementation per platform. This pattern lets multiple maintainers work on different platforms independently.
How long does it take to write a production-grade Flutter plugin?
An MVP plugin is a weekend. A production plugin with real native edge cases, comprehensive tests, and CI for both platforms takes 4 to 8 weeks. The native code is rarely the hard part. The hard part is handling permission flows, platform-specific edge cases, and async lifecycle correctly.
What makes a Flutter plugin score well on pub.dev?
Pub points reward complete documentation, valid pubspec, OS support declarations, sound null safety, dependency freshness, and a comprehensive README with examples. Likes and downloads are social signals on top. A well-scored plugin should hit 130 to 160 pub points within a few releases.
Should I write a custom plugin or use an existing one?
Always check pub.dev first with multiple keyword variations. Most common needs are covered. Write a custom plugin only when no existing package supports your platform, when existing packages are abandoned, or when you need a specific native API surface no plugin exposes. Custom plugins create long-term maintenance burden.
Need a custom Flutter plugin?
Custom Flutter plugins, native bridge work, and SDK wrapping are common Flutter consulting engagements. Get in touch if you have a native API that needs a Flutter front end.
Before choosing an engineer, use the Flutter specialist evaluation guide to compare native integration work, framework contributions and a practical test and handover plan.
Contact