refactor: model rule options as a per-kind sum type
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). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,20 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy 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 */ = {
|
E20000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */;
|
||||||
@@ -34,22 +48,24 @@
|
|||||||
remoteGlobalIDString = C10000000000000000000003;
|
remoteGlobalIDString = C10000000000000000000003;
|
||||||
remoteInfo = OpenAppLockShieldAction;
|
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 */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
20A7EDCE2E47B7CD0097608D /* OpenAppLock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenAppLock.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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; };
|
B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
20A7EDD02E47B7CD0097608D /* OpenAppLock */ = {
|
20A7EDD02E47B7CD0097608D /* OpenAppLock */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
@@ -106,46 +146,6 @@
|
|||||||
};
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* 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 */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
|
20A7EDCB2E47B7CD0097608D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
@@ -308,8 +308,8 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA00000000000000000000A2 /* OpenAppLockMonitor */,
|
|
||||||
AA00000000000000000000A1 /* Shared */,
|
AA00000000000000000000A1 /* Shared */,
|
||||||
|
AA00000000000000000000A2 /* OpenAppLockMonitor */,
|
||||||
);
|
);
|
||||||
name = OpenAppLockMonitor;
|
name = OpenAppLockMonitor;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -331,8 +331,8 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
|
|
||||||
AA00000000000000000000A1 /* Shared */,
|
AA00000000000000000000A1 /* Shared */,
|
||||||
|
AA00000000000000000000A3 /* OpenAppLockShieldConfig */,
|
||||||
);
|
);
|
||||||
name = OpenAppLockShieldConfig;
|
name = OpenAppLockShieldConfig;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -354,8 +354,8 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
|
|
||||||
AA00000000000000000000A1 /* Shared */,
|
AA00000000000000000000A1 /* Shared */,
|
||||||
|
AA00000000000000000000A4 /* OpenAppLockShieldAction */,
|
||||||
);
|
);
|
||||||
name = OpenAppLockShieldAction;
|
name = OpenAppLockShieldAction;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
|||||||
@@ -89,10 +89,10 @@ extension BlockingRule {
|
|||||||
/// use the plain status label.
|
/// use the plain status label.
|
||||||
func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String {
|
func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String {
|
||||||
if case .upcoming = status {
|
if case .upcoming = status {
|
||||||
switch kind {
|
switch configuration {
|
||||||
case .schedule: break
|
case .schedule: break
|
||||||
case .timeLimit: return "\(dailyLimitMinutes)m / day"
|
case .timeLimit(let config): return "\(config.dailyLimitMinutes)m / day"
|
||||||
case .openLimit: return "\(maxOpens) opens / day"
|
case .openLimit(let config): return "\(config.maxOpens) opens / day"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status.label(relativeTo: now)
|
return status.label(relativeTo: now)
|
||||||
@@ -109,10 +109,10 @@ extension BlockingRule {
|
|||||||
/// Whether the given usage exhausts this rule's daily budget.
|
/// Whether the given usage exhausts this rule's daily budget.
|
||||||
/// Always false for schedule rules — they block by the clock.
|
/// Always false for schedule rules — they block by the clock.
|
||||||
func limitReached(given usage: RuleUsage) -> Bool {
|
func limitReached(given usage: RuleUsage) -> Bool {
|
||||||
switch kind {
|
switch configuration {
|
||||||
case .schedule: false
|
case .schedule: false
|
||||||
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
|
case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes
|
||||||
case .openLimit: usage.opensUsed >= maxOpens
|
case .openLimit(let config): usage.opensUsed >= config.maxOpens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import Foundation
|
|||||||
enum UsageDisplay {
|
enum UsageDisplay {
|
||||||
/// "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.kind {
|
switch rule.configuration {
|
||||||
case .schedule:
|
case .schedule:
|
||||||
""
|
""
|
||||||
case .timeLimit:
|
case .timeLimit(let config):
|
||||||
"\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of "
|
"\(min(usage.minutesUsed, config.dailyLimitMinutes))m of "
|
||||||
+ "\(rule.dailyLimitMinutes)m used today"
|
+ "\(config.dailyLimitMinutes)m used today"
|
||||||
case .openLimit:
|
case .openLimit(let config):
|
||||||
"\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today"
|
"\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens today"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,13 +27,13 @@ enum UsageDisplay {
|
|||||||
guard !rule.limitReached(given: usage) else {
|
guard !rule.limitReached(given: usage) else {
|
||||||
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
|
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
|
||||||
}
|
}
|
||||||
switch rule.kind {
|
switch rule.configuration {
|
||||||
case .schedule:
|
case .schedule:
|
||||||
return ""
|
return ""
|
||||||
case .timeLimit:
|
case .timeLimit(let config):
|
||||||
return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left"
|
return "\(config.dailyLimitMinutes - usage.minutesUsed)m left"
|
||||||
case .openLimit:
|
case .openLimit(let config):
|
||||||
let remaining = rule.maxOpens - usage.opensUsed
|
let remaining = config.maxOpens - usage.opensUsed
|
||||||
return remaining == 1 ? "1 open left" : "\(remaining) opens left"
|
return remaining == 1 ? "1 open left" : "\(remaining) opens left"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import SwiftData
|
|||||||
|
|
||||||
/// A recurring screen-time blocking rule.
|
/// 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
|
/// 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
|
/// 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.
|
/// its start (e.g. 22:00 → 06:00) crosses midnight into the following day.
|
||||||
@@ -19,10 +26,6 @@ final class BlockingRule {
|
|||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
||||||
var hardMode: Bool
|
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).
|
/// The reusable app list this rule blocks (or allows, in Allow Only mode).
|
||||||
///
|
///
|
||||||
/// Deliberately not an `init` parameter: SwiftData relationship properties
|
/// Deliberately not an `init` parameter: SwiftData relationship properties
|
||||||
@@ -36,51 +39,99 @@ final class BlockingRule {
|
|||||||
/// Legacy denormalized count; superseded by `appList?.selectionCount`.
|
/// Legacy denormalized count; superseded by `appList?.selectionCount`.
|
||||||
var selectionCount: Int
|
var selectionCount: Int
|
||||||
var dayNumbers: [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).
|
/// 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.
|
/// Cleared automatically once the date passes; never set while Hard Mode is active.
|
||||||
var pausedUntil: Date?
|
var pausedUntil: Date?
|
||||||
var createdAt: 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(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
name: String,
|
name: String,
|
||||||
kind: RuleKind = .schedule,
|
configuration: RuleConfiguration = .schedule(ScheduleConfig()),
|
||||||
isEnabled: Bool = true,
|
isEnabled: Bool = true,
|
||||||
hardMode: Bool = false,
|
hardMode: Bool = false,
|
||||||
blockAdultContent: Bool = false,
|
|
||||||
selectionMode: SelectionMode = .block,
|
|
||||||
selectionData: Data? = nil,
|
|
||||||
selectionCount: Int = 0,
|
|
||||||
days: Set<Weekday> = Weekday.weekdays,
|
days: Set<Weekday> = Weekday.weekdays,
|
||||||
startMinutes: Int = 9 * 60,
|
|
||||||
endMinutes: Int = 17 * 60,
|
|
||||||
dailyLimitMinutes: Int = 45,
|
|
||||||
maxOpens: Int = 5,
|
|
||||||
pausedUntil: Date? = nil,
|
pausedUntil: Date? = nil,
|
||||||
createdAt: Date = .now
|
createdAt: Date = .now
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.kindRaw = kind.rawValue
|
|
||||||
self.isEnabled = isEnabled
|
self.isEnabled = isEnabled
|
||||||
self.hardMode = hardMode
|
self.hardMode = hardMode
|
||||||
self.blockAdultContent = blockAdultContent
|
self.appList = nil
|
||||||
self.selectionModeRaw = selectionMode.rawValue
|
self.selectionData = nil
|
||||||
self.selectionData = selectionData
|
self.selectionCount = 0
|
||||||
self.selectionCount = selectionCount
|
|
||||||
self.dayNumbers = days.map(\.rawValue).sorted()
|
self.dayNumbers = days.map(\.rawValue).sorted()
|
||||||
self.startMinutes = startMinutes
|
|
||||||
self.endMinutes = endMinutes
|
|
||||||
self.dailyLimitMinutes = dailyLimitMinutes
|
|
||||||
self.maxOpens = maxOpens
|
|
||||||
self.pausedUntil = pausedUntil
|
self.pausedUntil = pausedUntil
|
||||||
self.createdAt = createdAt
|
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 {
|
var kind: RuleKind {
|
||||||
@@ -88,9 +139,9 @@ final class BlockingRule {
|
|||||||
set { kindRaw = newValue.rawValue }
|
set { kindRaw = newValue.rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How this rule interprets its app list. Always `.block` for limit kinds.
|
||||||
var selectionMode: SelectionMode {
|
var selectionMode: SelectionMode {
|
||||||
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
|
SelectionMode(rawValue: selectionModeRaw) ?? .block
|
||||||
set { selectionModeRaw = newValue.rawValue }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var days: Set<Weekday> {
|
var days: Set<Weekday> {
|
||||||
|
|||||||
@@ -9,58 +9,46 @@ import SwiftData
|
|||||||
/// Value-type working copy of a rule used by the editors, so cancelling an
|
/// 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
|
/// edit never touches the persisted model. Hashable so it can drive
|
||||||
/// `navigationDestination(item:)`.
|
/// `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 {
|
struct RuleDraft: Hashable {
|
||||||
var name: String
|
var name: String
|
||||||
var kind: RuleKind
|
|
||||||
var days: Set<Weekday>
|
var days: Set<Weekday>
|
||||||
var startMinutes: Int
|
|
||||||
var endMinutes: Int
|
|
||||||
var dailyLimitMinutes: Int
|
|
||||||
var maxOpens: Int
|
|
||||||
var hardMode: Bool
|
var hardMode: Bool
|
||||||
var blockAdultContent: Bool
|
|
||||||
var selectionMode: SelectionMode
|
|
||||||
/// Reference to the persisted list the rule will use. App lists are
|
/// Reference to the persisted list the rule will use. App lists are
|
||||||
/// managed (created/edited) directly by the picker, so the draft only
|
/// managed (created/edited) directly by the picker, so the draft only
|
||||||
/// carries the pointer.
|
/// carries the pointer.
|
||||||
var appList: AppList?
|
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
|
/// 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).
|
/// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||||
init(kind: RuleKind) {
|
init(kind: RuleKind) {
|
||||||
self.name = kind.defaultRuleName
|
self.name = kind.defaultRuleName
|
||||||
self.kind = kind
|
|
||||||
self.days = Weekday.weekdays
|
self.days = Weekday.weekdays
|
||||||
self.startMinutes = 9 * 60
|
|
||||||
self.endMinutes = 17 * 60
|
|
||||||
self.dailyLimitMinutes = 45
|
|
||||||
self.maxOpens = 5
|
|
||||||
self.hardMode = false
|
self.hardMode = false
|
||||||
self.blockAdultContent = false
|
|
||||||
self.selectionMode = .block
|
|
||||||
self.appList = nil
|
self.appList = nil
|
||||||
|
self.configuration = .default(for: kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(rule: BlockingRule) {
|
init(rule: BlockingRule) {
|
||||||
self.name = rule.name
|
self.name = rule.name
|
||||||
self.kind = rule.kind
|
|
||||||
self.days = rule.days
|
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.hardMode = rule.hardMode
|
||||||
self.blockAdultContent = rule.blockAdultContent
|
|
||||||
self.selectionMode = rule.selectionMode
|
|
||||||
self.appList = rule.appList
|
self.appList = rule.appList
|
||||||
|
self.configuration = rule.configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
init(preset: RulePreset) {
|
init(preset: RulePreset) {
|
||||||
self.init(kind: .schedule)
|
self.init(kind: .schedule)
|
||||||
self.name = preset.name
|
self.name = preset.name
|
||||||
self.days = preset.days
|
self.days = preset.days
|
||||||
self.startMinutes = preset.startMinutes
|
self.configuration = .schedule(
|
||||||
self.endMinutes = preset.endMinutes
|
ScheduleConfig(startMinutes: preset.startMinutes, endMinutes: preset.endMinutes))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the draft back onto a rule. The rule (and the chosen list) must
|
/// 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`).
|
/// assigned between managed models (see `BlockingRule.appList`).
|
||||||
func apply(to rule: BlockingRule) {
|
func apply(to rule: BlockingRule) {
|
||||||
rule.name = name
|
rule.name = name
|
||||||
rule.kind = kind
|
|
||||||
rule.days = days
|
rule.days = days
|
||||||
rule.startMinutes = startMinutes
|
|
||||||
rule.endMinutes = endMinutes
|
|
||||||
rule.dailyLimitMinutes = dailyLimitMinutes
|
|
||||||
rule.maxOpens = maxOpens
|
|
||||||
rule.hardMode = hardMode
|
rule.hardMode = hardMode
|
||||||
rule.blockAdultContent = blockAdultContent
|
rule.configuration = configuration
|
||||||
rule.selectionMode = selectionMode
|
|
||||||
if rule.appList !== appList {
|
if rule.appList !== appList {
|
||||||
rule.appList = appList
|
rule.appList = appList
|
||||||
}
|
}
|
||||||
@@ -87,26 +69,47 @@ struct RuleDraft: Hashable {
|
|||||||
/// ever written on a managed model.
|
/// ever written on a managed model.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func insertRule(into context: ModelContext) -> BlockingRule {
|
func insertRule(into context: ModelContext) -> BlockingRule {
|
||||||
let rule = BlockingRule(name: name, kind: kind)
|
let rule = BlockingRule(name: name, configuration: configuration)
|
||||||
context.insert(rule)
|
context.insert(rule)
|
||||||
apply(to: rule)
|
apply(to: rule)
|
||||||
return rule
|
return rule
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trims the name (falling back to the kind's default when it is empty)
|
/// 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
|
/// (Block / Allow Only no longer needs sanitizing: the sum type makes it
|
||||||
/// schedule-rule concept.
|
/// impossible for a limit draft to carry a selection mode at all.)
|
||||||
func sanitized() -> RuleDraft {
|
func sanitized() -> RuleDraft {
|
||||||
var copy = self
|
var copy = self
|
||||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||||
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
|
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
|
||||||
if kind != .schedule {
|
|
||||||
copy.selectionMode = .block
|
|
||||||
}
|
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
var schedule: RuleSchedule {
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ final class RuleEnforcer {
|
|||||||
shields.applyShield(
|
shields.applyShield(
|
||||||
ruleID: rule.id,
|
ruleID: rule.id,
|
||||||
selectionData: rule.appList?.selectionData,
|
selectionData: rule.appList?.selectionData,
|
||||||
// Allow Only is a schedule-rule concept; limit blocks always Block.
|
// Allow Only and Block Adult Content are Schedule-only options;
|
||||||
mode: rule.kind == .schedule ? rule.selectionMode : .block,
|
// the model already forces .block / false on limit rules, so we
|
||||||
|
// can forward the rule's values directly.
|
||||||
|
mode: rule.selectionMode,
|
||||||
blockAdultContent: rule.blockAdultContent
|
blockAdultContent: rule.blockAdultContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,17 @@ enum SampleRules {
|
|||||||
]
|
]
|
||||||
case .limits:
|
case .limits:
|
||||||
let timeKeeper = BlockingRule(
|
let timeKeeper = BlockingRule(
|
||||||
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
|
name: "Time Keeper",
|
||||||
dailyLimitMinutes: 45)
|
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
|
||||||
|
days: Weekday.everyDay)
|
||||||
let gateKeeper = BlockingRule(
|
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(
|
let doomScroll = BlockingRule(
|
||||||
name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay,
|
name: "Doom Scroll",
|
||||||
dailyLimitMinutes: 30)
|
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)),
|
||||||
|
days: Weekday.everyDay)
|
||||||
usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18)
|
usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18)
|
||||||
usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2)
|
usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2)
|
||||||
usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30)
|
usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30)
|
||||||
@@ -65,10 +69,9 @@ enum SampleRules {
|
|||||||
let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
|
let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
|
||||||
return BlockingRule(
|
return BlockingRule(
|
||||||
name: name,
|
name: name,
|
||||||
|
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
|
||||||
hardMode: hardMode,
|
hardMode: hardMode,
|
||||||
days: Weekday.everyDay,
|
days: Weekday.everyDay
|
||||||
startMinutes: start,
|
|
||||||
endMinutes: end
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +85,8 @@ enum SampleRules {
|
|||||||
let end = (start + 8 * 60) % (24 * 60)
|
let end = (start + 8 * 60) % (24 * 60)
|
||||||
return BlockingRule(
|
return BlockingRule(
|
||||||
name: name,
|
name: name,
|
||||||
days: Weekday.everyDay,
|
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
|
||||||
startMinutes: start,
|
days: Weekday.everyDay
|
||||||
endMinutes: end
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,26 +100,25 @@ struct RuleDetailSheet: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var detailRows: some View {
|
private var detailRows: some View {
|
||||||
switch rule.kind {
|
switch rule.configuration {
|
||||||
case .schedule:
|
case .schedule(let config):
|
||||||
row("During this time", rule.schedule.timeRangeLabel)
|
row("During this time", rule.schedule.timeRangeLabel)
|
||||||
row("On these days", rule.days.summary)
|
row("On these days", rule.days.summary)
|
||||||
row(rule.selectionMode.displayName, appCountLabel)
|
row(config.selectionMode.displayName, appCountLabel)
|
||||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
// Adult websites is a Schedule-only option (see spec §1).
|
||||||
|
row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
case .timeLimit:
|
case .timeLimit(let config):
|
||||||
row("When I use", appCountLabel)
|
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("On these days", rule.days.summary)
|
||||||
row("Then block until", "Tomorrow")
|
row("Then block until", "Tomorrow")
|
||||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
case .openLimit:
|
case .openLimit(let config):
|
||||||
row("When I open", appCountLabel)
|
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("On these days", rule.days.summary)
|
||||||
row("Then block until", "Tomorrow")
|
row("Then block until", "Tomorrow")
|
||||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ struct RuleEditorView: View {
|
|||||||
Section {
|
Section {
|
||||||
DatePicker(
|
DatePicker(
|
||||||
"From",
|
"From",
|
||||||
selection: timeBinding($draft.startMinutes),
|
selection: timeBinding($draft.scheduleConfig.startMinutes),
|
||||||
displayedComponents: .hourAndMinute
|
displayedComponents: .hourAndMinute
|
||||||
)
|
)
|
||||||
.accessibilityIdentifier("fromTimePicker")
|
.accessibilityIdentifier("fromTimePicker")
|
||||||
DatePicker(
|
DatePicker(
|
||||||
"To",
|
"To",
|
||||||
selection: timeBinding($draft.endMinutes),
|
selection: timeBinding($draft.scheduleConfig.endMinutes),
|
||||||
displayedComponents: .hourAndMinute
|
displayedComponents: .hourAndMinute
|
||||||
)
|
)
|
||||||
.accessibilityIdentifier("toTimePicker")
|
.accessibilityIdentifier("toTimePicker")
|
||||||
@@ -112,7 +112,7 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
daysSection
|
daysSection
|
||||||
Section {
|
Section {
|
||||||
Picker("Mode", selection: $draft.selectionMode) {
|
Picker("Mode", selection: $draft.scheduleConfig.selectionMode) {
|
||||||
ForEach(SelectionMode.allCases, id: \.self) { mode in
|
ForEach(SelectionMode.allCases, id: \.self) { mode in
|
||||||
Text(mode.displayName).tag(mode)
|
Text(mode.displayName).tag(mode)
|
||||||
}
|
}
|
||||||
@@ -121,10 +121,14 @@ struct RuleEditorView: View {
|
|||||||
.accessibilityIdentifier("selectionModePicker")
|
.accessibilityIdentifier("selectionModePicker")
|
||||||
appListRow
|
appListRow
|
||||||
} header: {
|
} 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)
|
.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:
|
case .timeLimit:
|
||||||
Section {
|
Section {
|
||||||
appListRow
|
appListRow
|
||||||
@@ -133,17 +137,23 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
budgetRow(
|
budgetRow(
|
||||||
value: "\(draft.dailyLimitMinutes)m",
|
value: "\(draft.timeLimitConfig.dailyLimitMinutes)m",
|
||||||
stepperID: "dailyLimitStepper",
|
stepperID: "dailyLimitStepper",
|
||||||
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
|
onIncrement: {
|
||||||
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
|
draft.timeLimitConfig.dailyLimitMinutes =
|
||||||
|
min(240, draft.timeLimitConfig.dailyLimitMinutes + 15)
|
||||||
|
},
|
||||||
|
onDecrement: {
|
||||||
|
draft.timeLimitConfig.dailyLimitMinutes =
|
||||||
|
max(15, draft.timeLimitConfig.dailyLimitMinutes - 15)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} header: {
|
} header: {
|
||||||
Text("For this long").textCase(nil)
|
Text("For this long").textCase(nil)
|
||||||
}
|
}
|
||||||
daysSection
|
daysSection
|
||||||
blockUntilSection
|
blockUntilSection
|
||||||
toggleSections
|
hardModeSection
|
||||||
case .openLimit:
|
case .openLimit:
|
||||||
Section {
|
Section {
|
||||||
appListRow
|
appListRow
|
||||||
@@ -152,17 +162,21 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
budgetRow(
|
budgetRow(
|
||||||
value: "\(draft.maxOpens) opens",
|
value: "\(draft.openLimitConfig.maxOpens) opens",
|
||||||
stepperID: "maxOpensStepper",
|
stepperID: "maxOpensStepper",
|
||||||
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
|
onIncrement: {
|
||||||
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
|
draft.openLimitConfig.maxOpens = min(50, draft.openLimitConfig.maxOpens + 1)
|
||||||
|
},
|
||||||
|
onDecrement: {
|
||||||
|
draft.openLimitConfig.maxOpens = max(1, draft.openLimitConfig.maxOpens - 1)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} header: {
|
} header: {
|
||||||
Text("More than").textCase(nil)
|
Text("More than").textCase(nil)
|
||||||
}
|
}
|
||||||
daysSection
|
daysSection
|
||||||
blockUntilSection
|
blockUntilSection
|
||||||
toggleSections
|
hardModeSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,8 +200,8 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
/// Hard Mode applies to every kind.
|
||||||
private var toggleSections: some View {
|
private var hardModeSection: some View {
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Hard Mode")
|
Text("Hard Mode")
|
||||||
@@ -199,11 +213,15 @@ struct RuleEditorView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("No unblocks allowed while the rule is blocking.")
|
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 {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Block Adult Content")
|
Text("Block Adult Content")
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle("", isOn: $draft.blockAdultContent)
|
Toggle("", isOn: $draft.scheduleConfig.blockAdultContent)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.accessibilityIdentifier("adultContentToggle")
|
.accessibilityIdentifier("adultContentToggle")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,10 +86,21 @@ struct AppListModelTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Suite("Legacy selection → AppList migration")
|
@Suite("Legacy selection → AppList migration")
|
||||||
struct AppListMigrationTests {
|
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")
|
@Test("Rules with legacy inline selections get a list named after them")
|
||||||
func createsListsFromLegacySelections() throws {
|
func createsListsFromLegacySelections() throws {
|
||||||
let context = try makeInMemoryContext()
|
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)
|
context.insert(rule)
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|
||||||
@@ -108,9 +119,9 @@ struct AppListMigrationTests {
|
|||||||
@Test("Rules with identical selections share one list")
|
@Test("Rules with identical selections share one list")
|
||||||
func sharesListForIdenticalSelections() throws {
|
func sharesListForIdenticalSelections() throws {
|
||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
|
let first = legacyRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
|
||||||
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
|
let second = legacyRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
|
||||||
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
|
let different = legacyRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
|
||||||
context.insert(first)
|
context.insert(first)
|
||||||
context.insert(second)
|
context.insert(second)
|
||||||
context.insert(different)
|
context.insert(different)
|
||||||
@@ -127,7 +138,7 @@ struct AppListMigrationTests {
|
|||||||
@Test("Migration is idempotent and skips selection-less rules")
|
@Test("Migration is idempotent and skips selection-less rules")
|
||||||
func idempotentAndSkipsEmpty() throws {
|
func idempotentAndSkipsEmpty() throws {
|
||||||
let context = try makeInMemoryContext()
|
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")
|
let empty = BlockingRule(name: "No Apps")
|
||||||
context.insert(legacy)
|
context.insert(legacy)
|
||||||
context.insert(empty)
|
context.insert(empty)
|
||||||
@@ -163,22 +174,24 @@ struct AppListDraftTests {
|
|||||||
#expect(other.name == "Other")
|
#expect(other.name == "Other")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Sanitizing forces Block mode for time- and open-limit rules")
|
@Test("Limit drafts structurally cannot carry a selection mode")
|
||||||
func sanitizedForcesBlockForLimitKinds() {
|
func limitDraftsHaveNoSelectionMode() {
|
||||||
var timeDraft = RuleDraft(kind: .timeLimit)
|
// The sum type makes Block / Allow Only a Schedule-only option: a limit
|
||||||
timeDraft.selectionMode = .allowOnly
|
// draft's configuration has no selection mode to force back to Block.
|
||||||
#expect(timeDraft.sanitized().selectionMode == .block)
|
#expect(RuleDraft(kind: .timeLimit).configuration.scheduleConfig == nil)
|
||||||
|
#expect(RuleDraft(kind: .openLimit).configuration.scheduleConfig == nil)
|
||||||
|
|
||||||
var openDraft = RuleDraft(kind: .openLimit)
|
// And the rule built from a limit draft is always Block.
|
||||||
openDraft.selectionMode = .allowOnly
|
let rule = BlockingRule(
|
||||||
#expect(openDraft.sanitized().selectionMode == .block)
|
name: "Time Keeper", configuration: RuleDraft(kind: .timeLimit).configuration)
|
||||||
|
#expect(rule.selectionMode == .block)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Sanitizing keeps Allow Only on schedule rules")
|
@Test("Allow Only survives on schedule rules")
|
||||||
func sanitizedKeepsAllowOnlyForSchedule() {
|
func keepsAllowOnlyForSchedule() {
|
||||||
var draft = RuleDraft(kind: .schedule)
|
var draft = RuleDraft(kind: .schedule)
|
||||||
draft.selectionMode = .allowOnly
|
draft.scheduleConfig.selectionMode = .allowOnly
|
||||||
#expect(draft.sanitized().selectionMode == .allowOnly)
|
#expect(draft.sanitized().scheduleConfig.selectionMode == .allowOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
OpenAppLockTests/RuleConfigurationTests.swift
Normal file
44
OpenAppLockTests/RuleConfigurationTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ struct RuleEnforcerTests {
|
|||||||
func skipsTimeLimitRules() {
|
func skipsTimeLimitRules() {
|
||||||
let shields = MockShieldController()
|
let shields = MockShieldController()
|
||||||
let enforcer = RuleEnforcer(shields: shields)
|
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)
|
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||||
|
|
||||||
@@ -104,7 +104,8 @@ struct RuleEnforcerTests {
|
|||||||
func forwardsSelectionMode() {
|
func forwardsSelectionMode() {
|
||||||
let shields = MockShieldController()
|
let shields = MockShieldController()
|
||||||
let enforcer = RuleEnforcer(shields: shields)
|
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)
|
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||||
|
|
||||||
@@ -115,7 +116,8 @@ struct RuleEnforcerTests {
|
|||||||
func forwardsAdultContentFlag() {
|
func forwardsAdultContentFlag() {
|
||||||
let shields = MockShieldController()
|
let shields = MockShieldController()
|
||||||
let enforcer = RuleEnforcer(shields: shields)
|
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")
|
let unfiltered = BlockingRule(name: "Plain")
|
||||||
|
|
||||||
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
|
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ struct RuleModelTests {
|
|||||||
|
|
||||||
@Test("Kind and selection mode survive raw storage, with safe fallbacks")
|
@Test("Kind and selection mode survive raw storage, with safe fallbacks")
|
||||||
func enumRoundTrip() {
|
func enumRoundTrip() {
|
||||||
let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly)
|
let rule = BlockingRule(
|
||||||
#expect(rule.kind == .openLimit)
|
name: "Test", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
|
||||||
|
#expect(rule.kind == .schedule)
|
||||||
#expect(rule.selectionMode == .allowOnly)
|
#expect(rule.selectionMode == .allowOnly)
|
||||||
rule.kindRaw = "garbage"
|
rule.kindRaw = "garbage"
|
||||||
rule.selectionModeRaw = "garbage"
|
rule.selectionModeRaw = "garbage"
|
||||||
@@ -45,12 +46,43 @@ struct RuleModelTests {
|
|||||||
#expect(rule.selectionMode == .block)
|
#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")
|
@Test("Rules persist and fetch through SwiftData")
|
||||||
func persistence() throws {
|
func persistence() throws {
|
||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
let rule = BlockingRule(
|
let rule = BlockingRule(
|
||||||
name: "Deep Sleep", hardMode: true,
|
name: "Deep Sleep",
|
||||||
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
|
configuration: .schedule(ScheduleConfig(startMinutes: 22 * 60, endMinutes: 6 * 60)),
|
||||||
|
hardMode: true,
|
||||||
|
days: Weekday.everyDay
|
||||||
)
|
)
|
||||||
context.insert(rule)
|
context.insert(rule)
|
||||||
try context.save()
|
try context.save()
|
||||||
@@ -73,7 +105,7 @@ struct RuleDraftTests {
|
|||||||
let draft = RuleDraft(kind: .timeLimit)
|
let draft = RuleDraft(kind: .timeLimit)
|
||||||
#expect(draft.name == "Time Keeper")
|
#expect(draft.name == "Time Keeper")
|
||||||
#expect(draft.kind == .timeLimit)
|
#expect(draft.kind == .timeLimit)
|
||||||
#expect(draft.dailyLimitMinutes == 45)
|
#expect(draft.timeLimitConfig.dailyLimitMinutes == 45)
|
||||||
#expect(draft.days == Weekday.weekdays)
|
#expect(draft.days == Weekday.weekdays)
|
||||||
#expect(!draft.hardMode)
|
#expect(!draft.hardMode)
|
||||||
}
|
}
|
||||||
@@ -87,18 +119,32 @@ struct RuleDraftTests {
|
|||||||
var draft = RuleDraft(kind: .schedule)
|
var draft = RuleDraft(kind: .schedule)
|
||||||
draft.name = "Locked In"
|
draft.name = "Locked In"
|
||||||
draft.days = Weekday.everyDay
|
draft.days = Weekday.everyDay
|
||||||
draft.startMinutes = 22 * 60
|
draft.configuration = .schedule(
|
||||||
draft.endMinutes = 6 * 60
|
ScheduleConfig(
|
||||||
|
startMinutes: 22 * 60, endMinutes: 6 * 60,
|
||||||
|
selectionMode: .allowOnly, blockAdultContent: true))
|
||||||
draft.hardMode = true
|
draft.hardMode = true
|
||||||
draft.blockAdultContent = true
|
|
||||||
draft.selectionMode = .allowOnly
|
|
||||||
draft.appList = list
|
draft.appList = list
|
||||||
|
|
||||||
let rule = draft.insertRule(into: context)
|
let rule = draft.insertRule(into: context)
|
||||||
#expect(rule.blockAdultContent)
|
#expect(rule.blockAdultContent)
|
||||||
|
#expect(rule.selectionMode == .allowOnly)
|
||||||
#expect(RuleDraft(rule: rule) == draft)
|
#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")
|
@Test("Applying a draft updates an existing rule")
|
||||||
func applyToExisting() throws {
|
func applyToExisting() throws {
|
||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
@@ -135,8 +181,8 @@ struct RuleDraftTests {
|
|||||||
)
|
)
|
||||||
let draft = RuleDraft(preset: preset)
|
let draft = RuleDraft(preset: preset)
|
||||||
#expect(draft.name == "Deep Sleep")
|
#expect(draft.name == "Deep Sleep")
|
||||||
#expect(draft.startMinutes == 22 * 60)
|
#expect(draft.scheduleConfig.startMinutes == 22 * 60)
|
||||||
#expect(draft.endMinutes == 6 * 60)
|
#expect(draft.scheduleConfig.endMinutes == 6 * 60)
|
||||||
#expect(draft.kind == .schedule)
|
#expect(draft.kind == .schedule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ struct RuleStatusTests {
|
|||||||
|
|
||||||
@Test("Time-limit rules are never schedule-active")
|
@Test("Time-limit rules are never schedule-active")
|
||||||
func timeLimitNeverActive() {
|
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)
|
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
|
||||||
#expect(!status.isActive)
|
#expect(!status.isActive)
|
||||||
if case .upcoming = status {} else {
|
if case .upcoming = status {} else {
|
||||||
@@ -116,7 +116,8 @@ struct RuleStatusTests {
|
|||||||
/// daily budget — never the vestigial 09:00 start as "Starts in 22h".
|
/// 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")
|
@Test("Idle time-limit rule shows its daily budget, not a clock countdown")
|
||||||
func timeLimitDisplayLabel() {
|
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 now = date(2025, 1, 6, 11, 38) // past the vestigial 09:00 window start
|
||||||
let status = rule.status(at: now, calendar: utc)
|
let status = rule.status(at: now, calendar: utc)
|
||||||
#expect(rule.statusLabel(for: status, relativeTo: now) == "15m / day")
|
#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")
|
@Test("Idle open-limit rule shows its daily opens budget")
|
||||||
func openLimitDisplayLabel() {
|
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 now = date(2025, 1, 6, 11, 38)
|
||||||
let status = rule.status(at: now, calendar: utc)
|
let status = rule.status(at: now, calendar: utc)
|
||||||
#expect(rule.statusLabel(for: status, relativeTo: now) == "5 opens / day")
|
#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")
|
@Test("A spent time-limit budget still shows the blocked countdown")
|
||||||
func timeLimitBlockingDisplayLabel() {
|
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 now = date(2025, 1, 6, 11, 38)
|
||||||
let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15))
|
let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15))
|
||||||
#expect(status.isActive)
|
#expect(status.isActive)
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ struct RuleSnapshotTests {
|
|||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
|
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
|
||||||
let rule = BlockingRule(
|
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(list)
|
||||||
context.insert(rule)
|
context.insert(rule)
|
||||||
rule.appList = list
|
rule.appList = list
|
||||||
@@ -135,7 +137,8 @@ struct RuleSchedulerTests {
|
|||||||
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
|
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
|
||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1)
|
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(list)
|
||||||
context.insert(rule)
|
context.insert(rule)
|
||||||
rule.appList = list
|
rule.appList = list
|
||||||
@@ -148,7 +151,9 @@ struct RuleSchedulerTests {
|
|||||||
) throws -> BlockingRule {
|
) throws -> BlockingRule {
|
||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
let rule = BlockingRule(
|
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)
|
context.insert(rule)
|
||||||
if withApps {
|
if withApps {
|
||||||
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
|
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
|
||||||
@@ -186,7 +191,7 @@ struct RuleSchedulerTests {
|
|||||||
let (scheduler, monitor, _) = makeScheduler()
|
let (scheduler, monitor, _) = makeScheduler()
|
||||||
let context = try makeInMemoryContext()
|
let context = try makeInMemoryContext()
|
||||||
let applessSchedule = BlockingRule(name: "Work Time")
|
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(applessSchedule)
|
||||||
context.insert(applessLimit)
|
context.insert(applessLimit)
|
||||||
|
|
||||||
|
|||||||
@@ -79,13 +79,17 @@ struct UsageStatusTests {
|
|||||||
let mondayMorning = date(2025, 1, 6, 10, 0)
|
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||||
|
|
||||||
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
|
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
|
||||||
BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
|
BlockingRule(
|
||||||
dailyLimitMinutes: limit)
|
name: "Time Keeper",
|
||||||
|
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
|
||||||
|
days: Weekday.everyDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
|
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
|
||||||
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay,
|
BlockingRule(
|
||||||
maxOpens: maxOpens)
|
name: "Gate Keeper",
|
||||||
|
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
|
||||||
|
days: Weekday.everyDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A time-limit rule with budget left is not active")
|
@Test("A time-limit rule with budget left is not active")
|
||||||
@@ -153,13 +157,17 @@ struct UsageEnforcementTests {
|
|||||||
let ledger = MockUsageLedger()
|
let ledger = MockUsageLedger()
|
||||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
let rule = BlockingRule(
|
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)
|
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45)
|
||||||
|
|
||||||
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||||
#expect(shields.appliedModes[rule.id] == .block)
|
#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")
|
@Test("A limit rule with budget left is not shielded")
|
||||||
@@ -168,7 +176,9 @@ struct UsageEnforcementTests {
|
|||||||
let ledger = MockUsageLedger()
|
let ledger = MockUsageLedger()
|
||||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
let rule = BlockingRule(
|
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)
|
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||||
|
|
||||||
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||||
@@ -181,9 +191,13 @@ struct UsageEnforcementTests {
|
|||||||
@Suite("Usage display strings")
|
@Suite("Usage display strings")
|
||||||
struct UsageDisplayTests {
|
struct UsageDisplayTests {
|
||||||
let timeRule = BlockingRule(
|
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(
|
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")
|
@Test("Time-limit rows show minutes used and remaining")
|
||||||
func timeLimitStrings() {
|
func timeLimitStrings() {
|
||||||
|
|||||||
@@ -131,6 +131,32 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
|
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 {
|
func testEditorSupportsNativeSwipeBack() throws {
|
||||||
let app = XCUIApplication.launchOpenAppLock()
|
let app = XCUIApplication.launchOpenAppLock()
|
||||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
|
|||||||
@@ -91,8 +91,10 @@ struct LimitEnforcement {
|
|||||||
shields.applyShield(
|
shields.applyShield(
|
||||||
ruleID: snapshot.id,
|
ruleID: snapshot.id,
|
||||||
selectionData: snapshot.selectionData,
|
selectionData: snapshot.selectionData,
|
||||||
|
// Limit rules are always Block and never engage the adult-content
|
||||||
|
// filter — those are Schedule-only options.
|
||||||
mode: .block,
|
mode: .block,
|
||||||
blockAdultContent: snapshot.blockAdultContent
|
blockAdultContent: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
Shared/RuleConfiguration.swift
Normal file
90
Shared/RuleConfiguration.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
| **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) |
|
| **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))
|
- **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
|
- **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
|
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.
|
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").
|
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"
|
- **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")
|
- **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):
|
Derived status (drives card/detail UI):
|
||||||
- **Active** → countdown to window end: green pill "6h left"
|
- **Active** → countdown to window end: green pill "6h left"
|
||||||
- **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h"
|
- **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.
|
blocked" / "Only these apps are allowed" accordingly.
|
||||||
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
||||||
toggle.
|
toggle.
|
||||||
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video)* —
|
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video;
|
||||||
subtitle "Filter adult websites while this rule is active"; trailing
|
**Schedule rules only**)* — subtitle "Filter adult websites while this rule
|
||||||
toggle. Maps to Screen Time's web-content filter
|
is active"; trailing toggle. Maps to Screen Time's web-content filter
|
||||||
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
|
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
|
||||||
and cleared together with the rule's shield. Surfaces in the rule detail
|
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**
|
6. **CTA**
|
||||||
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
|
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
|
||||||
interaction (deliberate friction) that fills, then saves and dismisses to
|
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 ⌃⌄`
|
4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
|
||||||
(reset point — e.g. tomorrow/next morning).
|
(reset point — e.g. tomorrow/next morning).
|
||||||
5. **Hard Mode** toggle — same as Schedule.
|
5. **Hard Mode** toggle — same as Schedule.
|
||||||
6. **Block Adult Content** toggle — same as Schedule.
|
6. **Hold to Commit**. *(No Block Adult Content toggle — Schedule-only.)*
|
||||||
7. **Hold to Commit**.
|
|
||||||
|
|
||||||
### 3.6 Rule Editor — Open Limit type
|
### 3.6 Rule Editor — Open Limit type
|
||||||
|
|
||||||
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
|
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
|
||||||
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
|
"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)
|
### 3.7 App Picker (shared component — also used in onboarding & timer)
|
||||||
|
|
||||||
@@ -309,36 +321,58 @@ Full-height sheet:
|
|||||||
|
|
||||||
### 5.2 Data model (SwiftData)
|
### 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
|
```swift
|
||||||
enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
|
enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
|
||||||
enum SelectionMode: String, Codable { case block, allowOnly }
|
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 {
|
@Model final class BlockingRule {
|
||||||
var id: UUID
|
var id: UUID
|
||||||
var name: String
|
var name: String
|
||||||
var kind: RuleKind
|
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
var hardMode: Bool
|
var hardMode: Bool
|
||||||
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
|
var appList: AppList?
|
||||||
var selectionMode: SelectionMode
|
var days: [Int] // 1...7, Calendar weekday numbers
|
||||||
var selectionData: Data // encoded FamilyActivitySelection
|
var pausedUntil: Date?
|
||||||
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 createdAt: 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
|
`RuleDraft` (the editors' value-type working copy) carries the same
|
||||||
("active", "starts in Xh", "Xh left") is **derived**, not stored.
|
`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
|
### 5.3 View inventory
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user