Volume 6: Platform Integration
Volume 6: Device & Platform Integration
Learning Objectives
- Access native device hardware (Camera, GPS, Bluetooth).
- Understand the permissions architecture on iOS and Android.
- Communicate with native Swift/Kotlin code using Platform Channels.
Why Does This Exist?
Flutter draws pixels beautifully, but it cannot physically turn on the camera hardware. It cannot read the GPS chip. It cannot vibrate the phone's haptic motor. To do these things, Flutter must ask the host operating system (Android or iOS) to do it on its behalf.
The Problem Before the Solution
When Flutter was first created, there were no packages for things like the Camera. If you wanted a camera, you had to write Java code for Android and Objective-C code for iOS, and then figure out how to pass the video feed into the Flutter engine. It was incredibly painful.
Mental Model: The Translator
Think of your Flutter app as an English-speaking CEO, and the host device (iOS/Android) as a factory that only speaks Swift and Kotlin. The CEO wants to turn on the factory's security camera.
The CEO cannot directly pull the lever. Instead, the CEO picks up a telephone line called a MethodChannel. They speak a command into it: "turn_on_camera". On the other side, a Swift developer and a Kotlin developer are listening. They hear the command, they pull the native lever to turn on the camera, and they speak back into the phone: "success, here is the image data."
Internal Working: Platform Channels
When you use a popular package like image_picker or geolocator, you are rarely just downloading Dart code. You are downloading a package that contains three folders: Dart, Android (Kotlin), and iOS (Swift). The Dart code simply acts as a wrapper that sends messages over a binary messaging channel to the native code.
Syntax: Requesting Permissions
Before you ask the OS to turn on the camera, you must ask the user for permission. Both Apple and Google have strict privacy rules. If you do not configure your native files properly, the OS will crash your app immediately upon requesting hardware.
// 1. AndroidManifest.xml (Android)
<uses-permission android:name="android.permission.CAMERA"/>
// 2. Info.plist (iOS)
<key>NSCameraUsageDescription</key>
<string>We need the camera to scan barcodes.</string>import 'package:permission_handler/permission_handler.dart';
Future<void> requestCamera() async {
var status = await Permission.camera.status;
if (status.isDenied) {
// Ask the user
status = await Permission.camera.request();
}
if (status.isGranted) {
print('We can now open the camera!');
} else if (status.isPermanentlyDenied) {
// The user clicked "Never Ask Again". We must send them to the OS settings.
openAppSettings();
}
}Break It & Debug It
The Bug: You run your app on iOS. You tap the "Take Photo" button. The app instantly closes. No error is printed in the Flutter console.
The Reason: Apple mandates that any app requesting hardware must have a usage description in the Info.plist file explaining why the app needs it. If this string is missing, iOS terminates the app instantly to protect user privacy. Flutter doesn't even get a chance to catch the error because the host OS literally assassinated the process.
The Fix: Open XCode (or edit Info.plist) and add the NSCameraUsageDescription key. Always remember: Hardware features require native configuration!
🔧 Mini Project ⏱ 20 min
Goal: The Device Info Card
Use the device_info_plus package to read device information. Display a card showing: device model, OS version, whether it's a physical device or emulator, and screen size (from MediaQuery). Show platform-specific UI: on iOS show a Cupertino-style card, on Android show a Material card.
💡 See One Approach (Mini Project)
One valid solution — yours may differ.
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
void main() => runApp(const MaterialApp(home: DeviceInfoScreen()));
class DeviceInfoScreen extends StatefulWidget {
const DeviceInfoScreen({super.key});
@override
State createState() => _DeviceInfoScreenState();
}
class _DeviceInfoScreenState extends State {
Map _info = {};
@override
void initState() {
super.initState();
_loadInfo();
}
Future _loadInfo() async {
final plugin = DeviceInfoPlugin();
Map info = {};
if (Platform.isIOS) {
final d = await plugin.iosInfo;
info = {
'Model': d.model,
'OS': 'iOS ${d.systemVersion}',
'Device': d.isPhysicalDevice ? 'Physical' : 'Simulator',
'Name': d.name,
};
} else if (Platform.isAndroid) {
final d = await plugin.androidInfo;
info = {
'Model': '${d.manufacturer} ${d.model}',
'OS': 'Android ${d.version.release} (SDK ${d.version.sdkInt})',
'Device': d.isPhysicalDevice ? 'Physical' : 'Emulator',
'Brand': d.brand,
};
}
setState(() => _info = info);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final isIOS = Platform.isIOS;
Widget card = Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(isIOS ? '🍎 iOS Device' : '🤖 Android Device',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
..._info.entries.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
SizedBox(width: 80, child: Text(e.key, style: const TextStyle(color: Colors.grey))),
Text(e.value, style: const TextStyle(fontWeight: FontWeight.w500)),
]),
)),
const Divider(height: 24),
Text('Screen: ${size.width.toInt()} × ${size.height.toInt()}px', style: const TextStyle(color: Colors.grey)),
],
),
);
return Scaffold(
appBar: isIOS
? null
: AppBar(title: const Text('Device Info')),
body: isIOS
? CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(middle: Text('Device Info')),
child: SafeArea(child: Center(child: CupertinoFormSection.insetGrouped(children: [
CupertinoFormRow(prefix: const Text('Model'), child: Text(_info['Model'] ?? '...')),
CupertinoFormRow(prefix: const Text('OS'), child: Text(_info['OS'] ?? '...')),
CupertinoFormRow(prefix: const Text('Type'), child: Text(_info['Device'] ?? '...')),
]))),
)
: Center(child: Card(margin: const EdgeInsets.all(24), child: card)),
);
}
}
🏗 Bigger Project ⏱ 1.5 hrs
Goal: The Camera + Gallery Picker
Build a screen that lets the user take a photo with the camera OR pick one from the gallery (using image_picker package). Display the selected image. Below it, show image metadata: file size in KB, approximate dimensions. Add a "Clear" button. Implement proper permission handling: if camera permission is denied, show a dialog explaining why it's needed with a button to open Settings. Handle the case where the user is on a web browser (disable the camera button).
💡 See One Approach (Bigger Project)
One valid solution — yours may differ.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
void main() => runApp(const MaterialApp(home: ImagePickerScreen()));
class ImagePickerScreen extends StatefulWidget {
const ImagePickerScreen({super.key});
@override
State createState() => _ImagePickerScreenState();
}
class _ImagePickerScreenState extends State {
final _picker = ImagePicker();
XFile? _image;
int? _fileSizeKb;
Future _pick(ImageSource source) async {
try {
final picked = await _picker.pickImage(source: source, imageQuality: 85, maxWidth: 1080);
if (picked == null) return; // User cancelled
final bytes = await picked.readAsBytes();
setState(() {
_image = picked;
_fileSizeKb = bytes.lengthInBytes ~/ 1024;
});
} catch (e) {
if (!mounted) return;
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Permission Required'),
content: Text('Camera/gallery access is needed. Please enable it in Settings. Error: $e'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(onPressed: () { Navigator.pop(context); /* openAppSettings(); */ }, child: const Text('Settings')),
],
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Image Picker'), actions: [
if (_image != null) IconButton(icon: const Icon(Icons.close), onPressed: () => setState(() { _image = null; _fileSizeKb = null; })),
]),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
if (!Platform.isLinux && !Platform.isWindows)
ElevatedButton.icon(onPressed: () => _pick(ImageSource.camera), icon: const Icon(Icons.camera_alt), label: const Text('Camera')),
const SizedBox(width: 16),
OutlinedButton.icon(onPressed: () => _pick(ImageSource.gallery), icon: const Icon(Icons.photo_library), label: const Text('Gallery')),
]),
const SizedBox(height: 24),
Expanded(
child: _image == null
? const Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.add_photo_alternate, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text('No image selected', style: TextStyle(color: Colors.grey)),
]))
: Column(children: [
Expanded(child: ClipRRect(borderRadius: BorderRadius.circular(16),
child: Image.file(File(_image!.path), fit: BoxFit.contain))),
const SizedBox(height: 12),
Text('${_image!.name} • ${_fileSizeKb ?? '?'} KB',
style: const TextStyle(color: Colors.grey, fontSize: 13)),
]),
),
]),
),
);
}
}
🎯 Interview Questions
Answer these before revealing. These appear in real Flutter/Dart interviews.
🔍 Easy: What is a Platform Channel in Flutter and when do you need one?
A Platform Channel is Flutter's mechanism for calling native Android (Kotlin/Java) or iOS (Swift/Obj-C) code from Dart. You need one when a package doesn't exist for your native feature, or when you need to use a platform API not exposed by any package. Flutter provides MethodChannel (one-off calls), EventChannel (streams of events), and BasicMessageChannel (arbitrary message passing). Most apps never write raw platform channels — established packages (camera, gps, bluetooth) already wrap the native code.
🔍 Medium: Explain the pubspec.yaml configuration required to add a native plugin, and what happens during flutter pub get.
In pubspec.yaml under dependencies, you add package_name: ^version. Running flutter pub get downloads the Dart package AND generates the native binding code. For Android plugins, it modifies settings.gradle and build.gradle. For iOS plugins, it runs pod install, fetching the native CocoaPod. The plugin's pubspec.yaml declares its native implementations under flutter.plugin.platforms. If a plugin has no Android implementation listed, it compiles out of the Android build entirely. This is the federated plugin architecture.
🔍 Hard: Your Flutter app uses a plugin that requires Bluetooth permission on both iOS and Android. Walk me through all the configuration changes needed before the plugin will work.
Android: Add to AndroidManifest.xml: <uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>, <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>. For Android 12+, also add BLUETOOTH_ADVERTISE. In Dart, use permission_handler package to request at runtime before first use — Android requires runtime permission for dangerous permissions since API 23. iOS: Add to Info.plist: NSBluetoothAlwaysUsageDescription with a human-readable explanation string — Apple requires this string and will reject your app without it. Add NSBluetoothPeripheralUsageDescription for older iOS. In Flutter: call Permission.bluetooth.request() before using any Bluetooth API. Test on physical devices — Bluetooth does not work on simulators or emulators.