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
This commit is contained in:
2026-06-12 15:14:01 -04:00
parent fd1f5d758f
commit 2d609043f3
13 changed files with 525 additions and 914 deletions

View File

@@ -53,8 +53,6 @@ struct SeveredApp: App {
RootView()
.environment(authorization)
.environment(enforcer)
.preferredColorScheme(.dark)
.tint(Theme.accent)
}
.modelContainer(container)
}

View File

@@ -6,8 +6,9 @@
import SwiftData
import SwiftUI
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked
/// right now, then the Rules carousel with "+ New".
/// Home screen using plain iOS components: a List with a "Blocked Apps"
/// section (what's blocking right now) and a "Rules" section listing every
/// rule with its live status. "+" in the toolbar creates a new rule.
struct AppsHomeView: View {
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@@ -21,27 +22,23 @@ struct AppsHomeView: View {
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
ScrollView {
VStack(alignment: .leading, spacing: 28) {
blockedAppsSection(now: timeline.date)
rulesSection(now: timeline.date)
ruleList(now: timeline.date)
}
.navigationTitle("Apps")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("New Rule", systemImage: "plus") {
showingNewRule = true
}
.padding(.horizontal, 20)
.padding(.top, 8)
.accessibilityIdentifier("newRuleButton")
}
}
.background(Theme.background)
.navigationTitle("Apps")
.toolbarColorScheme(.dark, for: .navigationBar)
}
.sheet(item: $detailRule) { rule in
RuleDetailSheet(rule: rule)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showingNewRule) {
NewRuleSheet()
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Unblock \(unblockCandidate?.name ?? "")?",
@@ -74,30 +71,34 @@ struct AppsHomeView: View {
}
}
// MARK: - Blocked Apps
@ViewBuilder
private func blockedAppsSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive }
VStack(alignment: .leading, spacing: 14) {
Text("Blocked Apps")
.font(.system(size: 17, weight: .semibold))
if blocking.isEmpty {
Text("Nothing is blocked right now.")
.font(.system(size: 14))
.foregroundStyle(Theme.textTertiary)
.accessibilityIdentifier("nothingBlockedLabel")
} else {
HStack(spacing: 16) {
ForEach(blocking) { rule in
blockedTile(for: rule, now: now)
}
}
}
private func ruleList(now: Date) -> some View {
List {
blockedSection(now: now)
rulesSection(now: now)
}
}
private func blockedTile(for rule: BlockingRule, now: Date) -> some View {
// MARK: - Blocked Apps
@ViewBuilder
private func blockedSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive }
Section {
if blocking.isEmpty {
Text("Nothing is blocked right now.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("nothingBlockedLabel")
} else {
ForEach(blocking) { rule in
blockedRow(for: rule, now: now)
}
}
} header: {
Text("Blocked Apps").textCase(nil)
}
}
private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
Button {
if RulePolicy.canUnblock(rule, at: now) {
unblockCandidate = rule
@@ -105,75 +106,86 @@ struct AppsHomeView: View {
hardModeBlockedAttempt = true
}
} label: {
VStack(spacing: 6) {
HStack {
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.frame(width: 58, height: 58)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
)
.foregroundStyle(rule.hardMode ? .red : .secondary)
.frame(width: 28)
Text(rule.name)
.foregroundStyle(Color.primary)
Spacer()
Text("Unblock")
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
.foregroundStyle(.tint)
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("blockedTile-\(rule.name)")
}
// MARK: - Rules
@ViewBuilder
private func rulesSection(now: Date) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Rules")
.font(.system(size: 17, weight: .semibold))
Spacer()
Button {
showingNewRule = true
} label: {
Label("New", systemImage: "plus")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
}
.accessibilityIdentifier("newRuleButton")
}
Section {
if rules.isEmpty {
emptyRulesCard
VStack(alignment: .leading, spacing: 4) {
Text("No rules yet")
.font(.headline)
Text("Create a rule to block distracting apps on a schedule.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("emptyRulesCard")
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 14) {
ForEach(rules) { rule in
Button {
detailRule = rule
} label: {
RuleCardView(rule: rule, now: now)
}
.buttonStyle(.plain)
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
}
ForEach(rules) { rule in
ruleRow(for: rule, now: now)
}
}
} header: {
Text("Rules").textCase(nil)
}
}
private var emptyRulesCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("No rules yet")
.font(.system(size: 16, weight: .semibold))
Text("Create a rule to block distracting apps on a schedule.")
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
let status = rule.status(at: now)
return Button {
detailRule = rule
} label: {
HStack {
Image(systemName: rule.kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(blockSummary(for: rule))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Text(statusText(for: rule, status: status, now: now))
.font(.subheadline)
.foregroundStyle(status.isActive ? .green : .secondary)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
private func blockSummary(for rule: BlockingRule) -> String {
let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps"
return "\(rule.selectionMode.displayName) · \(apps)"
}
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
switch rule.kind {
case .schedule:
status.label(relativeTo: now)
case .timeLimit:
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 22))
.accessibilityElement(children: .combine)
.accessibilityIdentifier("emptyRulesCard")
}
// MARK: - Enforcement
@@ -181,8 +193,9 @@ struct AppsHomeView: View {
/// Changes whenever any rule's blocking-relevant state changes.
private var ruleChangeToken: String {
rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|"
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
+ "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
}
.joined(separator: ",")
}

View File

@@ -1,90 +0,0 @@
//
// RuleCardView.swift
// Severed
//
import SwiftUI
/// A rule tile in the home screen's Rules carousel: icon pair, live status,
/// name, and the block summary. Active rules glow green.
struct RuleCardView: View {
let rule: BlockingRule
let now: Date
private var status: RuleStatus { rule.status(at: now) }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
RuleIconPair(kind: rule.kind, isActive: status.isActive)
statusView
Text(rule.name)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 5) {
Text(rule.selectionMode.displayName)
.foregroundStyle(Theme.textSecondary)
Text(blockSummary)
.foregroundStyle(Theme.textTertiary)
}
.font(.system(size: 13))
}
.padding(16)
.frame(width: 172, alignment: .leading)
.background(cardBackground, in: RoundedRectangle(cornerRadius: 22))
.overlay(
RoundedRectangle(cornerRadius: 22)
.strokeBorder(
status.isActive ? Theme.accent.opacity(0.35) : .white.opacity(0.08),
lineWidth: 1
)
)
}
@ViewBuilder
private var statusView: some View {
switch status {
case .active:
Text(status.label(relativeTo: now))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.black)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Theme.accent, in: Capsule())
.accessibilityIdentifier("ruleStatus-\(rule.name)")
case .paused, .disabled, .dormant, .upcoming:
Text(statusText)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.textSecondary)
.padding(.vertical, 5)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
private var statusText: String {
switch rule.kind {
case .schedule:
status.label(relativeTo: now)
case .timeLimit:
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
}
}
private var blockSummary: String {
rule.selectionCount == 1 ? "· 1 app" : "· \(rule.selectionCount) apps"
}
private var cardBackground: some ShapeStyle {
if status.isActive {
return AnyShapeStyle(
LinearGradient(
colors: [Theme.activeCardTop, Theme.activeCardBottom],
startPoint: .top, endPoint: .bottom
)
)
}
return AnyShapeStyle(Theme.surface)
}
}

View File

@@ -5,29 +5,19 @@
import SwiftUI
/// "On these days:" seven circular toggles (S M T W T F S) with a
/// human-readable summary, as in the reference rule editors.
/// Seven circular day toggles (S M T W T F S) using system colors, meant to
/// sit inside a Form/List row. The day-set summary is shown by the enclosing
/// section header.
struct DayOfWeekPicker: View {
@Binding var days: Set<Weekday>
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("On these days:")
.font(.system(size: 15, weight: .semibold))
Spacer()
Text(days.summary)
.font(.system(size: 14))
.foregroundStyle(Theme.textSecondary)
}
HStack(spacing: 8) {
ForEach(Weekday.displayOrder, id: \.self) { day in
dayToggle(day)
}
HStack(spacing: 8) {
ForEach(Weekday.displayOrder, id: \.self) { day in
dayToggle(day)
}
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
.padding(.vertical, 4)
}
private func dayToggle(_ day: Weekday) -> some View {
@@ -40,13 +30,16 @@ struct DayOfWeekPicker: View {
}
} label: {
Text(day.shortLabel)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(isOn ? .black : Theme.textSecondary)
.font(.subheadline.weight(.semibold))
.foregroundStyle(isOn ? Color.white : .secondary)
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.background(isOn ? Color.white : Theme.surfaceElevated, in: Circle())
.background(
isOn ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.tertiarySystemFill)),
in: Circle()
)
}
.buttonStyle(.plain)
.buttonStyle(.borderless)
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
.accessibilityLabel(day.abbreviation)
.accessibilityAddTraits(isOn ? .isSelected : [])
@@ -55,7 +48,15 @@ struct DayOfWeekPicker: View {
#Preview {
@Previewable @State var days = Weekday.weekdays
DayOfWeekPicker(days: $days)
.padding()
.background(Theme.background)
Form {
Section {
DayOfWeekPicker(days: $days)
} header: {
HStack {
Text("On these days").textCase(nil)
Spacer()
Text(days.summary).textCase(nil)
}
}
}
}

View File

@@ -1,54 +0,0 @@
//
// HoldToCommitButton.swift
// Severed
//
import SwiftUI
/// The deliberate-friction commit button: the user must press and hold for a
/// second to save a rule, mirroring the reference app's "Hold to Commit".
struct HoldToCommitButton: View {
var title = "Hold to Commit"
var holdDuration: Double = 1.0
let action: () -> Void
@State private var progress: Double = 0
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 28)
.fill(Theme.surfaceElevated)
GeometryReader { proxy in
RoundedRectangle(cornerRadius: 28)
.fill(Theme.commitGradient)
.opacity(0.85)
.frame(width: proxy.size.width * progress)
.animation(.linear(duration: 0.1), value: progress == 0)
}
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
}
.frame(height: 56)
.clipShape(RoundedRectangle(cornerRadius: 28))
.contentShape(RoundedRectangle(cornerRadius: 28))
.onLongPressGesture(minimumDuration: holdDuration) {
progress = 0
action()
} onPressingChanged: { pressing in
withAnimation(.linear(duration: pressing ? holdDuration : 0.2)) {
progress = pressing ? 1 : 0
}
}
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier("holdToCommitButton")
.accessibilityLabel(title)
}
}
#Preview {
HoldToCommitButton { }
.padding()
.background(Theme.background)
}

View File

@@ -5,9 +5,9 @@
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.
/// 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
@@ -31,23 +31,18 @@ struct OnboardingView: View {
Spacer()
footer
}
.padding(.horizontal, 28)
.padding(.bottom, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundStyle(.white)
.background(Theme.background)
.padding()
}
private var welcome: some View {
VStack(spacing: 18) {
VStack(spacing: 16) {
Image(systemName: "scissors")
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(Theme.accent)
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Severed")
.font(.system(size: 38, weight: .bold))
.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.")
.font(.system(size: 16))
.foregroundStyle(Theme.textSecondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
@@ -55,10 +50,10 @@ struct OnboardingView: View {
private var permission: some View {
VStack(spacing: 24) {
Image(systemName: "hourglass")
.font(.system(size: 48, weight: .semibold))
.foregroundStyle(Theme.accent)
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("Allow Screen Time Access")
.font(.system(size: 28, weight: .bold))
.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.")
@@ -68,8 +63,8 @@ struct OnboardingView: View {
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)
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.accessibilityIdentifier("permissionDeniedLabel")
Button("Open Settings") {
@@ -77,8 +72,6 @@ struct OnboardingView: View {
openURL(url)
}
}
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("openSettingsButton")
}
}
@@ -88,12 +81,11 @@ struct OnboardingView: View {
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)
.foregroundStyle(.tint)
.frame(width: 24)
Text(text)
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
@@ -127,13 +119,10 @@ struct OnboardingView: View {
) -> 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)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier(identifier)
}
}

View File

@@ -6,10 +6,8 @@
import FamilyControls
import SwiftUI
/// App/category/website selection for a rule. Wraps Apple's
/// `FamilyActivityPicker` (the system-sanctioned way to choose apps without
/// seeing their identities) and adds the reference app's Block / Allow Only
/// mode switch.
/// App/category/website selection wrapping Apple's `FamilyActivityPicker`,
/// presented as a plain sheet with a segmented Block / Allow Only control.
struct AppSelectionSheet: View {
@Binding var draft: RuleDraft
@Environment(\.dismiss) private var dismiss
@@ -26,58 +24,56 @@ struct AppSelectionSheet: View {
}
var body: some View {
VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "chevron.left") { dismiss() }
NavigationStack {
VStack(spacing: 0) {
Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
.accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection)
}
.navigationTitle("Selected")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier("selectionBackButton")
Spacer()
CircleIconButton(systemImage: "checkmark", tint: .black) { save() }
.background(Theme.accent, in: Circle())
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
save()
}
.accessibilityIdentifier("confirmSelectionButton")
}
.overlay(
Text("Selected")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
)
.padding(.horizontal, 20)
.padding(.top, 18)
Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 16)
.accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection)
.padding(.top, 4)
VStack(spacing: 10) {
Text(selectionSummary)
.font(.system(size: 14))
.foregroundStyle(Theme.textSecondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier("saveSelectionButton")
}
.buttonStyle(.plain)
.accessibilityIdentifier("saveSelectionButton")
.padding(.horizontal)
.padding(.bottom, 8)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.background(Theme.background)
}
private var selectionSummary: String {

View File

@@ -6,9 +6,8 @@
import SwiftData
import SwiftUI
/// "New Rule": the three rule-type cards up top, then the preset gallery.
/// Picking either one morphs the sheet into the editor; committing saves the
/// rule and closes the sheet.
/// "New Rule" as a plain list: a Rule Type section, then the preset sections.
/// Picking either pushes the editor; committing saves and closes the sheet.
struct NewRuleSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@@ -16,142 +15,97 @@ struct NewRuleSheet: View {
var body: some View {
NavigationStack {
chooser
.toolbar(.hidden, for: .navigationBar)
.navigationDestination(item: $pendingDraft) { draft in
RuleEditorView(
mode: .create,
draft: draft,
embedsInNavigationStack: true,
onCommit: { committed in
modelContext.insert(committed.makeRule())
dismiss()
}
)
List {
Section {
ForEach(RuleKind.allCases, id: \.self) { kind in
kindRow(kind)
}
} header: {
Text("Rule Type").textCase(nil)
}
}
.background(Theme.background)
}
private var chooser: some View {
VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "xmark") { dismiss() }
ForEach(RulePresetSection.all) { section in
Section {
ForEach(section.presets) { preset in
presetRow(preset)
}
} header: {
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
Text(section.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
.textCase(nil)
}
}
}
.navigationTitle("New Rule")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeNewRuleButton")
Spacer()
}
.overlay(
Text("New Rule")
.font(.system(size: 18, weight: .semibold))
)
.padding(.horizontal, 20)
.padding(.top, 18)
ScrollView {
VStack(alignment: .leading, spacing: 26) {
HStack(spacing: 10) {
ForEach(RuleKind.allCases, id: \.self) { kind in
kindCard(kind)
}
}
ForEach(RulePresetSection.all) { section in
presetSection(section)
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 30)
}
.navigationDestination(item: $pendingDraft) { draft in
RuleEditorView(
mode: .create,
draft: draft,
onCommit: { committed in
modelContext.insert(committed.makeRule())
dismiss()
}
)
}
}
.foregroundStyle(.white)
}
private func kindCard(_ kind: RuleKind) -> some View {
private func kindRow(_ kind: RuleKind) -> some View {
Button {
pendingDraft = RuleDraft(kind: kind)
} label: {
VStack(spacing: 8) {
HStack {
Image(systemName: kind.symbolName)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(height: 26)
Text(kind.displayName)
.font(.system(size: 14, weight: .semibold))
Text(kind.exampleText)
.font(.system(size: 11))
.foregroundStyle(Theme.textTertiary)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(kind.displayName)
.foregroundStyle(Color.primary)
Text(kind.exampleText)
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.plain)
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
}
private func presetSection(_ section: RulePresetSection) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(section.title)
.font(.system(size: 18, weight: .bold))
Text(section.subtitle)
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
spacing: 12
) {
ForEach(section.presets) { preset in
presetCard(preset)
}
}
.padding(.top, 12)
}
}
private func presetCard(_ preset: RulePreset) -> some View {
private func presetRow(_ preset: RulePreset) -> some View {
Button {
pendingDraft = RuleDraft(preset: preset)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
RuleIconPair(kind: .schedule)
Spacer()
HStack {
Image(systemName: preset.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(preset.name)
.foregroundStyle(Color.primary)
Text("\(preset.schedule.timeRangeLabel) · \(preset.days.summary)")
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Image(systemName: preset.symbolName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
.padding(.bottom, 8)
Text(preset.schedule.timeRangeLabel)
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
Text(preset.name)
.font(.system(size: 16, weight: .semibold))
HStack {
Text("Block")
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
Spacer()
Image(systemName: "plus.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.85))
}
Image(systemName: "plus.circle.fill")
.foregroundStyle(.tint)
}
.padding(14)
.frame(height: 190)
.background(
LinearGradient(
colors: [preset.gradientTop, preset.gradientBottom],
startPoint: .top, endPoint: .bottom
),
in: RoundedRectangle(cornerRadius: 20)
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("preset-\(preset.id)")
}
}

View File

@@ -6,10 +6,9 @@
import SwiftData
import SwiftUI
/// The card-style rule summary shown when tapping a rule: icon pair, live
/// status, the key facts, and "Edit Rule" which morphs the sheet into the
/// editor, as in the reference app. A hard-locked rule shows a lock notice
/// instead of the edit button.
/// Rule summary presented as a plain sheet: inline title with a live status
/// caption, the rule's facts as labeled rows, and "Edit Rule" which pushes
/// the editor. A hard-locked rule shows a lock notice instead.
struct RuleDetailSheet: View {
let rule: BlockingRule
@@ -19,12 +18,14 @@ struct RuleDetailSheet: View {
@State private var pendingDeletion = false
var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
if isEditing {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
detailList(now: timeline.date)
}
.navigationDestination(isPresented: $isEditing) {
RuleEditorView(
mode: .edit(isEnabled: rule.isEnabled),
draft: RuleDraft(rule: rule),
onBack: { isEditing = false },
onCommit: { draft in
draft.apply(to: rule)
isEditing = false
@@ -39,11 +40,8 @@ struct RuleDetailSheet: View {
dismiss()
}
)
} else {
detail(now: timeline.date)
}
}
.background(Theme.background)
.onDisappear {
if pendingDeletion {
modelContext.delete(rule)
@@ -51,82 +49,77 @@ struct RuleDetailSheet: View {
}
}
private func detail(now: Date) -> some View {
private func detailList(now: Date) -> some View {
let status = rule.status(at: now)
return VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "xmark") { dismiss() }
.accessibilityIdentifier("closeDetailButton")
Spacer()
return List {
Section {
detailRows
}
.padding(.horizontal, 20)
.padding(.top, 18)
ScrollView {
VStack(spacing: 24) {
RuleIconPair(kind: rule.kind, isActive: status.isActive)
VStack(spacing: 6) {
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.textSecondary)
.accessibilityIdentifier("detailStatusLabel")
Text(rule.name)
.font(.system(size: 26, weight: .bold))
.accessibilityIdentifier("detailRuleName")
Section {
if RulePolicy.canEdit(rule, at: now) {
Button {
isEditing = true
} label: {
Label("Edit Rule", systemImage: "pencil")
}
detailRows
.accessibilityIdentifier("editRuleButton")
} else {
Label(
"Hard Mode is on — this rule is locked until the block ends.",
systemImage: "lock.fill"
)
.foregroundStyle(.secondary)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice")
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeDetailButton")
}
ToolbarItem(placement: .principal) {
VStack(spacing: 1) {
Text(rule.name)
.font(.headline)
.accessibilityIdentifier("detailRuleName")
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("detailStatusLabel")
}
.padding(.horizontal, 20)
.padding(.top, 8)
}
footer(now: now)
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.foregroundStyle(.white)
}
@ViewBuilder
private var detailRows: some View {
VStack(spacing: 0) {
switch rule.kind {
case .schedule:
row("During this time", rule.schedule.timeRangeLabel)
divider
row("On these days", rule.days.summary)
divider
row(rule.selectionMode.displayName, appCountLabel)
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
divider
row("For this long", "\(rule.dailyLimitMinutes)m daily")
divider
row("On these days", rule.days.summary)
divider
row("Then block until", "Tomorrow")
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
divider
row("More than", "\(rule.maxOpens) opens daily")
divider
row("On these days", rule.days.summary)
divider
row("Then block until", "Tomorrow")
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
switch rule.kind {
case .schedule:
row("During this time", rule.schedule.timeRangeLabel)
row("On these days", rule.days.summary)
row(rule.selectionMode.displayName, appCountLabel)
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
row("For this long", "\(rule.dailyLimitMinutes)m daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
row("More than", "\(rule.maxOpens) opens daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var appCountLabel: String {
@@ -134,51 +127,8 @@ struct RuleDetailSheet: View {
}
private func row(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 15, weight: .medium))
Spacer()
Text(value)
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("detailRow-\(label)")
}
private var divider: some View {
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
}
@ViewBuilder
private func footer(now: Date) -> some View {
if RulePolicy.canEdit(rule, at: now) {
Button {
isEditing = true
} label: {
Label("Edit Rule", systemImage: "pencil")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("editRuleButton")
} else {
HStack(spacing: 10) {
Image(systemName: "lock.fill")
Text("Hard Mode is on — this rule is locked until the block ends.")
.font(.system(size: 14, weight: .medium))
}
.foregroundStyle(Theme.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
LabeledContent(label, value: value)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice")
}
.accessibilityIdentifier("detailRow-\(label)")
}
}

View File

@@ -5,9 +5,10 @@
import SwiftUI
/// The rule editor used both for creation (Hold to Commit) and editing
/// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind,
/// mirroring the reference app's "In the Zone" and "Time Keeper" editors.
/// The rule editor as a plain Form, always pushed inside a NavigationStack
/// (New Rule flow and detail editing both push it). Creation commits with a
/// prominent "Add Rule" button; editing uses a toolbar Done plus red
/// Disable/Delete rows.
struct RuleEditorView: View {
enum Mode: Equatable {
case create
@@ -16,11 +17,6 @@ struct RuleEditorView: View {
let mode: Mode
@State var draft: RuleDraft
/// True when pushed inside a NavigationStack (New Rule flow): the editor
/// then uses native navigation chrome (system back button, swipe-back)
/// instead of its custom header.
var embedsInNavigationStack = false
var onBack: () -> Void = {}
var onCommit: (RuleDraft) -> Void
var onToggleEnabled: (() -> Void)?
var onDelete: (() -> Void)?
@@ -30,54 +26,62 @@ struct RuleEditorView: View {
@State private var renameText = ""
var body: some View {
if embedsInNavigationStack {
core
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .principal) {
Text(draft.name)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle")
Form {
sections
if case .edit(let isEnabled) = mode {
Section {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
ToolbarItem(placement: .topBarTrailing) {
Button {
renameText = draft.name
showingRename = true
} label: {
Image(systemName: "pencil")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
}
.accessibilityIdentifier("renameButton")
}
}
} else {
core
}
}
.foregroundStyle(.red)
.accessibilityIdentifier("toggleEnabledButton")
private var core: some View {
VStack(spacing: 0) {
if !embedsInNavigationStack {
header
}
ScrollView {
VStack(spacing: 18) {
sections
Button("Delete Rule", role: .destructive) {
onDelete?()
}
.accessibilityIdentifier("deleteRuleButton")
}
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 24)
}
footer
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.foregroundStyle(.white)
.background(Theme.background)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text(draft.name)
.font(.headline)
.lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle")
}
ToolbarItem(placement: .topBarTrailing) {
Button("Rename", systemImage: "pencil") {
renameText = draft.name
showingRename = true
}
.accessibilityIdentifier("renameButton")
}
if case .edit = mode {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onCommit(draft)
}
.accessibilityIdentifier("doneButton")
}
}
}
.safeAreaInset(edge: .bottom) {
if mode == .create {
Button {
onCommit(draft)
} label: {
Text("Add Rule")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal)
.padding(.bottom, 8)
.accessibilityIdentifier("commitRuleButton")
}
}
.alert("Rule Name", isPresented: $showingRename) {
TextField("Name", text: $renameText)
Button("OK") {
@@ -90,256 +94,159 @@ struct RuleEditorView: View {
}
.sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft)
.presentationDragIndicator(.visible)
}
}
// MARK: - Header
private var header: some View {
HStack {
CircleIconButton(systemImage: "chevron.left", action: onBack)
.accessibilityIdentifier("editorBackButton")
Spacer()
CircleIconButton(systemImage: "pencil") {
renameText = draft.name
showingRename = true
}
.accessibilityIdentifier("renameButton")
}
.overlay(
Text(draft.name)
.font(.system(size: 18, weight: .semibold))
.lineLimit(1)
.padding(.horizontal, 56)
.accessibilityIdentifier("ruleEditorTitle")
)
.padding(.horizontal, 20)
.padding(.top, 18)
}
// MARK: - Sections
@ViewBuilder
private var sections: some View {
switch draft.kind {
case .schedule:
timeWindowSection
DayOfWeekPicker(days: $draft.days)
appsSection(header: "Apps are blocked")
hardModeSection
adultContentSection
Section {
DatePicker(
"From",
selection: timeBinding($draft.startMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("fromTimePicker")
DatePicker(
"To",
selection: timeBinding($draft.endMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("toTimePicker")
} header: {
Text("During this time").textCase(nil)
}
daysSection
Section {
selectedAppsRow
} header: {
Text("Apps are blocked").textCase(nil)
}
toggleSections
case .timeLimit:
appsSection(header: "When I use")
budgetSection(
title: "For this long",
value: "\(draft.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
)
DayOfWeekPicker(days: $draft.days)
Section {
selectedAppsRow
} header: {
Text("When I use").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
)
} header: {
Text("For this long").textCase(nil)
}
daysSection
blockUntilSection
hardModeSection
adultContentSection
toggleSections
case .openLimit:
appsSection(header: "When I open")
budgetSection(
title: "More than",
value: "\(draft.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
)
DayOfWeekPicker(days: $draft.days)
Section {
selectedAppsRow
} header: {
Text("When I open").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
)
} header: {
Text("More than").textCase(nil)
}
daysSection
blockUntilSection
hardModeSection
adultContentSection
toggleSections
}
}
private var timeWindowSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionHeader(systemImage: "calendar", title: "During this time")
VStack(spacing: 0) {
timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker")
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker")
private var daysSection: some View {
Section {
DayOfWeekPicker(days: $draft.days)
} header: {
HStack {
Text("On these days").textCase(nil)
Spacer()
Text(draft.days.summary).textCase(nil)
}
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
}
private func timeRow(label: String, minutes: Binding<Int>, identifier: String) -> some View {
HStack {
Text(label)
.font(.system(size: 15, weight: .medium))
Spacer()
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute)
.labelsHidden()
.colorScheme(.dark)
.accessibilityIdentifier(identifier)
private var blockUntilSection: some View {
Section {
LabeledContent("Until", value: "Tomorrow")
} header: {
Text("Then block app").textCase(nil)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func appsSection(header: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
sectionHeader(systemImage: "shield.fill", title: header)
Button {
showingAppPicker = true
} label: {
HStack {
Text("Selected Apps")
.font(.system(size: 15, weight: .medium))
Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.textTertiary)
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
@ViewBuilder
private var toggleSections: some View {
Section {
HStack {
Text("Hard Mode")
Spacer()
Toggle("", isOn: $draft.hardMode)
.labelsHidden()
.accessibilityIdentifier("hardModeToggle")
}
.buttonStyle(.plain)
.accessibilityIdentifier("selectedAppsRow")
} footer: {
Text("No unblocks allowed while the rule is blocking.")
}
Section {
HStack {
Text("Block Adult Content")
Spacer()
Toggle("", isOn: $draft.blockAdultContent)
.labelsHidden()
.accessibilityIdentifier("adultContentToggle")
}
} footer: {
Text("Filter adult websites while this rule is active.")
}
}
private func budgetSection(
title: String,
private var selectedAppsRow: some View {
Button {
showingAppPicker = true
} label: {
HStack {
Text("Selected Apps")
.foregroundStyle(Color.primary)
Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
.foregroundStyle(Color.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.accessibilityIdentifier("selectedAppsRow")
}
private func budgetRow(
value: String,
stepperID: String,
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void
) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 15, weight: .medium))
Text("Daily")
.font(.system(size: 12))
.foregroundStyle(Theme.textTertiary)
}
Text("Daily")
Spacer()
Text(value)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.secondary)
.accessibilityIdentifier("\(stepperID)Value")
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
.labelsHidden()
.accessibilityIdentifier(stepperID)
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var blockUntilSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionHeader(systemImage: "shield.fill", title: "Then block app")
HStack {
Text("Until")
.font(.system(size: 15, weight: .medium))
Spacer()
Text("Tomorrow")
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
}
private var hardModeSection: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Hard Mode")
.font(.system(size: 15, weight: .semibold))
Text("No unblocks allowed")
.font(.system(size: 12))
.foregroundStyle(Theme.textTertiary)
}
Spacer()
Toggle("", isOn: $draft.hardMode)
.labelsHidden()
.tint(Theme.accent)
.accessibilityIdentifier("hardModeToggle")
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var adultContentSection: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Block Adult Content")
.font(.system(size: 15, weight: .semibold))
Text("Filter adult websites while this rule is active")
.font(.system(size: 12))
.foregroundStyle(Theme.textTertiary)
}
Spacer()
Toggle("", isOn: $draft.blockAdultContent)
.labelsHidden()
.tint(Theme.accent)
.accessibilityIdentifier("adultContentToggle")
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private func sectionHeader(systemImage: String, title: String) -> some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.textSecondary)
Text(title)
.font(.system(size: 15, weight: .semibold))
}
.padding(.leading, 4)
}
// MARK: - Footer
@ViewBuilder
private var footer: some View {
switch mode {
case .create:
HoldToCommitButton {
onCommit(draft)
}
case .edit(let isEnabled):
VStack(spacing: 12) {
Button {
onCommit(draft)
} label: {
Label("Done", systemImage: "checkmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("doneButton")
HStack(spacing: 24) {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
.accessibilityIdentifier("toggleEnabledButton")
Button("Delete Rule") {
onDelete?()
}
.accessibilityIdentifier("deleteRuleButton")
}
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.destructive)
}
}
}
private func timeBinding(_ minutes: Binding<Int>) -> Binding<Date> {

View File

@@ -1,73 +0,0 @@
//
// Theme.swift
// Severed
//
import SwiftUI
/// Dark, green-tinted palette matching the reference app.
enum Theme {
static let background = Color(red: 0.03, green: 0.05, blue: 0.04)
static let surface = Color.white.opacity(0.07)
static let surfaceElevated = Color.white.opacity(0.12)
static let accent = Color(red: 0.66, green: 0.88, blue: 0.50)
static let activeCardTop = Color(red: 0.10, green: 0.20, blue: 0.12)
static let activeCardBottom = Color(red: 0.05, green: 0.10, blue: 0.06)
static let textSecondary = Color.white.opacity(0.6)
static let textTertiary = Color.white.opacity(0.4)
static let destructive = Color(red: 1.0, green: 0.32, blue: 0.36)
static let commitGradient = LinearGradient(
colors: [
Color(red: 0.35, green: 0.65, blue: 0.85),
Color(red: 0.45, green: 0.75, blue: 0.55),
Color(red: 0.85, green: 0.80, blue: 0.45),
],
startPoint: .leading, endPoint: .trailing
)
}
/// Circular chrome button used in sheet headers (, , ).
struct CircleIconButton: View {
let systemImage: String
var tint: Color = .white
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 38, height: 38)
.background(Theme.surfaceElevated, in: Circle())
}
.buttonStyle(.plain)
}
}
/// The "calendar shield" icon pair shown on rule cards, details, and presets.
struct RuleIconPair: View {
let kind: RuleKind
var isActive = false
var body: some View {
HStack(spacing: 8) {
iconTile(systemImage: kind.symbolName, tinted: isActive)
Image(systemName: "arrow.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.textTertiary)
iconTile(systemImage: "shield.fill", tinted: isActive)
}
}
private func iconTile(systemImage: String, tinted: Bool) -> some View {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tinted ? Theme.accent : .white.opacity(0.7))
.frame(width: 32, height: 32)
.background(
(tinted ? Theme.accent.opacity(0.18) : Theme.surfaceElevated),
in: RoundedRectangle(cornerRadius: 9)
)
}
}

View File

@@ -23,7 +23,7 @@ final class RuleCreationUITests: XCTestCase {
XCTAssertTrue(app.staticTexts["During this time"].exists)
// Hold to commit saves the rule and returns home.
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-In the Zone"].waitToAppear()
XCTAssertFalse(app.element("emptyRulesCard").exists)
}
@@ -35,7 +35,7 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["preset-work-time"].waitToAppear().tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time")
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-Work Time"].waitToAppear()
}
@@ -53,7 +53,7 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["OK"].tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus")
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-My Focus"].waitToAppear()
}
@@ -81,7 +81,7 @@ final class RuleCreationUITests: XCTestCase {
app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m")
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-Time Keeper"].waitToAppear()
}
@@ -91,7 +91,7 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.switches["adultContentToggle"].waitToAppear().tap()
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-In the Zone"].waitToAppear().tap()
let row = app.element("detailRow-Adult websites").waitToAppear()

View File

@@ -329,3 +329,23 @@ enum SelectionMode: String, Codable { case block, allowOnly }
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
friction screen), notification nudges ("Complete Your Setup").
---
## 6. Native UI re-skin (current presentation)
Severed has since replaced the Opal-style custom presentation with the bare
iOS design language, keeping the backend (models, logic, services), the
flows, and the accessibility identifiers intact. Sections 15 remain as the
reference for *what* the feature does; presentation now maps as follows:
| Spec element | Native presentation |
|---|---|
| Apps home | `NavigationStack` + `List`; "Blocked Apps" and "Rules" sections; **rules are list rows** (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button |
| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead |
| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` |
| Rule editor | Native `Form`: `DatePicker` rows, day-circle row with the summary in the section header, toggle rows with footers, stepper rows. Create commits with a prominent **"Add Rule"** button (replaces Hold to Commit); edit uses toolbar **Done** plus red Disable/Delete rows |
| Onboarding / app picker | System styling, `.borderedProminent` buttons, default color scheme (no forced dark, default accent) |
Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,
icon-pair/circle-button chrome.