Files
OpenAppLock/Severed/Views/Onboarding/OnboardingView.swift
Brendan Chen 2d609043f3 refactor: re-skin UI with native iOS components
Replace the Opal-style custom presentation with the bare iOS design
language, keeping the backend, flows, and accessibility identifiers:

- Home: NavigationStack + List; rules now presented as list rows with
  kind icon, block summary, and trailing live status; '+' in the toolbar
- New Rule: plain list of rule types and presets; editor still pushed
  via navigationDestination
- Editor: native Form (DatePicker rows, day circles with summary in the
  section header, toggle rows with footers, stepper rows); create
  commits with a prominent 'Add Rule' button replacing Hold to Commit;
  edit uses toolbar Done plus red Disable/Delete rows
- Detail: sheet with inline title + status caption, LabeledContent
  rows; Edit Rule pushes the editor natively
- Default color scheme: drop forced dark mode and custom tint; system
  appearance and accent throughout
- Delete Theme, HoldToCommitButton, RuleCardView and custom chrome
- Use concrete Color.primary/secondary in button row labels (the
  hierarchical styles resolve against the button tint)
- Tests: 93 passing; only delta is holdToCommitButton press →
  commitRuleButton tap in 5 call sites
- Spec: add §6 mapping the original presentation to the native one
2026-06-12 15:14:01 -04:00

129 lines
4.3 KiB
Swift

//
// OnboardingView.swift
// Severed
//
import SwiftUI
/// Two-step onboarding using system styling: 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()
}
private var welcome: some View {
VStack(spacing: 16) {
Image(systemName: "scissors")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Severed")
.font(.largeTitle.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.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
private var permission: some View {
VStack(spacing: 24) {
Image(systemName: "hourglass")
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("Allow Screen Time Access")
.font(.title.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(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.accessibilityIdentifier("permissionDeniedLabel")
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
.accessibilityIdentifier("openSettingsButton")
}
}
}
}
private func bullet(_ systemImage: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.foregroundStyle(.tint)
.frame(width: 24)
Text(text)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
@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)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier(identifier)
}
}