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:
139
Severed/Views/Onboarding/OnboardingView.swift
Normal file
139
Severed/Views/Onboarding/OnboardingView.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// OnboardingView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Two-step onboarding: a welcome screen, then the Screen Time permission
|
||||
/// request. Onboarding only completes once authorization is approved — the
|
||||
/// app cannot block anything without it.
|
||||
struct OnboardingView: View {
|
||||
@Environment(ScreenTimeAuthorization.self) private var authorization
|
||||
@Environment(\.openURL) private var openURL
|
||||
let onComplete: () -> Void
|
||||
|
||||
private enum Step {
|
||||
case welcome
|
||||
case permission
|
||||
}
|
||||
|
||||
@State private var step = Step.welcome
|
||||
@State private var isRequesting = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
switch step {
|
||||
case .welcome: welcome
|
||||
case .permission: permission
|
||||
}
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundStyle(.white)
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
private var welcome: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "scissors")
|
||||
.font(.system(size: 56, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("Severed")
|
||||
.font(.system(size: 38, weight: .bold))
|
||||
Text("Block your most distracting apps with rules that keep you honest — on a schedule, with no way out when you choose Hard Mode.")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var permission: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "hourglass")
|
||||
.font(.system(size: 48, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("Allow Screen Time Access")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
bullet("shield.fill", "Severed uses Apple's Screen Time framework to block the apps you choose.")
|
||||
bullet("hand.raised.fill", "Your app activity stays on this device and is never collected.")
|
||||
bullet("gearshape.fill", "You can change this anytime in Settings.")
|
||||
}
|
||||
if authorization.status == .denied || authorization.lastRequestFailed {
|
||||
VStack(spacing: 10) {
|
||||
Text("Screen Time access was declined. Severed can't block apps without it.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.destructive)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier("permissionDeniedLabel")
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("openSettingsButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bullet(_ systemImage: String, _ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.frame(width: 22)
|
||||
Text(text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
switch step {
|
||||
case .welcome:
|
||||
pillButton("Continue", identifier: "onboardingContinueButton") {
|
||||
step = .permission
|
||||
}
|
||||
case .permission:
|
||||
pillButton(
|
||||
isRequesting ? "Requesting…" : "Allow Screen Time Access",
|
||||
identifier: "allowScreenTimeButton"
|
||||
) {
|
||||
guard !isRequesting else { return }
|
||||
isRequesting = true
|
||||
Task {
|
||||
await authorization.request()
|
||||
isRequesting = false
|
||||
if authorization.status == .approved {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pillButton(
|
||||
_ title: String, identifier: String, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user