← Articles

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.

Building a Flutter plugin from scratch — ANN Tech