fix: editor usability and visual polish from UI scan
- Rule name is now an inline text field at the top of the editor; the pencil/rename button and alert are gone (it was unclear what the edit button did). Names are sanitized on commit: trimmed, falling back to the kind's default when emptied - Day-of-week toggles fill the full row width with equal cells and >= 44pt tap targets - 'Add Rule' commit button sits on a bar-material bottom inset so form content no longer collides with it while scrolling (found via computer-use scan; verified clean in a follow-up scan) - Tests: 95 passing — new specs for the inline rename flow, day-toggle geometry, and name sanitization; three tests now scroll to reach bottom-of-form controls, matching real user behavior - Spec §6 updated for the editor changes
This commit is contained in:
@@ -83,6 +83,15 @@ struct RuleDraft: Hashable {
|
|||||||
return rule
|
return rule
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trims the name and falls back to the kind's default when it is empty,
|
||||||
|
/// so a cleared name field can never produce an unnamed rule.
|
||||||
|
func sanitized() -> RuleDraft {
|
||||||
|
var copy = self
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
var schedule: RuleSchedule {
|
var schedule: RuleSchedule {
|
||||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ struct DayOfWeekPicker: View {
|
|||||||
@Binding var days: Set<Weekday>
|
@Binding var days: Set<Weekday>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 0) {
|
||||||
ForEach(Weekday.displayOrder, id: \.self) { day in
|
ForEach(Weekday.displayOrder, id: \.self) { day in
|
||||||
dayToggle(day)
|
dayToggle(day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayToggle(_ day: Weekday) -> some View {
|
private func dayToggle(_ day: Weekday) -> some View {
|
||||||
@@ -31,13 +30,16 @@ struct DayOfWeekPicker: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Text(day.shortLabel)
|
Text(day.shortLabel)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(isOn ? Color.white : .secondary)
|
.foregroundStyle(isOn ? Color.white : Color.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(width: 38, height: 38)
|
||||||
.aspectRatio(1, contentMode: .fit)
|
|
||||||
.background(
|
.background(
|
||||||
isOn ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.tertiarySystemFill)),
|
isOn ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.tertiarySystemFill)),
|
||||||
in: Circle()
|
in: Circle()
|
||||||
)
|
)
|
||||||
|
// Each cell takes an equal share of the row and at least a
|
||||||
|
// 44pt-tall hit area, so the whole strip is comfortably tappable.
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
|
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ struct RuleEditorView: View {
|
|||||||
var onToggleEnabled: (() -> Void)?
|
var onToggleEnabled: (() -> Void)?
|
||||||
var onDelete: (() -> Void)?
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
@State private var showingRename = false
|
|
||||||
@State private var showingAppPicker = false
|
@State private var showingAppPicker = false
|
||||||
@State private var renameText = ""
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
nameSection
|
||||||
sections
|
sections
|
||||||
if case .edit(let isEnabled) = mode {
|
if case .edit(let isEnabled) = mode {
|
||||||
Section {
|
Section {
|
||||||
@@ -46,22 +45,15 @@ struct RuleEditorView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
Text(draft.name)
|
Text(draft.sanitized().name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.accessibilityIdentifier("ruleEditorTitle")
|
.accessibilityIdentifier("ruleEditorTitle")
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Button("Rename", systemImage: "pencil") {
|
|
||||||
renameText = draft.name
|
|
||||||
showingRename = true
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("renameButton")
|
|
||||||
}
|
|
||||||
if case .edit = mode {
|
if case .edit = mode {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
onCommit(draft)
|
onCommit(draft.sanitized())
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("doneButton")
|
.accessibilityIdentifier("doneButton")
|
||||||
}
|
}
|
||||||
@@ -70,28 +62,22 @@ struct RuleEditorView: View {
|
|||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
if mode == .create {
|
if mode == .create {
|
||||||
Button {
|
Button {
|
||||||
onCommit(draft)
|
onCommit(draft.sanitized())
|
||||||
} label: {
|
} label: {
|
||||||
Text("Add Rule")
|
Text("Add Rule")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
.accessibilityIdentifier("commitRuleButton")
|
.accessibilityIdentifier("commitRuleButton")
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
// Bar material so scrolling form content doesn't collide
|
||||||
|
// with the floating button.
|
||||||
|
.background(.bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Rule Name", isPresented: $showingRename) {
|
|
||||||
TextField("Name", text: $renameText)
|
|
||||||
Button("OK") {
|
|
||||||
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
|
|
||||||
if !trimmed.isEmpty {
|
|
||||||
draft.name = trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingAppPicker) {
|
.sheet(isPresented: $showingAppPicker) {
|
||||||
AppSelectionSheet(draft: $draft)
|
AppSelectionSheet(draft: $draft)
|
||||||
}
|
}
|
||||||
@@ -99,6 +85,16 @@ struct RuleEditorView: View {
|
|||||||
|
|
||||||
// MARK: - Sections
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private var nameSection: some View {
|
||||||
|
Section {
|
||||||
|
TextField("Rule Name", text: $draft.name)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.accessibilityIdentifier("ruleNameField")
|
||||||
|
} header: {
|
||||||
|
Text("Name").textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var sections: some View {
|
private var sections: some View {
|
||||||
switch draft.kind {
|
switch draft.kind {
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ struct RuleDraftTests {
|
|||||||
#expect(rule.hardMode)
|
#expect(rule.hardMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Sanitizing trims whitespace and falls back to the kind default")
|
||||||
|
func sanitizedName() {
|
||||||
|
var draft = RuleDraft(kind: .schedule)
|
||||||
|
draft.name = " Deep Work "
|
||||||
|
#expect(draft.sanitized().name == "Deep Work")
|
||||||
|
|
||||||
|
draft.name = " "
|
||||||
|
#expect(draft.sanitized().name == "In the Zone")
|
||||||
|
|
||||||
|
var limitDraft = RuleDraft(kind: .timeLimit)
|
||||||
|
limitDraft.name = ""
|
||||||
|
#expect(limitDraft.sanitized().name == "Time Keeper")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Preset drafts copy the preset's schedule")
|
@Test("Preset drafts copy the preset's schedule")
|
||||||
func presetDraft() throws {
|
func presetDraft() throws {
|
||||||
let preset = try #require(
|
let preset = try #require(
|
||||||
|
|||||||
@@ -44,19 +44,38 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||||
|
|
||||||
app.buttons["renameButton"].waitToAppear().tap()
|
// The rule name is an inline text field at the top of the editor —
|
||||||
let nameField = app.textFields.firstMatch.waitToAppear()
|
// no separate edit/rename button.
|
||||||
nameField.tap()
|
XCTAssertFalse(app.buttons["renameButton"].exists)
|
||||||
// Clear the prefilled name, then type the new one.
|
let nameField = app.textFields["ruleNameField"].waitToAppear()
|
||||||
|
// Tap at the trailing edge so the cursor lands after the last character.
|
||||||
|
nameField.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap()
|
||||||
let deletions = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 24)
|
let deletions = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 24)
|
||||||
nameField.typeText(deletions + "My Focus")
|
nameField.typeText(deletions + "My Focus\n")
|
||||||
app.buttons["OK"].tap()
|
|
||||||
|
|
||||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus")
|
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus")
|
||||||
app.buttons["commitRuleButton"].waitToAppear().tap()
|
app.buttons["commitRuleButton"].waitToAppear().tap()
|
||||||
app.buttons["ruleCard-My Focus"].waitToAppear()
|
app.buttons["ruleCard-My Focus"].waitToAppear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDayTogglesFillRowAndHaveLargeTapTargets() throws {
|
||||||
|
let app = XCUIApplication.launchSevered()
|
||||||
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
|
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||||
|
|
||||||
|
let first = app.buttons["dayToggle-1"].waitToAppear()
|
||||||
|
let last = app.buttons["dayToggle-7"].waitToAppear()
|
||||||
|
let span = last.frame.maxX - first.frame.minX
|
||||||
|
XCTAssertGreaterThan(
|
||||||
|
span, app.frame.width * 0.75,
|
||||||
|
"Day toggles should span the full row width, got \(span) of \(app.frame.width)"
|
||||||
|
)
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
first.frame.height, 44,
|
||||||
|
"Day toggle tap target should be at least 44pt tall"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testDayTogglesUpdateSummary() throws {
|
func testDayTogglesUpdateSummary() throws {
|
||||||
let app = XCUIApplication.launchSevered()
|
let app = XCUIApplication.launchSevered()
|
||||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
@@ -90,6 +109,10 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||||
|
|
||||||
|
// The toggle lives at the bottom of the form; scroll it clear of the
|
||||||
|
// commit bar before tapping.
|
||||||
|
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||||
|
app.swipeUp()
|
||||||
app.switches["adultContentToggle"].waitToAppear().tap()
|
app.switches["adultContentToggle"].waitToAppear().tap()
|
||||||
app.buttons["commitRuleButton"].waitToAppear().tap()
|
app.buttons["commitRuleButton"].waitToAppear().tap()
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ final class RuleManagementUITests: XCTestCase {
|
|||||||
|
|
||||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||||
|
// The disable/delete rows sit at the bottom of the form.
|
||||||
|
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||||
|
app.swipeUp()
|
||||||
app.buttons["toggleEnabledButton"].waitToAppear().tap()
|
app.buttons["toggleEnabledButton"].waitToAppear().tap()
|
||||||
|
|
||||||
// The detail caption now reports the rule as disabled.
|
// The detail caption now reports the rule as disabled.
|
||||||
@@ -63,6 +66,9 @@ final class RuleManagementUITests: XCTestCase {
|
|||||||
|
|
||||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||||
|
// The disable/delete rows sit at the bottom of the form.
|
||||||
|
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||||
|
app.swipeUp()
|
||||||
app.buttons["deleteRuleButton"].waitToAppear().tap()
|
app.buttons["deleteRuleButton"].waitToAppear().tap()
|
||||||
|
|
||||||
app.buttons["newRuleButton"].waitToAppear()
|
app.buttons["newRuleButton"].waitToAppear()
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ reference for *what* the feature does; presentation now maps as follows:
|
|||||||
| 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 |
|
| 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 |
|
| 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:)` |
|
| 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 |
|
| Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Create commits with a prominent **"Add Rule"** button on a bar-material inset (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) |
|
| Onboarding / app picker | System styling, `.borderedProminent` buttons, default color scheme (no forced dark, default accent) |
|
||||||
|
|
||||||
Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,
|
Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,
|
||||||
|
|||||||
Reference in New Issue
Block a user