// // AppsHomeView.swift // Severed // 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) 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) rulesSection(now: now) } } // MARK: - Blocked Apps @ViewBuilder private func blockedSection(now: Date) -> some View { let blocking = rules.filter { $0.status(at: 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, 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: - 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 = rule.status(at: 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 { let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps" return "\(rule.selectionMode.displayName) ยท \(apps)" } private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String { switch rule.kind { case .schedule: status.label(relativeTo: now) case .timeLimit: rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled" case .openLimit: rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled" } } // 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.selectionCount)|\($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())) ?? [] enforcer.refresh(rules: allRules) try? await Task.sleep(for: .seconds(30)) } } }