- 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
138 lines
5.0 KiB
Swift
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)")
|
|
}
|
|
}
|