Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions Docs/AGENT_RULES_FEATURE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,15 @@ Dark theme throughout (near-black background, very dark green tint).
also appear here, blocked until midnight.
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:
- Time Limit row: subtitle "Time Limit · 18m of 45m used today", trailing "27m left".
- Open Limit row: subtitle "Open Limit · 2 of 5 opens today", trailing "3 opens left".
**that is not currently blocking**. Each row has a leading kind icon and a
`<Type> · <context>` subtitle (no trailing label); the type prefix keeps the
kind clear and "today" is dropped as implied:
- Time Limit row: subtitle "Time Limit · 18m of 45m used".
- Open Limit row: subtitle "Open Limit · 2 of 5 opens".
A rule whose budget is **spent** (actively blocking) **moves out of Usage into
the "Currently Blocking" section** (it shows its type + usage there instead);
a *soft-unblocked* spent rule is paused (not blocking), so it returns to Usage
reading "Unblocked until tomorrow". Usage numbers come from the shared app-group
reading "Paused". Usage numbers come from the shared app-group
ledger written by the DeviceActivity monitor and shield-action extensions.
3. **Rules** — header row: "Rules ›" (leading, tappable to a full list,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
Expand Down Expand Up @@ -532,8 +533,8 @@ it runs regardless of the selected tab.

| Spec element | Native presentation |
|---|---|
| Home tab | `NavigationStack` + `List`. **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: **no leading icon**; a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage) with a `<Type> · <usage>` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a `<Type> · NN of MM used today` subtitle + trailing remaining/blocked label. |
| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, block summary, trailing live status — green when active); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. |
| Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a `<Type> · <context>` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). |
| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, and a `<context>` subtitle — the same live status/countdown/usage as Home, but **without the type prefix** since the section header already conveys the kind, and **without a separate trailing status label**; the `ruleStatus-<name>` identifier lives on this subtitle); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. |
| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). |
| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead |
| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` |
Expand Down
36 changes: 24 additions & 12 deletions OpenAppLock/Logic/RuleStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,32 @@ extension BlockingRule {
return .dormant
}

/// User-facing status label, kind-aware. Limit rules apply all day and have
/// no clock window, so while they are not blocking they show their daily
/// budget ("15m / day") instead of `.upcoming`'s vestigial start countdown.
/// Schedule rules, and any rule that is actually blocking/paused/dormant,
/// use the plain status label.
func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String {
if case .upcoming = status {
switch configuration {
case .schedule: break
case .timeLimit(let config): return "\(config.dailyLimitMinutes)m / day"
case .openLimit(let config): return "\(config.maxOpens) opens / day"
/// The live "context" line shown under a rule's name on the Home and Rules
/// lists, and as the rule-detail caption. A single source of truth so every
/// screen renders a given kind/state the same way.
///
/// - Schedule rules read their clock status: "6h left", "Starts in 22h",
/// "Paused", "Disabled", "No days selected".
/// - Limit rules share that wording while disabled / dormant / paused;
/// otherwise they read their budget — live usage once the rule has been
/// used today ("18m of 45m used"), and the plain daily allowance while
/// still untouched ("45m / day"). A spent limit therefore reads
/// "45m of 45m used", never a clock countdown.
func rowContext(for status: RuleStatus, usage: RuleUsage, relativeTo now: Date) -> String {
switch configuration {
case .schedule:
return status.label(relativeTo: now)
case .timeLimit, .openLimit:
switch status {
case .disabled, .dormant, .paused:
return status.label(relativeTo: now)
case .active, .upcoming:
let usedToday = usage.minutesUsed > 0 || usage.opensUsed > 0
return usedToday
? UsageDisplay.usagePhrase(for: self, usage: usage)
: UsageDisplay.budgetPhrase(for: self)
}
}
return status.label(relativeTo: now)
}

/// Whether the rule's enabled days include the day containing `now`.
Expand Down
44 changes: 21 additions & 23 deletions OpenAppLock/Logic/UsageDisplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,43 @@

import Foundation

/// Strings for the home screen's Usage section. Used values clamp to the
/// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
/// Strings for the home- and rules-list rows. Used values clamp to the budget
/// so overshoot (thresholds can fire late) never reads "50m of 45m".
enum UsageDisplay {
/// The usage subtitle prefixed with the rule's type, so the kind is clear
/// without relying on an icon: "Time Limit · 18m of 45m used today".
/// Schedule rules (no usage text) fall back to just the type name.
static func typedSubtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
let usageText = subtitle(for: rule, usage: usage)
guard !usageText.isEmpty else { return rule.kind.displayName }
return "\(rule.kind.displayName) · \(usageText)"
/// The Home-list subtitle: the rule's type, then its live context, so the
/// kind reads without relying on the icon ("Time Limit · 18m of 45m used",
/// "Schedule · 6h left"). The Rules list omits the type prefix because its
/// section header already conveys it.
static func homeSubtitle(
for rule: BlockingRule, status: RuleStatus, usage: RuleUsage, relativeTo now: Date
) -> String {
"\(rule.kind.displayName) · \(rule.rowContext(for: status, usage: usage, relativeTo: now))"
}

/// "18m of 45m used today" / "2 of 5 opens today".
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
/// "18m of 45m used" / "2 of 5 opens". Empty for schedule rules, which have
/// no usage budget. ("today" is implied — usage always covers the current day.)
static func usagePhrase(for rule: BlockingRule, usage: RuleUsage) -> String {
switch rule.configuration {
case .schedule:
""
case .timeLimit(let config):
"\(min(usage.minutesUsed, config.dailyLimitMinutes))m of "
+ "\(config.dailyLimitMinutes)m used today"
+ "\(config.dailyLimitMinutes)m used"
case .openLimit(let config):
"\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens today"
"\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens"
}
}

/// "27m left" / "3 opens left", or the blocked/unblocked state once the
/// budget is spent.
static func remainingLabel(for rule: BlockingRule, usage: RuleUsage, isPaused: Bool) -> String {
guard !rule.limitReached(given: usage) else {
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
}
/// "45m / day" / "5 opens / day" — the plain daily allowance, shown while a
/// limit rule has no usage recorded today. Empty for schedule rules.
static func budgetPhrase(for rule: BlockingRule) -> String {
switch rule.configuration {
case .schedule:
return ""
""
case .timeLimit(let config):
return "\(config.dailyLimitMinutes - usage.minutesUsed)m left"
"\(config.dailyLimitMinutes)m / day"
case .openLimit(let config):
let remaining = config.maxOpens - usage.opensUsed
return remaining == 1 ? "1 open left" : "\(remaining) opens left"
"\(config.maxOpens) opens / day"
}
}
}
41 changes: 22 additions & 19 deletions OpenAppLock/Views/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ struct HomeView: View {
}
}

/// A blocking rule: no leading icon. A limit rule shows its type + usage so
/// the kind reads without an icon; a schedule rule shows just its name.
/// Trailing affordance: a lock when Hard Mode (the block can't be lifted),
/// otherwise an Unblock button.
/// A blocking rule: leading kind icon, name, and a "<Type> · <context>"
/// subtitle (a schedule shows its countdown, a limit its usage). Trailing
/// affordance: a lock when Hard Mode (the block can't be lifted), otherwise
/// an Unblock button.
private func blockingRow(for rule: BlockingRule, now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
let status = liveStatus(for: rule, now: now)
return Button {
if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
unblockCandidate = rule
Expand All @@ -94,14 +95,13 @@ struct HomeView: View {
}
} label: {
HStack {
kindIcon(for: rule)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
if rule.kind != .schedule {
Text(UsageDisplay.typedSubtitle(for: rule, usage: usage))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Text(UsageDisplay.homeSubtitle(for: rule, status: status, usage: usage, relativeTo: now))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
if rule.hardMode {
Expand All @@ -116,12 +116,21 @@ struct HomeView: View {
.accessibilityIdentifier("blockedTile-\(rule.name)")
}

/// The rule's kind icon, tinted, sized to align row text. Decorative — the
/// type is also spelled out in the subtitle, so it is hidden from VoiceOver.
private func kindIcon(for rule: BlockingRule) -> some View {
Image(systemName: rule.kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
.accessibilityHidden(true)
}

// MARK: - Usage

/// Live tracking for every limit rule scheduled today that is *not* already
/// blocking. Once a budget is spent (the rule is actively blocking) the row
/// moves up to "Currently Blocking"; a soft-unblocked rule (paused) stays
/// here reading "Unblocked until tomorrow".
/// here reading "Paused".
@ViewBuilder
private func usageSection(now: Date) -> some View {
let tracked = rules.filter {
Expand All @@ -141,23 +150,17 @@ struct HomeView: View {

private func usageRow(for rule: BlockingRule, now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
let isPaused =
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
let status = liveStatus(for: rule, now: now)
return HStack {
kindIcon(for: rule)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(UsageDisplay.typedSubtitle(for: rule, usage: usage))
Text(UsageDisplay.homeSubtitle(for: rule, status: status, usage: usage, relativeTo: now))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
.font(.subheadline)
.foregroundStyle(
rule.limitReached(given: usage) && !isPaused
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("usageRow-\(rule.name)")
Expand Down
2 changes: 1 addition & 1 deletion OpenAppLock/Views/Rules/RuleDetailSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ struct RuleDetailSheet: View {
Text(rule.name)
.font(.headline)
.accessibilityIdentifier("detailRuleName")
Text("\(rule.kind.displayName), \(rule.statusLabel(for: status, relativeTo: now))")
Text("\(rule.kind.displayName), \(rule.rowContext(for: status, usage: usage ?? RuleUsage(), relativeTo: now))")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("detailStatusLabel")
Expand Down
17 changes: 7 additions & 10 deletions OpenAppLock/Views/Rules/RulesListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,32 +80,29 @@ struct RulesListView: View {
}

private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
let status = rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
let status = rule.status(at: now, usage: usage)
return Button {
detailRule = rule
} label: {
HStack {
Image(systemName: rule.kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(blockSummary(for: rule))
// The kind is conveyed by the section header, so the
// subtitle is just the live context (no type prefix).
Text(rule.rowContext(for: status, usage: usage, relativeTo: now))
.font(.caption)
.foregroundStyle(Color.secondary)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
Spacer()
Text(rule.statusLabel(for: status, relativeTo: now))
.font(.subheadline)
.foregroundStyle(status.isActive ? .green : .secondary)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
.accessibilityIdentifier("ruleCard-\(rule.name)")
}

private func blockSummary(for rule: BlockingRule) -> String {
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
}
}
Loading
Loading