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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 22:17:08 -04:00
parent 0a0d00f53a
commit 32b1694e0a
26 changed files with 1039 additions and 427 deletions

View File

@@ -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:0017: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)
}
}

View File

@@ -79,3 +79,60 @@ struct RulePolicyTests {
#expect(rule.pausedUntil == nil)
}
}
@MainActor
@Suite("Uninstall protection policy")
struct UninstallProtectionPolicyTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
func scheduleRule(hardMode: Bool) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}
func hardLimitRule() -> BlockingRule {
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
hardMode: true,
days: Weekday.everyDay)
}
@Test("App removal is only denied with the setting on AND a hard rule active")
func deniedOnlyWhenEnabledAndHardLocked() {
let hard = scheduleRule(hardMode: true)
// Setting off: never deny, even with an active hard rule.
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [hard], enabled: false, at: mondayDuringWork, calendar: utc))
// Setting on + active hard rule: deny.
#expect(
RulePolicy.shouldDenyAppRemoval(
rules: [hard], enabled: true, at: mondayDuringWork, calendar: utc))
// Setting on but the hard rule is outside its window: do not deny.
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [hard], enabled: true, at: mondayEvening, calendar: utc))
}
@Test("A soft rule never triggers app-removal denial")
func softRuleNeverDenies() {
let soft = scheduleRule(hardMode: false)
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [soft], enabled: true, at: mondayDuringWork, calendar: utc))
}
@Test("A spent hard-mode limit rule triggers denial; unspent does not")
func spentHardLimitDenies() {
let rule = hardLimitRule()
#expect(
RulePolicy.shouldDenyAppRemoval(
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 45) },
at: mondayDuringWork, calendar: utc))
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) },
at: mondayDuringWork, calendar: utc))
}
}

View File

@@ -254,4 +254,14 @@ struct UsageDisplayTests {
let over = RuleUsage(minutesUsed: 60)
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today")
}
@Test("Typed subtitles prefix the rule kind so type is clear without the icon")
func typedSubtitles() {
#expect(
UsageDisplay.typedSubtitle(for: timeRule, usage: RuleUsage(minutesUsed: 18))
== "Time Limit · 18m of 45m used today")
#expect(
UsageDisplay.typedSubtitle(for: openRule, usage: RuleUsage(opensUsed: 2))
== "Open Limit · 2 of 5 opens today")
}
}