feat: re-skin to native three-tab UI and add Uninstall Protection
Replace the custom themed presentation with the bare native iOS design language across a three-tab TabView (Home / Rules / Settings): - MainTabView hosts the enforcement lifecycle (refresh loop, rule-change and scene-active reconcile) so it runs regardless of the selected tab - HomeView: "Currently Blocking" + "Usage" sections (replaces AppsHomeView) - RulesListView: rules grouped into Schedule / Time Limit / Open Limit - SettingsView: Uninstall Protection toggle + Manage App Lists - AppListLibraryView / ManageAppListsView: shared App List management - AppSettings: app-group-backed settings store Uninstall Protection: while enabled and any Hard Mode rule is actively blocking, deny device app-removal via a dedicated ManagedSettingsStore (RulePolicy.shouldDenyAppRemoval -> RuleEnforcer -> ShieldController). Slim AppListPickerSheet to the picker role; drop AppsHomeView and the custom Theme/HoldToCommitButton/RuleCardView chrome. Update spec section 6 and the unit/UI test suites accordingly.
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
import XCTest
|
||||
|
||||
/// App lists: the editor's App List row, the picker (select / create / edit),
|
||||
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown.
|
||||
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown. The rule
|
||||
/// editor (and its app-list picker) is reached from the Rules tab.
|
||||
final class AppListUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
@@ -14,6 +15,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testScheduleEditorOffersModeChoice() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -25,6 +27,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testLimitEditorsAreBlockOnly() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
@@ -46,6 +49,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testCreateAppListFlowSelectsNewList() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
@@ -79,6 +83,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testDetailShowsAppListName() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
|
||||
let row = app.element("detailRow-Block").waitToAppear()
|
||||
@@ -87,6 +92,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testHardModeSessionLocksAppListEditing() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
app.goToRulesTab()
|
||||
|
||||
// "Sleep" is soft and editable even while "Locked In" hard-blocks.
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
@@ -103,6 +109,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testAppListsEditableWithoutHardSession() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
|
||||
@@ -17,15 +17,17 @@ final class OnboardingUITests: XCTestCase {
|
||||
app.staticTexts["OpenAppLock"].waitToAppear()
|
||||
app.buttons["onboardingContinueButton"].waitToAppear().tap()
|
||||
|
||||
// Permission step: granting (mocked) lands on the Apps home screen.
|
||||
// Permission step: granting (mocked) lands on the tabbed home screen.
|
||||
app.buttons["allowScreenTimeButton"].waitToAppear().tap()
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
XCTAssertTrue(app.staticTexts["Blocked Apps"].exists)
|
||||
app.tabBars.buttons["Home"].waitToAppear()
|
||||
XCTAssertTrue(app.tabBars.buttons["Rules"].exists)
|
||||
XCTAssertTrue(app.tabBars.buttons["Settings"].exists)
|
||||
XCTAssertTrue(app.staticTexts["Currently Blocking"].exists)
|
||||
}
|
||||
|
||||
func testCompletedOnboardingIsSkipped() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(onboardingCompleted: true)
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
app.tabBars.buttons["Home"].waitToAppear()
|
||||
XCTAssertFalse(app.buttons["onboardingContinueButton"].exists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testCreateScheduleRuleFromTypeCard() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.element("emptyRulesCard").waitToAppear()
|
||||
|
||||
app.buttons["newRuleButton"].tap()
|
||||
@@ -33,6 +34,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testCreateRuleFromPreset() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["preset-work-time"].waitToAppear().tap()
|
||||
@@ -44,6 +46,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testRenameRuleInEditor() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -63,6 +66,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testDayTogglesFillRowAndHaveLargeTapTargets() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -81,6 +85,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testDayTogglesUpdateSummary() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -93,6 +98,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testCreateTimeLimitRule() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
@@ -109,6 +115,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testAdultContentToggleFlowsToDetail() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -126,6 +133,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testAdultContentDefaultsToAllowed() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
let row = app.element("detailRow-Adult websites").waitToAppear()
|
||||
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
|
||||
@@ -135,6 +143,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
/// not offer the toggle, and the rule's detail must not show the row.
|
||||
func testTimeLimitOmitsAdultContent() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
@@ -159,6 +168,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testEditorSupportsNativeSwipeBack() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||
@@ -174,6 +184,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testNewRuleSheetShowsTypesAndPresets() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["ruleKind-schedule"].waitToAppear()
|
||||
|
||||
@@ -7,6 +7,7 @@ import XCTest
|
||||
|
||||
/// Detail sheet, editing, disabling, deleting, and unblocking — seeded with
|
||||
/// one actively-blocking rule ("Work Time") and one upcoming rule ("Sleep").
|
||||
/// Rule cards live on the Rules tab; blocked tiles on the Home tab.
|
||||
final class RuleManagementUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
@@ -14,6 +15,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testDetailShowsLiveStatusAndFacts() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
|
||||
@@ -30,6 +32,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testEditRuleTogglesHardModeOn() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
@@ -44,6 +47,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testDisableRule() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
@@ -65,6 +69,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testDeleteRuleRemovesCard() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
@@ -84,11 +89,13 @@ final class RuleManagementUITests: XCTestCase {
|
||||
func testUnblockActiveSoftRule() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
|
||||
// The active rule surfaces in Blocked Apps; unblocking pauses it.
|
||||
// The active rule surfaces in Currently Blocking (Home tab); unblocking pauses it.
|
||||
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
|
||||
app.sheets.buttons["Unblock"].waitToAppear().tap()
|
||||
|
||||
app.staticTexts["nothingBlockedLabel"].waitToAppear()
|
||||
// The paused state shows on the rule's card over on the Rules tab.
|
||||
app.goToRulesTab()
|
||||
XCTAssertEqual(app.staticTexts["ruleStatus-Work Time"].waitToAppear().label, "Paused")
|
||||
}
|
||||
}
|
||||
@@ -101,6 +108,7 @@ final class HardModeUITests: XCTestCase {
|
||||
|
||||
func testHardLockedRuleCannotBeEdited() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Locked In"].waitToAppear().tap()
|
||||
|
||||
@@ -113,6 +121,8 @@ final class HardModeUITests: XCTestCase {
|
||||
func testHardLockedRuleCannotBeUnblocked() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
|
||||
// The hard rule shows a lock (not an Unblock button) in Currently Blocking;
|
||||
// tapping the row still explains why it can't be lifted.
|
||||
app.buttons["blockedTile-Locked In"].waitToAppear().tap()
|
||||
|
||||
// No unblock dialog — just the refusal alert.
|
||||
|
||||
67
OpenAppLockUITests/SettingsUITests.swift
Normal file
67
OpenAppLockUITests/SettingsUITests.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// SettingsUITests.swift
|
||||
// OpenAppLockUITests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// The Settings tab: the Uninstall Protection toggle and the Manage App Lists
|
||||
/// flow (which reuses the rule editor's app-list library, minus selection).
|
||||
final class SettingsUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testUninstallProtectionToggleStartsOffAndFlips() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToSettingsTab()
|
||||
|
||||
let toggle = app.switches["uninstallProtectionToggle"].waitToAppear()
|
||||
XCTAssertEqual(toggle.value as? String, "0", "Uninstall Protection should default off")
|
||||
// `.tap()` lands on the element's center (over the label) and doesn't
|
||||
// reliably flip a SwiftUI switch — tap the control itself.
|
||||
toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()
|
||||
XCTAssertEqual(toggle.value as? String, "1", "Tapping should turn it on")
|
||||
}
|
||||
|
||||
func testManageAppListsCreateFlow() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToSettingsTab()
|
||||
|
||||
app.element("manageAppListsButton").waitToAppear().tap()
|
||||
|
||||
// Fresh install: no lists yet — the same create flow as the rule editor.
|
||||
app.element("emptyAppListsLabel").waitToAppear()
|
||||
app.buttons["newAppListButton"].tap()
|
||||
|
||||
let nameField = app.textFields["appListNameField"].waitToAppear()
|
||||
nameField.tap()
|
||||
nameField.typeText("Distractions\n")
|
||||
|
||||
app.element("emptySelectionLabel").waitToAppear()
|
||||
app.buttons["editAppsButton"].tap()
|
||||
app.element("selectionCountLabel").waitToAppear()
|
||||
app.buttons["confirmSelectionButton"].tap()
|
||||
|
||||
app.buttons["saveAppListButton"].waitToAppear().tap()
|
||||
|
||||
// Saving returns to the management list with the new list present.
|
||||
app.element("appListRow-Distractions").waitToAppear()
|
||||
}
|
||||
|
||||
func testManageAppListsLockedDuringHardSession() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
app.goToSettingsTab()
|
||||
|
||||
app.element("manageAppListsButton").waitToAppear().tap()
|
||||
|
||||
// The seeded "Distractions" list is visible but read-only while the
|
||||
// hard-mode rule is blocking — same lock as the rule editor's picker.
|
||||
app.element("appListRow-Distractions").waitToAppear()
|
||||
app.element("appListsLockedNotice").waitToAppear()
|
||||
XCTAssertFalse(
|
||||
app.buttons["editAppListButton-Distractions"].exists,
|
||||
"App lists must be read-only while a Hard Mode rule is blocking"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,16 @@ extension XCUIApplication {
|
||||
}
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
/// Switches to the Home tab (Currently Blocking + Usage). Home is the
|
||||
/// default selection, so most Home-tab tests don't need to call this.
|
||||
func goToHomeTab() { tabBars.buttons["Home"].waitToAppear().tap() }
|
||||
/// Switches to the Rules tab (the rule list + New Rule button).
|
||||
func goToRulesTab() { tabBars.buttons["Rules"].waitToAppear().tap() }
|
||||
/// Switches to the Settings tab (Uninstall Protection + Manage App Lists).
|
||||
func goToSettingsTab() { tabBars.buttons["Settings"].waitToAppear().tap() }
|
||||
}
|
||||
|
||||
extension XCUIElement {
|
||||
/// Asserts the element appears within the timeout, then returns it.
|
||||
@discardableResult
|
||||
|
||||
@@ -5,35 +5,45 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
/// The Usage section under Blocked Apps — seeded with limit rules at various
|
||||
/// The Usage section on the Home tab — seeded with limit rules at various
|
||||
/// budget states ("Time Keeper" 18m/45m, "Gate Keeper" 2/5 opens,
|
||||
/// "Doom Scroll" spent → blocked).
|
||||
/// "Doom Scroll" spent → moved to Currently Blocking).
|
||||
final class UsageUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testUsageSectionShowsTimeAndOpenBudgets() throws {
|
||||
func testUsageSectionShowsTypeAndBudgets() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists)
|
||||
|
||||
// The row leads with the rule type, then the live usage and remaining.
|
||||
let timeRow = app.element("usageRow-Time Keeper").waitToAppear()
|
||||
XCTAssertTrue(timeRow.label.contains("Time Limit"), "Got: \(timeRow.label)")
|
||||
XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)")
|
||||
XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)")
|
||||
|
||||
let openRow = app.element("usageRow-Gate Keeper").waitToAppear()
|
||||
XCTAssertTrue(openRow.label.contains("Open Limit"), "Got: \(openRow.label)")
|
||||
XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)")
|
||||
XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)")
|
||||
}
|
||||
|
||||
func testSpentBudgetShowsAsBlockedUntilTomorrow() throws {
|
||||
func testSpentBudgetMovesToCurrentlyBlocking() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||
|
||||
let spentRow = app.element("usageRow-Doom Scroll").waitToAppear()
|
||||
XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)")
|
||||
// A spent budget is a real block: the rule surfaces in Blocked Apps.
|
||||
XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists)
|
||||
// A spent budget is a real block: the rule moves out of Usage and into
|
||||
// Currently Blocking, carrying its type + usage tracking.
|
||||
let tile = app.buttons["blockedTile-Doom Scroll"].waitToAppear()
|
||||
XCTAssertTrue(tile.label.contains("Time Limit"), "Got: \(tile.label)")
|
||||
XCTAssertTrue(tile.label.contains("30m of 30m used today"), "Got: \(tile.label)")
|
||||
|
||||
// It is no longer tracked under Usage.
|
||||
XCTAssertFalse(
|
||||
app.element("usageRow-Doom Scroll").exists,
|
||||
"A spent rule should leave the Usage section for Currently Blocking"
|
||||
)
|
||||
}
|
||||
|
||||
func testSpentBudgetCanBeUnblockedUntilTomorrow() throws {
|
||||
@@ -42,6 +52,7 @@ final class UsageUITests: XCTestCase {
|
||||
app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap()
|
||||
app.sheets.buttons["Unblock"].waitToAppear().tap()
|
||||
|
||||
// Unblocked → paused (not blocking), so it drops back into Usage.
|
||||
app.staticTexts["nothingBlockedLabel"].waitToAppear()
|
||||
let row = app.element("usageRow-Doom Scroll").waitToAppear()
|
||||
XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)")
|
||||
|
||||
Reference in New Issue
Block a user