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:
2026-06-13 22:17:08 -04:00
parent 0a0d00f53a
commit a658d196c4
26 changed files with 1039 additions and 427 deletions

View File

@@ -59,6 +59,17 @@ enum RulePolicy {
!isHardLocked(rule, usage: usage, at: now, calendar: calendar) !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 /// 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 /// 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 /// 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 }, rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current at now: Date = .now, calendar: Calendar = .current
) -> Bool { ) -> Bool {
!rules.contains { !isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
isHardLocked($0, usage: usageFor($0), 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) /// Pauses the rule's current block. Returns false (and changes nothing)

View File

@@ -8,6 +8,15 @@ import Foundation
/// Strings for the home screen's Usage section. Used values clamp to the /// 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". /// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
enum UsageDisplay { 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". /// "18m of 45m used today" / "2 of 5 opens today".
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String { static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
switch rule.configuration { switch rule.configuration {

View File

@@ -13,6 +13,7 @@ struct OpenAppLockApp: App {
private let container: ModelContainer private let container: ModelContainer
@State private var authorization: ScreenTimeAuthorization @State private var authorization: ScreenTimeAuthorization
@State private var enforcer: RuleEnforcer @State private var enforcer: RuleEnforcer
@State private var settings: AppSettingsStore
init() { init() {
let config = LaunchConfiguration.current let config = LaunchConfiguration.current
@@ -21,6 +22,15 @@ struct OpenAppLockApp: App {
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding") 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 schema = Schema([BlockingRule.self, AppList.self])
let modelConfiguration = ModelConfiguration( let modelConfiguration = ModelConfiguration(
schema: schema, isStoredInMemoryOnly: config.isUITesting schema: schema, isStoredInMemoryOnly: config.isUITesting
@@ -58,7 +68,7 @@ struct OpenAppLockApp: App {
_enforcer = State( _enforcer = State(
initialValue: RuleEnforcer( initialValue: RuleEnforcer(
shields: shields, usage: usageLedger, scheduler: scheduler, shields: shields, usage: usageLedger, scheduler: scheduler,
openSessions: openSessions)) openSessions: openSessions, settings: appSettings))
} }
var body: some Scene { var body: some Scene {
@@ -66,6 +76,7 @@ struct OpenAppLockApp: App {
RootView() RootView()
.environment(authorization) .environment(authorization)
.environment(enforcer) .environment(enforcer)
.environment(settings)
} }
.modelContainer(container) .modelContainer(container)
} }

View File

@@ -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
}
}

View File

@@ -27,16 +27,21 @@ final class RuleEnforcer {
/// Granted-open sessions, so a proactively-gated open-limit rule is left /// 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. /// un-shielded while the user is inside a session they paid an open for.
private let openSessions: OpenSessionReading private let openSessions: OpenSessionReading
/// App-wide settings (currently just Uninstall Protection) consulted on
/// every refresh.
private let settings: any AppSettingsReading
init( init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(), shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil, scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore() openSessions: OpenSessionReading = OpenSessionStore(),
settings: any AppSettingsReading = AppSettingsStore()
) { ) {
self.shields = shields self.shields = shields
self.usageReader = usage self.usageReader = usage
self.scheduler = scheduler self.scheduler = scheduler
self.openSessions = openSessions self.openSessions = openSessions
self.settings = settings
} }
/// The day's usage for a rule (nil for schedule rules, which don't track). /// 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 // "Blocked Apps" lists only rules whose budget/window is spent not the
// proactive open-limit gate, which surfaces under "Usage" instead. // proactive open-limit gate, which surfaces under "Usage" instead.
blockingRuleIDs = blocking 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) scheduler?.sync(rules: rules, at: now)
} }

View File

@@ -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<AppList?>?
/// 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)
}
}

View File

@@ -3,151 +3,31 @@
// OpenAppLock // OpenAppLock
// //
import SwiftData
import SwiftUI import SwiftUI
/// Chooses the app list a rule uses: saved lists (tap to select), an Edit /// Chooses the app list a rule uses. Wraps the shared `AppListLibraryView` in
/// affordance per list, and a "New List" flow. Lists are persisted directly, /// picker mode (checkmark + select-and-dismiss); the library handles the list
/// independent of any rule, so creating one here never depends on the rule /// rows, Edit/New flows, deletion, and the Hard Mode lock.
/// draft being committed.
struct AppListPickerSheet: View { struct AppListPickerSheet: View {
@Binding var selected: AppList? @Binding var selected: AppList?
@Environment(\.dismiss) private var dismiss @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 { var body: some View {
NavigationStack { NavigationStack {
List { AppListLibraryView(selection: $selected, onPick: { dismiss() })
Section { .navigationTitle("App List")
if lists.isEmpty { .navigationBarTitleDisplayMode(.inline)
Text("No app lists yet. Create one to choose which apps this rule affects.") .toolbar {
.foregroundStyle(.secondary) ToolbarItem(placement: .cancellationAction) {
.accessibilityIdentifier("emptyAppListsLabel") Button("Close", systemImage: "xmark") {
} else { dismiss()
ForEach(lists) { list in
listRow(list)
} }
} .accessibilityIdentifier("closeAppListPickerButton")
} 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")
}
}
.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 { extension AppList {

View File

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

View File

@@ -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<BlockingRule>())) ?? []
enforcer.refresh(rules: allRules)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -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)")
}
}

View File

@@ -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<BlockingRule>())) ?? []
enforcer.refresh(rules: allRules)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -15,7 +15,7 @@ struct RootView: View {
var body: some View { var body: some View {
Group { Group {
if hasCompletedOnboarding { if hasCompletedOnboarding {
AppsHomeView() MainTabView()
} else { } else {
OnboardingView { OnboardingView {
hasCompletedOnboarding = true hasCompletedOnboarding = true

View File

@@ -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")"
}
}

View File

@@ -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<Bool> {
Binding(
get: { uninstallProtectionOn },
set: { newValue in
uninstallProtectionOn = newValue
settings.uninstallProtectionEnabled = newValue
enforcer.refresh(rules: rules)
}
)
}
}

View File

@@ -269,3 +269,71 @@ struct OverlappingRuleEnforcementTests {
#expect(shields.shieldedRuleIDs == [rule.id]) #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) #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) let over = RuleUsage(minutesUsed: 60)
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today") #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")
}
} }

View File

@@ -6,7 +6,8 @@
import XCTest import XCTest
/// App lists: the editor's App List row, the picker (select / create / edit), /// 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 { final class AppListUITests: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
continueAfterFailure = false continueAfterFailure = false
@@ -14,6 +15,7 @@ final class AppListUITests: XCTestCase {
func testScheduleEditorOffersModeChoice() throws { func testScheduleEditorOffersModeChoice() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -25,6 +27,7 @@ final class AppListUITests: XCTestCase {
func testLimitEditorsAreBlockOnly() throws { func testLimitEditorsAreBlockOnly() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap() app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -46,6 +49,7 @@ final class AppListUITests: XCTestCase {
func testCreateAppListFlowSelectsNewList() throws { func testCreateAppListFlowSelectsNewList() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap() app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -79,6 +83,7 @@ final class AppListUITests: XCTestCase {
func testDetailShowsAppListName() throws { func testDetailShowsAppListName() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Work Time"].waitToAppear().tap() app.buttons["ruleCard-Work Time"].waitToAppear().tap()
let row = app.element("detailRow-Block").waitToAppear() let row = app.element("detailRow-Block").waitToAppear()
@@ -87,6 +92,7 @@ final class AppListUITests: XCTestCase {
func testHardModeSessionLocksAppListEditing() throws { func testHardModeSessionLocksAppListEditing() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
app.goToRulesTab()
// "Sleep" is soft and editable even while "Locked In" hard-blocks. // "Sleep" is soft and editable even while "Locked In" hard-blocks.
app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["ruleCard-Sleep"].waitToAppear().tap()
@@ -103,6 +109,7 @@ final class AppListUITests: XCTestCase {
func testAppListsEditableWithoutHardSession() throws { func testAppListsEditableWithoutHardSession() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap() app.buttons["editRuleButton"].waitToAppear().tap()

View File

@@ -17,15 +17,17 @@ final class OnboardingUITests: XCTestCase {
app.staticTexts["OpenAppLock"].waitToAppear() app.staticTexts["OpenAppLock"].waitToAppear()
app.buttons["onboardingContinueButton"].waitToAppear().tap() 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["allowScreenTimeButton"].waitToAppear().tap()
app.buttons["newRuleButton"].waitToAppear() app.tabBars.buttons["Home"].waitToAppear()
XCTAssertTrue(app.staticTexts["Blocked Apps"].exists) XCTAssertTrue(app.tabBars.buttons["Rules"].exists)
XCTAssertTrue(app.tabBars.buttons["Settings"].exists)
XCTAssertTrue(app.staticTexts["Currently Blocking"].exists)
} }
func testCompletedOnboardingIsSkipped() throws { func testCompletedOnboardingIsSkipped() throws {
let app = XCUIApplication.launchOpenAppLock(onboardingCompleted: true) let app = XCUIApplication.launchOpenAppLock(onboardingCompleted: true)
app.buttons["newRuleButton"].waitToAppear() app.tabBars.buttons["Home"].waitToAppear()
XCTAssertFalse(app.buttons["onboardingContinueButton"].exists) XCTAssertFalse(app.buttons["onboardingContinueButton"].exists)
} }
} }

View File

@@ -12,6 +12,7 @@ final class RuleCreationUITests: XCTestCase {
func testCreateScheduleRuleFromTypeCard() throws { func testCreateScheduleRuleFromTypeCard() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.element("emptyRulesCard").waitToAppear() app.element("emptyRulesCard").waitToAppear()
app.buttons["newRuleButton"].tap() app.buttons["newRuleButton"].tap()
@@ -33,6 +34,7 @@ final class RuleCreationUITests: XCTestCase {
func testCreateRuleFromPreset() throws { func testCreateRuleFromPreset() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["preset-work-time"].waitToAppear().tap() app.buttons["preset-work-time"].waitToAppear().tap()
@@ -44,6 +46,7 @@ final class RuleCreationUITests: XCTestCase {
func testRenameRuleInEditor() throws { func testRenameRuleInEditor() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -63,6 +66,7 @@ final class RuleCreationUITests: XCTestCase {
func testDayTogglesFillRowAndHaveLargeTapTargets() throws { func testDayTogglesFillRowAndHaveLargeTapTargets() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -81,6 +85,7 @@ final class RuleCreationUITests: XCTestCase {
func testDayTogglesUpdateSummary() throws { func testDayTogglesUpdateSummary() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -93,6 +98,7 @@ final class RuleCreationUITests: XCTestCase {
func testCreateTimeLimitRule() throws { func testCreateTimeLimitRule() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap() app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -109,6 +115,7 @@ final class RuleCreationUITests: XCTestCase {
func testAdultContentToggleFlowsToDetail() throws { func testAdultContentToggleFlowsToDetail() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -126,6 +133,7 @@ final class RuleCreationUITests: XCTestCase {
func testAdultContentDefaultsToAllowed() throws { func testAdultContentDefaultsToAllowed() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Work Time"].waitToAppear().tap() app.buttons["ruleCard-Work Time"].waitToAppear().tap()
let row = app.element("detailRow-Adult websites").waitToAppear() let row = app.element("detailRow-Adult websites").waitToAppear()
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)") 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. /// not offer the toggle, and the rule's detail must not show the row.
func testTimeLimitOmitsAdultContent() throws { func testTimeLimitOmitsAdultContent() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap() app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -159,6 +168,7 @@ final class RuleCreationUITests: XCTestCase {
func testEditorSupportsNativeSwipeBack() throws { func testEditorSupportsNativeSwipeBack() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.staticTexts["ruleEditorTitle"].waitToAppear() app.staticTexts["ruleEditorTitle"].waitToAppear()
@@ -174,6 +184,7 @@ final class RuleCreationUITests: XCTestCase {
func testNewRuleSheetShowsTypesAndPresets() throws { func testNewRuleSheetShowsTypesAndPresets() throws {
let app = XCUIApplication.launchOpenAppLock() let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear() app.buttons["ruleKind-schedule"].waitToAppear()

View File

@@ -7,6 +7,7 @@ import XCTest
/// Detail sheet, editing, disabling, deleting, and unblocking seeded with /// Detail sheet, editing, disabling, deleting, and unblocking seeded with
/// one actively-blocking rule ("Work Time") and one upcoming rule ("Sleep"). /// 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 { final class RuleManagementUITests: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
continueAfterFailure = false continueAfterFailure = false
@@ -14,6 +15,7 @@ final class RuleManagementUITests: XCTestCase {
func testDetailShowsLiveStatusAndFacts() throws { func testDetailShowsLiveStatusAndFacts() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Work Time"].waitToAppear().tap() app.buttons["ruleCard-Work Time"].waitToAppear().tap()
@@ -30,6 +32,7 @@ final class RuleManagementUITests: XCTestCase {
func testEditRuleTogglesHardModeOn() throws { func testEditRuleTogglesHardModeOn() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap() app.buttons["editRuleButton"].waitToAppear().tap()
@@ -44,6 +47,7 @@ final class RuleManagementUITests: XCTestCase {
func testDisableRule() throws { func testDisableRule() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap() app.buttons["editRuleButton"].waitToAppear().tap()
@@ -65,6 +69,7 @@ final class RuleManagementUITests: XCTestCase {
func testDeleteRuleRemovesCard() throws { func testDeleteRuleRemovesCard() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap() app.buttons["editRuleButton"].waitToAppear().tap()
@@ -84,11 +89,13 @@ final class RuleManagementUITests: XCTestCase {
func testUnblockActiveSoftRule() throws { func testUnblockActiveSoftRule() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") 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.buttons["blockedTile-Work Time"].waitToAppear().tap()
app.sheets.buttons["Unblock"].waitToAppear().tap() app.sheets.buttons["Unblock"].waitToAppear().tap()
app.staticTexts["nothingBlockedLabel"].waitToAppear() 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") XCTAssertEqual(app.staticTexts["ruleStatus-Work Time"].waitToAppear().label, "Paused")
} }
} }
@@ -101,6 +108,7 @@ final class HardModeUITests: XCTestCase {
func testHardLockedRuleCannotBeEdited() throws { func testHardLockedRuleCannotBeEdited() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
app.goToRulesTab()
app.buttons["ruleCard-Locked In"].waitToAppear().tap() app.buttons["ruleCard-Locked In"].waitToAppear().tap()
@@ -113,6 +121,8 @@ final class HardModeUITests: XCTestCase {
func testHardLockedRuleCannotBeUnblocked() throws { func testHardLockedRuleCannotBeUnblocked() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") 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() app.buttons["blockedTile-Locked In"].waitToAppear().tap()
// No unblock dialog just the refusal alert. // No unblock dialog just the refusal alert.

View File

@@ -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"
)
}
}

View File

@@ -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 { extension XCUIElement {
/// Asserts the element appears within the timeout, then returns it. /// Asserts the element appears within the timeout, then returns it.
@discardableResult @discardableResult

View File

@@ -5,35 +5,45 @@
import XCTest 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, /// 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 { final class UsageUITests: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
continueAfterFailure = false continueAfterFailure = false
} }
func testUsageSectionShowsTimeAndOpenBudgets() throws { func testUsageSectionShowsTypeAndBudgets() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits") let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists) 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() 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("18m of 45m used today"), "Got: \(timeRow.label)")
XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)") XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)")
let openRow = app.element("usageRow-Gate Keeper").waitToAppear() 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("2 of 5 opens today"), "Got: \(openRow.label)")
XCTAssertTrue(openRow.label.contains("3 opens left"), "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 app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
let spentRow = app.element("usageRow-Doom Scroll").waitToAppear() // A spent budget is a real block: the rule moves out of Usage and into
XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)") // Currently Blocking, carrying its type + usage tracking.
// A spent budget is a real block: the rule surfaces in Blocked Apps. let tile = app.buttons["blockedTile-Doom Scroll"].waitToAppear()
XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists) 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 { func testSpentBudgetCanBeUnblockedUntilTomorrow() throws {
@@ -42,6 +52,7 @@ final class UsageUITests: XCTestCase {
app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap() app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap()
app.sheets.buttons["Unblock"].waitToAppear().tap() app.sheets.buttons["Unblock"].waitToAppear().tap()
// Unblocked paused (not blocking), so it drops back into Usage.
app.staticTexts["nothingBlockedLabel"].waitToAppear() app.staticTexts["nothingBlockedLabel"].waitToAppear()
let row = app.element("usageRow-Doom Scroll").waitToAppear() let row = app.element("usageRow-Doom Scroll").waitToAppear()
XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)") XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)")

View File

@@ -19,6 +19,10 @@ protocol ShieldApplying: AnyObject {
/// Clears every shield except those for the given rule IDs. Covers rules /// Clears every shield except those for the given rule IDs. Covers rules
/// that were deleted or expired while the app was not running. /// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>) 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 /// 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 { private func store(for ruleID: UUID) -> ManagedSettingsStore {
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)")) ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
} }
@@ -91,6 +106,10 @@ final class MockShieldController: ShieldApplying {
private(set) var appliedModes: [UUID: SelectionMode] = [:] private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:] private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
private(set) var appliedSelectionData: [UUID: Data?] = [:] 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( func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
@@ -115,6 +134,11 @@ final class MockShieldController: ShieldApplying {
activeRuleIDs.contains($0.key) activeRuleIDs.contains($0.key)
} }
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) } appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
// Note: appRemovalDenied is intentionally left untouched here.
}
func setAppRemovalDenied(_ denied: Bool) {
appRemovalDenied = denied
} }
} }

View File

@@ -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 *(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day
also appear here, blocked until midnight. also appear here, blocked until midnight.
3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section 3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section
directly below Blocked Apps showing live tracking for every enabled showing live tracking for every enabled Time/Open Limit rule scheduled today
Time/Open Limit rule scheduled today: **that is not currently blocking**. Each row leads its subtitle with the rule
- Time Limit row: subtitle "18m of 45m used today", trailing "27m left"; **type** so the kind is clear without relying on the icon:
when spent: "Blocked until tomorrow" (red). - Time Limit row: subtitle "Time Limit · 18m of 45m used today", trailing "27m left".
- Open Limit row: subtitle "2 of 5 opens today", trailing "3 opens left"; - Open Limit row: subtitle "Open Limit · 2 of 5 opens today", trailing "3 opens left".
when spent: "Blocked until tomorrow" (red). A rule whose budget is **spent** (actively blocking) **moves out of Usage into
Usage numbers come from the shared app-group ledger written by the the "Currently Blocking" section** (it shows its type + usage there instead);
DeviceActivity monitor and shield-action extensions. 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, 3. **Rules** — header row: "Rules " (leading, tappable to a full list,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
sheet. sheet.
@@ -514,11 +516,28 @@ Xh", "Xh left") is **derived**, not stored.
OpenAppLock has since replaced the Opal-style custom presentation with the bare OpenAppLock has since replaced the Opal-style custom presentation with the bare
iOS design language, keeping the backend (models, logic, services), the iOS design language, keeping the backend (models, logic, services), the
flows, and the accessibility identifiers intact. Sections 15 remain as the flows, and the accessibility identifiers intact. Sections 15 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 | | 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 `<Type> · <usage>` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a `<Type> · 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 | | 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:)` | | 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 | | 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`, Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,
icon-pair/circle-button chrome. 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).