Rebuild the app around recurring screen-time blocking rules modeled on Opal's My Apps tab: - Onboarding with FamilyControls Screen Time authorization - Apps home with Blocked Apps tiles and live rule cards - New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery - Rule editors: time windows (incl. overnight), day picker, app selection via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit - Hard Mode: active hard rules cannot be edited, disabled, deleted, or unblocked until their window ends; soft rules pause until next window - Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer - 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg harness for in-memory storage, mocked authorization, seeded scenarios - docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording Known gap: background window transitions and time/open-limit thresholds need a DeviceActivityMonitor extension; shields currently sync while the app is running.
86 lines
2.3 KiB
Swift
86 lines
2.3 KiB
Swift
//
|
|
// ScreenTimeAuthorization.swift
|
|
// Severed
|
|
//
|
|
|
|
import FamilyControls
|
|
import Foundation
|
|
import Observation
|
|
|
|
enum ScreenTimeAuthorizationStatus: Equatable, Sendable {
|
|
case notDetermined
|
|
case denied
|
|
case approved
|
|
}
|
|
|
|
/// Abstracts FamilyControls authorization so views and tests never touch
|
|
/// `AuthorizationCenter` directly.
|
|
protocol AuthorizationProviding {
|
|
var currentStatus: ScreenTimeAuthorizationStatus { get }
|
|
func requestAuthorization() async throws
|
|
}
|
|
|
|
/// Real Screen Time authorization via FamilyControls.
|
|
struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
|
|
var currentStatus: ScreenTimeAuthorizationStatus {
|
|
switch AuthorizationCenter.shared.authorizationStatus {
|
|
case .approved: .approved
|
|
case .denied: .denied
|
|
case .notDetermined: .notDetermined
|
|
@unknown default: .notDetermined
|
|
}
|
|
}
|
|
|
|
func requestAuthorization() async throws {
|
|
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
|
|
}
|
|
}
|
|
|
|
/// In-memory provider for unit and UI tests.
|
|
final class MockAuthorizationProvider: AuthorizationProviding {
|
|
var status: ScreenTimeAuthorizationStatus
|
|
var requestShouldFail: Bool
|
|
|
|
init(status: ScreenTimeAuthorizationStatus = .notDetermined, requestShouldFail: Bool = false) {
|
|
self.status = status
|
|
self.requestShouldFail = requestShouldFail
|
|
}
|
|
|
|
var currentStatus: ScreenTimeAuthorizationStatus { status }
|
|
|
|
func requestAuthorization() async throws {
|
|
if requestShouldFail {
|
|
status = .denied
|
|
throw FamilyControlsError.authorizationCanceled
|
|
}
|
|
status = .approved
|
|
}
|
|
}
|
|
|
|
/// Observable authorization state for the UI.
|
|
@Observable
|
|
final class ScreenTimeAuthorization {
|
|
private(set) var status: ScreenTimeAuthorizationStatus
|
|
private(set) var lastRequestFailed = false
|
|
private let provider: AuthorizationProviding
|
|
|
|
init(provider: AuthorizationProviding) {
|
|
self.provider = provider
|
|
self.status = provider.currentStatus
|
|
}
|
|
|
|
func refresh() {
|
|
status = provider.currentStatus
|
|
}
|
|
|
|
func request() async {
|
|
do {
|
|
try await provider.requestAuthorization()
|
|
lastRequestFailed = false
|
|
} catch {
|
|
lastRequestFailed = true
|
|
}
|
|
refresh()
|
|
}
|
|
}
|