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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user