Integrating Apple's powerful Screen Time APIs—Family Controls, Managed Settings, and Device Activity—into a cross-platform Flutter application requires bridging native iOS capabilities with Dart code. Below is a comprehensive guide on how this architecture works, complete with code implementations.
Core Architecture: The Flutter-to-iOS Bridge
Because the Family Control APIs are exclusively available on iOS 15/16+, there isn't a single Flutter plugin that natively handles everything. The most reliable approach is to build a custom MethodChannel bridging Flutter to Swift.
Step 1: The Flutter Service (screen_time_bridge.dart)
On the Dart side, we define a class to communicate with iOS. It handles requesting authorization, showing the app picker, and adjusting earned time balances.
import 'package:flutter/services.dart';
class ScreenTimeBridge {
static const _channel = MethodChannel('com.yourcompany.app/screen_time');
Future<bool> requestAuthorization() async {
try {
final result = await _channel.invokeMethod<bool>('requestAuthorization');
return result ?? false;
} catch (e) {
print('Authorization failed: $e');
return false;
}
}
Future<List<String>?> showAppPicker() async {
try {
final result = await _channel.invokeMethod<List<dynamic>>('showAppPicker');
return result?.cast<String>();
} catch (e) {
print('Failed to open app picker: $e');
return null;
}
}
Future<bool> updateBalance(int minutes) async {
try {
final result = await _channel.invokeMethod<bool>('updateBalance', {
'minutes': minutes,
});
return result ?? false;
} catch (e) {
print('Failed to update balance: $e');
return false;
}
}
}
Step 2: The Native Swift Implementation
In iOS, your FlutterPlugin implementation requires importing specific Screen Time frameworks. Crucially, your app and its extensions must share an App Group to communicate synchronously.
import Flutter
import FamilyControls
import ManagedSettings
import DeviceActivity
import SwiftUI
@available(iOS 16.0, *)
class ScreenTimeBridge: NSObject, FlutterPlugin {
private let center = AuthorizationCenter.shared
private let store = ManagedSettingsStore()
private let activityCenter = DeviceActivityCenter()
// Shared App Group defaults to communicate with Background Extensions
private let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.app.shared")
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.yourcompany.app/screen_time", binaryMessenger: registrar.messenger())
let instance = ScreenTimeBridge()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "requestAuthorization":
Task {
do {
// .individual means authorizing for the current user's device autonomously
try await center.requestAuthorization(for: .individual)
result(true)
} catch {
result(FlutterError(code: "AUTH_FAILED", message: error.localizedDescription, details: nil))
}
}
// ... Handle other cases (showAppPicker, updateBalance, etc.) ...
default:
result(FlutterMethodNotImplemented)
}
}
}
Handling the App Picker using SwiftUI
Flutter cannot natively render iOS's FamilyActivityPicker. Instead, we wrap it inside a UIHostingController and present it natively over the active Flutter view controller.
// Triggered via Flutter "showAppPicker" method
func presentAppPicker(result: @escaping FlutterResult) {
let picker = ActivityPickerView { selection in
// Save highly secure selection tokens to App Group UserDefaults
if let data = try? JSONEncoder().encode(selection) {
sharedDefaults?.set(data, forKey: "familyActivitySelection")
sharedDefaults?.synchronize()
}
// Return placeholder tokens back to Flutter if needed
let dummyTokens = (0..<selection.applicationTokens.count).map { "token_\($0)" }
result(dummyTokens)
}
let hostingController = UIHostingController(rootView: picker)
// Present over the main Flutter UI
if let rootVC = UIApplication.shared.keyWindow?.rootViewController {
rootVC.present(hostingController, animated: true)
}
}
// SwiftUI Picker Wrapper
@available(iOS 16.0, *)
struct ActivityPickerView: View {
@State private var selection = FamilyActivitySelection()
@Environment(\.dismiss) var dismiss
var onComplete: (FamilyActivitySelection) -> Void
var body: some View {
NavigationView {
FamilyActivityPicker(selection: $selection)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onComplete(selection)
dismiss()
}
}
}
}
}
}
Applying Shields & Blocking Apps
Once apps are selected, you block them by extracting the tokens from UserDefaults and dropping them directly into the ManagedSettingsStore.
func applyShields() {
guard let data = sharedDefaults?.data(forKey: "familyActivitySelection"),
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data) else {
return
}
// Block selected apps natively immediately
store.shield.applications = selection.applicationTokens
store.shield.applicationCategories = .specific(selection.categoryTokens)
store.shield.webDomains = selection.webDomainTokens
sharedDefaults?.set(true, forKey: "shieldsEnabled")
sharedDefaults?.synchronize()
}
Monitoring Usage Limits and The 15-Minute Rule
Apple securely handles background app usage monitoring through DeviceActivityCenter. However, there is a critical constraint: DeviceActivity limits must be a minimum of 15 minutes long.
If your platform requires precise timers shorter than 15 minutes, you must construct a fallback system using background Timer instances, manual polling, and local UNUserNotification alerts to manually trigger the applyShield logic precisely when time expires.
Otherwise, utilizing native system scheduling looks like this:
func startMonitoring(minutes: Int) {
// Note: 'minutes' must be >= 15 for accurate OS scheduling
let now = Date()
let endDate = now.addingTimeInterval(TimeInterval(minutes * 60))
let schedule = DeviceActivitySchedule(
intervalStart: Calendar.current.dateComponents([.hour, .minute, .second], from: now),
intervalEnd: Calendar.current.dateComponents([.hour, .minute, .second], from: endDate),
repeats: false
)
do {
// Starts background tracking for this time bracket
try activityCenter.startMonitoring(DeviceActivityName("daily"), during: schedule)
} catch {
print("Failed to monitor: \(error)")
}
}
When the tracked time hits its threshold, Apple fires a DeviceActivityMonitorExtension. Since the extension operates in an isolated background sandbox, passing data via the shared App Group UserDefaults guarantees synchronization between Flutter and iOS contexts.
🛑 Critical Production Constraint: Required Entitlements
If you construct this architecture and build it onto your local iPhone via Xcode, it will work perfectly on a Development provisioning profile.
However, it will flatly fail and be rejected in production (TestFlight and the App Store) unless you obtain explicit App Store permission.
To distribute applications leveraging FamilyControls and Screen Time, developers must submit a formal request to Apple for the Family Controls Distribution Entitlement. During the review, Apple meticulously inspects whether your use case cleanly aligns with productivity workflows or parental control utilities.
Without this entitlement explicitly granted on your developer account and assigned to your App Profile, your screen time blockers are fundamentally limited to Debug mode on developer-local devices.
Top comments (0)