Building a Flutter plugin from scratch
By Charlin Joe · 12 January 2026
Flutter plugins bridge Dart code to native platform APIs — camera, Bluetooth, sensors, push notifications. Writing your own plugin is the right move when an existing package doesn't exist or doesn't meet your needs. Here is how to do it properly.
Plugin vs package
A package is pure Dart — no native code. A plugin contains platform-specific code (Kotlin/Java for Android, Swift/Obj-C for iOS) alongside a Dart API. Use a plugin when you need to access APIs that Dart can't reach.
Creating the scaffold
flutter create --template=plugin \
--platforms=android,ios \
--org=com.example \
my_sensor_plugin
cd my_sensor_plugin
This creates:
my_sensor_plugin/
├── lib/my_sensor_plugin.dart # Public Dart API
├── android/src/main/kotlin/.../ # Android implementation
├── ios/Classes/ # iOS implementation
├── example/ # Test app
└── pubspec.yaml
The method channel
Method channels are the standard bridge for one-off calls:
// lib/my_sensor_plugin.dart
import 'package:flutter/services.dart';
class MySensorPlugin {
static const _channel = MethodChannel('com.example/my_sensor_plugin');
static Future<double> getBatteryLevel() async {
try {
final level = await _channel.invokeMethod<int>('getBatteryLevel');
return level!.toDouble();
} on PlatformException catch (e) {
throw PlatformException(
code: e.code,
message: 'Failed to get battery level: ${e.message}',
);
}
}
}
Android implementation (Kotlin)
// android/src/main/kotlin/.../MySensorPlugin.kt
package com.example.my_sensor_plugin
import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MySensorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "com.example/my_sensor_plugin")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getBatteryLevel" -> {
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
if (level != -1) result.success(level)
else result.error("UNAVAILABLE", "Battery level not available", null)
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
iOS implementation (Swift)
// ios/Classes/MySensorPlugin.swift
import Flutter
import UIKit
public class MySensorPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.example/my_sensor_plugin",
binaryMessenger: registrar.messenger()
)
let instance = MySensorPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getBatteryLevel":
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
if level < 0 {
result(FlutterError(code: "UNAVAILABLE", message: "Battery not available", details: nil))
} else {
result(Int(level * 100))
}
default:
result(FlutterMethodNotImplemented)
}
}
}
Event channels for streams
For continuous data (sensor readings, Bluetooth events), use an EventChannel:
// Dart
class MySensorPlugin {
static const _eventChannel = EventChannel('com.example/my_sensor_plugin/events');
static Stream<double> get accelerometerStream {
return _eventChannel
.receiveBroadcastStream()
.map((event) => (event as num).toDouble());
}
}
// Kotlin
class MySensorPlugin : FlutterPlugin, EventChannel.StreamHandler {
private var eventSink: EventChannel.EventSink? = null
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
startSensorUpdates()
}
override fun onCancel(arguments: Any?) {
eventSink = null
stopSensorUpdates()
}
private fun onSensorValue(value: Double) {
eventSink?.success(value)
}
}
Pigeon: type-safe channels
For complex data types, use pigeon to generate type-safe channel code:
// pigeons/messages.dart
import 'package:pigeon/pigeon.dart';
class BatteryInfo {
int? level;
String? state; // 'charging', 'discharging', 'full'
}
@HostApi()
abstract class BatteryApi {
BatteryInfo getBatteryInfo();
}
flutter pub run pigeon \
--input pigeons/messages.dart \
--dart_out lib/src/messages.g.dart \
--kotlin_out android/.../Messages.g.kt \
--swift_out ios/Classes/Messages.g.swift
Pigeon generates matching code on all platforms — no manual string-based method names.
pubspec.yaml for plugins
flutter:
plugin:
platforms:
android:
package: com.example.my_sensor_plugin
pluginClass: MySensorPlugin
ios:
pluginClass: MySensorPlugin
Testing
Mock the method channel in unit tests:
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.example/my_sensor_plugin'),
(MethodCall call) async {
if (call.method == 'getBatteryLevel') return 85;
return null;
},
);
test('getBatteryLevel returns mocked value', () async {
expect(await MySensorPlugin.getBatteryLevel(), 85.0);
});
Common pitfalls
Calling native code from a background isolate. Method channels only work on the main isolate. To call native code from a background isolate, use IsolateNameServer and pass the result back.
Not handling onDetachedFromEngine. Always clean up — remove method call handlers, stop sensor listeners, release resources. Otherwise you get memory leaks when the engine restarts.
Hardcoding the channel name. The channel name must match exactly between Dart and native. Define it as a constant in one place and share it (or use Pigeon which eliminates this risk).
Sign in to like, dislike, or report.