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>
140 lines
5.0 KiB
Swift
140 lines
5.0 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|