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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user