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" ) }