diff --git a/OpenAppLock/Assets.xcassets/AppLogo.imageset/Contents.json b/OpenAppLock/Assets.xcassets/AppLogo.imageset/Contents.json
new file mode 100644
index 0000000..01e4b29
--- /dev/null
+++ b/OpenAppLock/Assets.xcassets/AppLogo.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images" : [
+ {
+ "filename" : "applogo-light.svg",
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "applogo-dark.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/OpenAppLock/Assets.xcassets/AppLogo.imageset/applogo-dark.svg b/OpenAppLock/Assets.xcassets/AppLogo.imageset/applogo-dark.svg
new file mode 100644
index 0000000..bfd8634
--- /dev/null
+++ b/OpenAppLock/Assets.xcassets/AppLogo.imageset/applogo-dark.svg
@@ -0,0 +1,62 @@
+
\ No newline at end of file
diff --git a/OpenAppLock/Assets.xcassets/AppLogo.imageset/applogo-light.svg b/OpenAppLock/Assets.xcassets/AppLogo.imageset/applogo-light.svg
new file mode 100644
index 0000000..a76b672
--- /dev/null
+++ b/OpenAppLock/Assets.xcassets/AppLogo.imageset/applogo-light.svg
@@ -0,0 +1,62 @@
+
\ No newline at end of file
diff --git a/OpenAppLock/Views/AppLists/AppListLibraryView.swift b/OpenAppLock/Views/AppLists/AppListLibraryView.swift
index 93db55b..3f9443c 100644
--- a/OpenAppLock/Views/AppLists/AppListLibraryView.swift
+++ b/OpenAppLock/Views/AppLists/AppListLibraryView.swift
@@ -40,36 +40,48 @@ struct AppListLibraryView: View {
}
var body: some View {
- List {
- Section {
- if lists.isEmpty {
- Text("No app lists yet. Create one to choose which apps a rule affects.")
- .foregroundStyle(.secondary)
+ Group {
+ if lists.isEmpty {
+ ContentUnavailableView {
+ Label("No App Lists", systemImage: "square.stack.3d.up")
+ } description: {
+ // Identifier on the description so it stays a distinct
+ // element instead of collapsing onto the action button.
+ Text("Create one to choose which apps a rule affects.")
.accessibilityIdentifier("emptyAppListsLabel")
- } else {
- ForEach(lists) { list in
- listRow(list)
+ } actions: {
+ Button("New List") {
+ creatingList = true
}
+ .accessibilityIdentifier("newAppListButton")
}
- } header: {
- Text("Your App Lists").textCase(nil)
- } footer: {
- if listsLocked {
- Label(
- "Hard Mode is on — app lists are locked until the block ends.",
- systemImage: "lock.fill"
- )
- .accessibilityElement(children: .combine)
- .accessibilityIdentifier("appListsLockedNotice")
- }
- }
- Section {
- Button {
- creatingList = true
- } label: {
- Label("New List", systemImage: "plus")
+ } else {
+ List {
+ Section {
+ ForEach(lists) { list in
+ listRow(list)
+ }
+ } header: {
+ Text("Your App Lists").textCase(nil)
+ } footer: {
+ if listsLocked {
+ Label(
+ "Hard Mode is on — app lists are locked until the block ends.",
+ systemImage: "lock.fill"
+ )
+ .accessibilityElement(children: .combine)
+ .accessibilityIdentifier("appListsLockedNotice")
+ }
+ }
+ Section {
+ Button {
+ creatingList = true
+ } label: {
+ Label("New List", systemImage: "plus")
+ }
+ .accessibilityIdentifier("newAppListButton")
+ }
}
- .accessibilityIdentifier("newAppListButton")
}
}
.navigationDestination(isPresented: $creatingList) {
@@ -90,49 +102,75 @@ struct AppListLibraryView: View {
}
}
+ @ViewBuilder
private func listRow(_ list: AppList) -> some View {
- HStack {
- Button {
- if isPicking {
+ if isPicking {
+ // Picker mode: tapping the row selects the list, so it keeps a
+ // distinct trailing Edit affordance to open the list for editing.
+ HStack {
+ Button {
selection?.wrappedValue = list
onPick?()
- } else if !listsLocked {
- editingList = list
- }
- } label: {
- HStack {
- if isPicking {
+ } label: {
+ HStack {
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
)
.frame(width: 28)
+ rowText(list)
}
- VStack(alignment: .leading, spacing: 2) {
- Text(list.name)
- .foregroundStyle(Color.primary)
- Text(list.appCountLabel)
- .font(.caption)
- .foregroundStyle(Color.secondary)
+ }
+ .accessibilityIdentifier("appListRow-\(list.name)")
+ Spacer()
+ if !listsLocked {
+ Button("Edit") {
+ editingList = list
}
+ .font(.subheadline)
+ .accessibilityIdentifier("editAppListButton-\(list.name)")
}
}
- .accessibilityIdentifier("appListRow-\(list.name)")
- Spacer()
- if !listsLocked {
- Button("Edit") {
- editingList = list
+ .buttonStyle(.borderless)
+ .swipeActions { deleteAction(list) }
+ } else {
+ // Management mode: the whole row taps to edit (a full-width target),
+ // with a disclosure chevron instead of a redundant Edit button.
+ Button {
+ if !listsLocked { editingList = list }
+ } label: {
+ HStack {
+ rowText(list)
+ Spacer()
+ if !listsLocked {
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(Color(.tertiaryLabel))
+ }
}
- .font(.subheadline)
- .accessibilityIdentifier("editAppListButton-\(list.name)")
+ .contentShape(Rectangle())
}
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("appListRow-\(list.name)")
+ .swipeActions { deleteAction(list) }
}
- .buttonStyle(.borderless)
- .swipeActions {
- if !listsLocked {
- Button("Delete", role: .destructive) {
- delete(list)
- }
+ }
+
+ private func rowText(_ list: AppList) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(list.name)
+ .foregroundStyle(Color.primary)
+ Text(list.appCountLabel)
+ .font(.caption)
+ .foregroundStyle(Color.secondary)
+ }
+ }
+
+ @ViewBuilder
+ private func deleteAction(_ list: AppList) -> some View {
+ if !listsLocked {
+ Button("Delete", role: .destructive) {
+ delete(list)
}
}
}
diff --git a/OpenAppLock/Views/Components/DayOfWeekPicker.swift b/OpenAppLock/Views/Components/DayOfWeekPicker.swift
index 19d8d45..ea6d0a3 100644
--- a/OpenAppLock/Views/Components/DayOfWeekPicker.swift
+++ b/OpenAppLock/Views/Components/DayOfWeekPicker.swift
@@ -30,6 +30,11 @@ struct DayOfWeekPicker: View {
} label: {
Text(day.shortLabel)
.font(.subheadline.weight(.semibold))
+ // Keep the circle a fixed size so all seven always fit one row;
+ // let the letter shrink to fit instead of clipping at large
+ // Dynamic Type sizes.
+ .lineLimit(1)
+ .minimumScaleFactor(0.5)
.foregroundStyle(isOn ? Color.white : Color.secondary)
.frame(width: 38, height: 38)
.background(
diff --git a/OpenAppLock/Views/Onboarding/OnboardingView.swift b/OpenAppLock/Views/Onboarding/OnboardingView.swift
index 9aa32f1..48aed4e 100644
--- a/OpenAppLock/Views/Onboarding/OnboardingView.swift
+++ b/OpenAppLock/Views/Onboarding/OnboardingView.swift
@@ -34,26 +34,40 @@ struct OnboardingView: View {
.padding()
}
+ /// Shared vertical rhythm so both onboarding steps line up.
+ private var stepSpacing: CGFloat { 20 }
+
private var welcome: some View {
- VStack(spacing: 16) {
- Image(systemName: "scissors")
- .font(.system(size: 56))
- .foregroundStyle(.tint)
+ VStack(spacing: stepSpacing) {
+ appLogo
Text("OpenAppLock")
.font(.largeTitle.bold())
+ .multilineTextAlignment(.center)
Text("Block your most distracting apps with rules that keep you honest — on a schedule, with no way out when you choose Hard Mode.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
+ /// The real app icon, shown as a rounded tile so the welcome screen leads
+ /// with the app's own identity rather than a generic symbol.
+ private var appLogo: some View {
+ Image("AppLogo")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 96, height: 96)
+ .clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
+ .shadow(color: .black.opacity(0.15), radius: 10, y: 4)
+ .accessibilityHidden(true)
+ }
+
private var permission: some View {
- VStack(spacing: 24) {
+ VStack(spacing: stepSpacing) {
Image(systemName: "hourglass")
- .font(.system(size: 48))
+ .font(.system(size: 52))
.foregroundStyle(.tint)
Text("Allow Screen Time Access")
- .font(.title.bold())
+ .font(.largeTitle.bold())
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 14) {
bullet("shield.fill", "OpenAppLock uses Apple's Screen Time framework to block the apps you choose.")
diff --git a/OpenAppLock/Views/Rules/NewRuleSheet.swift b/OpenAppLock/Views/Rules/NewRuleSheet.swift
index 3e75f81..847346d 100644
--- a/OpenAppLock/Views/Rules/NewRuleSheet.swift
+++ b/OpenAppLock/Views/Rules/NewRuleSheet.swift
@@ -42,7 +42,7 @@ struct NewRuleSheet: View {
.navigationTitle("New Rule")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .topBarLeading) {
+ ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") {
dismiss()
}
@@ -102,8 +102,12 @@ struct NewRuleSheet: View {
.foregroundStyle(Color.secondary)
}
Spacer()
- Image(systemName: "plus.circle.fill")
- .foregroundStyle(.tint)
+ // A chevron (matching the Rule Type rows) is the honest
+ // affordance: picking a preset pushes the editor to confirm,
+ // it does not add the rule outright.
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(Color(.tertiaryLabel))
}
}
.accessibilityIdentifier("preset-\(preset.id)")
diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift
index 7c191da..852b637 100644
--- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift
+++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift
@@ -78,7 +78,7 @@ struct RuleDetailSheet: View {
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .topBarLeading) {
+ ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") {
dismiss()
}
diff --git a/OpenAppLock/Views/Rules/RuleEditorView.swift b/OpenAppLock/Views/Rules/RuleEditorView.swift
index 20275d3..68ac55f 100644
--- a/OpenAppLock/Views/Rules/RuleEditorView.swift
+++ b/OpenAppLock/Views/Rules/RuleEditorView.swift
@@ -138,6 +138,8 @@ struct RuleEditorView: View {
Section {
budgetRow(
value: "\(draft.timeLimitConfig.dailyLimitMinutes)m",
+ accessibilityLabel: "Daily time limit",
+ accessibilityValue: "\(draft.timeLimitConfig.dailyLimitMinutes) minutes",
stepperID: "dailyLimitStepper",
onIncrement: {
draft.timeLimitConfig.dailyLimitMinutes =
@@ -163,6 +165,8 @@ struct RuleEditorView: View {
Section {
budgetRow(
value: "\(draft.openLimitConfig.maxOpens) opens",
+ accessibilityLabel: "Daily open limit",
+ accessibilityValue: "\(draft.openLimitConfig.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: {
draft.openLimitConfig.maxOpens = min(50, draft.openLimitConfig.maxOpens + 1)
@@ -200,16 +204,12 @@ struct RuleEditorView: View {
}
}
- /// Hard Mode applies to every kind.
+ /// Hard Mode applies to every kind. A labeled `Toggle` makes the whole row
+ /// the tap target and gives VoiceOver a "Hard Mode" switch in one element.
private var hardModeSection: some View {
Section {
- HStack {
- Text("Hard Mode")
- Spacer()
- Toggle("", isOn: $draft.hardMode)
- .labelsHidden()
- .accessibilityIdentifier("hardModeToggle")
- }
+ Toggle("Hard Mode", isOn: $draft.hardMode)
+ .accessibilityIdentifier("hardModeToggle")
} footer: {
Text("No unblocks allowed while the rule is blocking.")
}
@@ -218,13 +218,8 @@ struct RuleEditorView: View {
/// Schedule-only: filter adult websites while the rule's window is active.
private var adultContentSection: some View {
Section {
- HStack {
- Text("Block Adult Content")
- Spacer()
- Toggle("", isOn: $draft.scheduleConfig.blockAdultContent)
- .labelsHidden()
- .accessibilityIdentifier("adultContentToggle")
- }
+ Toggle("Block Adult Content", isOn: $draft.scheduleConfig.blockAdultContent)
+ .accessibilityIdentifier("adultContentToggle")
} footer: {
Text("Filter adult websites while this rule is active.")
}
@@ -255,6 +250,8 @@ struct RuleEditorView: View {
private func budgetRow(
value: String,
+ accessibilityLabel: String,
+ accessibilityValue: String,
stepperID: String,
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void
@@ -265,9 +262,14 @@ struct RuleEditorView: View {
Text(value)
.foregroundStyle(.secondary)
.accessibilityIdentifier("\(stepperID)Value")
+ // The stepper carries its own label + spoken value so VoiceOver
+ // announces e.g. "Daily time limit, 45 minutes" as it changes —
+ // the adjacent value Text is silent decoration to sighted users.
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
.labelsHidden()
.accessibilityIdentifier(stepperID)
+ .accessibilityLabel(accessibilityLabel)
+ .accessibilityValue(accessibilityValue)
}
}
diff --git a/OpenAppLock/Views/Rules/RulesListView.swift b/OpenAppLock/Views/Rules/RulesListView.swift
index efab762..14c3a62 100644
--- a/OpenAppLock/Views/Rules/RulesListView.swift
+++ b/OpenAppLock/Views/Rules/RulesListView.swift
@@ -40,21 +40,23 @@ struct RulesListView: View {
@ViewBuilder
private func rulesList(now: Date) -> some View {
- List {
- if rules.isEmpty {
- Section {
- 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)
+ if rules.isEmpty {
+ ContentUnavailableView {
+ Label("No Rules Yet", systemImage: "shield.lefthalf.filled")
+ } description: {
+ // The identifier lives on the description (not the container),
+ // so it surfaces as its own element rather than collapsing onto
+ // the action button.
+ Text("Create a rule to block distracting apps on a schedule.")
.accessibilityIdentifier("emptyRulesCard")
+ } actions: {
+ Button("New Rule") {
+ showingNewRule = true
}
- } else {
+ .accessibilityIdentifier("emptyStateNewRuleButton")
+ }
+ } else {
+ List {
kindSection(.schedule, now: now)
kindSection(.timeLimit, now: now)
kindSection(.openLimit, now: now)
diff --git a/OpenAppLockUITests/RuleCreationUITests.swift b/OpenAppLockUITests/RuleCreationUITests.swift
index caf8586..b39742e 100644
--- a/OpenAppLockUITests/RuleCreationUITests.swift
+++ b/OpenAppLockUITests/RuleCreationUITests.swift
@@ -105,9 +105,16 @@ final class RuleCreationUITests: XCTestCase {
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Time Keeper")
XCTAssertTrue(app.staticTexts["When I use"].exists)
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "45m")
+ // The stepper exposes a spoken value so VoiceOver announces changes.
+ XCTAssertEqual(
+ app.steppers["dailyLimitStepper"].value as? String, "45 minutes",
+ "The daily-limit stepper must expose a spoken accessibility value"
+ )
- app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
+ // The stepper's labeled control suffixes its increment button id.
+ app.steppers["dailyLimitStepper"].buttons["dailyLimitStepper-Increment"].tap()
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m")
+ XCTAssertEqual(app.steppers["dailyLimitStepper"].value as? String, "60 minutes")
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-Time Keeper"].waitToAppear()
@@ -123,7 +130,10 @@ final class RuleCreationUITests: XCTestCase {
// commit bar before tapping.
app.staticTexts["ruleEditorTitle"].waitToAppear()
app.swipeUp()
- app.switches["adultContentToggle"].waitToAppear().tap()
+ let adultToggle = app.switches["adultContentToggle"].waitToAppear()
+ XCTAssertEqual(adultToggle.label, "Block Adult Content")
+ // Labeled Toggle fills the row; tap the switch at the trailing edge.
+ adultToggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-In the Zone"].waitToAppear().tap()
diff --git a/OpenAppLockUITests/RuleManagementUITests.swift b/OpenAppLockUITests/RuleManagementUITests.swift
index 920eb0d..11f9f4f 100644
--- a/OpenAppLockUITests/RuleManagementUITests.swift
+++ b/OpenAppLockUITests/RuleManagementUITests.swift
@@ -37,7 +37,11 @@ final class RuleManagementUITests: XCTestCase {
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
- app.switches["hardModeToggle"].waitToAppear().tap()
+ let hardMode = app.switches["hardModeToggle"].waitToAppear()
+ XCTAssertEqual(hardMode.label, "Hard Mode", "The Hard Mode switch must carry its label for VoiceOver")
+ // A labeled Toggle fills the row, so a centered `.tap()` lands on the
+ // label; tap the switch itself at the trailing edge to flip it.
+ hardMode.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()
app.buttons["doneButton"].tap()
// Back on the detail view, unblocks are no longer allowed.
diff --git a/OpenAppLockUITests/SettingsUITests.swift b/OpenAppLockUITests/SettingsUITests.swift
index 3987853..dea8d56 100644
--- a/OpenAppLockUITests/SettingsUITests.swift
+++ b/OpenAppLockUITests/SettingsUITests.swift
@@ -62,6 +62,13 @@ final class SettingsUITests: XCTestCase {
// Saving returns to the management list with the new list present.
app.element("appListRow-Distractions").waitToAppear()
+
+ // Management rows open for editing on tap (no separate Edit button).
+ app.element("appListRow-Distractions").tap()
+ XCTAssertTrue(
+ app.textFields["appListNameField"].waitToAppear().exists,
+ "Tapping a list in management mode should open it for editing"
+ )
}
func testManageAppListsLockedDuringHardSession() throws {
@@ -74,8 +81,10 @@ final class SettingsUITests: XCTestCase {
// hard-mode rule is blocking — same lock as the rule editor's picker.
app.element("appListRow-Distractions").waitToAppear()
app.element("appListsLockedNotice").waitToAppear()
+ // Management mode edits via row tap; while locked, the tap must do nothing.
+ app.element("appListRow-Distractions").tap()
XCTAssertFalse(
- app.buttons["editAppListButton-Distractions"].exists,
+ app.textFields["appListNameField"].waitForExistence(timeout: 1.5),
"App lists must be read-only while a Hard Mode rule is blocking"
)
}