DEV Community

Cover image for Building a Custom Screen Time Flutter App with iOS Native APIs
es404020
es404020

Posted on

Building a Custom Screen Time Flutter App with iOS Native APIs

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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()
                        }
                    }
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)")
    }
}
Enter fullscreen mode Exit fullscreen mode

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)