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

@@ -19,6 +19,10 @@ protocol ShieldApplying: AnyObject {
/// Clears every shield except those for the given rule IDs. Covers rules
/// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>)
/// Engages or relinquishes the device-wide app-removal denial used by
/// Uninstall Protection. Independent of per-rule shields: it lives on its
/// own store so clearing rule shields never disturbs it.
func setAppRemovalDenied(_ denied: Bool)
}
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
@@ -65,6 +69,17 @@ final class ManagedSettingsShieldController: ShieldApplying {
}
}
func setAppRemovalDenied(_ denied: Bool) {
// A dedicated store keeps this device-wide setting off the per-rule
// stores, which get fully cleared when their shield lifts. Setting nil
// (not false) relinquishes the constraint entirely when off.
let store = ManagedSettingsStore(named: Self.uninstallProtectionStoreName)
store.application.denyAppRemoval = denied ? true : nil
}
private static let uninstallProtectionStoreName =
ManagedSettingsStore.Name("uninstall-protection")
private func store(for ruleID: UUID) -> ManagedSettingsStore {
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
}
@@ -91,6 +106,10 @@ final class MockShieldController: ShieldApplying {
private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
private(set) var appliedSelectionData: [UUID: Data?] = [:]
/// The device-wide app-removal denial. Deliberately separate from the
/// per-rule shield bookkeeping, mirroring the dedicated store in the real
/// controller `clearShields(except:)` must not disturb it.
private(set) var appRemovalDenied = false
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
@@ -115,6 +134,11 @@ final class MockShieldController: ShieldApplying {
activeRuleIDs.contains($0.key)
}
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
// Note: appRemovalDenied is intentionally left untouched here.
}
func setAppRemovalDenied(_ denied: Bool) {
appRemovalDenied = denied
}
}