From a658d196c4ee98f65c1b7322cf8670effc9cecf8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 13 Jun 2026 22:17:08 -0400 Subject: [PATCH] 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. --- OpenAppLock/Logic/RulePolicy.swift | 27 +- OpenAppLock/Logic/UsageDisplay.swift | 9 + OpenAppLock/OpenAppLockApp.swift | 13 +- OpenAppLock/Services/AppSettings.swift | 53 ++++ OpenAppLock/Services/RuleEnforcer.swift | 15 +- .../Views/AppLists/AppListLibraryView.swift | 154 ++++++++++ .../Views/AppLists/AppListPickerSheet.swift | 142 +--------- .../Views/AppLists/ManageAppListsView.swift | 17 ++ OpenAppLock/Views/Apps/AppsHomeView.swift | 266 ------------------ OpenAppLock/Views/Home/HomeView.swift | 165 +++++++++++ OpenAppLock/Views/MainTabView.swift | 68 +++++ OpenAppLock/Views/RootView.swift | 2 +- OpenAppLock/Views/Rules/RulesListView.swift | 109 +++++++ OpenAppLock/Views/Settings/SettingsView.swift | 63 +++++ OpenAppLockTests/RuleEnforcerTests.swift | 68 +++++ OpenAppLockTests/RulePolicyTests.swift | 57 ++++ OpenAppLockTests/UsageTests.swift | 10 + OpenAppLockUITests/AppListUITests.swift | 9 +- OpenAppLockUITests/OnboardingUITests.swift | 10 +- OpenAppLockUITests/RuleCreationUITests.swift | 11 + .../RuleManagementUITests.swift | 12 +- OpenAppLockUITests/SettingsUITests.swift | 67 +++++ OpenAppLockUITests/UITestSupport.swift | 10 + OpenAppLockUITests/UsageUITests.swift | 27 +- Shared/ShieldController.swift | 24 ++ docs/RULES_FEATURE_SPEC.md | 58 +++- 26 files changed, 1039 insertions(+), 427 deletions(-) create mode 100644 OpenAppLock/Services/AppSettings.swift create mode 100644 OpenAppLock/Views/AppLists/AppListLibraryView.swift create mode 100644 OpenAppLock/Views/AppLists/ManageAppListsView.swift delete mode 100644 OpenAppLock/Views/Apps/AppsHomeView.swift create mode 100644 OpenAppLock/Views/Home/HomeView.swift create mode 100644 OpenAppLock/Views/MainTabView.swift create mode 100644 OpenAppLock/Views/Rules/RulesListView.swift create mode 100644 OpenAppLock/Views/Settings/SettingsView.swift create mode 100644 OpenAppLockUITests/SettingsUITests.swift diff --git a/OpenAppLock/Logic/RulePolicy.swift b/OpenAppLock/Logic/RulePolicy.swift index c644354..6b6c989 100644 --- a/OpenAppLock/Logic/RulePolicy.swift +++ b/OpenAppLock/Logic/RulePolicy.swift @@ -59,6 +59,17 @@ enum RulePolicy { !isHardLocked(rule, usage: usage, at: now, calendar: calendar) } + /// Whether *any* rule is currently a hard block — the condition that locks + /// app-list editing and (when the user opts in) device app removal. + static func isAnyHardLocked( + rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil }, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + rules.contains { + isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar) + } + } + /// App lists feed active shields, so while any hard-mode rule is actively /// blocking, no list may be edited or deleted — changing a list would be a /// back door out of the hard block. Creating new lists and picking lists @@ -67,9 +78,19 @@ enum RulePolicy { rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil }, at now: Date = .now, calendar: Calendar = .current ) -> Bool { - !rules.contains { - isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar) - } + !isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar) + } + + /// Whether the device's app removal should be denied right now: the user has + /// turned on Uninstall Protection *and* a hard block is currently in force. + /// Engaging this while a hard rule blocks makes the block harder to escape + /// (the user can't delete the locked apps — or OpenAppLock itself). + static func shouldDenyAppRemoval( + rules: [BlockingRule], enabled: Bool, + usageFor: (BlockingRule) -> RuleUsage? = { _ in nil }, + at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + enabled && isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar) } /// Pauses the rule's current block. Returns false (and changes nothing) diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift index 1863233..ed8cf28 100644 --- a/OpenAppLock/Logic/UsageDisplay.swift +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -8,6 +8,15 @@ import Foundation /// Strings for the home screen's Usage section. Used values clamp to the /// budget so overshoot (thresholds can fire late) never reads "50m of 45m". enum UsageDisplay { + /// The usage subtitle prefixed with the rule's type, so the kind is clear + /// without relying on an icon: "Time Limit · 18m of 45m used today". + /// Schedule rules (no usage text) fall back to just the type name. + static func typedSubtitle(for rule: BlockingRule, usage: RuleUsage) -> String { + let usageText = subtitle(for: rule, usage: usage) + guard !usageText.isEmpty else { return rule.kind.displayName } + return "\(rule.kind.displayName) · \(usageText)" + } + /// "18m of 45m used today" / "2 of 5 opens today". static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String { switch rule.configuration { diff --git a/OpenAppLock/OpenAppLockApp.swift b/OpenAppLock/OpenAppLockApp.swift index d9d3def..473b05b 100644 --- a/OpenAppLock/OpenAppLockApp.swift +++ b/OpenAppLock/OpenAppLockApp.swift @@ -13,6 +13,7 @@ struct OpenAppLockApp: App { private let container: ModelContainer @State private var authorization: ScreenTimeAuthorization @State private var enforcer: RuleEnforcer + @State private var settings: AppSettingsStore init() { let config = LaunchConfiguration.current @@ -21,6 +22,15 @@ struct OpenAppLockApp: App { UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding") } + // The app-group suite persists across UI-test launches (unlike the + // in-memory SwiftData store), so start each UI-test run from a clean + // Uninstall Protection state. + if config.isUITesting { + AppSettingsStore.resetForTesting() + } + let appSettings = AppSettingsStore() + _settings = State(initialValue: appSettings) + let schema = Schema([BlockingRule.self, AppList.self]) let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: config.isUITesting @@ -58,7 +68,7 @@ struct OpenAppLockApp: App { _enforcer = State( initialValue: RuleEnforcer( shields: shields, usage: usageLedger, scheduler: scheduler, - openSessions: openSessions)) + openSessions: openSessions, settings: appSettings)) } var body: some Scene { @@ -66,6 +76,7 @@ struct OpenAppLockApp: App { RootView() .environment(authorization) .environment(enforcer) + .environment(settings) } .modelContainer(container) } diff --git a/OpenAppLock/Services/AppSettings.swift b/OpenAppLock/Services/AppSettings.swift new file mode 100644 index 0000000..e8be113 --- /dev/null +++ b/OpenAppLock/Services/AppSettings.swift @@ -0,0 +1,53 @@ +// +// AppSettings.swift +// OpenAppLock +// + +import Foundation +import Observation + +/// Read access to the app-wide settings the enforcer consults. Injected into +/// `RuleEnforcer` so the uninstall-protection decision has a single, +/// test-mockable source of truth. +protocol AppSettingsReading: AnyObject { + /// Whether the user has opted into denying device app removal while a Hard + /// Mode rule is actively blocking. + var uninstallProtectionEnabled: Bool { get } +} + +/// The settings store, backed by the shared app-group defaults so the value +/// persists across launches (and is reachable by the extensions in future). +/// Observable so the Settings screen's toggle stays in sync. +@Observable +final class AppSettingsStore: AppSettingsReading { + static let uninstallProtectionKey = "uninstallProtectionEnabled" + + @ObservationIgnored private let defaults: UserDefaults + + var uninstallProtectionEnabled: Bool { + didSet { + defaults.set(uninstallProtectionEnabled, forKey: Self.uninstallProtectionKey) + } + } + + init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + // Property observers don't fire during init, so this load doesn't write back. + self.uninstallProtectionEnabled = defaults.bool(forKey: Self.uninstallProtectionKey) + } + + /// Clears the persisted value — used by UI-test launches, since the + /// app-group suite is not wiped between runs the way the SwiftData store is. + static func resetForTesting(defaults: UserDefaults = AppGroup.defaults) { + defaults.removeObject(forKey: uninstallProtectionKey) + } +} + +/// In-memory settings for unit tests. +final class MockAppSettings: AppSettingsReading { + var uninstallProtectionEnabled: Bool + + init(uninstallProtectionEnabled: Bool = false) { + self.uninstallProtectionEnabled = uninstallProtectionEnabled + } +} diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 1e842e8..6fbecb5 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -27,16 +27,21 @@ final class RuleEnforcer { /// Granted-open sessions, so a proactively-gated open-limit rule is left /// un-shielded while the user is inside a session they paid an open for. private let openSessions: OpenSessionReading + /// App-wide settings (currently just Uninstall Protection) consulted on + /// every refresh. + private let settings: any AppSettingsReading init( shields: ShieldApplying, usage: UsageReading = UsageLedger(), scheduler: RuleScheduler? = nil, - openSessions: OpenSessionReading = OpenSessionStore() + openSessions: OpenSessionReading = OpenSessionStore(), + settings: any AppSettingsReading = AppSettingsStore() ) { self.shields = shields self.usageReader = usage self.scheduler = scheduler self.openSessions = openSessions + self.settings = settings } /// The day's usage for a rule (nil for schedule rules, which don't track). @@ -84,6 +89,14 @@ final class RuleEnforcer { // "Blocked Apps" lists only rules whose budget/window is spent — not the // proactive open-limit gate, which surfaces under "Usage" instead. blockingRuleIDs = blocking + // Uninstall Protection: deny device app removal while the user has opted + // in and any Hard Mode rule is actively blocking. + shields.setAppRemovalDenied( + RulePolicy.shouldDenyAppRemoval( + rules: rules, + enabled: settings.uninstallProtectionEnabled, + usageFor: { usage(for: $0, at: now, calendar: calendar) }, + at: now, calendar: calendar)) scheduler?.sync(rules: rules, at: now) } diff --git a/OpenAppLock/Views/AppLists/AppListLibraryView.swift b/OpenAppLock/Views/AppLists/AppListLibraryView.swift new file mode 100644 index 0000000..93db55b --- /dev/null +++ b/OpenAppLock/Views/AppLists/AppListLibraryView.swift @@ -0,0 +1,154 @@ +// +// AppListLibraryView.swift +// OpenAppLock +// + +import SwiftData +import SwiftUI + +/// The reusable app-list library: the saved lists, an Edit affordance, the New +/// List flow, swipe-to-delete, and the Hard Mode lock. Used in two modes: +/// +/// - **Picker** (`selection` non-nil): each row shows a checkmark and tapping it +/// selects the list and calls `onPick` (the rule editor uses this to dismiss). +/// Creating a list selects it without dismissing. +/// - **Management** (`selection` nil): no checkmark; tapping a row (when unlocked) +/// opens it for editing. Used by Settings ▸ Manage App Lists. +/// +/// In both modes editing and deletion are disabled while any Hard Mode rule is +/// actively blocking — changing a list would be a back door out of the block. +struct AppListLibraryView: View { + /// Picker mode when non-nil; management mode when nil. + var selection: Binding? + /// Called after a row is tapped in picker mode (e.g. to dismiss the sheet). + var onPick: (() -> Void)? + + @Environment(\.modelContext) private var modelContext + @Environment(RuleEnforcer.self) private var enforcer + @Query(sort: \AppList.createdAt) private var lists: [AppList] + @Query private var rules: [BlockingRule] + + @State private var editingList: AppList? + @State private var creatingList = false + @State private var deletionBlocked = false + + private var isPicking: Bool { selection != nil } + + /// While any hard-mode rule is actively blocking, lists are read-only. + private var listsLocked: Bool { + !RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) }) + } + + var body: some View { + List { + Section { + if lists.isEmpty { + Text("No app lists yet. Create one to choose which apps a rule affects.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("emptyAppListsLabel") + } else { + ForEach(lists) { list in + listRow(list) + } + } + } header: { + Text("Your App Lists").textCase(nil) + } footer: { + if listsLocked { + Label( + "Hard Mode is on — app lists are locked until the block ends.", + systemImage: "lock.fill" + ) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("appListsLockedNotice") + } + } + Section { + Button { + creatingList = true + } label: { + Label("New List", systemImage: "plus") + } + .accessibilityIdentifier("newAppListButton") + } + } + .navigationDestination(isPresented: $creatingList) { + AppListEditorView(list: nil) { created in + selection?.wrappedValue = created + creatingList = false + } + } + .navigationDestination(item: $editingList) { list in + AppListEditorView(list: list) { _ in + editingList = nil + } + } + .alert("This list is in use", isPresented: $deletionBlocked) { + Button("OK", role: .cancel) {} + } message: { + Text("Remove it from the rules that use it before deleting.") + } + } + + private func listRow(_ list: AppList) -> some View { + HStack { + Button { + if isPicking { + selection?.wrappedValue = list + onPick?() + } else if !listsLocked { + editingList = list + } + } label: { + HStack { + if isPicking { + Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle") + .foregroundStyle( + isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary) + ) + .frame(width: 28) + } + VStack(alignment: .leading, spacing: 2) { + Text(list.name) + .foregroundStyle(Color.primary) + Text(list.appCountLabel) + .font(.caption) + .foregroundStyle(Color.secondary) + } + } + } + .accessibilityIdentifier("appListRow-\(list.name)") + Spacer() + if !listsLocked { + Button("Edit") { + editingList = list + } + .font(.subheadline) + .accessibilityIdentifier("editAppListButton-\(list.name)") + } + } + .buttonStyle(.borderless) + .swipeActions { + if !listsLocked { + Button("Delete", role: .destructive) { + delete(list) + } + } + } + } + + private func isSelected(_ list: AppList) -> Bool { + selection?.wrappedValue?.id == list.id + } + + private func delete(_ list: AppList) { + guard !AppList.isInUse(list, context: modelContext) else { + deletionBlocked = true + return + } + if isSelected(list) { + selection?.wrappedValue = nil + } + modelContext.delete(list) + } +} diff --git a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift index 47f58c6..dc40a25 100644 --- a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift +++ b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift @@ -3,151 +3,31 @@ // OpenAppLock // -import SwiftData import SwiftUI -/// Chooses the app list a rule uses: saved lists (tap to select), an Edit -/// affordance per list, and a "New List" flow. Lists are persisted directly, -/// independent of any rule, so creating one here never depends on the rule -/// draft being committed. +/// Chooses the app list a rule uses. Wraps the shared `AppListLibraryView` in +/// picker mode (checkmark + select-and-dismiss); the library handles the list +/// rows, Edit/New flows, deletion, and the Hard Mode lock. struct AppListPickerSheet: View { @Binding var selected: AppList? @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @Environment(RuleEnforcer.self) private var enforcer - @Query(sort: \AppList.createdAt) private var lists: [AppList] - @Query private var rules: [BlockingRule] - - @State private var editingList: AppList? - @State private var creatingList = false - @State private var deletionBlocked = false - - /// While any hard-mode rule is actively blocking (by the clock or a spent - /// limit budget), lists are read-only — editing one would be a back door - /// out of the hard block. - private var listsLocked: Bool { - !RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) }) - } var body: some View { NavigationStack { - List { - Section { - if lists.isEmpty { - Text("No app lists yet. Create one to choose which apps this rule affects.") - .foregroundStyle(.secondary) - .accessibilityIdentifier("emptyAppListsLabel") - } else { - ForEach(lists) { list in - listRow(list) + AppListLibraryView(selection: $selected, onPick: { dismiss() }) + .navigationTitle("App List") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", systemImage: "xmark") { + dismiss() } - } - } header: { - Text("Your App Lists").textCase(nil) - } footer: { - if listsLocked { - Label( - "Hard Mode is on — app lists are locked until the block ends.", - systemImage: "lock.fill" - ) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("appListsLockedNotice") + .accessibilityIdentifier("closeAppListPickerButton") } } - Section { - Button { - creatingList = true - } label: { - Label("New List", systemImage: "plus") - } - .accessibilityIdentifier("newAppListButton") - } - } - .navigationTitle("App List") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close", systemImage: "xmark") { - dismiss() - } - .accessibilityIdentifier("closeAppListPickerButton") - } - } - .navigationDestination(isPresented: $creatingList) { - AppListEditorView(list: nil) { created in - selected = created - creatingList = false - } - } - .navigationDestination(item: $editingList) { list in - AppListEditorView(list: list) { _ in - editingList = nil - } - } - .alert("This list is in use", isPresented: $deletionBlocked) { - Button("OK", role: .cancel) {} - } message: { - Text("Remove it from the rules that use it before deleting.") - } } } - - private func listRow(_ list: AppList) -> some View { - HStack { - Button { - selected = list - dismiss() - } label: { - HStack { - Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle") - .foregroundStyle( - isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary) - ) - .frame(width: 28) - VStack(alignment: .leading, spacing: 2) { - Text(list.name) - .foregroundStyle(Color.primary) - Text(list.appCountLabel) - .font(.caption) - .foregroundStyle(Color.secondary) - } - } - } - .accessibilityIdentifier("appListRow-\(list.name)") - Spacer() - if !listsLocked { - Button("Edit") { - editingList = list - } - .font(.subheadline) - .accessibilityIdentifier("editAppListButton-\(list.name)") - } - } - .buttonStyle(.borderless) - .swipeActions { - if !listsLocked { - Button("Delete", role: .destructive) { - delete(list) - } - } - } - } - - private func isSelected(_ list: AppList) -> Bool { - selected?.id == list.id - } - - private func delete(_ list: AppList) { - guard !AppList.isInUse(list, context: modelContext) else { - deletionBlocked = true - return - } - if isSelected(list) { - selected = nil - } - modelContext.delete(list) - } } extension AppList { diff --git a/OpenAppLock/Views/AppLists/ManageAppListsView.swift b/OpenAppLock/Views/AppLists/ManageAppListsView.swift new file mode 100644 index 0000000..f286c81 --- /dev/null +++ b/OpenAppLock/Views/AppLists/ManageAppListsView.swift @@ -0,0 +1,17 @@ +// +// ManageAppListsView.swift +// OpenAppLock +// + +import SwiftUI + +/// Standalone app-list management (Settings ▸ Manage App Lists): the same +/// create / edit / delete flow the rule editor's picker uses, minus selection. +/// Pushed inside the Settings tab's navigation stack. +struct ManageAppListsView: View { + var body: some View { + AppListLibraryView() + .navigationTitle("App Lists") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/OpenAppLock/Views/Apps/AppsHomeView.swift b/OpenAppLock/Views/Apps/AppsHomeView.swift deleted file mode 100644 index 48c799c..0000000 --- a/OpenAppLock/Views/Apps/AppsHomeView.swift +++ /dev/null @@ -1,266 +0,0 @@ -// -// AppsHomeView.swift -// OpenAppLock -// - -import SwiftData -import SwiftUI - -/// Home screen using plain iOS components: a List with a "Blocked Apps" -/// section (what's blocking right now) and a "Rules" section listing every -/// rule with its live status. "+" in the toolbar creates a new rule. -struct AppsHomeView: View { - @Environment(\.modelContext) private var modelContext - @Environment(RuleEnforcer.self) private var enforcer - @Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule] - - @State private var detailRule: BlockingRule? - @State private var showingNewRule = false - @State private var unblockCandidate: BlockingRule? - @State private var hardModeBlockedAttempt = false - - var body: some View { - NavigationStack { - TimelineView(.periodic(from: .now, by: 30)) { timeline in - ruleList(now: timeline.date) - } - .navigationTitle("Apps") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("New Rule", systemImage: "plus") { - showingNewRule = true - } - .accessibilityIdentifier("newRuleButton") - } - } - } - .sheet(item: $detailRule) { rule in - RuleDetailSheet(rule: rule) - } - .sheet(isPresented: $showingNewRule) { - NewRuleSheet() - } - .confirmationDialog( - "Unblock \(unblockCandidate?.name ?? "")?", - isPresented: Binding( - get: { unblockCandidate != nil }, - set: { if !$0 { unblockCandidate = nil } } - ), - titleVisibility: .visible - ) { - Button("Unblock", role: .destructive) { - if let rule = unblockCandidate { - RulePolicy.unblock(rule, usage: enforcer.usage(for: rule)) - refreshEnforcement() - } - unblockCandidate = nil - } - } message: { - Text("Blocking resumes with the rule's next window.") - } - .alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) { - Button("OK", role: .cancel) {} - } message: { - Text("This block can't be lifted until it ends.") - } - .task { - await enforcementLoop() - } - .onChange(of: ruleChangeToken) { - refreshEnforcement() - } - } - - private func ruleList(now: Date) -> some View { - List { - blockedSection(now: now) - usageSection(now: now) - rulesSection(now: now) - } - } - - /// Status with the day's usage folded in, so limit rules whose budget is - /// spent count as actively blocking. - private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus { - rule.status(at: now, usage: enforcer.usage(for: rule, at: now)) - } - - // MARK: - Blocked Apps - - @ViewBuilder - private func blockedSection(now: Date) -> some View { - let blocking = rules.filter { liveStatus(for: $0, now: now).isActive } - Section { - if blocking.isEmpty { - Text("Nothing is blocked right now.") - .foregroundStyle(.secondary) - .accessibilityIdentifier("nothingBlockedLabel") - } else { - ForEach(blocking) { rule in - blockedRow(for: rule, now: now) - } - } - } header: { - Text("Blocked Apps").textCase(nil) - } - } - - private func blockedRow(for rule: BlockingRule, now: Date) -> some View { - Button { - if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) { - unblockCandidate = rule - } else { - hardModeBlockedAttempt = true - } - } label: { - HStack { - Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill") - .foregroundStyle(rule.hardMode ? .red : .secondary) - .frame(width: 28) - Text(rule.name) - .foregroundStyle(Color.primary) - Spacer() - Text("Unblock") - .foregroundStyle(.tint) - } - } - .accessibilityIdentifier("blockedTile-\(rule.name)") - } - - // MARK: - Usage - - /// Live tracking for every limit rule scheduled today: how much of the - /// daily budget is spent and what remains. - @ViewBuilder - private func usageSection(now: Date) -> some View { - let tracked = rules.filter { - $0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now) - } - if !tracked.isEmpty { - Section { - ForEach(tracked) { rule in - usageRow(for: rule, now: now) - } - } header: { - Text("Usage").textCase(nil) - } - } - } - - private func usageRow(for rule: BlockingRule, now: Date) -> some View { - let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage() - let isPaused = - if case .paused = liveStatus(for: rule, now: now) { true } else { false } - return HStack { - Image(systemName: rule.kind.symbolName) - .foregroundStyle(.tint) - .frame(width: 28) - VStack(alignment: .leading, spacing: 2) { - Text(rule.name) - .foregroundStyle(Color.primary) - Text(UsageDisplay.subtitle(for: rule, usage: usage)) - .font(.caption) - .foregroundStyle(Color.secondary) - } - Spacer() - Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused)) - .font(.subheadline) - .foregroundStyle( - rule.limitReached(given: usage) && !isPaused - ? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary) - ) - } - .accessibilityElement(children: .combine) - .accessibilityIdentifier("usageRow-\(rule.name)") - } - - // MARK: - Rules - - @ViewBuilder - private func rulesSection(now: Date) -> some View { - Section { - if rules.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("No rules yet") - .font(.headline) - Text("Create a rule to block distracting apps on a schedule.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("emptyRulesCard") - } else { - ForEach(rules) { rule in - ruleRow(for: rule, now: now) - } - } - } header: { - Text("Rules").textCase(nil) - } - } - - private func ruleRow(for rule: BlockingRule, now: Date) -> some View { - let status = liveStatus(for: rule, now: now) - return Button { - detailRule = rule - } label: { - HStack { - Image(systemName: rule.kind.symbolName) - .foregroundStyle(.tint) - .frame(width: 28) - VStack(alignment: .leading, spacing: 2) { - Text(rule.name) - .foregroundStyle(Color.primary) - Text(blockSummary(for: rule)) - .font(.caption) - .foregroundStyle(Color.secondary) - } - Spacer() - Text(statusText(for: rule, status: status, now: now)) - .font(.subheadline) - .foregroundStyle(status.isActive ? .green : .secondary) - .accessibilityIdentifier("ruleStatus-\(rule.name)") - } - } - .accessibilityIdentifier("ruleCard-\(rule.name)") - } - - private func blockSummary(for rule: BlockingRule) -> String { - "\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")" - } - - private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String { - // Shared with the detail sheet: limit rules that aren't blocking show - // their daily budget; everything else uses the live status label. - rule.statusLabel(for: status, relativeTo: now) - } - - // MARK: - Enforcement - - /// Changes whenever any rule's blocking-relevant state changes. - private var ruleChangeToken: String { - rules.map { - "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|" - + "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|" - + "\($0.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|" - + "\($0.appList?.selectionCount ?? 0)|" - + "\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" - } - .joined(separator: ",") - } - - private func refreshEnforcement() { - enforcer.refresh(rules: rules) - } - - /// Keeps shields in sync while the app is open, so windows that begin or - /// end while the user is looking at the screen take effect promptly. - private func enforcementLoop() async { - while !Task.isCancelled { - let allRules = (try? modelContext.fetch(FetchDescriptor())) ?? [] - enforcer.refresh(rules: allRules) - try? await Task.sleep(for: .seconds(30)) - } - } -} diff --git a/OpenAppLock/Views/Home/HomeView.swift b/OpenAppLock/Views/Home/HomeView.swift new file mode 100644 index 0000000..6fe74c7 --- /dev/null +++ b/OpenAppLock/Views/Home/HomeView.swift @@ -0,0 +1,165 @@ +// +// HomeView.swift +// OpenAppLock +// + +import SwiftData +import SwiftUI + +/// The Home tab: what's blocking right now, and live usage for today's limit +/// rules. The rule list and rule creation live on the Rules tab. +struct HomeView: View { + @Environment(RuleEnforcer.self) private var enforcer + @Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule] + + @State private var unblockCandidate: BlockingRule? + @State private var hardModeBlockedAttempt = false + + var body: some View { + NavigationStack { + TimelineView(.periodic(from: .now, by: 30)) { timeline in + homeList(now: timeline.date) + } + .navigationTitle("Home") + } + .confirmationDialog( + "Unblock \(unblockCandidate?.name ?? "")?", + isPresented: Binding( + get: { unblockCandidate != nil }, + set: { if !$0 { unblockCandidate = nil } } + ), + titleVisibility: .visible + ) { + Button("Unblock", role: .destructive) { + if let rule = unblockCandidate { + RulePolicy.unblock(rule, usage: enforcer.usage(for: rule)) + enforcer.refresh(rules: rules) + } + unblockCandidate = nil + } + } message: { + Text("Blocking resumes with the rule's next window.") + } + .alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) { + Button("OK", role: .cancel) {} + } message: { + Text("This block can't be lifted until it ends.") + } + } + + private func homeList(now: Date) -> some View { + List { + blockingSection(now: now) + usageSection(now: now) + } + } + + /// Status with the day's usage folded in, so limit rules whose budget is + /// spent count as actively blocking. + private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus { + rule.status(at: now, usage: enforcer.usage(for: rule, at: now)) + } + + // MARK: - Currently Blocking + + @ViewBuilder + private func blockingSection(now: Date) -> some View { + let blocking = rules.filter { liveStatus(for: $0, now: now).isActive } + Section { + if blocking.isEmpty { + Text("Nothing is blocking right now.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("nothingBlockedLabel") + } else { + ForEach(blocking) { rule in + blockingRow(for: rule, now: now) + } + } + } header: { + Text("Currently Blocking").textCase(nil) + } + } + + /// A blocking rule: no leading icon. A limit rule shows its type + usage so + /// the kind reads without an icon; a schedule rule shows just its name. + /// Trailing affordance: a lock when Hard Mode (the block can't be lifted), + /// otherwise an Unblock button. + private func blockingRow(for rule: BlockingRule, now: Date) -> some View { + let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage() + return Button { + if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) { + unblockCandidate = rule + } else { + hardModeBlockedAttempt = true + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(rule.name) + .foregroundStyle(Color.primary) + if rule.kind != .schedule { + Text(UsageDisplay.typedSubtitle(for: rule, usage: usage)) + .font(.caption) + .foregroundStyle(Color.secondary) + } + } + Spacer() + if rule.hardMode { + Image(systemName: "lock.fill") + .foregroundStyle(.red) + } else { + Text("Unblock") + .foregroundStyle(.tint) + } + } + } + .accessibilityIdentifier("blockedTile-\(rule.name)") + } + + // MARK: - Usage + + /// Live tracking for every limit rule scheduled today that is *not* already + /// blocking. Once a budget is spent (the rule is actively blocking) the row + /// moves up to "Currently Blocking"; a soft-unblocked rule (paused) stays + /// here reading "Unblocked until tomorrow". + @ViewBuilder + private func usageSection(now: Date) -> some View { + let tracked = rules.filter { + $0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now) + && !liveStatus(for: $0, now: now).isActive + } + if !tracked.isEmpty { + Section { + ForEach(tracked) { rule in + usageRow(for: rule, now: now) + } + } header: { + Text("Usage").textCase(nil) + } + } + } + + private func usageRow(for rule: BlockingRule, now: Date) -> some View { + let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage() + let isPaused = + if case .paused = liveStatus(for: rule, now: now) { true } else { false } + return HStack { + VStack(alignment: .leading, spacing: 2) { + Text(rule.name) + .foregroundStyle(Color.primary) + Text(UsageDisplay.typedSubtitle(for: rule, usage: usage)) + .font(.caption) + .foregroundStyle(Color.secondary) + } + Spacer() + Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused)) + .font(.subheadline) + .foregroundStyle( + rule.limitReached(given: usage) && !isPaused + ? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary) + ) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("usageRow-\(rule.name)") + } +} diff --git a/OpenAppLock/Views/MainTabView.swift b/OpenAppLock/Views/MainTabView.swift new file mode 100644 index 0000000..8c8ce38 --- /dev/null +++ b/OpenAppLock/Views/MainTabView.swift @@ -0,0 +1,68 @@ +// +// MainTabView.swift +// OpenAppLock +// + +import SwiftData +import SwiftUI + +/// The post-onboarding shell: a three-tab layout (Home / Rules / Settings). +/// +/// The app-level enforcement lifecycle lives here, not on any one tab, so it +/// runs regardless of the selected tab: a 30 s `refresh` loop, a reconcile on +/// any blocking-relevant rule change, and a reconcile whenever the app becomes +/// active (so Uninstall Protection re-evaluates on every foreground). +struct MainTabView: View { + @Environment(\.modelContext) private var modelContext + @Environment(RuleEnforcer.self) private var enforcer + @Environment(\.scenePhase) private var scenePhase + @Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule] + + var body: some View { + TabView { + HomeView() + .tabItem { Label("Home", systemImage: "house") } + RulesListView() + .tabItem { Label("Rules", systemImage: "shield.lefthalf.filled") } + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") } + } + .task { + await enforcementLoop() + } + .onChange(of: ruleChangeToken) { + refreshEnforcement() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { refreshEnforcement() } + } + } + + // MARK: - Enforcement + + /// Changes whenever any rule's blocking-relevant state changes. + private var ruleChangeToken: String { + rules.map { + "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|" + + "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|" + + "\($0.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|" + + "\($0.appList?.selectionCount ?? 0)|" + + "\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" + } + .joined(separator: ",") + } + + private func refreshEnforcement() { + enforcer.refresh(rules: rules) + } + + /// Keeps shields in sync while the app is open, so windows that begin or + /// end while the user is looking at the screen take effect promptly. + private func enforcementLoop() async { + while !Task.isCancelled { + let allRules = (try? modelContext.fetch(FetchDescriptor())) ?? [] + enforcer.refresh(rules: allRules) + try? await Task.sleep(for: .seconds(30)) + } + } +} diff --git a/OpenAppLock/Views/RootView.swift b/OpenAppLock/Views/RootView.swift index 6ed19f6..085a162 100644 --- a/OpenAppLock/Views/RootView.swift +++ b/OpenAppLock/Views/RootView.swift @@ -15,7 +15,7 @@ struct RootView: View { var body: some View { Group { if hasCompletedOnboarding { - AppsHomeView() + MainTabView() } else { OnboardingView { hasCompletedOnboarding = true diff --git a/OpenAppLock/Views/Rules/RulesListView.swift b/OpenAppLock/Views/Rules/RulesListView.swift new file mode 100644 index 0000000..efab762 --- /dev/null +++ b/OpenAppLock/Views/Rules/RulesListView.swift @@ -0,0 +1,109 @@ +// +// RulesListView.swift +// OpenAppLock +// + +import SwiftData +import SwiftUI + +/// The Rules tab: every rule, grouped into Schedule / Time Limit / Open Limit +/// sections. "+" creates a new rule; tapping a row opens its detail sheet. +struct RulesListView: View { + @Environment(RuleEnforcer.self) private var enforcer + @Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule] + + @State private var detailRule: BlockingRule? + @State private var showingNewRule = false + + var body: some View { + NavigationStack { + TimelineView(.periodic(from: .now, by: 30)) { timeline in + rulesList(now: timeline.date) + } + .navigationTitle("Rules") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("New Rule", systemImage: "plus") { + showingNewRule = true + } + .accessibilityIdentifier("newRuleButton") + } + } + } + .sheet(item: $detailRule) { rule in + RuleDetailSheet(rule: rule) + } + .sheet(isPresented: $showingNewRule) { + NewRuleSheet() + } + } + + @ViewBuilder + private func rulesList(now: Date) -> some View { + List { + if rules.isEmpty { + Section { + VStack(alignment: .leading, spacing: 4) { + Text("No rules yet") + .font(.headline) + Text("Create a rule to block distracting apps on a schedule.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("emptyRulesCard") + } + } else { + kindSection(.schedule, now: now) + kindSection(.timeLimit, now: now) + kindSection(.openLimit, now: now) + } + } + } + + /// One section per rule kind; empty kinds are omitted entirely. + @ViewBuilder + private func kindSection(_ kind: RuleKind, now: Date) -> some View { + let kindRules = rules.filter { $0.kind == kind } + if !kindRules.isEmpty { + Section { + ForEach(kindRules) { rule in + ruleRow(for: rule, now: now) + } + } header: { + Text(kind.displayName).textCase(nil) + } + } + } + + private func ruleRow(for rule: BlockingRule, now: Date) -> some View { + let status = rule.status(at: now, usage: enforcer.usage(for: rule, at: now)) + return Button { + detailRule = rule + } label: { + HStack { + Image(systemName: rule.kind.symbolName) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(rule.name) + .foregroundStyle(Color.primary) + Text(blockSummary(for: rule)) + .font(.caption) + .foregroundStyle(Color.secondary) + } + Spacer() + Text(rule.statusLabel(for: status, relativeTo: now)) + .font(.subheadline) + .foregroundStyle(status.isActive ? .green : .secondary) + .accessibilityIdentifier("ruleStatus-\(rule.name)") + } + } + .accessibilityIdentifier("ruleCard-\(rule.name)") + } + + private func blockSummary(for rule: BlockingRule) -> String { + "\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")" + } +} diff --git a/OpenAppLock/Views/Settings/SettingsView.swift b/OpenAppLock/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..482e9f5 --- /dev/null +++ b/OpenAppLock/Views/Settings/SettingsView.swift @@ -0,0 +1,63 @@ +// +// SettingsView.swift +// OpenAppLock +// + +import SwiftData +import SwiftUI + +/// The Settings tab: device-level protection and app-list management. +struct SettingsView: View { + @Environment(RuleEnforcer.self) private var enforcer + @Environment(AppSettingsStore.self) private var settings + @Query private var rules: [BlockingRule] + + /// Local mirror of the persisted setting; the explicit binding below writes + /// it through to the store and re-enforces in one step. + @State private var uninstallProtectionOn = false + + var body: some View { + NavigationStack { + List { + Section { + Toggle("Uninstall Protection", isOn: uninstallProtectionBinding) + .accessibilityIdentifier("uninstallProtectionToggle") + } header: { + Text("Protection").textCase(nil) + } footer: { + Text( + "While on, apps can't be deleted from this device whenever a " + + "Hard Mode rule is actively blocking — so the block can't be " + + "removed by uninstalling.") + } + Section { + NavigationLink { + ManageAppListsView() + } label: { + Label("Manage App Lists", systemImage: "square.stack.3d.up") + } + .accessibilityIdentifier("manageAppListsButton") + } header: { + Text("App Lists").textCase(nil) + } + } + .navigationTitle("Settings") + } + .onAppear { + uninstallProtectionOn = settings.uninstallProtectionEnabled + } + } + + /// Drives the toggle's visual state from `@State` while persisting and + /// re-enforcing on every change — so protection engages/lifts immediately. + private var uninstallProtectionBinding: Binding { + Binding( + get: { uninstallProtectionOn }, + set: { newValue in + uninstallProtectionOn = newValue + settings.uninstallProtectionEnabled = newValue + enforcer.refresh(rules: rules) + } + ) + } +} diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index fcab18c..2d433d1 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -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) + } +} diff --git a/OpenAppLockTests/RulePolicyTests.swift b/OpenAppLockTests/RulePolicyTests.swift index a0942ed..86d42d1 100644 --- a/OpenAppLockTests/RulePolicyTests.swift +++ b/OpenAppLockTests/RulePolicyTests.swift @@ -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)) + } +} diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index a28d2ce..fb687fd 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -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") + } } diff --git a/OpenAppLockUITests/AppListUITests.swift b/OpenAppLockUITests/AppListUITests.swift index 018b577..c8bc8aa 100644 --- a/OpenAppLockUITests/AppListUITests.swift +++ b/OpenAppLockUITests/AppListUITests.swift @@ -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() diff --git a/OpenAppLockUITests/OnboardingUITests.swift b/OpenAppLockUITests/OnboardingUITests.swift index 508463f..fc7f3aa 100644 --- a/OpenAppLockUITests/OnboardingUITests.swift +++ b/OpenAppLockUITests/OnboardingUITests.swift @@ -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) } } diff --git a/OpenAppLockUITests/RuleCreationUITests.swift b/OpenAppLockUITests/RuleCreationUITests.swift index 775abf5..d121f22 100644 --- a/OpenAppLockUITests/RuleCreationUITests.swift +++ b/OpenAppLockUITests/RuleCreationUITests.swift @@ -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() diff --git a/OpenAppLockUITests/RuleManagementUITests.swift b/OpenAppLockUITests/RuleManagementUITests.swift index 5b8bd11..920eb0d 100644 --- a/OpenAppLockUITests/RuleManagementUITests.swift +++ b/OpenAppLockUITests/RuleManagementUITests.swift @@ -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. diff --git a/OpenAppLockUITests/SettingsUITests.swift b/OpenAppLockUITests/SettingsUITests.swift new file mode 100644 index 0000000..b2c136d --- /dev/null +++ b/OpenAppLockUITests/SettingsUITests.swift @@ -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" + ) + } +} diff --git a/OpenAppLockUITests/UITestSupport.swift b/OpenAppLockUITests/UITestSupport.swift index 0304f87..cbb7fcc 100644 --- a/OpenAppLockUITests/UITestSupport.swift +++ b/OpenAppLockUITests/UITestSupport.swift @@ -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 diff --git a/OpenAppLockUITests/UsageUITests.swift b/OpenAppLockUITests/UsageUITests.swift index 70bac34..20c4a86 100644 --- a/OpenAppLockUITests/UsageUITests.swift +++ b/OpenAppLockUITests/UsageUITests.swift @@ -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)") diff --git a/Shared/ShieldController.swift b/Shared/ShieldController.swift index 7886f0b..d2bd14e 100644 --- a/Shared/ShieldController.swift +++ b/Shared/ShieldController.swift @@ -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) + /// 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 } } diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 50d5d3b..4768f46 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -91,14 +91,16 @@ Dark theme throughout (near-black background, very dark green tint). *(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day also appear here, blocked until midnight. 3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section - directly below Blocked Apps showing live tracking for every enabled - Time/Open Limit rule scheduled today: - - Time Limit row: subtitle "18m of 45m used today", trailing "27m left"; - when spent: "Blocked until tomorrow" (red). - - Open Limit row: subtitle "2 of 5 opens today", trailing "3 opens left"; - when spent: "Blocked until tomorrow" (red). - Usage numbers come from the shared app-group ledger written by the - DeviceActivity monitor and shield-action extensions. + showing live tracking for every enabled Time/Open Limit rule scheduled today + **that is not currently blocking**. Each row leads its subtitle with the rule + **type** so the kind is clear without relying on the icon: + - Time Limit row: subtitle "Time Limit · 18m of 45m used today", trailing "27m left". + - Open Limit row: subtitle "Open Limit · 2 of 5 opens today", trailing "3 opens left". + A rule whose budget is **spent** (actively blocking) **moves out of Usage into + the "Currently Blocking" section** (it shows its type + usage there instead); + a *soft-unblocked* spent rule is paused (not blocking), so it returns to Usage + reading "Unblocked until tomorrow". Usage numbers come from the shared app-group + ledger written by the DeviceActivity monitor and shield-action extensions. 3. **Rules** — header row: "Rules ›" (leading, tappable to a full list, presumably) and "**+ New**" (trailing, green tint) which opens the New Rule sheet. @@ -514,11 +516,28 @@ Xh", "Xh left") is **derived**, not stored. OpenAppLock has since replaced the Opal-style custom presentation with the bare iOS design language, keeping the backend (models, logic, services), the flows, and the accessibility identifiers intact. Sections 1–5 remain as the -reference for *what* the feature does; presentation now maps as follows: +reference for *what* the feature does; presentation now maps as follows. + +After onboarding the app is a three-tab `TabView` (`MainTabView`), each tab its +own `NavigationStack`: + +``` +TabView: [Home] [Rules] [Settings] + │ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode) + │ └── rules grouped into Schedule / Time Limit / Open Limit sections; "+" ─▶ New Rule sheet + │ └── tap a rule row ─▶ Rule Detail sheet ─▶ "Edit Rule" ─▶ Rule Editor + └── "Currently Blocking" section + "Usage" section +``` + +The app-level **enforcement lifecycle** (the `enforcer.refresh` 30 s loop, the +rule-change reconcile, and a scene-active reconcile) lives on `MainTabView`, so +it runs regardless of the selected tab. | Spec element | Native presentation | |---|---| -| Apps home | `NavigationStack` + `List`; "Blocked Apps" and "Rules" sections; **rules are list rows** (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button | +| Home tab | `NavigationStack` + `List`. **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: **no leading icon**; a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage) with a ` · ` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a ` · NN of MM used today` subtitle + trailing remaining/blocked label. | +| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, block summary, trailing live status — green when active); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. | +| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). | | Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | | New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` | | Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a **checkmark** in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an **ellipsis menu** ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule | @@ -526,3 +545,22 @@ reference for *what* the feature does; presentation now maps as follows: Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`, icon-pair/circle-button chrome. + +### 6.1 Uninstall Protection (Settings) + +A device-wide opt-in that makes Hard Mode harder to escape: while it is on **and** +any Hard Mode rule is actively blocking, the user cannot delete apps from the +device. `RulePolicy.shouldDenyAppRemoval(rules:enabled:usageFor:)` (= setting on +AND any rule `isHardLocked`) is the single gate; `RuleEnforcer.refresh` applies it +through `ShieldApplying.setAppRemovalDenied`, which sets +`ManagedSettingsStore(named: "uninstall-protection").application.denyAppRemoval` +(`true` to engage, `nil` to relinquish) on a **dedicated** store so per-rule +shield clears never touch it. The setting persists in the app-group defaults +(`uninstallProtectionEnabled`). + +Enforced on the **foreground path only** for v1 (launch + 30 s loop + rule change ++ scene-active). Known limitation: a Hard Mode window that *ends* while the app is +closed leaves protection engaged until the app is next foregrounded — the safe +failure direction for a locker. Background recompute in the monitor extension is a +follow-up. Like all Screen Time behavior, the real device effect is only +observable on a device (the simulator uses mock shields).