From fc0f5186083d31141afbe75801911365c5612cd8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 13 Jun 2026 12:49:16 -0400 Subject: [PATCH] refactor: model rule options as a per-kind sum type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the wide BlockingRule/RuleDraft "god struct" — where every option existed for every kind — with a RuleConfiguration sum type that carries only the options each kind actually has: .schedule(ScheduleConfig: window + selectionMode + blockAdultContent) .timeLimit(TimeLimitConfig: dailyLimitMinutes) .openLimit(OpenLimitConfig: maxOpens) Name, days, Hard Mode, app list, and pause stay common to all kinds. This makes illegal states unrepresentable: Block / Allow Only and Block Adult Content are now structurally Schedule-only. User-visible behavior change: the Time Limit and Open Limit editors no longer offer a Block Adult Content toggle, and their detail sheets drop the "Adult websites" row — those never made sense for a usage budget. Limit rules are always Block and never engage the web-content filter. BlockingRule keeps flat columns as raw persistence behind a computed `configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot wire format and the Screen Time extensions are untouched). Logic, the editors, and the detail sheet all switch on the sum type. Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with new structural-guarantee unit tests and a UI test asserting the Time Limit editor/detail omit adult content. Full suite green (180 tests). --- OpenAppLock.xcodeproj/project.pbxproj | 114 +++++++++--------- OpenAppLock/Logic/RuleStatus.swift | 12 +- OpenAppLock/Logic/UsageDisplay.swift | 22 ++-- OpenAppLock/Models/BlockingRule.swift | 111 ++++++++++++----- OpenAppLock/Models/RuleDraft.swift | 79 ++++++------ OpenAppLock/Services/RuleEnforcer.swift | 6 +- OpenAppLock/Services/SampleRules.swift | 24 ++-- OpenAppLock/Views/Rules/RuleDetailSheet.swift | 19 ++- OpenAppLock/Views/Rules/RuleEditorView.swift | 50 +++++--- OpenAppLockTests/AppListTests.swift | 47 +++++--- OpenAppLockTests/RuleConfigurationTests.swift | 44 +++++++ OpenAppLockTests/RuleEnforcerTests.swift | 8 +- OpenAppLockTests/RuleModelTests.swift | 68 +++++++++-- OpenAppLockTests/RuleStatusTests.swift | 11 +- OpenAppLockTests/SchedulingTests.swift | 13 +- OpenAppLockTests/UsageTests.swift | 30 +++-- OpenAppLockUITests/RuleCreationUITests.swift | 26 ++++ Shared/LimitEnforcement.swift | 4 +- Shared/RuleConfiguration.swift | 90 ++++++++++++++ docs/RULES_FEATURE_SPEC.md | 92 +++++++++----- 20 files changed, 612 insertions(+), 258 deletions(-) create mode 100644 OpenAppLockTests/RuleConfigurationTests.swift create mode 100644 Shared/RuleConfiguration.swift diff --git a/OpenAppLock.xcodeproj/project.pbxproj b/OpenAppLock.xcodeproj/project.pbxproj index 5142dfe..ad13038 100644 --- a/OpenAppLock.xcodeproj/project.pbxproj +++ b/OpenAppLock.xcodeproj/project.pbxproj @@ -13,6 +13,20 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D; + remoteInfo = OpenAppLock; + }; + 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D; + remoteInfo = OpenAppLock; + }; E20000000000000000000001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; @@ -34,22 +48,24 @@ remoteGlobalIDString = C10000000000000000000003; remoteInfo = OpenAppLockShieldAction; }; - 20A7EDDE2E47B7CF0097608D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D; - remoteInfo = OpenAppLock; - }; - 20A7EDE82E47B7CF0097608D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 20A7EDCD2E47B7CD0097608D; - remoteInfo = OpenAppLock; - }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + E10000000000000000000001 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */, + F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */, + F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; }; 20A7EDDD2E47B7CF0097608D /* OpenAppLockTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenAppLockTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -59,6 +75,30 @@ B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000001 /* OpenAppLockMonitor */; + }; + AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000002 /* OpenAppLockShieldConfig */; + }; + AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000003 /* OpenAppLockShieldAction */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 20A7EDD02E47B7CD0097608D /* OpenAppLock */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -106,46 +146,6 @@ }; /* End PBXFileSystemSynchronizedRootGroup section */ -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - AB00000000000000000000B1 /* Exceptions for "OpenAppLockMonitor" folder in "OpenAppLockMonitor" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = C10000000000000000000001 /* OpenAppLockMonitor */; - }; - AB00000000000000000000B2 /* Exceptions for "OpenAppLockShieldConfig" folder in "OpenAppLockShieldConfig" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = C10000000000000000000002 /* OpenAppLockShieldConfig */; - }; - AB00000000000000000000B3 /* Exceptions for "OpenAppLockShieldAction" folder in "OpenAppLockShieldAction" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = C10000000000000000000003 /* OpenAppLockShieldAction */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXCopyFilesBuildPhase section */ - E10000000000000000000001 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */, - F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */, - F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFrameworksBuildPhase section */ 20A7EDCB2E47B7CD0097608D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -308,8 +308,8 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - AA00000000000000000000A2 /* OpenAppLockMonitor */, AA00000000000000000000A1 /* Shared */, + AA00000000000000000000A2 /* OpenAppLockMonitor */, ); name = OpenAppLockMonitor; packageProductDependencies = ( @@ -331,8 +331,8 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - AA00000000000000000000A3 /* OpenAppLockShieldConfig */, AA00000000000000000000A1 /* Shared */, + AA00000000000000000000A3 /* OpenAppLockShieldConfig */, ); name = OpenAppLockShieldConfig; packageProductDependencies = ( @@ -354,8 +354,8 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - AA00000000000000000000A4 /* OpenAppLockShieldAction */, AA00000000000000000000A1 /* Shared */, + AA00000000000000000000A4 /* OpenAppLockShieldAction */, ); name = OpenAppLockShieldAction; packageProductDependencies = ( diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index 2527b0d..db3a06c 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -89,10 +89,10 @@ extension BlockingRule { /// use the plain status label. func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String { if case .upcoming = status { - switch kind { + switch configuration { case .schedule: break - case .timeLimit: return "\(dailyLimitMinutes)m / day" - case .openLimit: return "\(maxOpens) opens / day" + case .timeLimit(let config): return "\(config.dailyLimitMinutes)m / day" + case .openLimit(let config): return "\(config.maxOpens) opens / day" } } return status.label(relativeTo: now) @@ -109,10 +109,10 @@ extension BlockingRule { /// Whether the given usage exhausts this rule's daily budget. /// Always false for schedule rules — they block by the clock. func limitReached(given usage: RuleUsage) -> Bool { - switch kind { + switch configuration { case .schedule: false - case .timeLimit: usage.minutesUsed >= dailyLimitMinutes - case .openLimit: usage.opensUsed >= maxOpens + case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes + case .openLimit(let config): usage.opensUsed >= config.maxOpens } } } diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift index 26fce19..1863233 100644 --- a/OpenAppLock/Logic/UsageDisplay.swift +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -10,14 +10,14 @@ import Foundation enum UsageDisplay { /// "18m of 45m used today" / "2 of 5 opens today". static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String { - switch rule.kind { + switch rule.configuration { case .schedule: "" - case .timeLimit: - "\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of " - + "\(rule.dailyLimitMinutes)m used today" - case .openLimit: - "\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today" + case .timeLimit(let config): + "\(min(usage.minutesUsed, config.dailyLimitMinutes))m of " + + "\(config.dailyLimitMinutes)m used today" + case .openLimit(let config): + "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens today" } } @@ -27,13 +27,13 @@ enum UsageDisplay { guard !rule.limitReached(given: usage) else { return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow" } - switch rule.kind { + switch rule.configuration { case .schedule: return "" - case .timeLimit: - return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left" - case .openLimit: - let remaining = rule.maxOpens - usage.opensUsed + case .timeLimit(let config): + return "\(config.dailyLimitMinutes - usage.minutesUsed)m left" + case .openLimit(let config): + let remaining = config.maxOpens - usage.opensUsed return remaining == 1 ? "1 open left" : "\(remaining) opens left" } } diff --git a/OpenAppLock/Models/BlockingRule.swift b/OpenAppLock/Models/BlockingRule.swift index 80dac2f..e242073 100644 --- a/OpenAppLock/Models/BlockingRule.swift +++ b/OpenAppLock/Models/BlockingRule.swift @@ -8,6 +8,13 @@ import SwiftData /// A recurring screen-time blocking rule. /// +/// The kind-specific options live in a `RuleConfiguration` sum type, exposed +/// through the computed `configuration` bridge below. The raw per-kind columns +/// (`startMinutes`, `selectionModeRaw`, `dailyLimitMinutes`, …) are persistence +/// detail: they are read and written *only* via `configuration`, which keeps a +/// rule from ever carrying another kind's options (e.g. a Time Limit rule can +/// never hold Block Adult Content — see `applyConfiguration`). +/// /// Times are stored as minutes from midnight so the schedule repeats cleanly and /// is independent of time zones at creation. A window whose end is at or before /// its start (e.g. 22:00 → 06:00) crosses midnight into the following day. @@ -19,10 +26,6 @@ final class BlockingRule { var isEnabled: Bool /// Hard block: while the rule is active it cannot be disabled, edited, or unblocked. var hardMode: Bool - /// Engage Screen Time's adult-website filter while this rule is blocking. - /// Inline default so existing stores migrate cleanly. - var blockAdultContent: Bool = false - var selectionModeRaw: String /// The reusable app list this rule blocks (or allows, in Allow Only mode). /// /// Deliberately not an `init` parameter: SwiftData relationship properties @@ -36,51 +39,99 @@ final class BlockingRule { /// Legacy denormalized count; superseded by `appList?.selectionCount`. var selectionCount: Int var dayNumbers: [Int] - var startMinutes: Int - var endMinutes: Int - /// Daily usage budget for `.timeLimit` rules. - var dailyLimitMinutes: Int - /// Daily open budget for `.openLimit` rules. - var maxOpens: Int /// When set, the rule's current window is suspended (user tapped Unblock). /// Cleared automatically once the date passes; never set while Hard Mode is active. var pausedUntil: Date? var createdAt: Date + // MARK: Raw per-kind storage (access via `configuration`) + + /// Schedule-window bounds, minutes from midnight. Meaningful for `.schedule`. + var startMinutes: Int + var endMinutes: Int + /// Schedule-only; forced to `.block` for limit kinds by `applyConfiguration`. + var selectionModeRaw: String + /// Schedule-only; forced to `false` for limit kinds by `applyConfiguration`. + var blockAdultContent: Bool + /// Daily usage budget for `.timeLimit` rules. + var dailyLimitMinutes: Int + /// Daily open budget for `.openLimit` rules. + var maxOpens: Int + init( id: UUID = UUID(), name: String, - kind: RuleKind = .schedule, + configuration: RuleConfiguration = .schedule(ScheduleConfig()), isEnabled: Bool = true, hardMode: Bool = false, - blockAdultContent: Bool = false, - selectionMode: SelectionMode = .block, - selectionData: Data? = nil, - selectionCount: Int = 0, days: Set = Weekday.weekdays, - startMinutes: Int = 9 * 60, - endMinutes: Int = 17 * 60, - dailyLimitMinutes: Int = 45, - maxOpens: Int = 5, pausedUntil: Date? = nil, createdAt: Date = .now ) { self.id = id self.name = name - self.kindRaw = kind.rawValue self.isEnabled = isEnabled self.hardMode = hardMode - self.blockAdultContent = blockAdultContent - self.selectionModeRaw = selectionMode.rawValue - self.selectionData = selectionData - self.selectionCount = selectionCount + self.appList = nil + self.selectionData = nil + self.selectionCount = 0 self.dayNumbers = days.map(\.rawValue).sorted() - self.startMinutes = startMinutes - self.endMinutes = endMinutes - self.dailyLimitMinutes = dailyLimitMinutes - self.maxOpens = maxOpens self.pausedUntil = pausedUntil self.createdAt = createdAt + // Raw per-kind columns start at the reference defaults, then the + // configuration overwrites the ones that apply to its kind. + self.kindRaw = configuration.kind.rawValue + self.startMinutes = 9 * 60 + self.endMinutes = 17 * 60 + self.selectionModeRaw = SelectionMode.block.rawValue + self.blockAdultContent = false + self.dailyLimitMinutes = 45 + self.maxOpens = 5 + applyConfiguration(configuration) + } + + /// The kind-specific options of this rule. Reading assembles the sum type + /// from the raw columns; writing routes through `applyConfiguration`, which + /// keeps the schedule-only options off limit rules. + var configuration: RuleConfiguration { + get { + switch kind { + case .schedule: + .schedule( + ScheduleConfig( + startMinutes: startMinutes, + endMinutes: endMinutes, + selectionMode: selectionMode, + blockAdultContent: blockAdultContent)) + case .timeLimit: + .timeLimit(TimeLimitConfig(dailyLimitMinutes: dailyLimitMinutes)) + case .openLimit: + .openLimit(OpenLimitConfig(maxOpens: maxOpens)) + } + } + set { applyConfiguration(newValue) } + } + + /// Writes a configuration onto the raw columns. Limit kinds explicitly + /// reset the Schedule-only options so a rule can never carry Block / Allow + /// Only or Block Adult Content unless it is a Schedule rule. + private func applyConfiguration(_ configuration: RuleConfiguration) { + kindRaw = configuration.kind.rawValue + switch configuration { + case .schedule(let config): + startMinutes = config.startMinutes + endMinutes = config.endMinutes + selectionModeRaw = config.selectionMode.rawValue + blockAdultContent = config.blockAdultContent + case .timeLimit(let config): + dailyLimitMinutes = config.dailyLimitMinutes + selectionModeRaw = SelectionMode.block.rawValue + blockAdultContent = false + case .openLimit(let config): + maxOpens = config.maxOpens + selectionModeRaw = SelectionMode.block.rawValue + blockAdultContent = false + } } var kind: RuleKind { @@ -88,9 +139,9 @@ final class BlockingRule { set { kindRaw = newValue.rawValue } } + /// How this rule interprets its app list. Always `.block` for limit kinds. var selectionMode: SelectionMode { - get { SelectionMode(rawValue: selectionModeRaw) ?? .block } - set { selectionModeRaw = newValue.rawValue } + SelectionMode(rawValue: selectionModeRaw) ?? .block } var days: Set { diff --git a/OpenAppLock/Models/RuleDraft.swift b/OpenAppLock/Models/RuleDraft.swift index 9dfe4cd..be0de70 100644 --- a/OpenAppLock/Models/RuleDraft.swift +++ b/OpenAppLock/Models/RuleDraft.swift @@ -9,58 +9,46 @@ import SwiftData /// Value-type working copy of a rule used by the editors, so cancelling an /// edit never touches the persisted model. Hashable so it can drive /// `navigationDestination(item:)`. +/// +/// The kind-specific options live in `configuration`, so each editor branch +/// only ever sees the options that belong to its kind (the Schedule editor +/// gets Block/Allow-Only and Block Adult Content; the limit editors do not). struct RuleDraft: Hashable { var name: String - var kind: RuleKind var days: Set - var startMinutes: Int - var endMinutes: Int - var dailyLimitMinutes: Int - var maxOpens: Int var hardMode: Bool - var blockAdultContent: Bool - var selectionMode: SelectionMode /// Reference to the persisted list the rule will use. App lists are /// managed (created/edited) directly by the picker, so the draft only /// carries the pointer. var appList: AppList? + var configuration: RuleConfiguration + + var kind: RuleKind { configuration.kind } /// A fresh draft for a new rule of the given kind, using the reference /// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day). init(kind: RuleKind) { self.name = kind.defaultRuleName - self.kind = kind self.days = Weekday.weekdays - self.startMinutes = 9 * 60 - self.endMinutes = 17 * 60 - self.dailyLimitMinutes = 45 - self.maxOpens = 5 self.hardMode = false - self.blockAdultContent = false - self.selectionMode = .block self.appList = nil + self.configuration = .default(for: kind) } init(rule: BlockingRule) { self.name = rule.name - self.kind = rule.kind self.days = rule.days - self.startMinutes = rule.startMinutes - self.endMinutes = rule.endMinutes - self.dailyLimitMinutes = rule.dailyLimitMinutes - self.maxOpens = rule.maxOpens self.hardMode = rule.hardMode - self.blockAdultContent = rule.blockAdultContent - self.selectionMode = rule.selectionMode self.appList = rule.appList + self.configuration = rule.configuration } init(preset: RulePreset) { self.init(kind: .schedule) self.name = preset.name self.days = preset.days - self.startMinutes = preset.startMinutes - self.endMinutes = preset.endMinutes + self.configuration = .schedule( + ScheduleConfig(startMinutes: preset.startMinutes, endMinutes: preset.endMinutes)) } /// Writes the draft back onto a rule. The rule (and the chosen list) must @@ -68,15 +56,9 @@ struct RuleDraft: Hashable { /// assigned between managed models (see `BlockingRule.appList`). func apply(to rule: BlockingRule) { rule.name = name - rule.kind = kind rule.days = days - rule.startMinutes = startMinutes - rule.endMinutes = endMinutes - rule.dailyLimitMinutes = dailyLimitMinutes - rule.maxOpens = maxOpens rule.hardMode = hardMode - rule.blockAdultContent = blockAdultContent - rule.selectionMode = selectionMode + rule.configuration = configuration if rule.appList !== appList { rule.appList = appList } @@ -87,26 +69,47 @@ struct RuleDraft: Hashable { /// ever written on a managed model. @discardableResult func insertRule(into context: ModelContext) -> BlockingRule { - let rule = BlockingRule(name: name, kind: kind) + let rule = BlockingRule(name: name, configuration: configuration) context.insert(rule) apply(to: rule) return rule } - /// Trims the name (falling back to the kind's default when it is empty) - /// and forces Block mode for time- and open-limit rules — Allow Only is a - /// schedule-rule concept. + /// Trims the name, falling back to the kind's default when it is empty. + /// (Block / Allow Only no longer needs sanitizing: the sum type makes it + /// impossible for a limit draft to carry a selection mode at all.) func sanitized() -> RuleDraft { var copy = self let trimmed = name.trimmingCharacters(in: .whitespaces) copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed - if kind != .schedule { - copy.selectionMode = .block - } return copy } var schedule: RuleSchedule { - RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days) + RuleSchedule( + startMinutes: scheduleConfig.startMinutes, + endMinutes: scheduleConfig.endMinutes, + days: days) + } +} + +extension RuleDraft { + /// Typed projections of the active configuration, used to bind editor + /// controls. The getter returns kind defaults when the draft is a + /// different kind; each editor only reads the projection for its own kind, + /// and writing through it repackages the configuration to that kind. + var scheduleConfig: ScheduleConfig { + get { configuration.scheduleConfig ?? ScheduleConfig() } + set { configuration = .schedule(newValue) } + } + + var timeLimitConfig: TimeLimitConfig { + get { configuration.timeLimitConfig ?? TimeLimitConfig() } + set { configuration = .timeLimit(newValue) } + } + + var openLimitConfig: OpenLimitConfig { + get { configuration.openLimitConfig ?? OpenLimitConfig() } + set { configuration = .openLimit(newValue) } } } diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index d8d5959..e039522 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -56,8 +56,10 @@ final class RuleEnforcer { shields.applyShield( ruleID: rule.id, selectionData: rule.appList?.selectionData, - // Allow Only is a schedule-rule concept; limit blocks always Block. - mode: rule.kind == .schedule ? rule.selectionMode : .block, + // Allow Only and Block Adult Content are Schedule-only options; + // the model already forces .block / false on limit rules, so we + // can forward the rule's values directly. + mode: rule.selectionMode, blockAdultContent: rule.blockAdultContent ) } diff --git a/OpenAppLock/Services/SampleRules.swift b/OpenAppLock/Services/SampleRules.swift index 82eea85..7268fed 100644 --- a/OpenAppLock/Services/SampleRules.swift +++ b/OpenAppLock/Services/SampleRules.swift @@ -35,13 +35,17 @@ enum SampleRules { ] case .limits: let timeKeeper = BlockingRule( - name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, - dailyLimitMinutes: 45) + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) let gateKeeper = BlockingRule( - name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5) + name: "Gate Keeper", + configuration: .openLimit(OpenLimitConfig(maxOpens: 5)), + days: Weekday.everyDay) let doomScroll = BlockingRule( - name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay, - dailyLimitMinutes: 30) + name: "Doom Scroll", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)), + days: Weekday.everyDay) usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18) usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2) usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30) @@ -65,10 +69,9 @@ enum SampleRules { let end = min(24 * 60 - 1, nowMinutes + 6 * 60) return BlockingRule( name: name, + configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)), hardMode: hardMode, - days: Weekday.everyDay, - startMinutes: start, - endMinutes: end + days: Weekday.everyDay ) } @@ -82,9 +85,8 @@ enum SampleRules { let end = (start + 8 * 60) % (24 * 60) return BlockingRule( name: name, - days: Weekday.everyDay, - startMinutes: start, - endMinutes: end + configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)), + days: Weekday.everyDay ) } diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index 767c174..7c191da 100644 --- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift +++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift @@ -100,26 +100,25 @@ struct RuleDetailSheet: View { @ViewBuilder private var detailRows: some View { - switch rule.kind { - case .schedule: + switch rule.configuration { + case .schedule(let config): row("During this time", rule.schedule.timeRangeLabel) row("On these days", rule.days.summary) - row(rule.selectionMode.displayName, appCountLabel) - row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") + row(config.selectionMode.displayName, appCountLabel) + // Adult websites is a Schedule-only option (see spec §1). + row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed") row("Unblocks allowed", rule.hardMode ? "No" : "Yes") - case .timeLimit: + case .timeLimit(let config): row("When I use", appCountLabel) - row("For this long", "\(rule.dailyLimitMinutes)m daily") + row("For this long", "\(config.dailyLimitMinutes)m daily") row("On these days", rule.days.summary) row("Then block until", "Tomorrow") - row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") row("Unblocks allowed", rule.hardMode ? "No" : "Yes") - case .openLimit: + case .openLimit(let config): row("When I open", appCountLabel) - row("More than", "\(rule.maxOpens) opens daily") + row("More than", "\(config.maxOpens) opens daily") row("On these days", rule.days.summary) row("Then block until", "Tomorrow") - row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") row("Unblocks allowed", rule.hardMode ? "No" : "Yes") } } diff --git a/OpenAppLock/Views/Rules/RuleEditorView.swift b/OpenAppLock/Views/Rules/RuleEditorView.swift index 1da66d1..20275d3 100644 --- a/OpenAppLock/Views/Rules/RuleEditorView.swift +++ b/OpenAppLock/Views/Rules/RuleEditorView.swift @@ -97,13 +97,13 @@ struct RuleEditorView: View { Section { DatePicker( "From", - selection: timeBinding($draft.startMinutes), + selection: timeBinding($draft.scheduleConfig.startMinutes), displayedComponents: .hourAndMinute ) .accessibilityIdentifier("fromTimePicker") DatePicker( "To", - selection: timeBinding($draft.endMinutes), + selection: timeBinding($draft.scheduleConfig.endMinutes), displayedComponents: .hourAndMinute ) .accessibilityIdentifier("toTimePicker") @@ -112,7 +112,7 @@ struct RuleEditorView: View { } daysSection Section { - Picker("Mode", selection: $draft.selectionMode) { + Picker("Mode", selection: $draft.scheduleConfig.selectionMode) { ForEach(SelectionMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) } @@ -121,10 +121,14 @@ struct RuleEditorView: View { .accessibilityIdentifier("selectionModePicker") appListRow } header: { - Text(draft.selectionMode == .block ? "Apps are blocked" : "Only these apps are allowed") + Text(draft.scheduleConfig.selectionMode == .block + ? "Apps are blocked" : "Only these apps are allowed") .textCase(nil) } - toggleSections + hardModeSection + // Block Adult Content is a Schedule-only option: a usage budget + // does not pair with a web-content filter (see spec §1). + adultContentSection case .timeLimit: Section { appListRow @@ -133,17 +137,23 @@ struct RuleEditorView: View { } Section { budgetRow( - value: "\(draft.dailyLimitMinutes)m", + value: "\(draft.timeLimitConfig.dailyLimitMinutes)m", stepperID: "dailyLimitStepper", - onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) }, - onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) } + onIncrement: { + draft.timeLimitConfig.dailyLimitMinutes = + min(240, draft.timeLimitConfig.dailyLimitMinutes + 15) + }, + onDecrement: { + draft.timeLimitConfig.dailyLimitMinutes = + max(15, draft.timeLimitConfig.dailyLimitMinutes - 15) + } ) } header: { Text("For this long").textCase(nil) } daysSection blockUntilSection - toggleSections + hardModeSection case .openLimit: Section { appListRow @@ -152,17 +162,21 @@ struct RuleEditorView: View { } Section { budgetRow( - value: "\(draft.maxOpens) opens", + value: "\(draft.openLimitConfig.maxOpens) opens", stepperID: "maxOpensStepper", - onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) }, - onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) } + onIncrement: { + draft.openLimitConfig.maxOpens = min(50, draft.openLimitConfig.maxOpens + 1) + }, + onDecrement: { + draft.openLimitConfig.maxOpens = max(1, draft.openLimitConfig.maxOpens - 1) + } ) } header: { Text("More than").textCase(nil) } daysSection blockUntilSection - toggleSections + hardModeSection } } @@ -186,8 +200,8 @@ struct RuleEditorView: View { } } - @ViewBuilder - private var toggleSections: some View { + /// Hard Mode applies to every kind. + private var hardModeSection: some View { Section { HStack { Text("Hard Mode") @@ -199,11 +213,15 @@ struct RuleEditorView: View { } footer: { Text("No unblocks allowed while the rule is blocking.") } + } + + /// Schedule-only: filter adult websites while the rule's window is active. + private var adultContentSection: some View { Section { HStack { Text("Block Adult Content") Spacer() - Toggle("", isOn: $draft.blockAdultContent) + Toggle("", isOn: $draft.scheduleConfig.blockAdultContent) .labelsHidden() .accessibilityIdentifier("adultContentToggle") } diff --git a/OpenAppLockTests/AppListTests.swift b/OpenAppLockTests/AppListTests.swift index fe80dfa..980743b 100644 --- a/OpenAppLockTests/AppListTests.swift +++ b/OpenAppLockTests/AppListTests.swift @@ -86,10 +86,21 @@ struct AppListModelTests { @MainActor @Suite("Legacy selection → AppList migration") struct AppListMigrationTests { + /// Simulates a rule decoded from a pre-app-list store: a plain rule whose + /// legacy inline-selection columns are populated. + private func legacyRule(name: String, selectionData: Data, selectionCount: Int) -> BlockingRule { + let rule = BlockingRule(name: name) + rule.selectionData = selectionData + rule.selectionCount = selectionCount + return rule + } + @Test("Rules with legacy inline selections get a list named after them") func createsListsFromLegacySelections() throws { let context = try makeInMemoryContext() - let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3) + let rule = BlockingRule(name: "Work Time") + rule.selectionData = Data([1]) + rule.selectionCount = 3 context.insert(rule) try context.save() @@ -108,9 +119,9 @@ struct AppListMigrationTests { @Test("Rules with identical selections share one list") func sharesListForIdenticalSelections() throws { let context = try makeInMemoryContext() - let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2) - let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2) - let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1) + let first = legacyRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2) + let second = legacyRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2) + let different = legacyRule(name: "Gym", selectionData: Data([9]), selectionCount: 1) context.insert(first) context.insert(second) context.insert(different) @@ -127,7 +138,7 @@ struct AppListMigrationTests { @Test("Migration is idempotent and skips selection-less rules") func idempotentAndSkipsEmpty() throws { let context = try makeInMemoryContext() - let legacy = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1) + let legacy = legacyRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1) let empty = BlockingRule(name: "No Apps") context.insert(legacy) context.insert(empty) @@ -163,22 +174,24 @@ struct AppListDraftTests { #expect(other.name == "Other") } - @Test("Sanitizing forces Block mode for time- and open-limit rules") - func sanitizedForcesBlockForLimitKinds() { - var timeDraft = RuleDraft(kind: .timeLimit) - timeDraft.selectionMode = .allowOnly - #expect(timeDraft.sanitized().selectionMode == .block) + @Test("Limit drafts structurally cannot carry a selection mode") + func limitDraftsHaveNoSelectionMode() { + // The sum type makes Block / Allow Only a Schedule-only option: a limit + // draft's configuration has no selection mode to force back to Block. + #expect(RuleDraft(kind: .timeLimit).configuration.scheduleConfig == nil) + #expect(RuleDraft(kind: .openLimit).configuration.scheduleConfig == nil) - var openDraft = RuleDraft(kind: .openLimit) - openDraft.selectionMode = .allowOnly - #expect(openDraft.sanitized().selectionMode == .block) + // And the rule built from a limit draft is always Block. + let rule = BlockingRule( + name: "Time Keeper", configuration: RuleDraft(kind: .timeLimit).configuration) + #expect(rule.selectionMode == .block) } - @Test("Sanitizing keeps Allow Only on schedule rules") - func sanitizedKeepsAllowOnlyForSchedule() { + @Test("Allow Only survives on schedule rules") + func keepsAllowOnlyForSchedule() { var draft = RuleDraft(kind: .schedule) - draft.selectionMode = .allowOnly - #expect(draft.sanitized().selectionMode == .allowOnly) + draft.scheduleConfig.selectionMode = .allowOnly + #expect(draft.sanitized().scheduleConfig.selectionMode == .allowOnly) } } diff --git a/OpenAppLockTests/RuleConfigurationTests.swift b/OpenAppLockTests/RuleConfigurationTests.swift new file mode 100644 index 0000000..64232b9 --- /dev/null +++ b/OpenAppLockTests/RuleConfigurationTests.swift @@ -0,0 +1,44 @@ +// +// RuleConfigurationTests.swift +// OpenAppLockTests +// + +import Foundation +import Testing + +@testable import OpenAppLock + +@MainActor +@Suite("RuleConfiguration sum type") +struct RuleConfigurationTests { + @Test("Each case reports its kind") + func kindDerivation() { + #expect(RuleConfiguration.schedule(ScheduleConfig()).kind == .schedule) + #expect(RuleConfiguration.timeLimit(TimeLimitConfig()).kind == .timeLimit) + #expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit) + } + + @Test("Defaults match the reference app's new-rule defaults") + func defaults() { + let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig + #expect(schedule?.startMinutes == 9 * 60) + #expect(schedule?.endMinutes == 17 * 60) + #expect(schedule?.selectionMode == .block) + #expect(schedule?.blockAdultContent == false) + + #expect(RuleConfiguration.default(for: .timeLimit).timeLimitConfig?.dailyLimitMinutes == 45) + #expect(RuleConfiguration.default(for: .openLimit).openLimitConfig?.maxOpens == 5) + } + + @Test("Typed projections only unwrap the matching case") + func projections() { + let schedule = RuleConfiguration.schedule(ScheduleConfig(blockAdultContent: true)) + #expect(schedule.scheduleConfig?.blockAdultContent == true) + #expect(schedule.timeLimitConfig == nil) + #expect(schedule.openLimitConfig == nil) + + let openLimit = RuleConfiguration.openLimit(OpenLimitConfig(maxOpens: 7)) + #expect(openLimit.openLimitConfig?.maxOpens == 7) + #expect(openLimit.scheduleConfig == nil) + } +} diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index f77d03d..3b6a4a3 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -54,7 +54,7 @@ struct RuleEnforcerTests { func skipsTimeLimitRules() { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) - let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit) + let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig())) enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) @@ -104,7 +104,8 @@ struct RuleEnforcerTests { func forwardsSelectionMode() { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) - let rule = BlockingRule(name: "Focus", selectionMode: .allowOnly) + let rule = BlockingRule( + name: "Focus", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly))) enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) @@ -115,7 +116,8 @@ struct RuleEnforcerTests { func forwardsAdultContentFlag() { let shields = MockShieldController() let enforcer = RuleEnforcer(shields: shields) - let filtered = BlockingRule(name: "Clean Mode", blockAdultContent: true) + let filtered = BlockingRule( + name: "Clean Mode", configuration: .schedule(ScheduleConfig(blockAdultContent: true))) let unfiltered = BlockingRule(name: "Plain") enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc) diff --git a/OpenAppLockTests/RuleModelTests.swift b/OpenAppLockTests/RuleModelTests.swift index 5eb0de3..37ce3e1 100644 --- a/OpenAppLockTests/RuleModelTests.swift +++ b/OpenAppLockTests/RuleModelTests.swift @@ -36,8 +36,9 @@ struct RuleModelTests { @Test("Kind and selection mode survive raw storage, with safe fallbacks") func enumRoundTrip() { - let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly) - #expect(rule.kind == .openLimit) + let rule = BlockingRule( + name: "Test", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly))) + #expect(rule.kind == .schedule) #expect(rule.selectionMode == .allowOnly) rule.kindRaw = "garbage" rule.selectionModeRaw = "garbage" @@ -45,12 +46,43 @@ struct RuleModelTests { #expect(rule.selectionMode == .block) } + @Test("Limit kinds can never carry Schedule-only options") + func limitKindsHaveNoScheduleOptions() { + let timeLimit = BlockingRule( + name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30))) + #expect(timeLimit.kind == .timeLimit) + #expect(timeLimit.selectionMode == .block) + #expect(!timeLimit.blockAdultContent) + #expect(timeLimit.dailyLimitMinutes == 30) + + let openLimit = BlockingRule( + name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 3))) + #expect(openLimit.kind == .openLimit) + #expect(openLimit.selectionMode == .block) + #expect(!openLimit.blockAdultContent) + #expect(openLimit.maxOpens == 3) + } + + @Test("Configuration round-trips through the model's raw storage") + func configurationRoundTrip() { + let config = RuleConfiguration.schedule( + ScheduleConfig( + startMinutes: 22 * 60, endMinutes: 6 * 60, + selectionMode: .allowOnly, blockAdultContent: true)) + let rule = BlockingRule(name: "Deep Sleep", configuration: config) + #expect(rule.configuration == config) + #expect(rule.startMinutes == 22 * 60) + #expect(rule.blockAdultContent) + } + @Test("Rules persist and fetch through SwiftData") func persistence() throws { let context = try makeInMemoryContext() let rule = BlockingRule( - name: "Deep Sleep", hardMode: true, - days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60 + name: "Deep Sleep", + configuration: .schedule(ScheduleConfig(startMinutes: 22 * 60, endMinutes: 6 * 60)), + hardMode: true, + days: Weekday.everyDay ) context.insert(rule) try context.save() @@ -73,7 +105,7 @@ struct RuleDraftTests { let draft = RuleDraft(kind: .timeLimit) #expect(draft.name == "Time Keeper") #expect(draft.kind == .timeLimit) - #expect(draft.dailyLimitMinutes == 45) + #expect(draft.timeLimitConfig.dailyLimitMinutes == 45) #expect(draft.days == Weekday.weekdays) #expect(!draft.hardMode) } @@ -87,18 +119,32 @@ struct RuleDraftTests { var draft = RuleDraft(kind: .schedule) draft.name = "Locked In" draft.days = Weekday.everyDay - draft.startMinutes = 22 * 60 - draft.endMinutes = 6 * 60 + draft.configuration = .schedule( + ScheduleConfig( + startMinutes: 22 * 60, endMinutes: 6 * 60, + selectionMode: .allowOnly, blockAdultContent: true)) draft.hardMode = true - draft.blockAdultContent = true - draft.selectionMode = .allowOnly draft.appList = list let rule = draft.insertRule(into: context) #expect(rule.blockAdultContent) + #expect(rule.selectionMode == .allowOnly) #expect(RuleDraft(rule: rule) == draft) } + @Test("A limit draft cannot carry Schedule-only options") + func limitDraftHasNoScheduleOptions() { + let draft = RuleDraft(kind: .openLimit) + // The configuration is an open-limit case, so there is structurally no + // selection mode or adult-content flag to set. + #expect(draft.configuration.scheduleConfig == nil) + #expect(draft.openLimitConfig.maxOpens == 5) + + let rule = BlockingRule(name: draft.name, configuration: draft.configuration) + #expect(!rule.blockAdultContent) + #expect(rule.selectionMode == .block) + } + @Test("Applying a draft updates an existing rule") func applyToExisting() throws { let context = try makeInMemoryContext() @@ -135,8 +181,8 @@ struct RuleDraftTests { ) let draft = RuleDraft(preset: preset) #expect(draft.name == "Deep Sleep") - #expect(draft.startMinutes == 22 * 60) - #expect(draft.endMinutes == 6 * 60) + #expect(draft.scheduleConfig.startMinutes == 22 * 60) + #expect(draft.scheduleConfig.endMinutes == 6 * 60) #expect(draft.kind == .schedule) } } diff --git a/OpenAppLockTests/RuleStatusTests.swift b/OpenAppLockTests/RuleStatusTests.swift index 8f72a42..a277cd5 100644 --- a/OpenAppLockTests/RuleStatusTests.swift +++ b/OpenAppLockTests/RuleStatusTests.swift @@ -63,7 +63,7 @@ struct RuleStatusTests { @Test("Time-limit rules are never schedule-active") func timeLimitNeverActive() { - let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit) + let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig())) let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) #expect(!status.isActive) if case .upcoming = status {} else { @@ -116,7 +116,8 @@ struct RuleStatusTests { /// daily budget — never the vestigial 09:00 start as "Starts in 22h". @Test("Idle time-limit rule shows its daily budget, not a clock countdown") func timeLimitDisplayLabel() { - let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15) + let rule = BlockingRule( + name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15))) let now = date(2025, 1, 6, 11, 38) // past the vestigial 09:00 window start let status = rule.status(at: now, calendar: utc) #expect(rule.statusLabel(for: status, relativeTo: now) == "15m / day") @@ -124,7 +125,8 @@ struct RuleStatusTests { @Test("Idle open-limit rule shows its daily opens budget") func openLimitDisplayLabel() { - let rule = BlockingRule(name: "Gate Keeper", kind: .openLimit, maxOpens: 5) + let rule = BlockingRule( + name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 5))) let now = date(2025, 1, 6, 11, 38) let status = rule.status(at: now, calendar: utc) #expect(rule.statusLabel(for: status, relativeTo: now) == "5 opens / day") @@ -140,7 +142,8 @@ struct RuleStatusTests { @Test("A spent time-limit budget still shows the blocked countdown") func timeLimitBlockingDisplayLabel() { - let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15) + let rule = BlockingRule( + name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15))) let now = date(2025, 1, 6, 11, 38) let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15)) #expect(status.isActive) diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index 826f46f..03df5b8 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -41,7 +41,9 @@ struct RuleSnapshotTests { let context = try makeInMemoryContext() let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1) let rule = BlockingRule( - name: "Gate Keeper", kind: .openLimit, days: Weekday.weekends, maxOpens: 3) + name: "Gate Keeper", + configuration: .openLimit(OpenLimitConfig(maxOpens: 3)), + days: Weekday.weekends) context.insert(list) context.insert(rule) rule.appList = list @@ -135,7 +137,8 @@ struct RuleSchedulerTests { private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule { let context = try makeInMemoryContext() let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1) - let rule = BlockingRule(name: name, kind: kind, days: Weekday.everyDay) + let rule = BlockingRule( + name: name, configuration: .default(for: kind), days: Weekday.everyDay) context.insert(list) context.insert(rule) rule.appList = list @@ -148,7 +151,9 @@ struct RuleSchedulerTests { ) throws -> BlockingRule { let context = try makeInMemoryContext() let rule = BlockingRule( - name: name, kind: .schedule, days: days, startMinutes: start, endMinutes: end) + name: name, + configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)), + days: days) context.insert(rule) if withApps { let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1) @@ -186,7 +191,7 @@ struct RuleSchedulerTests { let (scheduler, monitor, _) = makeScheduler() let context = try makeInMemoryContext() let applessSchedule = BlockingRule(name: "Work Time") - let applessLimit = BlockingRule(name: "Empty", kind: .timeLimit) + let applessLimit = BlockingRule(name: "Empty", configuration: .timeLimit(TimeLimitConfig())) context.insert(applessSchedule) context.insert(applessLimit) diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 4df2f84..0a104c4 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -79,13 +79,17 @@ struct UsageStatusTests { let mondayMorning = date(2025, 1, 6, 10, 0) private func timeLimitRule(limit: Int = 45) -> BlockingRule { - BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, - dailyLimitMinutes: limit) + BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)), + days: Weekday.everyDay) } private func openLimitRule(maxOpens: Int = 5) -> BlockingRule { - BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, - maxOpens: maxOpens) + BlockingRule( + name: "Gate Keeper", + configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)), + days: Weekday.everyDay) } @Test("A time-limit rule with budget left is not active") @@ -153,13 +157,17 @@ struct UsageEnforcementTests { let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) let rule = BlockingRule( - name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45) + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45) enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) #expect(shields.shieldedRuleIDs == [rule.id]) #expect(shields.appliedModes[rule.id] == .block) + // Limit rules never engage the adult-content filter (Schedule-only). + #expect(shields.appliedAdultContentFlags[rule.id] == false) } @Test("A limit rule with budget left is not shielded") @@ -168,7 +176,9 @@ struct UsageEnforcementTests { let ledger = MockUsageLedger() let enforcer = RuleEnforcer(shields: shields, usage: ledger) let rule = BlockingRule( - name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5) + name: "Gate Keeper", + configuration: .openLimit(OpenLimitConfig(maxOpens: 5)), + days: Weekday.everyDay) ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2) enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) @@ -181,9 +191,13 @@ struct UsageEnforcementTests { @Suite("Usage display strings") struct UsageDisplayTests { let timeRule = BlockingRule( - name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45) + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) let openRule = BlockingRule( - name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5) + name: "Gate Keeper", + configuration: .openLimit(OpenLimitConfig(maxOpens: 5)), + days: Weekday.everyDay) @Test("Time-limit rows show minutes used and remaining") func timeLimitStrings() { diff --git a/OpenAppLockUITests/RuleCreationUITests.swift b/OpenAppLockUITests/RuleCreationUITests.swift index 817caa3..775abf5 100644 --- a/OpenAppLockUITests/RuleCreationUITests.swift +++ b/OpenAppLockUITests/RuleCreationUITests.swift @@ -131,6 +131,32 @@ final class RuleCreationUITests: XCTestCase { XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)") } + /// Block Adult Content is a Schedule-only option: the Time Limit editor must + /// not offer the toggle, and the rule's detail must not show the row. + func testTimeLimitOmitsAdultContent() throws { + let app = XCUIApplication.launchOpenAppLock() + app.buttons["newRuleButton"].waitToAppear().tap() + app.buttons["ruleKind-timeLimit"].waitToAppear().tap() + + // Scroll to the bottom of the form, where Hard Mode lives. The adult + // toggle (which would sit beside it on a Schedule rule) is absent. + app.staticTexts["ruleEditorTitle"].waitToAppear() + app.swipeUp() + app.switches["hardModeToggle"].waitToAppear() + XCTAssertFalse( + app.switches["adultContentToggle"].exists, + "Time-limit rules must not offer Block Adult Content" + ) + + app.buttons["commitRuleButton"].waitToAppear().tap() + app.buttons["ruleCard-Time Keeper"].waitToAppear().tap() + app.element("detailRuleName").waitToAppear() + XCTAssertFalse( + app.element("detailRow-Adult websites").exists, + "Time-limit detail must not show the Adult websites row" + ) + } + func testEditorSupportsNativeSwipeBack() throws { let app = XCUIApplication.launchOpenAppLock() app.buttons["newRuleButton"].waitToAppear().tap() diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index 86983c7..92d5484 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -91,8 +91,10 @@ struct LimitEnforcement { shields.applyShield( ruleID: snapshot.id, selectionData: snapshot.selectionData, + // Limit rules are always Block and never engage the adult-content + // filter — those are Schedule-only options. mode: .block, - blockAdultContent: snapshot.blockAdultContent + blockAdultContent: false ) } } diff --git a/Shared/RuleConfiguration.swift b/Shared/RuleConfiguration.swift new file mode 100644 index 0000000..b8786c0 --- /dev/null +++ b/Shared/RuleConfiguration.swift @@ -0,0 +1,90 @@ +// +// RuleConfiguration.swift +// OpenAppLock +// + +import Foundation + +/// The kind-specific options of a rule, modelled as a sum type so a rule can +/// only carry the options that belong to its kind. This makes illegal states +/// unrepresentable: an Open Limit rule cannot hold a time window, a Time Limit +/// rule cannot hold a Block/Allow-Only mode, and neither limit kind can hold +/// Block Adult Content — those are Schedule-only options. +/// +/// Kind-common attributes (name, days, hardMode, isEnabled, appList, +/// pausedUntil) live on the owning `BlockingRule` / `RuleDraft`, not here. +enum RuleConfiguration: Hashable, Sendable { + case schedule(ScheduleConfig) + case timeLimit(TimeLimitConfig) + case openLimit(OpenLimitConfig) + + var kind: RuleKind { + switch self { + case .schedule: .schedule + case .timeLimit: .timeLimit + case .openLimit: .openLimit + } + } + + /// The default configuration for a brand-new rule of the given kind, using + /// the reference app's defaults (9–5 schedule, 45m/day, 5 opens/day). + static func `default`(for kind: RuleKind) -> RuleConfiguration { + switch kind { + case .schedule: .schedule(ScheduleConfig()) + case .timeLimit: .timeLimit(TimeLimitConfig()) + case .openLimit: .openLimit(OpenLimitConfig()) + } + } + + var scheduleConfig: ScheduleConfig? { + if case .schedule(let config) = self { config } else { nil } + } + + var timeLimitConfig: TimeLimitConfig? { + if case .timeLimit(let config) = self { config } else { nil } + } + + var openLimitConfig: OpenLimitConfig? { + if case .openLimit(let config) = self { config } else { nil } + } +} + +/// Schedule-rule options: a recurring time window, how the app list is +/// interpreted, and whether the adult-website filter engages while the window +/// is active. A window whose end is at or before its start crosses midnight. +struct ScheduleConfig: Hashable, Sendable { + var startMinutes: Int + var endMinutes: Int + var selectionMode: SelectionMode + var blockAdultContent: Bool + + init( + startMinutes: Int = 9 * 60, + endMinutes: Int = 17 * 60, + selectionMode: SelectionMode = .block, + blockAdultContent: Bool = false + ) { + self.startMinutes = startMinutes + self.endMinutes = endMinutes + self.selectionMode = selectionMode + self.blockAdultContent = blockAdultContent + } +} + +/// Time Limit option: a daily cumulative-usage budget in minutes. +struct TimeLimitConfig: Hashable, Sendable { + var dailyLimitMinutes: Int + + init(dailyLimitMinutes: Int = 45) { + self.dailyLimitMinutes = dailyLimitMinutes + } +} + +/// Open Limit option: a daily budget of app opens. +struct OpenLimitConfig: Hashable, Sendable { + var maxOpens: Int + + init(maxOpens: Int = 5) { + self.maxOpens = maxOpens + } +} diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index bcd3820..b3b4bcf 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -19,7 +19,7 @@ offered (Opal's "New Rule" sheet): | **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point | | **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them (not demoed in video; inferred from card) | -Common attributes across all types: +**Common attributes** — present for *every* rule kind: - **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit)) - **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom @@ -28,14 +28,26 @@ Common attributes across all types: independently of any rule. When editing a rule the user picks an existing list or creates a new one; editing a list affects every rule that uses it. Deleting a list detaches it from its rules (they fall back to "no apps"). -- **Selection mode** — **Block** (block the list) or **Allow Only** (block - everything except the list). The mode belongs to the *rule*, not the list. - Only **Schedule** rules offer the choice; Time Limit and Open Limit rules - are always Block (a usage budget over "everything except X" is not - meaningful, and drafts of those kinds are sanitized back to Block). - **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes" - **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule") +**Per-kind options** — each kind carries only the options that make sense for +it. The data model expresses this as a sum type (`RuleConfiguration`, see §5.2) +so a kind structurally cannot hold another kind's options: + +- **Schedule** — a recurring **time window** (`From`/`To`, may cross midnight), + a **Selection mode** (**Block** the list, or **Allow Only** = block + everything except it; the mode belongs to the *rule*, not the list), and + **Block Adult Content** (engage Screen Time's adult-website filter while the + window is active). +- **Time Limit** — a **daily minutes budget**. +- **Open Limit** — a **daily opens budget**. + + Selection mode and Block Adult Content are **Schedule-only**: a usage budget + over "everything except X" is not meaningful, and engaging a web-content + filter when a *usage* budget is spent does not fit the feature. Time Limit and + Open Limit rules are always Block and never touch the adult-content filter. + Derived status (drives card/detail UI): - **Active** → countdown to window end: green pill "6h left" - **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h" @@ -174,12 +186,13 @@ Sections (each an inset rounded group with a small icon + caption header): blocked" / "Only these apps are allowed" accordingly. 4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing toggle. -5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)* — - subtitle "Filter adult websites while this rule is active"; trailing - toggle. Maps to Screen Time's web-content filter +5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video; + **Schedule rules only**)* — subtitle "Filter adult websites while this rule + is active"; trailing toggle. Maps to Screen Time's web-content filter (`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied and cleared together with the rule's shield. Surfaces in the rule detail - as an "Adult websites | Blocked/Allowed" row. + as an "Adult websites | Blocked/Allowed" row. Time Limit and Open Limit + editors do **not** offer this toggle (see §1, Per-kind options). 6. **CTA** - Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold interaction (deliberate friction) that fills, then saves and dismisses to @@ -198,14 +211,13 @@ Same chrome (back / title / rename). Sections: 4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄` (reset point — e.g. tomorrow/next morning). 5. **Hard Mode** toggle — same as Schedule. -6. **Block Adult Content** toggle — same as Schedule. -7. **Hold to Commit**. +6. **Hold to Commit**. *(No Block Adult Content toggle — Schedule-only.)* ### 3.6 Rule Editor — Open Limit type Not demoed beyond its card. Spec by analogy: "When I open [apps]" / "More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" / -Hard Mode / Block Adult Content / Hold to Commit. +Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)* ### 3.7 App Picker (shared component — also used in onboarding & timer) @@ -309,36 +321,58 @@ Full-height sheet: ### 5.2 Data model (SwiftData) -Replace the template `AppListProfile` with: +The domain currency is a **sum type** so a rule can only hold the options that +belong to its kind — illegal states (e.g. an Open Limit rule with a time +window, or a Time Limit rule with Block Adult Content) are unrepresentable: ```swift enum RuleKind: String, Codable { case schedule, timeLimit, openLimit } enum SelectionMode: String, Codable { case block, allowOnly } +enum RuleConfiguration: Hashable, Sendable { + case schedule(ScheduleConfig) + case timeLimit(TimeLimitConfig) + case openLimit(OpenLimitConfig) + var kind: RuleKind { … } +} + +struct ScheduleConfig: Hashable, Sendable { // Schedule-only options + var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00 + var endMinutes: Int // may be ≤ start (crosses midnight) + var selectionMode: SelectionMode + var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...) +} +struct TimeLimitConfig: Hashable, Sendable { var dailyLimitMinutes: Int } +struct OpenLimitConfig: Hashable, Sendable { var maxOpens: Int } +``` + +The kind-common attributes (`name`, `days`, `hardMode`, `isEnabled`, +`appList`, `pausedUntil`) live alongside the configuration: + +```swift @Model final class BlockingRule { var id: UUID var name: String - var kind: RuleKind var isEnabled: Bool var hardMode: Bool - var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...) - var selectionMode: SelectionMode - var selectionData: Data // encoded FamilyActivitySelection - var days: [Int] // 1...7, Calendar weekday numbers - // schedule - var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00 - var endMinutes: Int // may be < start (crosses midnight) - // time limit - var dailyLimitMinutes: Int? - var resetPoint: String? // "tomorrow" | "nextMorning" … - // open limit - var maxOpens: Int? + var appList: AppList? + var days: [Int] // 1...7, Calendar weekday numbers + var pausedUntil: Date? var createdAt: Date + // The kind-specific options, exposed as a computed bridge over the + // model's raw stored columns: + var configuration: RuleConfiguration { get set } + var kind: RuleKind { configuration.kind } } ``` -`FamilyActivitySelection` is `Codable` → store as `Data`. Status -("active", "starts in Xh", "Xh left") is **derived**, not stored. +`RuleDraft` (the editors' value-type working copy) carries the same +`configuration` + common fields, so each editor only renders its kind's +options. `BlockingRule` persists the configuration as flat columns and the +cross-process `RuleSnapshot` mirror keeps its flat wire shape; both are +read/written exclusively through the sum type. `FamilyActivitySelection` is +`Codable` → stored on the `AppList` as `Data`. Status ("active", "starts in +Xh", "Xh left") is **derived**, not stored. ### 5.3 View inventory