Files
OpenAppLock/OpenAppLock/Views/Rules/RuleDetailSheet.swift
Brendan Chen 8e3b8b70f5 feat: track limit budgets in a usage ledger and surface them in a Usage section
- RuleUsage + UsageLedger: per-rule, per-day minutes/opens in app-group
  defaults (monotonic minutes, incrementing opens, midnight rollover by
  day-keying); MockUsageLedger for tests and seeded UI scenarios
- usage-aware status: a limit rule whose daily budget is spent on an
  enabled day is active (blocking) until next midnight; Hard Mode gating
  and app-list locking honor it; soft unblock pauses until midnight
- RuleEnforcer shields spent limit rules (always Block mode) while the
  app runs
- new Usage section under Blocked Apps: '18m of 45m used today · 27m
  left', '2 of 5 opens today · 3 opens left', 'Blocked until tomorrow'
- new 'limits' seed scenario + UI tests
2026-06-12 20:22:48 -04:00

138 lines
5.0 KiB
Swift

//
// RuleDetailSheet.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// Rule summary presented as a plain sheet: inline title with a live status
/// caption, the rule's facts as labeled rows, and "Edit Rule" which pushes
/// the editor. A hard-locked rule shows a lock notice instead.
struct RuleDetailSheet: View {
let rule: BlockingRule
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@State private var isEditing = false
@State private var pendingDeletion = false
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
detailList(now: timeline.date)
}
.navigationDestination(isPresented: $isEditing) {
RuleEditorView(
mode: .edit(isEnabled: rule.isEnabled),
draft: RuleDraft(rule: rule),
onCommit: { draft in
draft.apply(to: rule)
isEditing = false
},
onToggleEnabled: {
rule.isEnabled.toggle()
rule.pausedUntil = nil
isEditing = false
},
onDelete: {
pendingDeletion = true
dismiss()
}
)
}
}
.onDisappear {
if pendingDeletion {
modelContext.delete(rule)
}
}
}
private func detailList(now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now)
let status = rule.status(at: now, usage: usage)
return List {
Section {
detailRows
}
Section {
if RulePolicy.canEdit(rule, usage: usage, at: now) {
Button {
isEditing = true
} label: {
Label("Edit Rule", systemImage: "pencil")
}
.accessibilityIdentifier("editRuleButton")
} else {
Label(
"Hard Mode is on — this rule is locked until the block ends.",
systemImage: "lock.fill"
)
.foregroundStyle(.secondary)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice")
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeDetailButton")
}
ToolbarItem(placement: .principal) {
VStack(spacing: 1) {
Text(rule.name)
.font(.headline)
.accessibilityIdentifier("detailRuleName")
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("detailStatusLabel")
}
}
}
}
@ViewBuilder
private var detailRows: some View {
switch rule.kind {
case .schedule:
row("During this time", rule.schedule.timeRangeLabel)
row("On these days", rule.days.summary)
row(rule.selectionMode.displayName, appCountLabel)
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
row("For this long", "\(rule.dailyLimitMinutes)m daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
row("More than", "\(rule.maxOpens) opens daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
}
private var appCountLabel: String {
guard let list = rule.appList else { return "No apps" }
return "\(list.name) · \(list.appCountLabel)"
}
private func row(_ label: String, _ value: String) -> some View {
LabeledContent(label, value: value)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("detailRow-\(label)")
}
}