Time-limit and open-limit rules reuse BlockingRule's startMinutes/
endMinutes, which default to 09:00-17:00. Those fields are meaningless
for limit rules (the budget applies all day, resetting at midnight), but
status() still falls through to schedule.nextStart(), so an idle limit
rule reported .upcoming(startsAt: next 09:00) — rendered in the detail
sheet header as "Starts in 22h".
The home card already special-cased this; the detail sheet rendered the
raw status label, so the misleading countdown leaked there. Centralize a
kind-aware BlockingRule.statusLabel(for:relativeTo:) used by both the
card and the detail header: limit rules that aren't blocking show their
daily budget ("45m / day", "5 opens / day"); schedule rules and any
blocking/paused/dormant rule keep the live status label.
Verified on simulator: the Time Keeper detail header now reads
"Time Limit, 45m / day". 173 tests pass.
Co-Authored-By: Claude <noreply@anthropic.com>
267 lines
9.4 KiB
Swift
267 lines
9.4 KiB
Swift
//
|
|
// AppsHomeView.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
/// Home screen using plain iOS components: a List with a "Blocked Apps"
|
|
/// section (what's blocking right now) and a "Rules" section listing every
|
|
/// rule with its live status. "+" in the toolbar creates a new rule.
|
|
struct AppsHomeView: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(RuleEnforcer.self) private var enforcer
|
|
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
|
|
|
|
@State private var detailRule: BlockingRule?
|
|
@State private var showingNewRule = false
|
|
@State private var unblockCandidate: BlockingRule?
|
|
@State private var hardModeBlockedAttempt = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
|
ruleList(now: timeline.date)
|
|
}
|
|
.navigationTitle("Apps")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("New Rule", systemImage: "plus") {
|
|
showingNewRule = true
|
|
}
|
|
.accessibilityIdentifier("newRuleButton")
|
|
}
|
|
}
|
|
}
|
|
.sheet(item: $detailRule) { rule in
|
|
RuleDetailSheet(rule: rule)
|
|
}
|
|
.sheet(isPresented: $showingNewRule) {
|
|
NewRuleSheet()
|
|
}
|
|
.confirmationDialog(
|
|
"Unblock \(unblockCandidate?.name ?? "")?",
|
|
isPresented: Binding(
|
|
get: { unblockCandidate != nil },
|
|
set: { if !$0 { unblockCandidate = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Unblock", role: .destructive) {
|
|
if let rule = unblockCandidate {
|
|
RulePolicy.unblock(rule, usage: enforcer.usage(for: rule))
|
|
refreshEnforcement()
|
|
}
|
|
unblockCandidate = nil
|
|
}
|
|
} message: {
|
|
Text("Blocking resumes with the rule's next window.")
|
|
}
|
|
.alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text("This block can't be lifted until it ends.")
|
|
}
|
|
.task {
|
|
await enforcementLoop()
|
|
}
|
|
.onChange(of: ruleChangeToken) {
|
|
refreshEnforcement()
|
|
}
|
|
}
|
|
|
|
private func ruleList(now: Date) -> some View {
|
|
List {
|
|
blockedSection(now: now)
|
|
usageSection(now: now)
|
|
rulesSection(now: now)
|
|
}
|
|
}
|
|
|
|
/// Status with the day's usage folded in, so limit rules whose budget is
|
|
/// spent count as actively blocking.
|
|
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
|
|
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
|
|
}
|
|
|
|
// MARK: - Blocked Apps
|
|
|
|
@ViewBuilder
|
|
private func blockedSection(now: Date) -> some View {
|
|
let blocking = rules.filter { liveStatus(for: $0, now: now).isActive }
|
|
Section {
|
|
if blocking.isEmpty {
|
|
Text("Nothing is blocked right now.")
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityIdentifier("nothingBlockedLabel")
|
|
} else {
|
|
ForEach(blocking) { rule in
|
|
blockedRow(for: rule, now: now)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Blocked Apps").textCase(nil)
|
|
}
|
|
}
|
|
|
|
private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
|
|
Button {
|
|
if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
|
|
unblockCandidate = rule
|
|
} else {
|
|
hardModeBlockedAttempt = true
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
|
|
.foregroundStyle(rule.hardMode ? .red : .secondary)
|
|
.frame(width: 28)
|
|
Text(rule.name)
|
|
.foregroundStyle(Color.primary)
|
|
Spacer()
|
|
Text("Unblock")
|
|
.foregroundStyle(.tint)
|
|
}
|
|
}
|
|
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
|
}
|
|
|
|
// MARK: - Usage
|
|
|
|
/// Live tracking for every limit rule scheduled today: how much of the
|
|
/// daily budget is spent and what remains.
|
|
@ViewBuilder
|
|
private func usageSection(now: Date) -> some View {
|
|
let tracked = rules.filter {
|
|
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
|
|
}
|
|
if !tracked.isEmpty {
|
|
Section {
|
|
ForEach(tracked) { rule in
|
|
usageRow(for: rule, now: now)
|
|
}
|
|
} header: {
|
|
Text("Usage").textCase(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
return HStack {
|
|
Image(systemName: rule.kind.symbolName)
|
|
.foregroundStyle(.tint)
|
|
.frame(width: 28)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(rule.name)
|
|
.foregroundStyle(Color.primary)
|
|
Text(UsageDisplay.subtitle(for: rule, usage: usage))
|
|
.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)")
|
|
}
|
|
|
|
// MARK: - Rules
|
|
|
|
@ViewBuilder
|
|
private func rulesSection(now: Date) -> some View {
|
|
Section {
|
|
if rules.isEmpty {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("No rules yet")
|
|
.font(.headline)
|
|
Text("Create a rule to block distracting apps on a schedule.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("emptyRulesCard")
|
|
} else {
|
|
ForEach(rules) { rule in
|
|
ruleRow(for: rule, now: now)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Rules").textCase(nil)
|
|
}
|
|
}
|
|
|
|
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
|
|
let status = liveStatus(for: rule, now: now)
|
|
return Button {
|
|
detailRule = rule
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: rule.kind.symbolName)
|
|
.foregroundStyle(.tint)
|
|
.frame(width: 28)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(rule.name)
|
|
.foregroundStyle(Color.primary)
|
|
Text(blockSummary(for: rule))
|
|
.font(.caption)
|
|
.foregroundStyle(Color.secondary)
|
|
}
|
|
Spacer()
|
|
Text(statusText(for: rule, status: status, now: 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")"
|
|
}
|
|
|
|
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
|
// Shared with the detail sheet: limit rules that aren't blocking show
|
|
// their daily budget; everything else uses the live status label.
|
|
rule.statusLabel(for: status, relativeTo: now)
|
|
}
|
|
|
|
// MARK: - Enforcement
|
|
|
|
/// Changes whenever any rule's blocking-relevant state changes.
|
|
private var ruleChangeToken: String {
|
|
rules.map {
|
|
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
|
|
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
|
|
+ "\($0.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
|
|
+ "\($0.appList?.selectionCount ?? 0)|"
|
|
+ "\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
|
}
|
|
.joined(separator: ",")
|
|
}
|
|
|
|
private func refreshEnforcement() {
|
|
enforcer.refresh(rules: rules)
|
|
}
|
|
|
|
/// Keeps shields in sync while the app is open, so windows that begin or
|
|
/// end while the user is looking at the screen take effect promptly.
|
|
private func enforcementLoop() async {
|
|
while !Task.isCancelled {
|
|
let allRules = (try? modelContext.fetch(FetchDescriptor<BlockingRule>())) ?? []
|
|
enforcer.refresh(rules: allRules)
|
|
try? await Task.sleep(for: .seconds(30))
|
|
}
|
|
}
|
|
}
|