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() 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)
rulesSection(now: timeline.date)
} }
.padding(.horizontal, 20)
.padding(.top, 8)
}
}
.background(Theme.background)
.navigationTitle("Apps") .navigationTitle("Apps")
.toolbarColorScheme(.dark, for: .navigationBar) .toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("New Rule", systemImage: "plus") {
showingNewRule = true
}
.accessibilityIdentifier("newRuleButton")
}
}
} }
.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 {
} }
} }
private func ruleList(now: Date) -> some View {
List {
blockedSection(now: now)
rulesSection(now: now)
}
}
// MARK: - Blocked Apps // MARK: - Blocked Apps
@ViewBuilder @ViewBuilder
private func blockedAppsSection(now: Date) -> some View { private func blockedSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive } let blocking = rules.filter { $0.status(at: now).isActive }
VStack(alignment: .leading, spacing: 14) { Section {
Text("Blocked Apps")
.font(.system(size: 17, weight: .semibold))
if blocking.isEmpty { if blocking.isEmpty {
Text("Nothing is blocked right now.") Text("Nothing is blocked right now.")
.font(.system(size: 14)) .foregroundStyle(.secondary)
.foregroundStyle(Theme.textTertiary)
.accessibilityIdentifier("nothingBlockedLabel") .accessibilityIdentifier("nothingBlockedLabel")
} else { } else {
HStack(spacing: 16) {
ForEach(blocking) { rule in ForEach(blocking) { rule in
blockedTile(for: rule, now: now) blockedRow(for: rule, now: now)
}
} }
} }
} header: {
Text("Blocked Apps").textCase(nil)
} }
} }
private func blockedTile(for rule: BlockingRule, now: Date) -> some View { 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) {
HStack(spacing: 14) {
ForEach(rules) { rule in ForEach(rules) { rule in
Button { ruleRow(for: rule, now: now)
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 {
Text("On these days:")
.font(.system(size: 15, weight: .semibold))
Spacer()
Text(days.summary)
.font(.system(size: 14))
.foregroundStyle(Theme.textSecondary)
}
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(Weekday.displayOrder, id: \.self) { day in ForEach(Weekday.displayOrder, id: \.self) { day in
dayToggle(day) dayToggle(day)
} }
} }
} .padding(.vertical, 4)
.padding(16)
.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
Form {
Section {
DayOfWeekPicker(days: $days) DayOfWeekPicker(days: $days)
.padding() } header: {
.background(Theme.background) 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 {
NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "chevron.left") { dismiss() }
.accessibilityIdentifier("selectionBackButton")
Spacer()
CircleIconButton(systemImage: "checkmark", tint: .black) { save() }
.background(Theme.accent, in: Circle())
.accessibilityIdentifier("confirmSelectionButton")
}
.overlay(
Text("Selected")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
)
.padding(.horizontal, 20)
.padding(.top, 18)
Picker("Mode", selection: $mode) { Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode) Text(mode.displayName).tag(mode)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.padding(.horizontal, 20) .padding(.horizontal)
.padding(.top, 16) .padding(.vertical, 8)
.accessibilityIdentifier("selectionModePicker") .accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection) FamilyActivityPicker(selection: $selection)
.padding(.top, 4) }
.navigationTitle("Selected")
VStack(spacing: 10) { .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier("selectionBackButton")
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
save()
}
.accessibilityIdentifier("confirmSelectionButton")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary) Text(selectionSummary)
.font(.system(size: 14)) .font(.footnote)
.foregroundStyle(Theme.textSecondary) .foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel") .accessibilityIdentifier("selectionCountLabel")
Button { Button {
save() save()
} label: { } label: {
Text("Save") Text("Save")
.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("saveSelectionButton") .accessibilityIdentifier("saveSelectionButton")
} }
.padding(.horizontal, 20) .padding(.horizontal)
.padding(.bottom, 16) .padding(.bottom, 8)
}
} }
.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,13 +15,44 @@ struct NewRuleSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
chooser List {
.toolbar(.hidden, for: .navigationBar) Section {
ForEach(RuleKind.allCases, id: \.self) { kind in
kindRow(kind)
}
} header: {
Text("Rule Type").textCase(nil)
}
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")
}
}
.navigationDestination(item: $pendingDraft) { draft in .navigationDestination(item: $pendingDraft) { draft in
RuleEditorView( RuleEditorView(
mode: .create, mode: .create,
draft: draft, draft: draft,
embedsInNavigationStack: true,
onCommit: { committed in onCommit: { committed in
modelContext.insert(committed.makeRule()) modelContext.insert(committed.makeRule())
dismiss() dismiss()
@@ -30,128 +60,52 @@ struct NewRuleSheet: View {
) )
} }
} }
.background(Theme.background)
} }
private var chooser: some View { private func kindRow(_ kind: RuleKind) -> some View {
VStack(spacing: 0) {
HStack {
CircleIconButton(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)
}
}
.foregroundStyle(.white)
}
private func kindCard(_ 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 {
RuleIconPair(kind: .schedule)
Spacer()
}
Spacer()
Image(systemName: preset.symbolName) Image(systemName: preset.symbolName)
.font(.system(size: 22, weight: .semibold)) .foregroundStyle(.tint)
.foregroundStyle(.white.opacity(0.8)) .frame(width: 28)
.padding(.bottom, 8) VStack(alignment: .leading, spacing: 2) {
Text(preset.schedule.timeRangeLabel)
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
Text(preset.name) Text(preset.name)
.font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.primary)
HStack { Text("\(preset.schedule.timeRangeLabel) · \(preset.days.summary)")
Text("Block") .font(.caption)
.font(.system(size: 12)) .foregroundStyle(Color.secondary)
.foregroundStyle(Theme.textSecondary) }
Spacer() Spacer()
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
.font(.system(size: 22)) .foregroundStyle(.tint)
.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 {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in TimelineView(.periodic(from: .now, by: 30)) { timeline in
if isEditing { 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,134 +49,86 @@ 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() }
.accessibilityIdentifier("closeDetailButton")
Spacer()
}
.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")
}
detailRows detailRows
} }
.padding(.horizontal, 20) Section {
.padding(.top, 8) if RulePolicy.canEdit(rule, at: now) {
} Button {
isEditing = true
footer(now: now) } label: {
.padding(.horizontal, 20) Label("Edit Rule", systemImage: "pencil")
.padding(.bottom, 16) }
} .accessibilityIdentifier("editRuleButton")
.foregroundStyle(.white) } 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")
}
}
}
} }
@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)
divider
row("On these days", rule.days.summary) row("On these days", rule.days.summary)
divider
row(rule.selectionMode.displayName, appCountLabel) row(rule.selectionMode.displayName, appCountLabel)
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit: case .timeLimit:
row("When I use", appCountLabel) row("When I use", appCountLabel)
divider
row("For this long", "\(rule.dailyLimitMinutes)m daily") row("For this long", "\(rule.dailyLimitMinutes)m daily")
divider
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")
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit: case .openLimit:
row("When I open", appCountLabel) row("When I open", appCountLabel)
divider
row("More than", "\(rule.maxOpens) opens daily") row("More than", "\(rule.maxOpens) opens daily")
divider
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")
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes") row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
} }
} }
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var appCountLabel: String { private var appCountLabel: String {
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps" rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
} }
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) .accessibilityElement(children: .combine)
.accessibilityIdentifier("detailRow-\(label)") .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)
.accessibilityIdentifier("hardModeLockedNotice")
}
}
} }

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
if case .edit(let isEnabled) = mode {
Section {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
.foregroundStyle(.red)
.accessibilityIdentifier("toggleEnabledButton")
Button("Delete Rule", role: .destructive) {
onDelete?()
}
.accessibilityIdentifier("deleteRuleButton")
}
}
}
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
Text(draft.name) Text(draft.name)
.font(.system(size: 18, weight: .semibold)) .font(.headline)
.foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle") .accessibilityIdentifier("ruleEditorTitle")
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button("Rename", systemImage: "pencil") {
renameText = draft.name renameText = draft.name
showingRename = true showingRename = true
} label: {
Image(systemName: "pencil")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
} }
.accessibilityIdentifier("renameButton") .accessibilityIdentifier("renameButton")
} }
if case .edit = mode {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onCommit(draft)
} }
} else { .accessibilityIdentifier("doneButton")
core
} }
} }
private var core: some View {
VStack(spacing: 0) {
if !embedsInNavigationStack {
header
} }
ScrollView { .safeAreaInset(edge: .bottom) {
VStack(spacing: 18) { if mode == .create {
sections Button {
onCommit(draft)
} label: {
Text("Add Rule")
.frame(maxWidth: .infinity)
} }
.padding(.horizontal, 20) .buttonStyle(.borderedProminent)
.padding(.top, 10) .controlSize(.large)
.padding(.bottom, 24) .padding(.horizontal)
.padding(.bottom, 8)
.accessibilityIdentifier("commitRuleButton")
} }
footer
.padding(.horizontal, 20)
.padding(.bottom, 16)
} }
.foregroundStyle(.white)
.background(Theme.background)
.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: {
Text("When I use").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.dailyLimitMinutes)m", value: "\(draft.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper", stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) }, onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) } onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
) )
DayOfWeekPicker(days: $draft.days) } 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: {
Text("When I open").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.maxOpens) opens", value: "\(draft.maxOpens) opens",
stepperID: "maxOpensStepper", stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) }, onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) } onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
) )
DayOfWeekPicker(days: $draft.days) } 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")
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker")
}
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
}
private func timeRow(label: String, minutes: Binding<Int>, identifier: String) -> some View {
HStack { HStack {
Text(label) Text("On these days").textCase(nil)
.font(.system(size: 15, weight: .medium))
Spacer() Spacer()
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute) Text(draft.days.summary).textCase(nil)
.labelsHidden() }
.colorScheme(.dark)
.accessibilityIdentifier(identifier)
} }
.padding(.horizontal, 16)
.padding(.vertical, 8)
} }
private func appsSection(header: String) -> some View { private var blockUntilSection: some View {
VStack(alignment: .leading, spacing: 10) { Section {
sectionHeader(systemImage: "shield.fill", title: header) LabeledContent("Until", value: "Tomorrow")
} header: {
Text("Then block app").textCase(nil)
}
}
@ViewBuilder
private var toggleSections: some View {
Section {
HStack {
Text("Hard Mode")
Spacer()
Toggle("", isOn: $draft.hardMode)
.labelsHidden()
.accessibilityIdentifier("hardModeToggle")
}
} 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 var selectedAppsRow: some View {
Button { Button {
showingAppPicker = true showingAppPicker = true
} label: { } label: {
HStack { HStack {
Text("Selected Apps") Text("Selected Apps")
.font(.system(size: 15, weight: .medium)) .foregroundStyle(Color.primary)
Spacer() Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps") Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
.font(.system(size: 15)) .foregroundStyle(Color.secondary)
.foregroundStyle(Theme.textSecondary)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(Theme.textTertiary) .foregroundStyle(.tertiary)
} }
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
} }
.buttonStyle(.plain)
.accessibilityIdentifier("selectedAppsRow") .accessibilityIdentifier("selectedAppsRow")
} }
}
private func budgetSection( private func budgetRow(
title: String,
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(title)
.font(.system(size: 15, weight: .medium))
Text("Daily") 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.