feat: clone Opal's rules feature with hard block enforcement
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. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
85
Severed/Services/ScreenTimeAuthorization.swift
Normal file
85
Severed/Services/ScreenTimeAuthorization.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user