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

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