diff --git a/AGENTS.md b/AGENTS.md index e640636..a1e6805 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ OpenAppLock is an iOS Screen Time app: recurring **rules** that block selected apps (Schedule windows, Time Limits, Open Limits), with a **Hard Mode** that makes an active block impossible to lift, edit, or delete until it ends. The -feature set is a clone of Opal's "Rules"; the presentation is bare native iOS -(List/Form/NavigationStack, default color scheme). +presentation is bare native iOS (List/Form/NavigationStack, default color +scheme). ## Repo layout @@ -39,9 +39,9 @@ OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open, OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app target defaults to MainActor isolation) OpenAppLockUITests/ XCUITest flows (see harness below) -docs/RULES_FEATURE_SPEC.md Feature spec derived from the Opal reference - recording; §6 maps it to the native presentation. - Review/update this BEFORE behavior changes. +docs/RULES_FEATURE_SPEC.md Feature spec for the rules behavior; §6 maps it + to the native presentation. Review/update this + BEFORE behavior changes. docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards agents must follow on this project. ``` @@ -53,7 +53,7 @@ docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards *starts* on. `start == end` = 24h window. - Status is always **derived** (`rule.status(at:calendar:)`), never stored: `disabled / dormant / active(until:) / paused(until:) / upcoming(startsAt:)`. - Labels match the reference app ("6h left" rounds hours **up**). + Countdown labels round hours **up** (e.g. "6h left"). - **Hard Mode**: `RulePolicy` is the single gate — while a hard-mode rule is actively blocking, canEdit/canDisable/canDelete/canUnblock are all false. Soft rules can be "unblocked", which sets `pausedUntil` = window end (the diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index db3a06c..a39db57 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -33,7 +33,7 @@ enum RuleStatus: Equatable, Sendable { } } - /// Compact countdown matching the reference app: minutes under an hour, + /// Compact countdown: minutes under an hour, /// hours (rounded up) under two days, then days. static func countdown(from now: Date, to target: Date) -> String { let minutes = max(1, Int(ceil(target.timeIntervalSince(now) / 60))) diff --git a/OpenAppLock/Models/BlockingRule.swift b/OpenAppLock/Models/BlockingRule.swift index e242073..ce7033e 100644 --- a/OpenAppLock/Models/BlockingRule.swift +++ b/OpenAppLock/Models/BlockingRule.swift @@ -78,7 +78,7 @@ final class BlockingRule { self.dayNumbers = days.map(\.rawValue).sorted() self.pausedUntil = pausedUntil self.createdAt = createdAt - // Raw per-kind columns start at the reference defaults, then the + // Raw per-kind columns start at the default values, then the // configuration overwrites the ones that apply to its kind. self.kindRaw = configuration.kind.rawValue self.startMinutes = 9 * 60 diff --git a/OpenAppLock/Models/RuleDraft.swift b/OpenAppLock/Models/RuleDraft.swift index be0de70..2ffc1a5 100644 --- a/OpenAppLock/Models/RuleDraft.swift +++ b/OpenAppLock/Models/RuleDraft.swift @@ -25,8 +25,8 @@ struct RuleDraft: Hashable { var kind: RuleKind { configuration.kind } - /// A fresh draft for a new rule of the given kind, using the reference - /// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day). + /// A fresh draft for a new rule of the given kind, using the default + /// values (9–5 weekdays schedule, 45m/day, 5 opens/day). init(kind: RuleKind) { self.name = kind.defaultRuleName self.days = Weekday.weekdays diff --git a/OpenAppLock/Models/RulePreset.swift b/OpenAppLock/Models/RulePreset.swift index defad72..a350047 100644 --- a/OpenAppLock/Models/RulePreset.swift +++ b/OpenAppLock/Models/RulePreset.swift @@ -5,8 +5,7 @@ import SwiftUI -/// A suggested schedule rule shown in the New Rule sheet's preset gallery, -/// mirroring the reference app's sections and timings. +/// A suggested schedule rule shown in the New Rule sheet's preset gallery. struct RulePreset: Identifiable, Hashable, Sendable { let id: String let name: String @@ -14,7 +13,7 @@ struct RulePreset: Identifiable, Hashable, Sendable { let endMinutes: Int let days: Set let symbolName: String - /// Gradient stand-in for the reference app's photo backgrounds. + /// Gradient background shown behind each preset card. let gradientTop: Color let gradientBottom: Color diff --git a/OpenAppLockTests/RuleConfigurationTests.swift b/OpenAppLockTests/RuleConfigurationTests.swift index 64232b9..99b79f9 100644 --- a/OpenAppLockTests/RuleConfigurationTests.swift +++ b/OpenAppLockTests/RuleConfigurationTests.swift @@ -18,7 +18,7 @@ struct RuleConfigurationTests { #expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit) } - @Test("Defaults match the reference app's new-rule defaults") + @Test("Defaults match the documented new-rule defaults") func defaults() { let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig #expect(schedule?.startMinutes == 9 * 60) diff --git a/OpenAppLockTests/RuleModelTests.swift b/OpenAppLockTests/RuleModelTests.swift index 37ce3e1..e6f5162 100644 --- a/OpenAppLockTests/RuleModelTests.swift +++ b/OpenAppLockTests/RuleModelTests.swift @@ -12,7 +12,7 @@ import Testing @MainActor @Suite("BlockingRule model & persistence") struct RuleModelTests { - @Test("Defaults match the reference app's new-rule defaults") + @Test("Defaults match the documented new-rule defaults") func defaults() { let rule = BlockingRule(name: "In the Zone") #expect(rule.kind == .schedule) diff --git a/OpenAppLockTests/RuleStatusTests.swift b/OpenAppLockTests/RuleStatusTests.swift index a277cd5..d755298 100644 --- a/OpenAppLockTests/RuleStatusTests.swift +++ b/OpenAppLockTests/RuleStatusTests.swift @@ -11,7 +11,7 @@ import Testing @MainActor @Suite("Rule status derivation and labels") struct RuleStatusTests { - /// 09:00–17:00 weekdays schedule rule, like the video's "Work Time". + /// 09:00–17:00 weekdays schedule rule (the "Work Time" preset). func workTime(hardMode: Bool = false) -> BlockingRule { BlockingRule(name: "Work Time", hardMode: hardMode) } @@ -71,16 +71,16 @@ struct RuleStatusTests { } } - @Test("Active label matches the reference style: hours round up") + @Test("Active label rounds hours up") func activeLabel() { - // 11:28 → 17:00 is 5h32m; the reference app shows "6h left". + // 11:28 → 17:00 is 5h32m; rounds up to "6h left". let status = workTime().status(at: date(2025, 1, 6, 11, 28), calendar: utc) #expect(status.label(relativeTo: date(2025, 1, 6, 11, 28)) == "6h left") } - @Test("Upcoming label matches the reference style") + @Test("Upcoming label formats hours until start") func upcomingLabel() { - // Friday 11:28 → Saturday 09:00 is 21h32m; the reference shows "Starts in 22h". + // Friday 11:28 → Saturday 09:00 is 21h32m; rounds up to "Starts in 22h". let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends) let friday = date(2025, 1, 10, 11, 28) #expect(weekend.status(at: friday, calendar: utc).label(relativeTo: friday) == "Starts in 22h") diff --git a/Shared/RuleConfiguration.swift b/Shared/RuleConfiguration.swift index b8786c0..f4509cc 100644 --- a/Shared/RuleConfiguration.swift +++ b/Shared/RuleConfiguration.swift @@ -26,8 +26,8 @@ enum RuleConfiguration: Hashable, Sendable { } } - /// 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). + /// The default configuration for a brand-new rule of the given kind + /// (9–5 schedule, 45m/day, 5 opens/day). static func `default`(for kind: RuleKind) -> RuleConfiguration { switch kind { case .schedule: .schedule(ScheduleConfig()) diff --git a/Shared/RuleKind.swift b/Shared/RuleKind.swift index 48a6321..c8938a2 100644 --- a/Shared/RuleKind.swift +++ b/Shared/RuleKind.swift @@ -5,7 +5,7 @@ import Foundation -/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet. +/// The three kinds of blocking rules offered on the "New Rule" sheet. enum RuleKind: String, Codable, CaseIterable, Sendable { /// Block selected apps during a recurring time window. case schedule @@ -38,7 +38,7 @@ enum RuleKind: String, Codable, CaseIterable, Sendable { } } - /// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper"). + /// Default name given to a brand-new rule of this kind (e.g. "In the Zone", "Time Keeper"). var defaultRuleName: String { switch self { case .schedule: "In the Zone" diff --git a/Shared/RuleSchedule.swift b/Shared/RuleSchedule.swift index f30e4e4..82c4170 100644 --- a/Shared/RuleSchedule.swift +++ b/Shared/RuleSchedule.swift @@ -76,7 +76,7 @@ struct RuleSchedule: Hashable, Sendable { } extension RuleSchedule { - /// "09:00" style label for a minutes-from-midnight value, matching the reference UI. + /// "09:00" style label for a minutes-from-midnight value. static func timeLabel(forMinutes minutes: Int) -> String { let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60) return String(format: "%02d:%02d", clamped / 60, clamped % 60) diff --git a/Shared/Weekday.swift b/Shared/Weekday.swift index f89cd84..8a27d9f 100644 --- a/Shared/Weekday.swift +++ b/Shared/Weekday.swift @@ -19,7 +19,7 @@ enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable { static let weekends: Set = [.saturday, .sunday] static let everyDay: Set = Set(Weekday.allCases) - /// Display order used by the day picker, matching the reference UI: S M T W T F S. + /// Display order used by the day picker: S M T W T F S. static let displayOrder: [Weekday] = [ .sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday, ] diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 4768f46..ec300b6 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -1,9 +1,8 @@ -# OpenAppLock — "Rules" Feature Spec (derived from Opal screen recording) +# OpenAppLock — "Rules" Feature Spec -Source: `ScreenRecording_06-12-2026 11-26-19_1.MP4` (iPhone, 4:29). The rules feature -appears roughly between 1:00–3:00 of the recording, on Opal's "My Apps" tab. -This spec describes what was observed, then maps it onto an implementation plan -for OpenAppLock. +This spec describes OpenAppLock's recurring app-blocking **rules** feature: the +behavior the app implements, then how it maps onto the native iOS presentation +(see §6). --- @@ -11,13 +10,13 @@ for OpenAppLock. A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off block/timer session, a rule re-arms itself on a schedule. Three rule types are -offered (Opal's "New Rule" sheet): +offered, presented on the "New Rule" sheet: | Type | Icon | Example shown | Semantics | |------|------|---------------|-----------| | **Schedule** | calendar grid | "e.g. 9-5, Daily" | Block selected apps during a daily time window on chosen days | | **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 | **Common attributes** — present for *every* rule kind: @@ -28,7 +27,7 @@ offered (Opal's "New Rule" sheet): 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"). -- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes" +- **Hard Mode** — boolean; 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 @@ -87,10 +86,10 @@ Dark theme throughout (near-black background, very dark green tint). 2. **Blocked Apps** — section header; horizontal row of currently-blocked app icons. Each icon has a lock badge overlay and a teal/green rounded-rect outline; caption "Unblock" under the icon. Tapping unblocks (with friction - if hard mode — not demoed). + if hard mode). *(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day also appear here, blocked until midnight. -3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section +3. **Usage** *(OpenAppLock addition)* — a section showing live tracking for every enabled Time/Open Limit rule scheduled today **that is not currently blocking**. Each row leads its subtitle with the rule **type** so the kind is clear without relying on the icon: @@ -175,8 +174,7 @@ Sections (each an inset rounded group with a small icon + caption header): 1. **📅 During this time** - Rows `From` / `To` with right-aligned time + stepper chevrons (`09:00 ⌃⌄`). - A dotted vertical line with ●/○ endpoints visually links From → To. - - Tapping a row expands an inline wheel time picker (24h, demoed changing - From 22:00 → 23:00). + - Tapping a row expands an inline wheel time picker (24h). 2. **On these days:** — trailing summary label ("Weekdays"/"Weekends"/custom); row of 7 circular toggles `S M T W T F S`; selected = filled white circle with black letter, unselected = dark circle. @@ -188,8 +186,8 @@ 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; - **Schedule rules only**)* — subtitle "Filter adult websites while this rule +5. **Block Adult Content** *(OpenAppLock addition; **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 @@ -217,7 +215,7 @@ Same chrome (back / title / rename). Sections: ### 3.6 Rule Editor — Open Limit type -Not demoed beyond its card. Spec by analogy: "When I open [apps]" / +Spec by analogy with the other editors: "When I open [apps]" / "More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" / Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)* @@ -243,11 +241,10 @@ Full-height sheet: letting users add arbitrary domains. - Footer: "**N Apps Selected**" caption + white pill "**Save**" (+ "Cancel"). -> Implementation note: Opal ships its own app categorization. On iOS, third -> parties cannot enumerate installed apps; the system-sanctioned route is -> `FamilyActivityPicker` (FamilyControls), which provides its own -> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should -> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker. +> Implementation note: On iOS, third parties cannot enumerate installed apps; +> the system-sanctioned route is `FamilyActivityPicker` (FamilyControls), which +> provides its own category/app/website UI and returns opaque tokens. **v1 of +> OpenAppLock embeds `FamilyActivityPicker`** rather than a custom app picker. > > **App Lists (OpenAppLock):** the selection itself lives on a reusable > **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`). @@ -305,11 +302,11 @@ Full-height sheet: (which lists only rules whose budget is exhausted); it shows under "Usage" with its remaining opens. 5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the - rule (card presumably shows disabled state). No delete flow was shown; - add delete via swipe/long-press or a button in the editor. + rule (the card shows a disabled state). Delete is offered from the rule + editor's actions menu. 6. **Commit friction** — creating/committing a rule uses press-and-hold - ("Hold to Commit"), echoing Opal's philosophy that *starting* a commitment - should be deliberate. Editing uses a plain "Done". + ("Hold to Commit"), making the *start* of a commitment deliberate. Editing + uses a plain "Done". 7. **Live countdowns** — "Starts in 22h" / "6h left" update over time (minute granularity is fine). 8. **Overlapping rules — strictest enforcement wins.** When several rules @@ -503,20 +500,20 @@ Xh", "Xh left") is **derived**, not stored. - All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit tested from the app test target. -### 5.6 Out of scope (seen in video, not part of "rules") +### 5.6 Out of scope (not part of "rules") -- Onboarding flow, paywall ("You know Opal works. Make it permanent."), - Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?" - friction screen), notification nudges ("Complete Your Setup"). +- Onboarding flow, paywall, Home tab gem/score UI, Timer tab (one-off focus + sessions, "Leave Early?" friction screen), notification nudges ("Complete + Your Setup"). --- ## 6. Native UI re-skin (current presentation) -OpenAppLock has since replaced the Opal-style custom presentation with the bare +OpenAppLock has since replaced its custom themed presentation with the bare iOS design language, keeping the backend (models, logic, services), the flows, and the accessibility identifiers intact. Sections 1–5 remain as the -reference for *what* the feature does; presentation now maps as follows. +spec for *what* the feature does; presentation now maps as follows. After onboarding the app is a three-tab `TabView` (`MainTabView`), each tab its own `NavigationStack`: