Flutter's promise of building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase is a compelling proposition for developers worldwide. Its declarative UI framework, powered by the Dart language, allows for rapid development and expressive interfaces. However, no application exists in a vacuum. Real-world apps often need to tap into the underlying power of the native platform, whether it's for accessing a specific hardware sensor, leveraging a platform-specific API, integrating with an existing native SDK, or performing computationally intensive tasks best handled by native code.
This is where Flutter's true genius shines through: its elegant and robust interoperability layer. Flutter does not attempt to be a one-size-fits-all solution that abstracts away the entire platform. Instead, it provides a clear, well-defined mechanism for communication between your Dart code and the native code of the host platform (Android or iOS). This mechanism is known as Platform Channels.
Platform channels are the essential bridge that connects your Flutter world with the native world. They allow you to send messages, invoke methods, and stream data back and forth, effectively giving your Flutter app superpowers by unlocking the full potential of the underlying device. The two primary types of platform channels, MethodChannel and EventChannel, serve distinct but complementary purposes. Understanding how to wield them effectively is not just an advanced topic; it is a fundamental skill for any serious Flutter developer looking to build complex, feature-rich applications. This guide will explore these channels in exhaustive detail, moving from foundational concepts to advanced implementation patterns and best practices.
The Architectural Foundation: Why We Need Platform Channels
To fully appreciate platform channels, it's crucial to understand Flutter's architecture. A Flutter application is hosted within a standard native application shell. On Android, this is an `Activity`; on iOS, it's a `UIViewController`. Your Dart code, which defines the UI and application logic, runs in a separate Dart VM (or is compiled to native ARM/x86 code in release mode). The Flutter engine, written in C++, is responsible for rendering your UI to a canvas and handling events.
This separation is powerful for performance and portability, but it creates a natural boundary. Your Dart code doesn't have direct access to, for example, the Android `BatteryManager` or the iOS `CoreMotion` framework. It lives in its own world. Platform channels are the sanctioned, message-passing mechanism that crosses this boundary. Messages are serialized on the Dart side, sent over the channel to the host platform, deserialized, and then processed by your native code. The response (if any) follows the reverse path.
This asynchronous message-passing approach ensures that the Flutter UI thread is never blocked by potentially long-running native operations, preserving the smooth, jank-free experience that Flutter is known for.
MethodChannel: The Request-Response Workhorse
The MethodChannel is the most common type of platform channel. It's designed for a simple, yet powerful, communication pattern: a single, asynchronous method call from Dart to the native platform, which may optionally return a single result. Think of it as a remote procedure call (RPC) mechanism between your Flutter UI and native services.
Use Cases for MethodChannel:
- Fetching a single piece of data from the platform, such as the current battery level, device name, or available storage space.
- Triggering a one-time native action, like showing a native toast message, triggering haptic feedback, or initiating a file download using a native library.
- Passing configuration data to a native component before it starts a long-running task.
- Querying the state of a native service (e.g., "Is Bluetooth enabled?").
The Core Components of a MethodChannel
Implementing a MethodChannel involves three key parts:
- A
MethodChannelinstance in your Flutter (Dart) code, used to send method invocations. - A
MethodChannelinstance on the native side (Android/iOS) with the exact same name, which receives the invocations. - A
MethodCallHandleron the native side that listens for incoming method calls, executes the appropriate native code, and sends a response back.
The channel name is a unique string that links the Dart and native sides. The convention is to use a reverse domain name style to avoid collisions, such as com.yourcompany.yourapp/channel_name.
Implementation in Detail: A Practical Example
Let's build a practical example: retrieving the device's unique platform version string (e.g., "Android 13" or "iOS 16.5").
Step 1: The Dart Side (Flutter)
In your Flutter widget or service, you'll first create an instance of MethodChannel. Then, you'll create a function to call the native method using invokeMethod. This method returns a Future, making the call asynchronous.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class DeviceInfoWidget extends StatefulWidget {
const DeviceInfoWidget({Key? key}) : super(key: key);
@override
State<DeviceInfoWidget> createState() => _DeviceInfoWidgetState();
}
class _DeviceInfoWidgetState extends State<DeviceInfoWidget> {
// 1. Define the MethodChannel with a unique name.
static const platform = MethodChannel('com.example.myapp/device_info');
String _platformVersion = 'Unknown platform version.';
@override
void initState() {
super.initState();
_getPlatformVersion();
}
// 2. Create an async function to call the native method.
Future<void> _getPlatformVersion() async {
String version;
try {
// 3. Use invokeMethod with the method name string.
// This string must match the one handled on the native side.
final String result = await platform.invokeMethod('getPlatformVersion');
version = 'Platform Version: $result';
} on PlatformException catch (e) {
// 4. Handle potential errors from the native side.
version = "Failed to get platform version: '${e.message}'.";
}
// 5. Update the UI with the result.
if (mounted) {
setState(() {
_platformVersion = version;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Platform Channel Demo'),
),
body: Center(
child: Text(_platformVersion),
),
);
}
}
Step 2: The Native Side (Android - Kotlin)
Now, we need to implement the receiving end in the native Android project. The ideal place to register the channel handler is in the configureFlutterEngine method of your MainActivity.kt file.
Navigate to android/app/src/main/kotlin/com/your-domain/your-app/MainActivity.kt.
package com.example.myapp
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
// 1. Define the channel name. It must be identical to the one in Dart.
private val CHANNEL = "com.example.myapp/device_info"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 2. Create a MethodChannel instance.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
call, result ->
// 3. Check the method name received from Dart.
if (call.method == "getPlatformVersion") {
// 4. Execute native code.
val version = "Android ${android.os.Build.VERSION.RELEASE}"
// 5. Send the result back to Dart.
result.success(version)
} else {
// 6. Handle unknown method calls.
result.notImplemented()
}
}
}
}
Breakdown of the Kotlin Code:
flutterEngine.dartExecutor.binaryMessenger: This is the key component that allows communication with the Dart isolate.setMethodCallHandler: This sets up a listener for incoming calls on this channel. The lambda receives two arguments:call(aMethodCallobject containing the method name and arguments) andresult(aResultobject used to send the response).result.success(data): Sends a successful response back to the DartFuture.result.error(code, message, details): Sends an error response, which triggers thePlatformExceptioncatch block in Dart.result.notImplemented(): A convenient way to indicate that the requested method is not implemented on this platform.
Step 3: The Native Side (iOS - Swift)
The process is conceptually identical on iOS. You'll register the handler in your AppDelegate.swift file.
Navigate to ios/Runner/AppDelegate.swift.
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// 1. Get the FlutterViewController, which acts as the bridge.
guard let controller = window?.rootViewController as? FlutterViewController else {
fatalError("rootViewController is not type FlutterViewController")
}
// 2. Define the channel name, identical to Dart and Android.
let deviceInfoChannel = FlutterMethodChannel(name: "com.example.myapp/device_info",
binaryMessenger: controller.binaryMessenger)
// 3. Set up the method call handler.
deviceInfoChannel.setMethodCallHandler({
(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
// 4. Check the method name.
guard call.method == "getPlatformVersion" else {
result(FlutterMethodNotImplemented)
return
}
// 5. Execute native code and send the result.
result("iOS " + UIDevice.current.systemVersion)
})
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Breakdown of the Swift Code:
controller.binaryMessenger: Similar to Android, this is the communication endpoint.setMethodCallHandler: The closure receives aFlutterMethodCallobject and aFlutterResultcallback.resultis an escaping closure (@escaping FlutterResult). You must call it exactly once to complete the communication. Calling it multiple times will cause a crash, and not calling it will leave the DartFuturehanging indefinitely.FlutterMethodNotImplementedis a predefined object to send back when the method isn't found.
Passing Arguments and Data Types
Method channels are not limited to parameter-less calls. You can pass arguments from Dart to native. The arguments can be primitive types (null, bool, int, double, String), or complex structures like Lists (which become List/NSArray) and Maps (which become HashMap/NSDictionary).
This serialization and deserialization is handled automatically by the StandardMethodCodec, which is used by default. It's highly efficient and covers most common use cases.
Example: A method to show a native toast message with custom text and duration.
Dart Code:
// In your service/widget class
static const platform = MethodChannel('com.example.myapp/ui_utils');
Future<void> showNativeToast(String message, bool isLong) async {
try {
// Pass arguments as a Map
await platform.invokeMethod('showToast', {
'message': message,
'isLong': isLong,
});
} on PlatformException catch (e) {
print("Failed to show toast: ${e.message}");
}
}
Android (Kotlin) Handler:
// Inside setMethodCallHandler
if (call.method == "showToast") {
// Safely extract arguments
val message = call.argument<String>("message")
val isLong = call.argument<Boolean>("isLong")
if (message == null || isLong == null) {
result.error("INVALID_ARGS", "Missing 'message' or 'isLong' argument", null)
return@setMethodCallHandler
}
val duration = if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
Toast.makeText(context, message, duration).show()
result.success(null) // Acknowledge the call was successful
}
iOS (Swift) Handler:
iOS doesn't have a direct equivalent of Android's Toast. We can simulate it with a temporary alert or a custom view. For simplicity, we'll just log it and acknowledge the call.
// Inside setMethodCallHandler
if call.method == "showToast" {
// Safely extract arguments
guard let args = call.arguments as? [String: Any],
let message = args["message"] as? String,
let isLong = args["isLong"] as? Bool else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing arguments", details: nil))
return
}
print("Toast from Flutter: '\(message)', isLong: \(isLong)")
// Here you would implement your custom toast view logic.
result(nil) // Acknowledge success
}
EventChannel: Streaming Data to Flutter
While MethodChannel is perfect for request-response interactions, it's inefficient and clumsy for handling continuous streams of data. Polling a method repeatedly from Dart is bad practice. This is the problem that EventChannel solves. It provides a way for native code to send a continuous stream of events to Flutter without Dart having to ask for it each time.
Use Cases for EventChannel:
- Streaming sensor data (e.g., accelerometer, gyroscope, GPS location updates).
- Listening for broadcast events from the native platform (e.g., network connectivity changes, battery state changes).
- Reporting progress on a long-running native task (e.g., a large file download or data processing).
- Subscribing to data from a native library that uses a callback or delegate pattern (e.g., Bluetooth LE notifications).
The Core Components of an EventChannel
The structure is similar to MethodChannel but adapted for a streaming model:
- An
EventChannelinstance in your Flutter (Dart) code, from which you get aStreamto listen to. - An
EventChannelinstance on the native side with the exact same name. - A
StreamHandleron the native side. This handler has two crucial methods:onListen: Called when Flutter starts listening to the stream. This is where you set up your native listener (e.g., register a broadcast receiver, start sensor updates).onCancel: Called when Flutter cancels its subscription to the stream. This is where you must clean up your native resources to prevent memory leaks (e.g., unregister the receiver, stop sensor updates).
The lifecycle management provided by onListen and onCancel is the most important aspect of using EventChannel correctly.
Implementation in Detail: A Battery State Stream
Let's build an app that listens for changes in the device's charging state (charging, discharging, full).
Step 1: The Dart Side (Flutter)
In Flutter, we'll create the EventChannel and use its receiveBroadcastStream() method to get a Stream. We can then listen to this stream using a StreamBuilder for a clean, reactive UI.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class BatteryStateWidget extends StatefulWidget {
const BatteryStateWidget({Key? key}) : super(key: key);
@override
State<BatteryStateWidget> createState() => _BatteryStateWidgetState();
}
class _BatteryStateWidgetState extends State<BatteryStateWidget> {
// 1. Define the EventChannel.
static const EventChannel _eventChannel = EventChannel('com.example.myapp/charging_status');
// We can also define a stream variable to hold the stream.
late Stream<String> _chargingStatusStream;
@override
void initState() {
super.initState();
// 2. Get the broadcast stream from the channel.
_chargingStatusStream = _eventChannel.receiveBroadcastStream().map((event) => event.toString());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Battery State Stream'),
),
body: Center(
// 3. Use a StreamBuilder to reactively build the UI.
child: StreamBuilder<String>(
stream: _chargingStatusStream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Text('Waiting for battery state...');
}
return Text(
'Battery Status: ${snapshot.data}',
style: Theme.of(context).textTheme.headlineSmall,
);
},
),
),
);
}
}
Step 2: The Native Side (Android - Kotlin)
On Android, we'll use a BroadcastReceiver to listen for battery state changes. The StreamHandler will manage the registration and unregistration of this receiver.
In MainActivity.kt:
package com.example.myapp
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
class MainActivity: FlutterActivity() {
private val CHARGING_CHANNEL = "com.example.myapp/charging_status"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CHARGING_CHANNEL).setStreamHandler(
ChargingStreamHandler(context)
)
}
}
// We create a separate class for the StreamHandler for better organization.
class ChargingStreamHandler(private val context: Context) : EventChannel.StreamHandler {
private var receiver: BroadcastReceiver? = null
// onListen is called when the first listener is registered.
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
if (events == null) return
// Create a broadcast receiver to listen to battery state changes.
receiver = createChargingStateReceiver(events)
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}
// onCancel is called when the last listener is removed.
override fun onCancel(arguments: Any?) {
// Clean up: unregister the receiver.
if (receiver != null) {
context.unregisterReceiver(receiver)
receiver = null
}
}
private fun createChargingStateReceiver(events: EventChannel.EventSink): BroadcastReceiver {
return object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
// Use the event sink to send data to Flutter.
when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> events.success("Charging")
BatteryManager.BATTERY_STATUS_FULL -> events.success("Full")
BatteryManager.BATTERY_STATUS_DISCHARGING -> events.success("Discharging")
else -> events.error("UNAVAILABLE", "Charging status unavailable", null)
}
}
}
}
}
Key Points (Android):
- The
EventChannel.EventSinkis our gateway to Flutter. We callevents.success(data)to send a data event andevents.error(...)to send an error. - Resource management is critical. We register the receiver in
onListenand must unregister it inonCancelto prevent a memory leak where the native platform keeps running a receiver for a UI that no longer exists.
Step 3: The Native Side (iOS - Swift)
On iOS, we can listen for battery state changes using the UIDevice.BatteryState enum and notifications.
In AppDelegate.swift:
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
guard let controller = window?.rootViewController as? FlutterViewController else {
fatalError("rootViewController is not type FlutterViewController")
}
let chargingChannel = FlutterEventChannel(name: "com.example.myapp/charging_status",
binaryMessenger: controller.binaryMessenger)
// For better organization, we use a separate handler class.
chargingChannel.setStreamHandler(ChargingStreamHandler())
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
// Conforming to FlutterStreamHandler requires implementing onListen and onCancel.
class ChargingStreamHandler: NSObject, FlutterStreamHandler {
private var eventSink: FlutterEventSink?
// onListen: Set up the native event source.
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
UIDevice.current.isBatteryMonitoringEnabled = true
// Add an observer for battery state changes.
NotificationCenter.default.addObserver(
self,
selector: #selector(onBatteryStateDidChange),
name: UIDevice.batteryStateDidChangeNotification,
object: nil)
// Send the initial state immediately.
sendBatteryState()
return nil // No error
}
// onCancel: Clean up resources.
func onCancel(withArguments arguments: Any?) -> FlutterError? {
NotificationCenter.default.removeObserver(self)
UIDevice.current.isBatteryMonitoringEnabled = false
eventSink = nil
return nil // No error
}
@objc private func onBatteryStateDidChange(notification: NSNotification) {
sendBatteryState()
}
private func sendBatteryState() {
guard let sink = eventSink else { return }
switch UIDevice.current.batteryState {
case .charging:
sink("Charging")
case .full:
sink("Full")
case .unplugged:
sink("Discharging")
case .unknown:
sink(FlutterError(code: "UNAVAILABLE", message: "Charging status unavailable", details: nil))
@unknown default:
sink(FlutterError(code: "UNAVAILABLE", message: "Unknown charging status", details: nil))
}
}
}
Key Points (iOS):
- The
FlutterStreamHandlerprotocol defines theonListenandonCancelmethods. - We store the
FlutterEventSinkin a property to be able to send events from other methods (like our notification handler). - It's crucial to set
isBatteryMonitoringEnabledto true before we can get accurate state updates. - Just like on Android, we register for notifications in
onListenand remove the observer inonCancel. This is non-negotiable for a memory-safe implementation.
Combining MethodChannel and EventChannel: A Powerful Pattern
The true power of platform channels is often realized when you use them together. A common and highly effective pattern is to use a MethodChannel for control and configuration, and an EventChannel for the resulting data stream.
Imagine a GPS location tracking feature. You don't just want a stream of locations; you want to be able to start the stream, stop it, and perhaps query if the service is already running. This is a perfect use case for our combined pattern.
- MethodChannel ('com.example.myapp/location_service'):
start(): Starts the location updates.stop(): Stops the location updates.isServiceRunning(): Returns a boolean indicating the current state.
- EventChannel ('com.example.myapp/location_stream'):
- Streams location data (e.g., a Map with latitude and longitude) whenever the service is running and a new location is available.
In this architecture, the native start() method would initiate the location manager and also set up the listener that feeds data into the EventChannel's sink. The stop() method would tear down the location manager and also signal the onCancel logic of the StreamHandler. This creates a clean separation of concerns: the MethodChannel handles commands, and the EventChannel handles data.
Advanced Topics and Best Practices
1. Threading Model: The Golden Rule
Platform channels are not thread-safe by default. On both Android and iOS, platform channel calls from Dart are executed on the main UI thread. This is convenient for tasks that need to interact with the UI, but it's a major pitfall for long-running or blocking operations.
The Golden Rule: Never perform long-running or blocking operations on the platform's main thread in response to a channel call.
Doing so will freeze the entire native UI, and potentially the Flutter UI as well, leading to an "Application Not Responding" (ANR) error on Android or a frozen app on iOS.
Solution: Dispatch the work to a background thread/queue and return the result to the main thread to send it back to Flutter.
Android (Kotlin with Coroutines):
// In your MethodCallHandler
if (call.method == "processHeavyData") {
// Launch a coroutine on a background dispatcher
CoroutineScope(Dispatchers.IO).launch {
val data = heavyBackgroundWork() // This runs in the background
// Switch back to the main thread to send the result
withContext(Dispatchers.Main) {
result.success(data)
}
}
}
iOS (Swift with Grand Central Dispatch - GCD):
// In your MethodCallHandler
if call.method == "processHeavyData" {
// Dispatch to a background queue
DispatchQueue.global(qos: .userInitiated).async {
let data = self.heavyBackgroundWork() // Runs in the background
// Dispatch back to the main queue to send the result
DispatchQueue.main.async {
result(data)
}
}
}
2. Codecs: Controlling Data Serialization
The magic of passing data like Maps and Lists is handled by a MethodCodec. The default, StandardMethodCodec, is a highly optimized binary format that supports a wide range of types. However, there are others you can use if needed:
JSONMethodCodec: Serializes data to and from JSON. It's less efficient than the standard codec but can be useful if you need to interoperate with native code that already speaks JSON.StringCodec: For passing simple string messages.BinaryCodec: For passing raw, unstructured binary data (as aByteDataobject).
You can specify a codec when you create the channel:
// Dart side
const platform = MethodChannel('my_channel', JSONMethodCodec());
You must use the corresponding codec on the native side as well.
3. Structuring Your Code: Beyond MainActivity/AppDelegate
For any non-trivial application, putting all your channel handler logic directly into MainActivity or AppDelegate will quickly become unmanageable. The best practice is to structure your platform channel code as a proper Flutter Plugin.
Even if you're not planning to publish the plugin, organizing your code this way provides huge benefits:
- Encapsulation: The platform-specific code is isolated in its own module.
- Reusability: You can easily reuse the native functionality in other Flutter projects. - Clarity: Your main application code (
MainActivity/AppDelegate) remains clean and focused on application lifecycle management.
You can create a plugin with the command flutter create --template=plugin my_plugin. This will generate a project with a standard structure, including a Dart API file and example implementations for Android and iOS, where you can place your channel handlers.
4. Testing with Platform Channels
Testing code that depends on platform channels can seem tricky, but Flutter provides excellent mocking capabilities. You can use setMockMethodCallHandler on a MethodChannel to intercept calls within your Dart tests and provide fake responses without ever touching native code.
// In a test file (e.g., my_widget_test.dart)
testWidgets('MyWidget shows platform version', (WidgetTester tester) async {
const channel = MethodChannel('com.example.myapp/device_info');
// Set up the mock handler before the test runs
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
if (methodCall.method == 'getPlatformVersion') {
return 'MockOS 1.0'; // Return a fake response
}
return null;
});
await tester.pumpWidget(MaterialApp(home: DeviceInfoWidget()));
await tester.pumpAndSettle(); // Allow futures to complete
// Verify the UI shows the mocked data
expect(find.text('Platform Version: MockOS 1.0'), findsOneWidget);
// Clean up the mock handler after the test
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
Conclusion: The Indispensable Bridge
Platform channels are not an optional extra in the Flutter ecosystem; they are the fundamental mechanism that allows Flutter to be a truly no-compromise framework. They provide the escape hatch needed to access the vast world of native platform features, ensuring that you are never limited by the abstractions provided by the framework itself.
By mastering the distinct roles of MethodChannel for request-response actions and EventChannel for continuous data streams, you unlock the ability to build sophisticated, deeply integrated applications. Remember the core principles: maintain identical channel names, handle data and errors robustly, respect the platform's main thread, and, most importantly for EventChannel, meticulously manage the resource lifecycle with onListen and onCancel. By applying these techniques and patterns, you can confidently bridge the gap between Dart and native, building Flutter applications that are both beautiful and immensely powerful.
Post a Comment