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