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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:14:01 -04:00
parent c99538565c
commit fb219aa8d8
13 changed files with 525 additions and 914 deletions

View File

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

View File

@@ -6,8 +6,9 @@
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked /// Home screen using plain iOS components: a List with a "Blocked Apps"
/// right now, then the Rules carousel with "+ New". /// 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 { struct AppsHomeView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer @Environment(RuleEnforcer.self) private var enforcer
@@ -21,27 +22,23 @@ struct AppsHomeView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in TimelineView(.periodic(from: .now, by: 30)) { timeline in
ScrollView { ruleList(now: timeline.date)
VStack(alignment: .leading, spacing: 28) { }
blockedAppsSection(now: timeline.date) .navigationTitle("Apps")
rulesSection(now: timeline.date) .toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("New Rule", systemImage: "plus") {
showingNewRule = true
} }
.padding(.horizontal, 20) .accessibilityIdentifier("newRuleButton")
.padding(.top, 8)
} }
} }
.background(Theme.background)
.navigationTitle("Apps")
.toolbarColorScheme(.dark, for: .navigationBar)
} }
.sheet(item: $detailRule) { rule in .sheet(item: $detailRule) { rule in
RuleDetailSheet(rule: rule) RuleDetailSheet(rule: rule)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
} }
.sheet(isPresented: $showingNewRule) { .sheet(isPresented: $showingNewRule) {
NewRuleSheet() NewRuleSheet()
.presentationDragIndicator(.visible)
} }
.confirmationDialog( .confirmationDialog(
"Unblock \(unblockCandidate?.name ?? "")?", "Unblock \(unblockCandidate?.name ?? "")?",
@@ -74,30 +71,34 @@ struct AppsHomeView: View {
} }
} }
// MARK: - Blocked Apps private func ruleList(now: Date) -> some View {
List {
@ViewBuilder blockedSection(now: now)
private func blockedAppsSection(now: Date) -> some View { rulesSection(now: now)
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 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 { Button {
if RulePolicy.canUnblock(rule, at: now) { if RulePolicy.canUnblock(rule, at: now) {
unblockCandidate = rule unblockCandidate = rule
@@ -105,75 +106,86 @@ struct AppsHomeView: View {
hardModeBlockedAttempt = true hardModeBlockedAttempt = true
} }
} label: { } label: {
VStack(spacing: 6) { HStack {
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill") Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
.font(.system(size: 20, weight: .semibold)) .foregroundStyle(rule.hardMode ? .red : .secondary)
.foregroundStyle(.white.opacity(0.85)) .frame(width: 28)
.frame(width: 58, height: 58) Text(rule.name)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14)) .foregroundStyle(Color.primary)
.overlay( Spacer()
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
)
Text("Unblock") Text("Unblock")
.font(.system(size: 12)) .foregroundStyle(.tint)
.foregroundStyle(Theme.textSecondary)
} }
} }
.buttonStyle(.plain)
.accessibilityIdentifier("blockedTile-\(rule.name)") .accessibilityIdentifier("blockedTile-\(rule.name)")
} }
// MARK: - Rules // MARK: - Rules
@ViewBuilder
private func rulesSection(now: Date) -> some View { private func rulesSection(now: Date) -> some View {
VStack(alignment: .leading, spacing: 14) { Section {
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")
}
if rules.isEmpty { 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 { } else {
ScrollView(.horizontal, showsIndicators: false) { ForEach(rules) { rule in
HStack(spacing: 14) { ruleRow(for: rule, now: now)
ForEach(rules) { rule in
Button {
detailRule = rule
} label: {
RuleCardView(rule: rule, now: now)
}
.buttonStyle(.plain)
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
}
} }
} }
} header: {
Text("Rules").textCase(nil)
} }
} }
private var emptyRulesCard: some View { private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
VStack(alignment: .leading, spacing: 8) { let status = rule.status(at: now)
Text("No rules yet") return Button {
.font(.system(size: 16, weight: .semibold)) detailRule = rule
Text("Create a rule to block distracting apps on a schedule.") } label: {
.font(.system(size: 13)) HStack {
.foregroundStyle(Theme.textSecondary) 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 // MARK: - Enforcement
@@ -181,8 +193,9 @@ struct AppsHomeView: View {
/// Changes whenever any rule's blocking-relevant state changes. /// Changes whenever any rule's blocking-relevant state changes.
private var ruleChangeToken: String { private var ruleChangeToken: String {
rules.map { rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|" "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" + "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
+ "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
} }
.joined(separator: ",") .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 import SwiftUI
/// "On these days:" seven circular toggles (S M T W T F S) with a /// Seven circular day toggles (S M T W T F S) using system colors, meant to
/// human-readable summary, as in the reference rule editors. /// sit inside a Form/List row. The day-set summary is shown by the enclosing
/// section header.
struct DayOfWeekPicker: View { struct DayOfWeekPicker: View {
@Binding var days: Set<Weekday> @Binding var days: Set<Weekday>
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { HStack(spacing: 8) {
HStack { ForEach(Weekday.displayOrder, id: \.self) { day in
Text("On these days:") dayToggle(day)
.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)
}
} }
} }
.padding(16) .padding(.vertical, 4)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
} }
private func dayToggle(_ day: Weekday) -> some View { private func dayToggle(_ day: Weekday) -> some View {
@@ -40,13 +30,16 @@ struct DayOfWeekPicker: View {
} }
} label: { } label: {
Text(day.shortLabel) Text(day.shortLabel)
.font(.system(size: 15, weight: .semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(isOn ? .black : Theme.textSecondary) .foregroundStyle(isOn ? Color.white : .secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit) .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)") .accessibilityIdentifier("dayToggle-\(day.rawValue)")
.accessibilityLabel(day.abbreviation) .accessibilityLabel(day.abbreviation)
.accessibilityAddTraits(isOn ? .isSelected : []) .accessibilityAddTraits(isOn ? .isSelected : [])
@@ -55,7 +48,15 @@ struct DayOfWeekPicker: View {
#Preview { #Preview {
@Previewable @State var days = Weekday.weekdays @Previewable @State var days = Weekday.weekdays
DayOfWeekPicker(days: $days) Form {
.padding() Section {
.background(Theme.background) 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 import SwiftUI
/// Two-step onboarding: a welcome screen, then the Screen Time permission /// Two-step onboarding using system styling: a welcome screen, then the
/// request. Onboarding only completes once authorization is approved the /// Screen Time permission request. Onboarding only completes once
/// app cannot block anything without it. /// authorization is approved the app cannot block anything without it.
struct OnboardingView: View { struct OnboardingView: View {
@Environment(ScreenTimeAuthorization.self) private var authorization @Environment(ScreenTimeAuthorization.self) private var authorization
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@@ -31,23 +31,18 @@ struct OnboardingView: View {
Spacer() Spacer()
footer footer
} }
.padding(.horizontal, 28) .padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundStyle(.white)
.background(Theme.background)
} }
private var welcome: some View { private var welcome: some View {
VStack(spacing: 18) { VStack(spacing: 16) {
Image(systemName: "scissors") Image(systemName: "scissors")
.font(.system(size: 56, weight: .semibold)) .font(.system(size: 56))
.foregroundStyle(Theme.accent) .foregroundStyle(.tint)
Text("Severed") 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.") 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(.secondary)
.foregroundStyle(Theme.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
} }
@@ -55,10 +50,10 @@ struct OnboardingView: View {
private var permission: some View { private var permission: some View {
VStack(spacing: 24) { VStack(spacing: 24) {
Image(systemName: "hourglass") Image(systemName: "hourglass")
.font(.system(size: 48, weight: .semibold)) .font(.system(size: 48))
.foregroundStyle(Theme.accent) .foregroundStyle(.tint)
Text("Allow Screen Time Access") Text("Allow Screen Time Access")
.font(.system(size: 28, weight: .bold)) .font(.title.bold())
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
bullet("shield.fill", "Severed uses Apple's Screen Time framework to block the apps you choose.") 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 { if authorization.status == .denied || authorization.lastRequestFailed {
VStack(spacing: 10) { VStack(spacing: 10) {
Text("Screen Time access was declined. Severed can't block apps without it.") Text("Screen Time access was declined. Severed can't block apps without it.")
.font(.system(size: 13)) .font(.footnote)
.foregroundStyle(Theme.destructive) .foregroundStyle(.red)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.accessibilityIdentifier("permissionDeniedLabel") .accessibilityIdentifier("permissionDeniedLabel")
Button("Open Settings") { Button("Open Settings") {
@@ -77,8 +72,6 @@ struct OnboardingView: View {
openURL(url) openURL(url)
} }
} }
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("openSettingsButton") .accessibilityIdentifier("openSettingsButton")
} }
} }
@@ -88,12 +81,11 @@ struct OnboardingView: View {
private func bullet(_ systemImage: String, _ text: String) -> some View { private func bullet(_ systemImage: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.system(size: 15, weight: .semibold)) .foregroundStyle(.tint)
.foregroundStyle(Theme.accent) .frame(width: 24)
.frame(width: 22)
Text(text) Text(text)
.font(.system(size: 15)) .font(.subheadline)
.foregroundStyle(Theme.textSecondary) .foregroundStyle(.secondary)
} }
} }
@@ -127,13 +119,10 @@ struct OnboardingView: View {
) -> some View { ) -> some View {
Button(action: action) { Button(action: action) {
Text(title) Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
} }
.buttonStyle(.plain) .buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier(identifier) .accessibilityIdentifier(identifier)
} }
} }

View File

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

View File

@@ -6,9 +6,8 @@
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// "New Rule": the three rule-type cards up top, then the preset gallery. /// "New Rule" as a plain list: a Rule Type section, then the preset sections.
/// Picking either one morphs the sheet into the editor; committing saves the /// Picking either pushes the editor; committing saves and closes the sheet.
/// rule and closes the sheet.
struct NewRuleSheet: View { struct NewRuleSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@@ -16,142 +15,97 @@ struct NewRuleSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
chooser List {
.toolbar(.hidden, for: .navigationBar) Section {
.navigationDestination(item: $pendingDraft) { draft in ForEach(RuleKind.allCases, id: \.self) { kind in
RuleEditorView( kindRow(kind)
mode: .create, }
draft: draft, } header: {
embedsInNavigationStack: true, Text("Rule Type").textCase(nil)
onCommit: { committed in
modelContext.insert(committed.makeRule())
dismiss()
}
)
} }
} ForEach(RulePresetSection.all) { section in
.background(Theme.background) Section {
} ForEach(section.presets) { preset in
presetRow(preset)
private var chooser: some View { }
VStack(spacing: 0) { } header: {
HStack { VStack(alignment: .leading, spacing: 2) {
CircleIconButton(systemImage: "xmark") { dismiss() } 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") .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) .navigationDestination(item: $pendingDraft) { draft in
.padding(.bottom, 30) 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 { Button {
pendingDraft = RuleDraft(kind: kind) pendingDraft = RuleDraft(kind: kind)
} label: { } label: {
VStack(spacing: 8) { HStack {
Image(systemName: kind.symbolName) Image(systemName: kind.symbolName)
.font(.system(size: 20, weight: .semibold)) .foregroundStyle(.tint)
.foregroundStyle(Theme.accent) .frame(width: 28)
.frame(height: 26) VStack(alignment: .leading, spacing: 2) {
Text(kind.displayName) Text(kind.displayName)
.font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.primary)
Text(kind.exampleText) Text(kind.exampleText)
.font(.system(size: 11)) .font(.caption)
.foregroundStyle(Theme.textTertiary) .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)") .accessibilityIdentifier("ruleKind-\(kind.rawValue)")
} }
private func presetSection(_ section: RulePresetSection) -> some View { private func presetRow(_ preset: RulePreset) -> 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 {
Button { Button {
pendingDraft = RuleDraft(preset: preset) pendingDraft = RuleDraft(preset: preset)
} label: { } label: {
VStack(alignment: .leading, spacing: 4) { HStack {
HStack { Image(systemName: preset.symbolName)
RuleIconPair(kind: .schedule) .foregroundStyle(.tint)
Spacer() .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() Spacer()
Image(systemName: preset.symbolName) Image(systemName: "plus.circle.fill")
.font(.system(size: 22, weight: .semibold)) .foregroundStyle(.tint)
.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))
}
} }
.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)") .accessibilityIdentifier("preset-\(preset.id)")
} }
} }

View File

@@ -6,10 +6,9 @@
import SwiftData import SwiftData
import SwiftUI import SwiftUI
/// The card-style rule summary shown when tapping a rule: icon pair, live /// Rule summary presented as a plain sheet: inline title with a live status
/// status, the key facts, and "Edit Rule" which morphs the sheet into the /// caption, the rule's facts as labeled rows, and "Edit Rule" which pushes
/// editor, as in the reference app. A hard-locked rule shows a lock notice /// the editor. A hard-locked rule shows a lock notice instead.
/// instead of the edit button.
struct RuleDetailSheet: View { struct RuleDetailSheet: View {
let rule: BlockingRule let rule: BlockingRule
@@ -19,12 +18,14 @@ struct RuleDetailSheet: View {
@State private var pendingDeletion = false @State private var pendingDeletion = false
var body: some View { var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { timeline in NavigationStack {
if isEditing { TimelineView(.periodic(from: .now, by: 30)) { timeline in
detailList(now: timeline.date)
}
.navigationDestination(isPresented: $isEditing) {
RuleEditorView( RuleEditorView(
mode: .edit(isEnabled: rule.isEnabled), mode: .edit(isEnabled: rule.isEnabled),
draft: RuleDraft(rule: rule), draft: RuleDraft(rule: rule),
onBack: { isEditing = false },
onCommit: { draft in onCommit: { draft in
draft.apply(to: rule) draft.apply(to: rule)
isEditing = false isEditing = false
@@ -39,11 +40,8 @@ struct RuleDetailSheet: View {
dismiss() dismiss()
} }
) )
} else {
detail(now: timeline.date)
} }
} }
.background(Theme.background)
.onDisappear { .onDisappear {
if pendingDeletion { if pendingDeletion {
modelContext.delete(rule) 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) let status = rule.status(at: now)
return VStack(spacing: 0) { return List {
HStack { Section {
CircleIconButton(systemImage: "xmark") { dismiss() } detailRows
.accessibilityIdentifier("closeDetailButton")
Spacer()
} }
.padding(.horizontal, 20) Section {
.padding(.top, 18) if RulePolicy.canEdit(rule, at: now) {
Button {
ScrollView { isEditing = true
VStack(spacing: 24) { } label: {
RuleIconPair(kind: rule.kind, isActive: status.isActive) Label("Edit Rule", systemImage: "pencil")
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")
} }
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 { private var detailRows: some View {
VStack(spacing: 0) { switch rule.kind {
switch rule.kind { case .schedule:
case .schedule: row("During this time", rule.schedule.timeRangeLabel)
row("During this time", rule.schedule.timeRangeLabel) row("On these days", rule.days.summary)
divider row(rule.selectionMode.displayName, appCountLabel)
row("On these days", rule.days.summary) row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
row(rule.selectionMode.displayName, appCountLabel) case .timeLimit:
divider row("When I use", appCountLabel)
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") row("For this long", "\(rule.dailyLimitMinutes)m daily")
divider row("On these days", rule.days.summary)
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Then block until", "Tomorrow")
case .timeLimit: row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("When I use", appCountLabel) row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
divider case .openLimit:
row("For this long", "\(rule.dailyLimitMinutes)m daily") row("When I open", appCountLabel)
divider row("More than", "\(rule.maxOpens) opens daily")
row("On these days", rule.days.summary) row("On these days", rule.days.summary)
divider row("Then block until", "Tomorrow")
row("Then block until", "Tomorrow") row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
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")
}
} }
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
} }
private var appCountLabel: String { private var appCountLabel: String {
@@ -134,51 +127,8 @@ struct RuleDetailSheet: View {
} }
private func row(_ label: String, _ value: String) -> some View { private func row(_ label: String, _ value: String) -> some View {
HStack { LabeledContent(label, value: value)
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))
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice") .accessibilityIdentifier("detailRow-\(label)")
}
} }
} }

View File

@@ -5,9 +5,10 @@
import SwiftUI import SwiftUI
/// The rule editor used both for creation (Hold to Commit) and editing /// The rule editor as a plain Form, always pushed inside a NavigationStack
/// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind, /// (New Rule flow and detail editing both push it). Creation commits with a
/// mirroring the reference app's "In the Zone" and "Time Keeper" editors. /// prominent "Add Rule" button; editing uses a toolbar Done plus red
/// Disable/Delete rows.
struct RuleEditorView: View { struct RuleEditorView: View {
enum Mode: Equatable { enum Mode: Equatable {
case create case create
@@ -16,11 +17,6 @@ struct RuleEditorView: View {
let mode: Mode let mode: Mode
@State var draft: RuleDraft @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 onCommit: (RuleDraft) -> Void
var onToggleEnabled: (() -> Void)? var onToggleEnabled: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
@@ -30,54 +26,62 @@ struct RuleEditorView: View {
@State private var renameText = "" @State private var renameText = ""
var body: some View { var body: some View {
if embedsInNavigationStack { Form {
core sections
.navigationBarTitleDisplayMode(.inline) if case .edit(let isEnabled) = mode {
.toolbarBackground(.hidden, for: .navigationBar) Section {
.toolbar { Button(isEnabled ? "Disable Rule" : "Enable Rule") {
ToolbarItem(placement: .principal) { onToggleEnabled?()
Text(draft.name)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle")
} }
ToolbarItem(placement: .topBarTrailing) { .foregroundStyle(.red)
Button { .accessibilityIdentifier("toggleEnabledButton")
renameText = draft.name
showingRename = true
} label: {
Image(systemName: "pencil")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
}
.accessibilityIdentifier("renameButton")
}
}
} else {
core
}
}
private var core: some View { Button("Delete Rule", role: .destructive) {
VStack(spacing: 0) { onDelete?()
if !embedsInNavigationStack { }
header .accessibilityIdentifier("deleteRuleButton")
}
ScrollView {
VStack(spacing: 18) {
sections
} }
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 24)
} }
footer
.padding(.horizontal, 20)
.padding(.bottom, 16)
} }
.foregroundStyle(.white) .navigationBarTitleDisplayMode(.inline)
.background(Theme.background) .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) { .alert("Rule Name", isPresented: $showingRename) {
TextField("Name", text: $renameText) TextField("Name", text: $renameText)
Button("OK") { Button("OK") {
@@ -90,256 +94,159 @@ struct RuleEditorView: View {
} }
.sheet(isPresented: $showingAppPicker) { .sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft) 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 // MARK: - Sections
@ViewBuilder @ViewBuilder
private var sections: some View { private var sections: some View {
switch draft.kind { switch draft.kind {
case .schedule: case .schedule:
timeWindowSection Section {
DayOfWeekPicker(days: $draft.days) DatePicker(
appsSection(header: "Apps are blocked") "From",
hardModeSection selection: timeBinding($draft.startMinutes),
adultContentSection 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: case .timeLimit:
appsSection(header: "When I use") Section {
budgetSection( selectedAppsRow
title: "For this long", } header: {
value: "\(draft.dailyLimitMinutes)m", Text("When I use").textCase(nil)
stepperID: "dailyLimitStepper", }
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) }, Section {
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) } budgetRow(
) value: "\(draft.dailyLimitMinutes)m",
DayOfWeekPicker(days: $draft.days) 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 blockUntilSection
hardModeSection toggleSections
adultContentSection
case .openLimit: case .openLimit:
appsSection(header: "When I open") Section {
budgetSection( selectedAppsRow
title: "More than", } header: {
value: "\(draft.maxOpens) opens", Text("When I open").textCase(nil)
stepperID: "maxOpensStepper", }
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) }, Section {
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) } budgetRow(
) value: "\(draft.maxOpens) opens",
DayOfWeekPicker(days: $draft.days) 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 blockUntilSection
hardModeSection toggleSections
adultContentSection
} }
} }
private var timeWindowSection: some View { private var daysSection: some View {
VStack(alignment: .leading, spacing: 10) { Section {
sectionHeader(systemImage: "calendar", title: "During this time") DayOfWeekPicker(days: $draft.days)
VStack(spacing: 0) { } header: {
timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker") HStack {
Divider().background(.white.opacity(0.06)).padding(.leading, 16) Text("On these days").textCase(nil)
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker") 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 { private var blockUntilSection: some View {
HStack { Section {
Text(label) LabeledContent("Until", value: "Tomorrow")
.font(.system(size: 15, weight: .medium)) } header: {
Spacer() Text("Then block app").textCase(nil)
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute)
.labelsHidden()
.colorScheme(.dark)
.accessibilityIdentifier(identifier)
} }
.padding(.horizontal, 16)
.padding(.vertical, 8)
} }
private func appsSection(header: String) -> some View { @ViewBuilder
VStack(alignment: .leading, spacing: 10) { private var toggleSections: some View {
sectionHeader(systemImage: "shield.fill", title: header) Section {
Button { HStack {
showingAppPicker = true Text("Hard Mode")
} label: { Spacer()
HStack { Toggle("", isOn: $draft.hardMode)
Text("Selected Apps") .labelsHidden()
.font(.system(size: 15, weight: .medium)) .accessibilityIdentifier("hardModeToggle")
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))
} }
.buttonStyle(.plain) } footer: {
.accessibilityIdentifier("selectedAppsRow") 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( private var selectedAppsRow: some View {
title: String, 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, value: String,
stepperID: String, stepperID: String,
onIncrement: @escaping () -> Void, onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void onDecrement: @escaping () -> Void
) -> some View { ) -> some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { Text("Daily")
Text(title)
.font(.system(size: 15, weight: .medium))
Text("Daily")
.font(.system(size: 12))
.foregroundStyle(Theme.textTertiary)
}
Spacer() Spacer()
Text(value) Text(value)
.font(.system(size: 15, weight: .semibold)) .foregroundStyle(.secondary)
.accessibilityIdentifier("\(stepperID)Value") .accessibilityIdentifier("\(stepperID)Value")
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement) Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
.labelsHidden() .labelsHidden()
.accessibilityIdentifier(stepperID) .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> { 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) XCTAssertTrue(app.staticTexts["During this time"].exists)
// Hold to commit saves the rule and returns home. // 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() app.buttons["ruleCard-In the Zone"].waitToAppear()
XCTAssertFalse(app.element("emptyRulesCard").exists) XCTAssertFalse(app.element("emptyRulesCard").exists)
} }
@@ -35,7 +35,7 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["preset-work-time"].waitToAppear().tap() app.buttons["preset-work-time"].waitToAppear().tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time") 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() app.buttons["ruleCard-Work Time"].waitToAppear()
} }
@@ -53,7 +53,7 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["OK"].tap() app.buttons["OK"].tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus") 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() app.buttons["ruleCard-My Focus"].waitToAppear()
} }
@@ -81,7 +81,7 @@ final class RuleCreationUITests: XCTestCase {
app.steppers["dailyLimitStepper"].buttons["Increment"].tap() app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m") 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() app.buttons["ruleCard-Time Keeper"].waitToAppear()
} }
@@ -91,7 +91,7 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.switches["adultContentToggle"].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() app.buttons["ruleCard-In the Zone"].waitToAppear().tap()
let row = app.element("detailRow-Adult websites").waitToAppear() 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."), - Onboarding flow, paywall ("You know Opal works. Make it permanent."),
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?" Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
friction screen), notification nudges ("Complete Your Setup"). 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.