// // AppsHomeView.swift // Severed // import SwiftData import SwiftUI /// The app's home screen, modeled on the reference "Apps" tab: what's blocked /// right now, then the Rules carousel with "+ New". 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 ScrollView { VStack(alignment: .leading, spacing: 28) { blockedAppsSection(now: timeline.date) rulesSection(now: timeline.date) } .padding(.horizontal, 20) .padding(.top, 8) } } .background(Theme.background) .navigationTitle("Apps") .toolbarColorScheme(.dark, for: .navigationBar) } .sheet(item: $detailRule) { rule in RuleDetailSheet(rule: rule) .presentationDetents([.large]) .presentationDragIndicator(.visible) } .sheet(isPresented: $showingNewRule) { NewRuleSheet() .presentationDragIndicator(.visible) } .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() } } // MARK: - Blocked Apps @ViewBuilder private func blockedAppsSection(now: Date) -> some View { let blocking = rules.filter { $0.status(at: now).isActive } VStack(alignment: .leading, spacing: 14) { Text("Blocked Apps") .font(.system(size: 17, weight: .semibold)) if blocking.isEmpty { Text("Nothing is blocked right now.") .font(.system(size: 14)) .foregroundStyle(Theme.textTertiary) .accessibilityIdentifier("nothingBlockedLabel") } else { HStack(spacing: 16) { ForEach(blocking) { rule in blockedTile(for: rule, now: now) } } } } } private func blockedTile(for rule: BlockingRule, now: Date) -> some View { Button { if RulePolicy.canUnblock(rule, at: now) { unblockCandidate = rule } else { hardModeBlockedAttempt = true } } label: { VStack(spacing: 6) { Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white.opacity(0.85)) .frame(width: 58, height: 58) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 14)) .overlay( RoundedRectangle(cornerRadius: 14) .strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1) ) Text("Unblock") .font(.system(size: 12)) .foregroundStyle(Theme.textSecondary) } } .buttonStyle(.plain) .accessibilityIdentifier("blockedTile-\(rule.name)") } // MARK: - Rules private func rulesSection(now: Date) -> some View { VStack(alignment: .leading, spacing: 14) { HStack { Text("Rules") .font(.system(size: 17, weight: .semibold)) Spacer() Button { showingNewRule = true } label: { Label("New", systemImage: "plus") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.accent) } .accessibilityIdentifier("newRuleButton") } if rules.isEmpty { emptyRulesCard } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 14) { ForEach(rules) { rule in Button { detailRule = rule } label: { RuleCardView(rule: rule, now: now) } .buttonStyle(.plain) .accessibilityIdentifier("ruleCard-\(rule.name)") } } } } } } private var emptyRulesCard: some View { VStack(alignment: .leading, spacing: 8) { Text("No rules yet") .font(.system(size: 16, weight: .semibold)) Text("Create a rule to block distracting apps on a schedule.") .font(.system(size: 13)) .foregroundStyle(Theme.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 22)) .accessibilityElement(children: .combine) .accessibilityIdentifier("emptyRulesCard") } // MARK: - Enforcement /// Changes whenever any rule's blocking-relevant state changes. private var ruleChangeToken: String { rules.map { "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|" + "\($0.dayNumbers)|\($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)) } } }