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:
@@ -269,3 +269,71 @@ struct OverlappingRuleEnforcementTests {
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall Protection: `refresh` denies device app removal only while the
|
||||
/// user opted in *and* a Hard Mode rule is actively blocking.
|
||||
@MainActor
|
||||
@Suite("Uninstall protection enforcement")
|
||||
struct UninstallProtectionEnforcementTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0) // outside it
|
||||
|
||||
private func hardRule() -> BlockingRule {
|
||||
BlockingRule(name: "Locked In", hardMode: true)
|
||||
}
|
||||
|
||||
@Test("Disabled setting never denies app removal")
|
||||
func disabledSettingNeverDenies() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: false))
|
||||
|
||||
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(!shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("Enabled setting denies removal while a hard rule is blocking")
|
||||
func deniesDuringHardBlock() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||||
|
||||
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("A soft rule does not deny removal even with the setting on")
|
||||
func softRuleDoesNotDeny() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||||
|
||||
enforcer.refresh(rules: [BlockingRule(name: "Work Time")], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(!shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("Denial lifts once the hard window ends")
|
||||
func liftsWhenWindowEnds() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||||
let rule = hardRule()
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
#expect(shields.appRemovalDenied)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc)
|
||||
#expect(!shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("clearShields(except:) does not disturb the app-removal denial")
|
||||
func clearShieldsPreservesDenial() {
|
||||
let shields = MockShieldController()
|
||||
shields.setAppRemovalDenied(true)
|
||||
shields.clearShields(except: [])
|
||||
#expect(shields.appRemovalDenied)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user